mirror of
https://github.com/peco/peco.git
synced 2026-09-10 15:26:29 -04:00
Compare commits
778 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4368da0fbf | ||
|
|
d473d30d3a | ||
|
|
ea8d6f064d | ||
|
|
578b59851e | ||
|
|
beaf90d759 | ||
|
|
ed3e39d9a2 | ||
|
|
2e25a62fcd | ||
|
|
e7ffb77baa | ||
|
|
ed3929e8c4 | ||
|
|
3752f7fc38 | ||
|
|
40bcfa25d1 | ||
|
|
f730b08b5d | ||
|
|
e947b755d7 | ||
|
|
a45e60b285 | ||
|
|
a4afa02128 | ||
|
|
c3f424413e | ||
|
|
51ff07275e | ||
|
|
49d9793ea1 | ||
|
|
7966368c66 | ||
|
|
baf8773274 | ||
|
|
f4fbedefa2 | ||
|
|
d9c865b45f | ||
|
|
78dc0ea123 | ||
|
|
55667855d4 | ||
|
|
08e3c514de | ||
|
|
8762bd7da0 | ||
|
|
41a8422b22 | ||
|
|
a66446d2e2 | ||
|
|
5f6ec1b21c | ||
|
|
643ad7881d | ||
|
|
886f02bdee | ||
|
|
ac77b9f2ff | ||
|
|
7e657cfd9f | ||
|
|
35ea2ba494 | ||
|
|
b819e73236 | ||
|
|
70243267cd | ||
|
|
4008f2e7bd | ||
|
|
adc039f3a8 | ||
|
|
003bf6b456 | ||
|
|
45c4bf09ac | ||
|
|
78c41943e8 | ||
|
|
68be2eeb7f | ||
|
|
69c9ca8cd7 | ||
|
|
b461fd28cc | ||
|
|
3df3d9735f | ||
|
|
45dae94e47 | ||
|
|
8c7dd793d9 | ||
|
|
d842d4124e | ||
|
|
cb1c747fe6 | ||
|
|
0188a6534c | ||
|
|
8c9aca356b | ||
|
|
dcd727a5b2 | ||
|
|
8e925b8870 | ||
|
|
93de8d0469 | ||
|
|
778ae4368e | ||
|
|
c2d3f453bf | ||
|
|
db69779cf1 | ||
|
|
3efedc3128 | ||
|
|
082d12b1f4 | ||
|
|
df830dc80d | ||
|
|
16d700eb28 | ||
|
|
1fbf855884 | ||
|
|
6bc81c893f | ||
|
|
b1734bbc34 | ||
|
|
42c39e38c8 | ||
|
|
1eab243c5e | ||
|
|
d232db1e32 | ||
|
|
14ed0ef01e | ||
|
|
3ed0b3015e | ||
|
|
6ab3ff6d5d | ||
|
|
acdfd7ddba | ||
|
|
a980ec02e4 | ||
|
|
efe1c5254f | ||
|
|
e5d7c9ede6 | ||
|
|
ce63a3fc81 | ||
|
|
70845827cd | ||
|
|
dcad6fbf1b | ||
|
|
cf2ea47a52 | ||
|
|
600e20302d | ||
|
|
2a39bf9315 | ||
|
|
8f128cce45 | ||
|
|
1808f304ca | ||
|
|
8cd3fdfc62 | ||
|
|
e1453e217c | ||
|
|
05d06f3a48 | ||
|
|
fdf0c9040a | ||
|
|
9a70e05aa9 | ||
|
|
28039b1bd9 | ||
|
|
f0ea966553 | ||
|
|
ba5c351e63 | ||
|
|
e37246c18e | ||
|
|
b65eb6e7b1 | ||
|
|
bf8595be01 | ||
|
|
6be528ba24 | ||
|
|
7a63934041 | ||
|
|
bf02727ebd | ||
|
|
43becc50fa | ||
|
|
a5c51bcc4b | ||
|
|
b4c5d2e928 | ||
|
|
4b6baa3751 | ||
|
|
4b1dfa1c4d | ||
|
|
a79df901f7 | ||
|
|
6ea14d47b9 | ||
|
|
a91172b458 | ||
|
|
7588d33015 | ||
|
|
a19fa681e2 | ||
|
|
35e1681805 | ||
|
|
c0d97e2a49 | ||
|
|
06c4370c85 | ||
|
|
e2bc390c68 | ||
|
|
3c27da49ac | ||
|
|
580e062548 | ||
|
|
8569cbd5bf | ||
|
|
40ac45f94d | ||
|
|
ec3f2cb98b | ||
|
|
5c39fff7ad | ||
|
|
113c0aee16 | ||
|
|
1a8dda7f4f | ||
|
|
17536e1410 | ||
|
|
e01669b48d | ||
|
|
6869d4dcfa | ||
|
|
14530444b1 | ||
|
|
f689006279 | ||
|
|
9ab2b5af84 | ||
|
|
f05b49ceef | ||
|
|
1bfa7d2467 | ||
|
|
76a48722da | ||
|
|
82a4d605b7 | ||
|
|
673f8b0688 | ||
|
|
cc2d63bb15 | ||
|
|
8c4db9e026 | ||
|
|
44d5885a2f | ||
|
|
e024c7a01e | ||
|
|
38ce584c21 | ||
|
|
4e58dad08a | ||
|
|
f90f5cd0c2 | ||
|
|
982d0d30cd | ||
|
|
ba6d3a7b31 | ||
|
|
6d97fcaa0f | ||
|
|
cef9453b46 | ||
|
|
c404f05d43 | ||
|
|
3bd446705d | ||
|
|
76f0724099 | ||
|
|
464fc30b3d | ||
|
|
b5b1f523be | ||
|
|
3c54a192a7 | ||
|
|
2914b94aaa | ||
|
|
fbb737c72d | ||
|
|
727e85afed | ||
|
|
98f51cbbf5 | ||
|
|
5fa4303b52 | ||
|
|
0354f3071a | ||
|
|
3dec918f69 | ||
|
|
4f7978201b | ||
|
|
e32edbda88 | ||
|
|
2f20cddcf6 | ||
|
|
deb12acf59 | ||
|
|
720f1b0be8 | ||
|
|
e4e92ad5ac | ||
|
|
acbeb2e59a | ||
|
|
e0fe6b7375 | ||
|
|
b00e80a94e | ||
|
|
2b6d042f93 | ||
|
|
174b0f1b30 | ||
|
|
01956a4c51 | ||
|
|
e01c17be99 | ||
|
|
8a2b5334de | ||
|
|
c55bb30529 | ||
|
|
6e169570fe | ||
|
|
136b4a80e9 | ||
|
|
70975b098e | ||
|
|
5fc35a050e | ||
|
|
bb8a6310ea | ||
|
|
ed55e27368 | ||
|
|
d10fe55d8e | ||
|
|
43e5cf1ddc | ||
|
|
0ccff01ddd | ||
|
|
717dfca53b | ||
|
|
11754d722b | ||
|
|
14748ed33b | ||
|
|
09aacb6f50 | ||
|
|
d8d8e43b97 | ||
|
|
1baeb547b4 | ||
|
|
7b54585c95 | ||
|
|
ebaf55dafa | ||
|
|
2a8d21774a | ||
|
|
01ede393bb | ||
|
|
bdf9b33e6d | ||
|
|
37a9b23c61 | ||
|
|
8a9dfefb3a | ||
|
|
19327037c9 | ||
|
|
88b5b7a39c | ||
|
|
1fea5cd5a0 | ||
|
|
4d178a4675 | ||
|
|
0fe5e98477 | ||
|
|
d8d010eafc | ||
|
|
e06fae240a | ||
|
|
fc18c0d3de | ||
|
|
892882f69c | ||
|
|
93fcda4a08 | ||
|
|
8b74d9421a | ||
|
|
f918f68134 | ||
|
|
ac64686192 | ||
|
|
21ceb9908a | ||
|
|
7199f09552 | ||
|
|
a74bdfe4d0 | ||
|
|
63473702b4 | ||
|
|
6f5b949656 | ||
|
|
796f6b2d2b | ||
|
|
bf6e2a78f2 | ||
|
|
2c9c5c0a41 | ||
|
|
e809133abe | ||
|
|
275bfe2f80 | ||
|
|
80f7560eb2 | ||
|
|
a4e1fcb1dc | ||
|
|
62842229bf | ||
|
|
e5f12cb7f3 | ||
|
|
661c4fb5f6 | ||
|
|
041557f913 | ||
|
|
2ab4ac0567 | ||
|
|
d3d4b313c3 | ||
|
|
7e319f0b6d | ||
|
|
8a74cc4a70 | ||
|
|
64027d9a2b | ||
|
|
f8694463a2 | ||
|
|
a24479624d | ||
|
|
ea2ce9e909 | ||
|
|
d9e46440b4 | ||
|
|
e207eaa72f | ||
|
|
83f154459a | ||
|
|
67de06c1e1 | ||
|
|
aa8bec3184 | ||
|
|
a84879b78e | ||
|
|
2238188798 | ||
|
|
9b0b83a302 | ||
|
|
76f0525a1a | ||
|
|
ebe017f5c1 | ||
|
|
8212acecae | ||
|
|
4d61e9c718 | ||
|
|
a28679d2df | ||
|
|
bbfff0f835 | ||
|
|
8fc5813265 | ||
|
|
8e84cfc875 | ||
|
|
8b35079480 | ||
|
|
40b743b04f | ||
|
|
4a76ffdaa6 | ||
|
|
7e206f13a7 | ||
|
|
7041a265db | ||
|
|
fef7922a36 | ||
|
|
bae7ac32f2 | ||
|
|
74ffb86a87 | ||
|
|
cfd508a785 | ||
|
|
a29d50cd0b | ||
|
|
740db75767 | ||
|
|
a1a3596848 | ||
|
|
95e13933c2 | ||
|
|
be9d23d9ac | ||
|
|
3df337e1f4 | ||
|
|
c5bf382061 | ||
|
|
a24814affe | ||
|
|
a01c747f02 | ||
|
|
ebd05de222 | ||
|
|
c60329eca9 | ||
|
|
c8748a736e | ||
|
|
cf77e13382 | ||
|
|
37be8451e6 | ||
|
|
3a4730167a | ||
|
|
27cda7fc36 | ||
|
|
8f7038b5c2 | ||
|
|
52cb231084 | ||
|
|
e20b3f4f34 | ||
|
|
2f74f1c7f0 | ||
|
|
381ce7ca5b | ||
|
|
34e539b7da | ||
|
|
7bbd53e775 | ||
|
|
66bf406024 | ||
|
|
51e6af6d36 | ||
|
|
09f5ed6972 | ||
|
|
92724b8dc5 | ||
|
|
e09776384f | ||
|
|
441feb59a9 | ||
|
|
6fdcd40b78 | ||
|
|
554a196a8b | ||
|
|
dfbae22734 | ||
|
|
b4141eca2a | ||
|
|
d1e5b18232 | ||
|
|
1e90260188 | ||
|
|
0d55ffa4ae | ||
|
|
44b78c5dd8 | ||
|
|
a547170580 | ||
|
|
6fe3e4516d | ||
|
|
de2d3465cb | ||
|
|
90f9c5ceca | ||
|
|
22fff45193 | ||
|
|
fa5cc8aa9d | ||
|
|
ef6c4839e3 | ||
|
|
b96fe3ae01 | ||
|
|
1fd9332873 | ||
|
|
965feb2b8c | ||
|
|
9425a01e3e | ||
|
|
57d6761a9f | ||
|
|
0e71932a1d | ||
|
|
0b4f9d0adf | ||
|
|
6dfecd61c8 | ||
|
|
6058cbee57 | ||
|
|
22439d1b69 | ||
|
|
0c49ba8856 | ||
|
|
2acd138f66 | ||
|
|
022c7ba4f4 | ||
|
|
6a89f1e352 | ||
|
|
e1c62ab701 | ||
|
|
8910d5bf17 | ||
|
|
dfe029958c | ||
|
|
1f218ae97e | ||
|
|
ced740f32d | ||
|
|
73d5d370a4 | ||
|
|
c4e02ac1ce | ||
|
|
fade21e143 | ||
|
|
e7db38fa07 | ||
|
|
515e13fbf3 | ||
|
|
3564a975b5 | ||
|
|
a77cad279a | ||
|
|
8d5a0f672c | ||
|
|
77f66d5150 | ||
|
|
b7e04a549a | ||
|
|
c73fa58511 | ||
|
|
464c1dc72a | ||
|
|
c877ae72d9 | ||
|
|
e3005e7a14 | ||
|
|
46331edcfd | ||
|
|
6435d29b6d | ||
|
|
e61e114ded | ||
|
|
bcfd3bff83 | ||
|
|
d4744778bb | ||
|
|
761dd460a9 | ||
|
|
78aff3a21b | ||
|
|
970b3e545f | ||
|
|
3869c3bd25 | ||
|
|
28d16a4b1a | ||
|
|
c69a935ea8 | ||
|
|
ded5d7f305 | ||
|
|
681d034bc4 | ||
|
|
a2959c038a | ||
|
|
32765acb02 | ||
|
|
ce6dac6800 | ||
|
|
32f69ee127 | ||
|
|
e34cd07e65 | ||
|
|
2b7c9190f0 | ||
|
|
b0c340aca1 | ||
|
|
f6f3111df6 | ||
|
|
45ba1585c0 | ||
|
|
b67e306752 | ||
|
|
a734850952 | ||
|
|
ec9ebb4f02 | ||
|
|
4f795dc6cb | ||
|
|
1bb115f22b | ||
|
|
7f2815efab | ||
|
|
55c3397a6d | ||
|
|
4922d24a68 | ||
|
|
f3f29cf45d | ||
|
|
44780a2518 | ||
|
|
aacc46f593 | ||
|
|
af981da581 | ||
|
|
939f75ee85 | ||
|
|
055b7c3bbc | ||
|
|
b9702a35b8 | ||
|
|
02012db634 | ||
|
|
bb004da328 | ||
|
|
01a3faccb5 | ||
|
|
60d90d2ae1 | ||
|
|
4ad0bc31c6 | ||
|
|
5fe676efb6 | ||
|
|
c3c4116dc0 | ||
|
|
6bce9be413 | ||
|
|
6be6200c0d | ||
|
|
6037ac2ee5 | ||
|
|
65643706c7 | ||
|
|
6c8ecd04fd | ||
|
|
7907224eda | ||
|
|
e2ddf46710 | ||
|
|
de0b3bf338 | ||
|
|
4e0f004cb3 | ||
|
|
a813e7198e | ||
|
|
24df34ddda | ||
|
|
3f2817153b | ||
|
|
28d7583555 | ||
|
|
4ebbe37d8c | ||
|
|
35f920b3f1 | ||
|
|
a861e13008 | ||
|
|
7e43c9e1c9 | ||
|
|
5c13375911 | ||
|
|
4746c32231 | ||
|
|
efac590a5c | ||
|
|
0c55a69065 | ||
|
|
785f8e264d | ||
|
|
c373a303a8 | ||
|
|
e4de1c5504 | ||
|
|
3a14f1f89b | ||
|
|
20ad174000 | ||
|
|
d0489cc5bd | ||
|
|
0986318381 | ||
|
|
aa89a3bc42 | ||
|
|
8b87099a71 | ||
|
|
e5d29e85be | ||
|
|
9d6d8a8531 | ||
|
|
e4da6d08b1 | ||
|
|
3ba1cbdb01 | ||
|
|
84484a3213 | ||
|
|
0aac27f2a5 | ||
|
|
dfe6f1e775 | ||
|
|
873d6c4236 | ||
|
|
02f7140f27 | ||
|
|
c76990e3f0 | ||
|
|
382199aa53 | ||
|
|
fd89c1aefa | ||
|
|
7cd4b18817 | ||
|
|
70123a95ae | ||
|
|
4285ef3779 | ||
|
|
2f0d21d047 | ||
|
|
8f0204320c | ||
|
|
cfc3736e8e | ||
|
|
2bd6fa1504 | ||
|
|
fdf90a2121 | ||
|
|
62e667cce8 | ||
|
|
27c8f08bea | ||
|
|
79bc52f006 | ||
|
|
90807c9142 | ||
|
|
ffc3e85bd0 | ||
|
|
b8d9a859f7 | ||
|
|
d0aadb6525 | ||
|
|
48c7133f41 | ||
|
|
06534679f1 | ||
|
|
aff5a0d521 | ||
|
|
446d0d332b | ||
|
|
c543201da9 | ||
|
|
310e11700f | ||
|
|
92a2b97712 | ||
|
|
e3472a53a9 | ||
|
|
cd26822d4c | ||
|
|
576519b02f | ||
|
|
a74e621fe0 | ||
|
|
6ecb5c777d | ||
|
|
57a10a87a2 | ||
|
|
4c439c71a8 | ||
|
|
e1164500e6 | ||
|
|
51396c3c12 | ||
|
|
772c3ef5f1 | ||
|
|
4cd07c8dd3 | ||
|
|
5e9de3365b | ||
|
|
470179cd60 | ||
|
|
ac7821dce4 | ||
|
|
550d56c9a4 | ||
|
|
f5f6262d82 | ||
|
|
a1d7596b1a | ||
|
|
dc53fb8909 | ||
|
|
b775ac5dea | ||
|
|
c2c812db24 | ||
|
|
74adf55448 | ||
|
|
78d7e4a5d8 | ||
|
|
4541ad2962 | ||
|
|
cdb31988d8 | ||
|
|
3a85ae62e0 | ||
|
|
deee60fad0 | ||
|
|
b152854e6c | ||
|
|
5a88606205 | ||
|
|
7f62fe18b3 | ||
|
|
19f9bd1be9 | ||
|
|
12a55491ef | ||
|
|
53a69a609f | ||
|
|
d40ea73946 | ||
|
|
fe611dde03 | ||
|
|
952884009c | ||
|
|
eb3da9c70f | ||
|
|
5bd247e9a6 | ||
|
|
2debeb5b28 | ||
|
|
91339e96f9 | ||
|
|
b7f50d8a33 | ||
|
|
5f20cf3b7f | ||
|
|
4c1065f284 | ||
|
|
f0c35f429b | ||
|
|
eb17420fee | ||
|
|
6de9f7e58f | ||
|
|
090f44859a | ||
|
|
8710f98b1f | ||
|
|
82bc046253 | ||
|
|
d691144f96 | ||
|
|
0fb619016d | ||
|
|
731c3972b7 | ||
|
|
d298cca584 | ||
|
|
e0de14a560 | ||
|
|
8ff71a8c69 | ||
|
|
909635d588 | ||
|
|
4b49f65118 | ||
|
|
dc696bc92f | ||
|
|
d8f557e108 | ||
|
|
49f7153598 | ||
|
|
e82a4a67e2 | ||
|
|
3aa22bd477 | ||
|
|
087900d58f | ||
|
|
d4ef70813f | ||
|
|
c33d0f50cb | ||
|
|
ba97c8394e | ||
|
|
e97970e021 | ||
|
|
479226adc2 | ||
|
|
ee131131c7 | ||
|
|
84017db910 | ||
|
|
6ec2528651 | ||
|
|
e366f13607 | ||
|
|
b3264ffd9a | ||
|
|
68d17afc7c | ||
|
|
757abc8b66 | ||
|
|
4f1d149ceb | ||
|
|
35a014be56 | ||
|
|
97e97dffe1 | ||
|
|
aae60b5083 | ||
|
|
7b9a0b2021 | ||
|
|
637c5b3e07 | ||
|
|
8ac38b0de6 | ||
|
|
87ce6ab7cb | ||
|
|
2e2c39bb8a | ||
|
|
f6fed6226f | ||
|
|
4b209aaf84 | ||
|
|
fb07df89c0 | ||
|
|
b0b422152b | ||
|
|
2c591ff8ed | ||
|
|
17549703ae | ||
|
|
fbba2f8dc3 | ||
|
|
835f4698c2 | ||
|
|
1a76db2e67 | ||
|
|
97f408b99e | ||
|
|
e54c80ebbe | ||
|
|
ca4a52ba53 | ||
|
|
1921422bf8 | ||
|
|
a35076da6f | ||
|
|
46ecbd1e57 | ||
|
|
31f2ecd178 | ||
|
|
7715ff482f | ||
|
|
56c81db24a | ||
|
|
ab653b3a53 | ||
|
|
17b0aba8e8 | ||
|
|
8ecd1711e0 | ||
|
|
aa1335ff70 | ||
|
|
7c8c95b391 | ||
|
|
214f2faeb1 | ||
|
|
59108b523e | ||
|
|
b560fcbbe7 | ||
|
|
4ed16af465 | ||
|
|
0ef5cdb83d | ||
|
|
29d968e359 | ||
|
|
3a7556f264 | ||
|
|
c2f53b273d | ||
|
|
f2c6c895b8 | ||
|
|
46f3bb7fe5 | ||
|
|
0fb46c5d1e | ||
|
|
496c035741 | ||
|
|
1effee57e9 | ||
|
|
e228d8d366 | ||
|
|
be3d6cff25 | ||
|
|
f701816be7 | ||
|
|
2253af3961 | ||
|
|
ed9a2aa56c | ||
|
|
0d84ed1846 | ||
|
|
c1ffd8b128 | ||
|
|
3723b65a5f | ||
|
|
e4f2b4837d | ||
|
|
95f9dba8ad | ||
|
|
3621c464d4 | ||
|
|
d7fde6ac27 | ||
|
|
72dc9483ca | ||
|
|
6caeefc551 | ||
|
|
e53e2c6213 | ||
|
|
c3d9ee0527 | ||
|
|
247683e347 | ||
|
|
1fc9c00698 | ||
|
|
0531cfd20a | ||
|
|
db63d9f380 | ||
|
|
8356007755 | ||
|
|
7d42de7860 | ||
|
|
e9c5582ba1 | ||
|
|
288862215c | ||
|
|
0c4112ee16 | ||
|
|
7b17e88d96 | ||
|
|
fa33145d71 | ||
|
|
1ed60d275f | ||
|
|
5e01c7ce2f | ||
|
|
ccf33a8e97 | ||
|
|
5d9f2a78de | ||
|
|
6d0ff86f90 | ||
|
|
23707858fe | ||
|
|
82faeab193 | ||
|
|
a3f06f11be | ||
|
|
1543952823 | ||
|
|
b17182d9d2 | ||
|
|
3a76bd164b | ||
|
|
51f38bf44c | ||
|
|
330bd08291 | ||
|
|
06617b8768 | ||
|
|
0fcedc33d9 | ||
|
|
41a1b95a91 | ||
|
|
fd875d9739 | ||
|
|
427ee9eb89 | ||
|
|
73738a251b | ||
|
|
49aa736533 | ||
|
|
2ae9bd4c59 | ||
|
|
d91744a24b | ||
|
|
16e8a9f97d | ||
|
|
6468ffad6e | ||
|
|
8f1e90e10e | ||
|
|
819ab79a8a | ||
|
|
aa4c20d911 | ||
|
|
f8145bb325 | ||
|
|
2779a03cdd | ||
|
|
46703dc3aa | ||
|
|
7352294fa4 | ||
|
|
dd0d8ae00f | ||
|
|
06a80bf8a9 | ||
|
|
8c3265f159 | ||
|
|
1e185a785f | ||
|
|
3af95d6d07 | ||
|
|
ce38fa40be | ||
|
|
a7853fe85b | ||
|
|
d4b3c115e9 | ||
|
|
9c8756c26e | ||
|
|
a1678931a9 | ||
|
|
dc559668f3 | ||
|
|
f39f746023 | ||
|
|
d350aae268 | ||
|
|
366ed21d52 | ||
|
|
27cc910092 | ||
|
|
21d3f70e05 | ||
|
|
bf09debbe1 | ||
|
|
2550b108b1 | ||
|
|
ea18d1890f | ||
|
|
af816b11c3 | ||
|
|
19f296dfa4 | ||
|
|
cc161e0c4b | ||
|
|
d8b6fb5601 | ||
|
|
d237add077 | ||
|
|
4045ebad41 | ||
|
|
80cc79651d | ||
|
|
9e70017663 | ||
|
|
1ee7eade47 | ||
|
|
8417a33e75 | ||
|
|
ed54c2d02d | ||
|
|
21baa08c54 | ||
|
|
7dce295f0e | ||
|
|
1b7d631c07 | ||
|
|
653e368e2f | ||
|
|
3c5fc2c915 | ||
|
|
e8c3231ea8 | ||
|
|
e82bb34767 | ||
|
|
8d38aa104c | ||
|
|
e00862063c | ||
|
|
5832d4aff3 | ||
|
|
a7cd43ff22 | ||
|
|
a14f3abf35 | ||
|
|
dc845d6028 | ||
|
|
9c4464680c | ||
|
|
458682b9db | ||
|
|
c1fffe19e8 | ||
|
|
358871840d | ||
|
|
d2378d0d4c | ||
|
|
fe2b4b56f8 | ||
|
|
822a38601a | ||
|
|
ffc06167f8 | ||
|
|
1f0b384376 | ||
|
|
45e7fc6452 | ||
|
|
9a06ebeedf | ||
|
|
1b1d197911 | ||
|
|
05485f5ce5 | ||
|
|
b749b6191b | ||
|
|
71c4c48efe | ||
|
|
305a208ca2 | ||
|
|
143638649c | ||
|
|
43ee9051f8 | ||
|
|
6b42ace9d0 | ||
|
|
5aeaf9b2c0 | ||
|
|
01cc2b3c6b | ||
|
|
24136f4ffa | ||
|
|
e7faada179 | ||
|
|
4d0335dbd5 | ||
|
|
55cdd5825f | ||
|
|
4ad576bc6e | ||
|
|
6bce15df53 | ||
|
|
73fa36a33f | ||
|
|
9a22355560 | ||
|
|
c4ad0a7ba4 | ||
|
|
ff7b71a8b9 | ||
|
|
927fba1ce1 | ||
|
|
6972baf397 | ||
|
|
82a3beea29 | ||
|
|
2b7b521460 | ||
|
|
d289bb620a | ||
|
|
742d55319e | ||
|
|
a2f99967f2 | ||
|
|
250632a9f4 | ||
|
|
4b06b9bd51 | ||
|
|
e6c113ee8f | ||
|
|
c30492cb37 | ||
|
|
0a08098528 | ||
|
|
7753939751 | ||
|
|
9a4b60a41f | ||
|
|
0f22620f6c | ||
|
|
8b90384f70 | ||
|
|
69a009ac0f | ||
|
|
967233e253 | ||
|
|
6d1bce6bce | ||
|
|
231cc2c1fe | ||
|
|
38fcb2ef1e | ||
|
|
e823aea441 | ||
|
|
24e9b2c7b5 | ||
|
|
d8570b225e | ||
|
|
a243d95ff7 | ||
|
|
82edaa0f42 | ||
|
|
c93f2e424e | ||
|
|
7e59e48412 | ||
|
|
eb778350a5 | ||
|
|
2255f2b54b | ||
|
|
6762b04883 | ||
|
|
5027605f27 | ||
|
|
dc15605aee | ||
|
|
a53ba91609 | ||
|
|
00707e8727 | ||
|
|
053dde7342 | ||
|
|
a30b40c08f | ||
|
|
7b62d0196d | ||
|
|
d02bb4122c | ||
|
|
e6cbd7b122 | ||
|
|
0233a5f2a1 | ||
|
|
8c92244428 | ||
|
|
601a7c7adc | ||
|
|
1cb1b137ac | ||
|
|
90b5fa5d1c | ||
|
|
281db7f5e3 | ||
|
|
9542898d4c | ||
|
|
9385f4ec68 | ||
|
|
b60d067747 | ||
|
|
814932819a | ||
|
|
48c72760de | ||
|
|
695ef530e9 | ||
|
|
5c847fd6a9 | ||
|
|
06c6f59708 | ||
|
|
b80edba1de | ||
|
|
798152335b | ||
|
|
c335455888 | ||
|
|
387272fa8e | ||
|
|
7a70f43e5d | ||
|
|
cc1aac9e9f | ||
|
|
972ecec7a2 | ||
|
|
a47c6ca159 | ||
|
|
d213c89cab | ||
|
|
4341024b1d | ||
|
|
3058ce77f4 | ||
|
|
2b0093b828 | ||
|
|
d25a14bf40 | ||
|
|
b9a1bbec36 | ||
|
|
3629a8cf99 | ||
|
|
75f471e6f0 | ||
|
|
bdb30f5fb7 | ||
|
|
47e1fb3c3b | ||
|
|
6ee4fb7a0a | ||
|
|
3566702e4b | ||
|
|
149bf78df5 | ||
|
|
a523adfc13 | ||
|
|
4498edd44f | ||
|
|
bbc1646dfb | ||
|
|
1403c233d2 | ||
|
|
b47401a470 | ||
|
|
7b7749376f | ||
|
|
297ea2af36 | ||
|
|
f1c639ef92 | ||
|
|
0329ae4a87 | ||
|
|
e63b3a43a0 | ||
|
|
6157554008 | ||
|
|
b35ae0fa92 | ||
|
|
81fb06c4f7 | ||
|
|
1ab4a3576b | ||
|
|
492fad3a62 |
50
.claude/docs/cli.md
Normal file
50
.claude/docs/cli.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<!-- Agent-consumed file. Keep terse, unambiguous, machine-parseable. -->
|
||||
|
||||
# CLI
|
||||
|
||||
## Entry Point
|
||||
|
||||
`cmd/peco/peco.go` — parses flags, creates `peco.New()`, calls `Run(ctx)`
|
||||
|
||||
## Flags (CLIOptions)
|
||||
|
||||
| Flag | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `--help` | bool | Show help |
|
||||
| `--version` | bool | Show version |
|
||||
| `--query` | string | Initial query string |
|
||||
| `--rcfile` | string | Config file path |
|
||||
| `--buffer-size` | int | Max lines to read (0=unlimited) |
|
||||
| `--null` | bool | Use NUL as line separator |
|
||||
| `--initial-index` | int | Initial cursor position |
|
||||
| `--initial-filter` | string | Initial filter name |
|
||||
| `--prompt` | string | Prompt string |
|
||||
| `--layout` | string | Layout type: top-down, bottom-up, top-down-query-bottom |
|
||||
| `--select-1` | bool | Auto-select if single match |
|
||||
| `--exit-zero` | bool | Exit immediately with status 1 if input is empty |
|
||||
| `--select-all` | bool | Select all lines and exit immediately |
|
||||
| `--on-cancel` | string | Cancel behavior: success/error |
|
||||
| `--selection-prefix` | string | Prefix for selected lines |
|
||||
| `--exec` | string | Command to execute with selection |
|
||||
| `--print-query` | bool | Print query as first output line |
|
||||
| `--color` | ColorMode | Color mode: auto, none |
|
||||
| `--height` | string | Terminal height spec |
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- 0 — success (lines selected)
|
||||
- 0 — cancel (default, `--on-cancel success`)
|
||||
- 1 — cancel with `--on-cancel error`
|
||||
- Custom — from `--exec` command exit status
|
||||
|
||||
## Input
|
||||
|
||||
- Reads from stdin by default
|
||||
- Positional arg → read from file
|
||||
- Supports streaming (infinite) input
|
||||
|
||||
## Output
|
||||
|
||||
- Selected lines to stdout, one per line
|
||||
- With `--print-query`: query string as first line
|
||||
- With `--exec`: pipes selection to command
|
||||
57
.claude/docs/dependencies.md
Normal file
57
.claude/docs/dependencies.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<!-- Agent-consumed file. Keep terse, unambiguous, machine-parseable. -->
|
||||
|
||||
# Internal Dependency Graph
|
||||
|
||||
```
|
||||
cmd/peco → peco (root), internal/util
|
||||
cmd/filterbench → peco (root), filter, line, pipeline
|
||||
|
||||
peco (root) → config, filter, hub, line, pipeline, query, selection, sig
|
||||
→ internal/ansi, internal/keyseq, internal/util, internal/buffer
|
||||
|
||||
config → internal/util
|
||||
|
||||
filter → line, pipeline, internal/util
|
||||
|
||||
selection → line
|
||||
|
||||
line → internal/ansi
|
||||
|
||||
pipeline → line
|
||||
|
||||
internal/buffer → line
|
||||
```
|
||||
|
||||
## Layer Grouping
|
||||
|
||||
### Leaf (no internal deps)
|
||||
- `hub` — message bus, no imports
|
||||
- `query` — query text/caret, no imports
|
||||
- `sig` — signal handling, no imports
|
||||
- `internal/ansi` — ANSI parser, no imports
|
||||
- `internal/keyseq` — key matching, no imports
|
||||
- `internal/util` — platform utils, no imports
|
||||
|
||||
### Core
|
||||
- `line` → internal/ansi
|
||||
- `pipeline` → line
|
||||
- `internal/buffer` → line
|
||||
|
||||
### Processing
|
||||
- `config` → internal/util
|
||||
- `filter` → line, pipeline, internal/util
|
||||
- `selection` → line
|
||||
|
||||
### Application
|
||||
- `peco` (root) → all above
|
||||
- `cmd/peco` → peco, internal/util
|
||||
- `cmd/filterbench` → peco (root), filter, line, pipeline
|
||||
|
||||
## External Dependencies
|
||||
- `github.com/gdamore/tcell/v2` — terminal UI (screen.go)
|
||||
- `github.com/goccy/go-yaml` — config parsing
|
||||
- `github.com/google/btree` — ordered selection storage
|
||||
- `github.com/jessevdk/go-flags` — CLI flag parsing
|
||||
- `github.com/lestrrat-go/pdebug` — debug logging
|
||||
- `github.com/mattn/go-runewidth` — Unicode width calculation
|
||||
- `github.com/stretchr/testify` — test assertions
|
||||
102
.claude/docs/internals.md
Normal file
102
.claude/docs/internals.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<!-- Agent-consumed file. Keep terse, unambiguous, machine-parseable. -->
|
||||
|
||||
# Internals
|
||||
|
||||
## Concurrency Model
|
||||
|
||||
Three main goroutines coordinated via context cancellation:
|
||||
|
||||
1. **Input loop** (`input.go`) — reads terminal events, resolves key sequences via Keymap, dispatches actions
|
||||
2. **View loop** (`view.go`) — renders screen in response to hub messages (draw, paging, status)
|
||||
3. **Filter loop** (`filter.go`) — executes query against line buffer when query changes
|
||||
|
||||
Communication → **Hub** (`hub/`), central message bus with typed generic channels.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
stdin/file → Source → MemoryBuffer
|
||||
↓
|
||||
User keystroke → Input → Hub.SendQuery() → Filter loop
|
||||
↓
|
||||
Filter.Apply() → FilteredBuffer → Hub.SendDraw() → View loop
|
||||
↓
|
||||
View → Layout → Screen (tcell) → terminal
|
||||
```
|
||||
|
||||
## Hub Message Types
|
||||
|
||||
| Channel | Payload | Sender | Receiver |
|
||||
|---------|---------|--------|----------|
|
||||
| `QueryCh` | `string` | Input (action) | Filter loop |
|
||||
| `DrawCh` | `*DrawOptions` | Filter, actions | View loop |
|
||||
| `PagingCh` | `PagingRequest` | Input (action) | View loop |
|
||||
| `StatusMsgCh` | `StatusMsg` | Various | View loop |
|
||||
|
||||
Hub supports **batch mode** — multiple sends within `Batch()` callback are processed together.
|
||||
|
||||
## Buffer Architecture
|
||||
|
||||
- `MemoryBuffer` — stores all input lines, grows as Source reads
|
||||
- `FilteredBuffer` — wraps any Buffer with index range (page slice)
|
||||
- `Source` — implements `pipeline.Source`, reads input lines into MemoryBuffer
|
||||
- `ContextBuffer` — adds surrounding context lines for zoom view
|
||||
- `CurrentLineBuffer()` returns raw MemoryBuffer (no filter) or FilteredBuffer (after filter)
|
||||
|
||||
## Filter Pipeline
|
||||
|
||||
1. Query change arrives via Hub
|
||||
2. Filter loop creates pipeline: `MemoryBufferSource → filter.Apply → MemoryBuffer`
|
||||
3. Filter.Apply runs in parallel chunks (if `SupportsParallel()`)
|
||||
4. Results collected into new MemoryBuffer → set as CurrentLineBuffer
|
||||
5. Hub.SendDraw() triggers View redraw
|
||||
|
||||
## Screen Abstraction
|
||||
|
||||
- `Screen` interface wraps terminal operations
|
||||
- `TcellScreen` — production impl using tcell/v2
|
||||
- `InlineScreen` — wraps TcellScreen for height-limited display
|
||||
- `SimScreen` — test mock with event injection
|
||||
|
||||
## Layout System
|
||||
|
||||
- `BasicLayout` composes: `UserPrompt` + `ListArea` + `StatusBar`
|
||||
- Layout variants registered via `RegisterLayout(name, LayoutBuilder)`
|
||||
- Built-in: `top-down` (default), `bottom-up`, `top-down-query-bottom`
|
||||
- `AnchorSettings` controls vertical positioning (top/bottom anchor)
|
||||
|
||||
## Key Sequence Resolution
|
||||
|
||||
- `internal/keyseq.Keyseq` uses AhoCorasick matcher
|
||||
- Supports multi-key sequences (e.g., C-x,C-c)
|
||||
- Longest-match-wins semantics
|
||||
- `InMiddleOfChain()` indicates partial match in progress
|
||||
|
||||
## Selection Model
|
||||
|
||||
- `selection.Set` uses `google/btree` for ordered storage by line ID
|
||||
- Supports: single select, multi-select (toggle), range select, select-all
|
||||
- Sticky selection — persists across query changes (configurable)
|
||||
|
||||
## Action System
|
||||
|
||||
- ~40 built-in actions in `action.go`
|
||||
- Actions implement `Action` interface: `Execute(ctx, *Peco, Event)`
|
||||
- `ActionFunc` — function adapter with `Register()` for key binding
|
||||
- Combined actions — multiple actions bound to single key sequence
|
||||
- `Keymap.LookupAction(Event) → Action` — resolve event to action
|
||||
|
||||
## State Objects
|
||||
|
||||
| State | Purpose |
|
||||
|-------|---------|
|
||||
| `Location` | Current page, line number, offset, per-page count |
|
||||
| `SingleKeyJumpState` | Single-key jump mode toggle and prefix map |
|
||||
| `ZoomState` | Zoom view buffer and line reference |
|
||||
| `FrozenState` | Frozen source buffer for suspend/resume |
|
||||
| `QueryExecState` | Query execution delay timer |
|
||||
|
||||
## Object Pools
|
||||
|
||||
- `line.GetMatched/ReleaseMatched` — pool for Matched line wrappers
|
||||
- `internal/buffer.GetLineListBuf/ReleaseLineListBuf` — pool for line slices
|
||||
189
.claude/docs/packages.md
Normal file
189
.claude/docs/packages.md
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
<!-- Agent-consumed file. Keep terse, unambiguous, machine-parseable. -->
|
||||
|
||||
# Package Map
|
||||
|
||||
## peco (root)
|
||||
|
||||
Interactive filtering tool core. Holds global state, goroutine loops, UI components.
|
||||
|
||||
- **New() → *Peco** — create new instance
|
||||
- **(*Peco).Setup() → error** — initialize from config/options
|
||||
- **(*Peco).Run(ctx) → error** — main event loop
|
||||
- **(*Peco).ApplyConfig(CLIOptions) → error** — apply CLI flags to config
|
||||
- **(*Peco).SetupSource(ctx) → (*Source, error)** — initialize input source
|
||||
- **(*Peco).PrintResults()** — output selected lines to stdout
|
||||
- **(*Peco).CurrentLineBuffer() → Buffer** — active line buffer (raw or filtered)
|
||||
- **(*Peco).ExecQuery(ctx, func()) → bool** — execute filter with debounce
|
||||
- Key types: `Peco`, `Buffer`, `FilteredBuffer`, `MemoryBuffer`, `Source`, `Screen`, `Layout`, `Action`, `Keymap`, `Event`, `CLIOptions`, `Location`, `PageCrop`
|
||||
- Key interfaces: `MessageHub`, `Screen`, `Layout`, `Action`, `ActionMap`, `Buffer`, `Keyseq`, `ConfigReader`
|
||||
- Screen impls: `TcellScreen` (production), `InlineScreen` (height-limited), `SimScreen` (tests)
|
||||
- Layout impls: `BasicLayout` with builders: `DefaultLayout`, `BottomUpLayout`, `TopDownQueryBottomLayout`
|
||||
- Files: `peco.go`, `action.go`, `buffer.go`, `event.go`, `filter.go`, `input.go`, `keymap.go`, `layout.go`, `layout_any.go`, `layout_windows.go`, `options.go`, `page.go`, `screen.go`, `screen_inline.go`, `source.go`, `state.go`, `view.go`, `vertical_anchor_gen.go`
|
||||
- Imports: config, filter, hub, line, pipeline, query, selection, sig, internal/ansi, internal/buffer, internal/keyseq, internal/util
|
||||
|
||||
## cmd/peco
|
||||
|
||||
CLI entry point.
|
||||
|
||||
- Parses flags via `go-flags`, creates `peco.New()`, calls `Run(ctx)`
|
||||
- Files: `peco.go`
|
||||
- Imports: peco (root), internal/util
|
||||
|
||||
## cmd/filterbench
|
||||
|
||||
Benchmark tool for filter performance.
|
||||
|
||||
- Files: `main.go`
|
||||
- Imports: peco (root), filter, line, pipeline
|
||||
|
||||
## config/
|
||||
|
||||
Configuration loading and types.
|
||||
|
||||
- **Config** — main config struct (Keymap, Action, Style, Layout, CustomFilter, SingleKeyJump, Height, etc.)
|
||||
- **(*Config).Init() → error** — set defaults
|
||||
- **(*Config).ReadFilename(string) → error** — load YAML config file
|
||||
- **LocateRcfile(Locator) → (string, error)** — find config file path
|
||||
- Key types: `Config`, `StyleSet`, `Style`, `Attribute`, `OnCancelBehavior`, `ColorMode`, `CustomFilterConfig`, `SingleKeyJumpConfig`, `HeightSpec`
|
||||
- Color constants: `ColorDefault`, `ColorBlack`..`ColorWhite`, `AttrBold`, `AttrUnderline`, `AttrReverse`, `AttrTrueColor`
|
||||
- Layout constants: `LayoutTypeTopDown`, `LayoutTypeBottomUp`, `LayoutTypeTopDownQueryBottom`
|
||||
- Files: `config.go`, `style.go`, `height.go`, `layout.go`
|
||||
- Imports: internal/util
|
||||
|
||||
## filter/
|
||||
|
||||
Filter algorithm implementations.
|
||||
|
||||
- **Filter** interface — `Apply(ctx, []line.Line, ChanOutput) → error`, `BufSize() → int`, `NewContext(ctx, string) → ctx`, `String() → string`, `SupportsParallel() → bool`
|
||||
- **Collector** interface (optional) — `ApplyCollect(ctx, []line.Line) → ([]line.Line, error)`
|
||||
- **Set** — filter collection with rotation: `Add(Filter)`, `Rotate()`, `Current() → Filter`, `SetCurrentByName(string) → error`
|
||||
- Implementations: `NewIgnoreCase()`, `NewCaseSensitive()`, `NewSmartCase()`, `NewRegexp()`, `NewIRegexp()`, `NewFuzzy(sortLongest bool)`, `NewExternalCmd(name, cmd string, args []string, threshold int, idgen IDGenerator, enableSep bool)`
|
||||
- Files: `filter.go`, `base.go`, `regexp.go`, `fuzzy.go`, `external.go`, `set.go`
|
||||
- Imports: line, pipeline, internal/util
|
||||
|
||||
## hub/
|
||||
|
||||
Central message bus for goroutine communication.
|
||||
|
||||
- **New(bufsize int) → *Hub** — create hub with channel buffer size
|
||||
- **(*Hub).SendDraw(ctx, *DrawOptions)** — trigger screen redraw
|
||||
- **(*Hub).SendQuery(ctx, string)** — send query change
|
||||
- **(*Hub).SendPaging(ctx, PagingRequest)** — send paging command
|
||||
- **(*Hub).SendStatusMsg(ctx, string, time.Duration)** — show status message
|
||||
- **(*Hub).Batch(ctx, func(ctx))** — batch multiple sends atomically
|
||||
- Channel accessors: `DrawCh()`, `PagingCh()`, `QueryCh()`, `StatusMsgCh()`
|
||||
- Key types: `Hub`, `Payload[T]`, `DrawOptions`, `PagingRequest`, `PagingRequestType`, `StatusMsg`
|
||||
- Paging types: `ToLineAbove`, `ToLineBelow`, `ToScrollPageDown`, `ToScrollPageUp`, `ToScrollLeft`, `ToScrollRight`, `ToScrollFirstItem`, `ToScrollLastItem`, `ToLineInPage`
|
||||
- Files: `hub.go`, `draw.go`, `paging.go`, `paging_request_type_gen.go`
|
||||
- Imports: (none internal)
|
||||
|
||||
## line/
|
||||
|
||||
Line data types for display and selection.
|
||||
|
||||
- **Line** interface — `ID() → uint64`, `Buffer() → string`, `DisplayString() → string`, `Output() → string`, `IsDirty() → bool`, `SetDirty(bool)`, implements `btree.Item`
|
||||
- **NewRaw(id uint64, s string, enableSep bool, stripANSI bool) → *Raw** — create raw line
|
||||
- **NewMatched(Line, [][]int) → *Matched** — wrap line with match indices
|
||||
- **GetMatched(Line, [][]int) → *Matched** — pooled allocation
|
||||
- **ReleaseMatched(*Matched)** — return to pool
|
||||
- **IDGenerator** interface — `Next() → uint64`
|
||||
- Files: `raw.go`, `matched.go`
|
||||
- Imports: internal/ansi, btree
|
||||
|
||||
## pipeline/
|
||||
|
||||
Generic source→acceptor→destination pipeline.
|
||||
|
||||
- **New() → *Pipeline** — create pipeline
|
||||
- **(*Pipeline).SetSource(Source)** — set data source
|
||||
- **(*Pipeline).Add(Acceptor)** — add processing stage
|
||||
- **(*Pipeline).SetDestination(Destination)** — set terminal stage
|
||||
- **(*Pipeline).Run(ctx) → error** — execute pipeline
|
||||
- Key interfaces: `Source` (`Start`, `Reset`), `Acceptor` (`Accept`), `Destination` (`Accept`, `Reset`, `Done`), `Suspender` (optional `Suspend`/`Resume`)
|
||||
- **ChanOutput** (chan line.Line) — `Send(ctx, line.Line) → error`, `OutCh() → <-chan line.Line`
|
||||
- **NewQueryContext(ctx, string) → ctx** / **QueryFromContext(ctx) → string** — pass query through context
|
||||
- Files: `pipeline.go`
|
||||
- Imports: line
|
||||
|
||||
## query/
|
||||
|
||||
Query text and caret management.
|
||||
|
||||
- **Text** — query string with save/restore: `Set(string)`, `Reset()`, `SaveQuery()`, `RestoreSavedQuery()`, `DeleteRange(int, int)`, `InsertAt(rune, int)`, `String()`, `Len()`, `RuneSlice()`, `RuneAt(int)`
|
||||
- **Caret** — cursor position: `Pos() → int`, `SetPos(int)`, `Move(int)`
|
||||
- Files: `query.go`
|
||||
- Imports: (none internal)
|
||||
|
||||
## selection/
|
||||
|
||||
Ordered selection storage using btree.
|
||||
|
||||
- **New() → *Set** — create selection set
|
||||
- **(*Set).Add(line.Line)** — add to selection
|
||||
- **(*Set).Remove(line.Line)** — remove from selection
|
||||
- **(*Set).Has(line.Line) → bool** — check membership
|
||||
- **(*Set).Len() → int** — count selected
|
||||
- **(*Set).Ascend(func(line.Line) bool)** — iterate in order
|
||||
- **(*Set).Copy(dst *Set)** — copy all items
|
||||
- **RangeStart** — range selection start marker: `Valid()`, `Value()`, `SetValue(int)`, `Reset()`
|
||||
- Files: `selection.go`
|
||||
- Imports: line, btree
|
||||
|
||||
## sig/
|
||||
|
||||
OS signal handling.
|
||||
|
||||
- **New(handler ReceivedHandler, sigs ...os.Signal) → *Handler**
|
||||
- **(*Handler).Loop(ctx, func()) → error** — signal listening loop
|
||||
- **ReceivedHandler** interface — `Handle(os.Signal)`
|
||||
- Files: `sig.go`
|
||||
- Imports: (none internal)
|
||||
|
||||
## internal/ansi
|
||||
|
||||
ANSI escape sequence parser.
|
||||
|
||||
- **Parse(string) → ParseResult** — strip ANSI, extract color spans
|
||||
- **ExtractSegment([]AttrSpan, start, end int) → []AttrSpan** — slice attr spans for substring
|
||||
- Key types: `ParseResult` (`Stripped string`, `Attrs []AttrSpan`), `AttrSpan` (`Fg, Bg Attribute`, `Length int`)
|
||||
- Files: `parser.go`
|
||||
- Imports: (none internal)
|
||||
|
||||
## internal/buffer
|
||||
|
||||
Line list buffer pool.
|
||||
|
||||
- **GetLineListBuf() → []line.Line** — get from pool
|
||||
- **ReleaseLineListBuf([]line.Line)** — return to pool
|
||||
- Files: `line.go`
|
||||
- Imports: line
|
||||
|
||||
## internal/keyseq
|
||||
|
||||
Key sequence matching (multi-key bindings).
|
||||
|
||||
- **New() → *Keyseq** — create matcher (uses AhoCorasick internally)
|
||||
- **(*Keyseq).Add(KeyList, any)** — register key sequence → action
|
||||
- **(*Keyseq).Compile() → error** — build matcher
|
||||
- **(*Keyseq).AcceptKey(Key) → (any, error)** — feed key, get action if matched
|
||||
- **ToKeyList(string) → (KeyList, error)** — parse "C-x,C-c" → KeyList
|
||||
- **KeyEventToString(KeyType, rune, ModifierKey) → (string, error)** — event → name
|
||||
- Key types: `Key`, `KeyList`, `KeyType`, `ModifierKey`
|
||||
- Matcher impls: Trie, TernarySearch, AhoCorasick (AhoCorasick used by default)
|
||||
- Files: `keyseq.go`, `keys.go`, `trie.go`, `ternary.go`, `ahocorasick.go`
|
||||
- Imports: (none internal)
|
||||
|
||||
## internal/util
|
||||
|
||||
Platform utilities.
|
||||
|
||||
- **IsTty(io.Reader) → bool** — check if reader is terminal
|
||||
- **Homedir() → (string, error)** — user home directory
|
||||
- **Shell(ctx, string) → *exec.Cmd** — create shell command
|
||||
- **StripANSISequence(string) → string** — remove ANSI escapes (deprecated, use internal/ansi)
|
||||
- **IsCollectResultsError(error) → bool** — check for collect-results sentinel
|
||||
- **IsIgnorableError(error) → bool** — check for ignorable errors
|
||||
- **GetExitStatus(error) → (int, bool)** — extract exit code
|
||||
- Platform files: `tty_posix.go`, `tty_bsd.go`, `tty_windows.go`, `shell_unix.go`, `shell_windows.go`, `homedir_posix.go`, `homedir_darwin.go`, `homedir_windows.go`
|
||||
- Files: `util.go`
|
||||
- Imports: (none internal)
|
||||
58
.claude/docs/testing.md
Normal file
58
.claude/docs/testing.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<!-- Agent-consumed file. Keep terse, unambiguous, machine-parseable. -->
|
||||
|
||||
# Testing
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
make test # go test -v -race ./...
|
||||
go test -v -run TestFoo ./... # single test, all packages
|
||||
go test -v -run TestFoo ./filter/ # single test, specific package
|
||||
go test -race -coverprofile=coverage.out ./... # coverage
|
||||
```
|
||||
|
||||
## Test Package Convention
|
||||
|
||||
- Tests use same package name (not `_test` suffix) — white-box testing
|
||||
- Exception: some packages use `_test` suffix for external testing
|
||||
|
||||
## Key Test Helpers
|
||||
|
||||
- `newPeco() → *Peco` — creates test instance with SimScreen, default config
|
||||
- `NewDummyScreen() → *SimScreen` — mock terminal; supports `SendEvent(Event)` for input injection
|
||||
- SimScreen has fixed size, no-op rendering, collects events
|
||||
|
||||
## Test Patterns
|
||||
|
||||
- Table-driven tests with `t.Run()` subtests
|
||||
- Regression tests for GitHub issues in `issues_test.go`
|
||||
- Filter tests in `filter/filter_test.go`, `filter/base_test.go`
|
||||
- Hub tests in `hub/hub_test.go`
|
||||
- Key sequence tests in `internal/keyseq/trie_test.go`, `ahocorasick_test.go`, `ternary_test.go`
|
||||
- Pipeline tests in `pipeline/pipeline_test.go`
|
||||
- Selection tests in `selection/selection_test.go`
|
||||
- Query tests in `query/query_test.go`
|
||||
|
||||
## Benchmark Tests
|
||||
|
||||
- `filter/bench_test.go` — filter algorithm benchmarks
|
||||
- `hub/bench_test.go` — hub message passing benchmarks
|
||||
- `line/bench_test.go` — line allocation benchmarks
|
||||
- `internal/ansi/bench_test.go` — ANSI parsing benchmarks
|
||||
- `internal/util/bench_test.go` — utility benchmarks
|
||||
- `cmd/filterbench/` — standalone filter benchmark CLI
|
||||
|
||||
## No Test Data Directory
|
||||
|
||||
- No `testdata/` or golden files
|
||||
- Tests use inline data and programmatic setup
|
||||
|
||||
## Build Tags
|
||||
|
||||
- Platform-specific files: `_posix.go`, `_bsd.go`, `_windows.go`, `_darwin.go`
|
||||
- No custom build tags for testing
|
||||
|
||||
## Code Generation
|
||||
|
||||
- `go generate ./...` — runs `stringer` for enum types
|
||||
- Generated files: `vertical_anchor_gen.go`, `hub/paging_request_type_gen.go`
|
||||
12
.github/FUNDING.yml
vendored
Normal file
12
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: ["lestrrat", "mattn", "syohex"]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
17
.github/dependabot.yml
vendored
Normal file
17
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
time: '10:00'
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "go"
|
||||
- "dependencies"
|
||||
- "dependabot"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
66
.github/workflows/ci.yml
vendored
Normal file
66
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
name: CI
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go: [ '1.25' ]
|
||||
name: Go ${{ matrix.go }} test
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
check-latest: true
|
||||
- name: Test
|
||||
run: make test
|
||||
- name: Install GoReleaser
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||
with:
|
||||
install-only: true
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
generate:
|
||||
runs-on: ubuntu-latest
|
||||
name: Check generated code
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
- name: Install stringer
|
||||
run: go install golang.org/x/tools/cmd/stringer@v0.42.0
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
- name: Check for uncommitted changes
|
||||
run: |
|
||||
if ! git diff --exit-code; then
|
||||
echo "::error::Generated code is out of date. Run 'go generate ./...' and commit the result."
|
||||
exit 1
|
||||
fi
|
||||
37
.github/workflows/codeql.yml
vendored
Normal file
37
.github/workflows/codeql.yml
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
schedule:
|
||||
- cron: '40 13 * * 5'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4.37.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4.37.9
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4.37.9
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
16
.github/workflows/lint.yml
vendored
Normal file
16
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
name: lint
|
||||
on: [push]
|
||||
jobs:
|
||||
golangci:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||
with:
|
||||
version: v2.8.0
|
||||
- name: Run go vet
|
||||
run: go vet ./...
|
||||
25
.github/workflows/manpage.yml
vendored
Normal file
25
.github/workflows/manpage.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
name: Update man page
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths: ['README.md']
|
||||
|
||||
jobs:
|
||||
update-manpage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install pandoc
|
||||
run: sudo apt-get update && sudo apt-get install -y pandoc
|
||||
- name: Generate man page
|
||||
run: bash contrib/man/generate.sh
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
commit-message: "docs: regenerate man page from README"
|
||||
title: "docs: update man page"
|
||||
body: |
|
||||
Auto-generated update to `contrib/man/peco.1` from changes to `README.md`.
|
||||
branch: update-manpage
|
||||
delete-branch: true
|
||||
31
.github/workflows/release.yml
vendored
Normal file
31
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||
with:
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -22,7 +22,7 @@ _testmain.go
|
|||
*.exe
|
||||
*.test
|
||||
|
||||
peco
|
||||
/peco
|
||||
|
||||
build/src
|
||||
|
||||
|
|
@ -34,3 +34,6 @@ out
|
|||
_internal_bin
|
||||
vendor
|
||||
github_token
|
||||
|
||||
releases
|
||||
dist
|
||||
|
|
|
|||
98
.golangci.yml
Normal file
98
.golangci.yml
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
version: "2"
|
||||
linters:
|
||||
default: all
|
||||
disable:
|
||||
- cyclop
|
||||
- depguard
|
||||
- dogsled
|
||||
- dupl
|
||||
- embeddedstructfieldcheck
|
||||
- err113
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- exhaustruct
|
||||
- forbidigo
|
||||
- funcorder
|
||||
- funlen
|
||||
- gochecknoglobals
|
||||
- gochecknoinits
|
||||
- gocognit
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- godot
|
||||
- godox
|
||||
- gosec
|
||||
- gosmopolitan
|
||||
- inamedparam
|
||||
- interfacebloat
|
||||
- ireturn
|
||||
- lll
|
||||
- maintidx
|
||||
- makezero
|
||||
- mnd
|
||||
- nakedret
|
||||
- nestif
|
||||
- nlreturn
|
||||
- noinlineerr
|
||||
- nonamedreturns
|
||||
- paralleltest
|
||||
- perfsprint
|
||||
- recvcheck
|
||||
- tagalign
|
||||
- tagliatelle
|
||||
- testifylint
|
||||
- testpackage
|
||||
- thelper
|
||||
- varnamelen
|
||||
- wrapcheck
|
||||
- wsl
|
||||
- wsl_v5
|
||||
settings:
|
||||
govet:
|
||||
disable:
|
||||
- shadow
|
||||
- fieldalignment
|
||||
enable-all: true
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
rules:
|
||||
- linters:
|
||||
- revive
|
||||
path: /*.go
|
||||
text: don't use an underscore in package name
|
||||
- linters:
|
||||
- revive
|
||||
path: internal/util/
|
||||
text: 'var-naming: avoid meaningless package names'
|
||||
- linters:
|
||||
- revive
|
||||
path: /*_test.go
|
||||
text: 'var-naming: '
|
||||
- linters:
|
||||
- errcheck
|
||||
path: /main.go
|
||||
- linters:
|
||||
- errcheck
|
||||
- errchkjson
|
||||
- forcetypeassert
|
||||
path: /*_test.go
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
44
.goreleaser.yml
Normal file
44
.goreleaser.yml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
version: 2
|
||||
|
||||
builds:
|
||||
- id: peco
|
||||
main: ./cmd/peco
|
||||
binary: peco
|
||||
ldflags:
|
||||
- -s -w -X github.com/peco/peco.version={{.Version}}
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: darwin
|
||||
goarch: arm
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
archives:
|
||||
- formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
- goos: darwin
|
||||
formats:
|
||||
- zip
|
||||
- goos: windows
|
||||
formats:
|
||||
- zip
|
||||
files:
|
||||
- README.md
|
||||
- Changes
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
algorithm: sha256
|
||||
|
||||
changelog:
|
||||
disable: true
|
||||
14
.travis.yml
14
.travis.yml
|
|
@ -1,14 +0,0 @@
|
|||
language: go
|
||||
go:
|
||||
- 1.8.x
|
||||
- tip
|
||||
sudo: false
|
||||
cache:
|
||||
directories:
|
||||
- vendor
|
||||
- _internal_bin/$(go version | awk '{ print $NF }')
|
||||
install:
|
||||
- make installdeps
|
||||
script:
|
||||
- make test
|
||||
- make all
|
||||
104
CLAUDE.md
Normal file
104
CLAUDE.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<!-- Agent-consumed file. Keep terse, unambiguous, machine-parseable. -->
|
||||
|
||||
# CLAUDE.md
|
||||
|
||||
peco is an interactive filtering tool for the terminal, written in Go.
|
||||
|
||||
## Pre-Read Rules
|
||||
|
||||
Read the linked doc BEFORE working in that area. No exceptions.
|
||||
|
||||
| Trigger | Doc |
|
||||
|---------|-----|
|
||||
| Working with any package API or adding imports | `.claude/docs/packages.md` |
|
||||
| Modifying cross-package dependencies | `.claude/docs/dependencies.md` |
|
||||
| Writing or running tests | `.claude/docs/testing.md` |
|
||||
| Modifying CLI flags, entry point, or output | `.claude/docs/cli.md` |
|
||||
| Modifying concurrency, hub, buffers, filters, layout, screen, actions | `.claude/docs/internals.md` |
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
make # Build binary via goreleaser (default target)
|
||||
make build # Build binary to dist/peco_<os>_<arch>/peco
|
||||
make test # Run all tests: go test -v -race ./...
|
||||
make deps # Download Go module dependencies
|
||||
make clean # Remove build artifacts
|
||||
```
|
||||
|
||||
Run a single test:
|
||||
```bash
|
||||
go test -v -run TestFunctionName ./...
|
||||
go test -v -run TestFunctionName ./filter/ # for a specific package
|
||||
```
|
||||
|
||||
The entry point is `cmd/peco/peco.go`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Concurrency Model
|
||||
|
||||
peco runs three main goroutines coordinated via context cancellation:
|
||||
|
||||
- **Input loop** (`input.go`) — reads tcell key events, resolves key sequences via Keymap, dispatches actions
|
||||
- **View loop** (`view.go`) — renders screen in response to draw/paging/status messages
|
||||
- **Filter loop** (`filter.go`) — executes queries against the line buffer when query text changes
|
||||
|
||||
These goroutines communicate through the **Hub** (`hub/`), a central message bus with typed channels: `QueryCh`, `DrawCh`, `PagingCh`, `StatusMsgCh`.
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Source** (`source.go`) reads input lines (stdin or file), implements `pipeline.Source`
|
||||
2. User keystrokes trigger actions that modify the query
|
||||
3. Query changes are sent to the Filter loop via Hub
|
||||
4. **Filter** applies the active filter algorithm to produce matched lines
|
||||
5. Results flow through the **Pipeline** (`pipeline/`) as `Source → Acceptor → Destination`
|
||||
6. **View** receives draw messages and delegates to **Layout** (`layout.go`) which composes `UserPrompt`, `ListArea`, and `StatusBar`
|
||||
7. **Screen** (`screen.go`) wraps tcell/v2 for terminal cell rendering
|
||||
|
||||
### Key Interfaces
|
||||
|
||||
- **`Buffer`** — line storage (`LineAt`, `Size`); implemented by `MemoryBuffer`, `FilteredBuffer`, `Source`
|
||||
- **`Filter`** (in `filter/`) — `Apply(ctx, []line.Line, ChanOutput)` for each filter algorithm (IgnoreCase, CaseSensitive, SmartCase, Regexp, IRegexp, Fuzzy, ExternalCmd)
|
||||
- **`Line`** (`line/`) — represents a single line with `ID`, `Buffer`, `DisplayString`, `Output`
|
||||
- **`Screen`** — terminal abstraction (`Init`, `SetCell`, `Flush`, `PollEvent`); `SimScreen` used in tests
|
||||
- **`Layout`** — screen composition (`DrawScreen`, `DrawPrompt`, `MovePage`)
|
||||
- **`Action`** — user actions bound to keys (`action.go`); ~40 built-in actions, supports combined action sequences
|
||||
|
||||
### Selection
|
||||
|
||||
Uses `google/btree` for ordered selection storage. Supports multi-select, range mode, and sticky selection (persists across query changes).
|
||||
|
||||
### Key Sequence Resolution
|
||||
|
||||
`internal/keyseq/` implements Trie, TernarySearch, and AhoCorasick for matching multi-key sequences to actions (longest-match-wins).
|
||||
|
||||
### Platform-Specific Code
|
||||
|
||||
Platform-specific behavior lives in `layout_any.go` / `layout_windows.go` (handling the `extraOffset` layout constant) and in files suffixed `_posix.go` / `_windows.go` under `internal/util/` (TTY detection, shell integration, home directory resolution).
|
||||
|
||||
### Code Generation
|
||||
|
||||
Uses `go:generate` with `stringer` for enum string representations.
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
- `newPeco()` helper creates a test instance with `SimScreen` (mock terminal)
|
||||
- `NewDummyScreen()` returns a `SimScreen` that supports event injection for simulating user input
|
||||
- Table-driven tests with `t.Run()` subtests are the common pattern
|
||||
- Regression tests for specific GitHub issues in `issues_test.go`
|
||||
|
||||
## Cache Maintenance
|
||||
|
||||
These docs cache repository state. Still read source before modifying code.
|
||||
|
||||
1. When your changes affect a doc below, update it in the same commit.
|
||||
2. If you notice any doc is wrong or stale — even on an unrelated task — fix it immediately.
|
||||
|
||||
| Doc | Update trigger |
|
||||
|-----|----------------|
|
||||
| `packages.md` | Add/remove/rename exported functions, types, or packages |
|
||||
| `dependencies.md` | Add/remove internal package imports |
|
||||
| `testing.md` | Change test infrastructure, helpers, or test commands |
|
||||
| `cli.md` | Add/remove CLI flags, change exit codes or output format |
|
||||
| `internals.md` | Change concurrency model, hub channels, buffer types, layout system, action system |
|
||||
231
Changes
231
Changes
|
|
@ -1,6 +1,227 @@
|
|||
Changes
|
||||
=======
|
||||
|
||||
v0.6.0 - 24 Feb 2026
|
||||
[Breaking Changes]
|
||||
* Migrated terminal backend from termbox-go to tcell (#567).
|
||||
* Removed deprecated config fields `Matcher`, `InitialMatcher`, and
|
||||
`CustomMatcher`. Use `InitialFilter` and `CustomFilter` instead.
|
||||
Note: `CustomFilter` uses a different format than `CustomMatcher`:
|
||||
|
||||
Before: "CustomMatcher": { "MyFilter": ["/path/to/cmd", "$QUERY"] }
|
||||
After: "CustomFilter": { "MyFilter": { "Cmd": "/path/to/cmd", "Args": ["$QUERY"] } }
|
||||
* Removed deprecated CLI option `--initial-matcher`.
|
||||
Use `--initial-filter` instead.
|
||||
* The action `ViewArround` has been renamed to `ViewAround` (fix typo).
|
||||
The old name still works but shows a deprecation notice. If you have
|
||||
`"ViewArround"` in your config, please update it to `"ViewAround"`.
|
||||
* Removed `Use256Color` config field. After the tcell migration,
|
||||
256-color and truecolor support is automatic via terminfo detection.
|
||||
Existing configs with this field will continue to load without error.
|
||||
* Shift+Tab is now mapped to Tab internally. Use `M-[,Z` in your
|
||||
keymap config if you need a distinct Shift+Tab binding.
|
||||
* Default key bindings for `GoToNextSelection` and
|
||||
`GoToPreviousSelection` have changed. `GoToNextSelection` was
|
||||
previously unbound (it silently conflicted with `KillEndOfLine` on
|
||||
Ctrl+K in v0.5.x) and is now bound to Alt+j. `GoToPreviousSelection`
|
||||
was previously bound to Ctrl+J and is now bound to Alt+k.
|
||||
* ANSI color support is now enabled by default (`--color=auto`).
|
||||
ANSI escape sequences in input are now parsed and rendered with
|
||||
colors preserved. To disable, use `--color=none` or set
|
||||
`"Color": "none"` in your config file.
|
||||
* Query terms starting with `-` are now interpreted as negative
|
||||
(exclusion) filters. In v0.5.x, `foo -bar` searched for lines
|
||||
matching both "foo" and literal "-bar"; in v0.6.0 it searches for
|
||||
"foo" while excluding lines containing "bar". To match a literal
|
||||
hyphen prefix, escape it with a backslash: `\-bar`.
|
||||
|
||||
[Features]
|
||||
* Added YAML configuration file support. You can now use
|
||||
`~/.config/peco/config.yaml` in addition to JSON (#576).
|
||||
* Added negative filter terms. Prefix a search term with `-` to exclude
|
||||
matching lines, e.g. `-unittests SSO` matches "SSO" but excludes
|
||||
lines containing "unittests" (#434, #592).
|
||||
* Added `peco.FreezeResults` / `peco.UnfreezeResults` actions.
|
||||
Freezing locks the current result set so you can clear the query,
|
||||
switch filters, and continue filtering against the frozen subset (#424, #596).
|
||||
* Added `--height` option for inline display. Accepts a line count
|
||||
(e.g. `--height 5`) or a percentage (e.g. `--height 50%`) and
|
||||
renders peco inline instead of taking over the full screen
|
||||
(#471, #598).
|
||||
* Added `peco.ZoomIn` / `peco.ZoomOut` actions that expand visible
|
||||
context around matched lines, showing surrounding lines (#289, #600).
|
||||
* Added ANSI color rendering support via `--color=auto|none` CLI flag
|
||||
and `"Color"` config field. Colored output from tools like
|
||||
`git log --color` or `rg --color` is now displayed with colors
|
||||
preserved. Default is `auto` (enabled) (#398, #602).
|
||||
* Added `IRegexp` filter for case-insensitive regexp matching, so you
|
||||
no longer need to manually type `(?i)` in regex queries. `IRegexp`
|
||||
can also be used as a value for `--initial-filter` / `InitialFilter`
|
||||
(#564).
|
||||
* Added `--exit-0` option to immediately exit with non-zero status
|
||||
when the input is empty, analogous to `--select-1` (#538, #582).
|
||||
* Added Ctrl+Shift key modifier and CSI u / fixterms escape sequence
|
||||
support for modern terminals (#529, #581).
|
||||
* Added Page Up/Down and Home/End key support (#428, #584).
|
||||
* Added a new `top-down-query-bottom` layout: top-down list with
|
||||
query prompt at the bottom. Use `--layout top-down-query-bottom`
|
||||
or `"Layout": "top-down-query-bottom"` in config (#445, #595).
|
||||
* Added man page (`peco.1`) for offline documentation (#467, #587).
|
||||
* Added support for ANSI color codes in the `--prompt` string (#294, #586).
|
||||
* Added `--select-all` option to select all input lines and immediately
|
||||
exit without displaying the selection view. When combined with
|
||||
`--query`, only matching lines are selected.
|
||||
* Added `SuppressStatusMsg` config field to suppress the status message
|
||||
bar at the bottom of the screen.
|
||||
* Added `FilterBufSize` config field to control the internal buffer
|
||||
size (in lines) used during filtering. Default is 1000.
|
||||
* Added `Prompt` and `Context` style targets. `Prompt` controls the
|
||||
appearance of the query prompt prefix (e.g. `QUERY>`). `Context`
|
||||
controls the appearance of context lines shown by ZoomIn (default:
|
||||
bold).
|
||||
* Added `#RRGGBB` truecolor support in style configuration. You can
|
||||
now use hex color values like `"#ff6600"` for foreground and
|
||||
`"on_#003366"` for background colors.
|
||||
* Added `QueryCursor` style for explicit control over the query prompt
|
||||
cursor appearance. When not configured, the cursor is derived from
|
||||
the `Query` style by swapping fg/bg colors.
|
||||
|
||||
[Bugs/Fixes]
|
||||
* Fixed `ToggleQuery` (C-t) not working correctly (#563).
|
||||
* Fixed fuzzy sort producing incorrect order with >1000 lines of
|
||||
input due to chunk-size boundary (#557, #589).
|
||||
* Fixed matched highlight style bleeding to the right edge of the
|
||||
terminal (#460, #583).
|
||||
* Fixed screen not being properly redrawn when stderr output from
|
||||
piped commands corrupts the display (#455, #588).
|
||||
* Suppressed flickering "Running query..." status message (#511, #585).
|
||||
* Fixed `--null` not working correctly with custom filters (#312, #577).
|
||||
* Fixed `doGoToNextSelection` and `doGoToPreviousSelection` sharing
|
||||
the same default key binding (#575, #579). `GoToNextSelection` is
|
||||
now bound to Alt+j and `GoToPreviousSelection` to Alt+k. The
|
||||
previous Ctrl+J and Ctrl+K bindings have been removed (Ctrl+K
|
||||
remains bound to `KillEndOfLine`). If you relied on Ctrl+J or
|
||||
Ctrl+K for selection navigation, update your keymap config.
|
||||
* Fixed `GoToPreviousSelection` being registered with a malformed
|
||||
internal name (`\tdoGoToPreviousSelection`) in v0.5.x. Users who
|
||||
had `"peco.GoToPreviousSelection"` in their config keymap will find
|
||||
that it now correctly binds to the action.
|
||||
* Fixed `lastLine` tracking variable not being updated (#574, #578).
|
||||
* Fixed tab rendering off-by-one error (#672).
|
||||
* Restored mouse support lost during the tcell migration (#735).
|
||||
* Fixed Ctrl-C not properly exiting peco due to meta character
|
||||
handling (#715, #716).
|
||||
* Fixed `--select-1` not triggering on interactive queries and not
|
||||
respecting context for streaming sources (#726).
|
||||
* Fixed filter cache not being invalidated on freeze/unfreeze,
|
||||
causing stale results (#727).
|
||||
* Fixed external filters (`CustomFilter`) incorrectly running in
|
||||
parallel, which could produce garbled results. They now run
|
||||
sequentially (#722).
|
||||
* Removed redundant Esc key timer; Escape should now feel more
|
||||
responsive (#728).
|
||||
* Filter errors are now reported to the status bar instead of being
|
||||
silently swallowed (#682).
|
||||
* Fixed screen Init error being swallowed on suspend/resume (#681).
|
||||
* Fixed I/O errors during stdin read being silently ignored (#680).
|
||||
* Fixed ANSI SGR parser not handling malformed escape sequences
|
||||
gracefully (#692).
|
||||
* Fixed potential panic on negative index in FilteredBuffer (#770).
|
||||
* Fixed errors from external filters being silently dropped
|
||||
(#752, #766).
|
||||
* Fixed various internal race conditions, resource leaks, and
|
||||
potential deadlocks.
|
||||
|
||||
[Performance]
|
||||
* Optimized filtering for large inputs with parallelized workers,
|
||||
achieving 7-30x speedup for ~1M lines (#454, #590).
|
||||
* Reduced overall memory consumption (#572).
|
||||
* Reduced per-query allocation overhead and GC pauses: sync.Pool for
|
||||
match structs, in-place merge, slice reuse in regexp/fuzzy filters,
|
||||
closure elimination, stack-allocated ANSI parsing, and fast-path
|
||||
ANSI stripping (#781-#789, #765).
|
||||
|
||||
v0.5.11 - 21 Mar 2023
|
||||
[Features]
|
||||
* Added new option for "FuzzyLongestSort" flag that can be specified in your config file
|
||||
as
|
||||
```
|
||||
{
|
||||
"FuzzyLongestSort": true,
|
||||
...
|
||||
}
|
||||
```
|
||||
allows you to sort matched lines with the following ordering precedence (#539):
|
||||
1. longer substring
|
||||
2. earlier (left positioned) substring, and
|
||||
3. shorter line.
|
||||
|
||||
[Miscellaneous]
|
||||
* Posted project status at https://github.com/peco/peco/discussions/540
|
||||
|
||||
v0.5.10 - 08 Jun 2021
|
||||
* Previous release was botched. No code change except for version string,
|
||||
and Makefile typo.
|
||||
|
||||
v0.5.9 - 08 Jun 2021
|
||||
* Remove support for 386 arch
|
||||
* Add go.sum
|
||||
* Add darwin-arm
|
||||
* Fix double cursor issue (#513)
|
||||
|
||||
v0.5.8 - 18 Jul 2020
|
||||
Features:
|
||||
* Add support for 256-colors in the configuration. You must first enable
|
||||
this in the config file by specifying "Use256Colors": true
|
||||
|
||||
v0.5.7 - 10 Jan 2020
|
||||
Bugs/Fixes:
|
||||
* Code introduced in v0.5.5 introduced a bug where if the input is coming
|
||||
from standard input and is possibly infinite, the user would see the
|
||||
query being "stuck" after the first character is entered.
|
||||
If peco was applied on a finite set of data, this did not manifest itself.
|
||||
(#494 fixed by #495)
|
||||
|
||||
v0.5.6 - 07 Jan 2020
|
||||
Miscellaneous:
|
||||
* I completely messed up the release process. Retagging and uploading
|
||||
binaries properly. No code changes except for the version number (#493)
|
||||
|
||||
v0.5.5 - 02 Jan 2020
|
||||
Features:
|
||||
* Properly exit out of pec if --select-1 and --query are both specified,
|
||||
and the query produces exactly one result (#490)
|
||||
|
||||
v0.5.4 - 19 Dec 2019
|
||||
Miscellaneous:
|
||||
* Properly set cursor position (#450)
|
||||
* Update termbox-go version (#487/#488)
|
||||
* Update go version to go 1.13
|
||||
|
||||
v0.5.3 - 12 Mar 2018
|
||||
Features:
|
||||
* New --print-query option prints out the query upon successful exit
|
||||
* New actions ScrollFirstItem and ScrollLastItem
|
||||
Miscellaneous:
|
||||
* Update termbox-go version (#447)
|
||||
* Documentation fixes (#444)
|
||||
* Build peco using go1.10
|
||||
|
||||
v0.5.2 - 08 Dec 2017
|
||||
Backwards Incompatible Change:
|
||||
* --version flag now prints out the Go version used to build the binary
|
||||
Micellaneous:
|
||||
* Various doc fixes
|
||||
* Build peco using go1.9.2.
|
||||
* There have apparently been problems running the stock peco binary in
|
||||
MacOS High Sierra, with an error message like "failed MSpanList_Insert...",
|
||||
which can averted by compiling the binary with a newer Go.
|
||||
|
||||
Reports suggest that peco v0.5.1 is not affected by this problem, but
|
||||
we're making this release anyway just to make sure that we're building
|
||||
on a new-ish Go, and that users have a way to see what Go version was
|
||||
used to build their binaries
|
||||
|
||||
v0.5.1 - 17 Mar 2017
|
||||
Bugs/Fixes
|
||||
* When --exec is used, and you come back from the external command,
|
||||
|
|
@ -9,12 +230,12 @@ v0.5.1 - 17 Mar 2017
|
|||
* --tty has been removed. it was not being used anyways.
|
||||
Miscellaneous
|
||||
* When scrolling right, you could potentially keep on scrolling
|
||||
infinitely. This has bee addressed by #409, #412
|
||||
infinitely. This has been addressed by #409, #412
|
||||
* External commands specified in --exec now receive
|
||||
PECO_FILENAME, PECO_LINE_COUNT, PECO_QUERY, and
|
||||
PECO_MACHED_LINE_COUNT as environment variables
|
||||
PECO_MATCHED_LINE_COUNT as environment variables
|
||||
* Removed unused structs
|
||||
* Fixed glide related Mkaefile actions
|
||||
* Fixed glide related Makefile actions
|
||||
|
||||
v0.5.0 - 06 Mar 2017
|
||||
Backwards Incompatible Change:
|
||||
|
|
@ -354,7 +575,7 @@ v0.1.8 - 25 Jun 2014
|
|||
|
||||
v0.1.7 - 22 Jun 2014
|
||||
Bugs/Fixes:
|
||||
* Moved repository to a Github organization: http://github.com/peco/peco
|
||||
* Moved repository to a GitHub organization: http://github.com/peco/peco
|
||||
* Because of the above change, a lot of links, imports needed fixing.
|
||||
Features:
|
||||
* Automatically set GOMAXPROCS to NumCPU. If you would like to
|
||||
|
|
@ -429,4 +650,4 @@ v0.1.1 - 12 Jun 2014
|
|||
* Add peco --version
|
||||
|
||||
v0.1.0 - 12 Jun 2014
|
||||
* Initial versioned release
|
||||
* Initial versioned release
|
||||
|
|
|
|||
159
Makefile
159
Makefile
|
|
@ -1,147 +1,34 @@
|
|||
INTERNAL_BIN_DIR=_internal_bin
|
||||
GOVERSION=$(shell go version)
|
||||
THIS_GOOS=$(word 1,$(subst /, ,$(lastword $(GOVERSION))))
|
||||
THIS_GOARCH=$(word 2,$(subst /, ,$(lastword $(GOVERSION))))
|
||||
GOOS=$(THIS_GOOS)
|
||||
GOARCH=$(THIS_GOARCH)
|
||||
VERSION=$(patsubst "%",%,$(lastword $(shell grep 'const version' peco.go)))
|
||||
RELEASE_DIR=releases
|
||||
ARTIFACTS_DIR=$(RELEASE_DIR)/artifacts/$(VERSION)
|
||||
SRC_FILES = $(wildcard *.go cmd/peco/*.go internal/*/*.go)
|
||||
HAVE_GLIDE:=$(shell (test -e $(INTERNAL_BIN_DIR)/$(THIS_GOOS)/$(THIS_GOARCH)/glide || which glide >/dev/null 2>&1 ) && echo "yes")
|
||||
GITHUB_USERNAME=peco
|
||||
BUILD_TARGETS= \
|
||||
build-linux-arm64 \
|
||||
build-linux-arm \
|
||||
build-linux-amd64 \
|
||||
build-linux-386 \
|
||||
build-darwin-amd64 \
|
||||
build-darwin-386 \
|
||||
build-windows-amd64 \
|
||||
build-windows-386
|
||||
RELEASE_TARGETS=\
|
||||
release-linux-arm64 \
|
||||
release-linux-arm \
|
||||
release-linux-amd64 \
|
||||
release-linux-386 \
|
||||
release-darwin-amd64 \
|
||||
release-darwin-386 \
|
||||
release-windows-amd64 \
|
||||
release-windows-386
|
||||
MAKEFLAGS += --no-print-directory
|
||||
export PATH := $(HOME)/go/bin:$(PATH)
|
||||
|
||||
.PHONY: clean build $(RELEASE_TARGETS) $(BUILD_TARGETS) $(RELEASE_DIR)/$(GOOS)/$(GOARCH)/peco$(SUFFIX)
|
||||
.DEFAULT_GOAL := build
|
||||
|
||||
build: $(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)/peco$(SUFFIX)
|
||||
.PHONY: build snapshot test lint generate cover clean deps check-goreleaser
|
||||
|
||||
$(INTERNAL_BIN_DIR):
|
||||
@echo "Creating $(INTERNAL_BIN_DIR)"
|
||||
@mkdir -p $(INTERNAL_BIN_DIR)
|
||||
check-goreleaser:
|
||||
@command -v goreleaser >/dev/null 2>&1 || { echo "goreleaser not found. Install it: https://goreleaser.com/install/"; exit 1; }
|
||||
|
||||
$(INTERNAL_BIN_DIR)/$(THIS_GOOS)/$(THIS_GOARCH)/glide: $(INTERNAL_BIN_DIR)
|
||||
ifeq ($(HAVE_GLIDE),)
|
||||
@echo "Installing glide for $(THIS_GOOS)/$(THIS_GOARCH)..."
|
||||
@mkdir -p $(INTERNAL_BIN_DIR)/$(THIS_GOOS)/$(THIS_GOARCH)
|
||||
@wget -q -O - https://github.com/Masterminds/glide/releases/download/0.10.2/glide-0.10.2-$(THIS_GOOS)-$(THIS_GOARCH).tar.gz | tar xvz
|
||||
@mv $(THIS_GOOS)-$(THIS_GOARCH)/glide $(INTERNAL_BIN_DIR)/$(THIS_GOOS)/$(THIS_GOARCH)/glide
|
||||
@rm -rf $(THIS_GOOS)-$(THIS_GOARCH)
|
||||
endif
|
||||
build: check-goreleaser
|
||||
goreleaser build --snapshot --single-target --clean
|
||||
|
||||
glide: $(INTERNAL_BIN_DIR)/$(THIS_GOOS)/$(THIS_GOARCH)/glide
|
||||
snapshot: check-goreleaser
|
||||
goreleaser release --snapshot --clean
|
||||
|
||||
installdeps: glide $(SRC_FILES)
|
||||
@echo "Installing dependencies..."
|
||||
@$(INTERNAL_BIN_DIR)/$(THIS_GOOS)/$(THIS_GOARCH)/glide install
|
||||
test:
|
||||
go test -v -race ./...
|
||||
|
||||
build-windows-amd64:
|
||||
@$(MAKE) build GOOS=windows GOARCH=amd64 SUFFIX=.exe
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
build-windows-386:
|
||||
@$(MAKE) build GOOS=windows GOARCH=386 SUFFIX=.exe
|
||||
generate:
|
||||
go generate ./...
|
||||
|
||||
build-linux-amd64:
|
||||
@$(MAKE) build GOOS=linux GOARCH=amd64
|
||||
|
||||
build-linux-arm:
|
||||
@$(MAKE) build GOOS=linux GOARCH=arm
|
||||
|
||||
build-linux-arm64:
|
||||
@$(MAKE) build GOOS=linux GOARCH=arm64
|
||||
|
||||
build-linux-386:
|
||||
@$(MAKE) build GOOS=linux GOARCH=386
|
||||
|
||||
build-darwin-amd64:
|
||||
@$(MAKE) build GOOS=darwin GOARCH=amd64
|
||||
|
||||
build-darwin-386:
|
||||
@$(MAKE) build GOOS=darwin GOARCH=386
|
||||
|
||||
$(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)/peco$(SUFFIX): installdeps
|
||||
go build -o $(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)/peco$(SUFFIX) cmd/peco/peco.go
|
||||
|
||||
all: $(BUILD_TARGETS)
|
||||
|
||||
release: $(RELEASE_TARGETS)
|
||||
|
||||
$(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)/Changes:
|
||||
@cp Changes $(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)
|
||||
|
||||
$(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)/README.md:
|
||||
@cp README.md $(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)
|
||||
|
||||
release-changes: $(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)/Changes
|
||||
release-readme: $(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)/README.md
|
||||
|
||||
release-windows-amd64: build-windows-amd64
|
||||
@$(MAKE) release-changes release-readme release-zip GOOS=windows GOARCH=amd64
|
||||
|
||||
release-windows-386: build-windows-386
|
||||
@$(MAKE) release-changes release-readme release-zip GOOS=windows GOARCH=386
|
||||
|
||||
release-linux-amd64: build-linux-amd64
|
||||
@$(MAKE) release-changes release-readme release-targz GOOS=linux GOARCH=amd64
|
||||
|
||||
release-linux-arm: build-linux-arm
|
||||
@$(MAKE) release-changes release-readme release-targz GOOS=linux GOARCH=arm
|
||||
|
||||
release-linux-arm64: build-linux-arm64
|
||||
@$(MAKE) release-changes release-readme release-targz GOOS=linux GOARCH=arm64
|
||||
|
||||
release-linux-386: build-linux-386
|
||||
@$(MAKE) release-changes release-readme release-targz GOOS=linux GOARCH=386
|
||||
|
||||
release-darwin-amd64: build-darwin-amd64
|
||||
@$(MAKE) release-changes release-readme release-zip GOOS=darwin GOARCH=amd64
|
||||
|
||||
release-darwin-386: build-darwin-386
|
||||
@$(MAKE) release-changes release-readme release-zip GOOS=darwin GOARCH=386
|
||||
|
||||
$(ARTIFACTS_DIR):
|
||||
@mkdir -p $(ARTIFACTS_DIR)
|
||||
|
||||
# note: I dreamt of using tar.bz2 for my releases, but then historically
|
||||
# (for whatever reason that is unknwon to me now) I was creating .zip for
|
||||
# darwin/windows, and .tar.gz for linux, so I guess we'll stick with those.
|
||||
# (I think this is from goxc days)
|
||||
release-tarbz: $(ARTIFACTS_DIR)
|
||||
tar -cjf $(ARTIFACTS_DIR)/peco_$(GOOS)_$(GOARCH).tar.bz2 -C $(RELEASE_DIR) peco_$(GOOS)_$(GOARCH)
|
||||
|
||||
release-targz: $(ARTIFACTS_DIR)
|
||||
tar -czf $(ARTIFACTS_DIR)/peco_$(GOOS)_$(GOARCH).tar.gz -C $(RELEASE_DIR) peco_$(GOOS)_$(GOARCH)
|
||||
|
||||
release-zip: $(ARTIFACTS_DIR)
|
||||
cd $(RELEASE_DIR) && zip -9 $(CURDIR)/$(ARTIFACTS_DIR)/peco_$(GOOS)_$(GOARCH).zip peco_$(GOOS)_$(GOARCH)/*
|
||||
|
||||
release-github-token: github_token
|
||||
@echo "file `github_token` is required"
|
||||
|
||||
release-upload: release release-github-token
|
||||
ghr -u $(GITHUB_USERNAME) -t $(shell cat github_token) --draft --replace $(VERSION) $(ARTIFACTS_DIR)
|
||||
|
||||
test: installdeps
|
||||
@echo "Running tests..."
|
||||
@PATH=$(INTERNAL_BIN_DIR)/$(GOOS)/$(GOARCH):$(PATH) go test -v $(shell $(INTERNAL_BIN_DIR)/$(THIS_GOOS)/$(THIS_GOARCH)/glide nv)
|
||||
cover:
|
||||
go test -race -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out
|
||||
|
||||
clean:
|
||||
-rm -rf $(RELEASE_DIR)/*/*
|
||||
-rm -rf $(ARTIFACTS_DIR)/*
|
||||
rm -rf dist
|
||||
|
||||
deps:
|
||||
go mod download
|
||||
|
|
|
|||
691
README.md
691
README.md
|
|
@ -2,10 +2,9 @@
|
|||
|
||||
Simplistic interactive filtering tool
|
||||
|
||||
*NOTE*: If you are viewing this on Github, this document refers to the state of `peco` in whatever current branch you are viewing, _not_ necessarily the state of a currently released version. Please make sure to checkout the [Changes](./Changes) file for features and changes.
|
||||
|
||||
This README is long and comprehensive. Use the [Table of Contents](#table-of-contents) to navigate to the section that interests you. It has been placed at the bottom of the README file because of its length.
|
||||
*NOTE*: If you are viewing this on GitHub, this document refers to the state of `peco` in whatever current branch you are viewing, _not_ necessarily the state of a currently released version. Please make sure to checkout the [Changes](./Changes) file for features and changes.
|
||||
|
||||
> If you use peco, please consider sponsoring the authors of this project from the "Sponsor" button on the project page at https://github.com/peco/peco. Sponsorship plans start at $1 :)
|
||||
|
||||
# Description
|
||||
|
||||
|
|
@ -44,9 +43,28 @@ Multiple terms turn the query into an "AND" query:
|
|||
When you find that line that you want, press enter, and the resulting line
|
||||
is printed to stdout, which allows you to pipe it to other tools
|
||||
|
||||
## Negative Matching
|
||||
|
||||
You can exclude lines from the results by prefixing a term with `-`. For example, the query `SSO -tests -javadoc` shows lines matching "SSO" that do NOT contain "tests" or "javadoc".
|
||||
|
||||
| Query | Meaning |
|
||||
|-------|---------|
|
||||
| `foo -bar` | Lines matching "foo" but not containing "bar" |
|
||||
| `-foo -bar` | All lines not containing "foo" or "bar" |
|
||||
| `\-foo` | Literal match for "-foo" (escaped with backslash) |
|
||||
| `-` | Literal match for a hyphen character |
|
||||
|
||||
Negative matching works with all built-in filters (IgnoreCase, CaseSensitive, SmartCase, Regexp, IRegexp, and Fuzzy). For the Fuzzy filter, negative terms use regexp-based exclusion rather than fuzzy matching. External custom filters receive the query as-is and are responsible for their own parsing.
|
||||
|
||||
Only positive terms produce match highlighting. Lines matched solely by negative exclusion (e.g. an all-negative query like `-foo`) are shown without highlighting.
|
||||
|
||||
**Note:** When using the SmartCase filter with negative terms, results may be incomplete if the query transitions from all-lowercase to mixed-case (e.g. typing `foo -bar` then adding an uppercase character). If this happens, clearing the query and retyping it will produce the correct results.
|
||||
|
||||
**Upgrading from v0.5.x:** In v0.5.x, a query like `test -v` matched lines containing both "test" and literal "-v". From v0.6.0 onwards, `-v` is treated as a negative term, so the query matches "test" while excluding lines containing "v". To search for a literal hyphen-prefixed term, escape it with a backslash (e.g. `\-v`).
|
||||
|
||||
## Select Multiple Lines
|
||||
|
||||
You can select multiple lines!
|
||||
You can select multiple lines! (this example uses C-Space)
|
||||
|
||||

|
||||
|
||||
|
|
@ -58,22 +76,148 @@ Not only can you select multiple lines one by one, you can select a range of lin
|
|||
|
||||
## Select Filters
|
||||
|
||||
Different types of filters are available. Default is case-insensitive filter, so lines with any case will match. You can toggle between IgnoreCase, CaseSensitive, SmartCase RegExp and Fuzzy filters.
|
||||
Different types of filters are available. Default is case-insensitive filter, so lines with any case will match. You can toggle between IgnoreCase, CaseSensitive, SmartCase, Regexp case insensitive, Regexp and Fuzzy filters.
|
||||
|
||||
The SmartCase filter uses case-*insensitive* matching when all of the queries are lower case, and case-*sensitive* matching otherwise.
|
||||
|
||||
The RegExp filter allows you to use any valid regular expression to match lines
|
||||
The Regexp filter allows you to use any valid regular expression to match lines.
|
||||
|
||||
The Fuzzy filter allows you to find matches using partial patterns. For example, when searching for `ALongString`, you can enable the Fuzzy filter and search `ALS` to find it. The Fuzzy filter uses smart case search like the SmartCase filter.
|
||||
The Fuzzy filter allows you to find matches using partial patterns. For example, when searching for `ALongString`, you can enable the Fuzzy filter and search `ALS` to find it. The Fuzzy filter uses smart case search like the SmartCase filter. With the `FuzzyLongestSort` flag enabled in the configuration file, it does a smarter match. It sorts the matched lines by the following precedence: 1. longer substring, 2. earlier (left positioned) substring, and 3. shorter line.
|
||||
|
||||

|
||||
|
||||
## Multi-Stage Filtering (Freeze Results)
|
||||
|
||||
You can "freeze" the current filter results, clear the query, and continue filtering on top of the frozen results. This enables multi-stage filtering workflows -- for example, first filter by file extension, freeze, then filter by filename.
|
||||
|
||||
Use `peco.FreezeResults` to snapshot the current results and clear the query. Use `peco.UnfreezeResults` to discard the frozen results and revert to the original input. These actions are **not bound to any key by default** -- you need to add keybindings in your config file:
|
||||
|
||||
```json
|
||||
{
|
||||
"Keymap": {
|
||||
"M-f": "peco.FreezeResults",
|
||||
"M-u": "peco.UnfreezeResults"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can freeze multiple times to progressively narrow down results. Unfreezing always reverts back to the original unfiltered input.
|
||||
|
||||
**Example:** Given this input via `ls | peco`:
|
||||
|
||||
```
|
||||
QUERY>
|
||||
app.go
|
||||
app_test.go
|
||||
filter.go
|
||||
filter_test.go
|
||||
main.go
|
||||
readme.md
|
||||
```
|
||||
|
||||
Type `_test` to filter:
|
||||
|
||||
```
|
||||
QUERY> _test
|
||||
app_test.go
|
||||
filter_test.go
|
||||
```
|
||||
|
||||
Press `M-f` to freeze. The two test files become the new base and the query clears:
|
||||
|
||||
```
|
||||
QUERY>
|
||||
app_test.go
|
||||
filter_test.go
|
||||
```
|
||||
|
||||
Now type `filter` to search within the frozen results:
|
||||
|
||||
```
|
||||
QUERY> filter
|
||||
filter_test.go
|
||||
```
|
||||
|
||||
Press `Enter` to select `filter_test.go`, or press `M-u` to unfreeze and return to the original full list.
|
||||
|
||||
## Horizontal Scrolling
|
||||
|
||||
When input lines are longer than the terminal width, they are clipped at the edge of the screen. You can scroll horizontally to reveal the rest of the line using the `peco.ScrollLeft` and `peco.ScrollRight` actions. These actions are **not bound to any key by default** -- you need to add keybindings in your config file:
|
||||
|
||||
```json
|
||||
{
|
||||
"Keymap": {
|
||||
"ArrowLeft": "peco.ScrollLeft",
|
||||
"ArrowRight": "peco.ScrollRight"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each scroll moves by half the terminal width.
|
||||
|
||||
If your input contains very long lines (e.g. minified files) and they do not appear at all, try increasing `MaxScanBufferSize` in your config. The default is 256 (KB), which limits the maximum length of a single input line.
|
||||
|
||||
## ANSI Color Support
|
||||
|
||||
By default (`--color=auto`), peco parses ANSI SGR (Select Graphic Rendition) escape sequences from the input and renders the original colors in the terminal. This lets you pipe colored output from tools like `git log --color`, `rg --color=always`, or `ls --color` through peco while preserving the visual formatting.
|
||||
|
||||
```
|
||||
git log --color=always | peco
|
||||
rg --color=always pattern | peco
|
||||
ls --color=always | peco
|
||||
```
|
||||
|
||||
To disable ANSI color rendering, use `--color=none`.
|
||||
|
||||
peco uses two independent color layers, both supporting 8 named colors, 256-color, and 24-bit truecolor. The first is peco's own **UI styling** — selection highlighting, match highlighting, prompt colors, etc. — configured via the `Style` section in the config file (see [Styles](#styles)). UI styling is always active and is not affected by the `--color` flag. The second is **input ANSI colors** — escape sequences already present in the piped input from tools like `git log --color`. This layer is controlled by the `--color` flag described above.
|
||||
|
||||
When input ANSI color support is enabled, filtering and matching operate against the **stripped** (plain text) version of each line, so escape codes do not interfere with your queries. Input ANSI colors are displayed as the **base layer**; peco's own UI styling takes precedence. Selected lines' output preserves the **original** ANSI codes, so downstream tools receive colored text.
|
||||
|
||||
ANSI color support can be controlled via the configuration file (see [Color](#color) under Global configuration).
|
||||
|
||||
## Context Lines (Zoom In/Out)
|
||||
|
||||
When filtering results (e.g. searching for "error" in a log file), you often need to see the surrounding lines to understand the context. peco supports expanding filtered results to show context lines around each match, similar to `grep -C`.
|
||||
|
||||
Two actions are available:
|
||||
|
||||
- **`peco.ZoomIn`** — Expands the current filtered view by showing 3 lines of context (before and after) around every matched line. Overlapping context ranges are merged automatically. Context lines are displayed with the `Context` style (bold by default) to visually distinguish them from matched lines.
|
||||
|
||||
- **`peco.ZoomOut`** — Collapses back to the original filtered view, restoring the cursor position.
|
||||
|
||||
These actions are **not bound to any key by default**. Add keybindings in your config file:
|
||||
|
||||
```json
|
||||
{
|
||||
"Keymap": {
|
||||
"C-o": "peco.ZoomIn",
|
||||
"C-i": "peco.ZoomOut"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
ZoomIn only works when there is an active filter query; if you are viewing the unfiltered source, it is a no-op. You cannot zoom in twice — zooming in while already zoomed shows a status message. The cursor position is preserved: after ZoomIn, the cursor stays on the same matched line; after ZoomOut, it returns to where it was before zooming. Context lines cannot be selected — only the original matched lines participate in selection. The `Context` style can be customized in the config file (see [Styles](#styles)).
|
||||
|
||||
## Selectable Layout
|
||||
|
||||
As of v0.2.5, if you would rather not move your eyes off of the bottom of the screen, you can change the screen layout by either providing the `--layout=bottom-up` command line option, or set the `Layout` variable in your configuration file
|
||||
|
||||

|
||||
|
||||
## Inline Mode (--height)
|
||||
|
||||
By default peco takes over the entire terminal screen using the alternate screen buffer. With `--height`, peco renders inline at the bottom of the terminal, preserving your scroll history above. This is similar to fzf's `--height` option.
|
||||
|
||||
```
|
||||
# Render with 5 result lines at the bottom of the terminal
|
||||
ls | peco --height 5
|
||||
|
||||
# Use 40% of the terminal height
|
||||
ls | peco --height 40%
|
||||
```
|
||||
|
||||
All layout modes (`top-down`, `bottom-up`, `top-down-query-bottom`) work with `--height`. See [--height](#--height-numpercentage) for details.
|
||||
|
||||
## Works on Windows!
|
||||
|
||||
I have been told that peco even works on windows :) Look ma! I'm not lying!
|
||||
|
|
@ -86,16 +230,50 @@ I have been told that peco even works on windows :) Look ma! I'm not lying!
|
|||
|
||||
Go to the [releases page](https://github.com/peco/peco/releases), find the version you want, and download the zip file. Unpack the zip file, and put the binary to somewhere you want (on UNIX-y systems, /usr/local/bin or the like). Make sure it has execution bits turned on. Yes, it is a single binary! You can put it anywhere you want :)
|
||||
|
||||
_THIS IS THE RECOMMENDED WAY_ (except for OS X homebrew users)
|
||||
_THIS IS THE RECOMMENDED WAY_ (except for macOS homebrew users)
|
||||
|
||||
### Mac OS X / Homebrew
|
||||
### macOS (Homebrew, Scarf)
|
||||
|
||||
If you're on OS X and want to use homebrew:
|
||||
If you're on macOS and want to use homebrew:
|
||||
|
||||
```
|
||||
brew install peco
|
||||
```
|
||||
|
||||
or with Scarf:
|
||||
|
||||
```
|
||||
scarf install peco
|
||||
```
|
||||
|
||||
### Debian and Ubuntu based distributions (APT, Scarf)
|
||||
|
||||
There is an official Debian package that can be installed via APT:
|
||||
|
||||
```
|
||||
apt install peco
|
||||
```
|
||||
|
||||
or with Scarf:
|
||||
|
||||
```
|
||||
scarf install peco
|
||||
```
|
||||
|
||||
### Void Linux (XBPS)
|
||||
|
||||
```
|
||||
xbps-install -S peco
|
||||
```
|
||||
|
||||
### Arch Linux
|
||||
|
||||
There is an official Arch Linux package that can be installed via `pacman`:
|
||||
|
||||
```
|
||||
pacman -Syu peco
|
||||
```
|
||||
|
||||
### Windows (Chocolatey NuGet Users)
|
||||
|
||||
There's a third-party [peco package available](https://chocolatey.org/packages/peco) for Chocolatey NuGet.
|
||||
|
|
@ -104,25 +282,50 @@ There's a third-party [peco package available](https://chocolatey.org/packages/p
|
|||
C:\> choco install peco
|
||||
```
|
||||
|
||||
### X-CMD (Linux, macOS, Windows WSL, Windows GitBash)
|
||||
|
||||
peco is available from [x-cmd](https://www.x-cmd.com).
|
||||
|
||||
To install peco, run:
|
||||
|
||||
```shell
|
||||
x env use peco
|
||||
```
|
||||
|
||||
### Linux / macOS / Windows (Conda, Mamba, Pixi)
|
||||
|
||||
`conda`, `mamba` and `pixi` are platform-agnostic package managers for conda-format packages.
|
||||
|
||||
This means that the same command can be used to install peco across Windows, MacOS, and Linux.
|
||||
|
||||
```
|
||||
# conda
|
||||
conda install -c conda-forge peco
|
||||
|
||||
# mamba
|
||||
mamba install -c conda-forge peco
|
||||
|
||||
# install user-globally using pixi
|
||||
pixi global install peco
|
||||
```
|
||||
|
||||
### Using go install
|
||||
|
||||
If you have a Go toolchain installed, you can install peco with:
|
||||
|
||||
```
|
||||
go install github.com/peco/peco/cmd/peco@latest
|
||||
```
|
||||
|
||||
### Building peco yourself
|
||||
|
||||
From the root of your cloned peco repository, run:
|
||||
Clone the repository and run:
|
||||
|
||||
```
|
||||
glide install
|
||||
make build
|
||||
```
|
||||
|
||||
This installs the correct versions of peco's dependencies. Then build it:
|
||||
|
||||
```
|
||||
go build cmd/peco/peco.go
|
||||
```
|
||||
|
||||
This compiles a peco binary in the root of the cloned peco repository. Copy this file to an appropriate location.
|
||||
|
||||
### go get IS NOT RECOMMENDED
|
||||
|
||||
Please DO NOT use `go get` to install this tool. It bypasses the developers' intention of controlling the dependency versioning.
|
||||
This will build the binary into `dist/peco_<os>_<arch>/peco`. Copy it to somewhere in your `$PATH`.
|
||||
|
||||
# Command Line Options
|
||||
|
||||
|
|
@ -136,7 +339,11 @@ Display the version of peco
|
|||
|
||||
### --query <query>
|
||||
|
||||
Specifies the default query to be used upon startup. This is useful for scripts and functions where you can figure out before hand what the most likely query string is.
|
||||
Specifies the default query to be used upon startup. This is useful for scripts and functions where you can figure out beforehand what the most likely query string is.
|
||||
|
||||
### --print-query
|
||||
|
||||
When exiting, prints out the query typed by the user as the first line of output. The query will be printed even if there are no matches, if the program is terminated normally (i.e. enter key). On the other hand, the query will NOT be printed if the user exits via a cancel (i.e. esc key).
|
||||
|
||||
### --rcfile <filename>
|
||||
|
||||
|
|
@ -148,27 +355,25 @@ Limits the buffer size to `num`. This is an important feature when you are using
|
|||
|
||||
### --null
|
||||
|
||||
WARNING: EXPERIMENTAL. This feature will probably stay, but the option name may change in the future.
|
||||
|
||||
Changes how peco interprets incoming data. When this flag is set, you may insert NUL ('\0') characters in your input. Anything before the NUL character is treated as the string to be displayed by peco and is used for matching against user query. Anything after the NUL character is used as the "result": i.e., when peco is about to exit, it displays this string instead of the original string displayed.
|
||||
|
||||
[Here's a simple example of how to use this feature](https://gist.github.com/mattn/3c7a14c1677ecb193acd)
|
||||
|
||||
### --initial-index
|
||||
|
||||
Specifies the initial line position upon start up. E.g. If you want to start out with the second line selected, set it to "1" (because the index is 0 based)
|
||||
Specifies the initial line position upon start up. E.g. If you want to start out with the second line selected, set it to "1" (because the index is 0 based).
|
||||
|
||||
### --initial-filter `IgnoreCase|CaseSensitive|SmartCase|Regexp|Fuzzy`
|
||||
### --initial-filter `IgnoreCase|CaseSensitive|SmartCase|IRegexp|Regexp|Fuzzy`
|
||||
|
||||
Specifies the initial filter to use upon start up. You should specify the name of the filter like `IgnoreCase`, `CaseSensitive`, `SmartCase`, `Regexp` and `Fuzzy`. Default is `IgnoreCase`.
|
||||
Specifies the initial filter to use upon start up. You should specify the name of the filter like `IgnoreCase`, `CaseSensitive`, `SmartCase`, `IRegexp`, `Regexp` and `Fuzzy`. Default is `IgnoreCase`.
|
||||
|
||||
### --prompt
|
||||
|
||||
Specifies the query line's prompt string. When specified, takes precedence over the configuration file's `Prompt` section. The default value is `QUERY>`
|
||||
Specifies the query line's prompt string. When specified, takes precedence over the configuration file's `Prompt` section. The default value is `QUERY>`.
|
||||
|
||||
### --layout `top-down|bottom-up`
|
||||
### --layout `top-down|bottom-up|top-down-query-bottom`
|
||||
|
||||
Specifies the display layout. Default is `top-down`, where query prompt is at the top, followed by the list, then the system status message line. `bottom-up` changes this to the list first (displayed in reverse order), the query prompt, and then the system status message line.
|
||||
Specifies the display layout. Default is `top-down`, where query prompt is at the top, followed by the list, then the system status message line. `bottom-up` changes this to the list first (displayed in reverse order), the query prompt, and then the system status message line. `top-down-query-bottom` places the list at the top with the query prompt at the bottom.
|
||||
|
||||
For `percol` users, `--layout=bottom-up` is almost equivalent of `--prompt-bottom --result-bottom-up`.
|
||||
|
||||
|
|
@ -178,6 +383,14 @@ When specified *and* the input contains exactly 1 line, peco skips prompting you
|
|||
|
||||
If there are multiple lines in the input, the usual selection view is displayed.
|
||||
|
||||
### --exit-0
|
||||
|
||||
When specified and the input is empty (zero lines), peco exits immediately with status 1 without displaying the selection view.
|
||||
|
||||
### --select-all
|
||||
|
||||
When specified, peco selects all input lines and immediately exits without displaying the selection view.
|
||||
|
||||
### --on-cancel `success|error`
|
||||
|
||||
Specifies the exit status to use when the user cancels the query execution.
|
||||
|
|
@ -185,7 +398,7 @@ For historical and back-compatibility reasons, the default is `success`, meaning
|
|||
|
||||
### --selection-prefix `string`
|
||||
|
||||
When specified, peco uses the specified prefix instead of changing line color to indicate currently selected line(s). default is to use colors. This option is experimental
|
||||
When specified, peco uses the specified prefix instead of changing line color to indicate currently selected line(s). Default is to use colors.
|
||||
|
||||
### --exec `string`
|
||||
|
||||
|
|
@ -195,6 +408,55 @@ Upon exiting from the external command, the control goes back to peco where you
|
|||
|
||||
To exit out of peco when running in this mode, you must execute the Cancel command, usually the escape key.
|
||||
|
||||
### --color `auto|none`
|
||||
|
||||
Controls ANSI color code support. `auto` (the default) enables parsing and rendering of ANSI SGR escape sequences from the input. `none` disables it, stripping escape sequences without rendering colors.
|
||||
|
||||
See [ANSI Color Support](#ansi-color-support) in the Features section for details.
|
||||
|
||||
### --height `num|percentage`
|
||||
|
||||
When specified, peco renders inline at the bottom of the terminal using only the requested number of lines, instead of taking over the full screen. This preserves your terminal scroll history above the peco interface.
|
||||
|
||||
The value can be:
|
||||
|
||||
- An absolute number of **result lines** (e.g. `--height 5`). The prompt and status bar are added automatically, so `--height 5` uses 7 terminal rows total (5 result lines + prompt + status bar).
|
||||
- A percentage of the terminal height (e.g. `--height 50%`). This refers to the total height including prompt and status bar.
|
||||
|
||||
The minimum effective height is 3 rows (1 result line + prompt + status bar). Values that exceed the terminal height are clamped.
|
||||
|
||||
```
|
||||
# Show 5 result lines inline
|
||||
ls | peco --height 5
|
||||
|
||||
# Use 40% of the terminal
|
||||
ls | peco --height 40%
|
||||
```
|
||||
|
||||
Without `--height`, peco uses the full terminal screen (default behavior, unchanged).
|
||||
|
||||
**Note:** In inline mode, peco sets the environment variable `TCELL_ALTSCREEN=disable` to prevent tcell from using the alternate screen buffer, and restores the original value on exit. If peco is killed abnormally (e.g. `SIGKILL`), you may need to unset this variable manually: `unset TCELL_ALTSCREEN`.
|
||||
|
||||
### -f, --follow
|
||||
|
||||
When specified, peco follows streaming input, automatically scrolling to keep the newest lines visible at the bottom of the list, just like `tail -f`. This is useful for live log streams:
|
||||
|
||||
```
|
||||
journalctl -f -n 1000 | peco --follow --layout top-down-query-bottom
|
||||
```
|
||||
|
||||
Moving the cursor manually (e.g. with the arrow keys) turns follow mode off so you can scroll back through the history. The `ToggleFollow` action turns it back on; bind it to a key in your configuration file, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"Keymap": {
|
||||
"C-f": "peco.ToggleFollow"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Follow mode can also be enabled by default with the `Follow` configuration variable. The `--follow` command line option takes precedence over the configuration file.
|
||||
|
||||
# Configuration File
|
||||
|
||||
peco by default consults a few locations for the config files.
|
||||
|
|
@ -202,7 +464,7 @@ peco by default consults a few locations for the config files.
|
|||
1. Location specified in --rcfile. If this doesn't exist, peco complains and exits
|
||||
2. $XDG\_CONFIG\_HOME/peco/config.json
|
||||
3. $HOME/.config/peco/config.json
|
||||
4. for each directories listed in $XDG\_CONFIG\_DIRS, $DIR/peco/config.json
|
||||
4. for each directory listed in $XDG\_CONFIG\_DIRS, $DIR/peco/config.json
|
||||
5. If all else fails, $HOME/.peco/config.json
|
||||
|
||||
Below are configuration sections that you may specify in your config file:
|
||||
|
|
@ -211,9 +473,8 @@ Below are configuration sections that you may specify in your config file:
|
|||
* [Keymaps](#keymaps)
|
||||
* [Styles](#styles)
|
||||
* [CustomFilter](#customfilter)
|
||||
* [CustomMatcher](#custommatcher)
|
||||
* [Prompt](#prompt)
|
||||
* [InitialMatcher](#initialmatcher)
|
||||
* [Color](#color)
|
||||
|
||||
## Global
|
||||
|
||||
|
|
@ -229,13 +490,15 @@ You can change the query line's prompt, which is `QUERY>` by default.
|
|||
}
|
||||
```
|
||||
|
||||
### InitialMatcher
|
||||
|
||||
*InitialMatcher* has been deprecated. Please use `InitialFilter` instead.
|
||||
|
||||
### InitialFilter
|
||||
|
||||
Specifies the filter name to start peco with. You should specify the name of the filter, such as `IgnoreCase`, `CaseSensitive`, `SmartCase`, `Regexp` and `Fuzzy`.
|
||||
Specifies the filter name to start peco with. You should specify the name of the filter, such as `IgnoreCase`, `CaseSensitive`, `SmartCase`, `IRegexp`, `Regexp` and `Fuzzy`.
|
||||
|
||||
### FuzzyLongestSort
|
||||
|
||||
Enables the longest substring match and sorts the output. It affects only the Fuzzy filter.
|
||||
|
||||
Default value for FuzzyLongestSort is false.
|
||||
|
||||
### StickySelection
|
||||
|
||||
|
|
@ -246,13 +509,26 @@ Specifies the filter name to start peco with. You should specify the name of the
|
|||
```
|
||||
|
||||
StickySelection allows selections to persist even between changes to the query.
|
||||
For example, when you set this to true you can select a few lines, type in a
|
||||
For example, when you set this to true you can select a few lines, type in a
|
||||
new query, select those lines, and then delete the query. The result is all
|
||||
the lines that you selected before and after the modification to the query are
|
||||
left in tact.
|
||||
left intact.
|
||||
|
||||
Default value for StickySelection is false.
|
||||
|
||||
### SuppressStatusMsg
|
||||
|
||||
```json
|
||||
{
|
||||
"SuppressStatusMsg": true
|
||||
}
|
||||
```
|
||||
|
||||
SuppressStatusMsg suppresses the status message bar at the bottom of the screen.
|
||||
When set to true, messages like "Running query..." will not be displayed.
|
||||
|
||||
Default value for SuppressStatusMsg is false.
|
||||
|
||||
### OnCancel
|
||||
|
||||
```json
|
||||
|
|
@ -277,6 +553,44 @@ very long lines that prohibit peco from reading them, try increasing this number
|
|||
|
||||
The same time, the default MaxScanBuferSize is 256kb.
|
||||
|
||||
### FilterBufSize
|
||||
|
||||
```json
|
||||
{
|
||||
"FilterBufSize": 1000
|
||||
}
|
||||
```
|
||||
|
||||
Controls the number of lines buffered internally during filtering. If you are
|
||||
working with very large inputs and notice sluggish filter updates, you can try
|
||||
adjusting this value.
|
||||
|
||||
Default value for FilterBufSize is 1000.
|
||||
|
||||
### Color
|
||||
|
||||
```json
|
||||
{
|
||||
"Color": "none"
|
||||
}
|
||||
```
|
||||
|
||||
Controls ANSI color code support. Valid values are `"auto"` (parse and render ANSI SGR escape sequences) and `"none"` (disable). This is equivalent to using the `--color` command line flag. The command line flag takes precedence if both are specified.
|
||||
|
||||
Default value for Color is `"auto"` (enabled).
|
||||
|
||||
See [ANSI Color Support](#ansi-color-support) in the Features section for details.
|
||||
|
||||
### Height
|
||||
|
||||
```json
|
||||
{
|
||||
"Height": "10"
|
||||
}
|
||||
```
|
||||
|
||||
`Height` is equivalent to using `--height` on the command line. When set, peco renders inline at the bottom of the terminal instead of using the full screen. The value is the number of result lines (e.g. `"10"`) or a percentage of terminal height (e.g. `"50%"`). The command line `--height` option takes precedence over this config value.
|
||||
|
||||
## Keymaps
|
||||
|
||||
Example:
|
||||
|
|
@ -336,9 +650,24 @@ As a similar example, a common idiom in emacs is that `C-c C-c` means "take the
|
|||
|
||||
### Available keys
|
||||
|
||||
Since v0.1.8, in addition to values below, you may put a `M-` prefix on any
|
||||
Since v0.1.8, in addition to values below, you may put a `M-` prefix on any
|
||||
key item to use Alt/Option key as a mask.
|
||||
|
||||
You can also use `C-` and `S-` prefixes on navigation keys to bind Ctrl and Shift modified keys. Multiple modifiers can be combined. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"Keymap": {
|
||||
"C-ArrowLeft": "peco.BackwardWord",
|
||||
"C-ArrowRight": "peco.ForwardWord",
|
||||
"S-ArrowUp": "peco.SelectUp",
|
||||
"C-M-Delete": "peco.DeleteForwardWord"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `C-` on single characters (e.g. `C-a`) refers to ASCII control codes as before. `C-` as a modifier applies to navigation keys such as `ArrowLeft`, `Home`, `Delete`, etc.
|
||||
|
||||
| Name | Notes |
|
||||
|-------------|-------|
|
||||
| C-a ... C-z | Control + whatever character |
|
||||
|
|
@ -376,56 +705,67 @@ key item to use Alt/Option key as a mask.
|
|||
Some keys just... don't map correctly / too easily for various reasons. Here, we'll list possible workarounds for key sequences that are often asked for:
|
||||
|
||||
|
||||
| You want this | Use this instead | Notes |
|
||||
|---------------|------------------|------------------|
|
||||
| Shift+Tab | M-\[,Z | Verified on OS X |
|
||||
| You want this | Use this instead | Notes |
|
||||
|---------------|------------------|-------------------|
|
||||
| Shift+Tab | M-\[,Z | Verified on macOS |
|
||||
|
||||
**Note:** Due to the tcell migration, Shift+Tab is internally mapped to Tab. If you need a distinct Shift+Tab binding, use the `M-[,Z` key sequence in your config instead.
|
||||
|
||||
### Available actions
|
||||
|
||||
| Name | Notes |
|
||||
|------|-------|
|
||||
| peco.ForwardChar | Move caret forward 1 character |
|
||||
| peco.BackToInitialFilter | Switch to first filter in the list |
|
||||
| peco.BackwardChar | Move caret backward 1 character |
|
||||
| peco.ForwardWord | Move caret forward 1 word |
|
||||
| peco.BackwardWord | Move caret backward 1 word|
|
||||
| peco.BackToInitialFilter| Switch to first filter in the list |
|
||||
| peco.BackwardWord | Move caret backward 1 word |
|
||||
| peco.BeginningOfLine | Move caret to the beginning of line |
|
||||
| peco.EndOfLine | Move caret to the end of line |
|
||||
| peco.EndOfFile | Delete one character forward, otherwise exit from peco with failure status |
|
||||
| peco.DeleteForwardChar | Delete one character forward |
|
||||
| peco.Cancel | Exits from peco with failure status, or cancel select mode |
|
||||
| peco.CancelRangeMode | Finish selecting by range and cancel range selection |
|
||||
| peco.CancelSelectMode | (DEPRECATED) Alias to CancelRangeMode |
|
||||
| peco.DeleteAll | Delete all entered characters |
|
||||
| peco.DeleteBackwardChar | Delete one character backward |
|
||||
| peco.DeleteForwardWord | Delete one word forward |
|
||||
| peco.DeleteBackwardWord | Delete one word backward |
|
||||
| peco.DeleteForwardChar | Delete one character forward |
|
||||
| peco.DeleteForwardWord | Delete one word forward |
|
||||
| peco.EndOfFile | Delete one character forward, otherwise exit from peco with failure status |
|
||||
| peco.EndOfLine | Move caret to the end of line |
|
||||
| peco.Finish | Exits from peco with success status |
|
||||
| peco.ForwardChar | Move caret forward 1 character |
|
||||
| peco.ForwardWord | Move caret forward 1 word |
|
||||
| peco.FreezeResults | Freeze current results and clear the query to start a new filter on top |
|
||||
| peco.GoToNextSelection | Jump cursor to the next saved selection |
|
||||
| peco.GoToPreviousSelection | Jump cursor to the previous saved selection |
|
||||
| peco.InvertSelection | Inverts the selected lines |
|
||||
| peco.KillBeginningOfLine | Delete the characters under the cursor backward until the beginning of the line |
|
||||
| peco.KillEndOfLine | Delete the characters under the cursor until the end of the line |
|
||||
| peco.DeleteAll | Delete all entered characters |
|
||||
| peco.RefreshScreen | Redraws the screen. Note that this effectively re-runs your query |
|
||||
| peco.SelectPreviousPage | (DEPRECATED) Alias to ScrollPageUp |
|
||||
| peco.SelectNextPage | (DEPRECATED) Alias to ScrollPageDown |
|
||||
| peco.RotateFilter | Rotate between filters (by default, ignore-case/no-ignore-case) |
|
||||
| peco.ScrollFirstItem | Scrolls to the first item (in the entire buffer, not the current screen) |
|
||||
| peco.ScrollLastItem | Scrolls to the last item (in the entire buffer, not the current screen) |
|
||||
| peco.ScrollLeft | Scrolls the screen to the left |
|
||||
| peco.ScrollPageDown | Moves the selected line cursor for an entire page, downwards |
|
||||
| peco.ScrollPageUp | Moves the selected line cursor for an entire page, upwards |
|
||||
| peco.SelectUp | Moves the selected line cursor to one line above |
|
||||
| peco.SelectDown | Moves the selected line cursor to one line below |
|
||||
| peco.SelectPrevious | (DEPRECATED) Alias to SelectUp |
|
||||
| peco.SelectNext | (DEPRECATED) Alias to SelectDown |
|
||||
| peco.ScrollLeft | Scrolls the screen to the left |
|
||||
| peco.ScrollRight | Scrolls the screen to the right |
|
||||
| peco.SelectAll | Selects the all line, and save it |
|
||||
| peco.SelectDown | Moves the selected line cursor to one line below |
|
||||
| peco.SelectNext | (DEPRECATED) Alias to SelectDown |
|
||||
| peco.SelectNextPage | (DEPRECATED) Alias to ScrollPageDown |
|
||||
| peco.SelectNone | Remove all saved selections |
|
||||
| peco.SelectPrevious | (DEPRECATED) Alias to SelectUp |
|
||||
| peco.SelectPreviousPage | (DEPRECATED) Alias to ScrollPageUp |
|
||||
| peco.SelectUp | Moves the selected line cursor to one line above |
|
||||
| peco.SelectVisible | Selects the all visible line, and save it |
|
||||
| peco.ToggleQuery | Toggle list between filtered by query and not filtered |
|
||||
| peco.ToggleRangeMode | Start selecting by range, or append selecting range to selections |
|
||||
| peco.ToggleFollow | Toggle follow mode (auto-scroll to the newest lines, like tail -f) |
|
||||
| peco.ToggleSelectMode | (DEPRECATED) Alias to ToggleRangeMode |
|
||||
| peco.ToggleSelection | Selects the current line, and saves it |
|
||||
| peco.ToggleSelectionAndSelectNext | Selects the current line, saves it, and proceeds to the next line |
|
||||
| peco.ToggleSingleKeyJump | Enables SingleKeyJump mode a.k.a. "hit-a-hint" |
|
||||
| peco.SelectNone | Remove all saved selections |
|
||||
| peco.SelectAll | Selects the all line, and save it |
|
||||
| peco.SelectVisible | Selects the all visible line, and save it |
|
||||
| peco.ToggleSelectMode | (DEPRECATED) Alias to ToggleRangeMode |
|
||||
| peco.CancelSelectMode | (DEPRECATED) Alias to CancelRangeMode |
|
||||
| peco.ToggleQuery | Toggle list between filterd by query and not filterd. |
|
||||
| peco.ToggleRangeMode | Start selecting by range, or append selecting range to selections |
|
||||
| peco.CancelRangeMode | Finish selecting by range and cancel range selection |
|
||||
| peco.RotateMatcher | (DEPRECATED) Use peco.RotateFilter |
|
||||
| peco.RotateFilter | Rotate between filters (by default, ignore-case/no-ignore-case)|
|
||||
| peco.Finish | Exits from peco with success status |
|
||||
| peco.Cancel | Exits from peco with failure status, or cancel select mode |
|
||||
| peco.UnfreezeResults | Discard frozen results and revert to the original input |
|
||||
| peco.ViewAround | Toggle display of context lines around each match |
|
||||
| peco.ZoomIn | Expand filtered results with context lines around each match |
|
||||
| peco.ZoomOut | Collapse back to the filtered view (undo ZoomIn) |
|
||||
|
||||
|
||||
### Default Keymap
|
||||
|
|
@ -434,33 +774,35 @@ Note: If in case below keymap seems wrong, check the source code in [keymap.go](
|
|||
|
||||
|Key|Action|
|
||||
|---|------|
|
||||
|Esc|peco.Cancel|
|
||||
|C-c|peco.Cancel|
|
||||
|Enter|peco.Finish|
|
||||
|C-f|peco.ForwardChar|
|
||||
|C-a|peco.BeginningOfLine|
|
||||
|C-b|peco.BackwardChar|
|
||||
|C-d|peco.DeleteForwardChar|
|
||||
|C-e|peco.EndOfLine|
|
||||
|C-k|peco.KillEndOfLine|
|
||||
|C-u|peco.KillBeginningOfLine|
|
||||
|BS|peco.DeleteBackwardChar|
|
||||
|C-8|peco.DeleteBackwardChar|
|
||||
|C-w|peco.DeleteBackwardWord|
|
||||
|C-g|peco.SelectNone|
|
||||
|C-n|peco.SelectDown|
|
||||
|C-p|peco.SelectUp|
|
||||
|C-r|peco.RotateMatcher|
|
||||
|C-t|peco.ToggleQuery|
|
||||
|C-Space|peco.ToggleSelectionAndSelectNext|
|
||||
|ArrowUp|peco.SelectUp|
|
||||
|ArrowDown|peco.SelectDown|
|
||||
|ArrowLeft|peco.ScrollPageUp|
|
||||
|ArrowRight|peco.ScrollPageDown|
|
||||
|ArrowUp|peco.SelectUp|
|
||||
|BS|peco.DeleteBackwardChar|
|
||||
|C-8|peco.DeleteBackwardChar|
|
||||
|C-Space|peco.ToggleSelectionAndSelectNext|
|
||||
|C-a|peco.BeginningOfLine|
|
||||
|C-b|peco.BackwardChar|
|
||||
|C-c|peco.Cancel|
|
||||
|C-d|peco.DeleteForwardChar|
|
||||
|C-e|peco.EndOfLine|
|
||||
|C-f|peco.ForwardChar|
|
||||
|C-g|peco.SelectNone|
|
||||
|C-k|peco.KillEndOfLine|
|
||||
|C-n|peco.SelectDown|
|
||||
|C-p|peco.SelectUp|
|
||||
|C-r|peco.RotateFilter|
|
||||
|C-t|peco.ToggleQuery|
|
||||
|C-u|peco.KillBeginningOfLine|
|
||||
|C-w|peco.DeleteBackwardWord|
|
||||
|Enter|peco.Finish|
|
||||
|Esc|peco.Cancel|
|
||||
|Pgdn|peco.ScrollPageDown|
|
||||
|Pgup|peco.ScrollPageUp|
|
||||
|
||||
## Styles
|
||||
|
||||
For now, styles of following 5 items can be customized in `config.json`.
|
||||
Styles can be customized in `config.json`.
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -469,7 +811,10 @@ For now, styles of following 5 items can be customized in `config.json`.
|
|||
"SavedSelection": ["bold", "on_yellow", "white"],
|
||||
"Selected": ["underline", "on_cyan", "black"],
|
||||
"Query": ["yellow", "bold"],
|
||||
"Matched": ["red", "on_blue"]
|
||||
"QueryCursor": ["white", "on_red"],
|
||||
"Matched": ["red", "on_blue"],
|
||||
"Prompt": ["green", "bold"],
|
||||
"Context": ["bold"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -478,50 +823,68 @@ For now, styles of following 5 items can be customized in `config.json`.
|
|||
- `SavedSelection` for lines of saved selection
|
||||
- `Selected` for a currently selecting line
|
||||
- `Query` for a query line
|
||||
- `QueryCursor` for the cursor on the query line. If not specified, the cursor colors are derived automatically: when `Query` has custom colors, they are swapped (fg becomes bg and vice versa); otherwise, the terminal's reverse video attribute is used.
|
||||
- `Matched` for a query matched word
|
||||
- `Prompt` for the query prompt prefix (e.g., `QUERY>`)
|
||||
- `Context` for context lines shown by ZoomIn (default: bold)
|
||||
|
||||
### Foreground Colors
|
||||
|
||||
- `"black"` for `termbox.ColorBlack`
|
||||
- `"red"` for `termbox.ColorRed`
|
||||
- `"green"` for `termbox.ColorGreen`
|
||||
- `"yellow"` for `termbox.ColorYellow`
|
||||
- `"blue"` for `termbox.ColorBlue`
|
||||
- `"magenta"` for `termbox.ColorMagenta`
|
||||
- `"cyan"` for `termbox.ColorCyan`
|
||||
- `"white"` for `termbox.ColorWhite`
|
||||
- `"default"` for the terminal's default foreground color
|
||||
- `"black"` for `tcell.ColorBlack`
|
||||
- `"red"` for `tcell.ColorRed`
|
||||
- `"green"` for `tcell.ColorGreen`
|
||||
- `"yellow"` for `tcell.ColorYellow`
|
||||
- `"blue"` for `tcell.ColorBlue`
|
||||
- `"magenta"` for `tcell.ColorMagenta`
|
||||
- `"cyan"` for `tcell.ColorCyan`
|
||||
- `"white"` for `tcell.ColorWhite`
|
||||
- `"0"`-`"255"` for 256color (automatically supported via tcell)
|
||||
- `"#RRGGBB"` for 24-bit truecolor (e.g. `"#ff6600"`)
|
||||
|
||||
### Background Colors
|
||||
|
||||
- `"on_black"` for `termbox.ColorBlack`
|
||||
- `"on_red"` for `termbox.ColorRed`
|
||||
- `"on_green"` for `termbox.ColorGreen`
|
||||
- `"on_yellow"` for `termbox.ColorYellow`
|
||||
- `"on_blue"` for `termbox.ColorBlue`
|
||||
- `"on_magenta"` for `termbox.ColorMagenta`
|
||||
- `"on_cyan"` for `termbox.ColorCyan`
|
||||
- `"on_white"` for `termbox.ColorWhite`
|
||||
- `"on_default"` for the terminal's default background color
|
||||
- `"on_black"` for `tcell.ColorBlack`
|
||||
- `"on_red"` for `tcell.ColorRed`
|
||||
- `"on_green"` for `tcell.ColorGreen`
|
||||
- `"on_yellow"` for `tcell.ColorYellow`
|
||||
- `"on_blue"` for `tcell.ColorBlue`
|
||||
- `"on_magenta"` for `tcell.ColorMagenta`
|
||||
- `"on_cyan"` for `tcell.ColorCyan`
|
||||
- `"on_white"` for `tcell.ColorWhite`
|
||||
- `"on_0"`-`"on_255"` for 256color (automatically supported via tcell)
|
||||
- `"on_#RRGGBB"` for 24-bit truecolor (e.g. `"on_#003366"`)
|
||||
|
||||
### Attributes
|
||||
|
||||
- `"bold"` for fg: `termbox.AttrBold`
|
||||
- `"underline"` for fg: `termbox.AttrUnderline`
|
||||
- `"reverse"` for fg: `termbox.AttrReverse`
|
||||
- `"on_bold"` for bg: `termbox.AttrBold` (this attribute actually makes the background blink on some platforms/environments, e.g. linux console, xterm...)
|
||||
- `"bold"` for fg: `tcell.AttrBold`
|
||||
- `"underline"` for fg: `tcell.AttrUnderline`
|
||||
- `"reverse"` for fg: `tcell.AttrReverse`
|
||||
- `"on_bold"` for bg: `tcell.AttrBold` (this attribute actually makes the background blink on some platforms/environments, e.g. linux console, xterm...)
|
||||
|
||||
## CustomFilter
|
||||
|
||||
This is an experimental feature. Please note that some details of this specification may change
|
||||
By default `peco` comes with `IgnoreCase`, `CaseSensitive`, `SmartCase`, `IRegexp`, `Regexp` and `Fuzzy` filters, but since v0.1.3, it is possible to create your own custom filter.
|
||||
|
||||
By default `peco` comes with `IgnoreCase`, `CaseSensitive`, `SmartCase`, `Regexp` and `Fuzzy` filters, but since v0.1.3, it is possible to create your own custom filter.
|
||||
|
||||
The filter will be executed via `Command.Run()` as an external process, and it will be passed the query values in the command line, and the original unaltered buffer is passed via `os.Stdin`. Your filter must perform the matching, and print out to `os.Stdout` matched lines. You filter MAY be called multiple times if the buffer
|
||||
given to peco is big enough. See `BufferThreshold` below.
|
||||
The filter will be executed via `Command.Run()` as an external process, and it will be passed the query values in the command line, and the original unaltered buffer is passed via `os.Stdin`. Your filter must perform the matching, and print out to `os.Stdout` matched lines. Your filter MAY be called multiple times if the buffer given to peco is big enough. See `BufferThreshold` below.
|
||||
|
||||
Note that currently there is no way for the custom filter to specify where in the line the match occurred, so matched portions in the string WILL NOT BE HIGHLIGHTED.
|
||||
|
||||
The filter does not need to be a go program. It can be a perl/ruby/python/bash script, or anything else that is executable.
|
||||
|
||||
### Batching Behavior
|
||||
|
||||
Unlike the built-in filters (which process batches in parallel), external filters are invoked **sequentially**, one batch at a time. Each invocation receives a subset of the input lines on stdin, not the complete input. `BufferThreshold` controls how many lines are buffered before each invocation.
|
||||
|
||||
Because of this batching, your filter **must be stateless** — it cannot assume it sees all input lines in a single invocation. Each invocation is independent. Filters that require global context (e.g., sorting the entire input or counting total lines) will not work correctly, as they only see one batch per invocation.
|
||||
|
||||
A larger `BufferThreshold` means fewer invocations but a longer wait before results appear. A smaller threshold means more invocations but faster feedback.
|
||||
|
||||
Note that negative query terms (e.g., `-foo`) are NOT parsed by peco for external filters; the raw query string including any `-` prefixes is passed as-is to the external command via `$QUERY`.
|
||||
|
||||
### Configuration
|
||||
|
||||
Once you have a filter, you must specify how the matcher is spawned:
|
||||
|
||||
```json
|
||||
|
|
@ -536,19 +899,18 @@ Once you have a filter, you must specify how the matcher is spawned:
|
|||
}
|
||||
```
|
||||
|
||||
`Cmd` specifies the command name. This must be searcheable via `exec.LookPath`.
|
||||
`Cmd` specifies the command name. This must be searchable via `exec.LookPath`.
|
||||
|
||||
Elements in the `Args` section are string keys to array of program arguments. The special token `$QUERY` will be replaced with the unaltered query as the user typed in (i.e. multiple-word queries will be passed as a single string). You may pass in any other arguments in this array. If you omit this in your config, a default value of `[]string{"$QUERY"}` will be used
|
||||
Elements in the `Args` section are string keys to array of program arguments. The special token `$QUERY` will be replaced with the unaltered query as the user typed in (i.e. multiple-word queries will be passed as a single string). You may pass in any other arguments in this array. If you omit this in your config, a default value of `[]string{"$QUERY"}` will be used.
|
||||
|
||||
`BufferThreshold` specifies that the filter command should be invoked when peco has this many lines to process
|
||||
in the buffer. For example, if you are using peco against a 1000-line input, and your `BufferThreshold` is 100 (which is the default), then your filter will be invoked 10 times. For obvious reasons, the larger this threshold is, the faster the overall performance will be, but the longer you will have to wait to see the filter results.
|
||||
`BufferThreshold` specifies that the filter command should be invoked when peco has this many lines to process in the buffer. For example, if you are using peco against a 1000-line input, and your `BufferThreshold` is 100 (which is the default), then your filter will be invoked 10 times. The larger this threshold is, the faster the overall performance will be, but the longer you will have to wait to see the filter results.
|
||||
|
||||
You may specify as many filters as you like in the `CustomFilter` section.
|
||||
|
||||
### Examples
|
||||
|
||||
* [An example of a simple perl regexp matcher](https://gist.github.com/mattn/24712964da6e3112251c)
|
||||
* [An example using migemogrep Japanese grep using latin-1 chars](https://github.com/peco/peco/wiki/CustomMatcher)
|
||||
* [An example using migemogrep Japanese grep using latin-1 chars](https://github.com/peco/peco/wiki/CustomFilter)
|
||||
|
||||
## Layout
|
||||
|
||||
|
|
@ -585,13 +947,16 @@ No. https://github.com/peco/peco/issues/336#issuecomment-243939696
|
|||
|
||||
Are you using raster fonts? https://github.com/peco/peco/issues/341
|
||||
|
||||
## Seeing escape sequences `[200~` and `[201~` when pasting text?
|
||||
|
||||
Disable bracketed paste mode. https://github.com/peco/peco/issues/417
|
||||
|
||||
# Hacking
|
||||
|
||||
First, fork this repo, and get your clone locally.
|
||||
|
||||
1. Make sure you have [go](http://golang.org) installed, with GOPATH appropriately set
|
||||
2. Make sure you have `make` installed
|
||||
3. Run `make installdeps` (You only need to do this once, or when you update glide.yml/glide.lock)
|
||||
|
||||
To test, run
|
||||
|
||||
|
|
@ -615,9 +980,7 @@ which will create the binary in the local directory.
|
|||
|
||||
# TODO
|
||||
|
||||
Test it. In doing so, we may change the repo structure
|
||||
|
||||
Implement all(?) of the original percol options
|
||||
Unit test it.
|
||||
|
||||
# AUTHORS
|
||||
|
||||
|
|
@ -649,74 +1012,10 @@ Implement all(?) of the original percol options
|
|||
* Yousuke Ushiki
|
||||
* Linda\_pp
|
||||
* Tomohiro Nishimura (Sixeight)
|
||||
* Naruki Tanabe (narugit)
|
||||
|
||||
# Notes
|
||||
|
||||
Obviously, kudos to the original percol: https://github.com/mooz/percol
|
||||
Much code stolen from https://github.com/mattn/gof
|
||||
|
||||
# Table of Contents
|
||||
|
||||
* [peco](#peco)
|
||||
* [Description](#description)
|
||||
* [Demo](#demo)
|
||||
* [Features](#features)
|
||||
* [Incremental Search](#incremental-search)
|
||||
* [Select Multiple Lines](#select-multiple-lines)
|
||||
* [Select Range Of Lines](#select-range-of-lines)
|
||||
* [Select Filters](#select-filters)
|
||||
* [Selectable Layout](#selectable-layout)
|
||||
* [Works on Windows!](#works-on-windows)
|
||||
* [Installation](#installation)
|
||||
* [Just want the binary?](#just-want-the-binary)
|
||||
* [Mac OS X / Homebrew](#mac-os-x--homebrew)
|
||||
* [Windows (Chocolatey NuGet Users)](#windows-chocolatey-nuget-users)
|
||||
* [Building peco yourself](#building-peco-yourself)
|
||||
* [go get IS NOT RECOMMENDED](#go-get-is-not-recommended)
|
||||
* [Command Line Options](#command-line-options)
|
||||
* [-h, --help](#-h---help)
|
||||
* [--version](#--version)
|
||||
* [--query <query>](#--query-query)
|
||||
* [--rcfile <filename>](#--rcfile-filename)
|
||||
* [-b, --buffer-size <num>](#-b---buffer-size-num)
|
||||
* [--null](#--null)
|
||||
* [--initial-index](#--initial-index)
|
||||
* [--initial-filter `IgnoreCase|CaseSensitive|SmartCase|Regexp|Fuzzy`](#--initial-filter-ignorecasecasesensitivesmartcaseregexpfuzzy)
|
||||
* [--prompt](#--prompt)
|
||||
* [--layout `top-down|bottom-up`](#--layout-top-downbottom-up)
|
||||
* [--select-1](#--select-1)
|
||||
* [--on-cancel `success|error`](#--on-cancel-successerror)
|
||||
* [--selection-prefix `string`](#--selection-prefix-string)
|
||||
* [--exec `string`](#--exec-string)
|
||||
* [Configuration File](#configuration-file)
|
||||
* [Global](#global)
|
||||
* [Prompt](#prompt)
|
||||
* [InitialMatcher](#initialmatcher)
|
||||
* [InitialFilter](#initialfilter)
|
||||
* [StickySelection](#stickyselection)
|
||||
* [OnCancel](#oncancel)
|
||||
* [Keymaps](#keymaps)
|
||||
* [Key sequences](#key-sequences)
|
||||
* [Combined actions](#combined-actions)
|
||||
* [Available keys](#available-keys)
|
||||
* [Key workarounds](#key-workarounds)
|
||||
* [Available actions](#available-actions)
|
||||
* [Default Keymap](#default-keymap)
|
||||
* [Styles](#styles)
|
||||
* [Foreground Colors](#foreground-colors)
|
||||
* [Background Colors](#background-colors)
|
||||
* [Attributes](#attributes)
|
||||
* [CustomFilter](#customfilter)
|
||||
* [Examples](#examples)
|
||||
* [Layout](#layout)
|
||||
* [SingleKeyJump](#singlekeyjump)
|
||||
* [SelectionPrefix](#selectionprefix)
|
||||
* [FAQ](#faq)
|
||||
* [Does peco work on (msys2|cygwin)?](#does-peco-work-on-msys2cygwin)
|
||||
* [Non-latin fonts (e.g. Japanese) look weird on my Windows machine...?](#non-latin-fonts-eg-japanese-look-weird-on-my-windows-machine)
|
||||
* [Hacking](#hacking)
|
||||
* [TODO](#todo)
|
||||
* [AUTHORS](#authors)
|
||||
* [CONTRIBUTORS](#contributors)
|
||||
* [Notes](#notes)
|
||||
* [Table of Contents](#table-of-contents)
|
||||
|
|
|
|||
1186
action_test.go
1186
action_test.go
File diff suppressed because it is too large
Load diff
369
buffer.go
369
buffer.go
|
|
@ -1,25 +1,74 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"context"
|
||||
|
||||
"github.com/lestrrat/go-pdebug"
|
||||
"github.com/lestrrat-go/pdebug"
|
||||
runewidth "github.com/mattn/go-runewidth"
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Buffer interface is used for containers for lines to be
|
||||
// processed by peco. The unexported linesInRange method seals this
|
||||
// interface to the peco package — external packages cannot implement it.
|
||||
// This is intentional: linesInRange is an internal optimization for
|
||||
// efficient pagination in NewFilteredBuffer, not part of the public contract.
|
||||
type Buffer interface {
|
||||
linesInRange(int, int) []line.Line
|
||||
LineAt(int) (line.Line, error)
|
||||
Size() int
|
||||
}
|
||||
|
||||
// FilteredBuffer holds a "filtered" buffer. It holds a reference to
|
||||
// the source buffer (note: should be immutable) and a list of indices
|
||||
// into the source buffer
|
||||
type FilteredBuffer struct {
|
||||
maxcols int
|
||||
src Buffer
|
||||
selection []int // maps from our index to src's index
|
||||
}
|
||||
|
||||
// MemoryBuffer is an implementation of Buffer
|
||||
type MemoryBuffer struct {
|
||||
done chan struct{}
|
||||
doneOnce sync.Once
|
||||
lines []line.Line
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// ContextLine wraps a line.Line to mark it as a context line (non-matched
|
||||
// surrounding line shown during ZoomIn). Detected via type assertion in
|
||||
// ListArea.Draw() to apply the Context style.
|
||||
type ContextLine struct {
|
||||
line.Line
|
||||
}
|
||||
|
||||
// NewFilteredBuffer creates a FilteredBuffer containing one page of lines from
|
||||
// the source buffer, computing the maximum column width for horizontal scrolling.
|
||||
func NewFilteredBuffer(src Buffer, page, perPage int) *FilteredBuffer {
|
||||
return newFilteredBufferRange(src, perPage*(page-1), perPage)
|
||||
}
|
||||
|
||||
// newFilteredBufferRange creates a FilteredBuffer holding up to count lines
|
||||
// from the source buffer starting at the given index. This is the shared core
|
||||
// behind both page-aligned cropping (NewFilteredBuffer) and the sliding-window
|
||||
// crop used by follow mode (WindowCrop), where start is not page-aligned.
|
||||
func newFilteredBufferRange(src Buffer, start, count int) *FilteredBuffer {
|
||||
fb := FilteredBuffer{
|
||||
src: src,
|
||||
}
|
||||
|
||||
start := perPage * (page - 1)
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
// if for whatever reason we wanted a page that goes over the
|
||||
// if for whatever reason we wanted a range that goes over the
|
||||
// capacity of the original buffer, we don't need to do any more
|
||||
// calculations. bail out
|
||||
if start > src.Size() {
|
||||
|
|
@ -27,11 +76,8 @@ func NewFilteredBuffer(src Buffer, page, perPage int) *FilteredBuffer {
|
|||
}
|
||||
|
||||
// Copy over the selections that are applicable to this filtered buffer.
|
||||
selection := make([]int, 0, src.Size())
|
||||
end := start + perPage
|
||||
if end >= src.Size() {
|
||||
end = src.Size()
|
||||
}
|
||||
end := min(start+count, src.Size())
|
||||
selection := make([]int, 0, end-start)
|
||||
|
||||
lines := src.linesInRange(start, end)
|
||||
var maxcols int
|
||||
|
|
@ -58,8 +104,8 @@ func (flb *FilteredBuffer) MaxColumn() int {
|
|||
// in this filtered buffer may actually correspond to a totally
|
||||
// different line number in the source buffer.
|
||||
func (flb FilteredBuffer) LineAt(i int) (line.Line, error) {
|
||||
if i >= len(flb.selection) {
|
||||
return nil, errors.Errorf("specified index %d is out of range", len(flb.selection))
|
||||
if i < 0 || i >= len(flb.selection) {
|
||||
return nil, fmt.Errorf("specified index %d is out of range (size=%d)", i, len(flb.selection))
|
||||
}
|
||||
return flb.src.LineAt(flb.selection[i])
|
||||
}
|
||||
|
|
@ -69,22 +115,30 @@ func (flb FilteredBuffer) Size() int {
|
|||
return len(flb.selection)
|
||||
}
|
||||
|
||||
func NewMemoryBuffer() *MemoryBuffer {
|
||||
const defaultMemoryBufferCap = 1024
|
||||
|
||||
// NewMemoryBuffer creates a new MemoryBuffer. If cap > 0, the lines
|
||||
// slice is pre-allocated with that capacity; otherwise it defaults to
|
||||
// defaultMemoryBufferCap.
|
||||
func NewMemoryBuffer(capacity int) *MemoryBuffer {
|
||||
if capacity <= 0 {
|
||||
capacity = defaultMemoryBufferCap
|
||||
}
|
||||
mb := &MemoryBuffer{}
|
||||
mb.Reset()
|
||||
mb.done = make(chan struct{})
|
||||
mb.lines = make([]line.Line, 0, capacity)
|
||||
return mb
|
||||
}
|
||||
|
||||
// Size returns the number of lines currently held in the buffer, thread-safe.
|
||||
func (mb *MemoryBuffer) Size() int {
|
||||
mb.mutex.RLock()
|
||||
defer mb.mutex.RUnlock()
|
||||
return bufferSize(mb.lines)
|
||||
}
|
||||
|
||||
func bufferSize(lines []line.Line) int {
|
||||
return len(lines)
|
||||
return len(mb.lines)
|
||||
}
|
||||
|
||||
// Reset clears the buffer, reinitializing the done channel and lines slice
|
||||
// so the buffer can be reused for a new pipeline run.
|
||||
func (mb *MemoryBuffer) Reset() {
|
||||
mb.mutex.Lock()
|
||||
defer mb.mutex.Unlock()
|
||||
|
|
@ -92,26 +146,49 @@ func (mb *MemoryBuffer) Reset() {
|
|||
g := pdebug.Marker("MemoryBuffer.Reset")
|
||||
defer g.End()
|
||||
}
|
||||
// Return pooled Matched objects before discarding the slice
|
||||
for _, l := range mb.lines {
|
||||
if m, ok := l.(*line.Matched); ok {
|
||||
line.ReleaseMatched(m)
|
||||
}
|
||||
}
|
||||
mb.done = make(chan struct{})
|
||||
mb.doneOnce = sync.Once{}
|
||||
mb.lines = []line.Line(nil)
|
||||
}
|
||||
|
||||
// MarkComplete signals that the buffer is fully populated. It is safe
|
||||
// to call multiple times; only the first call closes the done channel.
|
||||
// Use this instead of manually closing the done channel when populating
|
||||
// a MemoryBuffer outside of the pipeline (e.g. freeze).
|
||||
func (mb *MemoryBuffer) MarkComplete() {
|
||||
mb.doneOnce.Do(func() {
|
||||
mb.mutex.Lock()
|
||||
close(mb.done)
|
||||
mb.mutex.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// Done returns a channel that is closed when the buffer has been fully populated.
|
||||
func (mb *MemoryBuffer) Done() <-chan struct{} {
|
||||
mb.mutex.RLock()
|
||||
defer mb.mutex.RUnlock()
|
||||
return mb.done
|
||||
}
|
||||
|
||||
func (mb *MemoryBuffer) Accept(ctx context.Context, in chan interface{}, _ pipeline.ChanOutput) {
|
||||
// Accept receives lines from a pipeline input channel and appends them to the
|
||||
// buffer in batches. It marks the buffer complete when the channel closes or
|
||||
// the context is cancelled.
|
||||
func (mb *MemoryBuffer) Accept(ctx context.Context, in <-chan line.Line, _ pipeline.ChanOutput) {
|
||||
if pdebug.Enabled {
|
||||
g := pdebug.Marker("MemoryBuffer.Accept")
|
||||
defer g.End()
|
||||
}
|
||||
defer func() {
|
||||
mb.mutex.Lock()
|
||||
close(mb.done)
|
||||
mb.mutex.Unlock()
|
||||
}()
|
||||
defer mb.MarkComplete()
|
||||
|
||||
// batch collects lines from the channel so we can append them
|
||||
// under a single lock acquisition instead of locking per line.
|
||||
batch := make([]line.Line, 0, 256)
|
||||
|
||||
start := time.Now()
|
||||
for {
|
||||
|
|
@ -121,36 +198,75 @@ func (mb *MemoryBuffer) Accept(ctx context.Context, in chan interface{}, _ pipel
|
|||
pdebug.Printf("MemoryBuffer received context done")
|
||||
}
|
||||
return
|
||||
case v := <-in:
|
||||
switch v.(type) {
|
||||
case error:
|
||||
if pipeline.IsEndMark(v.(error)) {
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("MemoryBuffer received end mark (read %d lines, %s since starting accept loop)", len(mb.lines), time.Since(start).String())
|
||||
}
|
||||
return
|
||||
case v, ok := <-in:
|
||||
if !ok {
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("MemoryBuffer input channel closed (read %d lines, %s since starting accept loop)", len(mb.lines)+len(batch), time.Since(start).String())
|
||||
}
|
||||
case line.Line:
|
||||
mb.mutex.Lock()
|
||||
mb.lines = append(mb.lines, v.(line.Line))
|
||||
mb.mutex.Unlock()
|
||||
// Flush remaining batch
|
||||
if len(batch) > 0 {
|
||||
mb.mutex.Lock()
|
||||
mb.lines = append(mb.lines, batch...)
|
||||
mb.mutex.Unlock()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
batch = append(batch, v)
|
||||
|
||||
// Drain any additional ready values without blocking
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case v2, ok2 := <-in:
|
||||
if !ok2 {
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("MemoryBuffer input channel closed (read %d lines, %s since starting accept loop)", len(mb.lines)+len(batch), time.Since(start).String())
|
||||
}
|
||||
mb.mutex.Lock()
|
||||
mb.lines = append(mb.lines, batch...)
|
||||
mb.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
batch = append(batch, v2)
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
}
|
||||
|
||||
// Flush the batch
|
||||
mb.mutex.Lock()
|
||||
mb.lines = append(mb.lines, batch...)
|
||||
mb.mutex.Unlock()
|
||||
batch = batch[:0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AppendLine adds a line to the buffer. This is used by the benchmark tool
|
||||
// to populate a MemoryBuffer that will be used as a pipeline source.
|
||||
func (mb *MemoryBuffer) AppendLine(l line.Line) {
|
||||
mb.mutex.Lock()
|
||||
mb.lines = append(mb.lines, l)
|
||||
mb.mutex.Unlock()
|
||||
}
|
||||
|
||||
// LineAt returns the line at the given index, thread-safe.
|
||||
func (mb *MemoryBuffer) LineAt(n int) (line.Line, error) {
|
||||
mb.mutex.RLock()
|
||||
defer mb.mutex.RUnlock()
|
||||
return bufferLineAt(mb.lines, n)
|
||||
}
|
||||
|
||||
// linesInRange returns a slice of lines between start and end indices, thread-safe.
|
||||
func (mb *MemoryBuffer) linesInRange(start, end int) []line.Line {
|
||||
mb.mutex.RLock()
|
||||
defer mb.mutex.RUnlock()
|
||||
return mb.lines[start:end]
|
||||
}
|
||||
|
||||
// bufferLineAt is a shared helper that retrieves a line by index from a raw
|
||||
// line slice, returning an error if the index is out of bounds.
|
||||
func bufferLineAt(lines []line.Line, n int) (line.Line, error) {
|
||||
if s := len(lines); s <= 0 || n >= s {
|
||||
return nil, errors.New("empty buffer")
|
||||
|
|
@ -158,3 +274,184 @@ func bufferLineAt(lines []line.Line, n int) (line.Line, error) {
|
|||
|
||||
return lines[n], nil
|
||||
}
|
||||
|
||||
// ContextBuffer holds an expanded view of a filtered buffer, inserting
|
||||
// context lines from the source around each match (similar to grep -C).
|
||||
type ContextBuffer struct {
|
||||
entries []line.Line
|
||||
maxcols int
|
||||
filtered Buffer // original filtered buffer, for reference
|
||||
matchEntryIndices []int // maps filtered buffer index -> entries index
|
||||
}
|
||||
|
||||
// NewContextBuffer builds a ContextBuffer by expanding the filtered buffer
|
||||
// with contextSize lines of surrounding context from the source.
|
||||
func NewContextBuffer(filtered Buffer, source Buffer, contextSize int) *ContextBuffer {
|
||||
cb := &ContextBuffer{
|
||||
filtered: filtered,
|
||||
}
|
||||
|
||||
filteredSize := filtered.Size()
|
||||
if filteredSize == 0 {
|
||||
return cb
|
||||
}
|
||||
|
||||
sourceSize := source.Size()
|
||||
|
||||
// Step 1: Collect source indices of matched lines
|
||||
type matchInfo struct {
|
||||
srcIdx int
|
||||
filteredIdx int
|
||||
line line.Line
|
||||
}
|
||||
matches := make([]matchInfo, 0, filteredSize)
|
||||
for i := range filteredSize {
|
||||
l, err := filtered.LineAt(i)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
srcIdx := int(l.ID())
|
||||
matches = append(matches, matchInfo{srcIdx: srcIdx, filteredIdx: i, line: l})
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
return cb
|
||||
}
|
||||
|
||||
// Step 2: Compute context ranges and merge overlapping ones
|
||||
type contextRange struct {
|
||||
start int // inclusive
|
||||
end int // inclusive
|
||||
}
|
||||
ranges := make([]contextRange, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
start := max(m.srcIdx-contextSize, 0)
|
||||
end := m.srcIdx + contextSize
|
||||
if end >= sourceSize {
|
||||
end = sourceSize - 1
|
||||
}
|
||||
ranges = append(ranges, contextRange{start: start, end: end})
|
||||
}
|
||||
|
||||
// Merge overlapping/adjacent ranges
|
||||
merged := []contextRange{ranges[0]}
|
||||
for i := 1; i < len(ranges); i++ {
|
||||
last := &merged[len(merged)-1]
|
||||
if ranges[i].start <= last.end+1 {
|
||||
if ranges[i].end > last.end {
|
||||
last.end = ranges[i].end
|
||||
}
|
||||
} else {
|
||||
merged = append(merged, ranges[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Build a set of matched source indices for quick lookup
|
||||
matchedSet := make(map[int]int, len(matches)) // srcIdx -> matches slice index
|
||||
for i, m := range matches {
|
||||
matchedSet[m.srcIdx] = i
|
||||
}
|
||||
|
||||
// Step 4: Build entries
|
||||
cb.entries = make([]line.Line, 0, merged[len(merged)-1].end-merged[0].start+1)
|
||||
cb.matchEntryIndices = make([]int, filteredSize)
|
||||
// Initialize to -1 so unmapped entries are obvious
|
||||
for i := range cb.matchEntryIndices {
|
||||
cb.matchEntryIndices[i] = -1
|
||||
}
|
||||
|
||||
for _, r := range merged {
|
||||
for idx := r.start; idx <= r.end; idx++ {
|
||||
if mi, ok := matchedSet[idx]; ok {
|
||||
// Use the filtered (matched) line, preserving match highlighting
|
||||
cb.matchEntryIndices[matches[mi].filteredIdx] = len(cb.entries)
|
||||
cb.entries = append(cb.entries, matches[mi].line)
|
||||
} else {
|
||||
// Context line from source
|
||||
srcLine, err := source.LineAt(idx)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cb.entries = append(cb.entries, &ContextLine{srcLine})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Compute maxcols
|
||||
for _, l := range cb.entries {
|
||||
cols := runewidth.StringWidth(l.DisplayString())
|
||||
if cols > cb.maxcols {
|
||||
cb.maxcols = cols
|
||||
}
|
||||
}
|
||||
|
||||
return cb
|
||||
}
|
||||
|
||||
// Size returns the number of lines in the context buffer.
|
||||
func (cb *ContextBuffer) Size() int {
|
||||
return len(cb.entries)
|
||||
}
|
||||
|
||||
// LineAt returns the line at index i.
|
||||
func (cb *ContextBuffer) LineAt(i int) (line.Line, error) {
|
||||
if i < 0 || i >= len(cb.entries) {
|
||||
return nil, fmt.Errorf("specified index %d is out of range (size=%d)", i, len(cb.entries))
|
||||
}
|
||||
return cb.entries[i], nil
|
||||
}
|
||||
|
||||
// linesInRange returns a slice of entries between start and end indices.
|
||||
func (cb *ContextBuffer) linesInRange(start, end int) []line.Line {
|
||||
return cb.entries[start:end]
|
||||
}
|
||||
|
||||
// MaxColumn returns the max column size for horizontal scrolling.
|
||||
func (cb *ContextBuffer) MaxColumn() int {
|
||||
return cb.maxcols
|
||||
}
|
||||
|
||||
// MatchEntryIndices returns the mapping from filtered buffer index to
|
||||
// entries index. Used by ZoomIn to map the cursor position.
|
||||
func (cb *ContextBuffer) MatchEntryIndices() []int {
|
||||
return cb.matchEntryIndices
|
||||
}
|
||||
|
||||
// MemoryBufferSource wraps a completed MemoryBuffer as a pipeline.Source,
|
||||
// allowing previous filter results to be reused as the input for
|
||||
// incremental filtering.
|
||||
type MemoryBufferSource struct {
|
||||
buf *MemoryBuffer
|
||||
}
|
||||
|
||||
// NewMemoryBufferSource creates a new MemoryBufferSource from an existing
|
||||
// MemoryBuffer. The buffer should be fully populated (pipeline completed).
|
||||
func NewMemoryBufferSource(buf *MemoryBuffer) *MemoryBufferSource {
|
||||
return &MemoryBufferSource{buf: buf}
|
||||
}
|
||||
|
||||
// Start iterates through the MemoryBuffer's lines and sends them
|
||||
// individually to the output channel, implementing pipeline.Source.
|
||||
// The output channel is closed when all lines have been sent.
|
||||
func (s *MemoryBufferSource) Start(ctx context.Context, out pipeline.ChanOutput) {
|
||||
defer close(out)
|
||||
|
||||
s.buf.mutex.RLock()
|
||||
lines := s.buf.lines
|
||||
s.buf.mutex.RUnlock()
|
||||
|
||||
for _, l := range lines {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
if err := out.Send(ctx, l); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset is a no-op for MemoryBufferSource since the underlying buffer
|
||||
// is immutable (from a completed pipeline run).
|
||||
func (s *MemoryBufferSource) Reset() {}
|
||||
|
|
|
|||
19
caret.go
19
caret.go
|
|
@ -1,19 +0,0 @@
|
|||
package peco
|
||||
|
||||
func (c *Caret) Pos() int {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
return c.pos
|
||||
}
|
||||
|
||||
func (c *Caret) SetPos(p int) {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
c.pos = p
|
||||
}
|
||||
|
||||
func (c *Caret) Move(diff int) {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
c.pos = c.pos + diff
|
||||
}
|
||||
462
cmd/filterbench/main.go
Normal file
462
cmd/filterbench/main.go
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
// filterbench measures the query performance of peco's filter implementations
|
||||
// against synthetically generated datasets of configurable size.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/filterbench [flags]
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// go run ./cmd/filterbench -lines 1000000 -query "foo"
|
||||
// go run ./cmd/filterbench -lines 500000 -query "foo bar" -filter IgnoreCase
|
||||
// go run ./cmd/filterbench -lines 1000000 -query "f" -incremental "fo,foo,foob,fooba,foobar"
|
||||
// go run ./cmd/filterbench -lines 1000000 -pipeline -filter IgnoreCase -query "foo"
|
||||
// go run ./cmd/filterbench -lines 1000000 -input /path/to/largefile.txt
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
peco "github.com/peco/peco"
|
||||
"github.com/peco/peco/filter"
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
)
|
||||
|
||||
type result struct {
|
||||
Filter string `json:"filter"`
|
||||
Query string `json:"query"`
|
||||
Lines int `json:"lines"`
|
||||
Matches int `json:"matches"`
|
||||
Duration string `json:"duration"`
|
||||
DurationMs float64 `json:"duration_ms"`
|
||||
LinesPerSec float64 `json:"lines_per_sec"`
|
||||
Mode string `json:"mode"` // "direct", "pipeline", "FULL", "INCR"
|
||||
}
|
||||
|
||||
type benchConfig struct {
|
||||
numLines int
|
||||
query string
|
||||
incremental string
|
||||
filterName string
|
||||
inputFile string
|
||||
jsonOutput bool
|
||||
seed uint64
|
||||
lineLen int
|
||||
usePipeline bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
var cfg benchConfig
|
||||
|
||||
flag.IntVar(&cfg.numLines, "lines", 1_000_000, "number of lines to generate")
|
||||
flag.StringVar(&cfg.query, "query", "foo", "query string to filter with")
|
||||
flag.StringVar(&cfg.incremental, "incremental", "", "comma-separated queries to run in sequence (simulates typing)")
|
||||
flag.StringVar(&cfg.filterName, "filter", "", "filter to test (empty = all filters)")
|
||||
flag.StringVar(&cfg.inputFile, "input", "", "read lines from file instead of generating them")
|
||||
flag.BoolVar(&cfg.jsonOutput, "json", false, "output results as JSON")
|
||||
flag.Uint64Var(&cfg.seed, "seed", 42, "random seed for data generation")
|
||||
flag.IntVar(&cfg.lineLen, "line-length", 80, "average length of generated lines")
|
||||
flag.BoolVar(&cfg.usePipeline, "pipeline", false, "use full pipeline (Source -> filterProcessor -> MemoryBuffer) instead of direct Apply")
|
||||
flag.Parse()
|
||||
|
||||
lines := loadOrGenerate(cfg)
|
||||
|
||||
filters := buildFilters(cfg.filterName)
|
||||
if len(filters) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "unknown filter: %s\n", cfg.filterName)
|
||||
fmt.Fprintf(os.Stderr, "available: IgnoreCase, CaseSensitive, SmartCase, Regexp, IRegexp, Fuzzy, FuzzyLongest\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
queries := buildQueries(cfg)
|
||||
|
||||
if !cfg.jsonOutput {
|
||||
fmt.Fprintf(os.Stderr, "Dataset: %d lines\n", len(lines))
|
||||
fmt.Fprintf(os.Stderr, "Filters: %d\n", len(filters))
|
||||
fmt.Fprintf(os.Stderr, "Queries: %v\n", queries)
|
||||
fmt.Fprintf(os.Stderr, "GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
|
||||
fmt.Fprintf(os.Stderr, "Pipeline: %v\n\n", cfg.usePipeline)
|
||||
}
|
||||
|
||||
var results []result
|
||||
|
||||
if cfg.incremental != "" {
|
||||
for name, f := range filters {
|
||||
results = append(results, benchIncremental(name, f, lines, queries, cfg)...)
|
||||
}
|
||||
} else {
|
||||
for name, f := range filters {
|
||||
for _, q := range queries {
|
||||
var r result
|
||||
if cfg.usePipeline {
|
||||
r = benchPipeline(name, f, lines, q)
|
||||
} else {
|
||||
r = benchDirect(name, f, lines, q)
|
||||
}
|
||||
results = append(results, r)
|
||||
if !cfg.jsonOutput {
|
||||
printResult(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.jsonOutput {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(results); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to encode results: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildQueries(cfg benchConfig) []string {
|
||||
if cfg.incremental != "" {
|
||||
return strings.Split(cfg.incremental, ",")
|
||||
}
|
||||
return []string{cfg.query}
|
||||
}
|
||||
|
||||
func buildFilters(name string) map[string]filter.Filter {
|
||||
all := map[string]filter.Filter{
|
||||
"IgnoreCase": filter.NewIgnoreCase(),
|
||||
"CaseSensitive": filter.NewCaseSensitive(),
|
||||
"SmartCase": filter.NewSmartCase(),
|
||||
"Regexp": filter.NewRegexp(),
|
||||
"IRegexp": filter.NewIRegexp(),
|
||||
"Fuzzy": filter.NewFuzzy(false),
|
||||
"FuzzyLongest": filter.NewFuzzy(true),
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return all
|
||||
}
|
||||
|
||||
if f, ok := all[name]; ok {
|
||||
return map[string]filter.Filter{name: f}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadOrGenerate(cfg benchConfig) []line.Line {
|
||||
if cfg.inputFile != "" {
|
||||
return loadFromFile(cfg.inputFile)
|
||||
}
|
||||
return generateLines(cfg)
|
||||
}
|
||||
|
||||
func loadFromFile(path string) []line.Line {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error opening file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var lines []line.Line
|
||||
scanner := bufio.NewScanner(f)
|
||||
// Allow long lines
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
var id uint64
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, line.NewRaw(id, scanner.Text(), false, false))
|
||||
id++
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error reading file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// generateLines creates a synthetic dataset. The data is designed so that:
|
||||
// - Common short queries (e.g. "f", "fo") match many lines
|
||||
// - Longer queries (e.g. "foobar") match progressively fewer lines
|
||||
// - Lines have realistic variety in length and content
|
||||
func generateLines(cfg benchConfig) []line.Line {
|
||||
rng := rand.New(rand.NewPCG(cfg.seed, cfg.seed+1))
|
||||
|
||||
// Words that will be scattered through the corpus.
|
||||
// "foo", "bar", "baz" appear frequently so short queries hit many lines.
|
||||
// Longer compound words like "foobar" appear rarely.
|
||||
words := []struct {
|
||||
word string
|
||||
freq float64 // probability of appearing in a line
|
||||
}{
|
||||
{"foo", 0.30},
|
||||
{"bar", 0.25},
|
||||
{"baz", 0.15},
|
||||
{"qux", 0.10},
|
||||
{"foobar", 0.03},
|
||||
{"foobaz", 0.02},
|
||||
{"foobarbaz", 0.005},
|
||||
}
|
||||
|
||||
// Base character set for random filler
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789_-./: "
|
||||
|
||||
lines := make([]line.Line, cfg.numLines)
|
||||
for i := range lines {
|
||||
var sb strings.Builder
|
||||
targetLen := cfg.lineLen/2 + rng.IntN(cfg.lineLen)
|
||||
|
||||
for sb.Len() < targetLen {
|
||||
// Randomly insert a known word
|
||||
for _, w := range words {
|
||||
if rng.Float64() < w.freq*0.3 { // Scale down per-character check
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteByte(' ')
|
||||
}
|
||||
sb.WriteString(w.word)
|
||||
}
|
||||
}
|
||||
// Add random filler
|
||||
n := 3 + rng.IntN(8)
|
||||
for j := 0; j < n && sb.Len() < targetLen; j++ {
|
||||
sb.WriteByte(alphabet[rng.IntN(len(alphabet))])
|
||||
}
|
||||
if sb.Len() < targetLen {
|
||||
sb.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
|
||||
lines[i] = line.NewRaw(uint64(i), sb.String(), false, false)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// benchDirect runs a single filter+query combination using direct Apply() and returns timing results.
|
||||
func benchDirect(name string, f filter.Filter, lines []line.Line, query string) result {
|
||||
ctx := f.NewContext(context.Background(), query)
|
||||
|
||||
// Count matches using a channel consumer
|
||||
matchCount := 0
|
||||
ch := make(chan line.Line, 4096)
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
for range ch {
|
||||
matchCount++
|
||||
}
|
||||
}()
|
||||
|
||||
out := pipeline.ChanOutput(ch)
|
||||
|
||||
// Force GC before measurement
|
||||
runtime.GC()
|
||||
|
||||
start := time.Now()
|
||||
err := f.Apply(ctx, lines, out)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
close(ch)
|
||||
<-done
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %s/%s: %v\n", name, query, err)
|
||||
}
|
||||
|
||||
lps := float64(len(lines)) / elapsed.Seconds()
|
||||
|
||||
return result{
|
||||
Filter: name,
|
||||
Query: query,
|
||||
Lines: len(lines),
|
||||
Matches: matchCount,
|
||||
Duration: elapsed.String(),
|
||||
DurationMs: float64(elapsed.Milliseconds()),
|
||||
LinesPerSec: lps,
|
||||
Mode: "direct",
|
||||
}
|
||||
}
|
||||
|
||||
// benchPipeline runs a filter using the full pipeline (Source -> filterProcessor -> MemoryBuffer).
|
||||
func benchPipeline(name string, f filter.Filter, lines []line.Line, query string) result {
|
||||
ctx := f.NewContext(context.Background(), query)
|
||||
|
||||
// Build a MemoryBuffer with all lines to use as source
|
||||
srcBuf := peco.NewMemoryBuffer(0)
|
||||
for _, l := range lines {
|
||||
func() {
|
||||
srcBuf.AppendLine(l)
|
||||
}()
|
||||
}
|
||||
src := peco.NewMemoryBufferSource(srcBuf)
|
||||
|
||||
// Setup pipeline
|
||||
p := pipeline.New()
|
||||
p.SetSource(src)
|
||||
p.Add(&directFilterProcessor{filter: f, query: query})
|
||||
|
||||
dst := peco.NewMemoryBuffer(len(lines) / 4)
|
||||
p.SetDestination(dst)
|
||||
|
||||
runtime.GC()
|
||||
start := time.Now()
|
||||
|
||||
if err := p.Run(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %s/%s: %v\n", name, query, err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
matchCount := dst.Size()
|
||||
lps := float64(len(lines)) / elapsed.Seconds()
|
||||
|
||||
return result{
|
||||
Filter: name,
|
||||
Query: query,
|
||||
Lines: len(lines),
|
||||
Matches: matchCount,
|
||||
Duration: elapsed.String(),
|
||||
DurationMs: float64(elapsed.Milliseconds()),
|
||||
LinesPerSec: lps,
|
||||
Mode: "pipeline",
|
||||
}
|
||||
}
|
||||
|
||||
// isQueryRefinement checks whether new is a refinement of prev.
|
||||
func isQueryRefinement(prev, cur string) bool {
|
||||
prev = strings.TrimSpace(prev)
|
||||
cur = strings.TrimSpace(cur)
|
||||
if prev == "" || cur == "" {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(cur, prev)
|
||||
}
|
||||
|
||||
// benchIncremental runs a sequence of queries, using previous results as source when the query is a refinement.
|
||||
func benchIncremental(name string, f filter.Filter, allLines []line.Line, queries []string, cfg benchConfig) []result {
|
||||
results := make([]result, 0, len(queries))
|
||||
var cumulative time.Duration
|
||||
|
||||
var prevQuery string
|
||||
var prevResults []line.Line
|
||||
|
||||
for _, q := range queries {
|
||||
var sourceLines []line.Line
|
||||
var mode string
|
||||
|
||||
// Check if we can use incremental filtering
|
||||
if prevResults != nil && isQueryRefinement(prevQuery, q) {
|
||||
sourceLines = prevResults
|
||||
mode = "INCR"
|
||||
} else {
|
||||
sourceLines = allLines
|
||||
mode = "FULL"
|
||||
}
|
||||
|
||||
var r result
|
||||
if cfg.usePipeline {
|
||||
r = benchPipelineOnLines(name, f, sourceLines, q)
|
||||
} else {
|
||||
r = benchDirect(name, f, sourceLines, q)
|
||||
}
|
||||
r.Mode = mode
|
||||
cumulative += time.Duration(r.DurationMs * float64(time.Millisecond))
|
||||
|
||||
if !cfg.jsonOutput {
|
||||
fmt.Printf("[%s] ", mode)
|
||||
printResult(r)
|
||||
fmt.Printf(" source lines: %d cumulative: %s\n", len(sourceLines), cumulative)
|
||||
}
|
||||
|
||||
results = append(results, r)
|
||||
|
||||
// Save results for next iteration
|
||||
prevQuery = q
|
||||
prevResults = collectMatchedLines(f, sourceLines, q)
|
||||
}
|
||||
|
||||
if !cfg.jsonOutput {
|
||||
fmt.Printf("\nCumulative time: %s\n\n", cumulative)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// collectMatchedLines runs the filter and returns matched lines as a slice.
|
||||
func collectMatchedLines(f filter.Filter, lines []line.Line, query string) []line.Line {
|
||||
ctx := f.NewContext(context.Background(), query)
|
||||
ch := make(chan line.Line, 4096)
|
||||
done := make(chan struct{})
|
||||
|
||||
var matched []line.Line
|
||||
go func() {
|
||||
defer close(done)
|
||||
for l := range ch {
|
||||
matched = append(matched, l)
|
||||
}
|
||||
}()
|
||||
|
||||
f.Apply(ctx, lines, pipeline.ChanOutput(ch))
|
||||
close(ch)
|
||||
<-done
|
||||
return matched
|
||||
}
|
||||
|
||||
// benchPipelineOnLines runs a filter through the pipeline on a specific set of lines.
|
||||
func benchPipelineOnLines(name string, f filter.Filter, lines []line.Line, query string) result {
|
||||
ctx := f.NewContext(context.Background(), query)
|
||||
|
||||
srcBuf := peco.NewMemoryBuffer(0)
|
||||
for _, l := range lines {
|
||||
srcBuf.AppendLine(l)
|
||||
}
|
||||
src := peco.NewMemoryBufferSource(srcBuf)
|
||||
|
||||
p := pipeline.New()
|
||||
p.SetSource(src)
|
||||
p.Add(&directFilterProcessor{filter: f, query: query})
|
||||
|
||||
dst := peco.NewMemoryBuffer(len(lines) / 4)
|
||||
p.SetDestination(dst)
|
||||
|
||||
runtime.GC()
|
||||
start := time.Now()
|
||||
|
||||
if err := p.Run(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %s/%s: %v\n", name, query, err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
matchCount := dst.Size()
|
||||
lps := float64(len(lines)) / elapsed.Seconds()
|
||||
|
||||
return result{
|
||||
Filter: name,
|
||||
Query: query,
|
||||
Lines: len(lines),
|
||||
Matches: matchCount,
|
||||
Duration: elapsed.String(),
|
||||
DurationMs: float64(elapsed.Milliseconds()),
|
||||
LinesPerSec: lps,
|
||||
}
|
||||
}
|
||||
|
||||
// directFilterProcessor wraps a filter.Filter for use in a pipeline, using the
|
||||
// same acceptAndFilter mechanism as peco's filterProcessor.
|
||||
type directFilterProcessor struct {
|
||||
filter filter.Filter
|
||||
query string
|
||||
}
|
||||
|
||||
func (p *directFilterProcessor) Accept(ctx context.Context, in <-chan line.Line, out pipeline.ChanOutput) {
|
||||
peco.AcceptAndFilter(ctx, p.filter, 0, in, out)
|
||||
}
|
||||
|
||||
func printResult(r result) {
|
||||
fmt.Printf("%-16s query=%-20s %d lines %6d matches %10s (%.0f lines/sec)\n",
|
||||
r.Filter, fmt.Sprintf("%q", r.Query), r.Lines, r.Matches, r.Duration, r.LinesPerSec)
|
||||
}
|
||||
|
|
@ -3,11 +3,10 @@ package main
|
|||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"context"
|
||||
|
||||
pdebug "github.com/lestrrat/go-pdebug"
|
||||
"github.com/lestrrat-go/pdebug"
|
||||
"github.com/peco/peco"
|
||||
"github.com/peco/peco/internal/util"
|
||||
)
|
||||
|
|
@ -26,9 +25,6 @@ func _main() int {
|
|||
if pdebug.Enabled {
|
||||
pdebug.DefaultCtx.Writer = os.Stderr
|
||||
}
|
||||
if envvar := os.Getenv("GOMAXPROCS"); envvar == "" {
|
||||
runtime.GOMAXPROCS(runtime.NumCPU())
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
|
|
@ -47,7 +43,6 @@ func _main() int {
|
|||
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return 0
|
||||
|
|
|
|||
210
config.go
210
config.go
|
|
@ -1,210 +0,0 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/nsf/termbox-go"
|
||||
"github.com/peco/peco/filter"
|
||||
"github.com/peco/peco/internal/util"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var homedirFunc = util.Homedir
|
||||
|
||||
// NewConfig creates a new Config
|
||||
func (c *Config) Init() error {
|
||||
c.Keymap = make(map[string]string)
|
||||
c.InitialMatcher = IgnoreCaseMatch
|
||||
c.Style.Init()
|
||||
c.Prompt = "QUERY>"
|
||||
c.Layout = LayoutTypeTopDown
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadFilename reads the config from the given file, and
|
||||
// does the appropriate processing, if any
|
||||
func (c *Config) ReadFilename(filename string) error {
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to open file %s", filename)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
err = json.NewDecoder(f).Decode(c)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to decode JSON")
|
||||
}
|
||||
|
||||
if !IsValidLayoutType(LayoutType(c.Layout)) {
|
||||
return errors.Errorf("invalid layout type: %s", c.Layout)
|
||||
}
|
||||
|
||||
if len(c.CustomMatcher) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "'CustomMatcher' is deprecated. Use CustomFilter instead\n")
|
||||
|
||||
for n, cfg := range c.CustomMatcher {
|
||||
if _, ok := c.CustomFilter[n]; ok {
|
||||
return errors.Errorf("failed to create CustomFilter: '%s' already exists. Refusing to overwrite with deprecated CustomMatcher config", n)
|
||||
}
|
||||
|
||||
c.CustomFilter[n] = CustomFilterConfig{
|
||||
Cmd: cfg[0],
|
||||
Args: cfg[1:],
|
||||
BufferThreshold: filter.DefaultCustomFilterBufferThreshold,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
stringToFg = map[string]termbox.Attribute{
|
||||
"default": termbox.ColorDefault,
|
||||
"black": termbox.ColorBlack,
|
||||
"red": termbox.ColorRed,
|
||||
"green": termbox.ColorGreen,
|
||||
"yellow": termbox.ColorYellow,
|
||||
"blue": termbox.ColorBlue,
|
||||
"magenta": termbox.ColorMagenta,
|
||||
"cyan": termbox.ColorCyan,
|
||||
"white": termbox.ColorWhite,
|
||||
}
|
||||
stringToBg = map[string]termbox.Attribute{
|
||||
"on_default": termbox.ColorDefault,
|
||||
"on_black": termbox.ColorBlack,
|
||||
"on_red": termbox.ColorRed,
|
||||
"on_green": termbox.ColorGreen,
|
||||
"on_yellow": termbox.ColorYellow,
|
||||
"on_blue": termbox.ColorBlue,
|
||||
"on_magenta": termbox.ColorMagenta,
|
||||
"on_cyan": termbox.ColorCyan,
|
||||
"on_white": termbox.ColorWhite,
|
||||
}
|
||||
stringToFgAttr = map[string]termbox.Attribute{
|
||||
"bold": termbox.AttrBold,
|
||||
"underline": termbox.AttrUnderline,
|
||||
"reverse": termbox.AttrReverse,
|
||||
}
|
||||
stringToBgAttr = map[string]termbox.Attribute{
|
||||
"on_bold": termbox.AttrBold,
|
||||
}
|
||||
)
|
||||
|
||||
// NewStyleSet creates a new StyleSet struct
|
||||
func NewStyleSet() *StyleSet {
|
||||
ss := &StyleSet{}
|
||||
ss.Init()
|
||||
return ss
|
||||
}
|
||||
|
||||
func (ss *StyleSet) Init() {
|
||||
ss.Basic.fg = termbox.ColorDefault
|
||||
ss.Basic.bg = termbox.ColorDefault
|
||||
ss.Query.fg = termbox.ColorDefault
|
||||
ss.Query.bg = termbox.ColorDefault
|
||||
ss.Matched.fg = termbox.ColorCyan
|
||||
ss.Matched.bg = termbox.ColorDefault
|
||||
ss.SavedSelection.fg = termbox.ColorBlack | termbox.AttrBold
|
||||
ss.SavedSelection.bg = termbox.ColorCyan
|
||||
ss.Selected.fg = termbox.ColorDefault | termbox.AttrUnderline
|
||||
ss.Selected.bg = termbox.ColorMagenta
|
||||
}
|
||||
|
||||
// UnmarshalJSON satisfies json.RawMessage.
|
||||
func (s *Style) UnmarshalJSON(buf []byte) error {
|
||||
raw := []string{}
|
||||
if err := json.Unmarshal(buf, &raw); err != nil {
|
||||
return errors.Wrapf(err, "failed to unmarshal Style")
|
||||
}
|
||||
return stringsToStyle(s, raw)
|
||||
}
|
||||
|
||||
func stringsToStyle(style *Style, raw []string) error {
|
||||
style.fg = termbox.ColorDefault
|
||||
style.bg = termbox.ColorDefault
|
||||
|
||||
for _, s := range raw {
|
||||
fg, ok := stringToFg[s]
|
||||
if ok {
|
||||
style.fg = fg
|
||||
}
|
||||
|
||||
bg, ok := stringToBg[s]
|
||||
if ok {
|
||||
style.bg = bg
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range raw {
|
||||
if fgAttr, ok := stringToFgAttr[s]; ok {
|
||||
style.fg |= fgAttr
|
||||
}
|
||||
|
||||
if bgAttr, ok := stringToBgAttr[s]; ok {
|
||||
style.bg |= bgAttr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// This is a variable because we want to change its behavior
|
||||
// when we run tests.
|
||||
type configLocateFunc func(string) (string, error)
|
||||
|
||||
func locateRcfileIn(dir string) (string, error) {
|
||||
const basename = "config.json"
|
||||
file := filepath.Join(dir, basename)
|
||||
if _, err := os.Stat(file); err != nil {
|
||||
return "", errors.Wrapf(err, "failed to stat file %s", file)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// LocateRcfile attempts to find the config file in various locations
|
||||
func LocateRcfile(locater configLocateFunc) (string, error) {
|
||||
// http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
||||
//
|
||||
// Try in this order:
|
||||
// $XDG_CONFIG_HOME/peco/config.json
|
||||
// $XDG_CONFIG_DIR/peco/config.json (where XDG_CONFIG_DIR is listed in $XDG_CONFIG_DIRS)
|
||||
// ~/.peco/config.json
|
||||
|
||||
home, uErr := homedirFunc()
|
||||
|
||||
// Try dir supplied via env var
|
||||
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
|
||||
if file, err := locater(filepath.Join(dir, "peco")); err == nil {
|
||||
return file, nil
|
||||
}
|
||||
} else if uErr == nil { // silently ignore failure for homedir()
|
||||
// Try "default" XDG location, is user is available
|
||||
if file, err := locater(filepath.Join(home, ".config", "peco")); err == nil {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
|
||||
// this standard does not take into consideration windows (duh)
|
||||
// while the spec says use ":" as the separator, Go provides us
|
||||
// with filepath.ListSeparator, so use it
|
||||
if dirs := os.Getenv("XDG_CONFIG_DIRS"); dirs != "" {
|
||||
for _, dir := range strings.Split(dirs, fmt.Sprintf("%c", filepath.ListSeparator)) {
|
||||
if file, err := locater(filepath.Join(dir, "peco")); err == nil {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if uErr == nil { // silently ignore failure for homedir()
|
||||
if file, err := locater(filepath.Join(home, ".peco")); err == nil {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("config file not found")
|
||||
}
|
||||
241
config/config.go
Normal file
241
config/config.go
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/peco/peco/internal/util"
|
||||
)
|
||||
|
||||
// OnCancelBehavior specifies what happens when the user cancels peco.
|
||||
type OnCancelBehavior string
|
||||
|
||||
const (
|
||||
OnCancelSuccess OnCancelBehavior = "success"
|
||||
OnCancelError OnCancelBehavior = "error"
|
||||
)
|
||||
|
||||
// UnmarshalText parses a text value into an OnCancelBehavior, accepting
|
||||
// "success" (or empty) and "error" as valid values.
|
||||
func (o *OnCancelBehavior) UnmarshalText(b []byte) error {
|
||||
switch s := string(b); s {
|
||||
case "", "success":
|
||||
*o = OnCancelSuccess
|
||||
case "error":
|
||||
*o = OnCancelError
|
||||
default:
|
||||
return fmt.Errorf("invalid OnCancel value %q: must be %q or %q", s, OnCancelSuccess, OnCancelError)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ColorMode specifies how peco handles ANSI color codes in input.
|
||||
type ColorMode string
|
||||
|
||||
const (
|
||||
ColorModeAuto ColorMode = "auto"
|
||||
ColorModeNone ColorMode = "none"
|
||||
)
|
||||
|
||||
func (c *ColorMode) unmarshal(s string) error {
|
||||
switch s {
|
||||
case "", "auto":
|
||||
*c = ColorModeAuto
|
||||
case "none":
|
||||
*c = ColorModeNone
|
||||
default:
|
||||
return fmt.Errorf("invalid Color value %q: must be %q or %q", s, ColorModeAuto, ColorModeNone)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalText implements encoding.TextUnmarshaler (used by JSON/YAML decoders).
|
||||
func (c *ColorMode) UnmarshalText(b []byte) error {
|
||||
return c.unmarshal(string(b))
|
||||
}
|
||||
|
||||
// UnmarshalFlag implements go-flags Unmarshaler (used by CLI flag parsing).
|
||||
func (c *ColorMode) UnmarshalFlag(s string) error {
|
||||
return c.unmarshal(s)
|
||||
}
|
||||
|
||||
// Config holds all the data that can be configured in the
|
||||
// external configuration file
|
||||
type Config struct {
|
||||
Action map[string][]string `json:"Action" yaml:"Action"`
|
||||
// Keymap used to be directly responsible for dispatching
|
||||
// events against user input, but since then this has changed
|
||||
// into something that just records the user's config input
|
||||
Keymap map[string]string `json:"Keymap" yaml:"Keymap"`
|
||||
InitialFilter string `json:"InitialFilter" yaml:"InitialFilter"`
|
||||
Style StyleSet `json:"Style" yaml:"Style"`
|
||||
Prompt string `json:"Prompt" yaml:"Prompt"`
|
||||
Layout string `json:"Layout" yaml:"Layout"`
|
||||
OnCancel OnCancelBehavior `json:"OnCancel" yaml:"OnCancel"`
|
||||
CustomFilter map[string]CustomFilterConfig `json:"CustomFilter" yaml:"CustomFilter"`
|
||||
QueryExecutionDelay int `json:"QueryExecutionDelay" yaml:"QueryExecutionDelay"`
|
||||
StickySelection bool `json:"StickySelection" yaml:"StickySelection"`
|
||||
MaxScanBufferSize int `json:"MaxScanBufferSize" yaml:"MaxScanBufferSize"`
|
||||
FilterBufSize int `json:"FilterBufSize" yaml:"FilterBufSize"`
|
||||
FuzzyLongestSort bool `json:"FuzzyLongestSort" yaml:"FuzzyLongestSort"`
|
||||
SuppressStatusMsg bool `json:"SuppressStatusMsg" yaml:"SuppressStatusMsg"`
|
||||
Color ColorMode `json:"Color" yaml:"Color"`
|
||||
|
||||
// If this is true, then the prefix for single key jump mode
|
||||
// is displayed by default.
|
||||
SingleKeyJump SingleKeyJumpConfig `json:"SingleKeyJump" yaml:"SingleKeyJump"`
|
||||
|
||||
// Use this prefix to denote currently selected line
|
||||
SelectionPrefix string `json:"SelectionPrefix" yaml:"SelectionPrefix"`
|
||||
|
||||
// Height specifies the display height in lines or percentage (e.g. "10", "50%").
|
||||
// When set, peco renders inline without using the alternate screen buffer.
|
||||
Height string `json:"Height" yaml:"Height"`
|
||||
|
||||
// Follow enables follow mode at startup: peco auto-scrolls to keep the
|
||||
// newest input lines visible, like "tail -f". The --follow CLI flag
|
||||
// overrides this value.
|
||||
Follow bool `json:"Follow" yaml:"Follow"`
|
||||
}
|
||||
|
||||
// SingleKeyJumpConfig holds configuration for single key jump mode.
|
||||
type SingleKeyJumpConfig struct {
|
||||
ShowPrefix bool `json:"ShowPrefix" yaml:"ShowPrefix"`
|
||||
}
|
||||
|
||||
// CustomFilterConfig is used to specify configuration parameters
|
||||
// to CustomFilters
|
||||
type CustomFilterConfig struct {
|
||||
// Cmd is the name of the command to invoke
|
||||
Cmd string `json:"Cmd" yaml:"Cmd"`
|
||||
|
||||
// TODO: need to check if how we use this is correct
|
||||
Args []string `json:"Args" yaml:"Args"`
|
||||
|
||||
// BufferThreshold defines how many lines peco buffers before
|
||||
// invoking the external command. If this value is big, we
|
||||
// will execute the external command fewer times, but the
|
||||
// results will not be generated for longer periods of time.
|
||||
// If this value is small, we will execute the external command
|
||||
// more often, but you pay the penalty of invoking that command
|
||||
// more times.
|
||||
BufferThreshold int `json:"BufferThreshold" yaml:"BufferThreshold"`
|
||||
}
|
||||
|
||||
// DefaultPrompt is the default prompt string shown in the query line.
|
||||
const DefaultPrompt = "QUERY>"
|
||||
|
||||
var homedirFunc = util.Homedir
|
||||
|
||||
// Init initializes the Config with default values
|
||||
func (c *Config) Init() error {
|
||||
c.Keymap = make(map[string]string)
|
||||
c.Style.Init()
|
||||
c.Prompt = DefaultPrompt
|
||||
c.Layout = LayoutTypeTopDown
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadFilename reads the config from the given file, and
|
||||
// does the appropriate processing, if any
|
||||
func (c *Config) ReadFilename(filename string) error {
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file %s: %w", filename, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
switch ext := filepath.Ext(filename); ext {
|
||||
case ".yaml", ".yml":
|
||||
err = yaml.NewDecoder(f).Decode(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode YAML: %w", err)
|
||||
}
|
||||
default:
|
||||
err = json.NewDecoder(f).Decode(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode JSON: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !IsValidLayoutType(c.Layout) {
|
||||
return fmt.Errorf("invalid layout type: %s", c.Layout)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Locator locates a config file in a given directory.
|
||||
type Locator interface {
|
||||
Locate(string) (string, error)
|
||||
}
|
||||
|
||||
// LocatorFunc is a function that implements Locator.
|
||||
type LocatorFunc func(string) (string, error)
|
||||
|
||||
// Locate calls the underlying function.
|
||||
func (f LocatorFunc) Locate(dir string) (string, error) {
|
||||
return f(dir)
|
||||
}
|
||||
|
||||
var configFilenames = []string{"config.json", "config.yaml", "config.yml"}
|
||||
|
||||
// DefaultConfigLocator searches for a config file with one of the known
|
||||
// filenames (config.json, config.yaml, config.yml) in the given directory.
|
||||
var DefaultConfigLocator = LocatorFunc(func(dir string) (string, error) {
|
||||
for _, basename := range configFilenames {
|
||||
file := filepath.Join(dir, basename)
|
||||
if _, err := os.Stat(file); err == nil {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("config file not found in %s", dir)
|
||||
})
|
||||
|
||||
// LocateRcfile attempts to find the config file in various locations
|
||||
func LocateRcfile(locater Locator) (string, error) {
|
||||
// http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
||||
//
|
||||
// Try in this order:
|
||||
// $XDG_CONFIG_HOME/peco/config.{json,yaml,yml}
|
||||
// $XDG_CONFIG_DIR/peco/config.{json,yaml,yml} (where XDG_CONFIG_DIR is listed in $XDG_CONFIG_DIRS)
|
||||
// ~/.peco/config.{json,yaml,yml}
|
||||
|
||||
home, uErr := homedirFunc()
|
||||
|
||||
// Try dir supplied via env var
|
||||
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
|
||||
if file, err := locater.Locate(filepath.Join(dir, "peco")); err == nil {
|
||||
return file, nil
|
||||
}
|
||||
} else if uErr == nil { // silently ignore failure for homedir()
|
||||
// Try "default" XDG location, is user is available
|
||||
if file, err := locater.Locate(filepath.Join(home, ".config", "peco")); err == nil {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
|
||||
// this standard does not take into consideration windows (duh)
|
||||
// while the spec says use ":" as the separator, Go provides us
|
||||
// with filepath.ListSeparator, so use it
|
||||
if dirs := os.Getenv("XDG_CONFIG_DIRS"); dirs != "" {
|
||||
for dir := range strings.SplitSeq(dirs, fmt.Sprintf("%c", filepath.ListSeparator)) {
|
||||
if file, err := locater.Locate(filepath.Join(dir, "peco")); err == nil {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if uErr == nil { // silently ignore failure for homedir()
|
||||
if file, err := locater.Locate(filepath.Join(home, ".peco")); err == nil {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("config file not found")
|
||||
}
|
||||
370
config/config_test.go
Normal file
370
config/config_test.go
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var expectedConfig = Config{
|
||||
Keymap: map[string]string{
|
||||
"C-j": "peco.Finish",
|
||||
"C-x,C-c": "peco.Finish",
|
||||
},
|
||||
Layout: DefaultLayoutType,
|
||||
Prompt: "[peco]",
|
||||
Style: StyleSet{
|
||||
Matched: Style{
|
||||
Fg: ColorCyan | AttrBold,
|
||||
Bg: ColorRed,
|
||||
},
|
||||
Query: Style{
|
||||
Fg: ColorYellow | AttrBold,
|
||||
Bg: ColorDefault,
|
||||
},
|
||||
Selected: Style{
|
||||
Fg: ColorBlack | AttrUnderline,
|
||||
Bg: ColorCyan,
|
||||
},
|
||||
SavedSelection: Style{
|
||||
Fg: ColorBlack | AttrBold,
|
||||
Bg: ColorCyan,
|
||||
},
|
||||
Prompt: Style{
|
||||
Fg: ColorGreen | AttrBold,
|
||||
Bg: ColorDefault,
|
||||
},
|
||||
Context: Style{
|
||||
Fg: ColorDefault | AttrBold,
|
||||
Bg: ColorDefault,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestReadRC(t *testing.T) {
|
||||
txt := `
|
||||
{
|
||||
"Keymap": {
|
||||
"C-j": "peco.Finish",
|
||||
"C-x,C-c": "peco.Finish"
|
||||
},
|
||||
"Style": {
|
||||
"Basic": ["on_default", "default"],
|
||||
"Selected": ["underline", "on_cyan", "black"],
|
||||
"Query": ["yellow", "bold"],
|
||||
"Matched": ["cyan", "bold", "on_red"],
|
||||
"Prompt": ["green", "bold"]
|
||||
},
|
||||
"Prompt": "[peco]"
|
||||
}
|
||||
`
|
||||
var cfg Config
|
||||
require.NoError(t, cfg.Init(), "Config.Init should succeed")
|
||||
require.NoError(t, json.Unmarshal([]byte(txt), &cfg), "Unmarshalling config should succeed")
|
||||
require.Equal(t, expectedConfig, cfg, "configuration matches expected")
|
||||
}
|
||||
|
||||
func TestReadRCYAML(t *testing.T) {
|
||||
txt := `
|
||||
Keymap:
|
||||
C-j: peco.Finish
|
||||
"C-x,C-c": peco.Finish
|
||||
Style:
|
||||
Basic:
|
||||
- on_default
|
||||
- default
|
||||
Selected:
|
||||
- underline
|
||||
- on_cyan
|
||||
- black
|
||||
Query:
|
||||
- yellow
|
||||
- bold
|
||||
Matched:
|
||||
- cyan
|
||||
- bold
|
||||
- on_red
|
||||
Prompt:
|
||||
- green
|
||||
- bold
|
||||
Prompt: "[peco]"
|
||||
`
|
||||
var cfg Config
|
||||
require.NoError(t, cfg.Init(), "Config.Init should succeed")
|
||||
require.NoError(t, yaml.Unmarshal([]byte(txt), &cfg), "Unmarshalling YAML config should succeed")
|
||||
require.Equal(t, expectedConfig, cfg, "YAML configuration matches expected")
|
||||
}
|
||||
|
||||
type stringsToStyleTest struct {
|
||||
strings []string
|
||||
style *Style
|
||||
}
|
||||
|
||||
func TestStringsToStyle(t *testing.T) {
|
||||
tests := []stringsToStyleTest{
|
||||
{
|
||||
strings: []string{"on_default", "default"},
|
||||
style: &Style{Fg: ColorDefault, Bg: ColorDefault},
|
||||
},
|
||||
{
|
||||
strings: []string{"bold", "on_blue", "yellow"},
|
||||
style: &Style{Fg: ColorYellow | AttrBold, Bg: ColorBlue},
|
||||
},
|
||||
{
|
||||
strings: []string{"underline", "on_cyan", "black"},
|
||||
style: &Style{Fg: ColorBlack | AttrUnderline, Bg: ColorCyan},
|
||||
},
|
||||
{
|
||||
strings: []string{"reverse", "on_red", "white"},
|
||||
style: &Style{Fg: ColorWhite | AttrReverse, Bg: ColorRed},
|
||||
},
|
||||
{
|
||||
strings: []string{"on_bold", "on_magenta", "green"},
|
||||
style: &Style{Fg: ColorGreen, Bg: ColorMagenta | AttrBold},
|
||||
},
|
||||
{
|
||||
strings: []string{"underline", "on_240", "214"},
|
||||
style: &Style{Fg: Attribute(214+1) | AttrUnderline, Bg: Attribute(240 + 1)},
|
||||
},
|
||||
{
|
||||
strings: []string{"#ff8800", "on_#0088ff"},
|
||||
style: &Style{Fg: Attribute(0xff8800) | AttrTrueColor, Bg: Attribute(0x0088ff) | AttrTrueColor},
|
||||
},
|
||||
{
|
||||
strings: []string{"bold", "#00ff00", "on_#000000"},
|
||||
style: &Style{Fg: Attribute(0x00ff00) | AttrTrueColor | AttrBold, Bg: Attribute(0x000000) | AttrTrueColor},
|
||||
},
|
||||
{
|
||||
strings: []string{"#000000"},
|
||||
style: &Style{Fg: Attribute(0x000000) | AttrTrueColor, Bg: ColorDefault},
|
||||
},
|
||||
}
|
||||
|
||||
t.Logf("Checking strings -> color mapping...")
|
||||
var a Style
|
||||
for _, test := range tests {
|
||||
t.Logf(" checking %s...", test.strings)
|
||||
require.NoError(t, StringsToStyle(&a, test.strings), "StringsToStyle should succeed")
|
||||
require.Equal(t, test.style, &a, "Expected '%s' to be '%#v', but got '%#v'", test.strings, test.style, a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocateRcfile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
homedirFunc = func() (string, error) {
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
expected := []string{
|
||||
filepath.Join(dir, "peco"),
|
||||
filepath.Join(dir, "1", "peco"),
|
||||
filepath.Join(dir, "2", "peco"),
|
||||
filepath.Join(dir, "3", "peco"),
|
||||
filepath.Join(dir, ".peco"),
|
||||
}
|
||||
|
||||
i := 0
|
||||
locater := LocatorFunc(func(dir string) (string, error) {
|
||||
t.Logf("looking for file in %s", dir)
|
||||
require.True(t, i <= len(expected)-1, "Got %d directories, only have %d", i+1, len(expected))
|
||||
require.Equal(t, expected[i], dir, "Expected %s, got %s", expected[i], dir)
|
||||
i++
|
||||
return "", errors.New("error: Not found")
|
||||
})
|
||||
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
t.Setenv("XDG_CONFIG_DIRS", strings.Join(
|
||||
[]string{
|
||||
filepath.Join(dir, "1"),
|
||||
filepath.Join(dir, "2"),
|
||||
filepath.Join(dir, "3"),
|
||||
},
|
||||
fmt.Sprintf("%c", filepath.ListSeparator),
|
||||
))
|
||||
|
||||
LocateRcfile(locater)
|
||||
expected[0] = filepath.Join(dir, ".config", "peco")
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
i = 0
|
||||
LocateRcfile(locater)
|
||||
}
|
||||
|
||||
func TestLocateRcfileYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create config.yaml (but not config.json) in the dir
|
||||
pecoDir := filepath.Join(dir, ".peco")
|
||||
require.NoError(t, os.MkdirAll(pecoDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(pecoDir, "config.yaml"), []byte("{}"), 0o644))
|
||||
|
||||
homedirFunc = func() (string, error) {
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// Clear XDG vars so it falls through to ~/.peco/
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
t.Setenv("XDG_CONFIG_DIRS", "")
|
||||
|
||||
file, err := LocateRcfile(DefaultConfigLocator)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, filepath.Join(pecoDir, "config.yaml"), file)
|
||||
}
|
||||
|
||||
func TestOnCancelBehavior(t *testing.T) {
|
||||
t.Run("valid values via JSON", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
input string
|
||||
expected OnCancelBehavior
|
||||
}{
|
||||
{`{"OnCancel":"success"}`, OnCancelSuccess},
|
||||
{`{"OnCancel":"error"}`, OnCancelError},
|
||||
{`{}`, ""}, // absent key stays at zero value; default applied later in ApplyConfig
|
||||
} {
|
||||
var cfg Config
|
||||
require.NoError(t, cfg.Init())
|
||||
require.NoError(t, json.Unmarshal([]byte(tc.input), &cfg))
|
||||
require.Equal(t, tc.expected, cfg.OnCancel)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid values via YAML", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
input string
|
||||
expected OnCancelBehavior
|
||||
}{
|
||||
{"OnCancel: success", OnCancelSuccess},
|
||||
{"OnCancel: error", OnCancelError},
|
||||
} {
|
||||
var cfg Config
|
||||
require.NoError(t, cfg.Init())
|
||||
require.NoError(t, yaml.Unmarshal([]byte(tc.input), &cfg))
|
||||
require.Equal(t, tc.expected, cfg.OnCancel)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid value via JSON", func(t *testing.T) {
|
||||
var cfg Config
|
||||
require.NoError(t, cfg.Init())
|
||||
err := json.Unmarshal([]byte(`{"OnCancel":"bogus"}`), &cfg)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "bogus")
|
||||
})
|
||||
|
||||
t.Run("invalid value via YAML", func(t *testing.T) {
|
||||
var cfg Config
|
||||
require.NoError(t, cfg.Init())
|
||||
err := yaml.Unmarshal([]byte("OnCancel: bogus"), &cfg)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "bogus")
|
||||
})
|
||||
}
|
||||
|
||||
func TestColorMode(t *testing.T) {
|
||||
t.Run("valid values via JSON", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
input string
|
||||
expected ColorMode
|
||||
}{
|
||||
{`{"Color":"auto"}`, ColorModeAuto},
|
||||
{`{"Color":"none"}`, ColorModeNone},
|
||||
{`{}`, ""}, // absent key stays at zero value; default applied later in ApplyConfig
|
||||
} {
|
||||
var cfg Config
|
||||
require.NoError(t, cfg.Init())
|
||||
require.NoError(t, json.Unmarshal([]byte(tc.input), &cfg))
|
||||
require.Equal(t, tc.expected, cfg.Color)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid values via YAML", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
input string
|
||||
expected ColorMode
|
||||
}{
|
||||
{"Color: auto", ColorModeAuto},
|
||||
{"Color: none", ColorModeNone},
|
||||
} {
|
||||
var cfg Config
|
||||
require.NoError(t, cfg.Init())
|
||||
require.NoError(t, yaml.Unmarshal([]byte(tc.input), &cfg))
|
||||
require.Equal(t, tc.expected, cfg.Color)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid value via JSON", func(t *testing.T) {
|
||||
var cfg Config
|
||||
require.NoError(t, cfg.Init())
|
||||
err := json.Unmarshal([]byte(`{"Color":"bogus"}`), &cfg)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "bogus")
|
||||
})
|
||||
|
||||
t.Run("invalid value via YAML", func(t *testing.T) {
|
||||
var cfg Config
|
||||
require.NoError(t, cfg.Init())
|
||||
err := yaml.Unmarshal([]byte("Color: bogus"), &cfg)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "bogus")
|
||||
})
|
||||
|
||||
t.Run("UnmarshalFlag valid values", func(t *testing.T) {
|
||||
var c ColorMode
|
||||
require.NoError(t, c.UnmarshalFlag("auto"))
|
||||
require.Equal(t, ColorModeAuto, c)
|
||||
|
||||
require.NoError(t, c.UnmarshalFlag("none"))
|
||||
require.Equal(t, ColorModeNone, c)
|
||||
|
||||
require.NoError(t, c.UnmarshalFlag(""))
|
||||
require.Equal(t, ColorModeAuto, c)
|
||||
})
|
||||
|
||||
t.Run("UnmarshalFlag invalid value", func(t *testing.T) {
|
||||
var c ColorMode
|
||||
err := c.UnmarshalFlag("bogus")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "bogus")
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadFilenameYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
yamlFile := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(yamlFile, []byte(`
|
||||
Keymap:
|
||||
C-j: peco.Finish
|
||||
"C-x,C-c": peco.Finish
|
||||
Style:
|
||||
Basic:
|
||||
- on_default
|
||||
- default
|
||||
Selected:
|
||||
- underline
|
||||
- on_cyan
|
||||
- black
|
||||
Query:
|
||||
- yellow
|
||||
- bold
|
||||
Matched:
|
||||
- cyan
|
||||
- bold
|
||||
- on_red
|
||||
Prompt:
|
||||
- green
|
||||
- bold
|
||||
Prompt: "[peco]"
|
||||
`), 0o644))
|
||||
|
||||
var cfg Config
|
||||
require.NoError(t, cfg.Init())
|
||||
require.NoError(t, cfg.ReadFilename(yamlFile))
|
||||
require.Equal(t, expectedConfig, cfg)
|
||||
}
|
||||
78
config/height.go
Normal file
78
config/height.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HeightSpec represents a height specification that can be either
|
||||
// an absolute number of lines or a percentage of terminal height.
|
||||
type HeightSpec struct {
|
||||
Value int
|
||||
IsPercent bool
|
||||
}
|
||||
|
||||
// ParseHeightSpec parses a height specification string.
|
||||
// Valid formats: "10" (absolute lines), "50%" (percentage).
|
||||
func ParseHeightSpec(s string) (HeightSpec, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return HeightSpec{}, fmt.Errorf("empty height specification")
|
||||
}
|
||||
|
||||
if strings.HasSuffix(s, "%") {
|
||||
numStr := s[:len(s)-1]
|
||||
if strings.Contains(numStr, "%") {
|
||||
return HeightSpec{}, fmt.Errorf("invalid height specification: %q", s)
|
||||
}
|
||||
v, err := strconv.Atoi(numStr)
|
||||
if err != nil {
|
||||
return HeightSpec{}, fmt.Errorf("invalid height specification: %q", s)
|
||||
}
|
||||
if v <= 0 {
|
||||
return HeightSpec{}, fmt.Errorf("height percentage must be positive: %q", s)
|
||||
}
|
||||
if v > 100 {
|
||||
return HeightSpec{}, fmt.Errorf("height percentage must not exceed 100: %q", s)
|
||||
}
|
||||
return HeightSpec{Value: v, IsPercent: true}, nil
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return HeightSpec{}, fmt.Errorf("invalid height specification: %q", s)
|
||||
}
|
||||
if v <= 0 {
|
||||
return HeightSpec{}, fmt.Errorf("height must be positive: %q", s)
|
||||
}
|
||||
return HeightSpec{Value: v, IsPercent: false}, nil
|
||||
}
|
||||
|
||||
// ChromLines is the number of lines used by the prompt and status bar.
|
||||
const ChromLines = 2
|
||||
|
||||
// Resolve converts the HeightSpec to an absolute number of screen rows,
|
||||
// clamped to [ChromLines+1, termHeight].
|
||||
//
|
||||
// For absolute values, Value is the number of result lines — the prompt
|
||||
// and status bar are added automatically (total = Value + 2).
|
||||
// For percentages, Value is the percentage of the full terminal height
|
||||
// (prompt and status bar are included in that total).
|
||||
func (h HeightSpec) Resolve(termHeight int) int {
|
||||
var height int
|
||||
if h.IsPercent {
|
||||
height = termHeight * h.Value / 100
|
||||
} else {
|
||||
height = h.Value + ChromLines
|
||||
}
|
||||
|
||||
minHeight := ChromLines + 1 // at least 1 result line
|
||||
if height < minHeight {
|
||||
height = minHeight
|
||||
}
|
||||
if height > termHeight {
|
||||
height = termHeight
|
||||
}
|
||||
return height
|
||||
}
|
||||
140
config/height_test.go
Normal file
140
config/height_test.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseHeightSpec(t *testing.T) {
|
||||
t.Run("valid absolute", func(t *testing.T) {
|
||||
spec, err := ParseHeightSpec("10")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 10, spec.Value)
|
||||
require.False(t, spec.IsPercent)
|
||||
})
|
||||
|
||||
t.Run("valid percentage", func(t *testing.T) {
|
||||
spec, err := ParseHeightSpec("50%")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 50, spec.Value)
|
||||
require.True(t, spec.IsPercent)
|
||||
})
|
||||
|
||||
t.Run("valid 100%", func(t *testing.T) {
|
||||
spec, err := ParseHeightSpec("100%")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 100, spec.Value)
|
||||
require.True(t, spec.IsPercent)
|
||||
})
|
||||
|
||||
t.Run("valid with whitespace", func(t *testing.T) {
|
||||
spec, err := ParseHeightSpec(" 10 ")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 10, spec.Value)
|
||||
require.False(t, spec.IsPercent)
|
||||
})
|
||||
|
||||
t.Run("invalid empty", func(t *testing.T) {
|
||||
_, err := ParseHeightSpec("")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid whitespace only", func(t *testing.T) {
|
||||
_, err := ParseHeightSpec(" ")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid abc", func(t *testing.T) {
|
||||
_, err := ParseHeightSpec("abc")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid negative", func(t *testing.T) {
|
||||
_, err := ParseHeightSpec("-5")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid zero", func(t *testing.T) {
|
||||
_, err := ParseHeightSpec("0")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid zero percent", func(t *testing.T) {
|
||||
_, err := ParseHeightSpec("0%")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid double percent", func(t *testing.T) {
|
||||
_, err := ParseHeightSpec("50%%")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid percent over 100", func(t *testing.T) {
|
||||
_, err := ParseHeightSpec("150%")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHeightSpecResolve(t *testing.T) {
|
||||
// For absolute values: Value is result lines, total = Value + ChromLines(2)
|
||||
t.Run("absolute adds chrome", func(t *testing.T) {
|
||||
spec := HeightSpec{Value: 10, IsPercent: false}
|
||||
// 10 result lines + 2 chrome = 12 total
|
||||
require.Equal(t, 12, spec.Resolve(24))
|
||||
})
|
||||
|
||||
t.Run("absolute 1 result line", func(t *testing.T) {
|
||||
spec := HeightSpec{Value: 1, IsPercent: false}
|
||||
// 1 result line + 2 chrome = 3 total
|
||||
require.Equal(t, 3, spec.Resolve(24))
|
||||
})
|
||||
|
||||
t.Run("absolute 2 result lines", func(t *testing.T) {
|
||||
spec := HeightSpec{Value: 2, IsPercent: false}
|
||||
// 2 result lines + 2 chrome = 4 total
|
||||
require.Equal(t, 4, spec.Resolve(24))
|
||||
})
|
||||
|
||||
t.Run("absolute clamped to terminal height", func(t *testing.T) {
|
||||
spec := HeightSpec{Value: 50, IsPercent: false}
|
||||
// 50 + 2 = 52, clamped to 24
|
||||
require.Equal(t, 24, spec.Resolve(24))
|
||||
})
|
||||
|
||||
t.Run("absolute clamped to minimum", func(t *testing.T) {
|
||||
// This shouldn't happen via ParseHeightSpec (rejects <= 0),
|
||||
// but Resolve handles it defensively.
|
||||
spec := HeightSpec{Value: 0, IsPercent: false}
|
||||
// 0 + 2 = 2, clamped to min 3
|
||||
require.Equal(t, ChromLines+1, spec.Resolve(24))
|
||||
})
|
||||
|
||||
// For percentages: Value is percentage of total terminal height
|
||||
t.Run("percentage", func(t *testing.T) {
|
||||
spec := HeightSpec{Value: 50, IsPercent: true}
|
||||
require.Equal(t, 12, spec.Resolve(24))
|
||||
})
|
||||
|
||||
t.Run("percentage 100%", func(t *testing.T) {
|
||||
spec := HeightSpec{Value: 100, IsPercent: true}
|
||||
require.Equal(t, 24, spec.Resolve(24))
|
||||
})
|
||||
|
||||
t.Run("percentage clamp to min", func(t *testing.T) {
|
||||
spec := HeightSpec{Value: 1, IsPercent: true}
|
||||
// 1% of 24 = 0, clamped to ChromLines+1 = 3
|
||||
require.Equal(t, ChromLines+1, spec.Resolve(24))
|
||||
})
|
||||
|
||||
t.Run("percentage clamp to terminal height", func(t *testing.T) {
|
||||
spec := HeightSpec{Value: 100, IsPercent: true}
|
||||
require.Equal(t, 5, spec.Resolve(5))
|
||||
})
|
||||
|
||||
t.Run("small terminal clamps absolute", func(t *testing.T) {
|
||||
spec := HeightSpec{Value: 10, IsPercent: false}
|
||||
// 10 + 2 = 12, clamped to 3
|
||||
require.Equal(t, 3, spec.Resolve(3))
|
||||
})
|
||||
}
|
||||
24
config/layout.go
Normal file
24
config/layout.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package config
|
||||
|
||||
// LayoutType describes the types of layout that peco can take
|
||||
type LayoutType = string
|
||||
|
||||
const (
|
||||
DefaultLayoutType = LayoutTypeTopDown // LayoutTypeTopDown makes the layout so the items read from top to bottom
|
||||
LayoutTypeTopDown = "top-down" // LayoutTypeTopDown displays prompt at top, list top-to-bottom
|
||||
LayoutTypeBottomUp = "bottom-up" // LayoutTypeBottomUp displays prompt at bottom, list bottom-to-top
|
||||
LayoutTypeTopDownQueryBottom = "top-down-query-bottom" // LayoutTypeTopDownQueryBottom displays list top-to-bottom, prompt at bottom
|
||||
)
|
||||
|
||||
// validLayoutTypes enumerates all recognized layout type values.
|
||||
var validLayoutTypes = map[LayoutType]struct{}{
|
||||
LayoutTypeTopDown: {},
|
||||
LayoutTypeBottomUp: {},
|
||||
LayoutTypeTopDownQueryBottom: {},
|
||||
}
|
||||
|
||||
// IsValidLayoutType checks if a string is a supported layout type
|
||||
func IsValidLayoutType(v LayoutType) bool {
|
||||
_, ok := validLayoutTypes[v]
|
||||
return ok
|
||||
}
|
||||
184
config/style.go
Normal file
184
config/style.go
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StyleSet holds styles for various sections
|
||||
type StyleSet struct {
|
||||
Basic Style `json:"Basic" yaml:"Basic"`
|
||||
SavedSelection Style `json:"SavedSelection" yaml:"SavedSelection"`
|
||||
Selected Style `json:"Selected" yaml:"Selected"`
|
||||
Query Style `json:"Query" yaml:"Query"`
|
||||
QueryCursor Style `json:"QueryCursor" yaml:"QueryCursor"`
|
||||
Matched Style `json:"Matched" yaml:"Matched"`
|
||||
Prompt Style `json:"Prompt" yaml:"Prompt"`
|
||||
Context Style `json:"Context" yaml:"Context"`
|
||||
}
|
||||
|
||||
// Attribute represents terminal display attributes such as colors
|
||||
// and text styling (bold, underline, reverse). It is a uint32 bitfield:
|
||||
//
|
||||
// Bits 0-8: Palette color index (0=default, 1-256 for 256-color palette)
|
||||
// Bits 0-23: RGB color value (when AttrTrueColor flag is set)
|
||||
// Bit 24: AttrTrueColor flag — distinguishes true color from palette
|
||||
// Bit 25: AttrBold
|
||||
// Bit 26: AttrUnderline
|
||||
// Bit 27: AttrReverse
|
||||
// Bits 28-31: Reserved
|
||||
type Attribute uint32
|
||||
|
||||
// Named palette color constants (values 0-8).
|
||||
const (
|
||||
ColorDefault Attribute = 0x0000
|
||||
ColorBlack Attribute = 0x0001
|
||||
ColorRed Attribute = 0x0002
|
||||
ColorGreen Attribute = 0x0003
|
||||
ColorYellow Attribute = 0x0004
|
||||
ColorBlue Attribute = 0x0005
|
||||
ColorMagenta Attribute = 0x0006
|
||||
ColorCyan Attribute = 0x0007
|
||||
ColorWhite Attribute = 0x0008
|
||||
)
|
||||
|
||||
const (
|
||||
AttrTrueColor Attribute = 0x01000000
|
||||
AttrBold Attribute = 0x02000000
|
||||
AttrUnderline Attribute = 0x04000000
|
||||
AttrReverse Attribute = 0x08000000
|
||||
)
|
||||
|
||||
// Style describes display attributes for foreground and background.
|
||||
type Style struct {
|
||||
Fg Attribute
|
||||
Bg Attribute
|
||||
}
|
||||
|
||||
var (
|
||||
StringToFg = map[string]Attribute{
|
||||
"default": ColorDefault,
|
||||
"black": ColorBlack,
|
||||
"red": ColorRed,
|
||||
"green": ColorGreen,
|
||||
"yellow": ColorYellow,
|
||||
"blue": ColorBlue,
|
||||
"magenta": ColorMagenta,
|
||||
"cyan": ColorCyan,
|
||||
"white": ColorWhite,
|
||||
}
|
||||
StringToBg = map[string]Attribute{
|
||||
"on_default": ColorDefault,
|
||||
"on_black": ColorBlack,
|
||||
"on_red": ColorRed,
|
||||
"on_green": ColorGreen,
|
||||
"on_yellow": ColorYellow,
|
||||
"on_blue": ColorBlue,
|
||||
"on_magenta": ColorMagenta,
|
||||
"on_cyan": ColorCyan,
|
||||
"on_white": ColorWhite,
|
||||
}
|
||||
StringToFgAttr = map[string]Attribute{
|
||||
"bold": AttrBold,
|
||||
"underline": AttrUnderline,
|
||||
"reverse": AttrReverse,
|
||||
}
|
||||
StringToBgAttr = map[string]Attribute{
|
||||
"on_bold": AttrBold,
|
||||
}
|
||||
)
|
||||
|
||||
// NewStyleSet creates a new StyleSet struct
|
||||
func NewStyleSet() *StyleSet {
|
||||
ss := &StyleSet{}
|
||||
ss.Init()
|
||||
return ss
|
||||
}
|
||||
|
||||
// Init initializes the StyleSet with default foreground and background colors
|
||||
// for each UI element (basic, query, matched, selected, prompt, context, etc.).
|
||||
func (ss *StyleSet) Init() {
|
||||
ss.Basic.Fg = ColorDefault
|
||||
ss.Basic.Bg = ColorDefault
|
||||
ss.Query.Fg = ColorDefault
|
||||
ss.Query.Bg = ColorDefault
|
||||
ss.Matched.Fg = ColorCyan
|
||||
ss.Matched.Bg = ColorDefault
|
||||
ss.SavedSelection.Fg = ColorBlack | AttrBold
|
||||
ss.SavedSelection.Bg = ColorCyan
|
||||
ss.Selected.Fg = ColorDefault | AttrUnderline
|
||||
ss.Selected.Bg = ColorMagenta
|
||||
ss.Prompt.Fg = ColorDefault
|
||||
ss.Prompt.Bg = ColorDefault
|
||||
ss.Context.Fg = ColorDefault | AttrBold
|
||||
ss.Context.Bg = ColorDefault
|
||||
}
|
||||
|
||||
// UnmarshalJSON satisfies json.RawMessage.
|
||||
func (s *Style) UnmarshalJSON(buf []byte) error {
|
||||
raw := []string{}
|
||||
if err := json.Unmarshal(buf, &raw); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal Style: %w", err)
|
||||
}
|
||||
return StringsToStyle(s, raw)
|
||||
}
|
||||
|
||||
// UnmarshalYAML decodes a YAML array of strings into a Style.
|
||||
func (s *Style) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
var raw []string
|
||||
if err := unmarshal(&raw); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal Style from YAML: %w", err)
|
||||
}
|
||||
return StringsToStyle(s, raw)
|
||||
}
|
||||
|
||||
// StringsToStyle parses an array of color and attribute strings (e.g. "red",
|
||||
// "on_blue", "bold", "#ff00ff") into a Style's foreground and background Attributes.
|
||||
func StringsToStyle(style *Style, raw []string) error {
|
||||
style.Fg = ColorDefault
|
||||
style.Bg = ColorDefault
|
||||
|
||||
for _, s := range raw {
|
||||
fg, ok := StringToFg[s]
|
||||
if ok {
|
||||
style.Fg = fg
|
||||
} else if strings.HasPrefix(s, "#") && len(s) == 7 {
|
||||
if rgb, err := strconv.ParseUint(s[1:], 16, 32); err == nil {
|
||||
style.Fg = Attribute(rgb) | AttrTrueColor
|
||||
}
|
||||
} else {
|
||||
if fg, err := strconv.ParseUint(s, 10, 8); err == nil {
|
||||
style.Fg = Attribute(fg + 1)
|
||||
}
|
||||
}
|
||||
|
||||
bg, ok := StringToBg[s]
|
||||
if ok {
|
||||
style.Bg = bg
|
||||
} else if strings.HasPrefix(s, "on_#") && len(s) == 10 {
|
||||
if rgb, err := strconv.ParseUint(s[4:], 16, 32); err == nil {
|
||||
style.Bg = Attribute(rgb) | AttrTrueColor
|
||||
}
|
||||
} else {
|
||||
if strings.HasPrefix(s, "on_") {
|
||||
if bg, err := strconv.ParseUint(s[3:], 10, 8); err == nil {
|
||||
style.Bg = Attribute(bg + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range raw {
|
||||
if fgAttr, ok := StringToFgAttr[s]; ok {
|
||||
style.Fg |= fgAttr
|
||||
}
|
||||
|
||||
if bgAttr, ok := StringToBgAttr[s]; ok {
|
||||
style.Bg |= bgAttr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
166
config_test.go
166
config_test.go
|
|
@ -1,166 +0,0 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nsf/termbox-go"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestReadRC(t *testing.T) {
|
||||
txt := `
|
||||
{
|
||||
"Keymap": {
|
||||
"C-j": "peco.Finish",
|
||||
"C-x,C-c": "peco.Finish"
|
||||
},
|
||||
"Style": {
|
||||
"Basic": ["on_default", "default"],
|
||||
"Selected": ["underline", "on_cyan", "black"],
|
||||
"Query": ["yellow", "bold"],
|
||||
"Matched": ["cyan", "bold", "on_red"]
|
||||
},
|
||||
"Prompt": "[peco]"
|
||||
}
|
||||
`
|
||||
var cfg Config
|
||||
if !assert.NoError(t, cfg.Init(), "Config.Init should succeed") {
|
||||
return
|
||||
}
|
||||
|
||||
if !assert.NoError(t, json.Unmarshal([]byte(txt), &cfg), "Unmarshalling config should succeed") {
|
||||
return
|
||||
}
|
||||
|
||||
expected := Config{
|
||||
Keymap: map[string]string{
|
||||
"C-j": "peco.Finish",
|
||||
"C-x,C-c": "peco.Finish",
|
||||
},
|
||||
InitialMatcher: IgnoreCaseMatch,
|
||||
Layout: DefaultLayoutType,
|
||||
Prompt: "[peco]",
|
||||
Style: StyleSet{
|
||||
Matched: Style{
|
||||
fg: termbox.ColorCyan | termbox.AttrBold,
|
||||
bg: termbox.ColorRed,
|
||||
},
|
||||
Query: Style{
|
||||
fg: termbox.ColorYellow | termbox.AttrBold,
|
||||
bg: termbox.ColorDefault,
|
||||
},
|
||||
Selected: Style{
|
||||
fg: termbox.ColorBlack | termbox.AttrUnderline,
|
||||
bg: termbox.ColorCyan,
|
||||
},
|
||||
SavedSelection: Style{
|
||||
fg: termbox.ColorBlack | termbox.AttrBold,
|
||||
bg: termbox.ColorCyan,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if !assert.Equal(t, expected, cfg, "configuration matches expected") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type stringsToStyleTest struct {
|
||||
strings []string
|
||||
style *Style
|
||||
}
|
||||
|
||||
func TestStringsToStyle(t *testing.T) {
|
||||
tests := []stringsToStyleTest{
|
||||
stringsToStyleTest{
|
||||
strings: []string{"on_default", "default"},
|
||||
style: &Style{fg: termbox.ColorDefault, bg: termbox.ColorDefault},
|
||||
},
|
||||
stringsToStyleTest{
|
||||
strings: []string{"bold", "on_blue", "yellow"},
|
||||
style: &Style{fg: termbox.ColorYellow | termbox.AttrBold, bg: termbox.ColorBlue},
|
||||
},
|
||||
stringsToStyleTest{
|
||||
strings: []string{"underline", "on_cyan", "black"},
|
||||
style: &Style{fg: termbox.ColorBlack | termbox.AttrUnderline, bg: termbox.ColorCyan},
|
||||
},
|
||||
stringsToStyleTest{
|
||||
strings: []string{"reverse", "on_red", "white"},
|
||||
style: &Style{fg: termbox.ColorWhite | termbox.AttrReverse, bg: termbox.ColorRed},
|
||||
},
|
||||
stringsToStyleTest{
|
||||
strings: []string{"on_bold", "on_magenta", "green"},
|
||||
style: &Style{fg: termbox.ColorGreen, bg: termbox.ColorMagenta | termbox.AttrBold},
|
||||
},
|
||||
}
|
||||
|
||||
t.Logf("Checking strings -> color mapping...")
|
||||
var a Style
|
||||
for _, test := range tests {
|
||||
t.Logf(" checking %s...", test.strings)
|
||||
if !assert.NoError(t, stringsToStyle(&a, test.strings), "stringsToStyle should succeed") {
|
||||
return
|
||||
}
|
||||
|
||||
if !assert.Equal(t, test.style, &a, "Expected '%s' to be '%#v', but got '%#v'", test.strings, test.style, a) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocateRcfile(t *testing.T) {
|
||||
dir, err := ioutil.TempDir("", "peco-")
|
||||
if !assert.NoError(t, err, "Failed to create temporary directory: %s", err) {
|
||||
return
|
||||
}
|
||||
|
||||
homedirFunc = func() (string, error) {
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
expected := []string{
|
||||
filepath.Join(dir, "peco"),
|
||||
filepath.Join(dir, "1", "peco"),
|
||||
filepath.Join(dir, "2", "peco"),
|
||||
filepath.Join(dir, "3", "peco"),
|
||||
filepath.Join(dir, ".peco"),
|
||||
}
|
||||
|
||||
i := 0
|
||||
locater := func(dir string) (string, error) {
|
||||
t.Logf("looking for file in %s", dir)
|
||||
if !assert.True(t, i <= len(expected)-1, "Got %d directories, only have %d", i+1, len(expected)) {
|
||||
return "", errors.New("error: Not found")
|
||||
}
|
||||
|
||||
if !assert.Equal(t, expected[i], dir, "Expected %s, got %s", expected[i], dir) {
|
||||
return "", errors.New("error: Not found")
|
||||
}
|
||||
i++
|
||||
return "", errors.New("error: Not found")
|
||||
}
|
||||
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
os.Setenv("XDG_CONFIG_DIRS", strings.Join(
|
||||
[]string{
|
||||
filepath.Join(dir, "1"),
|
||||
filepath.Join(dir, "2"),
|
||||
filepath.Join(dir, "3"),
|
||||
},
|
||||
fmt.Sprintf("%c", filepath.ListSeparator),
|
||||
))
|
||||
|
||||
LocateRcfile(locater)
|
||||
expected[0] = filepath.Join(dir, ".config", "peco")
|
||||
os.Setenv("XDG_CONFIG_HOME", "")
|
||||
i = 0
|
||||
LocateRcfile(locater)
|
||||
|
||||
}
|
||||
10
contrib/man/generate.sh
Executable file
10
contrib/man/generate.sh
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
pandoc -s -f markdown -t man \
|
||||
-V title=PECO -V section=1 \
|
||||
-V header="User Commands" \
|
||||
-V footer="peco" \
|
||||
-o "$SCRIPT_DIR/peco.1" \
|
||||
"$REPO_ROOT/README.md"
|
||||
1955
contrib/man/peco.1
Normal file
1955
contrib/man/peco.1
Normal file
File diff suppressed because it is too large
Load diff
23
event.go
Normal file
23
event.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package peco
|
||||
|
||||
import "github.com/peco/peco/internal/keyseq"
|
||||
|
||||
// EventType classifies the type of terminal event.
|
||||
type EventType uint8
|
||||
|
||||
const (
|
||||
// EventKey is a keyboard event
|
||||
EventKey EventType = iota
|
||||
// EventResize is a terminal resize event
|
||||
EventResize
|
||||
// EventError is an error event
|
||||
EventError
|
||||
)
|
||||
|
||||
// Event is peco's internal event type, decoupled from any terminal library.
|
||||
type Event struct {
|
||||
Type EventType
|
||||
Key keyseq.KeyType
|
||||
Ch rune
|
||||
Mod keyseq.ModifierKey
|
||||
}
|
||||
490
filter.go
490
filter.go
|
|
@ -1,12 +1,13 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"context"
|
||||
|
||||
"github.com/lestrrat/go-pdebug"
|
||||
"github.com/lestrrat-go/pdebug"
|
||||
"github.com/peco/peco/filter"
|
||||
"github.com/peco/peco/hub"
|
||||
"github.com/peco/peco/internal/buffer"
|
||||
|
|
@ -14,27 +15,87 @@ import (
|
|||
"github.com/peco/peco/pipeline"
|
||||
)
|
||||
|
||||
func newFilterProcessor(f filter.Filter, q string) *filterProcessor {
|
||||
// FilterErrorHandler handles errors that occur during filter execution.
|
||||
type FilterErrorHandler interface {
|
||||
HandleError(error)
|
||||
}
|
||||
|
||||
// FilterErrorHandlerFunc is a function that implements FilterErrorHandler.
|
||||
type FilterErrorHandlerFunc func(error)
|
||||
|
||||
// HandleError calls the underlying function.
|
||||
func (f FilterErrorHandlerFunc) HandleError(err error) {
|
||||
f(err)
|
||||
}
|
||||
|
||||
// nopFilterErrorHandler is a FilterErrorHandler that silently discards errors.
|
||||
var nopFilterErrorHandler = FilterErrorHandlerFunc(func(error) {})
|
||||
|
||||
// Filter is responsible for the actual "grep" part of peco
|
||||
type Filter struct {
|
||||
state *Peco
|
||||
prevQuery string
|
||||
prevResults *MemoryBuffer
|
||||
prevFilterName string
|
||||
prevFrozenSrc *MemoryBuffer // frozen source at cache time
|
||||
prevMu sync.Mutex
|
||||
}
|
||||
|
||||
type filterProcessor struct {
|
||||
filter filter.Filter
|
||||
query string
|
||||
bufSize int
|
||||
errorHandler FilterErrorHandler
|
||||
}
|
||||
|
||||
// newFilterProcessor creates a filterProcessor that runs the given filter
|
||||
// against a query, managing result buffering and error reporting.
|
||||
func newFilterProcessor(f filter.Filter, q string, bufSize int, eh FilterErrorHandler) *filterProcessor {
|
||||
return &filterProcessor{
|
||||
filter: f,
|
||||
query: q,
|
||||
filter: f,
|
||||
query: q,
|
||||
bufSize: bufSize,
|
||||
errorHandler: eh,
|
||||
}
|
||||
}
|
||||
|
||||
func (fp *filterProcessor) Accept(ctx context.Context, in chan interface{}, out pipeline.ChanOutput) {
|
||||
acceptAndFilter(ctx, fp.filter, in, out)
|
||||
// Accept receives lines from the pipeline, applies the filter, and forwards matches.
|
||||
func (fp *filterProcessor) Accept(ctx context.Context, in <-chan line.Line, out pipeline.ChanOutput) {
|
||||
acceptAndFilter(ctx, fp.filter, fp.bufSize, fp.errorHandler, in, out)
|
||||
}
|
||||
|
||||
// This flusher is run in a separate goroutine so that the filter can
|
||||
// run separately from accepting incoming messages
|
||||
func flusher(ctx context.Context, f filter.Filter, incoming chan []line.Line, done chan struct{}, out pipeline.ChanOutput) {
|
||||
// orderedChunk is a batch of lines tagged with a sequence number
|
||||
// so that parallel filter results can be merged back in order.
|
||||
type orderedChunk struct {
|
||||
seq int
|
||||
lines []line.Line
|
||||
}
|
||||
|
||||
// orderedResult is a filtered chunk tagged with the original sequence number.
|
||||
type orderedResult struct {
|
||||
seq int
|
||||
matched []line.Line
|
||||
}
|
||||
|
||||
// reportFilterError reports a filter error to the given handler.
|
||||
// If the context is already cancelled or err is nil, it does nothing.
|
||||
func reportFilterError(ctx context.Context, err error, eh FilterErrorHandler) {
|
||||
if err == nil || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
eh.HandleError(err)
|
||||
}
|
||||
|
||||
// flusher is the single-threaded fallback used when the filter does not
|
||||
// support parallel execution (e.g. Fuzzy with sortLongest).
|
||||
func flusher(ctx context.Context, f filter.Filter, incoming chan []line.Line, done chan struct{}, out pipeline.ChanOutput, eh FilterErrorHandler) {
|
||||
if pdebug.Enabled {
|
||||
g := pdebug.Marker("flusher goroutine")
|
||||
defer g.End()
|
||||
}
|
||||
|
||||
defer close(done)
|
||||
defer out.SendEndMark("end of filter")
|
||||
defer close(out)
|
||||
|
||||
for {
|
||||
select {
|
||||
|
|
@ -44,26 +105,210 @@ func flusher(ctx context.Context, f filter.Filter, incoming chan []line.Line, do
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
pdebug.Printf("flusher: %#v", buf)
|
||||
f.Apply(ctx, buf, out)
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("flusher: %#v", buf)
|
||||
}
|
||||
if err := f.Apply(ctx, buf, out); err != nil {
|
||||
reportFilterError(ctx, err, eh)
|
||||
}
|
||||
buffer.ReleaseLineListBuf(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func acceptAndFilter(ctx context.Context, f filter.Filter, in chan interface{}, out pipeline.ChanOutput) {
|
||||
flush := make(chan []line.Line)
|
||||
flushDone := make(chan struct{})
|
||||
go flusher(ctx, f, flush, flushDone, out)
|
||||
// parallelFlusher distributes filter work across multiple goroutines
|
||||
// and merges the results back in sequence order.
|
||||
func parallelFlusher(ctx context.Context, f filter.Filter, incoming chan orderedChunk, done chan struct{}, out pipeline.ChanOutput, eh FilterErrorHandler) {
|
||||
if pdebug.Enabled {
|
||||
g := pdebug.Marker("parallelFlusher goroutine")
|
||||
defer g.End()
|
||||
}
|
||||
|
||||
defer close(done)
|
||||
defer close(out)
|
||||
|
||||
numWorkers := max(runtime.GOMAXPROCS(0), 1)
|
||||
|
||||
// workCh distributes chunks to workers
|
||||
workCh := make(chan orderedChunk, numWorkers*2)
|
||||
// resultCh collects filtered results from workers
|
||||
resultCh := make(chan orderedResult, numWorkers*2)
|
||||
|
||||
// Check once whether the filter supports direct collection (bypasses
|
||||
// per-chunk channel allocation and goroutine spawn).
|
||||
collector, canCollect := f.(filter.Collector)
|
||||
|
||||
// Start workers
|
||||
var workerWg sync.WaitGroup
|
||||
workerWg.Add(numWorkers)
|
||||
for range numWorkers {
|
||||
go func() {
|
||||
defer workerWg.Done()
|
||||
for chunk := range workCh {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
buffer.ReleaseLineListBuf(chunk.lines)
|
||||
continue
|
||||
default:
|
||||
}
|
||||
|
||||
var matched []line.Line
|
||||
if canCollect {
|
||||
// Fast path: collect results directly into a slice
|
||||
var err error
|
||||
matched, err = collector.ApplyCollect(ctx, chunk.lines)
|
||||
if err != nil {
|
||||
reportFilterError(ctx, err, eh)
|
||||
}
|
||||
} else {
|
||||
// Fallback: use channel-based Apply for filters that
|
||||
// don't implement Collector (e.g. ExternalCmd)
|
||||
collectCh := make(chan line.Line, len(chunk.lines))
|
||||
go func(chunk orderedChunk) {
|
||||
if err := f.Apply(ctx, chunk.lines, pipeline.ChanOutput(collectCh)); err != nil {
|
||||
reportFilterError(ctx, err, eh)
|
||||
}
|
||||
close(collectCh)
|
||||
}(chunk)
|
||||
matched = make([]line.Line, 0, len(chunk.lines)/2)
|
||||
for l := range collectCh {
|
||||
matched = append(matched, l)
|
||||
}
|
||||
}
|
||||
|
||||
buffer.ReleaseLineListBuf(chunk.lines)
|
||||
|
||||
select {
|
||||
case resultCh <- orderedResult{seq: chunk.seq, matched: matched}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Close resultCh when all workers are done
|
||||
go func() {
|
||||
workerWg.Wait()
|
||||
close(resultCh)
|
||||
}()
|
||||
|
||||
// Merger goroutine: reorder results and send to out in sequence order
|
||||
mergerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(mergerDone)
|
||||
pending := make(map[int]orderedResult)
|
||||
nextSeq := 0
|
||||
|
||||
for result := range resultCh {
|
||||
pending[result.seq] = result
|
||||
|
||||
// Flush as many in-order results as possible
|
||||
for {
|
||||
r, ok := pending[nextSeq]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
delete(pending, nextSeq)
|
||||
nextSeq++
|
||||
|
||||
for _, l := range r.matched {
|
||||
if err := out.Send(ctx, l); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any remaining (shouldn't happen if producers are well-behaved)
|
||||
for seq := nextSeq; ; seq++ {
|
||||
r, ok := pending[seq]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
for _, l := range r.matched {
|
||||
if err := out.Send(ctx, l); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Dispatch incoming chunks to workers
|
||||
for chunk := range incoming {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
buffer.ReleaseLineListBuf(chunk.lines)
|
||||
case workCh <- chunk:
|
||||
}
|
||||
}
|
||||
close(workCh)
|
||||
|
||||
// Wait for merger to finish
|
||||
<-mergerDone
|
||||
}
|
||||
|
||||
// AcceptAndFilter is the exported entry point for the filter pipeline stage.
|
||||
// It batches incoming lines and dispatches them to the filter, using parallel
|
||||
// workers when the filter supports it.
|
||||
func AcceptAndFilter(ctx context.Context, f filter.Filter, configBufSize int, in <-chan line.Line, out pipeline.ChanOutput) {
|
||||
acceptAndFilter(ctx, f, configBufSize, nopFilterErrorHandler, in, out)
|
||||
}
|
||||
|
||||
// acceptAndFilter is the core filtering loop: it reads lines from in, batches
|
||||
// them, applies the filter function, and buffers matches for output.
|
||||
func acceptAndFilter(ctx context.Context, f filter.Filter, configBufSize int, eh FilterErrorHandler, in <-chan line.Line, out pipeline.ChanOutput) {
|
||||
useParallel := f.SupportsParallel() && runtime.GOMAXPROCS(0) > 1
|
||||
|
||||
buf := buffer.GetLineListBuf()
|
||||
bufsiz := f.BufSize()
|
||||
if bufsiz <= 0 {
|
||||
bufsiz = cap(buf)
|
||||
if configBufSize > 0 {
|
||||
bufsiz = configBufSize
|
||||
} else {
|
||||
bufsiz = cap(buf)
|
||||
}
|
||||
}
|
||||
defer func() { <-flushDone }() // Wait till the flush goroutine is done
|
||||
defer close(flush) // Kill the flush goroutine
|
||||
|
||||
if useParallel {
|
||||
acceptAndFilterParallel(ctx, f, bufsiz, buf, eh, in, out)
|
||||
} else {
|
||||
acceptAndFilterSerial(ctx, f, bufsiz, buf, eh, in, out)
|
||||
}
|
||||
}
|
||||
|
||||
// acceptAndFilterSerial runs the filter in a single goroutine, used as the
|
||||
// fallback when the filter does not support parallel execution.
|
||||
func acceptAndFilterSerial(ctx context.Context, f filter.Filter, bufsiz int, buf []line.Line, eh FilterErrorHandler, in <-chan line.Line, out pipeline.ChanOutput) {
|
||||
flush := make(chan []line.Line)
|
||||
flushDone := make(chan struct{})
|
||||
go flusher(ctx, f, flush, flushDone, out, eh)
|
||||
defer func() { <-flushDone }()
|
||||
defer close(flush)
|
||||
|
||||
batchAndFlush(ctx, bufsiz, buf, in, flush, func(b []line.Line) []line.Line { return b })
|
||||
}
|
||||
|
||||
// acceptAndFilterParallel distributes filter work across multiple goroutines,
|
||||
// tagging each batch with a sequence number for ordered result merging.
|
||||
func acceptAndFilterParallel(ctx context.Context, f filter.Filter, bufsiz int, buf []line.Line, eh FilterErrorHandler, in <-chan line.Line, out pipeline.ChanOutput) {
|
||||
flush := make(chan orderedChunk)
|
||||
flushDone := make(chan struct{})
|
||||
go parallelFlusher(ctx, f, flush, flushDone, out, eh)
|
||||
defer func() { <-flushDone }()
|
||||
defer close(flush)
|
||||
|
||||
seq := 0
|
||||
batchAndFlush(ctx, bufsiz, buf, in, flush, func(b []line.Line) orderedChunk {
|
||||
chunk := orderedChunk{seq: seq, lines: b}
|
||||
seq++
|
||||
return chunk
|
||||
})
|
||||
}
|
||||
|
||||
// batchAndFlush reads lines from in, batches them into slices of up to bufsiz,
|
||||
// and sends each batch to flushCh via the wrap function. Batches are flushed
|
||||
// when full or every 50ms, whichever comes first.
|
||||
func batchAndFlush[T any](ctx context.Context, bufsiz int, buf []line.Line, in <-chan line.Line, flushCh chan T, wrap func([]line.Line) T) {
|
||||
flushTicker := time.NewTicker(50 * time.Millisecond)
|
||||
defer flushTicker.Stop()
|
||||
|
||||
|
|
@ -78,65 +323,105 @@ func acceptAndFilter(ctx context.Context, f filter.Filter, in chan interface{},
|
|||
return
|
||||
case <-flushTicker.C:
|
||||
if len(buf) > 0 {
|
||||
flush <- buf
|
||||
flushCh <- wrap(buf)
|
||||
buf = buffer.GetLineListBuf()
|
||||
}
|
||||
case v := <-in:
|
||||
switch v.(type) {
|
||||
case error:
|
||||
if pipeline.IsEndMark(v.(error)) {
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("filter received end mark (read %d lines, %s since starting accept loop)", lines+len(buf), time.Since(start).String())
|
||||
}
|
||||
if len(buf) > 0 {
|
||||
flush <- buf
|
||||
buf = nil
|
||||
}
|
||||
case v, ok := <-in:
|
||||
if !ok {
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("filter input closed (read %d lines, %s since starting accept loop)", lines+len(buf), time.Since(start).String())
|
||||
}
|
||||
if len(buf) > 0 {
|
||||
flushCh <- wrap(buf)
|
||||
}
|
||||
return
|
||||
case line.Line:
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("incoming line")
|
||||
lines++
|
||||
}
|
||||
// We buffer the lines so that we can receive more lines to
|
||||
// process while we filter what we already have. The buffer
|
||||
// size is fairly big, because this really only makes a
|
||||
// difference if we have a lot of lines to process.
|
||||
buf = append(buf, v.(line.Line))
|
||||
if len(buf) >= bufsiz {
|
||||
flush <- buf
|
||||
buf = buffer.GetLineListBuf()
|
||||
}
|
||||
}
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("incoming line")
|
||||
lines++
|
||||
}
|
||||
buf = append(buf, v)
|
||||
if len(buf) >= bufsiz {
|
||||
flushCh <- wrap(buf)
|
||||
buf = buffer.GetLineListBuf()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewFilter creates a new Filter bound to the given Peco state.
|
||||
func NewFilter(state *Peco) *Filter {
|
||||
return &Filter{
|
||||
state: state,
|
||||
}
|
||||
}
|
||||
|
||||
// Work is the actual work horse that that does the matching
|
||||
// in a goroutine of its own. It wraps Matcher.Match().
|
||||
func (f *Filter) Work(ctx context.Context, q hub.Payload) {
|
||||
defer q.Done()
|
||||
|
||||
query, ok := q.Data().(string)
|
||||
if !ok {
|
||||
return
|
||||
// isQueryRefinement returns true if newQuery is a refinement of prevQuery,
|
||||
// meaning the new query can only produce a subset of the previous results.
|
||||
// With negative terms, refinement requires:
|
||||
// 1. Positive portion of prev is a prefix of positive portion of new
|
||||
// 2. All previous negative terms are still present in new
|
||||
// 3. New query may have additional positive or negative terms
|
||||
func isQueryRefinement(prev, cur string) bool {
|
||||
prev = strings.TrimSpace(prev)
|
||||
cur = strings.TrimSpace(cur)
|
||||
if prev == "" || cur == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
prevPos, prevNeg := filter.SplitQueryTerms(prev)
|
||||
newPos, newNeg := filter.SplitQueryTerms(cur)
|
||||
|
||||
// Positive portion: the joined prev positive terms must be a prefix of the joined new positive terms
|
||||
prevPosStr := strings.Join(prevPos, " ")
|
||||
newPosStr := strings.Join(newPos, " ")
|
||||
if prevPosStr != "" && !strings.HasPrefix(newPosStr, prevPosStr) {
|
||||
return false
|
||||
}
|
||||
|
||||
// All previous negative terms must still be present in new negative terms
|
||||
if len(prevNeg) > 0 {
|
||||
newNegSet := make(map[string]struct{}, len(newNeg))
|
||||
for _, t := range newNeg {
|
||||
newNegSet[t] = struct{}{}
|
||||
}
|
||||
for _, t := range prevNeg {
|
||||
if _, ok := newNegSet[t]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// At least one positive or negative term must exist in both
|
||||
if len(prevPos) == 0 && len(prevNeg) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Work is the actual work horse that does the matching
|
||||
// in a goroutine of its own. It wraps Matcher.Match().
|
||||
func (f *Filter) Work(ctx context.Context, q *hub.Payload[string]) {
|
||||
defer q.Done()
|
||||
|
||||
query := q.Data()
|
||||
|
||||
if pdebug.Enabled {
|
||||
g := pdebug.Marker("Filter.Work query '%s'", query)
|
||||
g := pdebug.Marker("Filter.Work (query=%#v, batch=%#v)", query, q.Batch())
|
||||
defer g.End()
|
||||
}
|
||||
|
||||
state := f.state
|
||||
if query == "" {
|
||||
state.ResetCurrentLineBuffer()
|
||||
f.prevMu.Lock()
|
||||
f.prevQuery = ""
|
||||
f.prevResults = nil
|
||||
f.prevFilterName = ""
|
||||
f.prevFrozenSrc = nil
|
||||
f.prevMu.Unlock()
|
||||
|
||||
state.ResetCurrentLineBuffer(ctx)
|
||||
if !state.config.StickySelection {
|
||||
state.Selection().Reset()
|
||||
}
|
||||
|
|
@ -145,45 +430,90 @@ func (f *Filter) Work(ctx context.Context, q hub.Payload) {
|
|||
|
||||
// Create a new pipeline
|
||||
p := pipeline.New()
|
||||
p.SetSource(state.Source())
|
||||
|
||||
// Wraps the actual filter
|
||||
// Determine the source: use incremental filtering if possible
|
||||
selectedFilter := state.Filters().Current()
|
||||
ctx = selectedFilter.NewContext(ctx, query)
|
||||
p.Add(newFilterProcessor(selectedFilter, query))
|
||||
filterName := selectedFilter.String()
|
||||
|
||||
buf := NewMemoryBuffer()
|
||||
p.SetDestination(buf)
|
||||
state.SetCurrentLineBuffer(buf)
|
||||
|
||||
go func() {
|
||||
defer state.Hub().SendDraw(&DrawOptions{RunningQuery: true})
|
||||
if err := p.Run(ctx); err != nil {
|
||||
state.Hub().SendStatusMsg(err.Error())
|
||||
var src pipeline.Source
|
||||
var srcSize int
|
||||
f.prevMu.Lock()
|
||||
if f.prevResults != nil &&
|
||||
f.prevFilterName == filterName &&
|
||||
f.prevFrozenSrc == state.Frozen().Source() &&
|
||||
isQueryRefinement(f.prevQuery, query) {
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("Using incremental source (prev=%q, new=%q, prevSize=%d)", f.prevQuery, query, f.prevResults.Size())
|
||||
}
|
||||
}()
|
||||
srcSize = f.prevResults.Size()
|
||||
src = NewMemoryBufferSource(f.prevResults)
|
||||
}
|
||||
f.prevMu.Unlock()
|
||||
|
||||
if src == nil {
|
||||
if fs := state.Frozen().Source(); fs != nil {
|
||||
src = NewMemoryBufferSource(fs)
|
||||
srcSize = fs.Size()
|
||||
} else {
|
||||
src = state.Source()
|
||||
if sizer, ok := src.(interface{ Size() int }); ok {
|
||||
srcSize = sizer.Size()
|
||||
}
|
||||
}
|
||||
}
|
||||
p.SetSource(src)
|
||||
|
||||
ctx = selectedFilter.NewContext(ctx, query)
|
||||
// Report non-cancellation filter errors (e.g. regex compilation failures)
|
||||
// to the status bar so the user can see why results are missing.
|
||||
onFilterError := FilterErrorHandlerFunc(func(err error) {
|
||||
state.Hub().SendStatusMsg(ctx, err.Error(), 5*time.Second)
|
||||
})
|
||||
p.Add(newFilterProcessor(selectedFilter, query, state.config.FilterBufSize, onFilterError))
|
||||
|
||||
buf := NewMemoryBuffer(srcSize / 4)
|
||||
p.SetDestination(buf)
|
||||
state.SetCurrentLineBuffer(ctx, buf)
|
||||
|
||||
go func(ctx context.Context) {
|
||||
defer state.Hub().SendDraw(ctx, &hub.DrawOptions{RunningQuery: true})
|
||||
if err := p.Run(ctx); err != nil {
|
||||
state.Hub().SendStatusMsg(ctx, err.Error(), 0)
|
||||
}
|
||||
}(ctx)
|
||||
|
||||
go func() {
|
||||
if pdebug.Enabled {
|
||||
g := pdebug.Marker("Periodic draw request for '%s'", query)
|
||||
defer g.End()
|
||||
}
|
||||
t := time.NewTicker(5 * time.Millisecond)
|
||||
t := time.NewTicker(50 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
defer state.Hub().SendStatusMsg("")
|
||||
defer state.Hub().SendDraw(&DrawOptions{RunningQuery: true})
|
||||
defer state.Hub().SendStatusMsg(ctx, "", 0)
|
||||
defer state.Hub().SendDraw(ctx, &hub.DrawOptions{RunningQuery: true})
|
||||
for {
|
||||
select {
|
||||
case <-p.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
state.Hub().SendDraw(&DrawOptions{RunningQuery: true})
|
||||
state.Hub().SendDraw(ctx, &hub.DrawOptions{RunningQuery: true})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
<-p.Done()
|
||||
|
||||
// Save results for incremental filtering only if pipeline completed
|
||||
// successfully (context not cancelled)
|
||||
if ctx.Err() == nil {
|
||||
f.prevMu.Lock()
|
||||
f.prevQuery = query
|
||||
f.prevResults = buf
|
||||
f.prevFilterName = filterName
|
||||
f.prevFrozenSrc = state.Frozen().Source()
|
||||
f.prevMu.Unlock()
|
||||
}
|
||||
|
||||
if !state.config.StickySelection {
|
||||
state.Selection().Reset()
|
||||
}
|
||||
|
|
@ -196,6 +526,12 @@ func (f *Filter) Work(ctx context.Context, q hub.Payload) {
|
|||
func (f *Filter) Loop(ctx context.Context, cancel func()) error {
|
||||
defer cancel()
|
||||
|
||||
// wg tracks in-flight Work goroutines so Loop doesn't return
|
||||
// while they are still running. This ensures clean shutdown
|
||||
// without blocking new queries from starting immediately.
|
||||
var wg sync.WaitGroup
|
||||
defer wg.Wait()
|
||||
|
||||
// previous holds the function that can cancel the previous
|
||||
// query. This is used when multiple queries come in succession
|
||||
// and the previous query is discarded anyway
|
||||
|
|
@ -218,9 +554,11 @@ func (f *Filter) Loop(ctx context.Context, cancel func()) error {
|
|||
previous = workcancel
|
||||
mutex.Unlock()
|
||||
|
||||
f.state.Hub().SendStatusMsg("Running query...")
|
||||
f.state.Hub().SendStatusMsg(ctx, "Running query...", 0)
|
||||
|
||||
go f.Work(workctx, q)
|
||||
wg.Go(func() {
|
||||
f.Work(workctx, q)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
77
filter/base.go
Normal file
77
filter/base.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
)
|
||||
|
||||
// LineEmitter receives matched lines from a filter.
|
||||
type LineEmitter interface {
|
||||
Emit(context.Context, line.Line)
|
||||
}
|
||||
|
||||
// chanEmitter sends matched lines to a pipeline channel.
|
||||
type chanEmitter struct {
|
||||
out pipeline.ChanOutput
|
||||
}
|
||||
|
||||
func (e *chanEmitter) Emit(ctx context.Context, l line.Line) {
|
||||
_ = e.out.Send(ctx, l)
|
||||
}
|
||||
|
||||
// LineCollector accumulates matched lines into a slice.
|
||||
type LineCollector struct {
|
||||
lines []line.Line
|
||||
}
|
||||
|
||||
// NewLineCollector creates a LineCollector pre-allocated with the given capacity.
|
||||
func NewLineCollector(n int) *LineCollector {
|
||||
return &LineCollector{lines: make([]line.Line, 0, n)}
|
||||
}
|
||||
|
||||
// Emit appends a matched line to the collector.
|
||||
func (c *LineCollector) Emit(_ context.Context, l line.Line) {
|
||||
c.lines = append(c.lines, l)
|
||||
}
|
||||
|
||||
// Lines returns the accumulated matched lines.
|
||||
func (c *LineCollector) Lines() []line.Line {
|
||||
return c.lines
|
||||
}
|
||||
|
||||
// lineApplier is implemented by filter types that embed baseFilter to provide
|
||||
// their type-specific matching logic.
|
||||
type lineApplier interface {
|
||||
applyInternal(ctx context.Context, lines []line.Line, em LineEmitter) error
|
||||
}
|
||||
|
||||
// baseFilter provides shared implementations of Apply, ApplyCollect,
|
||||
// NewContext, and BufSize for filters that follow the applyInternal pattern.
|
||||
// Filters embed this type and set impl to their concrete filter value.
|
||||
type baseFilter struct {
|
||||
impl lineApplier
|
||||
}
|
||||
|
||||
// NewContext returns a context initialized with the given query for pipeline use.
|
||||
func (b *baseFilter) NewContext(ctx context.Context, query string) context.Context {
|
||||
return newContext(ctx, query)
|
||||
}
|
||||
|
||||
func (b *baseFilter) BufSize() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Apply runs the filter's matching logic on lines, sending matches to out.
|
||||
func (b *baseFilter) Apply(ctx context.Context, lines []line.Line, out pipeline.ChanOutput) error {
|
||||
return b.impl.applyInternal(ctx, lines, &chanEmitter{out: out})
|
||||
}
|
||||
|
||||
// ApplyCollect runs the filter and returns matched lines directly as a slice,
|
||||
// bypassing channel-based output for better performance in parallel paths.
|
||||
func (b *baseFilter) ApplyCollect(ctx context.Context, lines []line.Line) ([]line.Line, error) {
|
||||
c := NewLineCollector(len(lines) / 2)
|
||||
err := b.impl.applyInternal(ctx, lines, c)
|
||||
return c.Lines(), err
|
||||
}
|
||||
140
filter/base_test.go
Normal file
140
filter/base_test.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestApplyAndApplyCollectConsistency verifies that Apply (channel-based) and
|
||||
// ApplyCollect (slice-based) produce identical results for every built-in
|
||||
// filter type. This is the key invariant that the baseFilter extraction must
|
||||
// preserve.
|
||||
func TestApplyAndApplyCollectConsistency(t *testing.T) {
|
||||
lines := makeLines(
|
||||
"hello world",
|
||||
"hello tests",
|
||||
"goodbye world",
|
||||
"goodbye tests",
|
||||
"fuzzy matching example",
|
||||
"another line",
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filter Filter
|
||||
query string
|
||||
}{
|
||||
{"IgnoreCase", NewIgnoreCase(), "hello"},
|
||||
{"CaseSensitive", NewCaseSensitive(), "hello"},
|
||||
{"SmartCase", NewSmartCase(), "hello"},
|
||||
{"Regexp", NewRegexp(), "hello.*world"},
|
||||
{"IRegexp", NewIRegexp(), "HELLO"},
|
||||
{"Fuzzy", NewFuzzy(false), "hw"},
|
||||
{"FuzzyLongest", NewFuzzy(true), "hw"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(
|
||||
tt.filter.NewContext(context.Background(), tt.query),
|
||||
10*time.Second,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
// Collect via Apply (channel path)
|
||||
ch := make(chan line.Line, len(lines)+1)
|
||||
err := tt.filter.Apply(ctx, lines, pipeline.ChanOutput(ch))
|
||||
require.NoError(t, err, "Apply should succeed")
|
||||
close(ch)
|
||||
|
||||
var applyResults []string
|
||||
for l := range ch {
|
||||
applyResults = append(applyResults, l.DisplayString())
|
||||
}
|
||||
|
||||
// Collect via ApplyCollect (direct slice path)
|
||||
collector, ok := tt.filter.(Collector)
|
||||
require.True(t, ok, "%s should implement Collector", tt.name)
|
||||
|
||||
ctx2, cancel2 := context.WithTimeout(
|
||||
tt.filter.NewContext(context.Background(), tt.query),
|
||||
10*time.Second,
|
||||
)
|
||||
defer cancel2()
|
||||
|
||||
collected, err := collector.ApplyCollect(ctx2, lines)
|
||||
require.NoError(t, err, "ApplyCollect should succeed")
|
||||
|
||||
collectResults := make([]string, 0, len(collected))
|
||||
for _, l := range collected {
|
||||
collectResults = append(collectResults, l.DisplayString())
|
||||
}
|
||||
|
||||
// They must produce exactly the same results
|
||||
require.Equal(t, applyResults, collectResults,
|
||||
"Apply and ApplyCollect should produce identical results")
|
||||
require.NotEmpty(t, applyResults,
|
||||
"test should produce at least one match (verify test data)")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBufSizeDefaults verifies that built-in filters return the expected
|
||||
// BufSize (0 for Regexp/Fuzzy, meaning "use config default").
|
||||
func TestBufSizeDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filter Filter
|
||||
expected int
|
||||
}{
|
||||
{"IgnoreCase", NewIgnoreCase(), 0},
|
||||
{"CaseSensitive", NewCaseSensitive(), 0},
|
||||
{"SmartCase", NewSmartCase(), 0},
|
||||
{"Regexp", NewRegexp(), 0},
|
||||
{"IRegexp", NewIRegexp(), 0},
|
||||
{"Fuzzy", NewFuzzy(false), 0},
|
||||
{"FuzzyLongest", NewFuzzy(true), 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.expected, tt.filter.BufSize())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewContextStoresQuery verifies that NewContext stores the query and it
|
||||
// can be retrieved by the filter's applyInternal.
|
||||
func TestNewContextStoresQuery(t *testing.T) {
|
||||
filters := []struct {
|
||||
name string
|
||||
filter Filter
|
||||
}{
|
||||
{"IgnoreCase", NewIgnoreCase()},
|
||||
{"Fuzzy", NewFuzzy(false)},
|
||||
}
|
||||
|
||||
for _, tt := range filters {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := tt.filter.NewContext(context.Background(), "test-query")
|
||||
// The query should be stored in context — verify by running Apply
|
||||
// with a line that matches "test-query"
|
||||
ch := make(chan line.Line, 2)
|
||||
lines := makeLines("this is a test-query line")
|
||||
err := tt.filter.Apply(ctx, lines, pipeline.ChanOutput(ch))
|
||||
require.NoError(t, err)
|
||||
close(ch)
|
||||
|
||||
var results []line.Line
|
||||
for l := range ch {
|
||||
results = append(results, l)
|
||||
}
|
||||
require.Len(t, results, 1, "query stored by NewContext should be used by Apply")
|
||||
})
|
||||
}
|
||||
}
|
||||
143
filter/bench_test.go
Normal file
143
filter/bench_test.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/peco/peco/internal/util"
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
)
|
||||
|
||||
// BenchmarkFuzzyFilter benchmarks the fuzzy filter to measure allocations
|
||||
// in the hot path (CaseInsensitiveIndexFunc closure, match slices, etc).
|
||||
func BenchmarkFuzzyFilter(b *testing.B) {
|
||||
lines := make([]line.Line, 200)
|
||||
for i := range lines {
|
||||
lines[i] = line.NewRaw(uint64(i), "this is a reasonably long line for benchmarking the fuzzy filter path", false, false)
|
||||
}
|
||||
|
||||
f := NewFuzzy(false)
|
||||
query := "trfp" // matches scattered chars across the line
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
ctx, cancel := context.WithTimeout(f.NewContext(context.Background(), query), 10*time.Second)
|
||||
ch := make(chan line.Line, len(lines))
|
||||
_ = f.Apply(ctx, lines, pipeline.ChanOutput(ch))
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkCaseInsensitiveIndexClosure measures the old closure-based approach.
|
||||
func BenchmarkCaseInsensitiveIndexClosure(b *testing.B) {
|
||||
txt := "this is a reasonably long line for benchmarking"
|
||||
r := 'r'
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
fn := util.CaseInsensitiveIndexFunc(r)
|
||||
strings.IndexFunc(txt, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkCaseInsensitiveIndexDirect measures the new direct approach.
|
||||
func BenchmarkCaseInsensitiveIndexDirect(b *testing.B) {
|
||||
txt := "this is a reasonably long line for benchmarking"
|
||||
r := 'r'
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
util.CaseInsensitiveIndex(txt, r)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkRegexpFilter benchmarks the regexp filter with overlapping matches
|
||||
// to exercise the dedup/merge path and matches slice allocation.
|
||||
func BenchmarkRegexpFilter(b *testing.B) {
|
||||
lines := make([]line.Line, 200)
|
||||
for i := range lines {
|
||||
lines[i] = line.NewRaw(uint64(i), "the quick brown fox jumps over the lazy brown dog", false, false)
|
||||
}
|
||||
|
||||
f := NewIgnoreCase()
|
||||
query := "brown the"
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
ctx, cancel := context.WithTimeout(f.NewContext(context.Background(), query), 10*time.Second)
|
||||
ch := make(chan line.Line, len(lines))
|
||||
_ = f.Apply(ctx, lines, pipeline.ChanOutput(ch))
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkRegexpFilterMultiCycle simulates multiple keystroke filter cycles.
|
||||
// On each cycle, the filter produces Matched objects which are collected in a
|
||||
// MemoryBuffer, then Reset() is called to release them before the next cycle.
|
||||
// This demonstrates pool reuse across keystrokes.
|
||||
func BenchmarkRegexpFilterMultiCycle(b *testing.B) {
|
||||
const numLines = 10_000
|
||||
lines := make([]line.Line, numLines)
|
||||
for i := range lines {
|
||||
lines[i] = line.NewRaw(uint64(i), "the quick brown fox jumps over the lazy dog", false, false)
|
||||
}
|
||||
|
||||
f := NewIgnoreCase()
|
||||
query := "fox"
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
// Simulate 3 keystroke cycles
|
||||
for range 3 {
|
||||
ctx := f.NewContext(context.Background(), query)
|
||||
ch := make(chan line.Line, numLines)
|
||||
_ = f.Apply(ctx, lines, pipeline.ChanOutput(ch))
|
||||
close(ch)
|
||||
|
||||
// Drain and release (simulating MemoryBuffer.Reset)
|
||||
for l := range ch {
|
||||
if m, ok := l.(*line.Matched); ok {
|
||||
line.ReleaseMatched(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkRegexpFilterOverlapping benchmarks the regexp filter with a query
|
||||
// that produces overlapping match ranges, exercising the mergeMatches path.
|
||||
// Each line contains repeating "aabb" patterns; the query terms "aab" and "abb"
|
||||
// produce match ranges that overlap (e.g. [0,3] and [1,4]), forcing mergeMatches
|
||||
// to be called on every line. With 10k lines the aggregate allocation difference
|
||||
// from in-place vs make([]int,2) becomes measurable.
|
||||
func BenchmarkRegexpFilterOverlapping(b *testing.B) {
|
||||
// Build a line with many "aabb" repeats so that "aab" and "abb" each match
|
||||
// many times with overlapping ranges between the two terms.
|
||||
base := strings.Repeat("aabb", 20) // 80 chars
|
||||
lines := make([]line.Line, 10_000)
|
||||
for i := range lines {
|
||||
lines[i] = line.NewRaw(uint64(i), base, false, false)
|
||||
}
|
||||
|
||||
f := NewIgnoreCase()
|
||||
// "aab" and "abb" share the middle "ab" in each "aabb" group, so their
|
||||
// match ranges overlap after sorting by start position.
|
||||
query := "aab abb"
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
ctx, cancel := context.WithTimeout(f.NewContext(context.Background(), query), 10*time.Second)
|
||||
ch := make(chan line.Line, len(lines))
|
||||
_ = f.Apply(ctx, lines, pipeline.ChanOutput(ch))
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
|
@ -4,19 +4,39 @@ import (
|
|||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sync"
|
||||
|
||||
pdebug "github.com/lestrrat/go-pdebug"
|
||||
pdebug "github.com/lestrrat-go/pdebug"
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// DefaultCustomFilterBufferThreshold is the default value
|
||||
// for BufferThreshold setting on CustomFilters.
|
||||
const DefaultCustomFilterBufferThreshold = 100
|
||||
|
||||
// queryPlaceholder is the placeholder string in external command arguments
|
||||
// that gets replaced with the actual query at filter execution time.
|
||||
const queryPlaceholder = "$QUERY"
|
||||
|
||||
// ExternalCmd is a filter that delegates matching to an external command.
|
||||
type ExternalCmd struct {
|
||||
args []string
|
||||
cmd string
|
||||
enableSep bool
|
||||
idgen line.IDGenerator
|
||||
name string
|
||||
thresholdBufsiz int
|
||||
}
|
||||
|
||||
// NewExternalCmd creates a new filter that uses an external
|
||||
// command to filter the input
|
||||
func NewExternalCmd(name string, cmd string, args []string, threshold int, idgen line.IDGenerator, enableSep bool) *ExternalCmd {
|
||||
if len(args) == 0 {
|
||||
args = []string{"$QUERY"}
|
||||
args = []string{queryPlaceholder}
|
||||
}
|
||||
|
||||
if threshold <= 0 {
|
||||
|
|
@ -29,7 +49,6 @@ func NewExternalCmd(name string, cmd string, args []string, threshold int, idgen
|
|||
enableSep: enableSep,
|
||||
idgen: idgen,
|
||||
name: name,
|
||||
outCh: pipeline.ChanOutput(make(chan interface{})),
|
||||
thresholdBufsiz: threshold,
|
||||
}
|
||||
}
|
||||
|
|
@ -38,62 +57,91 @@ func (ecf ExternalCmd) BufSize() int {
|
|||
return ecf.thresholdBufsiz
|
||||
}
|
||||
|
||||
// NewContext returns a context initialized with the given query for pipeline use.
|
||||
func (ecf *ExternalCmd) NewContext(ctx context.Context, query string) context.Context {
|
||||
return newContext(ctx, query)
|
||||
}
|
||||
|
||||
func (ecf ExternalCmd) SupportsParallel() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (ecf ExternalCmd) String() string {
|
||||
return ecf.name
|
||||
}
|
||||
|
||||
// Apply pipes the buffered lines through the external command and sends
|
||||
// matching output lines to out.
|
||||
func (ecf *ExternalCmd) Apply(ctx context.Context, buf []line.Line, out pipeline.ChanOutput) (err error) {
|
||||
var readerPanicErr error
|
||||
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("err: %s", err)
|
||||
}
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic in external filter %q: %v", ecf.name, r)
|
||||
}
|
||||
}() // ignore errors
|
||||
if err == nil && readerPanicErr != nil {
|
||||
err = readerPanicErr
|
||||
}
|
||||
}()
|
||||
if pdebug.Enabled {
|
||||
g := pdebug.Marker("ExternalCmd.Apply").BindError(&err)
|
||||
defer g.End()
|
||||
}
|
||||
|
||||
query := ctx.Value(queryKey).(string)
|
||||
query := pipeline.QueryFromContext(ctx)
|
||||
args := append([]string(nil), ecf.args...)
|
||||
for i, v := range args {
|
||||
if v == "$QUERY" {
|
||||
if v == queryPlaceholder {
|
||||
args[i] = query
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(ecf.cmd, args...)
|
||||
cmd := exec.CommandContext(ctx, ecf.cmd, args...)
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("Executing command %s %v", cmd.Path, cmd.Args)
|
||||
}
|
||||
|
||||
// When enableSep is true (--null mode), we need to preserve the original
|
||||
// lines so that Output() returns the correct post-separator text.
|
||||
// Build an index of display string -> original lines for lookup.
|
||||
var originalLines map[string][]line.Line
|
||||
if ecf.enableSep {
|
||||
originalLines = make(map[string][]line.Line)
|
||||
for _, l := range buf {
|
||||
ds := l.DisplayString()
|
||||
originalLines[ds] = append(originalLines[ds], l)
|
||||
}
|
||||
}
|
||||
|
||||
inbuf := &bytes.Buffer{}
|
||||
for _, l := range buf {
|
||||
inbuf.WriteString(l.DisplayString() + "\n")
|
||||
inbuf.WriteString(l.DisplayString())
|
||||
inbuf.WriteByte('\n')
|
||||
}
|
||||
|
||||
cmd.Stdin = inbuf
|
||||
r, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, `failed to get stdout pipe`)
|
||||
return fmt.Errorf("failed to get stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, `failed to start command`)
|
||||
return fmt.Errorf("failed to start command: %w", err)
|
||||
}
|
||||
|
||||
go cmd.Wait()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
cmdCh := make(chan line.Line)
|
||||
wg.Add(1)
|
||||
go func(ctx context.Context, cmdCh chan line.Line, rdr *bufio.Reader) {
|
||||
defer func() { recover() }()
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
readerPanicErr = fmt.Errorf("panic in external filter %q reader: %v", ecf.name, r)
|
||||
}
|
||||
}()
|
||||
defer close(cmdCh)
|
||||
defer func() { _ = cmd.Wait() }()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
|
@ -103,17 +151,33 @@ func (ecf *ExternalCmd) Apply(ctx context.Context, buf []line.Line, out pipeline
|
|||
|
||||
b, _, err := rdr.ReadLine()
|
||||
if len(b) > 0 {
|
||||
// TODO: need to redo the spec for custom matchers
|
||||
// This is the ONLY location where we need to actually
|
||||
// RECREATE a Raw, and thus the only place where
|
||||
// ctx.enableSep is required.
|
||||
s := string(b)
|
||||
var l line.Line
|
||||
|
||||
if originalLines != nil {
|
||||
if candidates, ok := originalLines[s]; ok && len(candidates) > 0 {
|
||||
// Pop the first matching original line to preserve order
|
||||
l = candidates[0]
|
||||
originalLines[s] = candidates[1:]
|
||||
}
|
||||
}
|
||||
|
||||
if l == nil {
|
||||
// No original line found (or enableSep is false):
|
||||
// create a new Raw line as before
|
||||
l = line.NewRaw(ecf.idgen.Next(), s, ecf.enableSep, false)
|
||||
}
|
||||
|
||||
select {
|
||||
case cmdCh <- line.NewRaw(ecf.idgen.Next(), string(b), ecf.enableSep):
|
||||
case cmdCh <- l:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
readerPanicErr = fmt.Errorf("failed to read from external filter %q: %w", ecf.name, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -121,20 +185,22 @@ func (ecf *ExternalCmd) Apply(ctx context.Context, buf []line.Line, out pipeline
|
|||
|
||||
defer func() {
|
||||
if p := cmd.Process; p != nil {
|
||||
p.Kill()
|
||||
_ = p.Kill()
|
||||
}
|
||||
wg.Wait()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
return ctx.Err()
|
||||
case l, ok := <-cmdCh:
|
||||
if l == nil || !ok {
|
||||
return nil
|
||||
}
|
||||
out.Send(l)
|
||||
if err := out.Send(ctx, l); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
226
filter/external_test.go
Normal file
226
filter/external_test.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testIDGen struct {
|
||||
mu sync.Mutex
|
||||
id uint64
|
||||
}
|
||||
|
||||
func (g *testIDGen) Next() uint64 {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.id++
|
||||
return g.id
|
||||
}
|
||||
|
||||
// collectOutput drains the output channel and returns the lines sent to it.
|
||||
func collectOutput(ctx context.Context, out pipeline.ChanOutput) []line.Line {
|
||||
var results []line.Line
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return results
|
||||
case l, ok := <-out.OutCh():
|
||||
if !ok {
|
||||
return results
|
||||
}
|
||||
results = append(results, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalCmd_SupportsParallel(t *testing.T) {
|
||||
idgen := &testIDGen{}
|
||||
ecf := NewExternalCmd("cat", "cat", nil, 0, idgen, false)
|
||||
require.False(t, ecf.SupportsParallel(), "ExternalCmd must not support parallel execution")
|
||||
}
|
||||
|
||||
func TestExternalCmd_CancelCleansUpGoroutine(t *testing.T) {
|
||||
idgen := &testIDGen{}
|
||||
|
||||
lines := []line.Line{
|
||||
line.NewRaw(idgen.Next(), "hello", false, false),
|
||||
}
|
||||
|
||||
// Use "cat" which reads stdin and writes to stdout; it will block
|
||||
// waiting for EOF on stdin, but since we provide input via a buffer
|
||||
// it completes quickly. Instead, use "sleep" which blocks for a long time.
|
||||
ecf := NewExternalCmd("sleep", "sleep", []string{"60"}, 0, idgen, false)
|
||||
ctx, cancel := context.WithCancel(ecf.NewContext(context.Background(), "test"))
|
||||
out := pipeline.ChanOutput(make(chan line.Line, 256))
|
||||
|
||||
// Record goroutine count before Apply
|
||||
runtime.GC()
|
||||
before := runtime.NumGoroutine()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- ecf.Apply(ctx, lines, out)
|
||||
}()
|
||||
|
||||
// Give the command time to start
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Cancel the context
|
||||
cancel()
|
||||
|
||||
// Apply should return promptly
|
||||
select {
|
||||
case err := <-done:
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
case <-time.After(5 * time.Second):
|
||||
require.Fail(t, "Apply did not return after context cancellation")
|
||||
}
|
||||
|
||||
// Wait briefly for goroutine cleanup, then verify no goroutine leak
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
runtime.GC()
|
||||
after := runtime.NumGoroutine()
|
||||
// Allow some slack (test infrastructure goroutines)
|
||||
if after <= before+1 {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
require.Fail(t, "goroutine leak", "before=%d, after=%d", before, runtime.NumGoroutine())
|
||||
}
|
||||
|
||||
func TestExternalCmd_ApplyWithoutQueryContext(t *testing.T) {
|
||||
idgen := &testIDGen{}
|
||||
|
||||
lines := []line.Line{
|
||||
line.NewRaw(idgen.Next(), "hello", false, false),
|
||||
}
|
||||
|
||||
ecf := NewExternalCmd("cat", "cat", nil, 0, idgen, false)
|
||||
out := pipeline.ChanOutput(make(chan line.Line, 256))
|
||||
|
||||
// Call Apply with a context that does NOT have the query key set.
|
||||
// QueryFromContext returns "" safely (no panic).
|
||||
err := ecf.Apply(context.Background(), lines, out)
|
||||
require.NoError(t, err, "Apply with missing query context should not panic or error")
|
||||
}
|
||||
|
||||
func TestExternalCmdFilter_NullSep(t *testing.T) {
|
||||
t.Run("preserves Output with enableSep", func(t *testing.T) {
|
||||
idgen := &testIDGen{}
|
||||
|
||||
// Create lines with null separator: display\0output
|
||||
lines := []line.Line{
|
||||
line.NewRaw(idgen.Next(), "apple\x00/fruit/apple", true, false),
|
||||
line.NewRaw(idgen.Next(), "banana\x00/fruit/banana", true, false),
|
||||
line.NewRaw(idgen.Next(), "apricot\x00/fruit/apricot", true, false),
|
||||
}
|
||||
|
||||
// Verify the lines are set up correctly
|
||||
require.Equal(t, "apple", lines[0].DisplayString())
|
||||
require.Equal(t, "/fruit/apple", lines[0].Output())
|
||||
|
||||
// Use grep to filter for lines containing "ap" (matches apple, apricot)
|
||||
ecf := NewExternalCmd("grep", "grep", []string{"ap"}, 0, idgen, true)
|
||||
|
||||
ctx := ecf.NewContext(context.Background(), "ap")
|
||||
out := pipeline.ChanOutput(make(chan line.Line, 256))
|
||||
|
||||
var results []line.Line
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
results = collectOutput(ctx, out)
|
||||
}()
|
||||
|
||||
err := ecf.Apply(ctx, lines, out)
|
||||
require.NoError(t, err)
|
||||
close(out)
|
||||
<-done
|
||||
|
||||
require.Len(t, results, 2)
|
||||
|
||||
// The key assertion: Output() must return the post-separator text,
|
||||
// not the display text that the external filter matched on.
|
||||
require.Equal(t, "apple", results[0].DisplayString())
|
||||
require.Equal(t, "/fruit/apple", results[0].Output())
|
||||
|
||||
require.Equal(t, "apricot", results[1].DisplayString())
|
||||
require.Equal(t, "/fruit/apricot", results[1].Output())
|
||||
})
|
||||
|
||||
t.Run("without enableSep creates new lines", func(t *testing.T) {
|
||||
idgen := &testIDGen{}
|
||||
|
||||
lines := []line.Line{
|
||||
line.NewRaw(idgen.Next(), "apple", false, false),
|
||||
line.NewRaw(idgen.Next(), "banana", false, false),
|
||||
line.NewRaw(idgen.Next(), "apricot", false, false),
|
||||
}
|
||||
|
||||
ecf := NewExternalCmd("grep", "grep", []string{"ap"}, 0, idgen, false)
|
||||
|
||||
ctx := ecf.NewContext(context.Background(), "ap")
|
||||
out := pipeline.ChanOutput(make(chan line.Line, 256))
|
||||
|
||||
var results []line.Line
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
results = collectOutput(ctx, out)
|
||||
}()
|
||||
|
||||
err := ecf.Apply(ctx, lines, out)
|
||||
require.NoError(t, err)
|
||||
close(out)
|
||||
<-done
|
||||
|
||||
require.Len(t, results, 2)
|
||||
require.Equal(t, "apple", results[0].DisplayString())
|
||||
require.Equal(t, "apple", results[0].Output())
|
||||
require.Equal(t, "apricot", results[1].DisplayString())
|
||||
require.Equal(t, "apricot", results[1].Output())
|
||||
})
|
||||
|
||||
t.Run("duplicate display strings preserve order", func(t *testing.T) {
|
||||
idgen := &testIDGen{}
|
||||
|
||||
// Three lines with the same display text but different outputs
|
||||
lines := []line.Line{
|
||||
line.NewRaw(idgen.Next(), "dup\x00first", true, false),
|
||||
line.NewRaw(idgen.Next(), "dup\x00second", true, false),
|
||||
line.NewRaw(idgen.Next(), "dup\x00third", true, false),
|
||||
}
|
||||
|
||||
// Use grep to return all lines (match literal "dup")
|
||||
ecf := NewExternalCmd("grep", "grep", []string{"-F", "$QUERY"}, 0, idgen, true)
|
||||
|
||||
ctx := ecf.NewContext(context.Background(), "dup")
|
||||
out := pipeline.ChanOutput(make(chan line.Line, 256))
|
||||
|
||||
var results []line.Line
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
results = collectOutput(ctx, out)
|
||||
}()
|
||||
|
||||
err := ecf.Apply(ctx, lines, out)
|
||||
require.NoError(t, err)
|
||||
close(out)
|
||||
<-done
|
||||
|
||||
require.Len(t, results, 3)
|
||||
require.Equal(t, "first", results[0].Output())
|
||||
require.Equal(t, "second", results[1].Output())
|
||||
require.Equal(t, "third", results[2].Output())
|
||||
})
|
||||
}
|
||||
|
|
@ -1,11 +1,61 @@
|
|||
package filter
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
)
|
||||
|
||||
// Filter is the interface that all filter implementations must satisfy.
|
||||
type Filter interface {
|
||||
Apply(context.Context, []line.Line, pipeline.ChanOutput) error
|
||||
BufSize() int
|
||||
NewContext(context.Context, string) context.Context
|
||||
String() string
|
||||
// SupportsParallel returns true if this filter can safely be invoked
|
||||
// concurrently on independent chunks of lines. Filters that require
|
||||
// global state across all lines (e.g. sorted output) should return false.
|
||||
SupportsParallel() bool
|
||||
}
|
||||
|
||||
// Collector is an optional interface that filters can implement to return
|
||||
// matched lines directly as a slice, bypassing channel-based output.
|
||||
// This avoids per-chunk channel allocation and goroutine overhead in the
|
||||
// parallel filter path.
|
||||
type Collector interface {
|
||||
ApplyCollect(context.Context, []line.Line) ([]line.Line, error)
|
||||
}
|
||||
|
||||
// isExcluded reports whether text matches any of the given negative regexps.
|
||||
func isExcluded(negRegexps []*regexp.Regexp, text string) bool {
|
||||
for _, rx := range negRegexps {
|
||||
if rx.MatchString(text) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// newContext initializes the context so that it is suitable
|
||||
// to be passed to `Run()`
|
||||
func newContext(ctx context.Context, query string) context.Context {
|
||||
return context.WithValue(ctx, queryKey, query)
|
||||
return pipeline.NewQueryContext(ctx, query)
|
||||
}
|
||||
|
||||
// checkCancelled returns ctx.Err() every 1000 iterations so that
|
||||
// long-running filter loops can bail out promptly on cancellation
|
||||
// without paying the cost of a channel receive on every single line.
|
||||
func checkCancelled(ctx context.Context, i int) error {
|
||||
if i%1000 == 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sort related stuff
|
||||
|
|
@ -30,31 +80,22 @@ func (m byMatchStart) Less(i, j int) bool {
|
|||
|
||||
return false
|
||||
}
|
||||
|
||||
// matchContains reports whether match range a fully contains match range b.
|
||||
func matchContains(a []int, b []int) bool {
|
||||
return a[0] <= b[0] && a[1] >= b[1]
|
||||
}
|
||||
|
||||
// matchOverlaps reports whether two match ranges overlap.
|
||||
func matchOverlaps(a []int, b []int) bool {
|
||||
return a[0] <= b[0] && a[1] >= b[0] ||
|
||||
a[0] <= b[1] && a[1] >= b[1]
|
||||
}
|
||||
|
||||
// mergeMatches combines two overlapping match ranges into a single range
|
||||
// spanning both. It mutates and returns a to avoid a heap allocation.
|
||||
func mergeMatches(a []int, b []int) []int {
|
||||
ret := make([]int, 2)
|
||||
|
||||
// Note: In practice this should never happen
|
||||
// because we're sorting by N[0] before calling
|
||||
// this routine, but for completeness' sake...
|
||||
if a[0] < b[0] {
|
||||
ret[0] = a[0]
|
||||
} else {
|
||||
ret[0] = b[0]
|
||||
}
|
||||
|
||||
if a[1] < b[1] {
|
||||
ret[1] = b[1]
|
||||
} else {
|
||||
ret[1] = a[1]
|
||||
}
|
||||
return ret
|
||||
a[0] = min(a[0], b[0])
|
||||
a[1] = max(a[1], b[1])
|
||||
return a
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,23 +3,71 @@ package filter
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type indexer interface {
|
||||
Indices() [][]int
|
||||
}
|
||||
|
||||
// TestFuzzy tests a fuzzy filter against various inputs
|
||||
func TestFuzzy(t *testing.T) {
|
||||
octx, ocancel := context.WithCancel(context.Background())
|
||||
defer ocancel()
|
||||
func TestCheckCancelled(t *testing.T) {
|
||||
t.Run("returns nil for non-boundary iterations", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, i := range []int{1, 2, 999, 1001, 2999} {
|
||||
require.NoError(t, checkCancelled(ctx, i))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns nil at boundary when context is active", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, i := range []int{0, 1000, 2000, 3000} {
|
||||
require.NoError(t, checkCancelled(ctx, i))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error at boundary when context is cancelled", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
require.ErrorIs(t, checkCancelled(ctx, 0), context.Canceled)
|
||||
require.ErrorIs(t, checkCancelled(ctx, 1000), context.Canceled)
|
||||
})
|
||||
|
||||
t.Run("returns nil off-boundary even when context is cancelled", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
require.NoError(t, checkCancelled(ctx, 1))
|
||||
require.NoError(t, checkCancelled(ctx, 999))
|
||||
})
|
||||
}
|
||||
|
||||
// TestFuzzy tests the Fuzzy filter against various inputs.
|
||||
//
|
||||
// testFuzzy: simple substring match
|
||||
// testFuzzyLongest: sorted longest substring match
|
||||
// testFuzzyMatch: match position without sorting
|
||||
// testFuzzyLongestMatch: match position with sorting
|
||||
func TestFuzzy(t *testing.T) {
|
||||
octx := t.Context()
|
||||
|
||||
testFuzzy(octx, t, NewFuzzy(false))
|
||||
testFuzzyLongest(octx, t, NewFuzzy(true))
|
||||
testFuzzyMatch(octx, t, NewFuzzy(false))
|
||||
}
|
||||
|
||||
// testFuzzy tests if given filter matches/rejects the query.
|
||||
// This test checks the following functionalities:
|
||||
// - Fuzzy substring match
|
||||
// - Case-insensitive match
|
||||
// - Multi-byte rune match
|
||||
func testFuzzy(octx context.Context, t *testing.T, filter Filter) {
|
||||
testValues := []struct {
|
||||
input string
|
||||
query string
|
||||
|
|
@ -38,35 +86,621 @@ func TestFuzzy(t *testing.T) {
|
|||
{"🚴🏻 abcd efgh", "🚴🏻e", true}, // unicode
|
||||
{"This is a test to Test the fuzzy filteR", "TTR", true},
|
||||
}
|
||||
filter := NewFuzzy()
|
||||
for i, v := range testValues {
|
||||
t.Run(fmt.Sprintf(`"%s" against "%s", expect "%t"`, v.input, v.query, v.selected), func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(filter.NewContext(octx, v.query), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan interface{}, 1)
|
||||
l := line.NewRaw(uint64(i), v.input, false)
|
||||
ch := make(chan line.Line, 1)
|
||||
l := line.NewRaw(uint64(i), v.input, false, false)
|
||||
err := filter.Apply(ctx, []line.Line{l}, pipeline.ChanOutput(ch))
|
||||
if !assert.NoError(t, err, `filter.Apply should succeed`) {
|
||||
return
|
||||
}
|
||||
require.NoError(t, err, `filter.Apply should succeed`)
|
||||
|
||||
select {
|
||||
case l, ok := <-ch:
|
||||
if !assert.True(t, ok, `channel read should succeed`) {
|
||||
return
|
||||
}
|
||||
|
||||
if !assert.Implements(t, (*line.Line)(nil), l, "result is a line") {
|
||||
return
|
||||
}
|
||||
|
||||
require.True(t, ok, `channel read should succeed`)
|
||||
t.Logf("%#v", l.(indexer).Indices())
|
||||
case <-ctx.Done():
|
||||
if !assert.False(t, v.selected, "did NOT expect to timeout") { // shouldn't happen if we're expecting a result
|
||||
return
|
||||
require.False(t, v.selected, "did NOT expect to timeout")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testFuzzyLongest tests if given filter matches/rejects the query.
|
||||
// This test check the following functionalities:
|
||||
// - Longest substring match
|
||||
// - Ordering the match result by precedence (substring length > match index > string length)
|
||||
func testFuzzyLongest(octx context.Context, t *testing.T, filter Filter) {
|
||||
testValues := []struct {
|
||||
name string
|
||||
query string
|
||||
input []string
|
||||
expect []string
|
||||
}{
|
||||
{
|
||||
name: "The longer the matched string, the higher it ranks",
|
||||
query: "abcd",
|
||||
input: []string{
|
||||
"abc-d",
|
||||
"ab-cd",
|
||||
"abcd",
|
||||
"a-bcd",
|
||||
},
|
||||
expect: []string{
|
||||
"abcd",
|
||||
"abc-d",
|
||||
"a-bcd",
|
||||
"ab-cd",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "The earlier it matches, the higher it ranks",
|
||||
query: "abcd",
|
||||
input: []string{
|
||||
"___abcd",
|
||||
"_abcd",
|
||||
"abcd",
|
||||
"__abcd",
|
||||
},
|
||||
expect: []string{
|
||||
"abcd",
|
||||
"_abcd",
|
||||
"__abcd",
|
||||
"___abcd",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "The shorter the original string, the higher it ranks",
|
||||
query: "abcd",
|
||||
input: []string{
|
||||
"abcdef",
|
||||
"abcdefg",
|
||||
"abcd",
|
||||
"abcde",
|
||||
},
|
||||
expect: []string{
|
||||
"abcd",
|
||||
"abcde",
|
||||
"abcdef",
|
||||
"abcdefg",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Mixed precedence",
|
||||
query: "abcd",
|
||||
input: []string{
|
||||
"abc-d",
|
||||
"ab-cd",
|
||||
"abcd",
|
||||
"ab_abcd",
|
||||
"a-bcd",
|
||||
"___abcd",
|
||||
"_abcd",
|
||||
"abcd",
|
||||
"__abcd",
|
||||
"abcdef",
|
||||
"abcdefg",
|
||||
"abcd",
|
||||
"abcde",
|
||||
},
|
||||
expect: []string{
|
||||
"abcd",
|
||||
"abcd",
|
||||
"abcd",
|
||||
"abcde",
|
||||
"abcdef",
|
||||
"abcdefg",
|
||||
"_abcd",
|
||||
"__abcd",
|
||||
"ab_abcd", // ab_abcd shall be above ___abcd because matched lines are stable-sorted
|
||||
"___abcd",
|
||||
"abc-d",
|
||||
"a-bcd",
|
||||
"ab-cd",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, v := range testValues {
|
||||
t.Run(v.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(filter.NewContext(octx, v.query), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var lines []line.Line
|
||||
for _, raw := range v.input {
|
||||
lines = append(lines, line.NewRaw(uint64(i), raw, false, false))
|
||||
}
|
||||
|
||||
var actual []string
|
||||
lc := make(chan line.Line)
|
||||
ec := make(chan error)
|
||||
go func() {
|
||||
ec <- filter.Apply(ctx, lines, pipeline.ChanOutput(lc))
|
||||
}()
|
||||
|
||||
OUTER:
|
||||
for {
|
||||
select {
|
||||
case l := <-lc:
|
||||
actual = append(actual, l.DisplayString())
|
||||
case err := <-ec:
|
||||
require.NoError(t, err, `filter.Apply should succeed`)
|
||||
break OUTER
|
||||
case <-ctx.Done():
|
||||
require.Fail(t, "unexpected timeout")
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, v.expect, actual, "result is ordered in expected order")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testFuzzyMatch tests if non-sorted & sorted Fuzzy filter returns the expected result
|
||||
func TestSplitQueryTerms(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantPos []string
|
||||
wantNeg []string
|
||||
}{
|
||||
{
|
||||
name: "simple positive",
|
||||
query: "foo bar",
|
||||
wantPos: []string{"foo", "bar"},
|
||||
},
|
||||
{
|
||||
name: "single negative",
|
||||
query: "-foo",
|
||||
wantNeg: []string{"foo"},
|
||||
},
|
||||
{
|
||||
name: "mixed positive and negative",
|
||||
query: "foo -bar baz",
|
||||
wantPos: []string{"foo", "baz"},
|
||||
wantNeg: []string{"bar"},
|
||||
},
|
||||
{
|
||||
name: "all negative",
|
||||
query: "-foo -bar",
|
||||
wantNeg: []string{"foo", "bar"},
|
||||
},
|
||||
{
|
||||
name: "escaped negative becomes positive",
|
||||
query: `\-foo`,
|
||||
wantPos: []string{"-foo"},
|
||||
},
|
||||
{
|
||||
name: "bare hyphen is positive literal",
|
||||
query: "-",
|
||||
wantPos: []string{"-"},
|
||||
},
|
||||
{
|
||||
name: "double hyphen is positive literal",
|
||||
query: "--",
|
||||
wantPos: []string{"--"},
|
||||
},
|
||||
{
|
||||
name: "mixed with escaping",
|
||||
query: `foo -bar \-baz`,
|
||||
wantPos: []string{"foo", "-baz"},
|
||||
wantNeg: []string{"bar"},
|
||||
},
|
||||
{
|
||||
name: "extra spaces are skipped",
|
||||
query: " foo -bar ",
|
||||
wantPos: []string{"foo"},
|
||||
wantNeg: []string{"bar"},
|
||||
},
|
||||
{
|
||||
name: "empty query",
|
||||
query: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotPos, gotNeg := SplitQueryTerms(tt.query)
|
||||
require.Equal(t, tt.wantPos, gotPos, "positive terms")
|
||||
require.Equal(t, tt.wantNeg, gotNeg, "negative terms")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// collectFilterResults runs the filter and collects all emitted lines.
|
||||
func collectFilterResults(t *testing.T, f Filter, query string, inputLines []line.Line) []line.Line {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(f.NewContext(context.Background(), query), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan line.Line, len(inputLines)+1)
|
||||
err := f.Apply(ctx, inputLines, pipeline.ChanOutput(ch))
|
||||
require.NoError(t, err, "filter.Apply should succeed")
|
||||
close(ch)
|
||||
|
||||
var results []line.Line
|
||||
for l := range ch {
|
||||
results = append(results, l)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func makeLines(inputs ...string) []line.Line {
|
||||
lines := make([]line.Line, len(inputs))
|
||||
for i, s := range inputs {
|
||||
lines[i] = line.NewRaw(uint64(i), s, false, false)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func makeANSILines(inputs ...string) []line.Line {
|
||||
lines := make([]line.Line, len(inputs))
|
||||
for i, s := range inputs {
|
||||
lines[i] = line.NewRaw(uint64(i), s, false, true)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func TestNegativeMatchingRegexp(t *testing.T) {
|
||||
filters := map[string]Filter{
|
||||
"IgnoreCase": NewIgnoreCase(),
|
||||
"CaseSensitive": NewCaseSensitive(),
|
||||
"SmartCase": NewSmartCase(),
|
||||
"Regexp": NewRegexp(),
|
||||
}
|
||||
|
||||
for name, f := range filters {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
lines := makeLines(
|
||||
"hello world",
|
||||
"hello tests",
|
||||
"goodbye world",
|
||||
"goodbye tests",
|
||||
)
|
||||
|
||||
t.Run("positive with negative exclusion", func(t *testing.T) {
|
||||
results := collectFilterResults(t, f, "hello -tests", lines)
|
||||
require.Len(t, results, 1)
|
||||
require.Equal(t, "hello world", results[0].DisplayString())
|
||||
})
|
||||
|
||||
t.Run("multiple negative terms", func(t *testing.T) {
|
||||
results := collectFilterResults(t, f, "hello -world -tests", lines)
|
||||
require.Len(t, results, 0)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllNegativeQuery(t *testing.T) {
|
||||
filters := map[string]Filter{
|
||||
"IgnoreCase": NewIgnoreCase(),
|
||||
"Regexp": NewRegexp(),
|
||||
"Fuzzy": NewFuzzy(false),
|
||||
}
|
||||
|
||||
lines := makeLines(
|
||||
"alpha",
|
||||
"beta",
|
||||
"gamma",
|
||||
)
|
||||
|
||||
for name, f := range filters {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
results := collectFilterResults(t, f, "-beta", lines)
|
||||
require.Len(t, results, 2)
|
||||
names := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
names = append(names, r.DisplayString())
|
||||
}
|
||||
require.Contains(t, names, "alpha")
|
||||
require.Contains(t, names, "gamma")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNegativeNoHighlight(t *testing.T) {
|
||||
f := NewIgnoreCase()
|
||||
lines := makeLines("alpha", "beta", "gamma")
|
||||
results := collectFilterResults(t, f, "-beta", lines)
|
||||
require.Len(t, results, 2)
|
||||
|
||||
for _, r := range results {
|
||||
idx, ok := r.(indexer)
|
||||
require.True(t, ok, "result should implement indexer")
|
||||
require.Nil(t, idx.Indices(), "all-negative query should produce nil indices")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNegativeMatchingFuzzy(t *testing.T) {
|
||||
f := NewFuzzy(false)
|
||||
lines := makeLines(
|
||||
"hello world",
|
||||
"hello tests",
|
||||
"goodbye world",
|
||||
"goodbye tests",
|
||||
)
|
||||
|
||||
t.Run("fuzzy positive with negative exclusion", func(t *testing.T) {
|
||||
// Fuzzy query "hlo" should match "hello" lines; -tests excludes one
|
||||
results := collectFilterResults(t, f, "hlo -tests", lines)
|
||||
require.Len(t, results, 1)
|
||||
require.Equal(t, "hello world", results[0].DisplayString())
|
||||
})
|
||||
|
||||
t.Run("fuzzy all-negative", func(t *testing.T) {
|
||||
results := collectFilterResults(t, f, "-world", lines)
|
||||
require.Len(t, results, 2)
|
||||
names := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
names = append(names, r.DisplayString())
|
||||
}
|
||||
require.Contains(t, names, "hello tests")
|
||||
require.Contains(t, names, "goodbye tests")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLiteralHyphenMatching(t *testing.T) {
|
||||
lines := makeLines(
|
||||
"hello-world",
|
||||
"hello world",
|
||||
"-foo bar",
|
||||
"foo bar",
|
||||
"--verbose flag",
|
||||
"verbose flag",
|
||||
)
|
||||
|
||||
f := NewIgnoreCase()
|
||||
|
||||
t.Run("escaped negative matches literal hyphen-prefixed term", func(t *testing.T) {
|
||||
// \-foo should match lines containing literal "-foo"
|
||||
results := collectFilterResults(t, f, `\-foo`, lines)
|
||||
require.Len(t, results, 1)
|
||||
require.Equal(t, "-foo bar", results[0].DisplayString())
|
||||
})
|
||||
|
||||
t.Run("bare hyphen matches lines containing hyphen", func(t *testing.T) {
|
||||
// bare "-" should be a positive literal matching any line with a hyphen
|
||||
results := collectFilterResults(t, f, "-", lines)
|
||||
require.Len(t, results, 3)
|
||||
names := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
names = append(names, r.DisplayString())
|
||||
}
|
||||
require.Contains(t, names, "hello-world")
|
||||
require.Contains(t, names, "-foo bar")
|
||||
require.Contains(t, names, "--verbose flag")
|
||||
})
|
||||
|
||||
t.Run("double hyphen matches lines containing double hyphen", func(t *testing.T) {
|
||||
results := collectFilterResults(t, f, "--", lines)
|
||||
require.Len(t, results, 1)
|
||||
require.Equal(t, "--verbose flag", results[0].DisplayString())
|
||||
})
|
||||
|
||||
t.Run("escaped negative with positive term", func(t *testing.T) {
|
||||
// Search for lines containing both "bar" and literal "-foo"
|
||||
results := collectFilterResults(t, f, `bar \-foo`, lines)
|
||||
require.Len(t, results, 1)
|
||||
require.Equal(t, "-foo bar", results[0].DisplayString())
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsExcluded(t *testing.T) {
|
||||
rxFoo := regexp.MustCompile(`(?i)foo`)
|
||||
rxBar := regexp.MustCompile(`(?i)bar`)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
negRegexps []*regexp.Regexp
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil regexps never excludes",
|
||||
negRegexps: nil,
|
||||
text: "anything",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty regexps never excludes",
|
||||
negRegexps: []*regexp.Regexp{},
|
||||
text: "anything",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "single match excludes",
|
||||
negRegexps: []*regexp.Regexp{rxFoo},
|
||||
text: "contains foo here",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no match does not exclude",
|
||||
negRegexps: []*regexp.Regexp{rxFoo},
|
||||
text: "contains baz here",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "second regexp matches",
|
||||
negRegexps: []*regexp.Regexp{rxFoo, rxBar},
|
||||
text: "has bar in it",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "neither regexp matches",
|
||||
negRegexps: []*regexp.Regexp{rxFoo, rxBar},
|
||||
text: "has baz in it",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, isExcluded(tt.negRegexps, tt.text))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testFuzzyMatch tests if non-sorted & sorted Fuzzy filter returns the expected result
|
||||
func testFuzzyMatch(octx context.Context, t *testing.T, filter Filter) {
|
||||
testValues := []struct {
|
||||
name string
|
||||
sort bool
|
||||
query string
|
||||
input string
|
||||
expect [][]int
|
||||
}{
|
||||
{
|
||||
name: "Fuzzy: exact match",
|
||||
sort: false,
|
||||
query: "asdf",
|
||||
input: "___asdf",
|
||||
// ^^^^
|
||||
expect: [][]int{
|
||||
{3, 4},
|
||||
{4, 5},
|
||||
{5, 6},
|
||||
{6, 7},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Fuzzy: scattered match",
|
||||
sort: false,
|
||||
query: "asdf",
|
||||
input: "as_asdf",
|
||||
// ^^ ^^
|
||||
expect: [][]int{
|
||||
{0, 1},
|
||||
{1, 2},
|
||||
{5, 6},
|
||||
{6, 7},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "FuzzyLongest: exact match",
|
||||
sort: true,
|
||||
query: "asdf",
|
||||
input: "___asdf",
|
||||
// ^^^^
|
||||
expect: [][]int{
|
||||
{3, 4},
|
||||
{4, 5},
|
||||
{5, 6},
|
||||
{6, 7},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "FuzzyLongest: scattered match",
|
||||
sort: true,
|
||||
query: "asdf",
|
||||
input: "as_asdf",
|
||||
// ^^^^
|
||||
expect: [][]int{
|
||||
{3, 4},
|
||||
{4, 5},
|
||||
{5, 6},
|
||||
{6, 7},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, v := range testValues {
|
||||
t.Run(v.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(filter.NewContext(octx, v.query), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
filter := NewFuzzy(v.sort)
|
||||
lc := make(chan line.Line)
|
||||
ec := make(chan error)
|
||||
go func() {
|
||||
ec <- filter.Apply(ctx, []line.Line{line.NewRaw(uint64(i), v.input, false, false)}, pipeline.ChanOutput(lc))
|
||||
}()
|
||||
|
||||
OUTER:
|
||||
for {
|
||||
select {
|
||||
case l := <-lc:
|
||||
require.Implements(t, (*indexer)(nil), l, "result is an indexer")
|
||||
require.Equal(t, v.expect, l.(indexer).Indices(), "result has expected indices")
|
||||
case err := <-ec:
|
||||
require.NoError(t, err, `filter.Apply should succeed`)
|
||||
break OUTER
|
||||
case <-ctx.Done():
|
||||
require.Fail(t, "unexpected timeout")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
a []int
|
||||
b []int
|
||||
want []int
|
||||
}{
|
||||
{"a before b overlapping", []int{1, 5}, []int{3, 7}, []int{1, 7}},
|
||||
{"b before a overlapping", []int{3, 7}, []int{1, 5}, []int{1, 7}},
|
||||
{"identical ranges", []int{2, 4}, []int{2, 4}, []int{2, 4}},
|
||||
{"a contains b", []int{0, 10}, []int{3, 7}, []int{0, 10}},
|
||||
{"adjacent ranges", []int{0, 3}, []int{3, 6}, []int{0, 6}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := mergeMatches(tt.a, tt.b)
|
||||
require.Equal(t, tt.want, got)
|
||||
// Verify in-place mutation: got should be the same slice as a
|
||||
require.Same(t, &tt.a[0], &got[0], "mergeMatches should mutate a in place")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchAcrossANSIColorBoundary verifies that filter queries match
|
||||
// against the ANSI-stripped text, so a pattern spanning characters
|
||||
// rendered in different colors still produces a match.
|
||||
func TestMatchAcrossANSIColorBoundary(t *testing.T) {
|
||||
// "\033[31mfoo\033[34mbar\033[0m" renders as red "foo" + blue "bar",
|
||||
// but the stripped text is "foobar". A query "oba" spans the boundary.
|
||||
lines := makeANSILines(
|
||||
"\x1b[31mfoo\x1b[34mbar\x1b[0m", // red "foo" + blue "bar"
|
||||
"\x1b[32mhello\x1b[0m world", // green "hello" + plain " world"
|
||||
"plain text no ansi", // no ANSI at all
|
||||
"\x1b[1m\x1b[33mBRI\x1b[35mGHT\x1b[0m", // bold yellow "BRI" + bold magenta "GHT"
|
||||
)
|
||||
|
||||
filters := map[string]Filter{
|
||||
"IgnoreCase": NewIgnoreCase(),
|
||||
"CaseSensitive": NewCaseSensitive(),
|
||||
"Regexp": NewRegexp(),
|
||||
"Fuzzy": NewFuzzy(false),
|
||||
}
|
||||
|
||||
for name, f := range filters {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Run("match spans two color regions", func(t *testing.T) {
|
||||
// "oba" spans red "foo" and blue "bar"
|
||||
results := collectFilterResults(t, f, "oba", lines)
|
||||
require.Len(t, results, 1)
|
||||
require.Equal(t, "foobar", results[0].DisplayString())
|
||||
})
|
||||
|
||||
t.Run("match spans color and plain regions", func(t *testing.T) {
|
||||
// "lo w" spans green "hello" and plain " world"
|
||||
results := collectFilterResults(t, f, "lo w", lines)
|
||||
require.Len(t, results, 1)
|
||||
require.Equal(t, "hello world", results[0].DisplayString())
|
||||
})
|
||||
|
||||
t.Run("match spans bold color change", func(t *testing.T) {
|
||||
// "RIGH" spans bold-yellow "BRI" and bold-magenta "GHT"
|
||||
results := collectFilterResults(t, f, "RIGH", lines)
|
||||
require.Len(t, results, 1)
|
||||
require.Equal(t, "BRIGHT", results[0].DisplayString())
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
280
filter/fuzzy.go
280
filter/fuzzy.go
|
|
@ -2,6 +2,10 @@ package filter
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
|
|
@ -10,60 +14,252 @@ import (
|
|||
"github.com/peco/peco/pipeline"
|
||||
)
|
||||
|
||||
// Fuzzy is a filter that performs fuzzy matching.
|
||||
type Fuzzy struct {
|
||||
baseFilter
|
||||
sortLongest bool
|
||||
}
|
||||
|
||||
// NewFuzzy builds a fuzzy-finder type of filter.
|
||||
// In effect, this uses a smart case filter, and for q query
|
||||
// like "ABC" it matches the equivalent of "A(.*)B(.*)C(.*)"
|
||||
func NewFuzzy() *Fuzzy {
|
||||
return &Fuzzy{}
|
||||
// like "ABC" it matches the equivalent of "A(.*)B(.*)C(.*)".
|
||||
//
|
||||
// With sortLongest = true, Fuzzy filter outputs the result
|
||||
// sorted in the following precedence:
|
||||
// 1. Longer match
|
||||
// 2. Earlier match
|
||||
// 3. Shorter line length
|
||||
func NewFuzzy(sortLongest bool) *Fuzzy {
|
||||
ff := &Fuzzy{
|
||||
sortLongest: sortLongest,
|
||||
}
|
||||
ff.impl = ff
|
||||
return ff
|
||||
}
|
||||
|
||||
func (ff Fuzzy) BufSize() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (ff *Fuzzy) NewContext(ctx context.Context, query string) context.Context {
|
||||
return newContext(ctx, query)
|
||||
func (ff Fuzzy) SupportsParallel() bool {
|
||||
return !ff.sortLongest
|
||||
}
|
||||
|
||||
func (ff Fuzzy) String() string {
|
||||
return "Fuzzy"
|
||||
}
|
||||
|
||||
func (ff *Fuzzy) Apply(ctx context.Context, lines []line.Line, out pipeline.ChanOutput) error {
|
||||
originalQuery := ctx.Value(queryKey).(string)
|
||||
hasUpper := util.ContainsUpper(originalQuery)
|
||||
// applyInternal performs fuzzy matching on each line, emitting matches with
|
||||
// their character-level match indices for highlighting.
|
||||
func (ff *Fuzzy) applyInternal(ctx context.Context, lines []line.Line, em LineEmitter) error {
|
||||
originalQuery := pipeline.QueryFromContext(ctx)
|
||||
|
||||
OUTER:
|
||||
for _, l := range lines {
|
||||
base := 0
|
||||
matches := [][]int{}
|
||||
txt := l.DisplayString()
|
||||
query := originalQuery
|
||||
for len(query) > 0 {
|
||||
r, n := utf8.DecodeRuneInString(query)
|
||||
query = query[n:]
|
||||
if r == utf8.RuneError {
|
||||
// "Silently" ignore
|
||||
continue OUTER
|
||||
}
|
||||
|
||||
var i int
|
||||
if hasUpper { // explicit match
|
||||
i = strings.IndexRune(txt, r)
|
||||
} else {
|
||||
i = strings.IndexFunc(txt, util.CaseInsensitiveIndexFunc(r))
|
||||
}
|
||||
if i == -1 {
|
||||
continue OUTER
|
||||
}
|
||||
|
||||
// otherwise we have a match, but the next match must match against
|
||||
// something AFTER the current match
|
||||
txt = txt[i+n:]
|
||||
matches = append(matches, []int{base + i, base + i + n})
|
||||
base = base + i + n
|
||||
// Parse negative terms and compile them as case-insensitive regexps
|
||||
posTerms, negTerms := SplitQueryTerms(originalQuery)
|
||||
var negRegexps []*regexp.Regexp
|
||||
for _, t := range negTerms {
|
||||
re, err := regexpFor(t, []string{"i"}, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile negative term regexp '%s': %w", t, err)
|
||||
}
|
||||
out.Send(line.NewMatched(l, matches))
|
||||
negRegexps = append(negRegexps, re)
|
||||
}
|
||||
|
||||
// Reconstruct the fuzzy query from positive terms joined together
|
||||
fuzzyQuery := strings.Join(posTerms, "")
|
||||
|
||||
hasUpper := util.ContainsUpper(fuzzyQuery)
|
||||
var matched []fuzzyMatchedItem
|
||||
|
||||
LINE:
|
||||
for i, l := range lines {
|
||||
if err := checkCancelled(ctx, i); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
txt := l.DisplayString()
|
||||
|
||||
// Check negative terms first — skip if any match
|
||||
if isExcluded(negRegexps, txt) {
|
||||
continue LINE
|
||||
}
|
||||
|
||||
// All-negative query: emit all non-excluded lines with nil indices
|
||||
if len(fuzzyQuery) == 0 {
|
||||
em.Emit(ctx, line.GetMatched(l, nil))
|
||||
continue LINE
|
||||
}
|
||||
|
||||
// Find the first valid rune of the query
|
||||
firstRune := utf8.RuneError
|
||||
for _, r := range fuzzyQuery {
|
||||
if r != utf8.RuneError {
|
||||
firstRune = r
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstRune == utf8.RuneError {
|
||||
return fmt.Errorf("the query has no valid character")
|
||||
}
|
||||
|
||||
// Find all byte offsets where the first query rune appears,
|
||||
// reusing txt from line 77 instead of re-fetching DisplayString().
|
||||
var firstRuneOffsets []int
|
||||
remaining := txt
|
||||
base := 0
|
||||
for len(remaining) > 0 {
|
||||
var idx int
|
||||
if hasUpper {
|
||||
idx = strings.IndexRune(remaining, firstRune)
|
||||
} else {
|
||||
idx = util.CaseInsensitiveIndex(remaining, firstRune)
|
||||
}
|
||||
if idx == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
firstRuneOffsets = append(firstRuneOffsets, base+idx)
|
||||
|
||||
if !ff.sortLongest {
|
||||
break
|
||||
}
|
||||
|
||||
_, n := utf8.DecodeRuneInString(remaining[idx:])
|
||||
remaining = remaining[idx+n:]
|
||||
base += idx + n
|
||||
}
|
||||
if len(firstRuneOffsets) == 0 {
|
||||
continue LINE
|
||||
}
|
||||
|
||||
// Find all candidate matches
|
||||
var candidates []fuzzyMatchedItem
|
||||
|
||||
// Pre-allocate backing array for match pairs to avoid per-match
|
||||
// heap allocations. Each match is a [start, end] pair, so we need
|
||||
// 2 ints per query rune (upper bound).
|
||||
queryRuneCount := utf8.RuneCountInString(fuzzyQuery)
|
||||
matchBacking := make([]int, 0, queryRuneCount*2)
|
||||
|
||||
OUTER:
|
||||
for _, offset := range firstRuneOffsets {
|
||||
q := fuzzyQuery
|
||||
candidateTxt := txt[offset:]
|
||||
candidateBase := offset
|
||||
matchBacking = matchBacking[:0]
|
||||
matches := make([][]int, 0, queryRuneCount)
|
||||
|
||||
for len(q) > 0 {
|
||||
var r rune
|
||||
var n int
|
||||
q, r, n = popRune(q)
|
||||
if r == utf8.RuneError {
|
||||
continue OUTER
|
||||
}
|
||||
|
||||
var idx int
|
||||
if hasUpper {
|
||||
idx = strings.IndexRune(candidateTxt, r)
|
||||
} else {
|
||||
idx = util.CaseInsensitiveIndex(candidateTxt, r)
|
||||
}
|
||||
if idx == -1 {
|
||||
continue OUTER
|
||||
}
|
||||
|
||||
candidateTxt = candidateTxt[idx+n:]
|
||||
start := candidateBase + idx
|
||||
matchBacking = append(matchBacking, start, start+n)
|
||||
matches = append(matches, matchBacking[len(matchBacking)-2:len(matchBacking):len(matchBacking)])
|
||||
candidateBase = start + n
|
||||
}
|
||||
|
||||
candidates = append(candidates, newFuzzyMatchedItem(l, matches))
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if ff.sortLongest {
|
||||
// Sort the candidate matches of a line and pick the best one
|
||||
sort.SliceStable(candidates, less(candidates))
|
||||
}
|
||||
matched = append(matched, candidates[0])
|
||||
}
|
||||
|
||||
if ff.sortLongest {
|
||||
// Sort all matched lines
|
||||
sort.SliceStable(matched, less(matched))
|
||||
}
|
||||
|
||||
for i := range matched {
|
||||
em.Emit(ctx, line.GetMatched(matched[i].line, matched[i].matches))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// popRune decodes and removes the first rune from s, returning the remainder,
|
||||
// the decoded rune, and its byte width.
|
||||
func popRune(s string) (string, rune, int) {
|
||||
r, n := utf8.DecodeRuneInString(s)
|
||||
return s[n:], r, n
|
||||
}
|
||||
|
||||
// less returns a comparison function that orders fuzzy matches by longest
|
||||
// contiguous match, earliest position, then shortest line length.
|
||||
func less(s []fuzzyMatchedItem) func(i, j int) bool {
|
||||
return func(i, j int) bool {
|
||||
if s[i].longest != s[j].longest {
|
||||
// Longer match is better
|
||||
return s[i].longest > s[j].longest
|
||||
}
|
||||
if s[i].earliest != s[j].earliest {
|
||||
// Earlier match is better
|
||||
return s[i].earliest < s[j].earliest
|
||||
}
|
||||
// Shorter line is better
|
||||
return s[i].Len() < s[j].Len()
|
||||
}
|
||||
}
|
||||
|
||||
type fuzzyMatchedItem struct {
|
||||
line line.Line
|
||||
matches [][]int
|
||||
longest int
|
||||
earliest int
|
||||
}
|
||||
|
||||
// newFuzzyMatchedItem creates a fuzzyMatchedItem, computing the longest
|
||||
// contiguous match length and earliest match position from the given indices.
|
||||
func newFuzzyMatchedItem(line line.Line, matches [][]int) fuzzyMatchedItem {
|
||||
longest := 0
|
||||
count := 0
|
||||
lastEnd := 0
|
||||
earliest := math.MaxInt
|
||||
|
||||
for i := range matches {
|
||||
length := matches[i][1] - matches[i][0]
|
||||
if matches[i][0] == lastEnd {
|
||||
count += length
|
||||
} else {
|
||||
count = length
|
||||
}
|
||||
if count > longest {
|
||||
longest = count
|
||||
}
|
||||
lastEnd = matches[i][1]
|
||||
|
||||
if matches[i][0] < earliest {
|
||||
earliest = matches[i][0]
|
||||
}
|
||||
}
|
||||
|
||||
return fuzzyMatchedItem{
|
||||
line: line,
|
||||
matches: matches,
|
||||
longest: longest,
|
||||
earliest: earliest,
|
||||
}
|
||||
}
|
||||
|
||||
func (f fuzzyMatchedItem) Len() int {
|
||||
return len(f.line.DisplayString())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
)
|
||||
|
||||
var ErrFilterNotFound = errors.New("specified filter was not found")
|
||||
|
||||
var ignoreCaseFlags = regexpFlagList([]string{"i"})
|
||||
var defaultFlags = regexpFlagList{}
|
||||
var queryKey = &struct{}{}
|
||||
var incomingBufferKey = &struct{}{}
|
||||
|
||||
// DefaultCustomFilterBufferThreshold is the default value
|
||||
// for BufferThreshold setting on CustomFilters.
|
||||
const DefaultCustomFilterBufferThreshold = 100
|
||||
|
||||
type Set struct {
|
||||
current int
|
||||
filters []Filter
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
// internal stuff
|
||||
type regexpFlags interface {
|
||||
flags(string) []string
|
||||
}
|
||||
type regexpFlagList []string
|
||||
|
||||
type regexpFlagFunc func(string) []string
|
||||
|
||||
type regexpQueryFactory struct {
|
||||
compiled map[string]regexpQuery
|
||||
mutex sync.Mutex
|
||||
threshold time.Duration
|
||||
}
|
||||
|
||||
type regexpQuery struct {
|
||||
rx []*regexp.Regexp
|
||||
lastUsed time.Time
|
||||
}
|
||||
|
||||
type Fuzzy struct {
|
||||
}
|
||||
|
||||
type Regexp struct {
|
||||
factory *regexpQueryFactory
|
||||
flags regexpFlags
|
||||
quotemeta bool
|
||||
mutex sync.Mutex
|
||||
name string
|
||||
onEnd func()
|
||||
outCh pipeline.ChanOutput
|
||||
}
|
||||
|
||||
type ExternalCmd struct {
|
||||
args []string
|
||||
cmd string
|
||||
enableSep bool
|
||||
idgen line.IDGenerator
|
||||
outCh pipeline.ChanOutput
|
||||
name string
|
||||
thresholdBufsiz int
|
||||
}
|
||||
|
||||
type Filter interface {
|
||||
Apply(context.Context, []line.Line, pipeline.ChanOutput) error
|
||||
BufSize() int
|
||||
NewContext(context.Context, string) context.Context
|
||||
String() string
|
||||
}
|
||||
121
filter/parallel_test.go
Normal file
121
filter/parallel_test.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSupportsParallel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filter Filter
|
||||
expected bool
|
||||
}{
|
||||
{"Regexp", NewRegexp(), true},
|
||||
{"IgnoreCase", NewIgnoreCase(), true},
|
||||
{"CaseSensitive", NewCaseSensitive(), true},
|
||||
{"SmartCase", NewSmartCase(), true},
|
||||
{"IRegexp", NewIRegexp(), true},
|
||||
{"Fuzzy(sortLongest=false)", NewFuzzy(false), true},
|
||||
{"Fuzzy(sortLongest=true)", NewFuzzy(true), false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.expected, tt.filter.SupportsParallel())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelFilterProducesSameResults(t *testing.T) {
|
||||
// Generate test lines
|
||||
const numLines = 5000
|
||||
lines := make([]line.Line, numLines)
|
||||
for i := range numLines {
|
||||
text := fmt.Sprintf("line-%04d foo bar baz", i)
|
||||
if i%3 == 0 {
|
||||
text = fmt.Sprintf("line-%04d matching-pattern test", i)
|
||||
}
|
||||
lines[i] = line.NewRaw(uint64(i), text, false, false)
|
||||
}
|
||||
|
||||
filters := []struct {
|
||||
name string
|
||||
filter Filter
|
||||
query string
|
||||
}{
|
||||
{"IgnoreCase", NewIgnoreCase(), "matching"},
|
||||
{"CaseSensitive", NewCaseSensitive(), "matching"},
|
||||
{"Regexp", NewRegexp(), "matching.*test"},
|
||||
{"Fuzzy", NewFuzzy(false), "mpt"},
|
||||
}
|
||||
|
||||
for _, ft := range filters {
|
||||
t.Run(ft.name, func(t *testing.T) {
|
||||
ctx := ft.filter.NewContext(context.Background(), ft.query)
|
||||
|
||||
// Run sequentially
|
||||
seqCh := make(chan line.Line, numLines)
|
||||
err := ft.filter.Apply(ctx, lines, pipeline.ChanOutput(seqCh))
|
||||
require.NoError(t, err)
|
||||
close(seqCh)
|
||||
|
||||
var seqResults []string
|
||||
for l := range seqCh {
|
||||
seqResults = append(seqResults, l.DisplayString())
|
||||
}
|
||||
|
||||
// Run on chunks (simulating parallel) - split into multiple chunks
|
||||
chunkSize := 500
|
||||
var parResults []string
|
||||
for start := 0; start < len(lines); start += chunkSize {
|
||||
end := min(start+chunkSize, len(lines))
|
||||
chunk := lines[start:end]
|
||||
|
||||
ch := make(chan line.Line, len(chunk))
|
||||
err := ft.filter.Apply(ctx, chunk, pipeline.ChanOutput(ch))
|
||||
require.NoError(t, err)
|
||||
close(ch)
|
||||
|
||||
for l := range ch {
|
||||
parResults = append(parResults, l.DisplayString())
|
||||
}
|
||||
}
|
||||
|
||||
// Results should be identical in count and order
|
||||
require.Equal(t, len(seqResults), len(parResults), "result count should match")
|
||||
require.Equal(t, seqResults, parResults, "results should be identical and in same order")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelFilterContextCancellation(t *testing.T) {
|
||||
// Generate a large set of lines
|
||||
const numLines = 100000
|
||||
lines := make([]line.Line, numLines)
|
||||
for i := range numLines {
|
||||
lines[i] = line.NewRaw(uint64(i), fmt.Sprintf("line-%d matching-pattern", i), false, false)
|
||||
}
|
||||
|
||||
f := NewIgnoreCase()
|
||||
ctx, cancel := context.WithCancel(f.NewContext(context.Background(), "matching"))
|
||||
|
||||
// Cancel after a short delay
|
||||
go func() {
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
ch := make(chan line.Line, numLines)
|
||||
err := f.Apply(ctx, lines, pipeline.ChanOutput(ch))
|
||||
|
||||
// Should return context error (cancelled)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
272
filter/regexp.go
272
filter/regexp.go
|
|
@ -6,14 +6,47 @@ import (
|
|||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/peco/peco/internal/util"
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// internal stuff
|
||||
type regexpFlags interface {
|
||||
flags(string) []string
|
||||
}
|
||||
|
||||
type regexpFlagList []string
|
||||
|
||||
type regexpFlagFunc func(string) []string
|
||||
|
||||
type regexpQueryFactory struct {
|
||||
compiled map[string]regexpQuery
|
||||
mutex sync.Mutex
|
||||
threshold time.Duration
|
||||
}
|
||||
|
||||
type regexpQuery struct {
|
||||
positive []*regexp.Regexp
|
||||
negative []*regexp.Regexp
|
||||
lastUsed time.Time
|
||||
}
|
||||
|
||||
var ignoreCaseFlags = regexpFlagList([]string{"i"})
|
||||
var defaultFlags = regexpFlagList{}
|
||||
|
||||
// Regexp is a filter that matches lines using regular expressions.
|
||||
type Regexp struct {
|
||||
baseFilter
|
||||
factory *regexpQueryFactory
|
||||
flags regexpFlags
|
||||
quotemeta bool
|
||||
name string
|
||||
}
|
||||
|
||||
func (r regexpFlagList) flags(_ string) []string {
|
||||
return []string(r)
|
||||
}
|
||||
|
|
@ -22,103 +55,207 @@ func (r regexpFlagFunc) flags(s string) []string {
|
|||
return r(s)
|
||||
}
|
||||
|
||||
// regexpFor compiles q into a regexp, optionally quoting meta characters
|
||||
// and prepending inline flags (e.g. case-insensitive).
|
||||
func regexpFor(q string, flags []string, quotemeta bool) (*regexp.Regexp, error) {
|
||||
reTxt := q
|
||||
if quotemeta {
|
||||
reTxt = regexp.QuoteMeta(q)
|
||||
}
|
||||
|
||||
if flags != nil && len(flags) > 0 {
|
||||
if len(flags) > 0 {
|
||||
reTxt = fmt.Sprintf("(?%s)%s", strings.Join(flags, ""), reTxt)
|
||||
}
|
||||
|
||||
re, err := regexp.Compile(reTxt)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to compile regular expression '%s'", reTxt)
|
||||
return nil, fmt.Errorf("failed to compile regular expression '%s': %w", reTxt, err)
|
||||
}
|
||||
return re, nil
|
||||
}
|
||||
|
||||
func queryToRegexps(query string, flags regexpFlags, quotemeta bool) ([]*regexp.Regexp, error) {
|
||||
queries := strings.Split(strings.TrimSpace(query), " ")
|
||||
regexps := make([]*regexp.Regexp, 0)
|
||||
// SplitQueryTerms splits a query string into positive and negative term slices.
|
||||
// Terms starting with `-` (followed by at least one non-hyphen char) are negative (the `-` is stripped).
|
||||
// Terms starting with `\-` are positive literals (the `\` is stripped).
|
||||
// Bare `-` or `--` are positive literals.
|
||||
// Empty tokens are skipped.
|
||||
func SplitQueryTerms(query string) (positive, negative []string) {
|
||||
tokens := strings.SplitSeq(strings.TrimSpace(query), " ")
|
||||
for tok := range tokens {
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(tok, `\-`) {
|
||||
// Escaped negative: treat as literal positive term (strip the backslash)
|
||||
positive = append(positive, tok[1:])
|
||||
} else if tok == "-" || tok == "--" {
|
||||
// Bare hyphen(s): literal positive
|
||||
positive = append(positive, tok)
|
||||
} else if strings.HasPrefix(tok, "-") {
|
||||
// Negative term: strip the leading hyphen
|
||||
negative = append(negative, tok[1:])
|
||||
} else {
|
||||
positive = append(positive, tok)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
re, err := regexpFor(q, flags.flags(query), quotemeta)
|
||||
// termsToRegexps compiles a slice of terms into regexps, using the full
|
||||
// original query for flag computation (needed for SmartCase).
|
||||
func termsToRegexps(terms []string, fullQuery string, flags regexpFlags, quotemeta bool) ([]*regexp.Regexp, error) {
|
||||
regexps := make([]*regexp.Regexp, 0, len(terms))
|
||||
for _, t := range terms {
|
||||
re, err := regexpFor(t, flags.flags(fullQuery), quotemeta)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to compile regular expression '%s'", q)
|
||||
return nil, fmt.Errorf("failed to compile regular expression '%s': %w", t, err)
|
||||
}
|
||||
regexps = append(regexps, re)
|
||||
}
|
||||
|
||||
return regexps, nil
|
||||
}
|
||||
|
||||
func (rf *Regexp) NewContext(ctx context.Context, query string) context.Context {
|
||||
return newContext(ctx, query)
|
||||
}
|
||||
|
||||
// NewRegexp creates a new regexp based filter
|
||||
func NewRegexp() *Regexp {
|
||||
return &Regexp{
|
||||
// newRegexpFilter is an internal helper that constructs a Regexp filter
|
||||
// with the given name, flags, and quotemeta setting.
|
||||
func newRegexpFilter(name string, flags regexpFlags, quotemeta bool) *Regexp {
|
||||
rf := &Regexp{
|
||||
factory: ®expQueryFactory{
|
||||
compiled: make(map[string]regexpQuery),
|
||||
threshold: time.Minute,
|
||||
},
|
||||
flags: regexpFlagList(defaultFlags),
|
||||
quotemeta: false,
|
||||
name: "Regexp",
|
||||
outCh: pipeline.ChanOutput(make(chan interface{})),
|
||||
flags: flags,
|
||||
quotemeta: quotemeta,
|
||||
name: name,
|
||||
}
|
||||
rf.impl = rf
|
||||
return rf
|
||||
}
|
||||
|
||||
// NewRegexp creates a new regexp based filter
|
||||
func NewRegexp() *Regexp {
|
||||
return newRegexpFilter("Regexp", defaultFlags, false)
|
||||
}
|
||||
|
||||
// NewIRegexp creates a new case-insensitive regexp based filter
|
||||
func NewIRegexp() *Regexp {
|
||||
return newRegexpFilter("IRegexp", ignoreCaseFlags, false)
|
||||
}
|
||||
|
||||
const maxRegexpCacheSize = 100
|
||||
|
||||
// evictLRU removes the least recently used entries from the cache until its
|
||||
// size is at or below targetSize. The caller must hold f.mutex.
|
||||
func (f *regexpQueryFactory) evictLRU(targetSize int) {
|
||||
type cacheEntry struct {
|
||||
key string
|
||||
lastUsed time.Time
|
||||
}
|
||||
|
||||
entries := make([]cacheEntry, 0, len(f.compiled))
|
||||
for k, v := range f.compiled {
|
||||
entries = append(entries, cacheEntry{key: k, lastUsed: v.lastUsed})
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].lastUsed.Before(entries[j].lastUsed)
|
||||
})
|
||||
|
||||
toRemove := len(f.compiled) - targetSize
|
||||
for i := range toRemove {
|
||||
delete(f.compiled, entries[i].key)
|
||||
}
|
||||
}
|
||||
|
||||
func (rf Regexp) BufSize() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rf *Regexp) OutCh() <-chan interface{} {
|
||||
rf.mutex.Lock()
|
||||
defer rf.mutex.Unlock()
|
||||
return rf.outCh
|
||||
}
|
||||
|
||||
func (f *regexpQueryFactory) Compile(s string, flags regexpFlags, quotemeta bool) ([]*regexp.Regexp, error) {
|
||||
// Compile parses the query string into positive and negative regexp slices,
|
||||
// caching compiled results for reuse within the expiry threshold.
|
||||
func (f *regexpQueryFactory) Compile(s string, flags regexpFlags, quotemeta bool) ([]*regexp.Regexp, []*regexp.Regexp, error) {
|
||||
f.mutex.Lock()
|
||||
defer f.mutex.Unlock()
|
||||
|
||||
rq, ok := f.compiled[s]
|
||||
if ok {
|
||||
if time.Since(rq.lastUsed) < f.threshold {
|
||||
return rq.rx, nil
|
||||
return rq.positive, rq.negative, nil
|
||||
}
|
||||
delete(f.compiled, s)
|
||||
}
|
||||
|
||||
rxs, err := queryToRegexps(s, flags, quotemeta)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, `failed to compile regular expression`)
|
||||
posTerms, negTerms := SplitQueryTerms(s)
|
||||
|
||||
var posRxs, negRxs []*regexp.Regexp
|
||||
if len(posTerms) > 0 {
|
||||
var err error
|
||||
posRxs, err = termsToRegexps(posTerms, s, flags, quotemeta)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to compile positive regular expressions: %w", err)
|
||||
}
|
||||
}
|
||||
if len(negTerms) > 0 {
|
||||
var err error
|
||||
negRxs, err = termsToRegexps(negTerms, s, flags, quotemeta)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to compile negative regular expressions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Evict stale entries if cache is over the size limit
|
||||
if len(f.compiled) >= maxRegexpCacheSize {
|
||||
for k, v := range f.compiled {
|
||||
if time.Since(v.lastUsed) >= f.threshold {
|
||||
delete(f.compiled, k)
|
||||
}
|
||||
}
|
||||
// If still over limit after evicting stale entries, evict the
|
||||
// least recently used entries to bring cache down to half capacity.
|
||||
if len(f.compiled) >= maxRegexpCacheSize {
|
||||
f.evictLRU(maxRegexpCacheSize / 2)
|
||||
}
|
||||
}
|
||||
|
||||
rq.lastUsed = time.Now()
|
||||
rq.rx = rxs
|
||||
rq.positive = posRxs
|
||||
rq.negative = negRxs
|
||||
f.compiled[s] = rq
|
||||
return rxs, nil
|
||||
return posRxs, negRxs, nil
|
||||
}
|
||||
|
||||
func (rf *Regexp) Apply(ctx context.Context, lines []line.Line, out pipeline.ChanOutput) error {
|
||||
query := ctx.Value(queryKey).(string)
|
||||
regexps, err := rf.factory.Compile(query, rf.flags, rf.quotemeta)
|
||||
// applyInternal matches each line against the compiled positive and negative
|
||||
// regexps, deduplicating overlapping match ranges before emitting results.
|
||||
func (rf *Regexp) applyInternal(ctx context.Context, lines []line.Line, em LineEmitter) error {
|
||||
query := pipeline.QueryFromContext(ctx)
|
||||
posRegexps, negRegexps, err := rf.factory.Compile(query, rf.flags, rf.quotemeta)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to compile queries as regular expression")
|
||||
return fmt.Errorf("failed to compile queries as regular expression: %w", err)
|
||||
}
|
||||
|
||||
for _, l := range lines {
|
||||
// Pre-allocate match and dedup slices outside the loop so they can
|
||||
// be reused across lines, avoiding per-line heap allocations.
|
||||
var matches [][]int
|
||||
var deduped [][]int
|
||||
|
||||
for i, l := range lines {
|
||||
if err := checkCancelled(ctx, i); err != nil {
|
||||
return err
|
||||
}
|
||||
v := l.DisplayString()
|
||||
|
||||
// Check negative terms first (fail-fast, no index collection)
|
||||
if isExcluded(negRegexps, v) {
|
||||
continue
|
||||
}
|
||||
|
||||
// All-negative query: emit line with nil indices (no highlighting)
|
||||
if len(posRegexps) == 0 {
|
||||
em.Emit(ctx, line.GetMatched(l, nil))
|
||||
continue
|
||||
}
|
||||
|
||||
// Positive matching (existing AND logic)
|
||||
allMatched := true
|
||||
matches := [][]int{}
|
||||
matches = matches[:0]
|
||||
TryRegexps:
|
||||
for _, rx := range regexps {
|
||||
match := rx.FindAllStringSubmatchIndex(v, -1)
|
||||
for _, rx := range posRegexps {
|
||||
match := rx.FindAllStringIndex(v, -1)
|
||||
if match == nil {
|
||||
allMatched = false
|
||||
break TryRegexps
|
||||
|
|
@ -135,11 +272,11 @@ func (rf *Regexp) Apply(ctx context.Context, lines []line.Line, out pipeline.Cha
|
|||
// We need to "dedupe" the results. For example, if we matched the
|
||||
// same region twice, we don't want that to be drawn
|
||||
|
||||
deduped := make([][]int, 0, len(matches))
|
||||
deduped = deduped[:0]
|
||||
|
||||
for i, m := range matches {
|
||||
for j, m := range matches {
|
||||
// Always push the first one
|
||||
if i == 0 {
|
||||
if j == 0 {
|
||||
deduped = append(deduped, m)
|
||||
continue
|
||||
}
|
||||
|
|
@ -158,41 +295,40 @@ func (rf *Regexp) Apply(ctx context.Context, lines []line.Line, out pipeline.Cha
|
|||
deduped = append(deduped, m)
|
||||
}
|
||||
}
|
||||
out.Send(line.NewMatched(l, deduped))
|
||||
|
||||
// Make a copy of deduped for emission since we reuse the slice
|
||||
result := make([][]int, len(deduped))
|
||||
copy(result, deduped)
|
||||
em.Emit(ctx, line.GetMatched(l, result))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rf Regexp) String() string {
|
||||
func (rf *Regexp) SupportsParallel() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (rf *Regexp) String() string {
|
||||
return rf.name
|
||||
}
|
||||
|
||||
// NewIgnoreCase creates a case-insensitive literal string filter.
|
||||
func NewIgnoreCase() *Regexp {
|
||||
rf := NewRegexp()
|
||||
rf.flags = ignoreCaseFlags
|
||||
rf.quotemeta = true
|
||||
rf.name = "IgnoreCase"
|
||||
return rf
|
||||
return newRegexpFilter("IgnoreCase", ignoreCaseFlags, true)
|
||||
}
|
||||
|
||||
// NewCaseSensitive creates a case-sensitive literal string filter.
|
||||
func NewCaseSensitive() *Regexp {
|
||||
rf := NewRegexp()
|
||||
rf.quotemeta = true
|
||||
rf.name = "CaseSensitive"
|
||||
return rf
|
||||
return newRegexpFilter("CaseSensitive", defaultFlags, true)
|
||||
}
|
||||
|
||||
// SmartCase turns ON the ignore-case flag in the regexp
|
||||
// if the query contains a upper-case character
|
||||
// NewSmartCase creates a filter that turns ON the ignore-case flag in the regexp
|
||||
// if the query contains no upper-case character
|
||||
func NewSmartCase() *Regexp {
|
||||
rf := NewRegexp()
|
||||
rf.quotemeta = true
|
||||
rf.name = "SmartCase"
|
||||
rf.flags = regexpFlagFunc(func(q string) []string {
|
||||
return newRegexpFilter("SmartCase", regexpFlagFunc(func(q string) []string {
|
||||
if util.ContainsUpper(q) {
|
||||
return defaultFlags
|
||||
}
|
||||
return []string{"i"}
|
||||
})
|
||||
return rf
|
||||
}), true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,46 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
pdebug "github.com/lestrrat/go-pdebug"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
pdebug "github.com/lestrrat-go/pdebug"
|
||||
)
|
||||
|
||||
// ErrFilterNotFound is returned when a filter name does not match any
|
||||
// registered filter in the Set.
|
||||
var ErrFilterNotFound = errors.New("specified filter was not found")
|
||||
|
||||
// Set holds the collection of available filters and tracks which one
|
||||
// is currently active.
|
||||
type Set struct {
|
||||
current int
|
||||
filters []Filter
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
// Reset sets the active filter back to the first one in the set.
|
||||
func (fs *Set) Reset() {
|
||||
fs.mutex.Lock()
|
||||
defer fs.mutex.Unlock()
|
||||
fs.current = 0
|
||||
}
|
||||
|
||||
// Size returns the number of filters in the set.
|
||||
func (fs *Set) Size() int {
|
||||
fs.mutex.Lock()
|
||||
defer fs.mutex.Unlock()
|
||||
return len(fs.filters)
|
||||
}
|
||||
|
||||
func (fs *Set) Add(lf Filter) error {
|
||||
// Add appends a new filter to the set.
|
||||
func (fs *Set) Add(lf Filter) {
|
||||
fs.mutex.Lock()
|
||||
defer fs.mutex.Unlock()
|
||||
fs.filters = append(fs.filters, lf)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rotate cycles to the next filter in the set, wrapping around to the first.
|
||||
func (fs *Set) Rotate() {
|
||||
fs.mutex.Lock()
|
||||
defer fs.mutex.Unlock()
|
||||
|
|
@ -35,6 +53,8 @@ func (fs *Set) Rotate() {
|
|||
}
|
||||
}
|
||||
|
||||
// SetCurrentByName switches the active filter to the one matching the given
|
||||
// name, returning ErrFilterNotFound if no filter matches.
|
||||
func (fs *Set) SetCurrentByName(name string) error {
|
||||
fs.mutex.Lock()
|
||||
defer fs.mutex.Unlock()
|
||||
|
|
|
|||
386
filter_incremental_test.go
Normal file
386
filter_incremental_test.go
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/peco/peco/filter"
|
||||
"github.com/peco/peco/hub"
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
"github.com/peco/peco/selection"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsQueryRefinement(t *testing.T) {
|
||||
tests := []struct {
|
||||
prev string
|
||||
new string
|
||||
expected bool
|
||||
}{
|
||||
// Refinements
|
||||
{"foo", "foob", true},
|
||||
{"foo", "foo bar", true},
|
||||
{"foo bar", "foo bart", true},
|
||||
{"f", "fo", true},
|
||||
{"foo", "foobar", true},
|
||||
|
||||
// Not refinements
|
||||
{"foob", "foo", false}, // backspace
|
||||
{"foo bar", "foo baz", false}, // edit mid-word
|
||||
{"foo", "bar", false}, // completely different
|
||||
{"", "foo", false}, // empty prev
|
||||
{"foo", "", false}, // empty new
|
||||
{"", "", false}, // both empty
|
||||
|
||||
// Whitespace handling
|
||||
{" foo ", " foo bar", true},
|
||||
{" foo ", " foo bar", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
name := fmt.Sprintf("%q->%q", tt.prev, tt.new)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
result := isQueryRefinement(tt.prev, tt.new)
|
||||
require.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsQueryRefinementWithNegation(t *testing.T) {
|
||||
tests := []struct {
|
||||
prev string
|
||||
new string
|
||||
expected bool
|
||||
}{
|
||||
// Adding a negative term is a refinement (narrows results)
|
||||
{"foo", "foo -bar", true},
|
||||
// Adding more negative terms is still a refinement
|
||||
{"foo -bar", "foo -bar -baz", true},
|
||||
// Removing a negative term is NOT a refinement (widens results)
|
||||
{"foo -bar -baz", "foo -bar", false},
|
||||
// Extending positive while keeping negatives
|
||||
{"foo -bar", "fooX -bar", true},
|
||||
// All-negative refinement
|
||||
{"-foo", "-foo -bar", true},
|
||||
// Changing a negative term is not a refinement
|
||||
{"-foo", "-bar", false},
|
||||
// Adding positive to all-negative is a refinement
|
||||
{"-foo", "hello -foo", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
name := fmt.Sprintf("%q->%q", tt.prev, tt.new)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
result := isQueryRefinement(tt.prev, tt.new)
|
||||
require.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryBufferSource(t *testing.T) {
|
||||
// Create and populate a MemoryBuffer
|
||||
mb := NewMemoryBuffer(0)
|
||||
expected := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
||||
|
||||
for i, s := range expected {
|
||||
mb.lines = append(mb.lines, line.NewRaw(uint64(i), s, false, false))
|
||||
}
|
||||
|
||||
// Wrap as source
|
||||
src := NewMemoryBufferSource(mb)
|
||||
|
||||
// Collect lines from source
|
||||
ctx := context.Background()
|
||||
out := make(chan line.Line, len(expected))
|
||||
go src.Start(ctx, pipeline.ChanOutput(out))
|
||||
|
||||
var got []string
|
||||
for l := range out {
|
||||
got = append(got, l.DisplayString())
|
||||
}
|
||||
require.Equal(t, expected, got, "MemoryBufferSource should iterate all lines in order")
|
||||
}
|
||||
|
||||
func TestMemoryBufferSourceCancellation(t *testing.T) {
|
||||
mb := NewMemoryBuffer(0)
|
||||
for i := range 10000 {
|
||||
mb.lines = append(mb.lines, line.NewRaw(uint64(i), fmt.Sprintf("line-%d", i), false, false))
|
||||
}
|
||||
|
||||
src := NewMemoryBufferSource(mb)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
out := make(chan line.Line, 100)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
src.Start(ctx, pipeline.ChanOutput(out))
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Cancel immediately
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
// Source closed the channel; drain to count lines sent
|
||||
count := 0
|
||||
for range out {
|
||||
count++
|
||||
}
|
||||
// We can't guarantee exact count due to race, but it should be less than total
|
||||
// (or equal if the cancellation happened after all sends)
|
||||
require.LessOrEqual(t, count, 10000)
|
||||
}
|
||||
|
||||
// TestAcceptAndFilterSerial exercises the serial (non-parallel) batching path
|
||||
// through AcceptAndFilter. It sends lines through a channel and verifies
|
||||
// that the filter is applied correctly and all matching lines appear in the output.
|
||||
func TestAcceptAndFilterSerial(t *testing.T) {
|
||||
f := filter.NewFuzzy(true) // Fuzzy with sortLongest=true does NOT support parallel
|
||||
require.False(t, f.SupportsParallel(), "Fuzzy with sortLongest should not support parallel")
|
||||
|
||||
inputLines := []line.Line{
|
||||
line.NewRaw(0, "alpha bravo", false, false),
|
||||
line.NewRaw(1, "charlie delta", false, false),
|
||||
line.NewRaw(2, "alpha charlie", false, false),
|
||||
line.NewRaw(3, "echo foxtrot", false, false),
|
||||
line.NewRaw(4, "alpha delta", false, false),
|
||||
}
|
||||
|
||||
ctx := f.NewContext(context.Background(), "alpha")
|
||||
in := make(chan line.Line, len(inputLines))
|
||||
for _, l := range inputLines {
|
||||
in <- l
|
||||
}
|
||||
close(in)
|
||||
|
||||
out := make(chan line.Line, len(inputLines))
|
||||
AcceptAndFilter(ctx, f, 0, in, pipeline.ChanOutput(out))
|
||||
|
||||
var got []string
|
||||
for l := range out {
|
||||
got = append(got, l.DisplayString())
|
||||
}
|
||||
|
||||
// Fuzzy with sortLongest sorts by: longer match > earlier match > shorter line.
|
||||
// All three match "alpha" at position 0 with length 5, so the tiebreaker is
|
||||
// line length (shorter first). "alpha bravo" and "alpha delta" tie at 11 chars,
|
||||
// so sort.SliceStable preserves their input order.
|
||||
require.Equal(t, []string{"alpha bravo", "alpha delta", "alpha charlie"}, got)
|
||||
}
|
||||
|
||||
// TestAcceptAndFilterParallel exercises the parallel batching path.
|
||||
// IgnoreCase (a Regexp filter) supports parallel execution.
|
||||
func TestAcceptAndFilterParallel(t *testing.T) {
|
||||
if runtime.GOMAXPROCS(0) < 2 {
|
||||
t.Skip("parallel path requires GOMAXPROCS >= 2")
|
||||
}
|
||||
|
||||
f := filter.NewIgnoreCase()
|
||||
require.True(t, f.SupportsParallel(), "IgnoreCase should support parallel")
|
||||
|
||||
inputLines := []line.Line{
|
||||
line.NewRaw(0, "foobar test", false, false),
|
||||
line.NewRaw(1, "football game", false, false),
|
||||
line.NewRaw(2, "something else", false, false),
|
||||
line.NewRaw(3, "foobaz entry", false, false),
|
||||
line.NewRaw(4, "barfoo other", false, false),
|
||||
line.NewRaw(5, "the foobird flies", false, false),
|
||||
line.NewRaw(6, "no match here", false, false),
|
||||
}
|
||||
|
||||
ctx := f.NewContext(context.Background(), "foo")
|
||||
in := make(chan line.Line, len(inputLines))
|
||||
for _, l := range inputLines {
|
||||
in <- l
|
||||
}
|
||||
close(in)
|
||||
|
||||
out := make(chan line.Line, len(inputLines))
|
||||
AcceptAndFilter(ctx, f, 0, in, pipeline.ChanOutput(out))
|
||||
|
||||
var got []string
|
||||
for l := range out {
|
||||
got = append(got, l.DisplayString())
|
||||
}
|
||||
|
||||
// Parallel preserves input order via ordered chunks — verify exact order
|
||||
expected := []string{"foobar test", "football game", "foobaz entry", "barfoo other", "the foobird flies"}
|
||||
require.Equal(t, expected, got)
|
||||
}
|
||||
|
||||
func TestIncrementalFiltering(t *testing.T) {
|
||||
// Test that filtering "foo" then "foob" produces correct results
|
||||
// and the second query runs on fewer lines
|
||||
|
||||
// Create test lines
|
||||
allLines := []line.Line{
|
||||
line.NewRaw(0, "foobar test", false, false),
|
||||
line.NewRaw(1, "football game", false, false),
|
||||
line.NewRaw(2, "barfoo other", false, false),
|
||||
line.NewRaw(3, "something else", false, false),
|
||||
line.NewRaw(4, "foobaz entry", false, false),
|
||||
line.NewRaw(5, "the foobird flies", false, false),
|
||||
}
|
||||
|
||||
f := filter.NewIgnoreCase()
|
||||
|
||||
// First query: "foo"
|
||||
ctx1 := f.NewContext(context.Background(), "foo")
|
||||
ch1 := make(chan line.Line, len(allLines))
|
||||
err := f.Apply(ctx1, allLines, pipeline.ChanOutput(ch1))
|
||||
require.NoError(t, err)
|
||||
close(ch1)
|
||||
|
||||
var firstResults []line.Line
|
||||
for l := range ch1 {
|
||||
firstResults = append(firstResults, l)
|
||||
}
|
||||
|
||||
// Should match: foobar, football, barfoo, foobaz, foobird
|
||||
require.Len(t, firstResults, 5, "first query should match 5 lines")
|
||||
|
||||
// Second query: "foob" on first results only
|
||||
ctx2 := f.NewContext(context.Background(), "foob")
|
||||
ch2 := make(chan line.Line, len(firstResults))
|
||||
err = f.Apply(ctx2, firstResults, pipeline.ChanOutput(ch2))
|
||||
require.NoError(t, err)
|
||||
close(ch2)
|
||||
|
||||
var secondResults []line.Line
|
||||
for l := range ch2 {
|
||||
secondResults = append(secondResults, l)
|
||||
}
|
||||
|
||||
// Should match: foobar, foobaz, foobird (not football, not barfoo)
|
||||
require.Len(t, secondResults, 3, "second query should match 3 lines")
|
||||
|
||||
// Verify the matched lines are correct
|
||||
displayStrings := make([]string, 0, len(secondResults))
|
||||
for _, l := range secondResults {
|
||||
displayStrings = append(displayStrings, l.DisplayString())
|
||||
}
|
||||
require.Contains(t, displayStrings, "foobar test")
|
||||
require.Contains(t, displayStrings, "foobaz entry")
|
||||
require.Contains(t, displayStrings, "the foobird flies")
|
||||
}
|
||||
|
||||
// TestFrozenCacheInvalidation verifies that the incremental filter cache
|
||||
// is invalidated when the frozen state changes. Without this fix, cached
|
||||
// results from the original source would be reused after freezing, producing
|
||||
// wrong results.
|
||||
func TestFrozenCacheInvalidation(t *testing.T) {
|
||||
// Set up a Peco state with a recording hub
|
||||
state := New()
|
||||
rHub := &recordingHub{}
|
||||
state.hub = rHub
|
||||
state.selection = selection.New()
|
||||
|
||||
// Populate filters (needed by Filter.Work)
|
||||
state.filters.Add(filter.NewIgnoreCase())
|
||||
|
||||
// Create original source with lines that include "apache" matches
|
||||
src := &Source{}
|
||||
src.setupDone = make(chan struct{})
|
||||
for i, s := range []string{"apache config", "apache error", "APACHE LOG", "nginx proxy", "redis cache"} {
|
||||
src.Append(line.NewRaw(uint64(i), s, false, false))
|
||||
}
|
||||
close(src.setupDone)
|
||||
state.source = src
|
||||
state.currentLineBuffer = src
|
||||
|
||||
// Create a Filter and simulate a previous "apache" query
|
||||
f := NewFilter(state)
|
||||
|
||||
// Run a real filter for "apache" to populate the cache
|
||||
q1 := hub.NewPayload("apache", false)
|
||||
f.Work(context.Background(), q1)
|
||||
|
||||
// Verify cache was populated from original source (3 matches: apache config, apache error, APACHE LOG)
|
||||
f.prevMu.Lock()
|
||||
require.Equal(t, "apache", f.prevQuery)
|
||||
require.NotNil(t, f.prevResults)
|
||||
require.Equal(t, 3, f.prevResults.Size(), "should have 3 'apache' matches from original source")
|
||||
require.Nil(t, f.prevFrozenSrc, "prevFrozenSrc should be nil (not frozen)")
|
||||
f.prevMu.Unlock()
|
||||
|
||||
// Now freeze with a subset: only "apache config" and "nginx proxy"
|
||||
frozen := NewMemoryBuffer(0)
|
||||
frozen.AppendLine(line.NewRaw(0, "apache config", false, false))
|
||||
frozen.AppendLine(line.NewRaw(3, "nginx proxy", false, false))
|
||||
frozen.MarkComplete()
|
||||
state.Frozen().Set(frozen)
|
||||
|
||||
// Query "apache c" — this is a refinement of "apache", so without the fix
|
||||
// the stale cache (3 results from original source) would be used instead
|
||||
// of filtering from the frozen buffer (which has only 1 apache match).
|
||||
q2 := hub.NewPayload("apache c", false)
|
||||
f.Work(context.Background(), q2)
|
||||
|
||||
// The result should come from the frozen buffer, not the stale cache.
|
||||
// Frozen buffer has "apache config" and "nginx proxy". Filtering for
|
||||
// "apache c" should match only "apache config".
|
||||
buf := state.CurrentLineBuffer()
|
||||
require.Equal(t, 1, buf.Size(), "should have 1 match from frozen buffer")
|
||||
l, err := buf.LineAt(0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "apache config", l.Buffer())
|
||||
|
||||
// Verify cache is now updated with frozen source
|
||||
f.prevMu.Lock()
|
||||
require.Equal(t, frozen, f.prevFrozenSrc, "prevFrozenSrc should match current frozen source")
|
||||
f.prevMu.Unlock()
|
||||
}
|
||||
|
||||
// errorFilter is a mock filter that always returns an error from Apply.
|
||||
type errorFilter struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *errorFilter) Apply(_ context.Context, _ []line.Line, _ pipeline.ChanOutput) error {
|
||||
return f.err
|
||||
}
|
||||
|
||||
func (f *errorFilter) BufSize() int { return 0 }
|
||||
func (f *errorFilter) NewContext(ctx context.Context, _ string) context.Context { return ctx }
|
||||
func (f *errorFilter) String() string { return "error-filter" }
|
||||
func (f *errorFilter) SupportsParallel() bool { return false }
|
||||
|
||||
// TestFilterApplyErrorReporting verifies that when a filter's Apply method
|
||||
// returns an error, the error is propagated to the onError callback (which in
|
||||
// production sends a status bar message to the user).
|
||||
func TestFilterApplyErrorReporting(t *testing.T) {
|
||||
simulatedErr := errors.New("simulated filter error")
|
||||
ef := &errorFilter{err: simulatedErr}
|
||||
|
||||
inputLines := []line.Line{
|
||||
line.NewRaw(0, "alpha", false, false),
|
||||
line.NewRaw(1, "bravo", false, false),
|
||||
}
|
||||
|
||||
in := make(chan line.Line, len(inputLines))
|
||||
for _, l := range inputLines {
|
||||
in <- l
|
||||
}
|
||||
close(in)
|
||||
|
||||
var mu sync.Mutex
|
||||
var reported []error
|
||||
onError := FilterErrorHandlerFunc(func(err error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
reported = append(reported, err)
|
||||
})
|
||||
|
||||
out := make(chan line.Line, len(inputLines))
|
||||
acceptAndFilter(context.Background(), ef, 0, onError, in, pipeline.ChanOutput(out))
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Len(t, reported, 1, "error handler should have been called once")
|
||||
require.Equal(t, simulatedErr, reported[0])
|
||||
}
|
||||
194
follow_test.go
Normal file
194
follow_test.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/peco/peco/filter"
|
||||
"github.com/peco/peco/hub"
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// makeFollowBuffer builds a MemoryBuffer with n lines labelled "lineN".
|
||||
func makeFollowBuffer(n int) *MemoryBuffer {
|
||||
mb := NewMemoryBuffer(0)
|
||||
for i := range n {
|
||||
mb.lines = append(mb.lines, line.NewRaw(uint64(i), fmt.Sprintf("line%d", i), false, false))
|
||||
}
|
||||
return mb
|
||||
}
|
||||
|
||||
// readScreenRow returns the text rendered on row y of the simulation screen,
|
||||
// trailing blanks trimmed.
|
||||
func readScreenRow(t *testing.T, s *SimScreen, y int) string {
|
||||
t.Helper()
|
||||
cells, w, _ := s.screen.GetContents()
|
||||
var b strings.Builder
|
||||
for x := range w {
|
||||
c := cells[y*w+x]
|
||||
if len(c.Runes) == 0 || c.Runes[0] == 0 {
|
||||
b.WriteRune(' ')
|
||||
continue
|
||||
}
|
||||
b.WriteRune(c.Runes[0])
|
||||
}
|
||||
return strings.TrimRight(b.String(), " ")
|
||||
}
|
||||
|
||||
func newFollowState(t *testing.T, buf Buffer) (*Peco, *SimScreen) {
|
||||
t.Helper()
|
||||
screen := NewDummyScreen()
|
||||
state := New()
|
||||
state.screen = screen
|
||||
state.Filters().Add(filter.NewIgnoreCase())
|
||||
state.currentLineBuffer = buf
|
||||
return state, screen
|
||||
}
|
||||
|
||||
func TestWindowCrop(t *testing.T) {
|
||||
buf := makeFollowBuffer(100)
|
||||
|
||||
t.Run("tail window", func(t *testing.T) {
|
||||
fb := WindowCrop{offset: 90, perPage: 10}.Crop(buf)
|
||||
require.Equal(t, 10, fb.Size())
|
||||
first, err := fb.LineAt(0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "line90", first.DisplayString())
|
||||
last, err := fb.LineAt(9)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "line99", last.DisplayString())
|
||||
})
|
||||
|
||||
t.Run("partial window smaller than perPage", func(t *testing.T) {
|
||||
small := makeFollowBuffer(5)
|
||||
fb := WindowCrop{offset: 0, perPage: 10}.Crop(small)
|
||||
require.Equal(t, 5, fb.Size())
|
||||
})
|
||||
|
||||
t.Run("negative offset clamps to zero", func(t *testing.T) {
|
||||
fb := WindowCrop{offset: -5, perPage: 3}.Crop(buf)
|
||||
require.Equal(t, 3, fb.Size())
|
||||
first, err := fb.LineAt(0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "line0", first.DisplayString())
|
||||
})
|
||||
|
||||
t.Run("offset past end yields empty", func(t *testing.T) {
|
||||
fb := WindowCrop{offset: 200, perPage: 10}.Crop(buf)
|
||||
require.Equal(t, 0, fb.Size())
|
||||
})
|
||||
}
|
||||
|
||||
// TestGHIssue820_FollowKeepsNewestVisible verifies that with follow mode on,
|
||||
// DrawScreen pins the viewport to the tail of the buffer so the newest line
|
||||
// is rendered on the last visible row (like tail -f), regardless of page
|
||||
// alignment.
|
||||
func TestGHIssue820_FollowKeepsNewestVisible(t *testing.T) {
|
||||
buf := makeFollowBuffer(50)
|
||||
state, screen := newFollowState(t, buf)
|
||||
state.Follow().Set(true)
|
||||
|
||||
// top-down-query-bottom is the layout from the issue: list anchored to
|
||||
// the top (row 0), query at the bottom.
|
||||
layout, err := TopDownQueryBottomLayout(state)
|
||||
require.NoError(t, err)
|
||||
|
||||
layout.DrawScreen(state, nil)
|
||||
|
||||
loc := state.Location()
|
||||
perPage := layout.linesPerPage()
|
||||
|
||||
require.Equal(t, 49, loc.LineNumber(), "cursor should be pinned to the newest line")
|
||||
require.Equal(t, 50, loc.Total())
|
||||
require.Equal(t, max(50-perPage, 0), loc.Offset(),
|
||||
"offset should be a sliding window over the tail, not page-aligned")
|
||||
|
||||
// The list area is anchored at row 0; the newest line sits on the last
|
||||
// visible row.
|
||||
lastRow := perPage - 1
|
||||
require.Equal(t, "line49", readScreenRow(t, screen, lastRow),
|
||||
"newest line should be on the bottom row of the list area")
|
||||
}
|
||||
|
||||
// TestFollowDefaultShowsHead verifies that without follow mode the viewport
|
||||
// stays at the head of the buffer (unchanged behavior).
|
||||
func TestFollowDefaultShowsHead(t *testing.T) {
|
||||
buf := makeFollowBuffer(50)
|
||||
state, screen := newFollowState(t, buf)
|
||||
require.False(t, state.IsFollowing())
|
||||
|
||||
layout, err := TopDownQueryBottomLayout(state)
|
||||
require.NoError(t, err)
|
||||
|
||||
layout.DrawScreen(state, nil)
|
||||
|
||||
loc := state.Location()
|
||||
require.Equal(t, 0, loc.LineNumber())
|
||||
require.Equal(t, 0, loc.Offset())
|
||||
require.Equal(t, "line0", readScreenRow(t, screen, 0),
|
||||
"oldest line should be on the top row when not following")
|
||||
}
|
||||
|
||||
// TestFollowDisabledByManualScroll verifies that any manual vertical
|
||||
// navigation turns follow mode off.
|
||||
func TestFollowDisabledByManualScroll(t *testing.T) {
|
||||
buf := makeFollowBuffer(50)
|
||||
state, _ := newFollowState(t, buf)
|
||||
state.Follow().Set(true)
|
||||
|
||||
layout, err := TopDownQueryBottomLayout(state)
|
||||
require.NoError(t, err)
|
||||
// Pin to the tail first.
|
||||
layout.DrawScreen(state, nil)
|
||||
require.True(t, state.IsFollowing())
|
||||
|
||||
moved := layout.MovePage(state, hub.ToLineAbove)
|
||||
require.True(t, moved)
|
||||
require.False(t, state.IsFollowing(),
|
||||
"manual vertical scroll should disable follow mode")
|
||||
}
|
||||
|
||||
// TestToggleFollowAction verifies the ToggleFollow action flips follow state.
|
||||
func TestToggleFollowAction(t *testing.T) {
|
||||
state := New()
|
||||
state.hub = nullHub{}
|
||||
require.False(t, state.IsFollowing())
|
||||
|
||||
doToggleFollow(t.Context(), state, Event{})
|
||||
require.True(t, state.IsFollowing(), "follow should be on after first toggle")
|
||||
|
||||
doToggleFollow(t.Context(), state, Event{})
|
||||
require.False(t, state.IsFollowing(), "follow should be off after second toggle")
|
||||
}
|
||||
|
||||
// TestFollowConfigAndFlag verifies that --follow and the Follow config field
|
||||
// both enable follow mode, with the CLI flag taking precedence.
|
||||
func TestFollowConfigAndFlag(t *testing.T) {
|
||||
t.Run("CLI flag", func(t *testing.T) {
|
||||
p := newPeco()
|
||||
require.NoError(t, p.ApplyConfig(CLIOptions{OptFollow: true}))
|
||||
require.True(t, p.IsFollowing())
|
||||
})
|
||||
|
||||
t.Run("config field", func(t *testing.T) {
|
||||
p := newPeco()
|
||||
p.config.Follow = true
|
||||
require.NoError(t, p.ApplyConfig(CLIOptions{}))
|
||||
require.True(t, p.IsFollowing())
|
||||
})
|
||||
|
||||
t.Run("default off", func(t *testing.T) {
|
||||
p := newPeco()
|
||||
require.NoError(t, p.ApplyConfig(CLIOptions{}))
|
||||
require.False(t, p.IsFollowing())
|
||||
})
|
||||
}
|
||||
|
||||
// TestToggleFollowRegistered verifies the action is wired into the registry so
|
||||
// it can be bound from a keymap.
|
||||
func TestToggleFollowRegistered(t *testing.T) {
|
||||
_, ok := nameToActions["peco.ToggleFollow"]
|
||||
require.True(t, ok, "peco.ToggleFollow should be registered")
|
||||
}
|
||||
24
glide.lock
generated
24
glide.lock
generated
|
|
@ -1,24 +0,0 @@
|
|||
hash: 3ce65eedf36489cf8210f31a499d744db368aeb0add09df5483a87c185b344b5
|
||||
updated: 2016-12-14T18:28:21.292809662+09:00
|
||||
imports:
|
||||
- name: github.com/davecgh/go-spew
|
||||
version: 346938d642f2ec3594ed81d874461961cd0faa76
|
||||
subpackages:
|
||||
- spew
|
||||
- name: github.com/google/btree
|
||||
version: 0c3044bc8bada22db67b93f5760fe3f05d6a5c25
|
||||
- name: github.com/jessevdk/go-flags
|
||||
version: 8bc97d602c3bfeb5fc6fc9b5a9c898f245495637
|
||||
- name: github.com/lestrrat/go-pdebug
|
||||
version: 2e6eaaa5717f81bda41d27070d3c966f40a1e75f
|
||||
- name: github.com/mattn/go-runewidth
|
||||
version: 737072b4e32b7a5018b4a7125da8d12de90e8045
|
||||
- name: github.com/nsf/termbox-go
|
||||
version: abe82ce5fb7a42fbd6784a5ceb71aff977e09ed8
|
||||
- name: github.com/pkg/errors
|
||||
version: 248dadf4e9068a0b3e79f02ed0a610d935de5302
|
||||
- name: github.com/stretchr/testify
|
||||
version: 18a02ba4a312f95da08ff4cfc0055750ce50ae9e
|
||||
subpackages:
|
||||
- assert
|
||||
devImports: []
|
||||
11
glide.yaml
11
glide.yaml
|
|
@ -1,11 +0,0 @@
|
|||
package: github.com/peco/peco
|
||||
import:
|
||||
- package: github.com/google/btree
|
||||
- package: github.com/jessevdk/go-flags
|
||||
- package: github.com/mattn/go-runewidth
|
||||
- package: github.com/nsf/termbox-go
|
||||
- package: github.com/pkg/errors
|
||||
- package: github.com/stretchr/testify
|
||||
subpackages:
|
||||
- assert
|
||||
- package: github.com/lestrrat/go-pdebug
|
||||
25
go.mod
Normal file
25
go.mod
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
module github.com/peco/peco
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gdamore/tcell/v2 v2.13.10
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/google/btree v1.1.3
|
||||
github.com/jessevdk/go-flags v1.6.1
|
||||
github.com/lestrrat-go/pdebug v0.0.0-20180220043849-39f9a71bcabe
|
||||
github.com/mattn/go-runewidth v0.0.28
|
||||
github.com/stretchr/testify v1.12.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.37.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
)
|
||||
63
go.sum
Normal file
63
go.sum
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA=
|
||||
github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
|
||||
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
|
||||
github.com/lestrrat-go/pdebug v0.0.0-20180220043849-39f9a71bcabe h1:S7XSBlgc/eI2v47LkPPVa+infH3FuTS4tPJbqCtJovo=
|
||||
github.com/lestrrat-go/pdebug v0.0.0-20180220043849-39f9a71bcabe/go.mod h1:zvUY6gZZVL2nu7NM+/3b51Z/hxyFZCZxV0hvfZ3NJlg=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU=
|
||||
github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
29
hub/bench_test.go
Normal file
29
hub/bench_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package hub_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/peco/peco/hub"
|
||||
)
|
||||
|
||||
// BenchmarkHubBatch measures the allocation cost of Hub.Batch context setup.
|
||||
func BenchmarkHubBatch(b *testing.B) {
|
||||
h := hub.New(5)
|
||||
ctx := context.Background()
|
||||
|
||||
// Drain query channel and call Done() so batch sends unblock
|
||||
go func() {
|
||||
for p := range h.QueryCh() {
|
||||
p.Done()
|
||||
}
|
||||
}()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
h.Batch(ctx, func(bctx context.Context) {
|
||||
h.SendQuery(bctx, "test")
|
||||
})
|
||||
}
|
||||
}
|
||||
10
hub/draw.go
Normal file
10
hub/draw.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package hub
|
||||
|
||||
// DrawOptions controls how the screen is drawn.
|
||||
type DrawOptions struct {
|
||||
Prompt bool // draw only the prompt
|
||||
PurgeCache bool // purge display cache only
|
||||
RunningQuery bool // a query is currently running
|
||||
DisableCache bool // disable display cache
|
||||
ForceSync bool // force a full screen sync
|
||||
}
|
||||
218
hub/hub.go
218
hub/hub.go
|
|
@ -1,111 +1,211 @@
|
|||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pdebug "github.com/lestrrat/go-pdebug"
|
||||
pdebug "github.com/lestrrat-go/pdebug"
|
||||
)
|
||||
|
||||
func NewPayload(data interface{}) *payload {
|
||||
p := &payload{data: data}
|
||||
return p
|
||||
// Hub acts as the messaging hub between components -- that is,
|
||||
// it controls how the communication that goes through channels
|
||||
// are handled.
|
||||
type Hub struct {
|
||||
mutex sync.Mutex
|
||||
queryCh chan *Payload[string]
|
||||
drawCh chan *Payload[*DrawOptions]
|
||||
statusMsgCh chan *Payload[StatusMsg]
|
||||
pagingCh chan *Payload[PagingRequest]
|
||||
}
|
||||
|
||||
// Data returns the underlying data
|
||||
func (p payload) Data() interface{} {
|
||||
// Payload is a wrapper around the actual request value that needs
|
||||
// to be passed. It contains an optional channel field which can
|
||||
// be filled to force synchronous communication between the
|
||||
// sender and receiver
|
||||
type Payload[T any] struct {
|
||||
batch bool
|
||||
data T
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewPayload creates a new Payload with the given data and batch flag.
|
||||
func NewPayload[T any](data T, batch bool) *Payload[T] {
|
||||
return &Payload[T]{
|
||||
data: data,
|
||||
batch: batch,
|
||||
}
|
||||
}
|
||||
|
||||
// Batch returns true if this payload is part of a batch operation.
|
||||
func (p *Payload[T]) Batch() bool {
|
||||
return p.batch
|
||||
}
|
||||
|
||||
// Data returns the underlying data.
|
||||
func (p *Payload[T]) Data() T {
|
||||
return p.data
|
||||
}
|
||||
|
||||
// Done marks the request as done. If Hub is operating in
|
||||
// asynchronous mode (default), it's a no op. Otherwise it
|
||||
// closes the reply channel to finish up the synchronous communication
|
||||
func (p payload) Done() {
|
||||
// Done marks the request as done. In non-batch mode it's a no-op.
|
||||
// In batch mode it signals the sender that the receiver has finished
|
||||
// processing this payload.
|
||||
func (p *Payload[T]) Done() {
|
||||
if p.done == nil {
|
||||
return
|
||||
}
|
||||
close(p.done)
|
||||
p.done <- struct{}{}
|
||||
}
|
||||
|
||||
// NewHub creates a new Hub struct
|
||||
// New creates a new Hub struct
|
||||
func New(bufsiz int) *Hub {
|
||||
return &Hub{
|
||||
isSync: false,
|
||||
queryCh: make(chan Payload, bufsiz),
|
||||
drawCh: make(chan Payload, bufsiz),
|
||||
statusMsgCh: make(chan Payload, bufsiz),
|
||||
pagingCh: make(chan Payload, bufsiz),
|
||||
queryCh: make(chan *Payload[string], bufsiz),
|
||||
drawCh: make(chan *Payload[*DrawOptions], bufsiz),
|
||||
statusMsgCh: make(chan *Payload[StatusMsg], bufsiz),
|
||||
pagingCh: make(chan *Payload[PagingRequest], bufsiz),
|
||||
}
|
||||
}
|
||||
|
||||
// batchCtxKey is a single context key that signals both "this is a batch
|
||||
// payload" and "the hub mutex is already held" for re-entrant detection.
|
||||
// Using one key instead of two avoids a second context.WithValue allocation.
|
||||
type batchCtxKey struct{}
|
||||
|
||||
// Batch allows you to synchronously send messages during the
|
||||
// scope of f() being executed.
|
||||
func (h *Hub) Batch(f func(), shouldLock bool) {
|
||||
// scope of f() being executed. The mutex is acquired automatically
|
||||
// unless this is a nested Batch call (detected via context).
|
||||
func (h *Hub) Batch(ctx context.Context, f func(ctx context.Context)) {
|
||||
nested, _ := ctx.Value(batchCtxKey{}).(bool)
|
||||
|
||||
if pdebug.Enabled {
|
||||
g := pdebug.Marker("Batch %t", shouldLock)
|
||||
g := pdebug.Marker("Batch (nested=%t)", nested)
|
||||
defer g.End()
|
||||
}
|
||||
if shouldLock {
|
||||
// lock during this operation
|
||||
|
||||
if !nested {
|
||||
h.mutex.Lock()
|
||||
defer h.mutex.Unlock()
|
||||
}
|
||||
|
||||
// temporarily set isSync = true
|
||||
o := h.isSync
|
||||
h.isSync = true
|
||||
defer func() { h.isSync = o }()
|
||||
// Log and re-panic instead of silently swallowing. The mutex
|
||||
// unlock defer (above) still runs during re-panic, so the hub
|
||||
// won't deadlock.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "peco: panic in Hub.Batch: %v\n%s", r, debug.Stack())
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
|
||||
// ignore panics
|
||||
defer func() { recover() }()
|
||||
|
||||
f()
|
||||
batchCtx := context.WithValue(ctx, batchCtxKey{}, true)
|
||||
f(batchCtx)
|
||||
}
|
||||
|
||||
// low-level utility
|
||||
func send(ch chan Payload, r *payload, needReply bool) {
|
||||
if needReply {
|
||||
r.done = make(chan struct{})
|
||||
defer func() { <-r.done }()
|
||||
var doneChPool = sync.Pool{
|
||||
New: func() any {
|
||||
return make(chan struct{})
|
||||
},
|
||||
}
|
||||
|
||||
// waitDone blocks until the receiver signals completion by calling Done.
|
||||
func (p *Payload[T]) waitDone() {
|
||||
// Save the channel reference before blocking. This read is safe because
|
||||
// p.done was set by send() on this same goroutine before the payload
|
||||
// was sent on the hub channel.
|
||||
ch := p.done
|
||||
|
||||
// Block until the receiver calls Done(), which sends on ch.
|
||||
<-ch
|
||||
|
||||
// After the receive, the receiver is finished with p.done (it already
|
||||
// sent on it), so this goroutine has exclusive access. Clear the field
|
||||
// and return the channel to the pool.
|
||||
p.done = nil
|
||||
doneChPool.Put(ch)
|
||||
}
|
||||
|
||||
// isBatchCtx reports whether the context was created by a Batch call.
|
||||
func isBatchCtx(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(batchCtxKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// send is the low-level generic utility for sending typed payloads.
|
||||
// Batch mode is determined from the payload's Batch() flag, which is
|
||||
// set by each Send* method via isBatchCtx before calling send.
|
||||
// The context is used for cancellation so sends don't block forever
|
||||
// during shutdown.
|
||||
func send[T any](ctx context.Context, ch chan *Payload[T], r *Payload[T]) {
|
||||
if pdebug.Enabled {
|
||||
g := pdebug.Marker("hub.send (isBatchMode=%t)", r.Batch())
|
||||
defer g.End()
|
||||
}
|
||||
|
||||
ch <- r
|
||||
if r.Batch() {
|
||||
// The pool only ever contains chan struct{} values (see doneChPool.Put
|
||||
// in Payload.Done), so this assertion cannot fail in practice.
|
||||
r.done, _ = doneChPool.Get().(chan struct{})
|
||||
if pdebug.Enabled {
|
||||
defer pdebug.Printf("request is part of batch operation. waiting")
|
||||
}
|
||||
defer r.waitDone()
|
||||
}
|
||||
|
||||
select {
|
||||
case ch <- r:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
// QueryCh returns the underlying channel for queries
|
||||
func (h *Hub) QueryCh() chan Payload {
|
||||
func (h *Hub) QueryCh() chan *Payload[string] {
|
||||
return h.queryCh
|
||||
}
|
||||
|
||||
// SendQuery sends the query string to be processed by the Filter
|
||||
func (h *Hub) SendQuery(q string) {
|
||||
send(h.QueryCh(), NewPayload(q), h.isSync)
|
||||
func (h *Hub) SendQuery(ctx context.Context, q string) {
|
||||
send(ctx, h.QueryCh(), NewPayload(q, isBatchCtx(ctx)))
|
||||
}
|
||||
|
||||
// DrawCh returns the channel to redraw the terminal display
|
||||
func (h *Hub) DrawCh() chan Payload {
|
||||
func (h *Hub) DrawCh() chan *Payload[*DrawOptions] {
|
||||
return h.drawCh
|
||||
}
|
||||
|
||||
// SendDrawPrompt sends a request to redraw the prompt only
|
||||
func (h *Hub) SendDrawPrompt() {
|
||||
send(h.DrawCh(), NewPayload("prompt"), h.isSync)
|
||||
func (h *Hub) SendDrawPrompt(ctx context.Context) {
|
||||
send(ctx, h.DrawCh(), NewPayload(&DrawOptions{Prompt: true}, isBatchCtx(ctx)))
|
||||
}
|
||||
|
||||
// SendDraw sends a request to redraw the terminal display
|
||||
func (h *Hub) SendDraw(options interface{}) {
|
||||
pdebug.Printf("START Hub.SendDraw %v", options)
|
||||
defer pdebug.Printf("END Hub.SendDraw %v", options)
|
||||
send(h.DrawCh(), NewPayload(options), h.isSync)
|
||||
func (h *Hub) SendDraw(ctx context.Context, options *DrawOptions) {
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("START Hub.SendDraw %v", options)
|
||||
defer pdebug.Printf("END Hub.SendDraw %v", options)
|
||||
}
|
||||
send(ctx, h.DrawCh(), NewPayload(options, isBatchCtx(ctx)))
|
||||
}
|
||||
|
||||
// StatusMsgCh returns the channel to update the status message
|
||||
func (h *Hub) StatusMsgCh() chan Payload {
|
||||
func (h *Hub) StatusMsgCh() chan *Payload[StatusMsg] {
|
||||
return h.statusMsgCh
|
||||
}
|
||||
|
||||
// SendStatusMsg sends a string to be displayed in the status message
|
||||
func (h *Hub) SendStatusMsg(q string) {
|
||||
h.SendStatusMsgAndClear(q, 0)
|
||||
// SendStatusMsg sends a string to be displayed in the status message.
|
||||
// If clearDelay is non-zero, the message will be cleared after that duration.
|
||||
func (h *Hub) SendStatusMsg(ctx context.Context, q string, clearDelay time.Duration) {
|
||||
msg := newStatusMsgReq(q, clearDelay)
|
||||
send(ctx, h.StatusMsgCh(), NewPayload[StatusMsg](msg, isBatchCtx(ctx)))
|
||||
}
|
||||
|
||||
// StatusMsg is an interface for status message requests.
|
||||
type StatusMsg interface {
|
||||
Message() string
|
||||
Delay() time.Duration
|
||||
}
|
||||
|
||||
type statusMsgReq struct {
|
||||
|
|
@ -121,6 +221,7 @@ func (r statusMsgReq) Delay() time.Duration {
|
|||
return r.delay
|
||||
}
|
||||
|
||||
// newStatusMsgReq creates a StatusMsg with the given message text and display duration.
|
||||
func newStatusMsgReq(s string, d time.Duration) *statusMsgReq {
|
||||
return &statusMsgReq{
|
||||
msg: s,
|
||||
|
|
@ -128,23 +229,12 @@ func newStatusMsgReq(s string, d time.Duration) *statusMsgReq {
|
|||
}
|
||||
}
|
||||
|
||||
// SendStatusMsgAndClear sends a string to be displayed in the status message,
|
||||
// as well as a delay until the message should be cleared
|
||||
func (h *Hub) SendStatusMsgAndClear(q string, clearDelay time.Duration) {
|
||||
msg := newStatusMsgReq(q, clearDelay)
|
||||
send(h.StatusMsgCh(), NewPayload(msg), h.isSync)
|
||||
}
|
||||
|
||||
func (h *Hub) SendPurgeDisplayCache() {
|
||||
send(h.DrawCh(), NewPayload("purgeCache"), h.isSync)
|
||||
}
|
||||
|
||||
// PagingCh returns the channel to page through the results
|
||||
func (h *Hub) PagingCh() chan Payload {
|
||||
func (h *Hub) PagingCh() chan *Payload[PagingRequest] {
|
||||
return h.pagingCh
|
||||
}
|
||||
|
||||
// SendPaging sends a request to move the cursor around
|
||||
func (h *Hub) SendPaging(x interface{}) {
|
||||
send(h.PagingCh(), NewPayload(x), h.isSync)
|
||||
func (h *Hub) SendPaging(ctx context.Context, x PagingRequest) {
|
||||
send(ctx, h.PagingCh(), NewPayload(x, isBatchCtx(ctx)))
|
||||
}
|
||||
|
|
|
|||
157
hub/hub_test.go
157
hub/hub_test.go
|
|
@ -1,56 +1,61 @@
|
|||
package hub
|
||||
package hub_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/peco/peco/hub"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHub(t *testing.T) {
|
||||
h := New(5)
|
||||
ctx := context.Background()
|
||||
|
||||
h := hub.New(5)
|
||||
|
||||
done := make(map[string]time.Time)
|
||||
|
||||
go func() {
|
||||
hr := <-h.QueryCh()
|
||||
require.Equal(t, "Hello World!", hr.Data())
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
done["query"] = time.Now()
|
||||
hr.Done()
|
||||
}()
|
||||
go func() {
|
||||
hr := <-h.DrawCh()
|
||||
switch v := hr.Data(); v.(type) {
|
||||
case string, bool, nil:
|
||||
// OK
|
||||
default:
|
||||
t.Errorf("Expected data to be nil, got %s", v)
|
||||
}
|
||||
// Data() returns *hub.DrawOptions directly — no type assertion needed
|
||||
_ = hr.Data()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
done["draw"] = time.Now()
|
||||
hr.Done()
|
||||
}()
|
||||
go func() {
|
||||
hr := <-h.StatusMsgCh()
|
||||
r := hr.Data().(*statusMsgReq)
|
||||
if r.Message() != "Hello, World!" {
|
||||
t.Errorf("Expected data to be 'Hello World!', got '%s'", r.Message())
|
||||
}
|
||||
// Data() returns hub.StatusMsg directly — no type assertion needed
|
||||
r := hr.Data()
|
||||
require.Equal(t, "Hello, World!", r.Message())
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
done["status"] = time.Now()
|
||||
hr.Done()
|
||||
}()
|
||||
go func() {
|
||||
hr := <-h.PagingCh()
|
||||
// Data() returns hub.PagingRequest directly — no type assertion needed
|
||||
r := hr.Data()
|
||||
require.Equal(t, hub.PagingRequestType(1), r.Type())
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
done["paging"] = time.Now()
|
||||
hr.Done()
|
||||
}()
|
||||
|
||||
h.Batch(func() {
|
||||
h.SendQuery("Hello World!")
|
||||
h.SendDraw(true)
|
||||
h.SendStatusMsg("Hello, World!")
|
||||
h.SendPaging(1)
|
||||
}, true)
|
||||
h.Batch(ctx, func(ctx context.Context) {
|
||||
h.SendQuery(ctx, "Hello World!")
|
||||
h.SendDraw(ctx, &hub.DrawOptions{})
|
||||
h.SendStatusMsg(ctx, "Hello, World!", 0)
|
||||
h.SendPaging(ctx, hub.PagingRequestType(1))
|
||||
})
|
||||
|
||||
phases := []string{
|
||||
"query",
|
||||
|
|
@ -59,9 +64,9 @@ func TestHub(t *testing.T) {
|
|||
"paging",
|
||||
}
|
||||
|
||||
max := len(phases) - 1
|
||||
last := len(phases) - 1
|
||||
for i := range phases {
|
||||
if max == i {
|
||||
if last == i {
|
||||
break
|
||||
}
|
||||
|
||||
|
|
@ -69,8 +74,114 @@ func TestHub(t *testing.T) {
|
|||
next := phases[i+1]
|
||||
|
||||
t.Logf("Checkin if %s was fired before %s", cur, next)
|
||||
if done[next].Before(done[cur]) {
|
||||
t.Errorf("%s executed before %s?!", next, cur)
|
||||
}
|
||||
require.False(t, done[next].Before(done[cur]),
|
||||
"%s executed before %s", next, cur)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPanicPropagates(t *testing.T) {
|
||||
h := hub.New(5)
|
||||
ctx := context.Background()
|
||||
|
||||
// A panic inside a Batch callback must propagate to the caller,
|
||||
// not be silently swallowed.
|
||||
require.Panics(t, func() {
|
||||
h.Batch(ctx, func(_ context.Context) {
|
||||
panic("bug in callback")
|
||||
})
|
||||
}, "Batch must not silently swallow panics")
|
||||
}
|
||||
|
||||
func TestBatchPanicReleasesLock(t *testing.T) {
|
||||
h := hub.New(5)
|
||||
ctx := context.Background()
|
||||
|
||||
// First call: panic inside a locked Batch.
|
||||
func() {
|
||||
defer func() { recover() }()
|
||||
h.Batch(ctx, func(_ context.Context) {
|
||||
panic("first call panics")
|
||||
})
|
||||
}()
|
||||
|
||||
// Second call: if the mutex was not released by the first panic,
|
||||
// this will deadlock. Use a timeout to detect that.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.Batch(ctx, func(ctx context.Context) {
|
||||
// Drain the message so the synchronous send completes.
|
||||
go func() {
|
||||
p := <-h.QueryCh()
|
||||
p.Done()
|
||||
}()
|
||||
h.SendQuery(ctx, "after panic")
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// success — mutex was properly released
|
||||
case <-time.After(2 * time.Second):
|
||||
require.Fail(t, "Batch deadlocked — mutex was not released after panic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchNestedDoesNotDeadlock(t *testing.T) {
|
||||
h := hub.New(5)
|
||||
ctx := context.Background()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.Batch(ctx, func(ctx context.Context) {
|
||||
// Nested Batch call — must not deadlock
|
||||
h.Batch(ctx, func(ctx context.Context) {
|
||||
go func() {
|
||||
p := <-h.QueryCh()
|
||||
p.Done()
|
||||
}()
|
||||
h.SendQuery(ctx, "nested")
|
||||
})
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// success — nested Batch did not deadlock
|
||||
case <-time.After(2 * time.Second):
|
||||
require.Fail(t, "nested Batch deadlocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendStatusMsg(t *testing.T) {
|
||||
t.Run("zero delay", func(t *testing.T) {
|
||||
h := hub.New(5)
|
||||
ctx := context.Background()
|
||||
|
||||
go func() {
|
||||
h.SendStatusMsg(ctx, "hello", 0)
|
||||
}()
|
||||
|
||||
p := <-h.StatusMsgCh()
|
||||
defer p.Done()
|
||||
|
||||
require.Equal(t, "hello", p.Data().Message())
|
||||
require.Equal(t, time.Duration(0), p.Data().Delay())
|
||||
})
|
||||
|
||||
t.Run("non-zero delay", func(t *testing.T) {
|
||||
h := hub.New(5)
|
||||
ctx := context.Background()
|
||||
|
||||
go func() {
|
||||
h.SendStatusMsg(ctx, "temporary", 500*time.Millisecond)
|
||||
}()
|
||||
|
||||
p := <-h.StatusMsgCh()
|
||||
defer p.Done()
|
||||
|
||||
require.Equal(t, "temporary", p.Data().Message())
|
||||
require.Equal(t, 500*time.Millisecond, p.Data().Delay())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
package hub
|
||||
|
||||
import "sync"
|
||||
|
||||
// Hub acts as the messaging hub between components -- that is,
|
||||
// it controls how the communication that goes through channels
|
||||
// are handled.
|
||||
type Hub struct {
|
||||
isSync bool
|
||||
mutex sync.Mutex
|
||||
queryCh chan Payload
|
||||
drawCh chan Payload
|
||||
statusMsgCh chan Payload
|
||||
pagingCh chan Payload
|
||||
}
|
||||
|
||||
// Payload is a wrapper around the actual request value that needs
|
||||
// to be passed. It contains an optional channel field which can
|
||||
// be filled to force synchronous communication between the
|
||||
// sender and receiver
|
||||
type Payload interface {
|
||||
// Data allows you to retrieve the data that's embedded in the payload.
|
||||
Data() interface{}
|
||||
|
||||
// Done is called when the payload has been processed. There are cases
|
||||
// where the payload processing is expected to be synchronized with the
|
||||
// caller, and this method signals the caller that it is safe to proceed
|
||||
// to whatever next action it was expecting
|
||||
Done()
|
||||
}
|
||||
|
||||
type payload struct {
|
||||
data interface{}
|
||||
done chan struct{}
|
||||
}
|
||||
41
hub/paging.go
Normal file
41
hub/paging.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//go:generate stringer -type PagingRequestType -output paging_request_type_gen.go
|
||||
|
||||
package hub
|
||||
|
||||
// PagingRequestType is the type of a paging request
|
||||
type PagingRequestType int
|
||||
|
||||
const (
|
||||
ToLineAbove PagingRequestType = iota // ToLineAbove moves the selection to the line above
|
||||
ToScrollPageDown // ToScrollPageDown moves the selection to the next page
|
||||
ToLineBelow // ToLineBelow moves the selection to the line below
|
||||
ToScrollPageUp // ToScrollPageUp moves the selection to the previous page
|
||||
ToScrollLeft // ToScrollLeft scrolls screen to the left
|
||||
ToScrollRight // ToScrollRight scrolls screen to the right
|
||||
ToLineInPage // ToLineInPage jumps to a particular line on the page
|
||||
ToScrollFirstItem // ToScrollFirstItem
|
||||
ToScrollLastItem // ToScrollLastItem
|
||||
)
|
||||
|
||||
// PagingRequest can be sent to move the selection cursor
|
||||
type PagingRequest interface {
|
||||
Type() PagingRequestType
|
||||
}
|
||||
|
||||
// Type satisfies the PagingRequest interface for PagingRequestType itself
|
||||
func (prt PagingRequestType) Type() PagingRequestType {
|
||||
return prt
|
||||
}
|
||||
|
||||
// JumpToLineRequest is a PagingRequest that jumps to a specific line
|
||||
type JumpToLineRequest int
|
||||
|
||||
// Type satisfies the PagingRequest interface
|
||||
func (jlr JumpToLineRequest) Type() PagingRequestType {
|
||||
return ToLineInPage
|
||||
}
|
||||
|
||||
// Line returns the target line number
|
||||
func (jlr JumpToLineRequest) Line() int {
|
||||
return int(jlr)
|
||||
}
|
||||
32
hub/paging_request_type_gen.go
Normal file
32
hub/paging_request_type_gen.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Code generated by "stringer -type PagingRequestType -output paging_request_type_gen.go"; DO NOT EDIT.
|
||||
|
||||
package hub
|
||||
|
||||
import "strconv"
|
||||
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
// Re-run the stringer command to generate them again.
|
||||
var x [1]struct{}
|
||||
_ = x[ToLineAbove-0]
|
||||
_ = x[ToScrollPageDown-1]
|
||||
_ = x[ToLineBelow-2]
|
||||
_ = x[ToScrollPageUp-3]
|
||||
_ = x[ToScrollLeft-4]
|
||||
_ = x[ToScrollRight-5]
|
||||
_ = x[ToLineInPage-6]
|
||||
_ = x[ToScrollFirstItem-7]
|
||||
_ = x[ToScrollLastItem-8]
|
||||
}
|
||||
|
||||
const _PagingRequestType_name = "ToLineAboveToScrollPageDownToLineBelowToScrollPageUpToScrollLeftToScrollRightToLineInPageToScrollFirstItemToScrollLastItem"
|
||||
|
||||
var _PagingRequestType_index = [...]uint8{0, 11, 27, 38, 52, 64, 77, 89, 106, 122}
|
||||
|
||||
func (i PagingRequestType) String() string {
|
||||
idx := int(i) - 0
|
||||
if i < 0 || idx >= len(_PagingRequestType_index)-1 {
|
||||
return "PagingRequestType(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _PagingRequestType_name[_PagingRequestType_index[idx]:_PagingRequestType_index[idx+1]]
|
||||
}
|
||||
78
input.go
78
input.go
|
|
@ -1,13 +1,20 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/nsf/termbox-go"
|
||||
"context"
|
||||
|
||||
"github.com/lestrrat-go/pdebug"
|
||||
)
|
||||
|
||||
func NewInput(state *Peco, am ActionMap, src chan termbox.Event) *Input {
|
||||
// Input handles dispatching key events to actions.
|
||||
type Input struct {
|
||||
actions ActionMap
|
||||
evsrc chan Event
|
||||
state *Peco
|
||||
}
|
||||
|
||||
// NewInput creates and returns a new Input instance for handling keyboard events.
|
||||
func NewInput(state *Peco, am ActionMap, src chan Event) *Input {
|
||||
return &Input{
|
||||
actions: am,
|
||||
evsrc: src,
|
||||
|
|
@ -15,6 +22,7 @@ func NewInput(state *Peco, am ActionMap, src chan termbox.Event) *Input {
|
|||
}
|
||||
}
|
||||
|
||||
// Loop runs the main input event loop, reading terminal events and dispatching them.
|
||||
func (i *Input) Loop(ctx context.Context, cancel func()) error {
|
||||
defer cancel()
|
||||
|
||||
|
|
@ -22,7 +30,10 @@ func (i *Input) Loop(ctx context.Context, cancel func()) error {
|
|||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case ev := <-i.evsrc:
|
||||
case ev, ok := <-i.evsrc:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := i.handleInputEvent(ctx, ev); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -30,49 +41,26 @@ func (i *Input) Loop(ctx context.Context, cancel func()) error {
|
|||
}
|
||||
}
|
||||
|
||||
func (i *Input) handleInputEvent(ctx context.Context, ev termbox.Event) error {
|
||||
// handleInputEvent processes a single terminal input event, mapping it to an action.
|
||||
func (i *Input) handleInputEvent(ctx context.Context, ev Event) error {
|
||||
if pdebug.Enabled {
|
||||
g := pdebug.Marker("event received from user: %#v", ev)
|
||||
defer g.End()
|
||||
}
|
||||
|
||||
switch ev.Type {
|
||||
case termbox.EventError:
|
||||
case EventError:
|
||||
return nil
|
||||
case termbox.EventResize:
|
||||
i.state.Hub().SendDraw(nil)
|
||||
case EventResize:
|
||||
i.state.Hub().SendDraw(ctx, nil)
|
||||
return nil
|
||||
case termbox.EventKey:
|
||||
// ModAlt is a sequence of letters with a leading \x1b (=Esc).
|
||||
// It would be nice if termbox differentiated this for us, but
|
||||
// we workaround it by waiting (juuuust a few milliseconds) for
|
||||
// extra key events. If no extra events arrive, it should be Esc
|
||||
|
||||
m := &i.mutex
|
||||
|
||||
// Smells like Esc or Alt. mod == nil checks for the presence
|
||||
// of a previous timer
|
||||
m.Lock()
|
||||
if ev.Ch == 0 && ev.Key == 27 && i.mod == nil {
|
||||
tmp := ev
|
||||
i.mod = time.AfterFunc(50*time.Millisecond, func() {
|
||||
m.Lock()
|
||||
i.mod = nil
|
||||
m.Unlock()
|
||||
i.state.Keymap().ExecuteAction(ctx, i.state, tmp)
|
||||
})
|
||||
m.Unlock()
|
||||
return nil
|
||||
}
|
||||
m.Unlock()
|
||||
|
||||
// it doesn't look like this is Esc or Alt. If we have a previous
|
||||
// timer, stop it because this is probably Alt+ this new key
|
||||
m.Lock()
|
||||
if i.mod != nil {
|
||||
i.mod.Stop()
|
||||
i.mod = nil
|
||||
ev.Mod |= termbox.ModAlt
|
||||
}
|
||||
m.Unlock()
|
||||
|
||||
i.state.Keymap().ExecuteAction(ctx, i.state, ev)
|
||||
|
||||
case EventKey:
|
||||
// tcell already disambiguates standalone Escape from Alt+key
|
||||
// sequences using its own internal escape timer. By the time
|
||||
// we receive a KeyEsc event here, tcell has already determined
|
||||
// it is a standalone Escape. Alt+key events arrive with ModAlt
|
||||
// already set. No application-layer timer is needed.
|
||||
_ = i.state.Keymap().ExecuteAction(ctx, i.state, ev)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
72
input_test.go
Normal file
72
input_test.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/peco/peco/internal/keyseq"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInputDispatchesEscImmediately(t *testing.T) {
|
||||
// After removing the application-layer Esc timer (tcell handles
|
||||
// Esc/Alt disambiguation), verify that a bare Esc event is
|
||||
// dispatched immediately without delay.
|
||||
|
||||
var escCount int64
|
||||
|
||||
testAction := ActionFunc(func(_ context.Context, _ *Peco, _ Event) {
|
||||
atomic.AddInt64(&escCount, 1)
|
||||
})
|
||||
nameToActions["peco.TestEscImmediate"] = testAction
|
||||
t.Cleanup(func() { delete(nameToActions, "peco.TestEscImmediate") })
|
||||
|
||||
state := New()
|
||||
state.hub = nullHub{}
|
||||
state.config.Keymap = map[string]string{
|
||||
"Esc": "peco.TestEscImmediate",
|
||||
}
|
||||
state.config.Action = map[string][]string{}
|
||||
require.NoError(t, state.populateKeymap())
|
||||
|
||||
ctx := context.Background()
|
||||
input := NewInput(state, state.Keymap(), make(chan Event))
|
||||
|
||||
escEv := Event{Type: EventKey, Key: keyseq.KeyEsc, Ch: 0}
|
||||
input.handleInputEvent(ctx, escEv)
|
||||
|
||||
require.Equal(t, int64(1), atomic.LoadInt64(&escCount),
|
||||
"Esc action should fire immediately without timer delay")
|
||||
}
|
||||
|
||||
func TestInputAltKeyPassthrough(t *testing.T) {
|
||||
// Verify that events arriving with ModAlt already set by tcell
|
||||
// are dispatched correctly.
|
||||
|
||||
var gotMod keyseq.ModifierKey
|
||||
|
||||
testAction := ActionFunc(func(_ context.Context, _ *Peco, ev Event) {
|
||||
gotMod = ev.Mod
|
||||
})
|
||||
nameToActions["peco.TestAltRecord"] = testAction
|
||||
t.Cleanup(func() { delete(nameToActions, "peco.TestAltRecord") })
|
||||
|
||||
state := New()
|
||||
state.hub = nullHub{}
|
||||
state.config.Keymap = map[string]string{
|
||||
"M-f": "peco.TestAltRecord",
|
||||
}
|
||||
state.config.Action = map[string][]string{}
|
||||
require.NoError(t, state.populateKeymap())
|
||||
|
||||
ctx := context.Background()
|
||||
input := NewInput(state, state.Keymap(), make(chan Event))
|
||||
|
||||
// Simulate tcell delivering Alt+f with ModAlt already set
|
||||
altEv := Event{Type: EventKey, Key: 0, Ch: 'f', Mod: keyseq.ModAlt}
|
||||
input.handleInputEvent(ctx, altEv)
|
||||
|
||||
require.Equal(t, keyseq.ModAlt, gotMod,
|
||||
"ModAlt should be preserved from tcell event")
|
||||
}
|
||||
468
interface.go
468
interface.go
|
|
@ -1,468 +0,0 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"context"
|
||||
|
||||
"github.com/google/btree"
|
||||
"github.com/nsf/termbox-go"
|
||||
"github.com/peco/peco/filter"
|
||||
"github.com/peco/peco/hub"
|
||||
"github.com/peco/peco/internal/keyseq"
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
)
|
||||
|
||||
const (
|
||||
successKey = "success"
|
||||
errorKey = "error"
|
||||
)
|
||||
|
||||
const (
|
||||
ToLineAbove PagingRequestType = iota // ToLineAbove moves the selection to the line above
|
||||
ToScrollPageDown // ToScrollPageDown moves the selection to the next page
|
||||
ToLineBelow // ToLineBelow moves the selection to the line below
|
||||
ToScrollPageUp // ToScrollPageUp moves the selection to the previous page
|
||||
ToScrollLeft // ToScrollLeft scrolls screen to the left
|
||||
ToScrollRight // ToScrollRight scrolls screen to the right
|
||||
ToLineInPage // ToLineInPage jumps to a particular line on the page
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultLayoutType = LayoutTypeTopDown // LayoutTypeTopDown makes the layout so the items read from top to bottom
|
||||
LayoutTypeTopDown = "top-down" // LayoutTypeBottomUp changes the layout to read from bottom to up
|
||||
LayoutTypeBottomUp = "bottom-up"
|
||||
)
|
||||
|
||||
const (
|
||||
AnchorTop VerticalAnchor = iota + 1 // AnchorTop anchors elements towards the top of the screen
|
||||
AnchorBottom // AnchorBottom anchors elements towards the bottom of the screen
|
||||
)
|
||||
|
||||
// These are used as keys in the config file
|
||||
const (
|
||||
IgnoreCaseMatch = "IgnoreCase"
|
||||
CaseSensitiveMatch = "CaseSensitive"
|
||||
SmartCaseMatch = "SmartCase"
|
||||
RegexpMatch = "Regexp"
|
||||
)
|
||||
|
||||
type idgen struct {
|
||||
ch chan uint64
|
||||
}
|
||||
|
||||
// Peco is the global object containing everything required to run peco.
|
||||
// It also contains the global state of the program.
|
||||
type Peco struct {
|
||||
Argv []string
|
||||
Stdin io.Reader
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
hub MessageHub
|
||||
|
||||
args []string
|
||||
bufferSize int
|
||||
caret Caret
|
||||
// Config contains the values read in from config file
|
||||
config Config
|
||||
currentLineBuffer Buffer
|
||||
enableSep bool // Enable parsing on separators
|
||||
execOnFinish string
|
||||
filters filter.Set
|
||||
idgen *idgen
|
||||
initialFilter string
|
||||
initialQuery string // populated if --query is specified
|
||||
inputseq Inputseq // current key sequence (just the names)
|
||||
keymap Keymap
|
||||
layoutType string
|
||||
location Location
|
||||
maxScanBufferSize int
|
||||
mutex sync.Mutex
|
||||
onCancel string
|
||||
prompt string
|
||||
query Query
|
||||
queryExecDelay time.Duration
|
||||
queryExecMutex sync.Mutex
|
||||
queryExecTimer *time.Timer
|
||||
readyCh chan struct{}
|
||||
resultCh chan line.Line
|
||||
screen Screen
|
||||
selection *Selection
|
||||
selectionPrefix string
|
||||
selectionRangeStart RangeStart
|
||||
selectOneAndExit bool // True if --select-1 is enabled
|
||||
singleKeyJumpMode bool
|
||||
singleKeyJumpPrefixes []rune
|
||||
singleKeyJumpPrefixMap map[rune]uint
|
||||
singleKeyJumpShowPrefix bool
|
||||
skipReadConfig bool
|
||||
styles StyleSet
|
||||
|
||||
// Source is where we buffer input. It gets reused when a new query is
|
||||
// executed.
|
||||
source *Source
|
||||
|
||||
// cancelFunc is called for Exit()
|
||||
cancelFunc func()
|
||||
// Errors are stored here
|
||||
err error
|
||||
}
|
||||
|
||||
type MatchIndexer interface {
|
||||
// Indices return the matched portion(s) of a string after filtering.
|
||||
// Note that while Indices may return nil, that just means that there are
|
||||
// no substrings to be highlighted. It doesn't mean there were no matches
|
||||
Indices() [][]int
|
||||
}
|
||||
|
||||
type Keyseq interface {
|
||||
Add(keyseq.KeyList, interface{})
|
||||
AcceptKey(keyseq.Key) (interface{}, error)
|
||||
CancelChain()
|
||||
Clear()
|
||||
Compile() error
|
||||
InMiddleOfChain() bool
|
||||
}
|
||||
|
||||
// PagingRequest can be sent to move the selection cursor
|
||||
type PagingRequestType int
|
||||
|
||||
type PagingRequest interface {
|
||||
Type() PagingRequestType
|
||||
}
|
||||
|
||||
type JumpToLineRequest int
|
||||
|
||||
// Selection stores the line ids that were selected by the user.
|
||||
// The contents of the Selection is always sorted from smallest to
|
||||
// largest line ID
|
||||
type Selection struct {
|
||||
mutex sync.Mutex
|
||||
tree *btree.BTree
|
||||
}
|
||||
|
||||
// Screen hides termbox from the consuming code so that
|
||||
// it can be swapped out for testing
|
||||
type Screen interface {
|
||||
Init() error
|
||||
Close() error
|
||||
Flush() error
|
||||
PollEvent(context.Context) chan termbox.Event
|
||||
Print(PrintArgs) int
|
||||
Resume()
|
||||
SetCell(int, int, rune, termbox.Attribute, termbox.Attribute)
|
||||
Size() (int, int)
|
||||
SendEvent(termbox.Event)
|
||||
Suspend()
|
||||
}
|
||||
|
||||
// Termbox just hands out the processing to the termbox library
|
||||
type Termbox struct {
|
||||
mutex sync.Mutex
|
||||
resumeCh chan chan struct{}
|
||||
suspendCh chan struct{}
|
||||
}
|
||||
|
||||
// View handles the drawing/updating the screen
|
||||
type View struct {
|
||||
layout Layout
|
||||
state *Peco
|
||||
}
|
||||
|
||||
// PageCrop filters out a new LineBuffer based on entries
|
||||
// per page and the page number
|
||||
type PageCrop struct {
|
||||
perPage int
|
||||
currentPage int
|
||||
}
|
||||
|
||||
// LayoutType describes the types of layout that peco can take
|
||||
type LayoutType string
|
||||
|
||||
// VerticalAnchor describes the direction to which elements in the
|
||||
// layout are anchored to
|
||||
type VerticalAnchor int
|
||||
|
||||
// Layout represents the component that controls where elements are placed on screen
|
||||
type Layout interface {
|
||||
PrintStatus(string, time.Duration)
|
||||
DrawPrompt(*Peco)
|
||||
DrawScreen(*Peco, *DrawOptions)
|
||||
MovePage(*Peco, PagingRequest) (moved bool)
|
||||
PurgeDisplayCache()
|
||||
}
|
||||
|
||||
// AnchorSettings groups items that are required to control
|
||||
// where an anchored item is actually placed
|
||||
type AnchorSettings struct {
|
||||
anchor VerticalAnchor // AnchorTop or AnchorBottom
|
||||
anchorOffset int // offset this many lines from the anchor
|
||||
screen Screen
|
||||
}
|
||||
|
||||
// UserPrompt draws the prompt line
|
||||
type UserPrompt struct {
|
||||
*AnchorSettings
|
||||
prompt string
|
||||
promptLen int
|
||||
styles *StyleSet
|
||||
}
|
||||
|
||||
// StatusBar draws the status message bar
|
||||
type StatusBar struct {
|
||||
*AnchorSettings
|
||||
clearTimer *time.Timer
|
||||
styles *StyleSet
|
||||
timerMutex sync.Mutex
|
||||
}
|
||||
|
||||
// ListArea represents the area where the actual line buffer is
|
||||
// displayed in the screen
|
||||
type ListArea struct {
|
||||
*AnchorSettings
|
||||
sortTopDown bool
|
||||
displayCache []line.Line
|
||||
dirty bool
|
||||
styles *StyleSet
|
||||
}
|
||||
|
||||
// BasicLayout is... the basic layout :) At this point this is the
|
||||
// only struct for layouts, which means that while the position
|
||||
// of components may be configurable, the actual types of components
|
||||
// that are used are set and static
|
||||
type BasicLayout struct {
|
||||
*StatusBar
|
||||
prompt *UserPrompt
|
||||
list *ListArea
|
||||
}
|
||||
|
||||
// Keymap holds all the key sequence to action map
|
||||
type Keymap struct {
|
||||
Config map[string]string
|
||||
Action map[string][]string // custom actions
|
||||
seq Keyseq
|
||||
}
|
||||
|
||||
// Filter is responsible for the actual "grep" part of peco
|
||||
type Filter struct {
|
||||
state *Peco
|
||||
}
|
||||
|
||||
// Action describes an action that can be executed upon receiving user
|
||||
// input. It's an interface so you can create any kind of Action you need,
|
||||
// but most everything is implemented in terms of ActionFunc, which is
|
||||
// callback based Action
|
||||
type Action interface {
|
||||
Register(string, ...termbox.Key)
|
||||
RegisterKeySequence(string, keyseq.KeyList)
|
||||
Execute(context.Context, *Peco, termbox.Event)
|
||||
}
|
||||
|
||||
// ActionFunc is a type of Action that is basically just a callback.
|
||||
type ActionFunc func(context.Context, *Peco, termbox.Event)
|
||||
|
||||
// FilteredBuffer holds a "filtered" buffer. It holds a reference to
|
||||
// the source buffer (note: should be immutable) and a list of indices
|
||||
// into the source buffer
|
||||
type FilteredBuffer struct {
|
||||
maxcols int
|
||||
src Buffer
|
||||
selection []int // maps from our index to src's index
|
||||
}
|
||||
|
||||
// Config holds all the data that can be configured in the
|
||||
// external configuration file
|
||||
type Config struct {
|
||||
Action map[string][]string `json:"Action"`
|
||||
// Keymap used to be directly responsible for dispatching
|
||||
// events against user input, but since then this has changed
|
||||
// into something that just records the user's config input
|
||||
Keymap map[string]string `json:"Keymap"`
|
||||
Matcher string `json:"Matcher"` // Deprecated.
|
||||
InitialMatcher string `json:"InitialMatcher"` // Use this instead of Matcher
|
||||
InitialFilter string `json:"InitialFilter"`
|
||||
Style StyleSet `json:"Style"`
|
||||
Prompt string `json:"Prompt"`
|
||||
Layout string `json:"Layout"`
|
||||
OnCancel string `json:"OnCancel"`
|
||||
CustomMatcher map[string][]string
|
||||
CustomFilter map[string]CustomFilterConfig
|
||||
QueryExecutionDelay int
|
||||
StickySelection bool
|
||||
MaxScanBufferSize int
|
||||
|
||||
// If this is true, then the prefix for single key jump mode
|
||||
// is displayed by default.
|
||||
SingleKeyJump SingleKeyJumpConfig `json:"SingleKeyJump"`
|
||||
|
||||
// Use this prefix to denote currently selected line
|
||||
SelectionPrefix string `json:"SelectionPrefix"`
|
||||
}
|
||||
|
||||
type SingleKeyJumpConfig struct {
|
||||
ShowPrefix bool `json:"ShowPrefix"`
|
||||
}
|
||||
|
||||
// CustomFilterConfig is used to specify configuration parameters
|
||||
// to CustomFilters
|
||||
type CustomFilterConfig struct {
|
||||
// Cmd is the name of the command to invoke
|
||||
Cmd string
|
||||
|
||||
// TODO: need to check if how we use this is correct
|
||||
Args []string
|
||||
|
||||
// BufferThreshold defines how many lines peco buffers before
|
||||
// invoking the external command. If this value is big, we
|
||||
// will execute the external command fewer times, but the
|
||||
// results will not be generated for longer periods of time.
|
||||
// If this value is small, we will execute the external command
|
||||
// more often, but you pay the penalty of invoking that command
|
||||
// more times.
|
||||
BufferThreshold int
|
||||
}
|
||||
|
||||
// StyleSet holds styles for various sections
|
||||
type StyleSet struct {
|
||||
Basic Style `json:"Basic"`
|
||||
SavedSelection Style `json:"SavedSelection"`
|
||||
Selected Style `json:"Selected"`
|
||||
Query Style `json:"Query"`
|
||||
Matched Style `json:"Matched"`
|
||||
}
|
||||
|
||||
// Style describes termbox styles
|
||||
type Style struct {
|
||||
fg termbox.Attribute
|
||||
bg termbox.Attribute
|
||||
}
|
||||
|
||||
type Caret struct {
|
||||
mutex sync.Mutex
|
||||
pos int
|
||||
}
|
||||
|
||||
type Location struct {
|
||||
col int
|
||||
lineno int
|
||||
maxPage int
|
||||
page int
|
||||
perPage int
|
||||
offset int
|
||||
total int
|
||||
}
|
||||
|
||||
type Query struct {
|
||||
query []rune
|
||||
savedQuery []rune
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
type FilterQuery Query
|
||||
|
||||
// Source implements pipeline.Source, and is the buffer for the input
|
||||
type Source struct {
|
||||
pipeline.ChanOutput
|
||||
|
||||
capacity int
|
||||
enableSep bool
|
||||
idgen line.IDGenerator
|
||||
in io.Reader
|
||||
lines []line.Line
|
||||
name string
|
||||
mutex sync.RWMutex
|
||||
ready chan struct{}
|
||||
setupDone chan struct{}
|
||||
setupOnce sync.Once
|
||||
}
|
||||
|
||||
type State interface {
|
||||
Keymap() *Keymap
|
||||
Query() Query
|
||||
Screen() Screen
|
||||
SetCurrentCol(int)
|
||||
CurrentCol() int
|
||||
SetCurrentLine(int)
|
||||
CurrentLine() int
|
||||
SetSingleKeyJumpMode(bool)
|
||||
SingleKeyJumpMode() bool
|
||||
}
|
||||
|
||||
type CLIOptions struct {
|
||||
OptHelp bool `short:"h" long:"help" description:"show this help message and exit"`
|
||||
OptQuery string `long:"query" description:"initial value for query"`
|
||||
OptRcfile string `long:"rcfile" description:"path to the settings file"`
|
||||
OptVersion bool `long:"version" description:"print the version and exit"`
|
||||
OptBufferSize int `long:"buffer-size" short:"b" description:"number of lines to keep in search buffer"`
|
||||
OptEnableNullSep bool `long:"null" description:"expect NUL (\\0) as separator for target/output"`
|
||||
OptInitialIndex int `long:"initial-index" description:"position of the initial index of the selection (0 base)"`
|
||||
OptInitialMatcher string `long:"initial-matcher" description:"specify the default matcher (deprecated)"`
|
||||
OptInitialFilter string `long:"initial-filter" description:"specify the default filter"`
|
||||
OptPrompt string `long:"prompt" description:"specify the prompt string"`
|
||||
OptLayout string `long:"layout" description:"layout to be used. 'top-down' or 'bottom-up'. default is 'top-down'"`
|
||||
OptSelect1 bool `long:"select-1" description:"select first item and immediately exit if the input contains only 1 item"`
|
||||
OptOnCancel string `long:"on-cancel" description:"specify action on user cancel. 'success' or 'error'.\ndefault is 'success'. This may change in future versions"`
|
||||
OptSelectionPrefix string `long:"selection-prefix" description:"use a prefix instead of changing line color to indicate currently selected lines.\ndefault is to use colors. This option is experimental"`
|
||||
OptExec string `long:"exec" description:"execute command instead of finishing/terminating peco.\nPlease note that this command will receive selected line(s) from stdin,\nand will be executed via '/bin/sh -c' or 'cmd /c'"`
|
||||
}
|
||||
|
||||
type CLI struct {
|
||||
}
|
||||
|
||||
type RangeStart struct {
|
||||
val int
|
||||
valid bool
|
||||
}
|
||||
|
||||
// Buffer interface is used for containers for lines to be
|
||||
// processed by peco.
|
||||
type Buffer interface {
|
||||
linesInRange(int, int) []line.Line
|
||||
LineAt(int) (line.Line, error)
|
||||
Size() int
|
||||
}
|
||||
|
||||
// MemoryBuffer is an implementation of Buffer
|
||||
type MemoryBuffer struct {
|
||||
done chan struct{}
|
||||
lines []line.Line
|
||||
mutex sync.RWMutex
|
||||
PeriodicFunc func()
|
||||
}
|
||||
|
||||
type ActionMap interface {
|
||||
ExecuteAction(context.Context, *Peco, termbox.Event) error
|
||||
}
|
||||
|
||||
type Input struct {
|
||||
actions ActionMap
|
||||
evsrc chan termbox.Event
|
||||
mod *time.Timer
|
||||
mutex sync.Mutex
|
||||
state *Peco
|
||||
}
|
||||
|
||||
// MessageHub is the interface that must be satisfied by the
|
||||
// message hub component. Unless we're in testing, github.com/peco/peco/hub.Hub
|
||||
// is used.
|
||||
type MessageHub interface {
|
||||
Batch(func(), bool)
|
||||
DrawCh() chan hub.Payload
|
||||
PagingCh() chan hub.Payload
|
||||
QueryCh() chan hub.Payload
|
||||
SendDraw(interface{})
|
||||
SendDrawPrompt()
|
||||
SendPaging(interface{})
|
||||
SendQuery(string)
|
||||
SendStatusMsg(string)
|
||||
SendStatusMsgAndClear(string, time.Duration)
|
||||
StatusMsgCh() chan hub.Payload
|
||||
}
|
||||
|
||||
type filterProcessor struct {
|
||||
filter filter.Filter
|
||||
query string
|
||||
}
|
||||
53
internal/ansi/bench_test.go
Normal file
53
internal/ansi/bench_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package ansi
|
||||
|
||||
import "testing"
|
||||
|
||||
// BenchmarkParse_MultipleColors benchmarks parsing a line with several ANSI
|
||||
// color changes, which exercises the parseSGR strings.Split path.
|
||||
func BenchmarkParse_MultipleColors(b *testing.B) {
|
||||
input := "\x1b[1;31mRed Bold\x1b[0m normal \x1b[38;5;196mExtended\x1b[0m \x1b[4;32mGreen UL\x1b[0m"
|
||||
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
Parse(input)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkParse_TrueColor benchmarks truecolor parsing (38;2;R;G;B).
|
||||
func BenchmarkParse_TrueColor(b *testing.B) {
|
||||
input := "\x1b[38;2;255;128;0mOrange\x1b[0m \x1b[48;2;0;128;255mBlueBG\x1b[0m"
|
||||
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
Parse(input)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkParseSGR benchmarks the raw SGR parameter parsing.
|
||||
func BenchmarkParseSGR(b *testing.B) {
|
||||
var fg, bg Attribute
|
||||
|
||||
b.Run("simple", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for range b.N {
|
||||
fg, bg = ColorDefault, ColorDefault
|
||||
parseSGR("1;31;42", &fg, &bg)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("256color", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for range b.N {
|
||||
fg, bg = ColorDefault, ColorDefault
|
||||
parseSGR("38;5;196", &fg, &bg)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("truecolor", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for range b.N {
|
||||
fg, bg = ColorDefault, ColorDefault
|
||||
parseSGR("38;2;255;128;0", &fg, &bg)
|
||||
}
|
||||
})
|
||||
}
|
||||
301
internal/ansi/parser.go
Normal file
301
internal/ansi/parser.go
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
// Package ansi provides ANSI SGR escape sequence parsing for peco.
|
||||
// It extracts color/style attributes from input text and produces
|
||||
// run-length encoded attribute spans alongside stripped plain text.
|
||||
package ansi
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Attribute mirrors peco.Attribute so we avoid an import cycle.
|
||||
// The values are identical and can be cast directly.
|
||||
type Attribute = uint32
|
||||
|
||||
// Named palette color constants (matching peco.Color* values).
|
||||
const (
|
||||
ColorDefault Attribute = 0x0000
|
||||
ColorBlack Attribute = 0x0001
|
||||
ColorRed Attribute = 0x0002
|
||||
ColorGreen Attribute = 0x0003
|
||||
ColorYellow Attribute = 0x0004
|
||||
ColorBlue Attribute = 0x0005
|
||||
ColorMagenta Attribute = 0x0006
|
||||
ColorCyan Attribute = 0x0007
|
||||
ColorWhite Attribute = 0x0008
|
||||
)
|
||||
|
||||
const (
|
||||
AttrTrueColor Attribute = 0x01000000
|
||||
AttrBold Attribute = 0x02000000
|
||||
AttrUnderline Attribute = 0x04000000
|
||||
AttrReverse Attribute = 0x08000000
|
||||
)
|
||||
|
||||
// basicFgColors maps SGR codes 30-37 to palette colors.
|
||||
var basicFgColors = [8]Attribute{
|
||||
ColorBlack, ColorRed, ColorGreen, ColorYellow,
|
||||
ColorBlue, ColorMagenta, ColorCyan, ColorWhite,
|
||||
}
|
||||
|
||||
// AttrSpan represents a run of characters sharing identical ANSI attributes.
|
||||
type AttrSpan struct {
|
||||
Fg Attribute
|
||||
Bg Attribute
|
||||
Length int // number of runes
|
||||
}
|
||||
|
||||
// ParseResult contains the output of ANSI parsing.
|
||||
type ParseResult struct {
|
||||
Stripped string // text with ANSI codes removed
|
||||
Attrs []AttrSpan // run-length encoded attributes; nil if no ANSI codes found
|
||||
}
|
||||
|
||||
// Parse parses ANSI SGR sequences from input and returns the stripped text
|
||||
// along with run-length encoded per-character attributes.
|
||||
// If no ANSI escape sequences are found, Attrs is nil.
|
||||
func Parse(input string) ParseResult {
|
||||
// Fast path: if no ESC character, return as-is
|
||||
if !strings.ContainsRune(input, '\x1b') {
|
||||
return ParseResult{Stripped: input, Attrs: nil}
|
||||
}
|
||||
|
||||
var (
|
||||
out strings.Builder
|
||||
spans []AttrSpan
|
||||
curFg = ColorDefault
|
||||
curBg = ColorDefault
|
||||
count int // runes in current span
|
||||
)
|
||||
|
||||
out.Grow(len(input))
|
||||
|
||||
flush := func() {
|
||||
if count > 0 {
|
||||
spans = append(spans, AttrSpan{Fg: curFg, Bg: curBg, Length: count})
|
||||
count = 0
|
||||
}
|
||||
}
|
||||
|
||||
i := 0
|
||||
for i < len(input) {
|
||||
if input[i] == '\x1b' && i+1 < len(input) && input[i+1] == '[' {
|
||||
// Found CSI sequence: ESC [
|
||||
j := i + 2
|
||||
// Scan for the terminating byte (0x40-0x7E)
|
||||
for j < len(input) && input[j] >= 0x20 && input[j] <= 0x3F {
|
||||
j++
|
||||
}
|
||||
if j >= len(input) {
|
||||
// Incomplete sequence at end of string: skip it
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
terminator := input[j]
|
||||
if terminator == 'm' {
|
||||
// SGR sequence
|
||||
params := input[i+2 : j]
|
||||
flush()
|
||||
parseSGR(params, &curFg, &curBg)
|
||||
}
|
||||
// Skip the entire sequence (including non-SGR ones)
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Regular character
|
||||
r, size := utf8.DecodeRuneInString(input[i:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
r = '?'
|
||||
}
|
||||
out.WriteRune(r)
|
||||
count++
|
||||
i += size
|
||||
}
|
||||
|
||||
flush()
|
||||
|
||||
return ParseResult{
|
||||
Stripped: out.String(),
|
||||
Attrs: spans,
|
||||
}
|
||||
}
|
||||
|
||||
// maxSGRParams is the maximum number of semicolon-separated parameters
|
||||
// we handle in a single SGR sequence. Truecolor (38;2;R;G;B) uses 5.
|
||||
// Any sequence with more parameters is truncated.
|
||||
const maxSGRParams = 8
|
||||
|
||||
// parseSGR interprets SGR parameters (the part between ESC[ and m).
|
||||
// It modifies fg and bg in place based on the parameter codes.
|
||||
// Parameters are split on semicolons into a stack-allocated array to
|
||||
// avoid the heap allocation that strings.Split would cause.
|
||||
func parseSGR(params string, fg, bg *Attribute) {
|
||||
if params == "" || params == "0" {
|
||||
// Reset all
|
||||
*fg = ColorDefault
|
||||
*bg = ColorDefault
|
||||
return
|
||||
}
|
||||
|
||||
// Split params into a stack-allocated array to avoid strings.Split's
|
||||
// heap-allocated []string slice.
|
||||
var parts [maxSGRParams]string
|
||||
n := 0
|
||||
for n < maxSGRParams {
|
||||
idx := strings.IndexByte(params, ';')
|
||||
if idx == -1 {
|
||||
parts[n] = params
|
||||
n++
|
||||
break
|
||||
}
|
||||
parts[n] = params[:idx]
|
||||
params = params[idx+1:]
|
||||
n++
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
code, err := strconv.Atoi(parts[i])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case code == 0:
|
||||
// Reset
|
||||
*fg = ColorDefault
|
||||
*bg = ColorDefault
|
||||
|
||||
case code == 1:
|
||||
*fg |= AttrBold
|
||||
case code == 4:
|
||||
*fg |= AttrUnderline
|
||||
case code == 7:
|
||||
*fg |= AttrReverse
|
||||
|
||||
// Basic foreground colors 30-37
|
||||
case code >= 30 && code <= 37:
|
||||
// Preserve attribute flags, set new color
|
||||
flags := *fg & (AttrBold | AttrUnderline | AttrReverse)
|
||||
*fg = basicFgColors[code-30] | flags
|
||||
|
||||
// Basic background colors 40-47
|
||||
case code >= 40 && code <= 47:
|
||||
*bg = basicFgColors[code-40]
|
||||
|
||||
// 256-color or truecolor foreground: 38;5;N or 38;2;R;G;B
|
||||
case code == 38:
|
||||
if i+1 < n {
|
||||
mode, err := strconv.Atoi(parts[i+1])
|
||||
if err != nil {
|
||||
i++
|
||||
break
|
||||
}
|
||||
switch mode {
|
||||
case 5: // 256-color: 38;5;N
|
||||
if i+2 < n {
|
||||
c, err := strconv.Atoi(parts[i+2])
|
||||
if err == nil && c >= 0 && c <= 255 {
|
||||
flags := *fg & (AttrBold | AttrUnderline | AttrReverse)
|
||||
*fg = Attribute(c+1) | flags
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
case 2: // Truecolor: 38;2;R;G;B
|
||||
if i+4 < n {
|
||||
r, err1 := strconv.Atoi(parts[i+2])
|
||||
g, err2 := strconv.Atoi(parts[i+3])
|
||||
b, err3 := strconv.Atoi(parts[i+4])
|
||||
if err1 == nil && err2 == nil && err3 == nil {
|
||||
flags := *fg & (AttrBold | AttrUnderline | AttrReverse)
|
||||
*fg = Attribute((r<<16)|(g<<8)|b) | AttrTrueColor | flags
|
||||
}
|
||||
i += 4
|
||||
}
|
||||
default:
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// 256-color or truecolor background: 48;5;N or 48;2;R;G;B
|
||||
case code == 48:
|
||||
if i+1 < n {
|
||||
mode, err := strconv.Atoi(parts[i+1])
|
||||
if err != nil {
|
||||
i++
|
||||
break
|
||||
}
|
||||
switch mode {
|
||||
case 5: // 256-color: 48;5;N
|
||||
if i+2 < n {
|
||||
c, err := strconv.Atoi(parts[i+2])
|
||||
if err == nil && c >= 0 && c <= 255 {
|
||||
*bg = Attribute(c + 1)
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
case 2: // Truecolor: 48;2;R;G;B
|
||||
if i+4 < n {
|
||||
r, err1 := strconv.Atoi(parts[i+2])
|
||||
g, err2 := strconv.Atoi(parts[i+3])
|
||||
b, err3 := strconv.Atoi(parts[i+4])
|
||||
if err1 == nil && err2 == nil && err3 == nil {
|
||||
*bg = Attribute((r<<16)|(g<<8)|b) | AttrTrueColor
|
||||
}
|
||||
i += 4
|
||||
}
|
||||
default:
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// Default foreground/background reset
|
||||
case code == 39:
|
||||
flags := *fg & (AttrBold | AttrUnderline | AttrReverse)
|
||||
*fg = ColorDefault | flags
|
||||
case code == 49:
|
||||
*bg = ColorDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractSegment extracts ANSI attributes for a rune-range [start, end)
|
||||
// from the given run-length encoded spans.
|
||||
// Returns nil if attrs is nil or the segment is empty.
|
||||
func ExtractSegment(attrs []AttrSpan, start, end int) []AttrSpan {
|
||||
if attrs == nil || start >= end {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result []AttrSpan
|
||||
pos := 0
|
||||
|
||||
for _, span := range attrs {
|
||||
spanEnd := pos + span.Length
|
||||
|
||||
if spanEnd <= start {
|
||||
pos = spanEnd
|
||||
continue
|
||||
}
|
||||
if pos >= end {
|
||||
break
|
||||
}
|
||||
|
||||
overlapStart := max(start, pos)
|
||||
overlapEnd := min(end, spanEnd)
|
||||
overlapLen := overlapEnd - overlapStart
|
||||
|
||||
if overlapLen > 0 {
|
||||
result = append(result, AttrSpan{
|
||||
Fg: span.Fg,
|
||||
Bg: span.Bg,
|
||||
Length: overlapLen,
|
||||
})
|
||||
}
|
||||
|
||||
pos = spanEnd
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
354
internal/ansi/parser_test.go
Normal file
354
internal/ansi/parser_test.go
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
package ansi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParse_NoANSI(t *testing.T) {
|
||||
r := Parse("Hello World")
|
||||
require.Equal(t, "Hello World", r.Stripped)
|
||||
require.Nil(t, r.Attrs)
|
||||
}
|
||||
|
||||
func TestParse_EmptyString(t *testing.T) {
|
||||
r := Parse("")
|
||||
require.Equal(t, "", r.Stripped)
|
||||
require.Nil(t, r.Attrs)
|
||||
}
|
||||
|
||||
func TestParse_BasicForegroundColors(t *testing.T) {
|
||||
r := Parse("\x1b[31mRed\x1b[0m")
|
||||
require.Equal(t, "Red", r.Stripped)
|
||||
require.Len(t, r.Attrs, 1)
|
||||
require.Equal(t, ColorRed, r.Attrs[0].Fg)
|
||||
require.Equal(t, ColorDefault, r.Attrs[0].Bg)
|
||||
require.Equal(t, 3, r.Attrs[0].Length)
|
||||
}
|
||||
|
||||
func TestParse_BasicBackgroundColors(t *testing.T) {
|
||||
r := Parse("\x1b[42mGreen BG\x1b[0m")
|
||||
require.Equal(t, "Green BG", r.Stripped)
|
||||
require.Len(t, r.Attrs, 1)
|
||||
require.Equal(t, ColorDefault, r.Attrs[0].Fg)
|
||||
require.Equal(t, ColorGreen, r.Attrs[0].Bg)
|
||||
require.Equal(t, 8, r.Attrs[0].Length)
|
||||
}
|
||||
|
||||
func TestParse_BoldAttribute(t *testing.T) {
|
||||
r := Parse("\x1b[1;31mBold Red\x1b[0m")
|
||||
require.Equal(t, "Bold Red", r.Stripped)
|
||||
require.Len(t, r.Attrs, 1)
|
||||
require.Equal(t, ColorRed|AttrBold, r.Attrs[0].Fg)
|
||||
require.Equal(t, 8, r.Attrs[0].Length)
|
||||
}
|
||||
|
||||
func TestParse_UnderlineAttribute(t *testing.T) {
|
||||
r := Parse("\x1b[4mUnderline\x1b[0m")
|
||||
require.Equal(t, "Underline", r.Stripped)
|
||||
require.Len(t, r.Attrs, 1)
|
||||
require.Equal(t, AttrUnderline, r.Attrs[0].Fg)
|
||||
}
|
||||
|
||||
func TestParse_ReverseAttribute(t *testing.T) {
|
||||
r := Parse("\x1b[7mReverse\x1b[0m")
|
||||
require.Equal(t, "Reverse", r.Stripped)
|
||||
require.Len(t, r.Attrs, 1)
|
||||
require.Equal(t, AttrReverse, r.Attrs[0].Fg)
|
||||
}
|
||||
|
||||
func TestParse_MultipleSegments(t *testing.T) {
|
||||
r := Parse("AAA\x1b[31mBBB\x1b[0mCCC")
|
||||
require.Equal(t, "AAABBBCCC", r.Stripped)
|
||||
require.Len(t, r.Attrs, 3)
|
||||
|
||||
require.Equal(t, ColorDefault, r.Attrs[0].Fg)
|
||||
require.Equal(t, 3, r.Attrs[0].Length)
|
||||
|
||||
require.Equal(t, ColorRed, r.Attrs[1].Fg)
|
||||
require.Equal(t, 3, r.Attrs[1].Length)
|
||||
|
||||
require.Equal(t, ColorDefault, r.Attrs[2].Fg)
|
||||
require.Equal(t, 3, r.Attrs[2].Length)
|
||||
}
|
||||
|
||||
func TestParse_256Color(t *testing.T) {
|
||||
// 38;5;196 = 256-color fg (bright red, index 196)
|
||||
r := Parse("\x1b[38;5;196mColor256\x1b[0m")
|
||||
require.Equal(t, "Color256", r.Stripped)
|
||||
require.Len(t, r.Attrs, 1)
|
||||
require.Equal(t, Attribute(197), r.Attrs[0].Fg) // 196+1 for palette encoding
|
||||
}
|
||||
|
||||
func TestParse_256ColorBackground(t *testing.T) {
|
||||
r := Parse("\x1b[48;5;21mBlueBG\x1b[0m")
|
||||
require.Equal(t, "BlueBG", r.Stripped)
|
||||
require.Len(t, r.Attrs, 1)
|
||||
require.Equal(t, Attribute(22), r.Attrs[0].Bg) // 21+1
|
||||
}
|
||||
|
||||
func TestParse_TrueColor(t *testing.T) {
|
||||
// 38;2;255;128;0 = truecolor fg (orange)
|
||||
r := Parse("\x1b[38;2;255;128;0mOrange\x1b[0m")
|
||||
require.Equal(t, "Orange", r.Stripped)
|
||||
require.Len(t, r.Attrs, 1)
|
||||
expected := Attribute((255<<16)|(128<<8)) | AttrTrueColor
|
||||
require.Equal(t, expected, r.Attrs[0].Fg)
|
||||
}
|
||||
|
||||
func TestParse_TrueColorBackground(t *testing.T) {
|
||||
r := Parse("\x1b[48;2;0;128;255mBG\x1b[0m")
|
||||
require.Equal(t, "BG", r.Stripped)
|
||||
require.Len(t, r.Attrs, 1)
|
||||
expected := Attribute((0<<16)|(128<<8)|255) | AttrTrueColor
|
||||
require.Equal(t, expected, r.Attrs[0].Bg)
|
||||
}
|
||||
|
||||
func TestParse_Reset(t *testing.T) {
|
||||
r := Parse("\x1b[1;31mBold Red\x1b[0m Normal")
|
||||
require.Equal(t, "Bold Red Normal", r.Stripped)
|
||||
require.Len(t, r.Attrs, 2)
|
||||
require.Equal(t, ColorRed|AttrBold, r.Attrs[0].Fg)
|
||||
require.Equal(t, 8, r.Attrs[0].Length)
|
||||
require.Equal(t, ColorDefault, r.Attrs[1].Fg)
|
||||
require.Equal(t, 7, r.Attrs[1].Length)
|
||||
}
|
||||
|
||||
func TestParse_MalformedIncomplete(t *testing.T) {
|
||||
// Incomplete sequence at end
|
||||
r := Parse("Hello\x1b[")
|
||||
require.Equal(t, "Hello", r.Stripped)
|
||||
}
|
||||
|
||||
func TestParse_MalformedNoTerminator(t *testing.T) {
|
||||
// ESC[31W — 'W' is a valid CSI terminator (non-SGR), so it is stripped
|
||||
// and "orld" remains as regular text
|
||||
r := Parse("Hello\x1b[31World")
|
||||
require.Equal(t, "Helloorld", r.Stripped)
|
||||
}
|
||||
|
||||
func TestParse_NonSGRSequence(t *testing.T) {
|
||||
// ESC[2J is "clear screen" (not SGR) — should be stripped, not rendered
|
||||
r := Parse("\x1b[2JHello")
|
||||
require.Equal(t, "Hello", r.Stripped)
|
||||
}
|
||||
|
||||
func TestParse_EmptyReset(t *testing.T) {
|
||||
// ESC[m is equivalent to ESC[0m
|
||||
r := Parse("\x1b[31mRed\x1b[mNormal")
|
||||
require.Equal(t, "RedNormal", r.Stripped)
|
||||
require.Len(t, r.Attrs, 2)
|
||||
require.Equal(t, ColorRed, r.Attrs[0].Fg)
|
||||
require.Equal(t, ColorDefault, r.Attrs[1].Fg)
|
||||
}
|
||||
|
||||
func TestParse_DefaultFgBgReset(t *testing.T) {
|
||||
r := Parse("\x1b[31;42mColored\x1b[39;49mReset")
|
||||
require.Equal(t, "ColoredReset", r.Stripped)
|
||||
require.Len(t, r.Attrs, 2)
|
||||
require.Equal(t, ColorRed, r.Attrs[0].Fg)
|
||||
require.Equal(t, ColorGreen, r.Attrs[0].Bg)
|
||||
// code 39 resets fg, code 49 resets bg
|
||||
require.Equal(t, ColorDefault, r.Attrs[1].Fg)
|
||||
require.Equal(t, ColorDefault, r.Attrs[1].Bg)
|
||||
}
|
||||
|
||||
func TestParse_ComplexCombined(t *testing.T) {
|
||||
// Bold underline red on blue background
|
||||
r := Parse("\x1b[1;4;31;44mStyled\x1b[0m")
|
||||
require.Equal(t, "Styled", r.Stripped)
|
||||
require.Len(t, r.Attrs, 1)
|
||||
require.Equal(t, ColorRed|AttrBold|AttrUnderline, r.Attrs[0].Fg)
|
||||
require.Equal(t, ColorBlue, r.Attrs[0].Bg)
|
||||
}
|
||||
|
||||
func TestParseSGR_MalformedExtendedColor(t *testing.T) {
|
||||
// parseSGR is tested directly to verify that malformed extended color
|
||||
// sequences don't corrupt color state.
|
||||
|
||||
t.Run("well-formed 256-color fg still works", func(t *testing.T) {
|
||||
var fg, bg Attribute
|
||||
parseSGR("38;5;196", &fg, &bg)
|
||||
require.Equal(t, Attribute(197), fg) // 196+1
|
||||
require.Equal(t, ColorDefault, bg)
|
||||
})
|
||||
|
||||
t.Run("well-formed truecolor fg still works", func(t *testing.T) {
|
||||
var fg, bg Attribute
|
||||
parseSGR("38;2;255;128;0", &fg, &bg)
|
||||
expected := Attribute((255<<16)|(128<<8)) | AttrTrueColor
|
||||
require.Equal(t, expected, fg)
|
||||
require.Equal(t, ColorDefault, bg)
|
||||
})
|
||||
|
||||
t.Run("well-formed 256-color bg still works", func(t *testing.T) {
|
||||
var fg, bg Attribute
|
||||
parseSGR("48;5;21", &fg, &bg)
|
||||
require.Equal(t, ColorDefault, fg)
|
||||
require.Equal(t, Attribute(22), bg) // 21+1
|
||||
})
|
||||
|
||||
t.Run("well-formed truecolor bg still works", func(t *testing.T) {
|
||||
var fg, bg Attribute
|
||||
parseSGR("48;2;0;128;255", &fg, &bg)
|
||||
expected := Attribute((0<<16)|(128<<8)|255) | AttrTrueColor
|
||||
require.Equal(t, ColorDefault, fg)
|
||||
require.Equal(t, expected, bg)
|
||||
})
|
||||
|
||||
t.Run("malformed mode in fg 38;abc;100 does not change fg", func(t *testing.T) {
|
||||
var fg, bg Attribute
|
||||
fg = ColorRed // set a prior color
|
||||
parseSGR("38;abc;100", &fg, &bg)
|
||||
// Malformed mode should skip the sequence, leaving fg unchanged
|
||||
require.Equal(t, ColorRed, fg)
|
||||
require.Equal(t, ColorDefault, bg)
|
||||
})
|
||||
|
||||
t.Run("malformed 256-color value in fg 38;5;abc does not change fg", func(t *testing.T) {
|
||||
var fg, bg Attribute
|
||||
fg = ColorRed
|
||||
parseSGR("38;5;abc", &fg, &bg)
|
||||
// Malformed color value should skip the color, leaving fg unchanged
|
||||
require.Equal(t, ColorRed, fg)
|
||||
require.Equal(t, ColorDefault, bg)
|
||||
})
|
||||
|
||||
t.Run("malformed truecolor R in fg 38;2;abc;0;0 does not change fg", func(t *testing.T) {
|
||||
var fg, bg Attribute
|
||||
fg = ColorRed
|
||||
parseSGR("38;2;abc;0;0", &fg, &bg)
|
||||
require.Equal(t, ColorRed, fg)
|
||||
require.Equal(t, ColorDefault, bg)
|
||||
})
|
||||
|
||||
t.Run("malformed truecolor G in fg 38;2;0;abc;0 does not change fg", func(t *testing.T) {
|
||||
var fg, bg Attribute
|
||||
fg = ColorRed
|
||||
parseSGR("38;2;0;abc;0", &fg, &bg)
|
||||
require.Equal(t, ColorRed, fg)
|
||||
require.Equal(t, ColorDefault, bg)
|
||||
})
|
||||
|
||||
t.Run("malformed truecolor B in fg 38;2;0;0;abc does not change fg", func(t *testing.T) {
|
||||
var fg, bg Attribute
|
||||
fg = ColorRed
|
||||
parseSGR("38;2;0;0;abc", &fg, &bg)
|
||||
require.Equal(t, ColorRed, fg)
|
||||
require.Equal(t, ColorDefault, bg)
|
||||
})
|
||||
|
||||
t.Run("malformed mode in bg 48;abc;100 does not change bg", func(t *testing.T) {
|
||||
var fg, bg Attribute
|
||||
bg = ColorGreen
|
||||
parseSGR("48;abc;100", &fg, &bg)
|
||||
require.Equal(t, ColorDefault, fg)
|
||||
require.Equal(t, ColorGreen, bg)
|
||||
})
|
||||
|
||||
t.Run("malformed 256-color value in bg 48;5;abc does not change bg", func(t *testing.T) {
|
||||
var fg, bg Attribute
|
||||
bg = ColorGreen
|
||||
parseSGR("48;5;abc", &fg, &bg)
|
||||
require.Equal(t, ColorDefault, fg)
|
||||
require.Equal(t, ColorGreen, bg)
|
||||
})
|
||||
|
||||
t.Run("malformed truecolor R in bg 48;2;abc;0;0 does not change bg", func(t *testing.T) {
|
||||
var fg, bg Attribute
|
||||
bg = ColorGreen
|
||||
parseSGR("48;2;abc;0;0", &fg, &bg)
|
||||
require.Equal(t, ColorDefault, fg)
|
||||
require.Equal(t, ColorGreen, bg)
|
||||
})
|
||||
|
||||
t.Run("malformed truecolor G in bg 48;2;0;abc;0 does not change bg", func(t *testing.T) {
|
||||
var fg, bg Attribute
|
||||
bg = ColorGreen
|
||||
parseSGR("48;2;0;abc;0", &fg, &bg)
|
||||
require.Equal(t, ColorDefault, fg)
|
||||
require.Equal(t, ColorGreen, bg)
|
||||
})
|
||||
|
||||
t.Run("malformed truecolor B in bg 48;2;0;0;abc does not change bg", func(t *testing.T) {
|
||||
var fg, bg Attribute
|
||||
bg = ColorGreen
|
||||
parseSGR("48;2;0;0;abc", &fg, &bg)
|
||||
require.Equal(t, ColorDefault, fg)
|
||||
require.Equal(t, ColorGreen, bg)
|
||||
})
|
||||
|
||||
t.Run("malformed extended color followed by valid code", func(t *testing.T) {
|
||||
var fg, bg Attribute
|
||||
// 38;abc;100 should be skipped, then 1 (bold) should still apply
|
||||
parseSGR("38;abc;100;1", &fg, &bg)
|
||||
require.Equal(t, AttrBold, fg)
|
||||
require.Equal(t, ColorDefault, bg)
|
||||
})
|
||||
|
||||
t.Run("malformed 256-color followed by valid code", func(t *testing.T) {
|
||||
var fg, bg Attribute
|
||||
// 38;5;abc should be skipped, then 31 (red) should apply
|
||||
parseSGR("38;5;abc;31", &fg, &bg)
|
||||
require.Equal(t, ColorRed, fg)
|
||||
require.Equal(t, ColorDefault, bg)
|
||||
})
|
||||
|
||||
t.Run("via Parse: malformed fg 256-color does not corrupt color state", func(t *testing.T) {
|
||||
// ESC[38;5; with a non-numeric parameter byte (<) that still passes
|
||||
// the CSI scanner (0x3C is in 0x20-0x3F range) but fails Atoi.
|
||||
// 38;5;<3 — the "<3" is not a valid integer.
|
||||
r := Parse("\x1b[38;5;<3mHello\x1b[0m")
|
||||
require.Equal(t, "Hello", r.Stripped)
|
||||
require.Len(t, r.Attrs, 1)
|
||||
require.Equal(t, ColorDefault, r.Attrs[0].Fg)
|
||||
require.Equal(t, ColorDefault, r.Attrs[0].Bg)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractSegment_Nil(t *testing.T) {
|
||||
require.Nil(t, ExtractSegment(nil, 0, 5))
|
||||
}
|
||||
|
||||
func TestExtractSegment_EmptyRange(t *testing.T) {
|
||||
attrs := []AttrSpan{{Fg: ColorRed, Bg: ColorDefault, Length: 10}}
|
||||
require.Nil(t, ExtractSegment(attrs, 5, 5))
|
||||
}
|
||||
|
||||
func TestExtractSegment_FullSpan(t *testing.T) {
|
||||
attrs := []AttrSpan{
|
||||
{Fg: ColorRed, Bg: ColorDefault, Length: 3},
|
||||
{Fg: ColorDefault, Bg: ColorDefault, Length: 7},
|
||||
{Fg: ColorBlue, Bg: ColorDefault, Length: 5},
|
||||
}
|
||||
result := ExtractSegment(attrs, 0, 15)
|
||||
require.Equal(t, attrs, result)
|
||||
}
|
||||
|
||||
func TestExtractSegment_MiddleSlice(t *testing.T) {
|
||||
attrs := []AttrSpan{
|
||||
{Fg: ColorRed, Bg: ColorDefault, Length: 5},
|
||||
{Fg: ColorBlue, Bg: ColorDefault, Length: 5},
|
||||
{Fg: ColorGreen, Bg: ColorDefault, Length: 5},
|
||||
}
|
||||
// Extract runes 3..8 — partial Red(2) + full Blue(5) + partial Green(1)
|
||||
result := ExtractSegment(attrs, 3, 12)
|
||||
require.Len(t, result, 3)
|
||||
require.Equal(t, 2, result[0].Length) // Red: runes 3..5
|
||||
require.Equal(t, ColorRed, result[0].Fg)
|
||||
require.Equal(t, 5, result[1].Length) // Blue: runes 5..10
|
||||
require.Equal(t, ColorBlue, result[1].Fg)
|
||||
require.Equal(t, 2, result[2].Length) // Green: runes 10..12
|
||||
require.Equal(t, ColorGreen, result[2].Fg)
|
||||
}
|
||||
|
||||
func TestExtractSegment_SingleSpanSlice(t *testing.T) {
|
||||
attrs := []AttrSpan{
|
||||
{Fg: ColorRed, Bg: ColorDefault, Length: 10},
|
||||
}
|
||||
result := ExtractSegment(attrs, 2, 7)
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, 5, result[0].Length)
|
||||
require.Equal(t, ColorRed, result[0].Fg)
|
||||
}
|
||||
|
|
@ -6,23 +6,25 @@ import (
|
|||
"github.com/peco/peco/line"
|
||||
)
|
||||
|
||||
const filterBufSize = 1000
|
||||
const DefaultFilterBufSize = 1000
|
||||
|
||||
var lineListPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return make([]line.Line, 0, filterBufSize)
|
||||
New: func() any {
|
||||
return make([]line.Line, 0, DefaultFilterBufSize)
|
||||
},
|
||||
}
|
||||
|
||||
// ReleaseLineListBuf returns a line list buffer to the sync.Pool for reuse.
|
||||
func ReleaseLineListBuf(l []line.Line) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l = l[0:0]
|
||||
lineListPool.Put(l)
|
||||
lineListPool.Put(l) //nolint:staticcheck // SA6002: converting to pointer-based pool breaks tests
|
||||
}
|
||||
|
||||
// GetLineListBuf retrieves a line list buffer from the sync.Pool, allocating if needed.
|
||||
func GetLineListBuf() []line.Line {
|
||||
l := lineListPool.Get().([]line.Line)
|
||||
l, _ := lineListPool.Get().([]line.Line)
|
||||
return l
|
||||
}
|
||||
|
|
|
|||
61
internal/buffer/line_test.go
Normal file
61
internal/buffer/line_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package buffer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetLineListBuf(t *testing.T) {
|
||||
t.Parallel()
|
||||
buf := GetLineListBuf()
|
||||
require.NotNil(t, buf)
|
||||
require.Equal(t, 0, len(buf))
|
||||
require.Equal(t, DefaultFilterBufSize, cap(buf))
|
||||
}
|
||||
|
||||
func TestReleaseAndGetLineListBuf(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Get a buffer, use it, release it, get it back
|
||||
buf := GetLineListBuf()
|
||||
require.NotNil(t, buf)
|
||||
|
||||
// Release it back to the pool
|
||||
ReleaseLineListBuf(buf)
|
||||
|
||||
// Get another one — may or may not be the same one (pool behavior is not guaranteed)
|
||||
buf2 := GetLineListBuf()
|
||||
require.NotNil(t, buf2)
|
||||
require.Equal(t, 0, len(buf2))
|
||||
}
|
||||
|
||||
func TestReleaseLineListBufNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Should not panic
|
||||
ReleaseLineListBuf(nil)
|
||||
}
|
||||
|
||||
func TestReleaseLineListBufResetsLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
buf := GetLineListBuf()
|
||||
|
||||
// Simulate usage — we can't easily append real line.Line values
|
||||
// without importing the line package's internals, but we can verify
|
||||
// that the pool resets the slice length to 0 on release.
|
||||
// The ReleaseLineListBuf function does: l = l[0:0]
|
||||
// This is verified by the pool returning zero-length slices.
|
||||
ReleaseLineListBuf(buf)
|
||||
|
||||
buf2 := GetLineListBuf()
|
||||
require.Equal(t, 0, len(buf2))
|
||||
}
|
||||
|
||||
func TestMultipleGetReleaseCycles(t *testing.T) {
|
||||
t.Parallel()
|
||||
for range 10 {
|
||||
buf := GetLineListBuf()
|
||||
require.NotNil(t, buf)
|
||||
require.Equal(t, 0, len(buf))
|
||||
ReleaseLineListBuf(buf)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,45 +7,50 @@ type Matcher struct {
|
|||
type Match struct {
|
||||
Index int
|
||||
Pattern KeyList
|
||||
Value interface{}
|
||||
Value any
|
||||
}
|
||||
|
||||
type nodeData struct {
|
||||
pattern *KeyList
|
||||
value interface{}
|
||||
value any
|
||||
failure *TernaryNode
|
||||
}
|
||||
|
||||
func (n *nodeData) Value() interface{} {
|
||||
func (n *nodeData) Value() any {
|
||||
return n.value
|
||||
}
|
||||
|
||||
// NewMatcher creates a new Aho-Corasick matcher for multi-pattern key sequence matching.
|
||||
func NewMatcher() *Matcher {
|
||||
return &Matcher{
|
||||
NewTernaryTrie(),
|
||||
}
|
||||
}
|
||||
|
||||
// Clear removes all patterns from the matcher, resetting it to empty state.
|
||||
func (m *Matcher) Clear() {
|
||||
m.Root().RemoveAll()
|
||||
}
|
||||
|
||||
func (m *Matcher) Add(pattern KeyList, v interface{}) {
|
||||
// Add inserts a key sequence pattern with an associated value into the matcher.
|
||||
func (m *Matcher) Add(pattern KeyList, v any) {
|
||||
m.Put(pattern, &nodeData{
|
||||
pattern: &pattern,
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
|
||||
// Compile builds the failure links needed for Aho-Corasick matching after all patterns are added.
|
||||
func (m *Matcher) Compile() error {
|
||||
m.Balance()
|
||||
root := m.Root().(*TernaryNode)
|
||||
root, _ := m.Root().(*TernaryNode)
|
||||
root.SetValue(&nodeData{failure: root})
|
||||
// fill data.failure of each node.
|
||||
EachWidth(m, func(n Node) bool {
|
||||
parent := n.(*TernaryNode)
|
||||
parent, _ := n.(*TernaryNode)
|
||||
parent.Each(func(m Node) bool {
|
||||
fillFailure(m.(*TernaryNode), root, parent)
|
||||
child, _ := m.(*TernaryNode)
|
||||
fillFailure(child, root, parent)
|
||||
return true
|
||||
})
|
||||
return true
|
||||
|
|
@ -53,6 +58,7 @@ func (m *Matcher) Compile() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// fillFailure recursively computes the failure link for curr based on its parent's failure chain.
|
||||
func fillFailure(curr, root, parent *TernaryNode) {
|
||||
data := getNodeData(curr)
|
||||
if data == nil {
|
||||
|
|
@ -68,15 +74,17 @@ func fillFailure(curr, root, parent *TernaryNode) {
|
|||
data.failure = fnode
|
||||
}
|
||||
|
||||
// Match tests a key sequence against all compiled patterns and returns matches on a channel.
|
||||
func (m *Matcher) Match(text KeyList) <-chan Match {
|
||||
ch := make(chan Match, 1)
|
||||
go m.startMatch(text, ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
// startMatch begins a new match attempt from the root of the Aho-Corasick automaton.
|
||||
func (m *Matcher) startMatch(text KeyList, ch chan<- Match) {
|
||||
defer close(ch)
|
||||
root := m.Root().(*TernaryNode)
|
||||
root, _ := m.Root().(*TernaryNode)
|
||||
curr := root
|
||||
for i, r := range text {
|
||||
curr = getNextNode(curr, root, r)
|
||||
|
|
@ -87,6 +95,7 @@ func (m *Matcher) startMatch(text KeyList, ch chan<- Match) {
|
|||
}
|
||||
}
|
||||
|
||||
// getNextNode follows failure links from node until it finds a child matching r, or returns root.
|
||||
func getNextNode(node, root *TernaryNode, r Key) *TernaryNode {
|
||||
for {
|
||||
next, _ := node.Get(r).(*TernaryNode)
|
||||
|
|
@ -99,6 +108,7 @@ func getNextNode(node, root *TernaryNode, r Key) *TernaryNode {
|
|||
}
|
||||
}
|
||||
|
||||
// fireAll emits all pattern matches found at curr by walking the failure chain back to root.
|
||||
func fireAll(curr, root *TernaryNode, ch chan<- Match, idx int) {
|
||||
for curr != root {
|
||||
data := getNodeData(curr)
|
||||
|
|
@ -113,11 +123,13 @@ func fireAll(curr, root *TernaryNode, ch chan<- Match, idx int) {
|
|||
}
|
||||
}
|
||||
|
||||
// getNodeData extracts the nodeData stored in a TernaryNode's value.
|
||||
func getNodeData(node *TernaryNode) *nodeData {
|
||||
d, _ := node.Value().(*nodeData)
|
||||
return d
|
||||
}
|
||||
|
||||
// getNodeFailure returns the failure link for node, falling back to root if none is set.
|
||||
func getNodeFailure(node, root *TernaryNode) *TernaryNode {
|
||||
next := getNodeData(node).failure
|
||||
if next == nil {
|
||||
|
|
|
|||
|
|
@ -3,32 +3,23 @@ package keyseq
|
|||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nsf/termbox-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func checkNode(t *testing.T, node Node, size int, data nodeData) {
|
||||
if node == nil {
|
||||
t.Error("Nil node:", data)
|
||||
}
|
||||
if node.Size() != size {
|
||||
t.Errorf("Unexpected childrens: %d != %d", node.Size(), size)
|
||||
}
|
||||
require.NotNil(t, node, "Nil node:", data)
|
||||
require.Equal(t, size, node.Size(), "Unexpected childrens")
|
||||
d := node.Value().(*nodeData)
|
||||
if d == nil {
|
||||
t.Error("Nil data:", data, node)
|
||||
require.NotNil(t, d, "Nil data:", data, node)
|
||||
if data.pattern != nil {
|
||||
require.True(t, d.pattern.Equals(*data.pattern), "Pattern unmatched:", data, node, *d.pattern)
|
||||
}
|
||||
if data.pattern != nil && !d.pattern.Equals(*data.pattern) {
|
||||
t.Error("Pattern unmatched:", data, node, *d.pattern)
|
||||
}
|
||||
if data.value != nil && d.value != data.value {
|
||||
t.Error("Value unmatched:", data, node, d.value)
|
||||
}
|
||||
if d.failure == nil {
|
||||
t.Error("Nil failure:", data, node)
|
||||
} else if d.failure != data.failure {
|
||||
t.Errorf("Failure unmatched: data=%+v node=%+v d.failure=%+v",
|
||||
data, node, d.failure)
|
||||
if data.value != nil {
|
||||
require.Equal(t, data.value, d.value, "Value unmatched:", data, node, d.value)
|
||||
}
|
||||
require.NotNil(t, d.failure, "Nil failure:", data, node)
|
||||
require.Equal(t, data.failure, d.failure, "Failure unmatched: data=%+v node=%+v d.failure=%+v",
|
||||
data, node, d.failure)
|
||||
}
|
||||
|
||||
func invalidData(failure Node) nodeData {
|
||||
|
|
@ -37,7 +28,7 @@ func invalidData(failure Node) nodeData {
|
|||
}
|
||||
}
|
||||
|
||||
func validData(pattern KeyList, value interface{}, failure Node) nodeData {
|
||||
func validData(pattern KeyList, value any, failure Node) nodeData {
|
||||
return nodeData{
|
||||
pattern: &pattern,
|
||||
value: value,
|
||||
|
|
@ -47,11 +38,11 @@ func validData(pattern KeyList, value interface{}, failure Node) nodeData {
|
|||
|
||||
func newTestMatcher() *Matcher {
|
||||
m := NewMatcher()
|
||||
m.Add(KeyList{Key{0, termbox.KeyCtrlA, rune(0)}, Key{0, termbox.KeyCtrlB, rune(0)}}, 2)
|
||||
m.Add(KeyList{Key{0, termbox.KeyCtrlB, rune(0)}, Key{0, termbox.KeyCtrlC, rune(0)}}, 4)
|
||||
m.Add(KeyList{Key{0, termbox.KeyCtrlB, rune(0)}, Key{0, termbox.KeyCtrlA, rune(0)}, Key{0, termbox.KeyCtrlB, rune(0)}}, 6)
|
||||
m.Add(KeyList{Key{0, termbox.KeyCtrlD, rune(0)}}, 7)
|
||||
m.Add(KeyList{Key{0, termbox.KeyCtrlA, rune(0)}, Key{0, termbox.KeyCtrlB, rune(0)}, Key{0, termbox.KeyCtrlC, rune(0)}, Key{0, termbox.KeyCtrlD, rune(0)}, Key{0, termbox.KeyCtrlE, rune(0)}}, 10)
|
||||
m.Add(KeyList{Key{0, KeyCtrlA, rune(0)}, Key{0, KeyCtrlB, rune(0)}}, 2)
|
||||
m.Add(KeyList{Key{0, KeyCtrlB, rune(0)}, Key{0, KeyCtrlC, rune(0)}}, 4)
|
||||
m.Add(KeyList{Key{0, KeyCtrlB, rune(0)}, Key{0, KeyCtrlA, rune(0)}, Key{0, KeyCtrlB, rune(0)}}, 6)
|
||||
m.Add(KeyList{Key{0, KeyCtrlD, rune(0)}}, 7)
|
||||
m.Add(KeyList{Key{0, KeyCtrlA, rune(0)}, Key{0, KeyCtrlB, rune(0)}, Key{0, KeyCtrlC, rune(0)}, Key{0, KeyCtrlD, rune(0)}, Key{0, KeyCtrlE, rune(0)}}, 10)
|
||||
m.Compile()
|
||||
return m
|
||||
}
|
||||
|
|
@ -61,52 +52,24 @@ func TestTree(t *testing.T) {
|
|||
// Check tree structure.
|
||||
r := m.Root()
|
||||
checkNode(t, r, 3, invalidData(r))
|
||||
n1 := r.Get(NewKeyFromKey(termbox.KeyCtrlA))
|
||||
n1 := r.Get(NewKeyFromKey(KeyCtrlA))
|
||||
checkNode(t, n1, 1, invalidData(r))
|
||||
n3 := r.Get(NewKeyFromKey(termbox.KeyCtrlB))
|
||||
n3 := r.Get(NewKeyFromKey(KeyCtrlB))
|
||||
checkNode(t, n3, 2, invalidData(r))
|
||||
n7 := r.Get(NewKeyFromKey(termbox.KeyCtrlD))
|
||||
n7 := r.Get(NewKeyFromKey(KeyCtrlD))
|
||||
checkNode(t, n7, 0, invalidData(r))
|
||||
n2 := n1.Get(NewKeyFromKey(termbox.KeyCtrlB))
|
||||
checkNode(t, n2, 1, validData(KeyList{NewKeyFromKey(termbox.KeyCtrlA), NewKeyFromKey(termbox.KeyCtrlB)}, 2, n3))
|
||||
n4 := n3.Get(NewKeyFromKey(termbox.KeyCtrlC))
|
||||
checkNode(t, n4, 0, validData(KeyList{NewKeyFromKey(termbox.KeyCtrlB), NewKeyFromKey(termbox.KeyCtrlC)}, 4, r))
|
||||
n5 := n3.Get(NewKeyFromKey(termbox.KeyCtrlA))
|
||||
n2 := n1.Get(NewKeyFromKey(KeyCtrlB))
|
||||
checkNode(t, n2, 1, validData(KeyList{NewKeyFromKey(KeyCtrlA), NewKeyFromKey(KeyCtrlB)}, 2, n3))
|
||||
n4 := n3.Get(NewKeyFromKey(KeyCtrlC))
|
||||
checkNode(t, n4, 0, validData(KeyList{NewKeyFromKey(KeyCtrlB), NewKeyFromKey(KeyCtrlC)}, 4, r))
|
||||
n5 := n3.Get(NewKeyFromKey(KeyCtrlA))
|
||||
checkNode(t, n5, 1, invalidData(n1))
|
||||
n8 := n2.Get(NewKeyFromKey(termbox.KeyCtrlC))
|
||||
n8 := n2.Get(NewKeyFromKey(KeyCtrlC))
|
||||
checkNode(t, n8, 1, invalidData(n4))
|
||||
n6 := n5.Get(NewKeyFromKey(termbox.KeyCtrlB))
|
||||
checkNode(t, n6, 0, validData(KeyList{NewKeyFromKey(termbox.KeyCtrlB), NewKeyFromKey(termbox.KeyCtrlA), NewKeyFromKey(termbox.KeyCtrlB)}, 6, n2))
|
||||
n9 := n8.Get(NewKeyFromKey(termbox.KeyCtrlD))
|
||||
n6 := n5.Get(NewKeyFromKey(KeyCtrlB))
|
||||
checkNode(t, n6, 0, validData(KeyList{NewKeyFromKey(KeyCtrlB), NewKeyFromKey(KeyCtrlA), NewKeyFromKey(KeyCtrlB)}, 6, n2))
|
||||
n9 := n8.Get(NewKeyFromKey(KeyCtrlD))
|
||||
checkNode(t, n9, 1, invalidData(n7))
|
||||
n10 := n9.Get(NewKeyFromKey(termbox.KeyCtrlE))
|
||||
checkNode(t, n10, 0, validData(KeyList{NewKeyFromKey(termbox.KeyCtrlA), NewKeyFromKey(termbox.KeyCtrlB), NewKeyFromKey(termbox.KeyCtrlC), NewKeyFromKey(termbox.KeyCtrlD), NewKeyFromKey(termbox.KeyCtrlE)}, 10, r))
|
||||
}
|
||||
|
||||
func assertMatches(t *testing.T, exp, act []Match) {
|
||||
if len(act) != len(exp) {
|
||||
t.Errorf("[]Match length is not %d (%d)", len(exp), len(act))
|
||||
t.Logf(" expected: %v", exp)
|
||||
t.Logf(" actually: %v", act)
|
||||
}
|
||||
for i, e := range exp {
|
||||
dump := false
|
||||
a := act[i]
|
||||
if a.Index != e.Index {
|
||||
t.Errorf("Index not matched at #%d\n", i)
|
||||
dump = true
|
||||
}
|
||||
if !a.Pattern.Equals(e.Pattern) {
|
||||
t.Errorf("Pattern not matched at #%d\n", i)
|
||||
dump = true
|
||||
}
|
||||
if a.Value != e.Value {
|
||||
t.Errorf("Value not matched at #%d\n", i)
|
||||
dump = true
|
||||
}
|
||||
if dump {
|
||||
t.Logf(" expected: %+v", e)
|
||||
t.Logf(" actually: %+v", a)
|
||||
}
|
||||
}
|
||||
n10 := n9.Get(NewKeyFromKey(KeyCtrlE))
|
||||
checkNode(t, n10, 0, validData(KeyList{NewKeyFromKey(KeyCtrlA), NewKeyFromKey(KeyCtrlB), NewKeyFromKey(KeyCtrlC), NewKeyFromKey(KeyCtrlD), NewKeyFromKey(KeyCtrlE)}, 10, r))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,25 +4,115 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/nsf/termbox-go"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// This map is populated using some magic numbers, which must match
|
||||
// the values defined in termbox-go. Verification against the actual
|
||||
// termbox constants are done in the test
|
||||
var stringToKey = map[string]termbox.Key{}
|
||||
var keyToString = map[termbox.Key]string{}
|
||||
// KeyType represents a keyboard key. The adapter layer in screen.go maps
|
||||
// between peco's KeyType and tcell's key constants.
|
||||
type KeyType uint16
|
||||
|
||||
func mapkey(n string, k termbox.Key) {
|
||||
// Function keys
|
||||
const (
|
||||
KeyF1 KeyType = 0xFFFF - iota
|
||||
KeyF2
|
||||
KeyF3
|
||||
KeyF4
|
||||
KeyF5
|
||||
KeyF6
|
||||
KeyF7
|
||||
KeyF8
|
||||
KeyF9
|
||||
KeyF10
|
||||
KeyF11
|
||||
KeyF12
|
||||
KeyInsert
|
||||
KeyDelete
|
||||
KeyHome
|
||||
KeyEnd
|
||||
KeyPgup
|
||||
KeyPgdn
|
||||
KeyArrowUp
|
||||
KeyArrowDown
|
||||
KeyArrowLeft
|
||||
KeyArrowRight
|
||||
)
|
||||
|
||||
// Mouse keys.
|
||||
// There is an intentional gap between KeyArrowRight and MouseLeft
|
||||
// (MouseLeft = KeyArrowRight - 2, not -1) for historical reasons.
|
||||
const (
|
||||
MouseLeft KeyType = 0xFFFF - iota - 23 // KeyArrowRight - 2
|
||||
MouseMiddle // KeyArrowRight - 3
|
||||
MouseRight // KeyArrowRight - 4
|
||||
)
|
||||
|
||||
// Ctrl keys and special keys.
|
||||
// These correspond to ASCII control codes.
|
||||
const (
|
||||
KeyCtrlTilde KeyType = 0x00
|
||||
KeyCtrl2 KeyType = 0x00
|
||||
KeyCtrlSpace KeyType = 0x00
|
||||
KeyCtrlA KeyType = 0x01
|
||||
KeyCtrlB KeyType = 0x02
|
||||
KeyCtrlC KeyType = 0x03
|
||||
KeyCtrlD KeyType = 0x04
|
||||
KeyCtrlE KeyType = 0x05
|
||||
KeyCtrlF KeyType = 0x06
|
||||
KeyCtrlG KeyType = 0x07
|
||||
KeyCtrlH KeyType = 0x08
|
||||
KeyCtrlI KeyType = 0x09
|
||||
KeyCtrlJ KeyType = 0x0A
|
||||
KeyCtrlK KeyType = 0x0B
|
||||
KeyCtrlL KeyType = 0x0C
|
||||
KeyCtrlM KeyType = 0x0D
|
||||
KeyCtrlN KeyType = 0x0E
|
||||
KeyCtrlO KeyType = 0x0F
|
||||
KeyCtrlP KeyType = 0x10
|
||||
KeyCtrlQ KeyType = 0x11
|
||||
KeyCtrlR KeyType = 0x12
|
||||
KeyCtrlS KeyType = 0x13
|
||||
KeyCtrlT KeyType = 0x14
|
||||
KeyCtrlU KeyType = 0x15
|
||||
KeyCtrlV KeyType = 0x16
|
||||
KeyCtrlW KeyType = 0x17
|
||||
KeyCtrlX KeyType = 0x18
|
||||
KeyCtrlY KeyType = 0x19
|
||||
KeyCtrlZ KeyType = 0x1A
|
||||
KeyCtrlLsqBracket KeyType = 0x1B
|
||||
KeyCtrl3 KeyType = 0x1B
|
||||
KeyEsc KeyType = 0x1B
|
||||
KeyCtrl4 KeyType = 0x1C
|
||||
KeyCtrlBackslash KeyType = 0x1C
|
||||
KeyCtrl5 KeyType = 0x1D
|
||||
KeyCtrlRsqBracket KeyType = 0x1D
|
||||
KeyCtrl6 KeyType = 0x1E
|
||||
KeyCtrl7 KeyType = 0x1F
|
||||
KeyCtrlSlash KeyType = 0x1F
|
||||
KeyCtrlUnderscore KeyType = 0x1F
|
||||
KeySpace KeyType = 0x20
|
||||
KeyBackspace2 KeyType = 0x7F
|
||||
KeyCtrl8 KeyType = 0x7F
|
||||
)
|
||||
|
||||
// Aliases
|
||||
const (
|
||||
KeyBackspace KeyType = 0x08 // = KeyCtrlH
|
||||
KeyTab KeyType = 0x09 // = KeyCtrlI
|
||||
KeyEnter KeyType = 0x0D // = KeyCtrlM
|
||||
)
|
||||
|
||||
// This map is populated in init() below.
|
||||
var stringToKey = map[string]KeyType{}
|
||||
var keyToString = map[KeyType]string{}
|
||||
|
||||
// mapkey registers a bidirectional mapping between a key name string and its KeyType constant.
|
||||
func mapkey(n string, k KeyType) {
|
||||
stringToKey[n] = k
|
||||
keyToString[k] = n
|
||||
}
|
||||
|
||||
func init() {
|
||||
fidx := 1
|
||||
for k := termbox.KeyF1; k >= termbox.KeyF12; k-- {
|
||||
for k := KeyF1; k >= KeyF12; k-- {
|
||||
sk := fmt.Sprintf("F%d", fidx)
|
||||
mapkey(sk, k)
|
||||
fidx++
|
||||
|
|
@ -41,7 +131,7 @@ func init() {
|
|||
"ArrowRight",
|
||||
}
|
||||
for i, n := range names {
|
||||
mapkey(n, termbox.Key(int(termbox.KeyF12)-(i+1)))
|
||||
mapkey(n, KeyType(int(KeyF12)-(i+1)))
|
||||
}
|
||||
|
||||
names = []string{
|
||||
|
|
@ -51,10 +141,10 @@ func init() {
|
|||
}
|
||||
for i, n := range names {
|
||||
sk := fmt.Sprintf("Mouse%s", n)
|
||||
mapkey(sk, termbox.Key(int(termbox.KeyArrowRight)-(i+2)))
|
||||
mapkey(sk, KeyType(int(KeyArrowRight)-(i+2)))
|
||||
}
|
||||
|
||||
whacky := [][]string{
|
||||
ctrlKeyNames := [][]string{
|
||||
{"~", "2", "Space"},
|
||||
{"a"},
|
||||
{"b"},
|
||||
|
|
@ -88,32 +178,31 @@ func init() {
|
|||
{"6"},
|
||||
{"7", "/", "_"},
|
||||
}
|
||||
for i, list := range whacky {
|
||||
for i, list := range ctrlKeyNames {
|
||||
for _, n := range list {
|
||||
sk := fmt.Sprintf("C-%s", n)
|
||||
mapkey(sk, termbox.Key(int(termbox.KeyCtrlTilde)+i))
|
||||
mapkey(sk, KeyType(int(KeyCtrlTilde)+i))
|
||||
}
|
||||
}
|
||||
|
||||
mapkey("BS", termbox.KeyBackspace)
|
||||
mapkey("Tab", termbox.KeyTab)
|
||||
mapkey("Enter", termbox.KeyEnter)
|
||||
mapkey("Esc", termbox.KeyEsc)
|
||||
mapkey("Space", termbox.KeySpace)
|
||||
mapkey("BS2", termbox.KeyBackspace2)
|
||||
mapkey("C-8", termbox.KeyCtrl8)
|
||||
|
||||
// panic(fmt.Sprintf("%#q", stringToKey))
|
||||
mapkey("BS", KeyBackspace)
|
||||
mapkey("Tab", KeyTab)
|
||||
mapkey("Enter", KeyEnter)
|
||||
mapkey("Esc", KeyEsc)
|
||||
mapkey("Space", KeySpace)
|
||||
mapkey("BS2", KeyBackspace2)
|
||||
mapkey("C-8", KeyCtrl8)
|
||||
}
|
||||
|
||||
// ToKeyList parses a comma-separated key binding string (e.g. "C-x,C-c") into a list of KeySeq values.
|
||||
func ToKeyList(ksk string) (KeyList, error) {
|
||||
list := KeyList{}
|
||||
for _, term := range strings.Split(ksk, ",") {
|
||||
for term := range strings.SplitSeq(ksk, ",") {
|
||||
term = strings.TrimSpace(term)
|
||||
|
||||
k, m, ch, err := ToKey(term)
|
||||
if err != nil {
|
||||
return list, errors.Wrapf(err, "failed to convert '%s'", term)
|
||||
return list, fmt.Errorf("failed to convert '%s': %w", term, err)
|
||||
}
|
||||
|
||||
list = append(list, Key{m, k, ch})
|
||||
|
|
@ -121,16 +210,17 @@ func ToKeyList(ksk string) (KeyList, error) {
|
|||
return list, nil
|
||||
}
|
||||
|
||||
// EventToString returns human readable name for a given termbox.Event
|
||||
func EventToString(ev termbox.Event) (string, error) {
|
||||
s := ""
|
||||
if ev.Key == 0 {
|
||||
s = string([]rune{ev.Ch})
|
||||
// KeyEventToString returns a human-readable name for a key event described
|
||||
// by the given key type, character, and modifier.
|
||||
func KeyEventToString(key KeyType, ch rune, mod ModifierKey) (string, error) {
|
||||
var s string
|
||||
if key == 0 {
|
||||
s = string([]rune{ch})
|
||||
} else {
|
||||
var ok bool
|
||||
s, ok = keyToString[ev.Key]
|
||||
s, ok = keyToString[key]
|
||||
if !ok {
|
||||
return "", errors.Errorf("no such key %#v", ev)
|
||||
return "", fmt.Errorf("no such key %d (ch=%c)", key, ch)
|
||||
}
|
||||
|
||||
// Special case for ArrowUp/Down/Left/Right
|
||||
|
|
@ -146,34 +236,58 @@ func EventToString(ev termbox.Event) (string, error) {
|
|||
}
|
||||
}
|
||||
|
||||
if ev.Mod&termbox.ModAlt == 1 {
|
||||
return "M-" + s, nil
|
||||
if m := mod.String(); m != "" {
|
||||
return m + "-" + s, nil
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func ToKey(key string) (k termbox.Key, modifier ModifierKey, ch rune, err error) {
|
||||
modifier = ModNone
|
||||
if strings.HasPrefix(key, "M-") {
|
||||
modifier = ModAlt
|
||||
key = key[2:]
|
||||
// ToKey parses a single key name string into its KeyType, modifier, and rune components.
|
||||
func ToKey(key string) (KeyType, ModifierKey, rune, error) {
|
||||
modifier := ModNone
|
||||
|
||||
// Try full string first. This handles legacy key names like "C-a",
|
||||
// "C-v", "Home", "ArrowLeft", etc. that are registered in stringToKey.
|
||||
if k, ok := stringToKey[key]; ok {
|
||||
return k, modifier, 0, nil
|
||||
}
|
||||
|
||||
// Parse modifier prefixes (C-, S-, M-) iteratively.
|
||||
// After each prefix is stripped, try the remainder as a key name.
|
||||
for {
|
||||
switch {
|
||||
case strings.HasPrefix(key, "C-"):
|
||||
modifier |= ModCtrl
|
||||
key = key[2:]
|
||||
case strings.HasPrefix(key, "S-"):
|
||||
modifier |= ModShift
|
||||
key = key[2:]
|
||||
case strings.HasPrefix(key, "M-"):
|
||||
modifier |= ModAlt
|
||||
key = key[2:]
|
||||
default:
|
||||
goto done
|
||||
}
|
||||
|
||||
// After stripping a prefix, try as a registered key name.
|
||||
// This handles e.g. "M-C-v" → strip M-, then "C-v" is found.
|
||||
if k, ok := stringToKey[key]; ok {
|
||||
return k, modifier, 0, nil
|
||||
}
|
||||
|
||||
// Single ASCII char after modifier(s) → treat as rune
|
||||
if len(key) == 1 {
|
||||
ch = rune(key[0])
|
||||
return
|
||||
return 0, modifier, rune(key[0]), nil
|
||||
}
|
||||
}
|
||||
|
||||
var ok bool
|
||||
k, ok = stringToKey[key]
|
||||
if !ok {
|
||||
// If this is a single rune, just allow it
|
||||
ch, _ = utf8.DecodeRuneInString(key)
|
||||
if ch != utf8.RuneError {
|
||||
return
|
||||
}
|
||||
|
||||
err = errors.Errorf("no such key %s", key)
|
||||
done:
|
||||
// Try as a single rune (handles multi-byte chars like "せ")
|
||||
ch, _ := utf8.DecodeRuneInString(key)
|
||||
if ch != utf8.RuneError {
|
||||
return 0, modifier, ch, nil
|
||||
}
|
||||
return
|
||||
|
||||
return 0, modifier, 0, fmt.Errorf("no such key %s", key)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,127 +4,113 @@ import (
|
|||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/nsf/termbox-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKeymapStrToKeyValue(t *testing.T) {
|
||||
expected := map[string]termbox.Key{
|
||||
"F1": termbox.KeyF1,
|
||||
"F2": termbox.KeyF2,
|
||||
"F3": termbox.KeyF3,
|
||||
"F4": termbox.KeyF4,
|
||||
"F5": termbox.KeyF5,
|
||||
"F6": termbox.KeyF6,
|
||||
"F7": termbox.KeyF7,
|
||||
"F8": termbox.KeyF8,
|
||||
"F9": termbox.KeyF9,
|
||||
"F10": termbox.KeyF10,
|
||||
"F11": termbox.KeyF11,
|
||||
"F12": termbox.KeyF12,
|
||||
"Insert": termbox.KeyInsert,
|
||||
"Delete": termbox.KeyDelete,
|
||||
"Home": termbox.KeyHome,
|
||||
"End": termbox.KeyEnd,
|
||||
"Pgup": termbox.KeyPgup,
|
||||
"Pgdn": termbox.KeyPgdn,
|
||||
"ArrowUp": termbox.KeyArrowUp,
|
||||
"ArrowDown": termbox.KeyArrowDown,
|
||||
"ArrowLeft": termbox.KeyArrowLeft,
|
||||
"ArrowRight": termbox.KeyArrowRight,
|
||||
"MouseLeft": termbox.MouseLeft,
|
||||
"MouseRight": termbox.MouseRight,
|
||||
"MouseMiddle": termbox.MouseMiddle,
|
||||
"BS": termbox.KeyBackspace,
|
||||
"Tab": termbox.KeyTab,
|
||||
"Enter": termbox.KeyEnter,
|
||||
"Esc": termbox.KeyEsc,
|
||||
"Space": termbox.KeySpace,
|
||||
"BS2": termbox.KeyBackspace2,
|
||||
"C-8": termbox.KeyCtrl8,
|
||||
"C-~": termbox.KeyCtrlTilde,
|
||||
"C-2": termbox.KeyCtrl2,
|
||||
"C-Space": termbox.KeyCtrlSpace,
|
||||
"C-a": termbox.KeyCtrlA,
|
||||
"C-b": termbox.KeyCtrlB,
|
||||
"C-c": termbox.KeyCtrlC,
|
||||
"C-d": termbox.KeyCtrlD,
|
||||
"C-e": termbox.KeyCtrlE,
|
||||
"C-f": termbox.KeyCtrlF,
|
||||
"C-g": termbox.KeyCtrlG,
|
||||
"C-h": termbox.KeyCtrlH,
|
||||
"C-i": termbox.KeyCtrlI,
|
||||
"C-j": termbox.KeyCtrlJ,
|
||||
"C-k": termbox.KeyCtrlK,
|
||||
"C-l": termbox.KeyCtrlL,
|
||||
"C-m": termbox.KeyCtrlM,
|
||||
"C-n": termbox.KeyCtrlN,
|
||||
"C-o": termbox.KeyCtrlO,
|
||||
"C-p": termbox.KeyCtrlP,
|
||||
"C-q": termbox.KeyCtrlQ,
|
||||
"C-r": termbox.KeyCtrlR,
|
||||
"C-s": termbox.KeyCtrlS,
|
||||
"C-t": termbox.KeyCtrlT,
|
||||
"C-u": termbox.KeyCtrlU,
|
||||
"C-v": termbox.KeyCtrlV,
|
||||
"C-w": termbox.KeyCtrlW,
|
||||
"C-x": termbox.KeyCtrlX,
|
||||
"C-y": termbox.KeyCtrlY,
|
||||
"C-z": termbox.KeyCtrlZ,
|
||||
"C-[": termbox.KeyCtrlLsqBracket,
|
||||
"C-3": termbox.KeyCtrl3,
|
||||
"C-4": termbox.KeyCtrl4,
|
||||
"C-\\": termbox.KeyCtrlBackslash,
|
||||
"C-5": termbox.KeyCtrl5,
|
||||
"C-]": termbox.KeyCtrlRsqBracket,
|
||||
"C-6": termbox.KeyCtrl6,
|
||||
"C-7": termbox.KeyCtrl7,
|
||||
"C-/": termbox.KeyCtrlSlash,
|
||||
"C-_": termbox.KeyCtrlUnderscore,
|
||||
expected := map[string]KeyType{
|
||||
"F1": KeyF1,
|
||||
"F2": KeyF2,
|
||||
"F3": KeyF3,
|
||||
"F4": KeyF4,
|
||||
"F5": KeyF5,
|
||||
"F6": KeyF6,
|
||||
"F7": KeyF7,
|
||||
"F8": KeyF8,
|
||||
"F9": KeyF9,
|
||||
"F10": KeyF10,
|
||||
"F11": KeyF11,
|
||||
"F12": KeyF12,
|
||||
"Insert": KeyInsert,
|
||||
"Delete": KeyDelete,
|
||||
"Home": KeyHome,
|
||||
"End": KeyEnd,
|
||||
"Pgup": KeyPgup,
|
||||
"Pgdn": KeyPgdn,
|
||||
"ArrowUp": KeyArrowUp,
|
||||
"ArrowDown": KeyArrowDown,
|
||||
"ArrowLeft": KeyArrowLeft,
|
||||
"ArrowRight": KeyArrowRight,
|
||||
"MouseLeft": MouseLeft,
|
||||
"MouseRight": MouseRight,
|
||||
"MouseMiddle": MouseMiddle,
|
||||
"BS": KeyBackspace,
|
||||
"Tab": KeyTab,
|
||||
"Enter": KeyEnter,
|
||||
"Esc": KeyEsc,
|
||||
"Space": KeySpace,
|
||||
"BS2": KeyBackspace2,
|
||||
"C-8": KeyCtrl8,
|
||||
"C-~": KeyCtrlTilde,
|
||||
"C-2": KeyCtrl2,
|
||||
"C-Space": KeyCtrlSpace,
|
||||
"C-a": KeyCtrlA,
|
||||
"C-b": KeyCtrlB,
|
||||
"C-c": KeyCtrlC,
|
||||
"C-d": KeyCtrlD,
|
||||
"C-e": KeyCtrlE,
|
||||
"C-f": KeyCtrlF,
|
||||
"C-g": KeyCtrlG,
|
||||
"C-h": KeyCtrlH,
|
||||
"C-i": KeyCtrlI,
|
||||
"C-j": KeyCtrlJ,
|
||||
"C-k": KeyCtrlK,
|
||||
"C-l": KeyCtrlL,
|
||||
"C-m": KeyCtrlM,
|
||||
"C-n": KeyCtrlN,
|
||||
"C-o": KeyCtrlO,
|
||||
"C-p": KeyCtrlP,
|
||||
"C-q": KeyCtrlQ,
|
||||
"C-r": KeyCtrlR,
|
||||
"C-s": KeyCtrlS,
|
||||
"C-t": KeyCtrlT,
|
||||
"C-u": KeyCtrlU,
|
||||
"C-v": KeyCtrlV,
|
||||
"C-w": KeyCtrlW,
|
||||
"C-x": KeyCtrlX,
|
||||
"C-y": KeyCtrlY,
|
||||
"C-z": KeyCtrlZ,
|
||||
"C-[": KeyCtrlLsqBracket,
|
||||
"C-3": KeyCtrl3,
|
||||
"C-4": KeyCtrl4,
|
||||
"C-\\": KeyCtrlBackslash,
|
||||
"C-5": KeyCtrl5,
|
||||
"C-]": KeyCtrlRsqBracket,
|
||||
"C-6": KeyCtrl6,
|
||||
"C-7": KeyCtrl7,
|
||||
"C-/": KeyCtrlSlash,
|
||||
"C-_": KeyCtrlUnderscore,
|
||||
}
|
||||
|
||||
t.Logf("Checking key name -> actual key value mapping...")
|
||||
for n, v := range expected {
|
||||
t.Logf(" checking %s...", n)
|
||||
e, modifier, _, err := ToKey(n)
|
||||
if err != nil {
|
||||
t.Errorf("Key name %s not found", n)
|
||||
}
|
||||
if e != v {
|
||||
t.Errorf("Expected '%s' to be '%d', but got '%d'", n, v, stringToKey[n])
|
||||
}
|
||||
if modifier != 0 {
|
||||
t.Errorf("Key name '%s' is not Alt-prefixed", n)
|
||||
}
|
||||
require.NoError(t, err, "Key name %s not found", n)
|
||||
require.Equal(t, v, e, "Expected '%s' to be '%d', but got '%d'", n, v, stringToKey[n])
|
||||
require.Equal(t, ModifierKey(0), modifier, "Key name '%s' is not Alt-prefixed", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeymapStrToKeyValueWithAlt(t *testing.T) {
|
||||
expected := map[string]struct {
|
||||
key termbox.Key
|
||||
key KeyType
|
||||
ch rune
|
||||
}{
|
||||
"M-v": {0, 'v'},
|
||||
"M-C-v": {termbox.KeyCtrlV, rune(0)},
|
||||
"M-Space": {termbox.KeySpace, rune(0)},
|
||||
"M-MouseLeft": {termbox.MouseLeft, rune(0)},
|
||||
"M-C-v": {KeyCtrlV, rune(0)},
|
||||
"M-Space": {KeySpace, rune(0)},
|
||||
"M-MouseLeft": {MouseLeft, rune(0)},
|
||||
}
|
||||
|
||||
t.Logf("Checking Alt prefixed key name mapping...")
|
||||
for n, v := range expected {
|
||||
t.Logf(" checking %s...", n)
|
||||
k, modifier, ch, err := ToKey(n)
|
||||
if err != nil {
|
||||
t.Errorf("Failed ToKey: Key name %s", n)
|
||||
}
|
||||
if modifier != 1 {
|
||||
t.Errorf("Key name %s has Alt prefix", n)
|
||||
}
|
||||
if k != v.key {
|
||||
t.Errorf("Expected '%s' to be '%d', but got '%d'", n, v.key, k)
|
||||
}
|
||||
if ch != v.ch {
|
||||
t.Errorf("Expected '%s' to be '%c', but got '%c'", n, v.ch, ch)
|
||||
}
|
||||
require.NoError(t, err, "Failed ToKey: Key name %s", n)
|
||||
require.Equal(t, ModifierKey(1), modifier, "Key name %s has Alt prefix", n)
|
||||
require.Equal(t, v.key, k, "Expected '%s' to be '%d', but got '%d'", n, v.key, k)
|
||||
require.Equal(t, v.ch, ch, "Expected '%s' to be '%c', but got '%c'", n, v.ch, ch)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -138,19 +124,131 @@ func TestKeymapStrToKeyValueCh(t *testing.T) {
|
|||
for _, n := range expected {
|
||||
t.Logf(" checking %s...", n)
|
||||
k, modifier, ch, err := ToKey(n)
|
||||
if err != nil {
|
||||
t.Errorf("Failed ToKey: Key name %s", n)
|
||||
}
|
||||
if k != 0 {
|
||||
t.Errorf("Key name %s is mapped key", n)
|
||||
}
|
||||
if modifier == 1 {
|
||||
t.Errorf("Key name %s has Alt prefix", n)
|
||||
}
|
||||
require.NoError(t, err, "Failed ToKey: Key name %s", n)
|
||||
require.Equal(t, KeyType(0), k, "Key name %s is mapped key", n)
|
||||
require.NotEqual(t, ModifierKey(1), modifier, "Key name %s has Alt prefix", n)
|
||||
r, _ := utf8.DecodeRuneInString(n)
|
||||
if ch != r {
|
||||
t.Errorf("key name %s cannot convert to rune", n)
|
||||
}
|
||||
require.Equal(t, r, ch, "key name %s cannot convert to rune", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeymapStrToKeyValueWithCtrl(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key KeyType
|
||||
}{
|
||||
{"C-ArrowLeft", KeyArrowLeft},
|
||||
{"C-ArrowRight", KeyArrowRight},
|
||||
{"C-ArrowUp", KeyArrowUp},
|
||||
{"C-ArrowDown", KeyArrowDown},
|
||||
{"C-Home", KeyHome},
|
||||
{"C-End", KeyEnd},
|
||||
{"C-Delete", KeyDelete},
|
||||
{"C-Insert", KeyInsert},
|
||||
{"C-Pgup", KeyPgup},
|
||||
{"C-Pgdn", KeyPgdn},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
k, modifier, ch, err := ToKey(tc.name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.key, k)
|
||||
require.Equal(t, ModCtrl, modifier)
|
||||
require.Equal(t, rune(0), ch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeymapStrToKeyValueWithShift(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key KeyType
|
||||
}{
|
||||
{"S-ArrowUp", KeyArrowUp},
|
||||
{"S-ArrowDown", KeyArrowDown},
|
||||
{"S-ArrowLeft", KeyArrowLeft},
|
||||
{"S-ArrowRight", KeyArrowRight},
|
||||
{"S-Home", KeyHome},
|
||||
{"S-End", KeyEnd},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
k, modifier, ch, err := ToKey(tc.name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.key, k)
|
||||
require.Equal(t, ModShift, modifier)
|
||||
require.Equal(t, rune(0), ch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeymapStrToKeyValueWithCombinedModifiers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key KeyType
|
||||
modifier ModifierKey
|
||||
}{
|
||||
{"C-M-ArrowLeft", KeyArrowLeft, ModCtrl | ModAlt},
|
||||
{"M-C-ArrowLeft", KeyArrowLeft, ModCtrl | ModAlt},
|
||||
{"C-S-Delete", KeyDelete, ModCtrl | ModShift},
|
||||
{"C-S-M-Home", KeyHome, ModCtrl | ModShift | ModAlt},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
k, modifier, ch, err := ToKey(tc.name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.key, k)
|
||||
require.Equal(t, tc.modifier, modifier)
|
||||
require.Equal(t, rune(0), ch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModifierKeyString(t *testing.T) {
|
||||
tests := []struct {
|
||||
mod ModifierKey
|
||||
expected string
|
||||
}{
|
||||
{ModNone, ""},
|
||||
{ModAlt, "M"},
|
||||
{ModCtrl, "C"},
|
||||
{ModShift, "S"},
|
||||
{ModCtrl | ModAlt, "C-M"},
|
||||
{ModCtrl | ModShift, "C-S"},
|
||||
{ModShift | ModAlt, "S-M"},
|
||||
{ModCtrl | ModShift | ModAlt, "C-S-M"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.expected, func(t *testing.T) {
|
||||
require.Equal(t, tc.expected, tc.mod.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyEventToStringWithModifiers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key KeyType
|
||||
ch rune
|
||||
mod ModifierKey
|
||||
expected string
|
||||
}{
|
||||
{"Ctrl+Left", KeyArrowLeft, 0, ModCtrl, "C-<"},
|
||||
{"Shift+Right", KeyArrowRight, 0, ModShift, "S->"},
|
||||
{"Ctrl+Alt+Delete", KeyDelete, 0, ModCtrl | ModAlt, "C-M-Delete"},
|
||||
{"Alt+char", 0, 'v', ModAlt, "M-v"},
|
||||
{"no modifier", KeyHome, 0, ModNone, "Home"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, err := KeyEventToString(tc.key, tc.ch, tc.mod)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, s)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
package keyseq
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nsf/termbox-go"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var ErrInSequence = errors.New("expected a key sequence")
|
||||
|
|
@ -15,35 +12,44 @@ var ErrNoMatch = errors.New("could not match key to any action")
|
|||
type ModifierKey int
|
||||
|
||||
const (
|
||||
ModNone ModifierKey = iota
|
||||
ModAlt
|
||||
ModMax
|
||||
ModNone ModifierKey = 0
|
||||
ModAlt ModifierKey = 1 << 0 // 0x01
|
||||
ModCtrl ModifierKey = 1 << 1 // 0x02
|
||||
ModShift ModifierKey = 1 << 2 // 0x04
|
||||
)
|
||||
|
||||
// Key is data in one trie node in the KeySequence
|
||||
type Key struct {
|
||||
Modifier ModifierKey // Alt, etc
|
||||
Key termbox.Key
|
||||
Key KeyType
|
||||
Ch rune
|
||||
}
|
||||
|
||||
// String returns a comma-separated string representation of the key list.
|
||||
func (kl KeyList) String() string {
|
||||
list := make([]string, len(kl))
|
||||
for i := 0; i < len(kl); i++ {
|
||||
for i := range kl {
|
||||
list[i] = kl[i].String()
|
||||
}
|
||||
return strings.Join(list, ",")
|
||||
}
|
||||
|
||||
// String returns the modifier key as a dash-separated string (e.g. "C-S-M").
|
||||
func (m ModifierKey) String() string {
|
||||
switch m {
|
||||
case ModAlt:
|
||||
return "M"
|
||||
default:
|
||||
return ""
|
||||
var parts []string
|
||||
if m&ModCtrl != 0 {
|
||||
parts = append(parts, "C")
|
||||
}
|
||||
if m&ModShift != 0 {
|
||||
parts = append(parts, "S")
|
||||
}
|
||||
if m&ModAlt != 0 {
|
||||
parts = append(parts, "M")
|
||||
}
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
// String returns a human-readable representation of the key, including any modifiers.
|
||||
func (k Key) String() string {
|
||||
var s string
|
||||
if m := k.Modifier.String(); m != "" {
|
||||
|
|
@ -59,7 +65,8 @@ func (k Key) String() string {
|
|||
return s
|
||||
}
|
||||
|
||||
func NewKeyFromKey(k termbox.Key) Key {
|
||||
// NewKeyFromKey creates a Key from a KeyType with no modifier and no rune.
|
||||
func NewKeyFromKey(k KeyType) Key {
|
||||
return Key{
|
||||
Modifier: 0,
|
||||
Key: k,
|
||||
|
|
@ -70,6 +77,7 @@ func NewKeyFromKey(k termbox.Key) Key {
|
|||
// KeyList is just the list of keys
|
||||
type KeyList []Key
|
||||
|
||||
// Compare returns -1, 0, or 1 comparing k and x by modifier, key type, and character.
|
||||
func (k Key) Compare(x Key) int {
|
||||
if k.Modifier < x.Modifier {
|
||||
return -1
|
||||
|
|
@ -92,13 +100,14 @@ func (k Key) Compare(x Key) int {
|
|||
return 0
|
||||
}
|
||||
|
||||
func (k KeyList) Equals(x KeyList) bool {
|
||||
if len(k) != len(x) {
|
||||
// Equals reports whether kl and x contain the same keys in the same order.
|
||||
func (kl KeyList) Equals(x KeyList) bool {
|
||||
if len(kl) != len(x) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := 0; i < len(k); i++ {
|
||||
if k[i].Compare(x[i]) != 0 {
|
||||
for i := range kl {
|
||||
if kl[i].Compare(x[i]) != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -112,11 +121,11 @@ type keyseqMatcher interface {
|
|||
|
||||
type Keyseq struct {
|
||||
*Matcher
|
||||
current keyseqMatcher
|
||||
mutex sync.Mutex
|
||||
prevInputTime time.Time
|
||||
current keyseqMatcher
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
// New creates a new Keyseq matcher for resolving multi-key bindings.
|
||||
func New() *Keyseq {
|
||||
return &Keyseq{
|
||||
Matcher: NewMatcher(),
|
||||
|
|
@ -124,10 +133,12 @@ func New() *Keyseq {
|
|||
}
|
||||
}
|
||||
|
||||
// InMiddleOfChain reports whether the matcher is partway through a multi-key sequence.
|
||||
func (k *Keyseq) InMiddleOfChain() bool {
|
||||
return k.current != nil && k.current != k.Matcher
|
||||
}
|
||||
|
||||
// CancelChain resets the matcher to the root, abandoning any in-progress key sequence.
|
||||
func (k *Keyseq) CancelChain() {
|
||||
k.mutex.Lock()
|
||||
defer k.mutex.Unlock()
|
||||
|
|
@ -146,15 +157,12 @@ func (k *Keyseq) Current() keyseqMatcher {
|
|||
return k.current
|
||||
}
|
||||
|
||||
func (k *Keyseq) updateInputTime() {
|
||||
k.prevInputTime = time.Now()
|
||||
}
|
||||
|
||||
func (k *Keyseq) AcceptKey(key Key) (interface{}, error) {
|
||||
// AcceptKey advances the key sequence matcher with the given key, returning the bound action
|
||||
// if a complete sequence is matched, or ErrInSequence if more keys are expected.
|
||||
func (k *Keyseq) AcceptKey(key Key) (any, error) {
|
||||
// XXX should we return Action instead of interface{}?
|
||||
k.mutex.Lock()
|
||||
defer k.mutex.Unlock()
|
||||
defer k.updateInputTime()
|
||||
c := k.Current()
|
||||
n := c.Get(key)
|
||||
|
||||
|
|
@ -175,7 +183,7 @@ func (k *Keyseq) AcceptKey(key Key) (interface{}, error) {
|
|||
return nil, ErrInSequence
|
||||
}
|
||||
|
||||
// If it got here, we should just rest the matcher, and return
|
||||
// If it got here, we should just reset the matcher, and return
|
||||
// whatever we matched
|
||||
k.setCurrent(k.Matcher)
|
||||
|
||||
|
|
@ -185,5 +193,6 @@ func (k *Keyseq) AcceptKey(key Key) (interface{}, error) {
|
|||
if data == nil {
|
||||
return nil, ErrNoMatch
|
||||
}
|
||||
return data.(*nodeData).Value(), nil
|
||||
nd, _ := data.(*nodeData)
|
||||
return nd.Value(), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ type TernaryTrie struct {
|
|||
root TernaryNode
|
||||
}
|
||||
|
||||
// NewTernaryTrie creates a new empty ternary search trie.
|
||||
func NewTernaryTrie() *TernaryTrie {
|
||||
return &TernaryTrie{}
|
||||
}
|
||||
|
|
@ -20,10 +21,11 @@ func (t *TernaryTrie) Get(k Key) Node {
|
|||
return Get(t, KeyList{k})
|
||||
}
|
||||
|
||||
func (t *TernaryTrie) Put(k KeyList, v interface{}) Node {
|
||||
func (t *TernaryTrie) Put(k KeyList, v any) Node {
|
||||
return Put(t, k, v)
|
||||
}
|
||||
|
||||
// Size returns the total number of nodes in the trie.
|
||||
func (t *TernaryTrie) Size() int {
|
||||
count := 0
|
||||
EachDepth(t, func(Node) bool {
|
||||
|
|
@ -33,9 +35,11 @@ func (t *TernaryTrie) Size() int {
|
|||
return count
|
||||
}
|
||||
|
||||
// Balance rebalances all sibling lists in the trie for optimal search performance.
|
||||
func (t *TernaryTrie) Balance() {
|
||||
EachDepth(t, func(n Node) bool {
|
||||
n.(*TernaryNode).Balance()
|
||||
tn, _ := n.(*TernaryNode)
|
||||
tn.Balance()
|
||||
return true
|
||||
})
|
||||
t.root.Balance()
|
||||
|
|
@ -45,17 +49,23 @@ type TernaryNode struct {
|
|||
label Key
|
||||
firstChild *TernaryNode
|
||||
low, high *TernaryNode
|
||||
value interface{}
|
||||
value any
|
||||
}
|
||||
|
||||
// NewTernaryNode creates a new ternary trie node with the given key label.
|
||||
func NewTernaryNode(l Key) *TernaryNode {
|
||||
return &TernaryNode{label: l}
|
||||
}
|
||||
|
||||
// GetList looks up a child node matching the first key in the list.
|
||||
func (n *TernaryNode) GetList(k KeyList) Node {
|
||||
if len(k) == 0 {
|
||||
return nil
|
||||
}
|
||||
return n.Get(k[0])
|
||||
}
|
||||
|
||||
// Get searches the children of this node for a child matching key k.
|
||||
func (n *TernaryNode) Get(k Key) Node {
|
||||
curr := n.firstChild
|
||||
for curr != nil {
|
||||
|
|
@ -71,7 +81,8 @@ func (n *TernaryNode) Get(k Key) Node {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (n *TernaryNode) Dig(k Key) (node Node, isnew bool) {
|
||||
// Dig finds or creates a child node for the given key, returning the node and whether it was newly created.
|
||||
func (n *TernaryNode) Dig(k Key) (Node, bool) {
|
||||
curr := n.firstChild
|
||||
if curr == nil {
|
||||
n.firstChild = NewTernaryNode(k)
|
||||
|
|
@ -105,6 +116,7 @@ func (n *TernaryNode) HasChildren() bool {
|
|||
return n.firstChild != nil
|
||||
}
|
||||
|
||||
// Size returns the number of direct children of this node.
|
||||
func (n *TernaryNode) Size() int {
|
||||
if n.firstChild == nil {
|
||||
return 0
|
||||
|
|
@ -117,6 +129,7 @@ func (n *TernaryNode) Size() int {
|
|||
return count
|
||||
}
|
||||
|
||||
// Each calls proc for every child node in sorted order, stopping early if proc returns false.
|
||||
func (n *TernaryNode) Each(proc func(Node) bool) {
|
||||
var f func(*TernaryNode) bool
|
||||
f = func(n *TernaryNode) bool {
|
||||
|
|
@ -130,6 +143,7 @@ func (n *TernaryNode) Each(proc func(Node) bool) {
|
|||
f(n.firstChild)
|
||||
}
|
||||
|
||||
// RemoveAll removes all children from this node.
|
||||
func (n *TernaryNode) RemoveAll() {
|
||||
n.firstChild = nil
|
||||
}
|
||||
|
|
@ -138,14 +152,15 @@ func (n *TernaryNode) Label() Key {
|
|||
return n.label
|
||||
}
|
||||
|
||||
func (n *TernaryNode) Value() interface{} {
|
||||
func (n *TernaryNode) Value() any {
|
||||
return n.value
|
||||
}
|
||||
|
||||
func (n *TernaryNode) SetValue(v interface{}) {
|
||||
func (n *TernaryNode) SetValue(v any) {
|
||||
n.value = v
|
||||
}
|
||||
|
||||
// children collects all direct child nodes into a sorted slice.
|
||||
func (n *TernaryNode) children() []*TernaryNode {
|
||||
children := make([]*TernaryNode, n.Size())
|
||||
if n.firstChild == nil {
|
||||
|
|
@ -153,13 +168,15 @@ func (n *TernaryNode) children() []*TernaryNode {
|
|||
}
|
||||
idx := 0
|
||||
n.Each(func(child Node) bool {
|
||||
children[idx] = child.(*TernaryNode)
|
||||
tn, _ := child.(*TernaryNode)
|
||||
children[idx] = tn
|
||||
idx++
|
||||
return true
|
||||
})
|
||||
return children
|
||||
}
|
||||
|
||||
// Balance rebalances the children of this node into a balanced binary search tree.
|
||||
func (n *TernaryNode) Balance() {
|
||||
if n.firstChild == nil {
|
||||
return
|
||||
|
|
@ -172,6 +189,7 @@ func (n *TernaryNode) Balance() {
|
|||
n.firstChild = balance(children, 0, len(children))
|
||||
}
|
||||
|
||||
// balance recursively builds a balanced binary tree from a sorted slice of nodes.
|
||||
func balance(nodes []*TernaryNode, s, e int) *TernaryNode {
|
||||
count := e - s
|
||||
if count <= 0 {
|
||||
|
|
@ -181,11 +199,10 @@ func balance(nodes []*TernaryNode, s, e int) *TernaryNode {
|
|||
} else if count == 2 {
|
||||
nodes[s].high = nodes[s+1]
|
||||
return nodes[s]
|
||||
} else {
|
||||
mid := (s + e) / 2
|
||||
n := nodes[mid]
|
||||
n.low = balance(nodes, s, mid)
|
||||
n.high = balance(nodes, mid+1, e)
|
||||
return n
|
||||
}
|
||||
mid := (s + e) / 2
|
||||
n := nodes[mid]
|
||||
n.low = balance(nodes, s, mid)
|
||||
n.high = balance(nodes, mid+1, e)
|
||||
return n
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +1,51 @@
|
|||
package keyseq
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
func assertNilBoth(t *testing.T, n *TernaryNode) {
|
||||
if n.low != nil {
|
||||
t.Errorf("low node has value: %v", &n.low)
|
||||
}
|
||||
if n.high != nil {
|
||||
t.Errorf("high node has value: %v", &n.high)
|
||||
}
|
||||
}
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBalance(t *testing.T) {
|
||||
trie := NewTernaryTrie()
|
||||
|
||||
list := []Key{}
|
||||
for i := 0; i < 15; i++ {
|
||||
list := make([]Key, 0, 15)
|
||||
for i := range 15 {
|
||||
list = append(list, Key{0, 0, rune(i)})
|
||||
}
|
||||
|
||||
for i, k := range list {
|
||||
trie.Put(KeyList{k}, i)
|
||||
}
|
||||
if s := trie.Size(); s != 15 {
|
||||
t.Fatalf("Size() returns not 15: %d", s)
|
||||
}
|
||||
require.Equal(t, 15, trie.Size())
|
||||
trie.Balance()
|
||||
|
||||
/*
|
||||
n8 := trie.Root().(*TernaryNode).firstChild
|
||||
checkTrieNode(t, n8, '8', 7)
|
||||
n4 := n8.low
|
||||
checkTrieNode(t, n4, '4', 3)
|
||||
n12 := n8.high
|
||||
checkTrieNode(t, n12, 'C', 11)
|
||||
n2 := n4.low
|
||||
checkTrieNode(t, n2, '2', 1)
|
||||
n6 := n4.high
|
||||
checkTrieNode(t, n6, '6', 5)
|
||||
n10 := n12.low
|
||||
checkTrieNode(t, n10, 'A', 9)
|
||||
n14 := n12.high
|
||||
checkTrieNode(t, n14, 'E', 13)
|
||||
n1 := n2.low
|
||||
checkTrieNode(t, n1, '1', 0)
|
||||
n3 := n2.high
|
||||
checkTrieNode(t, n3, '3', 2)
|
||||
n5 := n6.low
|
||||
checkTrieNode(t, n5, '5', 4)
|
||||
n7 := n6.high
|
||||
checkTrieNode(t, n7, '7', 6)
|
||||
n9 := n10.low
|
||||
checkTrieNode(t, n9, '9', 8)
|
||||
n11 := n10.high
|
||||
checkTrieNode(t, n11, 'B', 10)
|
||||
n13 := n14.low
|
||||
checkTrieNode(t, n13, 'D', 12)
|
||||
n15 := n14.high
|
||||
checkTrieNode(t, n15, 'F', 14)
|
||||
assertNilBoth(t, n1)
|
||||
assertNilBoth(t, n3)
|
||||
assertNilBoth(t, n5)
|
||||
assertNilBoth(t, n7)
|
||||
assertNilBoth(t, n9)
|
||||
assertNilBoth(t, n11)
|
||||
assertNilBoth(t, n13)
|
||||
assertNilBoth(t, n15)
|
||||
*/
|
||||
// After balancing, all keys must still be retrievable with correct values.
|
||||
for i, k := range list {
|
||||
node := trie.Get(k)
|
||||
require.NotNil(t, node, "key %d should be found after Balance", i)
|
||||
require.Equal(t, i, node.Value(), "value for key %d should be %d", i, i)
|
||||
}
|
||||
|
||||
// Size must be unchanged after balancing.
|
||||
require.Equal(t, 15, trie.Size())
|
||||
}
|
||||
|
||||
func TestBalancePreservesMultiKeySequences(t *testing.T) {
|
||||
trie := NewTernaryTrie()
|
||||
|
||||
// Insert multi-key sequences
|
||||
k1 := KeyList{{0, 0, 'a'}, {0, 0, 'b'}}
|
||||
k2 := KeyList{{0, 0, 'a'}, {0, 0, 'c'}}
|
||||
k3 := KeyList{{0, 0, 'x'}}
|
||||
|
||||
trie.Put(k1, "ab")
|
||||
trie.Put(k2, "ac")
|
||||
trie.Put(k3, "x")
|
||||
|
||||
trie.Balance()
|
||||
|
||||
require.Equal(t, "ab", trie.GetList(k1).Value())
|
||||
require.Equal(t, "ac", trie.GetList(k2).Value())
|
||||
require.Equal(t, "x", trie.GetList(k3).Value())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,14 +8,16 @@ type Trie interface {
|
|||
Root() Node
|
||||
GetList(KeyList) Node
|
||||
Get(Key) Node
|
||||
Put(KeyList, interface{}) Node
|
||||
Put(KeyList, any) Node
|
||||
Size() int
|
||||
}
|
||||
|
||||
// NewTrie creates a new empty Trie for storing key sequences.
|
||||
func NewTrie() Trie {
|
||||
return NewTernaryTrie()
|
||||
}
|
||||
|
||||
// Get looks up a value by key sequence path in the trie, returning nil if not found.
|
||||
func Get(t Trie, k KeyList) Node {
|
||||
if t == nil {
|
||||
return nil
|
||||
|
|
@ -30,7 +32,8 @@ func Get(t Trie, k KeyList) Node {
|
|||
return n
|
||||
}
|
||||
|
||||
func Put(t Trie, k KeyList, v interface{}) Node {
|
||||
// Put inserts a value at the given key sequence path in the trie, creating nodes as needed.
|
||||
func Put(t Trie, k KeyList, v any) Node {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -42,6 +45,7 @@ func Put(t Trie, k KeyList, v interface{}) Node {
|
|||
return n
|
||||
}
|
||||
|
||||
// EachDepth iterates over trie nodes in depth-first order, calling proc for each node.
|
||||
func EachDepth(t Trie, proc func(Node) bool) {
|
||||
if t == nil {
|
||||
return
|
||||
|
|
@ -55,6 +59,7 @@ func EachDepth(t Trie, proc func(Node) bool) {
|
|||
r.Each(f)
|
||||
}
|
||||
|
||||
// EachWidth iterates over trie nodes in breadth-first order, calling proc for each node.
|
||||
func EachWidth(t Trie, proc func(Node) bool) {
|
||||
if t == nil {
|
||||
return
|
||||
|
|
@ -64,7 +69,10 @@ func EachWidth(t Trie, proc func(Node) bool) {
|
|||
for q.Len() != 0 {
|
||||
f := q.Front()
|
||||
q.Remove(f)
|
||||
t := f.Value.(Node)
|
||||
t, ok := f.Value.(Node)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if !proc(t) {
|
||||
break
|
||||
}
|
||||
|
|
@ -85,10 +93,11 @@ type Node interface {
|
|||
RemoveAll()
|
||||
|
||||
Label() Key
|
||||
Value() interface{}
|
||||
SetValue(v interface{})
|
||||
Value() any
|
||||
SetValue(v any)
|
||||
}
|
||||
|
||||
// Children returns all child nodes of n as a slice.
|
||||
func Children(n Node) []Node {
|
||||
children := make([]Node, n.Size())
|
||||
idx := 0
|
||||
|
|
|
|||
|
|
@ -2,18 +2,14 @@ package keyseq
|
|||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func checkTrieNode(t *testing.T, n Node, k Key, value int) {
|
||||
if n == nil {
|
||||
t.Fatal("TrieNode is null")
|
||||
}
|
||||
if l := n.Label(); l != k {
|
||||
t.Errorf("TrieNode.Label() expected:'%c' actual:'%c'", k, l)
|
||||
}
|
||||
if v := n.Value().(int); v != value {
|
||||
t.Errorf("TrieNode.Value() expected:%d actual:%d", value, v)
|
||||
}
|
||||
require.NotNil(t, n, "TrieNode is null")
|
||||
require.Equal(t, k, n.Label(), "TrieNode.Label()")
|
||||
require.Equal(t, value, n.Value().(int), "TrieNode.Value()")
|
||||
}
|
||||
|
||||
func TestTrie(t *testing.T) {
|
||||
|
|
@ -23,18 +19,14 @@ func TestTrie(t *testing.T) {
|
|||
}
|
||||
|
||||
nodes := Children(trie.Root())
|
||||
for i := 0; i < 5; i++ {
|
||||
for i := range 5 {
|
||||
checkTrieNode(t, nodes[i], Key{0, 0, rune(i + 1)}, 111*(i+1))
|
||||
}
|
||||
|
||||
if s := trie.Size(); s != 5 {
|
||||
t.Errorf("trie.Size() returns not 5: %d", s)
|
||||
}
|
||||
require.Equal(t, 5, trie.Size())
|
||||
}
|
||||
|
||||
func TestNotFound(t *testing.T) {
|
||||
trie := NewTrie()
|
||||
if trie.Get(Key{999, 999, 'a'}) != nil {
|
||||
t.Errorf("found 'not_exist' in empty trie")
|
||||
}
|
||||
require.Nil(t, trie.Get(Key{999, 999, 'a'}))
|
||||
}
|
||||
|
|
|
|||
40
internal/util/bench_test.go
Normal file
40
internal/util/bench_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkStripANSISequenceBulk(b *testing.B) {
|
||||
b.Run("10k_plain_lines", func(b *testing.B) {
|
||||
lines := make([]string, 10_000)
|
||||
for i := range lines {
|
||||
lines[i] = fmt.Sprintf("line %d: this is a typical line without any ansi codes at all padding", i)
|
||||
}
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
for _, l := range lines {
|
||||
StripANSISequence(l)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("10k_mixed_95pct_plain", func(b *testing.B) {
|
||||
lines := make([]string, 10_000)
|
||||
for i := range lines {
|
||||
if i%20 == 0 {
|
||||
lines[i] = fmt.Sprintf("\x1b[31mline %d\x1b[0m: this has ansi codes in it for color", i)
|
||||
} else {
|
||||
lines[i] = fmt.Sprintf("line %d: this is a typical line without any ansi codes at all padding", i)
|
||||
}
|
||||
}
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
for _, l := range lines {
|
||||
StripANSISequence(l)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
// +build !darwin,!windows
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package util
|
||||
|
||||
|
|
@ -7,10 +7,11 @@ import (
|
|||
"os"
|
||||
)
|
||||
|
||||
// Homedir returns the current user's home directory from the HOME environment variable.
|
||||
func Homedir() (string, error) {
|
||||
home := os.Getenv("HOME")
|
||||
if home == "" {
|
||||
return "", errors.New("error: Environment variable HOME not set")
|
||||
return "", errors.New("environment variable HOME not set")
|
||||
}
|
||||
|
||||
return home, nil
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
// +build !windows
|
||||
//go:build !windows
|
||||
|
||||
package util
|
||||
|
||||
import "os/exec"
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func Shell(cmd ...string) *exec.Cmd {
|
||||
// Shell creates an exec.Cmd that runs the given command strings via /bin/sh -c.
|
||||
func Shell(ctx context.Context, cmd ...string) *exec.Cmd {
|
||||
const shellpath = `/bin/sh`
|
||||
const shellopt = `-c`
|
||||
const shellopt = `-c`
|
||||
|
||||
args := make([]string, len(cmd) + 1)
|
||||
args := make([]string, len(cmd)+1)
|
||||
args[0] = shellopt
|
||||
for i := 0; i < len(cmd); i++ {
|
||||
for i := range cmd {
|
||||
args[i+1] = cmd[i]
|
||||
}
|
||||
|
||||
return exec.Command(shellpath, args...)
|
||||
|
||||
return exec.CommandContext(ctx, shellpath, args...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
// +build windows
|
||||
//go:build windows
|
||||
|
||||
package util
|
||||
|
||||
import "os/exec"
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func Shell(cmd ...string) *exec.Cmd {
|
||||
func Shell(ctx context.Context, cmd ...string) *exec.Cmd {
|
||||
const shellpath = `cmd`
|
||||
const shellopt = `/c`
|
||||
const shellopt = `/c`
|
||||
|
||||
args := make([]string, len(cmd) + 1)
|
||||
args := make([]string, len(cmd)+1)
|
||||
args[0] = shellopt
|
||||
for i := 0; i < len(cmd); i++ {
|
||||
for i := range cmd {
|
||||
args[i+1] = cmd[i]
|
||||
}
|
||||
|
||||
return exec.Command(shellpath, args...)
|
||||
|
||||
return exec.CommandContext(ctx, shellpath, args...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// +build darwin freebsd openbsd netbsd dragonfly
|
||||
//go:build darwin || freebsd || openbsd || netbsd || dragonfly
|
||||
|
||||
package util
|
||||
|
||||
|
|
@ -8,7 +8,7 @@ import (
|
|||
)
|
||||
|
||||
// IsTty checks if the given fd is a tty
|
||||
func IsTty(arg interface{}) bool {
|
||||
func IsTty(arg any) bool {
|
||||
fdsrc, ok := arg.(fder)
|
||||
if !ok {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// +build !darwin,!windows,!freebsd,!openbsd,!netbsd,!dragonfly
|
||||
//go:build !darwin && !windows && !freebsd && !openbsd && !netbsd && !dragonfly
|
||||
|
||||
package util
|
||||
|
||||
|
|
@ -8,7 +8,7 @@ import (
|
|||
)
|
||||
|
||||
// IsTty checks if the given fd is a tty
|
||||
func IsTty(arg interface{}) bool {
|
||||
func IsTty(arg any) bool {
|
||||
fdsrc, ok := arg.(fder)
|
||||
if !ok {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -22,14 +21,14 @@ func getStdHandle(h int) (fd syscall.Handle) {
|
|||
|
||||
func setStdHandle(stdhandle int32, handle syscall.Handle) error {
|
||||
r1, _, err := procSetStdHandle.Call(uintptr(stdhandle), uintptr(handle))
|
||||
if r1 == 0 && err != nil {
|
||||
return errors.Wrap(err, "failed to call SetStdHandle")
|
||||
if r1 == 0 {
|
||||
return fmt.Errorf("failed to call SetStdHandle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsTty checks if the given fd is a tty
|
||||
func IsTty(arg interface{}) bool {
|
||||
func IsTty(arg any) bool {
|
||||
fdsrc, ok := arg.(fder)
|
||||
if !ok {
|
||||
return false
|
||||
|
|
@ -37,8 +36,8 @@ func IsTty(arg interface{}) bool {
|
|||
fd := fdsrc.Fd()
|
||||
|
||||
var st uint32
|
||||
r1, _, err := procGetConsoleMode.Call(fd, uintptr(unsafe.Pointer(&st)))
|
||||
return r1 != 0 && err != nil
|
||||
r1, _, _ := procGetConsoleMode.Call(fd, uintptr(unsafe.Pointer(&st)))
|
||||
return r1 != 0
|
||||
}
|
||||
|
||||
var stdin = os.Stdin
|
||||
|
|
@ -55,7 +54,10 @@ func TtyReady() error {
|
|||
os.Stdin = _stdin
|
||||
syscall.Stdin = syscall.Handle(os.Stdin.Fd())
|
||||
|
||||
return errors.Wrap(setStdHandle(syscall.STD_INPUT_HANDLE, syscall.Stdin), "failed to check for TtyReady")
|
||||
if err = setStdHandle(syscall.STD_INPUT_HANDLE, syscall.Stdin); err != nil {
|
||||
return fmt.Errorf("failed to check for TtyReady: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TtyTerm restores any state, if necessary
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
|
|
@ -9,6 +11,7 @@ type fder interface {
|
|||
Fd() uintptr
|
||||
}
|
||||
|
||||
// CaseInsensitiveIndexFunc returns a function that matches runes equal to r, ignoring case.
|
||||
func CaseInsensitiveIndexFunc(r rune) func(rune) bool {
|
||||
lr := unicode.ToUpper(r)
|
||||
return func(v rune) bool {
|
||||
|
|
@ -16,6 +19,20 @@ func CaseInsensitiveIndexFunc(r rune) func(rune) bool {
|
|||
}
|
||||
}
|
||||
|
||||
// CaseInsensitiveIndex returns the byte index of the first rune in s that
|
||||
// is case-insensitively equal to r. Returns -1 if not found. This avoids
|
||||
// the closure allocation of CaseInsensitiveIndexFunc + strings.IndexFunc.
|
||||
func CaseInsensitiveIndex(s string, r rune) int {
|
||||
upper := unicode.ToUpper(r)
|
||||
for i, c := range s {
|
||||
if unicode.ToUpper(c) == upper {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ContainsUpper reports whether the string contains any uppercase letter.
|
||||
func ContainsUpper(query string) bool {
|
||||
for _, c := range query {
|
||||
if unicode.IsUpper(c) {
|
||||
|
|
@ -28,15 +45,16 @@ func ContainsUpper(query string) bool {
|
|||
// Global var used to strips ansi sequences
|
||||
var reANSIEscapeChars = regexp.MustCompile("\x1B\\[(?:[0-9]{1,2}(?:;[0-9]{1,2})?)*[a-zA-Z]")
|
||||
|
||||
// Function who strips ansi sequences
|
||||
// StripANSISequence strips ANSI escape sequences from the given string.
|
||||
// Fast-path: if the string contains no ESC byte, return it unchanged
|
||||
// to avoid a regexp allocation.
|
||||
func StripANSISequence(s string) string {
|
||||
if !strings.Contains(s, "\x1b") {
|
||||
return s
|
||||
}
|
||||
return reANSIEscapeChars.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
type causer interface {
|
||||
Cause() error
|
||||
}
|
||||
|
||||
type ignorable interface {
|
||||
Ignorable() bool
|
||||
}
|
||||
|
|
@ -49,44 +67,32 @@ type exitStatuser interface {
|
|||
ExitStatus() int
|
||||
}
|
||||
|
||||
// IsIgnorableError checks whether err implements the Ignorable interface and returns true.
|
||||
func IsIgnorableError(err error) bool {
|
||||
for e := err; e != nil; {
|
||||
switch e.(type) {
|
||||
case ignorable:
|
||||
return e.(ignorable).Ignorable()
|
||||
case causer:
|
||||
e = e.(causer).Cause()
|
||||
default:
|
||||
return false
|
||||
for e := err; e != nil; e = errors.Unwrap(e) {
|
||||
if v, ok := e.(ignorable); ok {
|
||||
return v.Ignorable()
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsCollectResultsError checks whether err signals that results should be collected.
|
||||
func IsCollectResultsError(err error) bool {
|
||||
for e := err; e != nil; {
|
||||
switch e.(type) {
|
||||
case collectResults:
|
||||
return e.(collectResults).CollectResults()
|
||||
case causer:
|
||||
e = e.(causer).Cause()
|
||||
default:
|
||||
return false
|
||||
for e := err; e != nil; e = errors.Unwrap(e) {
|
||||
if v, ok := e.(collectResults); ok {
|
||||
return v.CollectResults()
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetExitStatus extracts the exit status code from an error, returning 1 and false if not found.
|
||||
func GetExitStatus(err error) (int, bool) {
|
||||
for e := err; e != nil; {
|
||||
for e := err; e != nil; e = errors.Unwrap(e) {
|
||||
if ese, ok := e.(exitStatuser); ok {
|
||||
return ese.ExitStatus(), true
|
||||
}
|
||||
if cerr, ok := e.(causer); ok {
|
||||
e = cerr.Cause()
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return 1, false
|
||||
}
|
||||
|
|
|
|||
173
internal/util/util_test.go
Normal file
173
internal/util/util_test.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCaseInsensitiveIndexFunc(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
r rune
|
||||
matchR rune
|
||||
matches bool
|
||||
}{
|
||||
{"same lowercase", 'a', 'a', true},
|
||||
{"upper matches lower", 'a', 'A', true},
|
||||
{"lower matches upper", 'A', 'a', true},
|
||||
{"different chars", 'a', 'b', false},
|
||||
{"unicode same", 'ä', 'Ä', true},
|
||||
{"digit matches itself", '1', '1', true},
|
||||
{"digit vs letter", '1', 'a', false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
fn := CaseInsensitiveIndexFunc(tt.r)
|
||||
require.Equal(t, tt.matches, fn(tt.matchR))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsUpper(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"all lowercase", "hello", false},
|
||||
{"all uppercase", "HELLO", true},
|
||||
{"mixed case", "hEllo", true},
|
||||
{"empty string", "", false},
|
||||
{"numbers only", "12345", false},
|
||||
{"with uppercase at end", "hellO", true},
|
||||
{"with uppercase at start", "Hello", true},
|
||||
{"unicode lowercase", "こんにちは", false},
|
||||
{"special chars", "!@#$%", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tt.expected, ContainsUpper(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripANSISequence(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"no ANSI", "hello", "hello"},
|
||||
{"empty string", "", ""},
|
||||
{"bold", "\x1B[1mhello\x1B[0m", "hello"},
|
||||
{"color red", "\x1B[31mred text\x1B[0m", "red text"},
|
||||
{"multiple sequences", "\x1B[1m\x1B[31mhello\x1B[0m", "hello"},
|
||||
{"color with semicolon", "\x1B[1;31mbold red\x1B[0m", "bold red"},
|
||||
{"mixed content", "before\x1B[32mgreen\x1B[0mafter", "beforegreenafter"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tt.expected, StripANSISequence(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mock error types for testing error interface checks
|
||||
|
||||
type mockIgnorableError struct {
|
||||
ignorable bool
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *mockIgnorableError) Error() string { return e.msg }
|
||||
func (e *mockIgnorableError) Ignorable() bool { return e.ignorable }
|
||||
|
||||
type mockCollectResultsError struct {
|
||||
collect bool
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *mockCollectResultsError) Error() string { return e.msg }
|
||||
func (e *mockCollectResultsError) CollectResults() bool { return e.collect }
|
||||
|
||||
type mockExitStatusError struct {
|
||||
status int
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *mockExitStatusError) Error() string { return e.msg }
|
||||
func (e *mockExitStatusError) ExitStatus() int { return e.status }
|
||||
|
||||
func TestIsIgnorableError(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{"ignorable true", &mockIgnorableError{ignorable: true, msg: "test"}, true},
|
||||
{"ignorable false", &mockIgnorableError{ignorable: false, msg: "test"}, false},
|
||||
{"non-ignorable error", errors.New("plain error"), false},
|
||||
{"wrapped ignorable", fmt.Errorf("wrapper: %w", &mockIgnorableError{ignorable: true, msg: "inner"}), true},
|
||||
{"wrapped non-ignorable", fmt.Errorf("wrapper: %w", errors.New("plain")), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tt.expected, IsIgnorableError(tt.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCollectResultsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{"collect true", &mockCollectResultsError{collect: true, msg: "test"}, true},
|
||||
{"collect false", &mockCollectResultsError{collect: false, msg: "test"}, false},
|
||||
{"plain error", errors.New("plain error"), false},
|
||||
{"wrapped collect", fmt.Errorf("wrapper: %w", &mockCollectResultsError{collect: true, msg: "inner"}), true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tt.expected, IsCollectResultsError(tt.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExitStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expectedStatus int
|
||||
expectedFound bool
|
||||
}{
|
||||
{"exit status 0", &mockExitStatusError{status: 0, msg: "test"}, 0, true},
|
||||
{"exit status 1", &mockExitStatusError{status: 1, msg: "test"}, 1, true},
|
||||
{"exit status 42", &mockExitStatusError{status: 42, msg: "test"}, 42, true},
|
||||
{"plain error", errors.New("plain"), 1, false},
|
||||
{"wrapped exit status", fmt.Errorf("wrapper: %w", &mockExitStatusError{status: 2, msg: "inner"}), 2, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
status, found := GetExitStatus(tt.err)
|
||||
require.Equal(t, tt.expectedStatus, status)
|
||||
require.Equal(t, tt.expectedFound, found)
|
||||
})
|
||||
}
|
||||
}
|
||||
152
issues_test.go
152
issues_test.go
|
|
@ -1,55 +1,39 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
termbox "github.com/nsf/termbox-go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"context"
|
||||
|
||||
"github.com/peco/peco/config"
|
||||
"github.com/peco/peco/filter"
|
||||
"github.com/peco/peco/internal/keyseq"
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssue212_SanityCheck(t *testing.T) {
|
||||
state := newPeco()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go state.Run(ctx)
|
||||
defer cancel()
|
||||
|
||||
<-state.Ready()
|
||||
state, ctx := setupPecoTest(t)
|
||||
|
||||
// Check if the default layout type is honored */
|
||||
// This the main issue on 212, but while we're at it, we're just
|
||||
// going to check that all the default values are as expected
|
||||
if !assert.Equal(t, state.config.Layout, "top-down", "Default layout type should be 'top-down', got '%s'", state.config.Layout) {
|
||||
return
|
||||
}
|
||||
require.Equal(t, state.config.Layout, "top-down", "Default layout type should be 'top-down', got '%s'", state.config.Layout)
|
||||
require.Equal(t, len(state.config.Keymap), 0, "Default keymap should be empty, but got '%#v'", state.config.Keymap)
|
||||
|
||||
if !assert.Equal(t, len(state.config.Keymap), 0, "Default keymap should be empty, but got '%#v'", state.config.Keymap) {
|
||||
return
|
||||
}
|
||||
|
||||
if !assert.Equal(t, state.config.InitialMatcher, IgnoreCaseMatch, "Default matcher should be IgnoreCaseMatch, but got '%s'", state.config.InitialMatcher) {
|
||||
return
|
||||
}
|
||||
|
||||
defstyle := StyleSet{}
|
||||
defstyle := config.StyleSet{}
|
||||
defstyle.Init()
|
||||
if !assert.Equal(t, state.config.Style, defstyle, "should be default style") {
|
||||
return
|
||||
}
|
||||
|
||||
if !assert.Equal(t, state.config.Prompt, "QUERY>", "Default prompt should be 'QUERY>', but got '%s'", state.config.Prompt) {
|
||||
return
|
||||
}
|
||||
require.Equal(t, state.config.Style, defstyle, "should be default style")
|
||||
require.Equal(t, state.config.Prompt, "QUERY>", "Default prompt should be 'QUERY>', but got '%s'", state.config.Prompt)
|
||||
|
||||
// Okay, this time create a dummy config file, and read that in
|
||||
f, err := ioutil.TempFile("", "peco-test-config")
|
||||
if !assert.NoError(t, err, "Failed to create temporary config file: %s", err) {
|
||||
return
|
||||
}
|
||||
f, err := os.CreateTemp(t.TempDir(), "peco-test-config")
|
||||
require.NoError(t, err, "Failed to create temporary config file: %s", err)
|
||||
fn := f.Name()
|
||||
defer os.Remove(fn)
|
||||
|
||||
|
|
@ -63,12 +47,8 @@ func TestIssue212_SanityCheck(t *testing.T) {
|
|||
|
||||
<-state.Ready()
|
||||
|
||||
if !assert.NoError(t, state.config.ReadFilename(fn), "Failed to read config: %s", err) {
|
||||
return
|
||||
}
|
||||
if !assert.Equal(t, state.config.Layout, "bottom-up", "Default layout type should be 'bottom-up', got '%s'", state.config.Layout) {
|
||||
return
|
||||
}
|
||||
require.NoError(t, state.config.ReadFilename(fn), "Failed to read config: %s", err)
|
||||
require.Equal(t, state.config.Layout, "bottom-up", "Default layout type should be 'bottom-up', got '%s'", state.config.Layout)
|
||||
}
|
||||
|
||||
func TestIssue345(t *testing.T) {
|
||||
|
|
@ -83,16 +63,12 @@ func TestIssue345(t *testing.T) {
|
|||
]
|
||||
}
|
||||
}`)
|
||||
if !assert.NoError(t, err, "newConfig should succeed") {
|
||||
return
|
||||
}
|
||||
require.NoError(t, err, "newConfig should succeed")
|
||||
defer os.Remove(cfg)
|
||||
|
||||
state := newPeco()
|
||||
state.skipReadConfig = false
|
||||
if !assert.NoError(t, state.config.Init(), "Config.Init should succeed") {
|
||||
return
|
||||
}
|
||||
state.configReader = defaultConfigReader
|
||||
require.NoError(t, state.config.Init(), "Config.Init should succeed")
|
||||
|
||||
state.Argv = append(state.Argv, []string{"--rcfile", cfg}...)
|
||||
|
||||
|
|
@ -102,14 +78,84 @@ func TestIssue345(t *testing.T) {
|
|||
|
||||
<-state.Ready()
|
||||
|
||||
ev := termbox.Event{
|
||||
Type: termbox.EventKey,
|
||||
Key: termbox.KeyCtrlT,
|
||||
}
|
||||
if !assert.NoError(t, state.Keymap().ExecuteAction(ctx, state, ev), "ExecuteAction should succeed") {
|
||||
return
|
||||
ev := Event{
|
||||
Type: EventKey,
|
||||
Key: keyseq.KeyCtrlT,
|
||||
}
|
||||
require.NoError(t, state.Keymap().ExecuteAction(ctx, state, ev), "ExecuteAction should succeed")
|
||||
|
||||
time.Sleep(time.Second)
|
||||
|
||||
// Brief pause to let async hub messages from the combined action
|
||||
// be processed before context cancellation tears down goroutines.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
// TestIssue557_FilterBufSize verifies that configuring FilterBufSize allows
|
||||
// FuzzyLongestSort to correctly sort results across what would otherwise be
|
||||
// separate chunks. With the default buf size of 1000, lines are batched into
|
||||
// 1000-line chunks and sorting only happens within each chunk. An exact match
|
||||
// in chunk 2 would always appear after all chunk 1 results.
|
||||
func TestIssue557_FilterBufSize(t *testing.T) {
|
||||
const totalLines = 1100
|
||||
const query = "exact"
|
||||
|
||||
// Build lines: 1 partial match, then 1098 non-matching lines, then 1 exact match.
|
||||
// The exact match ("exact") is at index 1099, which with the default buf size of
|
||||
// 1000 would land in the second chunk.
|
||||
var lines []line.Line
|
||||
lines = append(lines, line.NewRaw(0, "e_x_a_c_t filler text", false, false)) // fuzzy match
|
||||
for i := 1; i < totalLines-1; i++ {
|
||||
lines = append(lines, line.NewRaw(uint64(i), fmt.Sprintf("no match line %d", i), false, false))
|
||||
}
|
||||
lines = append(lines, line.NewRaw(uint64(totalLines-1), "exact", false, false)) // exact match, best result
|
||||
|
||||
f := filter.NewFuzzy(true)
|
||||
|
||||
// Use a configBufSize large enough to hold all lines in a single batch.
|
||||
// This ensures the fuzzy filter sees all lines at once and can sort globally.
|
||||
configBufSize := totalLines + 100
|
||||
fp := newFilterProcessor(f, query, configBufSize, nil)
|
||||
|
||||
// Set up pipeline: source -> filterProcessor -> destination
|
||||
p := pipeline.New()
|
||||
|
||||
src := &sliceSource{lines: lines}
|
||||
p.SetSource(src)
|
||||
p.Add(fp)
|
||||
|
||||
dst := NewMemoryBuffer(0)
|
||||
p.SetDestination(dst)
|
||||
|
||||
ctx := f.NewContext(context.Background(), query)
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := p.Run(ctx)
|
||||
require.NoError(t, err, "pipeline.Run should succeed")
|
||||
|
||||
require.True(t, dst.Size() >= 2, "expected at least 2 matched lines, got %d", dst.Size())
|
||||
|
||||
// The first result should be the exact match "exact" since it has the
|
||||
// longest continuous match and shortest line length.
|
||||
first, err := dst.LineAt(0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "exact", first.DisplayString(), "best match should be first")
|
||||
}
|
||||
|
||||
// sliceSource is a simple pipeline.Source backed by a slice of lines.
|
||||
type sliceSource struct {
|
||||
lines []line.Line
|
||||
}
|
||||
|
||||
func (s *sliceSource) Start(ctx context.Context, out pipeline.ChanOutput) {
|
||||
defer close(out)
|
||||
for _, l := range s.lines {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
out.Send(ctx, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sliceSource) Reset() {}
|
||||
|
|
|
|||
87
keymap.go
87
keymap.go
|
|
@ -1,17 +1,35 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat/go-pdebug"
|
||||
"github.com/nsf/termbox-go"
|
||||
"github.com/lestrrat-go/pdebug"
|
||||
"github.com/peco/peco/internal/keyseq"
|
||||
"github.com/pkg/errors"
|
||||
"context"
|
||||
)
|
||||
|
||||
// Keyseq is the interface for key sequence matching engines.
|
||||
type Keyseq interface {
|
||||
Add(keyseq.KeyList, any)
|
||||
AcceptKey(keyseq.Key) (any, error)
|
||||
CancelChain()
|
||||
Clear()
|
||||
Compile() error
|
||||
InMiddleOfChain() bool
|
||||
}
|
||||
|
||||
// Keymap holds all the key sequence to action map
|
||||
type Keymap struct {
|
||||
Config map[string]string
|
||||
Action map[string][]string // custom actions
|
||||
seq Keyseq
|
||||
}
|
||||
|
||||
// NewKeymap creates a new Keymap struct
|
||||
func NewKeymap(config map[string]string, actions map[string][]string) Keymap {
|
||||
return Keymap{
|
||||
|
|
@ -25,9 +43,8 @@ func (km Keymap) Sequence() Keyseq {
|
|||
return km.seq
|
||||
}
|
||||
|
||||
const isTopLevelActionCall = "peco.isTopLevelActionCall"
|
||||
|
||||
func (km Keymap) ExecuteAction(ctx context.Context, state *Peco, ev termbox.Event) (err error) {
|
||||
// ExecuteAction looks up and executes the action(s) bound to the given key event.
|
||||
func (km Keymap) ExecuteAction(ctx context.Context, state *Peco, ev Event) (err error) {
|
||||
if pdebug.Enabled {
|
||||
g := pdebug.Marker("Keymap.ExecuteAction %v", ev).BindError(&err)
|
||||
defer g.End()
|
||||
|
|
@ -38,20 +55,14 @@ func (km Keymap) ExecuteAction(ctx context.Context, state *Peco, ev termbox.Even
|
|||
return errors.New("action not found")
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, isTopLevelActionCall, true)
|
||||
a.Execute(ctx, state, ev)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LookupAction returns the appropriate action for the given termbox event
|
||||
func (km Keymap) LookupAction(ev termbox.Event) Action {
|
||||
modifier := keyseq.ModNone
|
||||
if (ev.Mod & termbox.ModAlt) != 0 {
|
||||
modifier = keyseq.ModAlt
|
||||
}
|
||||
|
||||
// LookupAction returns the appropriate action for the given event
|
||||
func (km Keymap) LookupAction(ev Event) Action {
|
||||
key := keyseq.Key{
|
||||
Modifier: modifier,
|
||||
Modifier: ev.Mod,
|
||||
Key: ev.Key,
|
||||
Ch: ev.Ch,
|
||||
}
|
||||
|
|
@ -63,7 +74,11 @@ func (km Keymap) LookupAction(ev termbox.Event) Action {
|
|||
if pdebug.Enabled {
|
||||
pdebug.Printf("Keymap.Handler: Fetched action")
|
||||
}
|
||||
return wrapClearSequence(action.(Action))
|
||||
a, ok := action.(Action)
|
||||
if !ok {
|
||||
return ActionFunc(doNothing)
|
||||
}
|
||||
return wrapClearSequence(a)
|
||||
case keyseq.ErrInSequence:
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("Keymap.Handler: Waiting for more commands...")
|
||||
|
|
@ -77,27 +92,29 @@ func (km Keymap) LookupAction(ev termbox.Event) Action {
|
|||
}
|
||||
}
|
||||
|
||||
// wrapRememberSequence wraps an action to record the key press as part of a multi-key sequence.
|
||||
func wrapRememberSequence(a Action) Action {
|
||||
return ActionFunc(func(ctx context.Context, state *Peco, ev termbox.Event) {
|
||||
if s, err := keyseq.EventToString(ev); err == nil {
|
||||
return ActionFunc(func(ctx context.Context, state *Peco, ev Event) {
|
||||
if s, err := keyseq.KeyEventToString(ev.Key, ev.Ch, ev.Mod); err == nil {
|
||||
seq := state.Inputseq()
|
||||
seq.Add(s)
|
||||
state.Hub().SendStatusMsg(strings.Join(seq.KeyNames(), " "))
|
||||
state.Hub().SendStatusMsg(ctx, strings.Join(seq.KeyNames(), " "), 0)
|
||||
}
|
||||
a.Execute(ctx, state, ev)
|
||||
})
|
||||
}
|
||||
|
||||
// wrapClearSequence wraps an action to clear the accumulated key sequence after execution.
|
||||
func wrapClearSequence(a Action) Action {
|
||||
return ActionFunc(func(ctx context.Context, state *Peco, ev termbox.Event) {
|
||||
return ActionFunc(func(ctx context.Context, state *Peco, ev Event) {
|
||||
seq := state.Inputseq()
|
||||
if s, err := keyseq.EventToString(ev); err == nil {
|
||||
if s, err := keyseq.KeyEventToString(ev.Key, ev.Ch, ev.Mod); err == nil {
|
||||
seq.Add(s)
|
||||
}
|
||||
|
||||
if seq.Len() > 0 {
|
||||
msg := strings.Join(seq.KeyNames(), " ")
|
||||
state.Hub().SendStatusMsgAndClear(msg, 500*time.Millisecond)
|
||||
state.Hub().SendStatusMsg(ctx, msg, 500*time.Millisecond)
|
||||
seq.Reset()
|
||||
}
|
||||
|
||||
|
|
@ -107,9 +124,10 @@ func wrapClearSequence(a Action) Action {
|
|||
|
||||
const maxResolveActionDepth = 100
|
||||
|
||||
// resolveActionName maps a string action name from config to the corresponding action function.
|
||||
func (km Keymap) resolveActionName(name string, depth int) (Action, error) {
|
||||
if depth >= maxResolveActionDepth {
|
||||
return nil, errors.Errorf("could not resolve %s: deep recursion", name)
|
||||
return nil, fmt.Errorf("could not resolve %s: deep recursion", name)
|
||||
}
|
||||
|
||||
// Can it be resolved via regular nameToActions ?
|
||||
|
|
@ -130,11 +148,10 @@ func (km Keymap) resolveActionName(name string, depth int) (Action, error) {
|
|||
actions = append(actions, child)
|
||||
}
|
||||
v = makeCombinedAction(actions...)
|
||||
nameToActions[name] = v
|
||||
return v, nil
|
||||
}
|
||||
|
||||
return nil, errors.Errorf("could not resolve %s: no such action", name)
|
||||
return nil, fmt.Errorf("could not resolve %s: no such action", name)
|
||||
}
|
||||
|
||||
// ApplyKeybinding applies all of the custom key bindings on top of
|
||||
|
|
@ -145,9 +162,7 @@ func (km *Keymap) ApplyKeybinding() error {
|
|||
|
||||
// Copy the map
|
||||
kb := map[string]Action{}
|
||||
for s, a := range defaultKeyBinding {
|
||||
kb[s] = a
|
||||
}
|
||||
maps.Copy(kb, defaultKeyBinding)
|
||||
|
||||
// munge the map using config
|
||||
for s, as := range km.Config {
|
||||
|
|
@ -158,7 +173,7 @@ func (km *Keymap) ApplyKeybinding() error {
|
|||
|
||||
v, err := km.resolveActionName(as, 0)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to resolve action name %s", as)
|
||||
return fmt.Errorf("failed to resolve action name %s: %w", as, err)
|
||||
}
|
||||
kb[s] = v
|
||||
}
|
||||
|
|
@ -176,16 +191,14 @@ func (km *Keymap) ApplyKeybinding() error {
|
|||
a := kb[s]
|
||||
list, err := keyseq.ToKeyList(s)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "urnknown key %s: %s", s, err)
|
||||
return fmt.Errorf("unknown key %s: %w", s, err)
|
||||
}
|
||||
|
||||
k.Add(list, a)
|
||||
}
|
||||
|
||||
return errors.Wrap(k.Compile(), "failed to compile key binding patterns")
|
||||
}
|
||||
|
||||
// TODO: this needs to be fixed.
|
||||
func (km Keymap) hasModifierMaps() bool {
|
||||
return false
|
||||
if err := k.Compile(); err != nil {
|
||||
return fmt.Errorf("failed to compile key binding patterns: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
5
layout_any.go
Normal file
5
layout_any.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//go:build !windows
|
||||
|
||||
package peco
|
||||
|
||||
const extraOffset = 0
|
||||
476
layout_test.go
476
layout_test.go
|
|
@ -5,27 +5,27 @@ import (
|
|||
"unicode/utf8"
|
||||
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/nsf/termbox-go"
|
||||
"github.com/peco/peco/config"
|
||||
"github.com/peco/peco/filter"
|
||||
"github.com/peco/peco/hub"
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLayoutType(t *testing.T) {
|
||||
layouts := []struct {
|
||||
value LayoutType
|
||||
value string
|
||||
expectOK bool
|
||||
}{
|
||||
{LayoutTypeTopDown, true},
|
||||
{LayoutTypeBottomUp, true},
|
||||
{config.LayoutTypeTopDown, true},
|
||||
{config.LayoutTypeBottomUp, true},
|
||||
{config.LayoutTypeTopDownQueryBottom, true},
|
||||
{"foobar", false},
|
||||
}
|
||||
for _, l := range layouts {
|
||||
valid := IsValidLayoutType(l.value)
|
||||
if valid != l.expectOK {
|
||||
t.Errorf("LayoutType %s, expected IsValidLayoutType to return %t, but got %t",
|
||||
l.value,
|
||||
l.expectOK,
|
||||
valid,
|
||||
)
|
||||
}
|
||||
valid := config.IsValidLayoutType(l.value)
|
||||
require.Equal(t, l.expectOK, valid,
|
||||
"IsValidLayoutType(%s)", l.value)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -34,25 +34,21 @@ func TestPrintScreen(t *testing.T) {
|
|||
|
||||
makeVerifier := func(initX, initY int, fill bool) func(string) {
|
||||
return func(msg string) {
|
||||
screen.interceptor.reset()
|
||||
screen.reset()
|
||||
t.Logf("Checking printScreen(%d, %d, %s, %t)", initX, initY, msg, fill)
|
||||
width := utf8.RuneCountInString(msg)
|
||||
screen.Print(PrintArgs{
|
||||
X: initX,
|
||||
Y: initY,
|
||||
Fg: termbox.ColorDefault,
|
||||
Bg: termbox.ColorDefault,
|
||||
Fg: config.ColorDefault,
|
||||
Bg: config.ColorDefault,
|
||||
Msg: msg,
|
||||
Fill: fill,
|
||||
})
|
||||
events := screen.interceptor.events["SetCell"]
|
||||
events := screen.events["SetCell"]
|
||||
if !fill {
|
||||
if len(events) != width {
|
||||
t.Errorf("Expected %d SetCell events, got %d",
|
||||
width,
|
||||
len(events),
|
||||
)
|
||||
}
|
||||
require.Equal(t, width, len(events),
|
||||
"SetCell event count for %q (fill=false)", msg)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -61,13 +57,8 @@ func TestPrintScreen(t *testing.T) {
|
|||
if rw := runewidth.StringWidth(msg); rw != width {
|
||||
w -= rw - width
|
||||
}
|
||||
if len(events) != w {
|
||||
t.Errorf("Expected %d SetCell events, got %d",
|
||||
w,
|
||||
len(events),
|
||||
)
|
||||
return
|
||||
}
|
||||
require.Equal(t, w, len(events),
|
||||
"SetCell event count for %q (fill=true)", msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,20 +71,27 @@ func TestPrintScreen(t *testing.T) {
|
|||
verify("日本語")
|
||||
}
|
||||
|
||||
func TestStatusBar(t *testing.T) {
|
||||
func TestScreenStatusBar(t *testing.T) {
|
||||
screen := NewDummyScreen()
|
||||
st := NewStatusBar(screen, AnchorBottom, 0, NewStyleSet())
|
||||
st, err := newScreenStatusBar(screen, AnchorBottom, 0, config.NewStyleSet())
|
||||
require.NoError(t, err)
|
||||
st.PrintStatus("Hello, World!", 0)
|
||||
|
||||
events := screen.interceptor.events
|
||||
if l := len(events["Flush"]); l != 1 {
|
||||
t.Errorf("Expected 1 Flush event, got %d", l)
|
||||
return
|
||||
}
|
||||
events := screen.events
|
||||
require.Equal(t, 1, len(events["Flush"]), "Flush event count")
|
||||
}
|
||||
|
||||
func TestNullStatusBar(t *testing.T) {
|
||||
screen := NewDummyScreen()
|
||||
var st StatusBar = nullStatusBar{}
|
||||
st.PrintStatus("Hello, World!", 0)
|
||||
|
||||
events := screen.events
|
||||
require.Equal(t, 0, len(events["Flush"]), "nullStatusBar should not Flush")
|
||||
}
|
||||
|
||||
func TestMergeAttribute(t *testing.T) {
|
||||
colors := stringToFg
|
||||
colors := config.StringToFg
|
||||
|
||||
// merge colors
|
||||
tests := [][]string{
|
||||
|
|
@ -109,21 +107,407 @@ func TestMergeAttribute(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, c := range tests {
|
||||
if m := mergeAttribute(colors[c[0]], colors[c[1]]); m != colors[c[2]] {
|
||||
t.Errorf("(%s + %s) expected %d(%s), got %d", c[0], c[1], colors[c[2]], c[2], m)
|
||||
}
|
||||
m := mergeAttribute(colors[c[0]], colors[c[1]])
|
||||
require.Equal(t, colors[c[2]], m, "(%s + %s) should be %s", c[0], c[1], c[2])
|
||||
}
|
||||
|
||||
// merge with white
|
||||
for _, c := range colors {
|
||||
if m := mergeAttribute(c, colors["white"]); m != colors["white"] {
|
||||
t.Errorf("expected white(%d), got %d", colors["white"], m)
|
||||
}
|
||||
for name, c := range colors {
|
||||
m := mergeAttribute(c, colors["white"])
|
||||
require.Equal(t, colors["white"], m, "%s + white should be white", name)
|
||||
}
|
||||
|
||||
// merge attributes
|
||||
if m := mergeAttribute(termbox.AttrBold|colors["red"], termbox.AttrUnderline|colors["cyan"]); m != termbox.AttrBold|termbox.AttrUnderline|colors["white"] {
|
||||
t.Errorf("expected %d, got %d", termbox.AttrBold|termbox.AttrUnderline|colors["white"], m)
|
||||
m := mergeAttribute(config.AttrBold|colors["red"], config.AttrUnderline|colors["cyan"])
|
||||
require.Equal(t, config.AttrBold|config.AttrUnderline|colors["white"], m)
|
||||
}
|
||||
|
||||
// TestGHIssue294_PromptStyleUsedForPromptPrefix verifies that UserPrompt.Draw
|
||||
// uses the Prompt style (not Basic) when rendering the prompt prefix string.
|
||||
func TestGHIssue294_PromptStyleUsedForPromptPrefix(t *testing.T) {
|
||||
styles := config.NewStyleSet()
|
||||
styles.Prompt.Fg = config.ColorGreen | config.AttrBold
|
||||
styles.Prompt.Bg = config.ColorBlue
|
||||
// Make sure Basic is different so we can distinguish them.
|
||||
styles.Basic.Fg = config.ColorDefault
|
||||
styles.Basic.Bg = config.ColorDefault
|
||||
|
||||
screen := NewDummyScreen()
|
||||
prompt, err := NewUserPrompt(screen, AnchorTop, 0, "QUERY>", styles)
|
||||
require.NoError(t, err)
|
||||
|
||||
state := New()
|
||||
state.screen = screen
|
||||
|
||||
state.Filters().Add(filter.NewIgnoreCase())
|
||||
|
||||
prompt.Draw(state)
|
||||
|
||||
// Collect SetCell events for y=0 (the prompt row).
|
||||
events := screen.events["SetCell"]
|
||||
|
||||
promptStr := "QUERY>"
|
||||
promptLen := len(promptStr)
|
||||
require.True(t, len(events) >= promptLen,
|
||||
"expected at least %d SetCell events, got %d", promptLen, len(events))
|
||||
|
||||
// The first promptLen cells should use the Prompt style colors.
|
||||
for i := range promptLen {
|
||||
ev := events[i]
|
||||
x := ev[0].(int)
|
||||
ch := ev[2].(rune)
|
||||
fg := ev[3].(config.Attribute)
|
||||
bg := ev[4].(config.Attribute)
|
||||
|
||||
require.Equal(t, i, x, "expected x=%d", i)
|
||||
require.Equal(t, rune(promptStr[i]), ch, "expected character %c at position %d", promptStr[i], i)
|
||||
require.Equal(t, styles.Prompt.Fg, fg,
|
||||
"cell at x=%d should use Prompt.Fg, got %v", i, fg)
|
||||
require.Equal(t, styles.Prompt.Bg, bg,
|
||||
"cell at x=%d should use Prompt.Bg, got %v", i, bg)
|
||||
}
|
||||
|
||||
// The cells after the prompt should NOT use the Prompt style —
|
||||
// they should use the Query style (for the query text area).
|
||||
if len(events) > promptLen {
|
||||
ev := events[promptLen]
|
||||
fg := ev[3].(config.Attribute)
|
||||
require.NotEqual(t, styles.Prompt.Fg, fg,
|
||||
"cell after prompt should not use Prompt style")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGHIssue460_MatchedStyleDoesNotBleedToEndOfLine verifies that matched
|
||||
// text highlighting in ListArea.Draw does not extend to the screen edge.
|
||||
func TestGHIssue460_MatchedStyleDoesNotBleedToEndOfLine(t *testing.T) {
|
||||
// Use a distinct Matched.Bg so we can detect it in SetCell events.
|
||||
styles := config.NewStyleSet()
|
||||
styles.Matched.Bg = config.ColorBlue
|
||||
|
||||
matchedBg := mergeAttribute(styles.Basic.Bg, styles.Matched.Bg) // config.ColorBlue
|
||||
basicBg := styles.Basic.Bg // config.ColorDefault
|
||||
|
||||
// Helper: set up a Peco state with one matched line and draw it,
|
||||
// returning the SetCell events for the line's row (y=0).
|
||||
drawAndCollect := func(t *testing.T, text string, matches [][]int) []interceptorArgs {
|
||||
t.Helper()
|
||||
|
||||
screen := NewDummyScreen()
|
||||
listArea, err := NewListArea(screen, AnchorTop, 0, true, styles)
|
||||
require.NoError(t, err)
|
||||
|
||||
state := New()
|
||||
state.screen = screen
|
||||
|
||||
mb := NewMemoryBuffer(0)
|
||||
raw := line.NewRaw(0, text, false, false)
|
||||
matched := line.NewMatched(raw, matches)
|
||||
mb.lines = append(mb.lines, matched)
|
||||
// Add a second line so we can set the cursor on it,
|
||||
// keeping line 0 in Basic (non-selected) style.
|
||||
mb.lines = append(mb.lines, line.NewRaw(1, "other", false, false))
|
||||
state.currentLineBuffer = mb
|
||||
|
||||
loc := state.Location()
|
||||
loc.SetPage(1)
|
||||
loc.SetPerPage(10)
|
||||
loc.SetLineNumber(1) // select line 1, so line 0 uses Basic style
|
||||
|
||||
listArea.Draw(state, nil, 10, &hub.DrawOptions{DisableCache: true})
|
||||
|
||||
// Collect SetCell events for y=0 (our matched line).
|
||||
var row []interceptorArgs
|
||||
for _, ev := range screen.events["SetCell"] {
|
||||
if ev[1].(int) == 0 {
|
||||
row = append(row, ev)
|
||||
}
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
t.Run("match at end of line", func(t *testing.T) {
|
||||
// "hello world" with "world" matched at end [6,11].
|
||||
row := drawAndCollect(t, "hello world", [][]int{{6, 11}})
|
||||
|
||||
screenWidth := 80
|
||||
require.Equal(t, screenWidth, len(row),
|
||||
"expected SetCell events to cover the full screen width")
|
||||
|
||||
for _, ev := range row {
|
||||
x := ev[0].(int)
|
||||
bg := ev[4].(config.Attribute)
|
||||
if x >= 6 && x <= 10 {
|
||||
require.Equal(t, matchedBg, bg,
|
||||
"cell at x=%d should have matched bg", x)
|
||||
} else {
|
||||
require.Equal(t, basicBg, bg,
|
||||
"cell at x=%d should have basic bg, not matched bg", x)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("match in middle of line", func(t *testing.T) {
|
||||
// "hello world, goodbye" with "world" matched at [6,11].
|
||||
row := drawAndCollect(t, "hello world, goodbye", [][]int{{6, 11}})
|
||||
|
||||
screenWidth := 80
|
||||
require.Equal(t, screenWidth, len(row),
|
||||
"expected SetCell events to cover the full screen width")
|
||||
|
||||
for _, ev := range row {
|
||||
x := ev[0].(int)
|
||||
bg := ev[4].(config.Attribute)
|
||||
if x >= 6 && x <= 10 {
|
||||
require.Equal(t, matchedBg, bg,
|
||||
"cell at x=%d should have matched bg", x)
|
||||
} else {
|
||||
require.Equal(t, basicBg, bg,
|
||||
"cell at x=%d should have basic bg, not matched bg", x)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestGHIssue455_DrawScreenForceSync verifies that BasicLayout.DrawScreen
|
||||
// calls Sync() (full redraw) instead of Flush() (differential) when
|
||||
// DrawOptions.ForceSync is true.
|
||||
func TestGHIssue455_DrawScreenForceSync(t *testing.T) {
|
||||
setupState := func(t *testing.T) (*Peco, *SimScreen) {
|
||||
t.Helper()
|
||||
|
||||
screen := NewDummyScreen()
|
||||
state := New()
|
||||
state.screen = screen
|
||||
state.Filters().Add(filter.NewIgnoreCase())
|
||||
|
||||
mb := NewMemoryBuffer(0)
|
||||
mb.lines = append(mb.lines, line.NewRaw(0, "line one", false, false))
|
||||
state.currentLineBuffer = mb
|
||||
|
||||
loc := state.Location()
|
||||
loc.SetPage(1)
|
||||
loc.SetPerPage(10)
|
||||
loc.SetLineNumber(0)
|
||||
|
||||
return state, screen
|
||||
}
|
||||
|
||||
t.Run("ForceSync true calls Sync instead of final Flush", func(t *testing.T) {
|
||||
state, screen := setupState(t)
|
||||
layout, err := DefaultLayout(state)
|
||||
require.NoError(t, err)
|
||||
|
||||
screen.reset()
|
||||
layout.DrawScreen(state, &hub.DrawOptions{DisableCache: true, ForceSync: true})
|
||||
|
||||
syncEvents := screen.events["Sync"]
|
||||
flushEvents := screen.events["Flush"]
|
||||
|
||||
require.Len(t, syncEvents, 1, "expected exactly 1 Sync call")
|
||||
// DrawPrompt internally calls Flush, but the final DrawScreen
|
||||
// Flush should be replaced by Sync.
|
||||
for i, ev := range screen.events["Flush"] {
|
||||
t.Logf("Flush event %d: %v", i, ev)
|
||||
}
|
||||
for i, ev := range screen.events["Sync"] {
|
||||
t.Logf("Sync event %d: %v", i, ev)
|
||||
}
|
||||
// The prompt's Flush still fires, but the final screen Flush
|
||||
// is replaced by Sync. So Flush count should be 1 less than
|
||||
// the non-ForceSync case.
|
||||
flushCountWithSync := len(flushEvents)
|
||||
|
||||
// Compare against the non-ForceSync case
|
||||
screen.reset()
|
||||
layout.DrawScreen(state, &hub.DrawOptions{DisableCache: true, ForceSync: false})
|
||||
flushCountWithout := len(screen.events["Flush"])
|
||||
|
||||
require.Equal(t, flushCountWithout-1, flushCountWithSync,
|
||||
"ForceSync should replace exactly one Flush call with Sync")
|
||||
})
|
||||
|
||||
t.Run("ForceSync false does not call Sync", func(t *testing.T) {
|
||||
state, screen := setupState(t)
|
||||
layout, err := DefaultLayout(state)
|
||||
require.NoError(t, err)
|
||||
|
||||
screen.reset()
|
||||
layout.DrawScreen(state, &hub.DrawOptions{DisableCache: true, ForceSync: false})
|
||||
|
||||
syncEvents := screen.events["Sync"]
|
||||
require.Empty(t, syncEvents, "expected no Sync calls when ForceSync is false")
|
||||
})
|
||||
|
||||
t.Run("nil options does not call Sync", func(t *testing.T) {
|
||||
state, screen := setupState(t)
|
||||
layout, err := DefaultLayout(state)
|
||||
require.NoError(t, err)
|
||||
|
||||
screen.reset()
|
||||
layout.DrawScreen(state, nil)
|
||||
|
||||
syncEvents := screen.events["Sync"]
|
||||
require.Empty(t, syncEvents, "expected no Sync calls with nil options")
|
||||
})
|
||||
}
|
||||
|
||||
// TestNewLayout verifies the layout registry returns correct layout types.
|
||||
func TestNewLayout(t *testing.T) {
|
||||
makeState := func() *Peco {
|
||||
state := New()
|
||||
state.screen = NewDummyScreen()
|
||||
state.Filters().Add(filter.NewIgnoreCase())
|
||||
return state
|
||||
}
|
||||
|
||||
t.Run("top-down", func(t *testing.T) {
|
||||
state := makeState()
|
||||
layout, err := NewLayout(config.LayoutTypeTopDown, state)
|
||||
require.NoError(t, err)
|
||||
require.True(t, layout.SortTopDown(), "top-down layout should sort top-down")
|
||||
require.Equal(t, AnchorTop, layout.prompt.anchor, "top-down prompt should be anchored at top")
|
||||
})
|
||||
|
||||
t.Run("bottom-up", func(t *testing.T) {
|
||||
state := makeState()
|
||||
layout, err := NewLayout(config.LayoutTypeBottomUp, state)
|
||||
require.NoError(t, err)
|
||||
require.False(t, layout.SortTopDown(), "bottom-up layout should not sort top-down")
|
||||
require.Equal(t, AnchorBottom, layout.prompt.anchor, "bottom-up prompt should be anchored at bottom")
|
||||
})
|
||||
|
||||
t.Run("top-down-query-bottom", func(t *testing.T) {
|
||||
state := makeState()
|
||||
layout, err := NewLayout(config.LayoutTypeTopDownQueryBottom, state)
|
||||
require.NoError(t, err)
|
||||
require.True(t, layout.SortTopDown(), "top-down-query-bottom layout should sort top-down")
|
||||
require.Equal(t, AnchorBottom, layout.prompt.anchor, "top-down-query-bottom prompt should be anchored at bottom")
|
||||
require.Equal(t, AnchorTop, layout.list.anchor, "top-down-query-bottom list should be anchored at top")
|
||||
})
|
||||
|
||||
t.Run("unknown falls back to top-down", func(t *testing.T) {
|
||||
state := makeState()
|
||||
layout, err := NewLayout("unknown-layout", state)
|
||||
require.NoError(t, err)
|
||||
require.True(t, layout.SortTopDown(), "fallback layout should sort top-down")
|
||||
require.Equal(t, AnchorTop, layout.prompt.anchor, "fallback prompt should be anchored at top")
|
||||
})
|
||||
}
|
||||
|
||||
// TestTopDownQueryBottomLayout verifies the specific properties of the
|
||||
// top-down-query-bottom layout.
|
||||
func TestTopDownQueryBottomLayout(t *testing.T) {
|
||||
state := New()
|
||||
state.screen = NewDummyScreen()
|
||||
|
||||
state.Filters().Add(filter.NewIgnoreCase())
|
||||
|
||||
layout, err := TopDownQueryBottomLayout(state)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, AnchorBottom, layout.prompt.anchor,
|
||||
"prompt should be anchored at bottom")
|
||||
require.Equal(t, 1+extraOffset, layout.prompt.anchorOffset,
|
||||
"prompt anchor offset should be 1+extraOffset")
|
||||
require.Equal(t, AnchorTop, layout.list.anchor,
|
||||
"list should be anchored at top")
|
||||
require.Equal(t, 0, layout.list.anchorOffset,
|
||||
"list anchor offset should be 0")
|
||||
require.True(t, layout.list.sortTopDown,
|
||||
"list should sort top-down")
|
||||
require.True(t, layout.SortTopDown(),
|
||||
"SortTopDown() should return true")
|
||||
}
|
||||
|
||||
// TestCursorStyle verifies that UserPrompt.cursorStyle returns the correct
|
||||
// fg/bg attributes depending on QueryCursor and Query style configuration.
|
||||
func TestCursorStyle(t *testing.T) {
|
||||
makePrompt := func(t *testing.T, styles *config.StyleSet) UserPrompt {
|
||||
t.Helper()
|
||||
screen := NewDummyScreen()
|
||||
p, err := NewUserPrompt(screen, AnchorTop, 0, "QUERY>", styles)
|
||||
require.NoError(t, err)
|
||||
return *p
|
||||
}
|
||||
|
||||
t.Run("both default falls back to config.AttrReverse", func(t *testing.T) {
|
||||
styles := config.NewStyleSet()
|
||||
// Query and QueryCursor are both config.ColorDefault (zero value)
|
||||
p := makePrompt(t, styles)
|
||||
fg, bg := p.cursorStyle()
|
||||
require.Equal(t, config.ColorDefault|config.AttrReverse, fg)
|
||||
require.Equal(t, config.ColorDefault|config.AttrReverse, bg)
|
||||
})
|
||||
|
||||
t.Run("Query has custom colors and QueryCursor is default swaps fg/bg", func(t *testing.T) {
|
||||
styles := config.NewStyleSet()
|
||||
styles.Query.Fg = config.ColorYellow
|
||||
styles.Query.Bg = config.ColorBlue
|
||||
p := makePrompt(t, styles)
|
||||
fg, bg := p.cursorStyle()
|
||||
require.Equal(t, config.ColorBlue, fg, "should use Query.Bg as cursor fg")
|
||||
require.Equal(t, config.ColorYellow, bg, "should use Query.Fg as cursor bg")
|
||||
})
|
||||
|
||||
t.Run("QueryCursor explicitly set takes precedence", func(t *testing.T) {
|
||||
styles := config.NewStyleSet()
|
||||
styles.Query.Fg = config.ColorYellow
|
||||
styles.Query.Bg = config.ColorBlue
|
||||
styles.QueryCursor.Fg = config.ColorWhite
|
||||
styles.QueryCursor.Bg = config.ColorRed
|
||||
p := makePrompt(t, styles)
|
||||
fg, bg := p.cursorStyle()
|
||||
require.Equal(t, config.ColorWhite, fg)
|
||||
require.Equal(t, config.ColorRed, bg)
|
||||
})
|
||||
|
||||
t.Run("QueryCursor with only fg set takes precedence", func(t *testing.T) {
|
||||
styles := config.NewStyleSet()
|
||||
styles.Query.Fg = config.ColorYellow
|
||||
styles.Query.Bg = config.ColorBlue
|
||||
styles.QueryCursor.Fg = config.ColorGreen
|
||||
// bg remains config.ColorDefault
|
||||
p := makePrompt(t, styles)
|
||||
fg, bg := p.cursorStyle()
|
||||
require.Equal(t, config.ColorGreen, fg)
|
||||
require.Equal(t, config.ColorDefault, bg)
|
||||
})
|
||||
|
||||
t.Run("Query has only fg set swaps correctly", func(t *testing.T) {
|
||||
styles := config.NewStyleSet()
|
||||
styles.Query.Fg = config.ColorRed
|
||||
// Query.Bg remains config.ColorDefault
|
||||
p := makePrompt(t, styles)
|
||||
fg, bg := p.cursorStyle()
|
||||
require.Equal(t, config.ColorDefault, fg, "should use Query.Bg (default) as cursor fg")
|
||||
require.Equal(t, config.ColorRed, bg, "should use Query.Fg as cursor bg")
|
||||
})
|
||||
}
|
||||
|
||||
// TestInvalidAnchorReturnsError verifies that invalid vertical anchors
|
||||
// produce errors instead of panics.
|
||||
func TestInvalidAnchorReturnsError(t *testing.T) {
|
||||
screen := NewDummyScreen()
|
||||
invalidAnchor := VerticalAnchor(99)
|
||||
|
||||
t.Run("NewAnchorSettings", func(t *testing.T) {
|
||||
_, err := NewAnchorSettings(screen, invalidAnchor, 0)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid vertical anchor")
|
||||
})
|
||||
|
||||
t.Run("NewUserPrompt", func(t *testing.T) {
|
||||
_, err := NewUserPrompt(screen, invalidAnchor, 0, "QUERY>", config.NewStyleSet())
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("NewListArea", func(t *testing.T) {
|
||||
_, err := NewListArea(screen, invalidAnchor, 0, true, config.NewStyleSet())
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("newScreenStatusBar", func(t *testing.T) {
|
||||
_, err := newScreenStatusBar(screen, invalidAnchor, 0, config.NewStyleSet())
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
package peco
|
||||
|
||||
func init() {
|
||||
// This is the value we pass to anchor offset when we're running
|
||||
// on windows platform
|
||||
extraOffset = 1
|
||||
}
|
||||
// extraOffset reserves one additional line at the bottom on Windows
|
||||
// to account for platform-specific terminal rendering behavior.
|
||||
const extraOffset = 1
|
||||
|
|
|
|||
76
line/bench_test.go
Normal file
76
line/bench_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package line
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// BenchmarkMatchedLifecycle benchmarks the create-use-release cycle for
|
||||
// Matched objects, simulating multiple keystroke filter runs. On the first
|
||||
// iteration all objects are freshly allocated; on subsequent iterations
|
||||
// they come from the pool.
|
||||
func BenchmarkMatchedLifecycle(b *testing.B) {
|
||||
const numLines = 10_000
|
||||
raws := make([]*Raw, numLines)
|
||||
for i := range raws {
|
||||
raws[i] = NewRaw(uint64(i), fmt.Sprintf("line %d content here", i), false, false)
|
||||
}
|
||||
indices := [][]int{{0, 4}, {5, 10}}
|
||||
|
||||
b.Run("pooled_GetMatched", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
// Simulate a filter run: create 10k Matched objects
|
||||
matched := make([]*Matched, numLines)
|
||||
for i, r := range raws {
|
||||
matched[i] = GetMatched(r, indices)
|
||||
}
|
||||
// Simulate Reset(): release all back to pool
|
||||
for _, m := range matched {
|
||||
ReleaseMatched(m)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("unpooled_NewMatched", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
// Simulate a filter run: create 10k Matched objects (no pooling)
|
||||
matched := make([]*Matched, numLines)
|
||||
for i, r := range raws {
|
||||
matched[i] = NewMatched(r, indices)
|
||||
}
|
||||
// No release -- objects become garbage
|
||||
_ = matched
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkNewRawAndDisplay benchmarks creating Raw lines and calling
|
||||
// DisplayString(), simulating peco's startup + render path for 10k lines.
|
||||
func BenchmarkNewRawAndDisplay(b *testing.B) {
|
||||
texts := make([]string, 10_000)
|
||||
for i := range texts {
|
||||
texts[i] = fmt.Sprintf("line %d: this is a typical plain text line without any ansi codes", i)
|
||||
}
|
||||
|
||||
b.Run("enableANSI_true_plain_text", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
for i, t := range texts {
|
||||
r := NewRaw(uint64(i), t, false, true)
|
||||
_ = r.DisplayString()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("enableANSI_false_plain_text", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
for i, t := range texts {
|
||||
r := NewRaw(uint64(i), t, false, false)
|
||||
_ = r.DisplayString()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
package line
|
||||
|
||||
import "github.com/google/btree"
|
||||
|
||||
// IDGenerator defines an interface for things that generate
|
||||
// unique IDs for lines used within peco.
|
||||
type IDGenerator interface {
|
||||
Next() uint64
|
||||
}
|
||||
|
||||
// Line represents each of the line that peco uses to display
|
||||
// and match against queries.
|
||||
type Line interface {
|
||||
btree.Item
|
||||
|
||||
ID() uint64
|
||||
|
||||
// Buffer returns the raw buffer
|
||||
Buffer() string
|
||||
|
||||
// DisplayString returns the string to be displayed. This means if you have
|
||||
// a null separator, the contents after the separator are not included
|
||||
// in this string
|
||||
DisplayString() string
|
||||
|
||||
// Output returns the string to be display as peco finishes up doing its
|
||||
// thing. This means if you have null separator, the contents before the
|
||||
// separator are not included in this string
|
||||
Output() string
|
||||
|
||||
// IsDirty returns true if this line should be forcefully redrawn
|
||||
IsDirty() bool
|
||||
|
||||
// SetDirty sets the dirty flag on or off
|
||||
SetDirty(bool)
|
||||
}
|
||||
|
||||
// Raw is the input line as sent to peco, before filtering and what not.
|
||||
type Raw struct {
|
||||
id uint64
|
||||
buf string
|
||||
sepLoc int
|
||||
displayString string
|
||||
dirty bool
|
||||
}
|
||||
|
||||
// Matched contains the indices to the matches
|
||||
type Matched struct {
|
||||
Line
|
||||
indices [][]int
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,5 +1,39 @@
|
|||
package line
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/peco/peco/internal/ansi"
|
||||
)
|
||||
|
||||
var matchedPool = sync.Pool{
|
||||
New: func() any { return &Matched{} },
|
||||
}
|
||||
|
||||
// GetMatched retrieves a Matched from the pool and initializes it.
|
||||
func GetMatched(rl Line, matches [][]int) *Matched {
|
||||
m, ok := matchedPool.Get().(*Matched)
|
||||
if !ok {
|
||||
m = &Matched{}
|
||||
}
|
||||
m.Line = rl
|
||||
m.indices = matches
|
||||
return m
|
||||
}
|
||||
|
||||
// ReleaseMatched returns a Matched to the pool after clearing its fields.
|
||||
func ReleaseMatched(m *Matched) {
|
||||
m.Line = nil
|
||||
m.indices = nil
|
||||
matchedPool.Put(m)
|
||||
}
|
||||
|
||||
// Matched contains the indices to the matches
|
||||
type Matched struct {
|
||||
Line
|
||||
indices [][]int
|
||||
}
|
||||
|
||||
// NewMatched creates a new Matched
|
||||
func NewMatched(rl Line, matches [][]int) *Matched {
|
||||
return &Matched{rl, matches}
|
||||
|
|
@ -10,3 +44,10 @@ func (ml Matched) Indices() [][]int {
|
|||
return ml.indices
|
||||
}
|
||||
|
||||
// ANSIAttrs returns the ANSI attributes from the underlying line, if available.
|
||||
func (ml Matched) ANSIAttrs() []ansi.AttrSpan {
|
||||
if r, ok := ml.Line.(*Raw); ok {
|
||||
return r.ANSIAttrs()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
28
line/matched_test.go
Normal file
28
line/matched_test.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package line
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetAndReleaseMatched(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := NewRaw(1, "hello", false, false)
|
||||
indices := [][]int{{0, 5}}
|
||||
|
||||
m := GetMatched(raw, indices)
|
||||
require.Equal(t, raw, m.Line)
|
||||
require.Equal(t, indices, m.Indices())
|
||||
require.Equal(t, "hello", m.DisplayString())
|
||||
|
||||
ReleaseMatched(m)
|
||||
require.Nil(t, m.Line)
|
||||
require.Nil(t, m.indices)
|
||||
|
||||
// Get again — should reuse the pooled object
|
||||
m2 := GetMatched(raw, nil)
|
||||
require.Equal(t, raw, m2.Line)
|
||||
require.Nil(t, m2.Indices())
|
||||
ReleaseMatched(m2)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue