mirror of
https://github.com/peco/peco.git
synced 2026-09-10 15:26:29 -04:00
Compare commits
1490 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 | ||
|
|
5b1bc48a30 | ||
|
|
96cf88f129 | ||
|
|
e8bef7854e | ||
|
|
73487ee4f0 | ||
|
|
405a729ac0 | ||
|
|
98936082f0 | ||
|
|
fa9f5d593a | ||
|
|
3308108729 | ||
|
|
94a0271706 | ||
|
|
8069f8eb2f | ||
|
|
91c93e2271 | ||
|
|
ffc148bc9e | ||
|
|
ccdf75e101 | ||
|
|
138af2c22e | ||
|
|
f92895fe26 | ||
|
|
873c8a3fbc | ||
|
|
aaa77cd3c8 | ||
|
|
1e14eea871 | ||
|
|
531849df65 | ||
|
|
f3e9250486 | ||
|
|
439ea20938 | ||
|
|
924b1041c8 | ||
|
|
c37f3a3ae8 | ||
|
|
5a94918fad | ||
|
|
8e70ee0072 | ||
|
|
97cf18213c | ||
|
|
ab72a5521f | ||
|
|
766fdd73c0 | ||
|
|
b3e1903818 | ||
|
|
ab74eda9e5 | ||
|
|
2abd71514d | ||
|
|
2aafac08c1 | ||
|
|
75d0d54da6 | ||
|
|
20fe945b21 | ||
|
|
8ca4f61967 | ||
|
|
eb3dcf5306 | ||
|
|
e670560421 | ||
|
|
482fa7fc6d | ||
|
|
a78051dcbe | ||
|
|
ecd2b35aff | ||
|
|
936910b202 | ||
|
|
3022ba26ed | ||
|
|
ee8ffa11bb | ||
|
|
bc7e5ea1f6 | ||
|
|
ed63779ea9 | ||
|
|
5a83582996 | ||
|
|
c59137c2bd | ||
|
|
ddec47bd0b | ||
|
|
8866cff58d | ||
|
|
ce93a80fde | ||
|
|
7a558401de | ||
|
|
e584efd745 | ||
|
|
a03e36f2ba | ||
|
|
2a903429e4 | ||
|
|
b94d764f51 | ||
|
|
7b9a3d1e54 | ||
|
|
6cef077bc5 | ||
|
|
ff10112452 | ||
|
|
6654b913c4 | ||
|
|
9673e19128 | ||
|
|
41ccdfe932 | ||
|
|
870e6c5767 | ||
|
|
b63b588464 | ||
|
|
eab12e589b | ||
|
|
98a6321aab | ||
|
|
c7f40e4346 | ||
|
|
6216146848 | ||
|
|
c5a069a15d | ||
|
|
6d5a2065a2 | ||
|
|
825adb2768 | ||
|
|
1279628768 | ||
|
|
8d83b77447 | ||
|
|
a379d18f4a | ||
|
|
e30265c15a | ||
|
|
2ac754b1fc | ||
|
|
003fe3127f | ||
|
|
9f1366c934 | ||
|
|
d8c7102efd | ||
|
|
08582ac4a8 | ||
|
|
6ec0a8d6b5 | ||
|
|
aa928308ca | ||
|
|
eccb83e040 | ||
|
|
0ed0d2b943 | ||
|
|
521c88abd6 | ||
|
|
e44bb17f75 | ||
|
|
b500e2f628 | ||
|
|
bac1323fe0 | ||
|
|
949166a67c | ||
|
|
47be5c7c85 | ||
|
|
80c7cde206 | ||
|
|
945cdf0b08 | ||
|
|
a98498e3e8 | ||
|
|
61b5d22fb8 | ||
|
|
b1ea3c36b1 | ||
|
|
c7eff12a67 | ||
|
|
fd87529bec | ||
|
|
9fb60e6ac3 | ||
|
|
cb95badd13 | ||
|
|
58b60cd7e3 | ||
|
|
45398301ea | ||
|
|
32a6786021 | ||
|
|
d659c93bdc | ||
|
|
668a498ed8 | ||
|
|
0ea80d7d90 | ||
|
|
3476752a12 | ||
|
|
2dbfd78205 | ||
|
|
bfb3ae2fc1 | ||
|
|
99f30cad6c | ||
|
|
c00f0d2975 | ||
|
|
1fb6e3b1b7 | ||
|
|
21179c2c38 | ||
|
|
4c4b4535ed | ||
|
|
f5dc16fe62 | ||
|
|
22837a443d | ||
|
|
a6e7a042c6 | ||
|
|
e432146891 | ||
|
|
296aceffbb | ||
|
|
fbeaa7d329 | ||
|
|
3acd28bf60 | ||
|
|
c2f781cb40 | ||
|
|
12fed927a1 | ||
|
|
9f536a6f4a | ||
|
|
0a025f1b8b | ||
|
|
837714dd75 | ||
|
|
ef5ed5f8eb | ||
|
|
470141f3f0 | ||
|
|
11836ff1a8 | ||
|
|
3ee675edcf | ||
|
|
0d4fbc0187 | ||
|
|
97bb22b6e1 | ||
|
|
8acd3b1250 | ||
|
|
72b05ececd | ||
|
|
5ad53b9f30 | ||
|
|
c8a43049f3 | ||
|
|
82a9d29697 | ||
|
|
bf00034e15 | ||
|
|
e1f82e731a | ||
|
|
138f34ba14 | ||
|
|
e95bcd4651 | ||
|
|
43d4f27119 | ||
|
|
e40c0acde1 | ||
|
|
4a563d7f3a | ||
|
|
c29ba5a856 | ||
|
|
72746ab553 | ||
|
|
93068da71f | ||
|
|
d4f7705129 | ||
|
|
1aceada5ae | ||
|
|
d6a5ab3dd4 | ||
|
|
f25f6e288c | ||
|
|
715d5b2a65 | ||
|
|
1c6f10a020 | ||
|
|
8b384d914e | ||
|
|
9ef6bb29a1 | ||
|
|
948a92a4db | ||
|
|
e3d8928df5 | ||
|
|
f1a3549dfa | ||
|
|
987e844ba2 | ||
|
|
25d942b08b | ||
|
|
08c244ef16 | ||
|
|
8a162fc2b5 | ||
|
|
b57305c0ea | ||
|
|
bf689d4255 | ||
|
|
9bea557510 | ||
|
|
e859398f0f | ||
|
|
559e3aa2fb | ||
|
|
94c0dc650a | ||
|
|
2c735b9f29 | ||
|
|
67eb241d83 | ||
|
|
da8d11008d | ||
|
|
9f1c8add77 | ||
|
|
396b821eb8 | ||
|
|
812b65f92c | ||
|
|
a40af8432a | ||
|
|
287f180b4b | ||
|
|
54bafeffa9 | ||
|
|
58cd0c9d81 | ||
|
|
52dfaa6189 | ||
|
|
821af182ab | ||
|
|
183a48b98d | ||
|
|
8d3daa3108 | ||
|
|
e73acf3488 | ||
|
|
aa4065aa62 | ||
|
|
0bad067fb9 | ||
|
|
32f0db1708 | ||
|
|
76d55c2147 | ||
|
|
1a098f25a0 | ||
|
|
7851980a11 | ||
|
|
0623bdb817 | ||
|
|
1340799367 | ||
|
|
87b4ad48a0 | ||
|
|
e0d2a50377 | ||
|
|
02c90c13d4 | ||
|
|
e8759162c8 | ||
|
|
79556d5ad1 | ||
|
|
622671ef4d | ||
|
|
f578cfc850 | ||
|
|
36daafbb18 | ||
|
|
497573e89a | ||
|
|
19ff36e276 | ||
|
|
405da08b8f | ||
|
|
7f70d73996 | ||
|
|
b14cd4955b | ||
|
|
07a22aa466 | ||
|
|
69b057ec47 | ||
|
|
9018aa45c6 | ||
|
|
d8e5bcb349 | ||
|
|
1c04e4ec94 | ||
|
|
bdcd74b2eb | ||
|
|
7499972766 | ||
|
|
94b85ea149 | ||
|
|
41cf51ccf6 | ||
|
|
ae98d7825d | ||
|
|
858c2e327e | ||
|
|
7d9e87304a | ||
|
|
aaebbf9d05 | ||
|
|
3b98bf9bde | ||
|
|
47ea4f3067 | ||
|
|
215572ff58 | ||
|
|
9cbe49c72d | ||
|
|
a6589820d0 | ||
|
|
5d265d60e9 | ||
|
|
f4ea32e0e3 | ||
|
|
ca7a273d98 | ||
|
|
464919f631 | ||
|
|
877a1254ff | ||
|
|
eada702831 | ||
|
|
e261219f4b | ||
|
|
e32b419df9 | ||
|
|
8d89338eb2 | ||
|
|
f09391251d | ||
|
|
0ec4029c40 | ||
|
|
68e614ed53 | ||
|
|
b8d45ca2fd | ||
|
|
7e18037f43 | ||
|
|
c048ef4d03 | ||
|
|
b04024c411 | ||
|
|
a46071dc05 | ||
|
|
f3ac1361c9 | ||
|
|
6b31c60d97 | ||
|
|
432629f140 | ||
|
|
d45b3c61bb | ||
|
|
614cf1691b | ||
|
|
b5490a0982 | ||
|
|
1bacd36dce | ||
|
|
3e4e8df881 | ||
|
|
e1e64b83dc | ||
|
|
8c400c1d81 | ||
|
|
f62e125f1e | ||
|
|
187658c717 | ||
|
|
a67fe2cda7 | ||
|
|
ca5fc95ae5 | ||
|
|
b85690181f | ||
|
|
bf161fb19a | ||
|
|
f036f025a0 | ||
|
|
e4124d14de | ||
|
|
71e367bda0 | ||
|
|
1af475f0a3 | ||
|
|
215d2ad73d | ||
|
|
6cdf3346dd | ||
|
|
7e0d5599d8 | ||
|
|
2a81e71247 | ||
|
|
41083de5ca | ||
|
|
c010abbb4e | ||
|
|
dfedb3f9d1 | ||
|
|
fcc6eb3dd9 | ||
|
|
185340534c | ||
|
|
01ccb5211a | ||
|
|
6511b31674 | ||
|
|
1785e73cd5 | ||
|
|
cd1c759a91 | ||
|
|
f7351adfe8 | ||
|
|
8f93144a19 | ||
|
|
c12cd97d29 | ||
|
|
39eb717c6e | ||
|
|
39031fd1f1 | ||
|
|
bfaf856727 | ||
|
|
22d942b220 | ||
|
|
52d4e90c92 | ||
|
|
10b2b57854 | ||
|
|
fb76393f3b | ||
|
|
78d8d6a35c | ||
|
|
75b3bd33d8 | ||
|
|
37e85afcd4 | ||
|
|
4169593599 | ||
|
|
d5381eeee4 | ||
|
|
ad19096c23 | ||
|
|
3f2be2c877 | ||
|
|
e3c36875fa | ||
|
|
c24f6f0fe0 | ||
|
|
4df1abb45b | ||
|
|
8669c17406 | ||
|
|
9ef1da7a71 | ||
|
|
45343ab1cc | ||
|
|
509685f77a | ||
|
|
3e9476676e | ||
|
|
098b88c90d | ||
|
|
7a239e4592 | ||
|
|
3c615080ba | ||
|
|
c7750ac50a | ||
|
|
5269d44dff | ||
|
|
83bffcdc5f | ||
|
|
b11549010c | ||
|
|
a78e298fc6 | ||
|
|
497029fbcc | ||
|
|
503f008d14 | ||
|
|
0a3adce117 | ||
|
|
77099ef426 | ||
|
|
c19b144883 | ||
|
|
d996d054ca | ||
|
|
a8d042d93c | ||
|
|
39d36c41b8 | ||
|
|
7064a04efa | ||
|
|
a1781ee023 | ||
|
|
b1c55013e9 | ||
|
|
c15f9d2964 | ||
|
|
417be36cfb | ||
|
|
406cfe3bd7 | ||
|
|
d4715dd9bf | ||
|
|
7af0422f14 | ||
|
|
39dfde1bfe | ||
|
|
4e47502086 | ||
|
|
e8c8ed19e7 | ||
|
|
6a093bb4bf | ||
|
|
e0f624c537 | ||
|
|
ff7739dd7d | ||
|
|
894ffac027 | ||
|
|
11367c118f | ||
|
|
ee54a81ca5 | ||
|
|
142e02b100 | ||
|
|
fa2c186f23 | ||
|
|
67b86e3b21 | ||
|
|
d337de61b2 | ||
|
|
6c131ecb48 | ||
|
|
37ae3d8e6d | ||
|
|
5762e4e3f5 | ||
|
|
4537850b83 | ||
|
|
c55fa0b365 | ||
|
|
606f9245ef | ||
|
|
315dbb9c4b | ||
|
|
ffdb665aa4 | ||
|
|
c645aaa14c | ||
|
|
64a3cd2437 | ||
|
|
1ccb24df9f | ||
|
|
05ad9ca805 | ||
|
|
4fe5ea7345 | ||
|
|
9724ce6d58 | ||
|
|
f611475ce0 | ||
|
|
36a5e2a4d3 | ||
|
|
8344e86ec9 | ||
|
|
c4a47a02da | ||
|
|
afbf55afc5 | ||
|
|
a9a999cc56 | ||
|
|
c56ae510ad | ||
|
|
92937c3101 | ||
|
|
84aa894be0 | ||
|
|
c1bf3c346d | ||
|
|
7533af0043 | ||
|
|
1ec2ee3b89 | ||
|
|
ec118d1088 | ||
|
|
fb8238770c | ||
|
|
ffaca4007d | ||
|
|
562340528c | ||
|
|
c3eeb5c8af | ||
|
|
1949759916 | ||
|
|
76c145c86c | ||
|
|
9d1afd4bc1 | ||
|
|
4962dd6028 | ||
|
|
912e858a10 | ||
|
|
97728cbc21 | ||
|
|
023b519467 | ||
|
|
4bde862ac6 | ||
|
|
5f38de0d5f | ||
|
|
5d8cc56786 | ||
|
|
4d706f7374 | ||
|
|
2f7edfb9db | ||
|
|
b149089c0f | ||
|
|
d07dfc94d4 | ||
|
|
b5e11fe7c6 | ||
|
|
62fa53e053 | ||
|
|
897bcd39b0 | ||
|
|
9dff126bec | ||
|
|
b528cd7bca | ||
|
|
05bb4d771d | ||
|
|
6e867c78c6 | ||
|
|
513cbe77e9 | ||
|
|
f727919361 | ||
|
|
faed898c6c | ||
|
|
5c28501217 | ||
|
|
b94e455e12 | ||
|
|
3a7062d0ac | ||
|
|
f3d87a4b77 | ||
|
|
95c840dcb0 | ||
|
|
41b3826136 | ||
|
|
d0eeecee2e | ||
|
|
586c36a0e7 | ||
|
|
6b3b872212 | ||
|
|
cbec2ff082 | ||
|
|
350a9d69c6 | ||
|
|
64e7e6f791 | ||
|
|
f2b92f5758 | ||
|
|
5beaeb6328 | ||
|
|
fe1be27ea7 | ||
|
|
d101e12bd4 | ||
|
|
5996542fe4 | ||
|
|
62c95562ef | ||
|
|
f6226d1311 | ||
|
|
ec38f98067 | ||
|
|
be5ba39a3f | ||
|
|
258501dc09 | ||
|
|
1970c6faef | ||
|
|
32ea8979a0 | ||
|
|
b988f27433 | ||
|
|
6a4f594568 | ||
|
|
03e0c99432 | ||
|
|
f21eae1d7a | ||
|
|
ce476ccb1b | ||
|
|
b192da6fad | ||
|
|
d3f3aaa280 | ||
|
|
3178676272 | ||
|
|
5c37d64b41 | ||
|
|
ea6724c57d | ||
|
|
f30c38224c | ||
|
|
8fb7f6eb16 | ||
|
|
f2c5cf2af7 | ||
|
|
bd87550ae5 | ||
|
|
b60d54fbc9 | ||
|
|
fa02d52f66 | ||
|
|
7816985f07 | ||
|
|
90e5498667 | ||
|
|
8afaf128af | ||
|
|
62ed896220 | ||
|
|
1e9e1886a2 | ||
|
|
d36c492e50 | ||
|
|
e9a5186526 | ||
|
|
003e2b9feb | ||
|
|
55a4f7f6c5 | ||
|
|
2e77ad9d95 | ||
|
|
c0fa721551 | ||
|
|
7f54c7fc0a | ||
|
|
700b77b5ba | ||
|
|
2e469bfeb4 | ||
|
|
9b43eb3fbb | ||
|
|
5c593f8ebd | ||
|
|
26c92bb01b | ||
|
|
a3fb52de7c | ||
|
|
cf07fbeb6b | ||
|
|
31fd0b89ea | ||
|
|
27547220e8 | ||
|
|
b6be30704b | ||
|
|
c24da22065 | ||
|
|
afabacac4e | ||
|
|
7b7f373fd8 | ||
|
|
3fe24360cd | ||
|
|
20269a268d | ||
|
|
023fba7fab | ||
|
|
89805d696e | ||
|
|
16c08c6d56 | ||
|
|
9d2a16c75b | ||
|
|
64fd5b4e84 | ||
|
|
e9da5a7cfe | ||
|
|
384598864f | ||
|
|
c37cee0a99 | ||
|
|
9f53e6fc06 | ||
|
|
64fc4d12fd | ||
|
|
580fc48264 | ||
|
|
105e067172 | ||
|
|
18f6c847d9 | ||
|
|
bcba884007 | ||
|
|
af4a4c8116 | ||
|
|
08653e2afe | ||
|
|
1ee6c37ed8 | ||
|
|
7ee40fe3b0 | ||
|
|
35397b95ec | ||
|
|
960051a38c | ||
|
|
ec82b5865d | ||
|
|
db56e1859c | ||
|
|
ca7dc1b6a5 | ||
|
|
d7c6aad237 | ||
|
|
3b8334eb52 | ||
|
|
85dea5ae68 | ||
|
|
b848c6c0a2 | ||
|
|
521df7fa01 | ||
|
|
100742b9d9 | ||
|
|
541886d965 | ||
|
|
8e18b66713 | ||
|
|
b40b377813 | ||
|
|
933cc2fcea | ||
|
|
a18faef700 | ||
|
|
761b700aab | ||
|
|
737bf68f7f | ||
|
|
a22d76e331 | ||
|
|
4ec0f93f81 | ||
|
|
6de39a305c | ||
|
|
851a3405ce | ||
|
|
fe5ae10e94 | ||
|
|
f323966c77 | ||
|
|
9178b578fa | ||
|
|
8518eeb8db | ||
|
|
85902a097a | ||
|
|
233acbc1c5 | ||
|
|
94851bae92 | ||
|
|
4157d44a0f | ||
|
|
d0dde77c50 | ||
|
|
b6faf2db28 | ||
|
|
104079e78c | ||
|
|
e8915c03cb | ||
|
|
6d04e2422b | ||
|
|
f12128e74c | ||
|
|
7ccde3a693 | ||
|
|
6fc11b7568 | ||
|
|
f8ec9e2042 | ||
|
|
99c4e9b6d0 | ||
|
|
4af9fc5b43 | ||
|
|
63de38ba21 | ||
|
|
6e39fa08ea | ||
|
|
4ef731dc96 | ||
|
|
ad9bba52e7 | ||
|
|
f014430c23 | ||
|
|
54ff2d01a1 | ||
|
|
a0f13ce8ea | ||
|
|
b6e65611ce | ||
|
|
0e36d641d7 | ||
|
|
2e0deabcc7 | ||
|
|
c84d4b1c46 | ||
|
|
f0c506536a | ||
|
|
733f9f00ae | ||
|
|
504b234a6e | ||
|
|
7ad8185fc8 | ||
|
|
9b452e2be2 | ||
|
|
c38e0a35e9 | ||
|
|
6f4d8559e2 | ||
|
|
951a089358 | ||
|
|
167daae10d | ||
|
|
a9b73fab23 | ||
|
|
dc3c8f062a | ||
|
|
1df0926a47 | ||
|
|
892f828463 | ||
|
|
df6a41568b | ||
|
|
9a94d9b942 | ||
|
|
1c14ebfca5 | ||
|
|
8866e66c6e | ||
|
|
b754398e5e | ||
|
|
0ad82671a0 | ||
|
|
cbd5fd7487 | ||
|
|
7a9e00712f | ||
|
|
c44aa5794c | ||
|
|
c2e45a1637 | ||
|
|
82451d844c | ||
|
|
cc846e1654 | ||
|
|
007f8bc8ab | ||
|
|
9968a271ca | ||
|
|
9d61c6186c | ||
|
|
0a04cd20bc | ||
|
|
b8063c31f7 | ||
|
|
74e0e70a55 | ||
|
|
883a41f44d | ||
|
|
4f770b2ec1 | ||
|
|
43b5513dbf | ||
|
|
cd7227ee45 | ||
|
|
7a08381490 | ||
|
|
e81da20b2a | ||
|
|
6f5bc5388e | ||
|
|
35cbc82ab0 | ||
|
|
946dfce289 | ||
|
|
f9429fbd76 | ||
|
|
8365365dc9 | ||
|
|
43d64dda8e | ||
|
|
dfb015fd4c | ||
|
|
f69b174269 | ||
|
|
b7bd37bdb5 | ||
|
|
0a6cae3f75 | ||
|
|
46bb851352 | ||
|
|
5e28de0a64 | ||
|
|
ba5c88a62e | ||
|
|
e799d54f70 | ||
|
|
f657c2024c | ||
|
|
2963853cef | ||
|
|
4594f2d26d | ||
|
|
38992a4cc0 | ||
|
|
98add93d64 | ||
|
|
d2c601512f | ||
|
|
bd7ceaf6f5 | ||
|
|
f6c4e30b28 | ||
|
|
2f1f4cbb63 | ||
|
|
4952013023 | ||
|
|
a6d046394c | ||
|
|
4b21d6151f | ||
|
|
7e503f2ea2 | ||
|
|
f574db6a24 | ||
|
|
b449f75073 | ||
|
|
0f3ae23697 | ||
|
|
2d18439167 | ||
|
|
af5155175e | ||
|
|
5e61b70367 | ||
|
|
dcf16a3ea9 | ||
|
|
382028ee15 | ||
|
|
65816da4f3 | ||
|
|
cf50014649 | ||
|
|
ae79e89bd7 | ||
|
|
9f5e082a20 | ||
|
|
3b08aa5e1d | ||
|
|
07bc7b35f3 | ||
|
|
a9f9e4f962 | ||
|
|
813b47e1ef | ||
|
|
d1cda09ce7 | ||
|
|
bf45b8e370 | ||
|
|
7dc6eac830 | ||
|
|
d17b81458d | ||
|
|
c63dd712a8 | ||
|
|
4c078d6505 | ||
|
|
98b19505e5 | ||
|
|
2aba11373c | ||
|
|
068cd85dac | ||
|
|
ac19ae07e0 | ||
|
|
959cef0d67 | ||
|
|
55a785606f | ||
|
|
7f1af3bd8f | ||
|
|
c2a5176351 | ||
|
|
1fc7dcb4ea | ||
|
|
0802c8e575 | ||
|
|
17a4436494 | ||
|
|
31cb92f13d | ||
|
|
4b7f66fcbc | ||
|
|
95a5aebed9 | ||
|
|
ec262335af | ||
|
|
2340bb9364 | ||
|
|
4b026b877b | ||
|
|
a054fa439c | ||
|
|
b281e725a7 | ||
|
|
fd86537235 | ||
|
|
81c34e5a2a | ||
|
|
57a546c8f2 | ||
|
|
0446c97734 | ||
|
|
f380195c2e | ||
|
|
a2fdcbab3e | ||
|
|
d34e4755cd | ||
|
|
f90771c6cd | ||
|
|
2aef061145 | ||
|
|
fc6469a46d | ||
|
|
f03f9f0e62 | ||
|
|
9f20cf2d44 | ||
|
|
541446a87c | ||
|
|
4dd46b4db1 | ||
|
|
4b4fd8f62c | ||
|
|
d0877a78ef | ||
|
|
fb7b2e5f6d | ||
|
|
3f38934228 | ||
|
|
9054e2778e | ||
|
|
f2c65aa650 | ||
|
|
e057cd404d | ||
|
|
54497c83f8 | ||
|
|
a5b032f149 | ||
|
|
686dd9447e | ||
|
|
7f6809eade | ||
|
|
297db59dc8 | ||
|
|
b1f78317b0 | ||
|
|
9fcafb70b6 | ||
|
|
357132f648 | ||
|
|
bec150fe15 | ||
|
|
cf14224f00 | ||
|
|
11dfa1b426 | ||
|
|
3b2b1fe07a | ||
|
|
3daa824e8e | ||
|
|
3a16a2b25e | ||
|
|
576c731b4f | ||
|
|
05e065d026 | ||
|
|
ddec4d4901 | ||
|
|
5f22bda676 | ||
|
|
44783b4270 | ||
|
|
4379a963c2 | ||
|
|
46f5714cf5 | ||
|
|
daa134e159 | ||
|
|
b7605254a7 | ||
|
|
63e997f9a7 | ||
|
|
cd515a369f | ||
|
|
a62bc068b2 | ||
|
|
bb1ea393a1 | ||
|
|
1e02a23bbb | ||
|
|
e11b2b8ab5 | ||
|
|
c7c95f8cbd | ||
|
|
9dfe97008a | ||
|
|
f78ebb76e9 | ||
|
|
6362fbc763 | ||
|
|
3c97d00109 | ||
|
|
d871329c0d | ||
|
|
a651890426 | ||
|
|
f6b6928262 | ||
|
|
3ccdc35c40 | ||
|
|
36549186c6 | ||
|
|
23a4381156 | ||
|
|
27cfbfebde | ||
|
|
91eefefa24 | ||
|
|
0aa077bfa2 | ||
|
|
74f4c46ce8 | ||
|
|
908f724c01 | ||
|
|
f9ee817fd5 | ||
|
|
04b1427f93 | ||
|
|
b189d0f695 | ||
|
|
7ed340e6ad | ||
|
|
789ee803c3 | ||
|
|
ebdbbf937a | ||
|
|
ca0d6f32a5 | ||
|
|
006228e0a5 | ||
|
|
2b8a2aab5a | ||
|
|
89beeeb3ba | ||
|
|
068c982864 | ||
|
|
5d7f648535 | ||
|
|
65614ad0f0 | ||
|
|
5006a121ea | ||
|
|
c8d8a56ca8 | ||
|
|
67b59846f1 | ||
|
|
70f0c2f06a |
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`
|
||||
15
.github/CONTRIBUTING.md
vendored
Normal file
15
.github/CONTRIBUTING.md
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# CONTRIBUTING
|
||||
|
||||
:+1::tada: Thank you for taking the time to contribute! :tada::+1:
|
||||
|
||||
## Reporting Issues/Filing Pulling Requests
|
||||
|
||||
* Please follow the [Issue Template](./ISSUE_TEMPLATE.md)
|
||||
* Please always include concrete example, code, etc.
|
||||
* Please Do not assume that the peco contributors know about what you are talking about.
|
||||
* Please always include enough information/links to terms/tools that you refer in your report.
|
||||
* Please include graphic material to explain UI/UX issues, such as screen captures and animated gifs.
|
||||
|
||||
## Miscellaneous
|
||||
|
||||
* We accept issues/PRs in English or Japanese, but English is preferred.
|
||||
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']
|
||||
9
.github/ISSUE_TEMPLATE.md
vendored
Normal file
9
.github/ISSUE_TEMPLATE.md
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# What is the peco version, OS, architecture?
|
||||
|
||||
(Specify N/A if not applicable)
|
||||
|
||||
# Abstract
|
||||
|
||||
# Discussion
|
||||
|
||||
(Please be specific. Include code, pseudocode, screen captures if you can)
|
||||
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 }}
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
|
|
@ -22,4 +22,18 @@ _testmain.go
|
|||
*.exe
|
||||
*.test
|
||||
|
||||
peco
|
||||
/peco
|
||||
|
||||
build/src
|
||||
|
||||
c.out
|
||||
out
|
||||
|
||||
.build
|
||||
|
||||
_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
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
language: go
|
||||
go:
|
||||
- 1.1
|
||||
- 1.2
|
||||
- 1.3
|
||||
- tip
|
||||
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 |
|
||||
491
Changes
491
Changes
|
|
@ -1,6 +1,493 @@
|
|||
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,
|
||||
you lost your selected location in the peco view. #410
|
||||
Backwards Incompatible Change:
|
||||
* --tty has been removed. it was not being used anyways.
|
||||
Miscellaneous
|
||||
* When scrolling right, you could potentially keep on scrolling
|
||||
infinitely. This has been addressed by #409, #412
|
||||
* External commands specified in --exec now receive
|
||||
PECO_FILENAME, PECO_LINE_COUNT, PECO_QUERY, and
|
||||
PECO_MATCHED_LINE_COUNT as environment variables
|
||||
* Removed unused structs
|
||||
* Fixed glide related Makefile actions
|
||||
|
||||
v0.5.0 - 06 Mar 2017
|
||||
Backwards Incompatible Change:
|
||||
* ExecuteCommand has been removed.
|
||||
Features:
|
||||
* A new command line option `--exec` has been added. This allows you to
|
||||
execute external commands via shell, and should be used as replacement
|
||||
to `ExecuteCommand`
|
||||
* A new configuration option `MaxScanBufferSize` has been added. Whereas
|
||||
bufio.Scanner (which peco internally relies on) only accepts lines that
|
||||
are < 64kb when reading the input, specifying this option in the config
|
||||
allows you to change this limit.
|
||||
The default MaxScanBuferSize is 256kb.
|
||||
Bugs/Fixes
|
||||
* When executing external commands, the screen and the capturing of user
|
||||
input would interfere when getting back to peco.
|
||||
|
||||
v0.4.9 - 01 Mar 2017
|
||||
Bugs/Fixes
|
||||
* SavedSelection under `--selection-prefix` was not properly working
|
||||
(#388)
|
||||
* An outstanding bug from v0.4.0 where specifying `--query` for a large
|
||||
enough input would lose results in the first query execution has been
|
||||
fixed (#389)
|
||||
|
||||
v0.4.8 - 26 Feb 2017
|
||||
Features:
|
||||
* A new command line option `--on-cancel` has been added. This allows you
|
||||
to determine if the user has canceled the query, which can be useful in
|
||||
a pipeline of commands.
|
||||
* `OnCancel` does the equivalent of `--on-cancel` in the config file.
|
||||
* A new command line option `--selection-prefix` has been added. This
|
||||
allows you to use a string prefix instead of highlighting currently
|
||||
selected lines.
|
||||
* `SelectionPrefix` does the equivalent of `--selection-prefix` in the
|
||||
config file.
|
||||
Miscellaneous
|
||||
* Build using go 1.8
|
||||
|
||||
v0.4.7 - 17 Dec 2016
|
||||
Bugs/Fixes
|
||||
* A regression of #363 appeared again, because the test was not testing
|
||||
for the correct behavior (#376)
|
||||
Miscellaneous
|
||||
* Internal house cleaning
|
||||
|
||||
v0.4.6 - 14 Dec 2016
|
||||
Features:
|
||||
* A new fuzzy filter has now been added. See README for details (#369, #370)
|
||||
Bugs/Fixes
|
||||
* A very subtle timing issue that causes the search to be reset
|
||||
has been resolved (#368)
|
||||
|
||||
v0.4.5 - 30 Oct 2016
|
||||
Bugs/Fixes
|
||||
* v0.4.4 broke --select-1 :/ (#363)
|
||||
|
||||
v0.4.4 - 23 Oct 2016
|
||||
Bugs/Fixes
|
||||
* Fix to force a redraw of the screen when the query becomes empty (#346)
|
||||
* Fix blocking when read from an slow input source is interrupted in
|
||||
the middle (#359)
|
||||
* Remove gratuitous use of panic that could happen occasionally when we
|
||||
bail out of setup (like the scenario above)
|
||||
|
||||
v0.4.3 - 05 Oct 2016
|
||||
Bugs/Fixes
|
||||
* Fix to apply InitialFilter setting from config file (#343)
|
||||
* Fix --buffer-size option
|
||||
* Fix --prompt option
|
||||
* Fix deadlock when certain actions are combined in the custom
|
||||
action sequence. This bug probably existed for a long time,
|
||||
but probably nobody bothered to report or didn't use the
|
||||
combinations that causes the deadlock (#345)
|
||||
* Force redraw when SelectNone/RefreshScreen is called (#346)
|
||||
|
||||
v0.4.2 - 23 Aug 2016
|
||||
Bugs/Fixes
|
||||
* Fix single key jump mode (#331, #332)
|
||||
|
||||
v0.4.1 - 21 Aug 2016
|
||||
Bugs/Fixes
|
||||
* Fix a panic that went unnoticed (#328, #329)
|
||||
|
||||
v0.4.0 - 19 Aug 2016
|
||||
Miscellaneous:
|
||||
* There is no real user visible change for this release
|
||||
* Build using go 1.7
|
||||
* LOTS of internal cleanup
|
||||
* Eliminated (hopefully) all of potential races
|
||||
* Remove silly embedded structs that existed all over the place
|
||||
* New pipeline mechanism should allow slightly faster filtering
|
||||
* Many fixes to properly release resources acquired by goroutines
|
||||
|
||||
v0.3.6 - 11 May 2016
|
||||
Features:
|
||||
* Implement way to execute arbitrary commands via ExecuteCommand config
|
||||
Bugs/Fixes:
|
||||
* Fixed wrong default value for LayoutType
|
||||
* Fixed wrong (actually lack of) extension for Windows binary
|
||||
Miscellaneous:
|
||||
* Changed textual cursor representation
|
||||
* Changed dependency manager to glide
|
||||
* Changed build system to... make. Yes. Make.
|
||||
* Compile with go1.6
|
||||
|
||||
v0.3.5 - 30 Oct 2015
|
||||
Features:
|
||||
* Implement --select-1 option (#278)
|
||||
Bugs/Fixes:
|
||||
* Do not switch lines when there's no output (#274)
|
||||
Miscellaneous:
|
||||
* Fix automatic gzip generation (#275)
|
||||
|
||||
v0.3.4 - 29 Sep 2015
|
||||
Features:
|
||||
* Implemented SingleKeyJump mode, a.k.a. hit-a-hint
|
||||
Bugs/Fixes:
|
||||
* Fix a problem where lines were sometimes not highlighted
|
||||
* Executing a query may sometimes result in a shorter list of entries
|
||||
such that the page you were at before the query is no longer valid.
|
||||
The cursor would now move to the closest page instead of an empty buffer
|
||||
Miscellaneous:
|
||||
* Only support go1.5 (we now use cross compilation and internal packages)
|
||||
* `go run build/make.go build` will now build all of the peco release files
|
||||
|
||||
v0.3.3 - 11 Jul 2015
|
||||
Features:
|
||||
* Implemented ScrollLeft/ScrollRight, to read long lines
|
||||
* Implemented BackToInitialFilter, to make it easier to create combined actions
|
||||
that change the filter type
|
||||
Bugs/Fixes
|
||||
* Build properly on *BSDs
|
||||
|
||||
v0.3.2 - 24 Mar 2015
|
||||
Bugs/Fixes
|
||||
* Filtered results could be wrong when QueryExecutionDelay is set to 0 (#241)
|
||||
|
||||
v0.3.1 - 24 Mar 2015
|
||||
Bugs/Fixes
|
||||
* Regexp filter was accidentally disabled (#239/#240). Now it's back
|
||||
|
||||
v0.3.0 - 20 Mar 2015
|
||||
* BEWARE! 50% of the code has been rewritten!
|
||||
Features
|
||||
* Matchers are now known as Filters
|
||||
* Added StickySelection configuration option
|
||||
* Added QueryExecutionDelay configuration option
|
||||
* Added CustomFilter.BufferThreshold option
|
||||
* For Windows, an extra space at the bottom has been added to facilitate
|
||||
those users using an IM (which consumes an extra line)
|
||||
Deprecations
|
||||
* --no-ignore-case has been completely removed
|
||||
* InitialMatcher configuration option has been deprecated. Use InitialFilter
|
||||
* CustomMatcher configuration option has been deprecated. Use CustomFilter
|
||||
* RotateMatcher action has been deprecated. Use RotateFilter
|
||||
Miscellaneous
|
||||
* To enable trace logs, compile with `-tags debug`, and enable the logs
|
||||
via PECO_TRACE environment variable. e.g. `PECO_TRACE=1 peco ...`
|
||||
|
||||
v0.2.12 - 27 Feb 2015
|
||||
Bugs/Fixes
|
||||
* Lines with tabs didn't display properly.
|
||||
* Matches that would fail to hilight certain parts of the text
|
||||
depending on the term order
|
||||
* SelectNext/SelectPrevious actions were reversed (however, these were
|
||||
deprecated anyways)
|
||||
Miscellaneous
|
||||
* You no longer need to tap our custom tap for peco to be installed from
|
||||
homebrew.
|
||||
* Custom matcher processing is fater now
|
||||
|
||||
v0.2.11 - 16 Dec 2014
|
||||
Features
|
||||
* Add a InvertSelection action. Not default keymapping has been assigned
|
||||
as of this change, so you need to explicitly set one in your config file.
|
||||
* Display total entries in additin to number of pages
|
||||
* Add experimental ToggleQuery action which enables/disables the current
|
||||
query.
|
||||
Miscellaneous
|
||||
* Massive refactoring to avoid possible race conditions. Note that I am
|
||||
not aware of any breakage caused by previous builds of peco. Even
|
||||
without these changes you most likely did not see a race or it was
|
||||
something you could totally ignore by simply redrawing the page.
|
||||
However, since I was in the mood, and since go is awesome for providing
|
||||
us the `-race` switch, I decided to silence these warnings.
|
||||
* Fix handling of --layout option when no config file is not present.
|
||||
|
||||
v0.2.10 - 24 Sep 2014
|
||||
Bugs/Fixes
|
||||
* There was a race condition in the way status messages were cleared,
|
||||
which could leave a message to be not cleared. This has no serious
|
||||
practical impact as it's just the status message, but has been fixed.
|
||||
Miscellaneous
|
||||
* We stopped using godep because it no longer allowed us to use -copy=false.
|
||||
This should have no effect on users.
|
||||
* Update termbox-go dependency
|
||||
* Remove stdout hack
|
||||
|
||||
v0.2.9 - 08 Sep 2014
|
||||
Features
|
||||
* Add SmartCase matcher, which automatically toggles between case sensitive
|
||||
and insensitive modes depending on the case of your query.
|
||||
* Ctrl-l is now "peco.RefreshScreen", which basically runs the current query
|
||||
again
|
||||
Bugs/Fixes
|
||||
* User prompt is now draw *before* the filtering is executed.
|
||||
* Be more strict about checking invalid matchers.
|
||||
* Add more tests (work in progress...)
|
||||
|
||||
v0.2.8 - 01 Sep 2014
|
||||
Bugs/Fixes
|
||||
* Fix in v0.2.7 was apparently not enough. Things should be fixed now.
|
||||
* Long standing bugs against DeleteForwardWord/DeleteBackwardWord have
|
||||
been fixed
|
||||
|
||||
v0.2.7 - 01 Sep 2014
|
||||
Bugs/Fixes
|
||||
* A regression introduced in v0.2.6 for DeleteForwardChar/DeleteBackwardChar
|
||||
has been fixed
|
||||
* Some entries in the Keymap section of the README was wrong, and has
|
||||
been fixed
|
||||
|
||||
v0.2.6 - 30 Aug 2014
|
||||
Bugs/Fixes
|
||||
* --prompt option was not properly working
|
||||
Miscellaneous
|
||||
* Bunch of internal cleanups to make testing easier
|
||||
|
||||
v0.2.5 - 18 Aug 2014
|
||||
Features
|
||||
* Add --layout option, which allows you to switch between `top-down`
|
||||
and `bottom-up` layout mode. This is equivalent of percol's
|
||||
`--prompt-bottom --result-bottom-up`. Default is `top-down`.
|
||||
The same option can be specified in the config file as "Layout"
|
||||
Miscellaneous
|
||||
* Because of the layout option, SelectNext/SelectPrevious and
|
||||
SelectNextPage/SelectPreviousPage no longer made sense.
|
||||
Now all of these are DEPRECATED, and are aliases to different
|
||||
action names. See the README for the details.
|
||||
In particular, you would need to configure your key bindings
|
||||
using these if you want to use the `bottom-up` layout
|
||||
|
||||
v0.2.4 - 13 Aug 2014
|
||||
Features
|
||||
* Add --initial-matcher command line option to specify which
|
||||
matcher type to use upon start up. --no-ignore-case is deprecated
|
||||
* Add InitialMatcher option to the config file. Matcher option is
|
||||
deprecated.
|
||||
Miscellaneous
|
||||
* Binaries are now built using go1.3.1
|
||||
|
||||
v0.2.3 - 28 Jul 2014
|
||||
Bugs/Fixes
|
||||
* Escape sequences are now stripped from input, so for example,
|
||||
|
|
@ -88,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
|
||||
|
|
@ -163,4 +650,4 @@ v0.1.1 - 12 Jun 2014
|
|||
* Add peco --version
|
||||
|
||||
v0.1.0 - 12 Jun 2014
|
||||
* Initial versioned release
|
||||
* Initial versioned release
|
||||
|
|
|
|||
20
Godeps
20
Godeps
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"ImportPath": "github.com/peco/peco",
|
||||
"GoVersion": "go1.3",
|
||||
"Deps": [
|
||||
{
|
||||
"ImportPath": "github.com/jessevdk/go-flags",
|
||||
"Comment": "v1-172-g8ec9564",
|
||||
"Rev": "8ec9564882e7923e632f012761c81c46dcf5bec1"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/mattn/go-runewidth",
|
||||
"Comment": "travisish-23-g36f63b8",
|
||||
"Rev": "36f63b8223e701c16f36010094fb6e84ffbaf8e0"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/nsf/termbox-go",
|
||||
"Rev": "e9227d640138066e099db60f3010bd8d55c8da72"
|
||||
}
|
||||
]
|
||||
}
|
||||
34
Makefile
Normal file
34
Makefile
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
MAKEFLAGS += --no-print-directory
|
||||
export PATH := $(HOME)/go/bin:$(PATH)
|
||||
|
||||
.DEFAULT_GOAL := build
|
||||
|
||||
.PHONY: build snapshot test lint generate cover clean deps check-goreleaser
|
||||
|
||||
check-goreleaser:
|
||||
@command -v goreleaser >/dev/null 2>&1 || { echo "goreleaser not found. Install it: https://goreleaser.com/install/"; exit 1; }
|
||||
|
||||
build: check-goreleaser
|
||||
goreleaser build --snapshot --single-target --clean
|
||||
|
||||
snapshot: check-goreleaser
|
||||
goreleaser release --snapshot --clean
|
||||
|
||||
test:
|
||||
go test -v -race ./...
|
||||
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
generate:
|
||||
go generate ./...
|
||||
|
||||
cover:
|
||||
go test -race -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out
|
||||
|
||||
clean:
|
||||
rm -rf dist
|
||||
|
||||
deps:
|
||||
go mod download
|
||||
1185
action_test.go
1185
action_test.go
File diff suppressed because it is too large
Load diff
457
buffer.go
Normal file
457
buffer.go
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"context"
|
||||
|
||||
"github.com/lestrrat-go/pdebug"
|
||||
runewidth "github.com/mattn/go-runewidth"
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
)
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
// 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() {
|
||||
return &fb
|
||||
}
|
||||
|
||||
// Copy over the selections that are applicable to this filtered buffer.
|
||||
end := min(start+count, src.Size())
|
||||
selection := make([]int, 0, end-start)
|
||||
|
||||
lines := src.linesInRange(start, end)
|
||||
var maxcols int
|
||||
for i := start; i < end; i++ {
|
||||
selection = append(selection, i)
|
||||
cols := runewidth.StringWidth(lines[i-start].DisplayString())
|
||||
if cols > maxcols {
|
||||
maxcols = cols
|
||||
}
|
||||
}
|
||||
fb.selection = selection
|
||||
fb.maxcols = maxcols
|
||||
|
||||
return &fb
|
||||
}
|
||||
|
||||
// MaxColumn returns the max column size, which controls the amount we
|
||||
// can scroll to the right
|
||||
func (flb *FilteredBuffer) MaxColumn() int {
|
||||
return flb.maxcols
|
||||
}
|
||||
|
||||
// LineAt returns the line at index `i`. Note that the i-th element
|
||||
// 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 < 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])
|
||||
}
|
||||
|
||||
// Size returns the number of lines in the buffer
|
||||
func (flb FilteredBuffer) Size() int {
|
||||
return len(flb.selection)
|
||||
}
|
||||
|
||||
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.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 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()
|
||||
if pdebug.Enabled {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 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 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("MemoryBuffer received context done")
|
||||
}
|
||||
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())
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
|
||||
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() {}
|
||||
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)
|
||||
}
|
||||
231
cmd/peco/peco.go
231
cmd/peco/peco.go
|
|
@ -3,212 +3,47 @@ package main
|
|||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/jessevdk/go-flags"
|
||||
"github.com/nsf/termbox-go"
|
||||
"context"
|
||||
|
||||
"github.com/lestrrat-go/pdebug"
|
||||
"github.com/peco/peco"
|
||||
"github.com/peco/peco/internal/util"
|
||||
)
|
||||
|
||||
var version = "v0.2.3"
|
||||
|
||||
func showHelp() {
|
||||
const v = `
|
||||
Usage: peco [options] [FILE]
|
||||
|
||||
Options:
|
||||
-h, --help show this help message and exit
|
||||
--version print the version and exit
|
||||
--rcfile=RCFILE path to the settings file
|
||||
--query=QUERY pre-input query
|
||||
--no-ignore-case start in case-sensitive mode
|
||||
-b, --buffer-size number of lines to keep in search buffer
|
||||
--null expect NUL (\0) as separator for target/output (EXPERIMENTAL)
|
||||
--initial-index position of the initial index of the selection (0 base)
|
||||
--prompt specify prompt
|
||||
`
|
||||
os.Stderr.Write([]byte(v))
|
||||
}
|
||||
|
||||
type cmdOptions struct {
|
||||
OptHelp bool `short:"h" long:"help" description:"show this help message and exit"`
|
||||
OptTTY string `long:"tty" description:"path to the TTY (usually, the value of $TTY)"`
|
||||
OptQuery string `long:"query"`
|
||||
OptRcfile string `long:"rcfile" descriotion:"path to the settings file"`
|
||||
OptNoIgnoreCase bool `long:"no-ignore-case" description:"start in case-sensitive-mode" default:"false"`
|
||||
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)"`
|
||||
OptPrompt string `long:"prompt"`
|
||||
}
|
||||
|
||||
// BufferSize returns the specified buffer size. Fulfills peco.CtxOptions
|
||||
func (o cmdOptions) BufferSize() int {
|
||||
return o.OptBufferSize
|
||||
}
|
||||
|
||||
// EnableNullSep returns tru if --null was specified. Fulfills peco.CtxOptions
|
||||
func (o cmdOptions) EnableNullSep() bool {
|
||||
return o.OptEnableNullSep
|
||||
}
|
||||
|
||||
func (o cmdOptions) InitialIndex() int {
|
||||
if o.OptInitialIndex >= 0 {
|
||||
return o.OptInitialIndex + 1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
var st int
|
||||
|
||||
defer func() { os.Exit(st) }()
|
||||
|
||||
if envvar := os.Getenv("GOMAXPROCS"); envvar == "" {
|
||||
runtime.GOMAXPROCS(runtime.NumCPU())
|
||||
}
|
||||
|
||||
opts := &cmdOptions{}
|
||||
p := flags.NewParser(opts, flags.PrintErrors)
|
||||
args, err := p.Parse()
|
||||
if err != nil {
|
||||
showHelp()
|
||||
st = 1
|
||||
return
|
||||
}
|
||||
|
||||
if opts.OptHelp {
|
||||
showHelp()
|
||||
return
|
||||
}
|
||||
|
||||
if opts.OptVersion {
|
||||
fmt.Fprintf(os.Stderr, "peco: %s\n", version)
|
||||
return
|
||||
}
|
||||
|
||||
var in *os.File
|
||||
|
||||
// receive in from either a file or Stdin
|
||||
switch {
|
||||
case len(args) > 0:
|
||||
in, err = os.Open(args[0])
|
||||
if err != nil {
|
||||
st = 1
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
case !peco.IsTty(os.Stdin.Fd()):
|
||||
in = os.Stdin
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "You must supply something to work with via filename or stdin")
|
||||
st = 1
|
||||
return
|
||||
}
|
||||
|
||||
ctx := peco.NewCtx(opts)
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
st = 1
|
||||
fmt.Fprintf(os.Stderr, "Error:\n%s", err)
|
||||
}
|
||||
|
||||
if result := ctx.Result(); result != nil {
|
||||
for _, match := range result {
|
||||
line := match.Output()
|
||||
if line[len(line)-1] != '\n' {
|
||||
line = line + "\n"
|
||||
}
|
||||
fmt.Fprint(os.Stdout, line)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
if opts.OptRcfile == "" {
|
||||
file, err := peco.LocateRcfile()
|
||||
if err == nil {
|
||||
opts.OptRcfile = file
|
||||
}
|
||||
}
|
||||
|
||||
// Default matcher is IgnoreCase
|
||||
ctx.SetCurrentMatcher(peco.IgnoreCaseMatch)
|
||||
|
||||
if opts.OptRcfile != "" {
|
||||
err = ctx.ReadConfig(opts.OptRcfile)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
st = 1
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if opts.OptNoIgnoreCase {
|
||||
ctx.SetCurrentMatcher(peco.CaseSensitiveMatch)
|
||||
}
|
||||
|
||||
// Try waiting for something available in the source stream
|
||||
// before doing any terminal initialization (also done by termbox)
|
||||
reader := ctx.NewBufferReader(in)
|
||||
ctx.AddWaitGroup(1)
|
||||
go reader.Loop()
|
||||
|
||||
// This channel blocks until we receive something from `in`
|
||||
<-reader.InputReadyCh()
|
||||
|
||||
err = peco.TtyReady()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
st = 1
|
||||
return
|
||||
}
|
||||
defer peco.TtyTerm()
|
||||
|
||||
err = termbox.Init()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
st = 1
|
||||
return
|
||||
}
|
||||
defer termbox.Close()
|
||||
|
||||
// Windows handle Esc/Alt self
|
||||
if runtime.GOOS == "windows" {
|
||||
termbox.SetInputMode(termbox.InputEsc | termbox.InputAlt)
|
||||
}
|
||||
|
||||
view := ctx.NewView()
|
||||
filter := ctx.NewFilter()
|
||||
input := ctx.NewInput()
|
||||
sig := ctx.NewSignalHandler()
|
||||
|
||||
loopers := []interface {
|
||||
Loop()
|
||||
}{
|
||||
view,
|
||||
filter,
|
||||
input,
|
||||
sig,
|
||||
}
|
||||
for _, looper := range loopers {
|
||||
ctx.AddWaitGroup(1)
|
||||
go looper.Loop()
|
||||
}
|
||||
|
||||
if len(opts.OptQuery) > 0 {
|
||||
ctx.SetQuery([]rune(opts.OptQuery))
|
||||
ctx.ExecQuery()
|
||||
} else {
|
||||
view.Refresh()
|
||||
}
|
||||
|
||||
if len(opts.OptPrompt) > 0 {
|
||||
ctx.SetPrompt([]rune(opts.OptPrompt))
|
||||
}
|
||||
|
||||
ctx.WaitDone()
|
||||
|
||||
st = ctx.ExitStatus
|
||||
os.Exit(_main())
|
||||
}
|
||||
|
||||
func _main() int {
|
||||
if pdebug.Enabled {
|
||||
pdebug.DefaultCtx.Writer = os.Stderr
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
cli := peco.New()
|
||||
if err := cli.Run(ctx); err != nil {
|
||||
switch {
|
||||
case util.IsCollectResultsError(err):
|
||||
cli.PrintResults()
|
||||
return 0
|
||||
case util.IsIgnorableError(err):
|
||||
if st, ok := util.GetExitStatus(err); ok {
|
||||
return st
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
|
|
|||
212
config.go
212
config.go
|
|
@ -1,212 +0,0 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/nsf/termbox-go"
|
||||
)
|
||||
|
||||
var homedirFunc = homedir
|
||||
|
||||
// Config holds all the data that can be configured in the
|
||||
// external configuran 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"`
|
||||
Style StyleSet `json:"Style"`
|
||||
CustomMatcher map[string][]string
|
||||
Prompt string `json:"Prompt"`
|
||||
}
|
||||
|
||||
// NewConfig creates a new Config
|
||||
func NewConfig() *Config {
|
||||
return &Config{
|
||||
Keymap: make(map[string]string),
|
||||
Matcher: IgnoreCaseMatch,
|
||||
Style: NewStyleSet(),
|
||||
Prompt: "QUERY>",
|
||||
}
|
||||
}
|
||||
|
||||
// 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 err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
err = json.NewDecoder(f).Decode(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// NewStyleSet creates a new StyleSet struct
|
||||
func NewStyleSet() StyleSet {
|
||||
return StyleSet{
|
||||
Basic: Style{fg: termbox.ColorDefault, bg: termbox.ColorDefault},
|
||||
SavedSelection: Style{fg: termbox.ColorBlack | termbox.AttrBold, bg: termbox.ColorCyan},
|
||||
Selected: Style{fg: termbox.ColorDefault | termbox.AttrUnderline, bg: termbox.ColorMagenta},
|
||||
Query: Style{fg: termbox.ColorDefault, bg: termbox.ColorDefault},
|
||||
Matched: Style{fg: termbox.ColorCyan, bg: termbox.ColorDefault},
|
||||
}
|
||||
}
|
||||
|
||||
// Style describes termbox styles
|
||||
type Style struct {
|
||||
fg termbox.Attribute
|
||||
bg termbox.Attribute
|
||||
}
|
||||
|
||||
// UnmarshalJSON satisfies json.RawMessage.
|
||||
func (s *Style) UnmarshalJSON(buf []byte) error {
|
||||
raw := []string{}
|
||||
if err := json.Unmarshal(buf, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = *stringsToStyle(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringsToStyle(raw []string) *Style {
|
||||
style := &Style{
|
||||
fg: termbox.ColorDefault,
|
||||
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 style
|
||||
}
|
||||
|
||||
var _locateRcfileIn = locateRcfileIn
|
||||
|
||||
func locateRcfileIn(dir string) (string, error) {
|
||||
const basename = "config.json"
|
||||
file := filepath.Join(dir, basename)
|
||||
if _, err := os.Stat(file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// LocateRcfile attempts to find the config file in various locations
|
||||
func LocateRcfile() (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 != "" {
|
||||
file, err := _locateRcfileIn(filepath.Join(dir, "peco"))
|
||||
if err == nil {
|
||||
return file, nil
|
||||
}
|
||||
} else if uErr == nil { // silently ignore failure for homedir()
|
||||
// Try "default" XDG location, is user is available
|
||||
file, err := _locateRcfileIn(filepath.Join(home, ".config", "peco"))
|
||||
if 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)) {
|
||||
file, err := _locateRcfileIn(filepath.Join(dir, "peco"))
|
||||
if err == nil {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if uErr == nil { // silently ignore failure for homedir()
|
||||
file, err := _locateRcfileIn(filepath.Join(home, ".peco"))
|
||||
if err == nil {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("error: 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
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func homedir() (string, error) {
|
||||
home := os.Getenv("HOME")
|
||||
if home == "" {
|
||||
return "", fmt.Errorf("error: Environment variable HOME not set")
|
||||
}
|
||||
|
||||
return home, nil
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
// +build !darwin,!windows
|
||||
|
||||
package peco
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func homedir() (string, error) {
|
||||
home := os.Getenv("HOME")
|
||||
if home == "" {
|
||||
return "", fmt.Errorf("error: Environment variable HOME not set")
|
||||
}
|
||||
|
||||
return home, nil
|
||||
}
|
||||
124
config_test.go
124
config_test.go
|
|
@ -1,124 +0,0 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nsf/termbox-go"
|
||||
)
|
||||
|
||||
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]"
|
||||
}
|
||||
`
|
||||
cfg := NewConfig()
|
||||
if err := json.Unmarshal([]byte(txt), cfg); err != nil {
|
||||
t.Fatalf("Error unmarshaling json: %s", err)
|
||||
}
|
||||
t.Logf("%#q", cfg)
|
||||
}
|
||||
|
||||
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...")
|
||||
for _, test := range tests {
|
||||
t.Logf(" checking %s...", test.strings)
|
||||
if a := stringsToStyle(test.strings); *a != *test.style {
|
||||
t.Errorf("Expected '%s' to be '%#v', but got '%#v'", test.strings, test.style, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocateRcfile(t *testing.T) {
|
||||
dir, err := ioutil.TempDir("", "peco-")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary directory: %s", err)
|
||||
}
|
||||
|
||||
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
|
||||
_locateRcfileIn = func(dir string) (string, error) {
|
||||
t.Logf("looking for file in %s", dir)
|
||||
if i > len(expected)-1 {
|
||||
t.Fatalf("Got %d directories, only have %d", i+1, len(expected))
|
||||
}
|
||||
|
||||
if expected[i] != dir {
|
||||
t.Errorf("Expected %s, got %s", expected[i], dir)
|
||||
}
|
||||
i++
|
||||
return "", fmt.Errorf("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()
|
||||
expected[0] = filepath.Join(dir, ".config", "peco")
|
||||
os.Setenv("XDG_CONFIG_HOME", "")
|
||||
i = 0
|
||||
LocateRcfile()
|
||||
|
||||
}
|
||||
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
268
ctx.go
268
ctx.go
|
|
@ -1,268 +0,0 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// CtxOptions is the interface that defines that options can be
|
||||
// passed in from the command line
|
||||
type CtxOptions interface {
|
||||
// EnableNullSep should return if the null separator is
|
||||
// enabled (--null)
|
||||
EnableNullSep() bool
|
||||
|
||||
// BufferSize should return the buffer size. By default (i.e.
|
||||
// when it returns 0), the buffer size is unlimited.
|
||||
// (--buffer-size)
|
||||
BufferSize() int
|
||||
|
||||
// InitialIndex is the line number to put the cursor on
|
||||
// when peco starts
|
||||
InitialIndex() int
|
||||
}
|
||||
|
||||
type PageInfo struct {
|
||||
index int
|
||||
offset int
|
||||
perPage int
|
||||
}
|
||||
|
||||
// Ctx contains all the important data. while you can easily access
|
||||
// data in this struct from anwyehre, only do so via channels
|
||||
type Ctx struct {
|
||||
*Hub
|
||||
enableSep bool
|
||||
result []Match
|
||||
mutex sync.Mutex
|
||||
query []rune
|
||||
prompt []rune
|
||||
caretPos int
|
||||
currentLine int
|
||||
currentPage PageInfo
|
||||
selection Selection
|
||||
lines []Match
|
||||
current []Match
|
||||
bufferSize int
|
||||
config *Config
|
||||
Matchers []Matcher
|
||||
CurrentMatcher int
|
||||
ExitStatus int
|
||||
selectionRangeStart int
|
||||
|
||||
wait *sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewCtx(o CtxOptions) *Ctx {
|
||||
return &Ctx{
|
||||
NewHub(),
|
||||
o.EnableNullSep(),
|
||||
[]Match{},
|
||||
sync.Mutex{},
|
||||
[]rune{},
|
||||
[]rune{},
|
||||
0,
|
||||
o.InitialIndex(),
|
||||
struct{ index, offset, perPage int }{0, 1, 0},
|
||||
Selection([]int{}),
|
||||
[]Match{},
|
||||
nil,
|
||||
o.BufferSize(),
|
||||
NewConfig(),
|
||||
[]Matcher{
|
||||
NewIgnoreCaseMatcher(o.EnableNullSep()),
|
||||
NewCaseSensitiveMatcher(o.EnableNullSep()),
|
||||
NewRegexpMatcher(o.EnableNullSep()),
|
||||
},
|
||||
0,
|
||||
0,
|
||||
invalidSelectionRange,
|
||||
&sync.WaitGroup{},
|
||||
}
|
||||
}
|
||||
|
||||
const invalidSelectionRange = -1
|
||||
|
||||
func (c *Ctx) ReadConfig(file string) error {
|
||||
if err := c.config.ReadFilename(file); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.LoadCustomMatcher(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.SetCurrentMatcher(c.config.Matcher)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Ctx) IsBufferOverflowing() bool {
|
||||
if c.bufferSize <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return len(c.lines) > c.bufferSize
|
||||
}
|
||||
|
||||
func (c *Ctx) IsRangeMode() bool {
|
||||
return c.selectionRangeStart != invalidSelectionRange
|
||||
}
|
||||
|
||||
func (c *Ctx) SelectedRange() Selection {
|
||||
if !c.IsRangeMode() {
|
||||
return Selection{}
|
||||
}
|
||||
|
||||
selectedLines := []int{}
|
||||
if c.selectionRangeStart < c.currentLine {
|
||||
for i := c.selectionRangeStart; i < c.currentLine; i++ {
|
||||
selectedLines = append(selectedLines, i)
|
||||
}
|
||||
} else {
|
||||
for i := c.selectionRangeStart; i > c.currentLine; i-- {
|
||||
selectedLines = append(selectedLines, i)
|
||||
}
|
||||
}
|
||||
return Selection(selectedLines)
|
||||
}
|
||||
|
||||
func (c *Ctx) Result() []Match {
|
||||
return c.result
|
||||
}
|
||||
|
||||
func (c *Ctx) AddWaitGroup(v int) {
|
||||
c.wait.Add(v)
|
||||
}
|
||||
|
||||
func (c *Ctx) ReleaseWaitGroup() {
|
||||
c.wait.Done()
|
||||
}
|
||||
|
||||
func (c *Ctx) WaitDone() {
|
||||
c.wait.Wait()
|
||||
}
|
||||
|
||||
func (c *Ctx) ExecQuery() bool {
|
||||
if len(c.query) > 0 {
|
||||
c.SendQuery(string(c.query))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Ctx) DrawMatches(m []Match) {
|
||||
c.SendDraw(m)
|
||||
}
|
||||
func (c *Ctx) Refresh() {
|
||||
c.DrawMatches(nil)
|
||||
}
|
||||
|
||||
func (c *Ctx) Buffer() []Match {
|
||||
// Copy lines so it's safe to read it
|
||||
lcopy := make([]Match, len(c.lines))
|
||||
copy(lcopy, c.lines)
|
||||
return lcopy
|
||||
}
|
||||
|
||||
func (c *Ctx) NewBufferReader(r io.ReadCloser) *BufferReader {
|
||||
return &BufferReader{c, r, make(chan struct{})}
|
||||
}
|
||||
|
||||
func (c *Ctx) NewView() *View {
|
||||
return &View{c, nil}
|
||||
}
|
||||
|
||||
func (c *Ctx) NewFilter() *Filter {
|
||||
return &Filter{c, make(chan string)}
|
||||
}
|
||||
|
||||
func (c *Ctx) NewInput() *Input {
|
||||
// Create a new keymap object
|
||||
k := NewKeymap(c.config.Keymap, c.config.Action)
|
||||
k.ApplyKeybinding()
|
||||
return &Input{c, &sync.Mutex{}, nil, k, []string{}}
|
||||
}
|
||||
|
||||
func (c *Ctx) SetQuery(q []rune) {
|
||||
c.query = q
|
||||
c.caretPos = len(q)
|
||||
}
|
||||
|
||||
func (c *Ctx) Matcher() Matcher {
|
||||
return c.Matchers[c.CurrentMatcher]
|
||||
}
|
||||
|
||||
func (c *Ctx) AddMatcher(m Matcher) error {
|
||||
if err := m.Verify(); err != nil {
|
||||
return fmt.Errorf("verification for custom matcher failed: %s", err)
|
||||
}
|
||||
c.Matchers = append(c.Matchers, m)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Ctx) SetCurrentMatcher(n string) bool {
|
||||
for i, m := range c.Matchers {
|
||||
if m.String() == n {
|
||||
c.CurrentMatcher = i
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Ctx) LoadCustomMatcher() error {
|
||||
if len(c.config.CustomMatcher) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for name, args := range c.config.CustomMatcher {
|
||||
if err := c.AddMatcher(NewCustomMatcher(c.enableSep, name, args)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Ctx) ExitWith(i int) {
|
||||
c.ExitStatus = i
|
||||
c.Stop()
|
||||
}
|
||||
|
||||
type signalHandler struct {
|
||||
*Ctx
|
||||
sigCh chan os.Signal
|
||||
}
|
||||
|
||||
func (c *Ctx) NewSignalHandler() *signalHandler {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
return &signalHandler{c, sigCh}
|
||||
}
|
||||
|
||||
func (s *signalHandler) Loop() {
|
||||
defer s.ReleaseWaitGroup()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.LoopCh():
|
||||
return
|
||||
case <-s.sigCh:
|
||||
// XXX For future reference: DO NOT, and I mean DO NOT call
|
||||
// termbox.Close() here. Calling termbox.Close() twice in our
|
||||
// context actually BLOCKS. Can you believe it? IT BLOCKS.
|
||||
//
|
||||
// So if we called termbox.Close() here, and then in main()
|
||||
// defer termbox.Close() blocks. Not cool.
|
||||
s.ExitWith(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Ctx) SetPrompt(p []rune) {
|
||||
c.prompt = p
|
||||
}
|
||||
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
|
||||
}
|
||||
568
filter.go
568
filter.go
|
|
@ -1,50 +1,564 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/pdebug"
|
||||
"github.com/peco/peco/filter"
|
||||
"github.com/peco/peco/hub"
|
||||
"github.com/peco/peco/internal/buffer"
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
*Ctx
|
||||
jobs chan string
|
||||
state *Peco
|
||||
prevQuery string
|
||||
prevResults *MemoryBuffer
|
||||
prevFilterName string
|
||||
prevFrozenSrc *MemoryBuffer // frozen source at cache time
|
||||
prevMu sync.Mutex
|
||||
}
|
||||
|
||||
// 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(cancel chan struct{}, q HubReq) {
|
||||
defer q.Done()
|
||||
query := q.DataString()
|
||||
if query == "" {
|
||||
f.DrawMatches(nil)
|
||||
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,
|
||||
bufSize: bufSize,
|
||||
errorHandler: eh,
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
f.current = f.Matcher().Match(cancel, query, f.Buffer())
|
||||
f.SendStatusMsg("")
|
||||
f.selection.Clear()
|
||||
f.DrawMatches(nil)
|
||||
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 close(out)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case buf, ok := <-incoming:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("flusher: %#v", buf)
|
||||
}
|
||||
if err := f.Apply(ctx, buf, out); err != nil {
|
||||
reportFilterError(ctx, err, eh)
|
||||
}
|
||||
buffer.ReleaseLineListBuf(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if configBufSize > 0 {
|
||||
bufsiz = configBufSize
|
||||
} else {
|
||||
bufsiz = cap(buf)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
start := time.Now()
|
||||
lines := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("filter received done")
|
||||
}
|
||||
return
|
||||
case <-flushTicker.C:
|
||||
if len(buf) > 0 {
|
||||
flushCh <- wrap(buf)
|
||||
buf = buffer.GetLineListBuf()
|
||||
}
|
||||
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
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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=%#v, batch=%#v)", query, q.Batch())
|
||||
defer g.End()
|
||||
}
|
||||
|
||||
state := f.state
|
||||
if query == "" {
|
||||
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()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new pipeline
|
||||
p := pipeline.New()
|
||||
|
||||
// Determine the source: use incremental filtering if possible
|
||||
selectedFilter := state.Filters().Current()
|
||||
filterName := selectedFilter.String()
|
||||
|
||||
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(50 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
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(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()
|
||||
}
|
||||
}
|
||||
|
||||
// Loop keeps watching for incoming queries, and upon receiving
|
||||
// a query, spawns a goroutine to do the heavy work. It also
|
||||
// checks for previously running queries, so we can avoid
|
||||
// running many goroutines doing the grep at the same time
|
||||
func (f *Filter) Loop() {
|
||||
defer f.ReleaseWaitGroup()
|
||||
func (f *Filter) Loop(ctx context.Context, cancel func()) error {
|
||||
defer cancel()
|
||||
|
||||
// previous holds a channel that can cancel the previous
|
||||
// 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
|
||||
var previous chan struct{}
|
||||
var mutex sync.Mutex
|
||||
var previous func()
|
||||
for {
|
||||
select {
|
||||
case <-f.LoopCh():
|
||||
return
|
||||
case q := <-f.QueryCh():
|
||||
if previous != nil {
|
||||
// Tell the previous query to stop
|
||||
previous <- struct{}{}
|
||||
}
|
||||
previous = make(chan struct{}, 1)
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case q := <-f.state.Hub().QueryCh():
|
||||
workctx, workcancel := context.WithCancel(ctx)
|
||||
|
||||
f.SendStatusMsg("Running query...")
|
||||
go f.Work(previous, q)
|
||||
mutex.Lock()
|
||||
if previous != nil {
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("Canceling previous query")
|
||||
}
|
||||
previous()
|
||||
}
|
||||
previous = workcancel
|
||||
mutex.Unlock()
|
||||
|
||||
f.state.Hub().SendStatusMsg(ctx, "Running query...", 0)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
206
filter/external.go
Normal file
206
filter/external.go
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sync"
|
||||
|
||||
pdebug "github.com/lestrrat-go/pdebug"
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
)
|
||||
|
||||
// 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{queryPlaceholder}
|
||||
}
|
||||
|
||||
if threshold <= 0 {
|
||||
threshold = DefaultCustomFilterBufferThreshold
|
||||
}
|
||||
|
||||
return &ExternalCmd{
|
||||
args: args,
|
||||
cmd: cmd,
|
||||
enableSep: enableSep,
|
||||
idgen: idgen,
|
||||
name: name,
|
||||
thresholdBufsiz: threshold,
|
||||
}
|
||||
}
|
||||
|
||||
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 r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic in external filter %q: %v", ecf.name, r)
|
||||
}
|
||||
if err == nil && readerPanicErr != nil {
|
||||
err = readerPanicErr
|
||||
}
|
||||
}()
|
||||
if pdebug.Enabled {
|
||||
g := pdebug.Marker("ExternalCmd.Apply").BindError(&err)
|
||||
defer g.End()
|
||||
}
|
||||
|
||||
query := pipeline.QueryFromContext(ctx)
|
||||
args := append([]string(nil), ecf.args...)
|
||||
for i, v := range args {
|
||||
if v == queryPlaceholder {
|
||||
args[i] = query
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
inbuf.WriteByte('\n')
|
||||
}
|
||||
|
||||
cmd.Stdin = inbuf
|
||||
r, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start command: %w", err)
|
||||
}
|
||||
|
||||
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 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():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
b, _, err := rdr.ReadLine()
|
||||
if len(b) > 0 {
|
||||
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 <- 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
|
||||
}
|
||||
}
|
||||
}(ctx, cmdCh, bufio.NewReader(r))
|
||||
|
||||
defer func() {
|
||||
if p := cmd.Process; p != nil {
|
||||
_ = p.Kill()
|
||||
}
|
||||
wg.Wait()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case l, ok := <-cmdCh:
|
||||
if l == nil || !ok {
|
||||
return nil
|
||||
}
|
||||
if err := out.Send(ctx, l); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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())
|
||||
})
|
||||
}
|
||||
101
filter/filter.go
Normal file
101
filter/filter.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package filter
|
||||
|
||||
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 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
|
||||
type byMatchStart [][]int
|
||||
|
||||
func (m byMatchStart) Len() int {
|
||||
return len(m)
|
||||
}
|
||||
|
||||
func (m byMatchStart) Swap(i, j int) {
|
||||
m[i], m[j] = m[j], m[i]
|
||||
}
|
||||
|
||||
func (m byMatchStart) Less(i, j int) bool {
|
||||
if m[i][0] < m[j][0] {
|
||||
return true
|
||||
}
|
||||
|
||||
if m[i][0] == m[j][0] {
|
||||
return m[i][1]-m[i][0] < m[j][1]-m[j][0]
|
||||
}
|
||||
|
||||
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 {
|
||||
a[0] = min(a[0], b[0])
|
||||
a[1] = max(a[1], b[1])
|
||||
return a
|
||||
}
|
||||
706
filter/filter_test.go
Normal file
706
filter/filter_test.go
Normal file
|
|
@ -0,0 +1,706 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type indexer interface {
|
||||
Indices() [][]int
|
||||
}
|
||||
|
||||
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
|
||||
selected bool
|
||||
}{
|
||||
{"this is a test to test the fuzzy Filter", "tf", true}, // normal selection
|
||||
{"this is a test to test the fuzzy Filter", "wp", false}, // incorrect selection
|
||||
{"THIS IS A TEST TO TEST THE FUZZY FILTER", "tu", true}, // case insensitivity
|
||||
{"this is a Test to test the fuzzy filter", "Tu", true}, // case sensitivity
|
||||
{"this is a Test to test the fUzzy filter", "TU", true}, // case sensitivity
|
||||
{"this is a test to test the fuzzy filter", "Tu", false}, // case sensitivity
|
||||
{"this is a test to Test the fuzzy filter", "TU", false}, // case sensitivity
|
||||
{"日本語は難しいです", "難", true}, // kanji
|
||||
{"あ、日本語は難しいですよ", "あい", true}, // hiragana
|
||||
{"パソコンは遅いですネ", "ソネ", true}, // katakana
|
||||
{"🚴🏻 abcd efgh", "🚴🏻e", true}, // unicode
|
||||
{"This is a test to Test the fuzzy filteR", "TTR", true},
|
||||
}
|
||||
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 line.Line, 1)
|
||||
l := line.NewRaw(uint64(i), v.input, false, false)
|
||||
err := filter.Apply(ctx, []line.Line{l}, pipeline.ChanOutput(ch))
|
||||
require.NoError(t, err, `filter.Apply should succeed`)
|
||||
|
||||
select {
|
||||
case l, ok := <-ch:
|
||||
require.True(t, ok, `channel read should succeed`)
|
||||
t.Logf("%#v", l.(indexer).Indices())
|
||||
case <-ctx.Done():
|
||||
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())
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
265
filter/fuzzy.go
Normal file
265
filter/fuzzy.go
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/peco/peco/internal/util"
|
||||
"github.com/peco/peco/line"
|
||||
"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(.*)".
|
||||
//
|
||||
// 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) SupportsParallel() bool {
|
||||
return !ff.sortLongest
|
||||
}
|
||||
|
||||
func (ff Fuzzy) String() string {
|
||||
return "Fuzzy"
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
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())
|
||||
}
|
||||
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)
|
||||
}
|
||||
334
filter/regexp.go
Normal file
334
filter/regexp.go
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/peco/peco/internal/util"
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/peco/peco/pipeline"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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 len(flags) > 0 {
|
||||
reTxt = fmt.Sprintf("(?%s)%s", strings.Join(flags, ""), reTxt)
|
||||
}
|
||||
|
||||
re, err := regexp.Compile(reTxt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile regular expression '%s': %w", reTxt, err)
|
||||
}
|
||||
return re, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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, fmt.Errorf("failed to compile regular expression '%s': %w", t, err)
|
||||
}
|
||||
regexps = append(regexps, re)
|
||||
}
|
||||
return regexps, nil
|
||||
}
|
||||
|
||||
// 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: 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.positive, rq.negative, nil
|
||||
}
|
||||
delete(f.compiled, s)
|
||||
}
|
||||
|
||||
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.positive = posRxs
|
||||
rq.negative = negRxs
|
||||
f.compiled[s] = rq
|
||||
return posRxs, negRxs, nil
|
||||
}
|
||||
|
||||
// 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 fmt.Errorf("failed to compile queries as regular expression: %w", err)
|
||||
}
|
||||
|
||||
// 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 = matches[:0]
|
||||
TryRegexps:
|
||||
for _, rx := range posRegexps {
|
||||
match := rx.FindAllStringIndex(v, -1)
|
||||
if match == nil {
|
||||
allMatched = false
|
||||
break TryRegexps
|
||||
}
|
||||
matches = append(matches, match...)
|
||||
}
|
||||
|
||||
if !allMatched {
|
||||
continue
|
||||
}
|
||||
|
||||
sort.Sort(byMatchStart(matches))
|
||||
|
||||
// We need to "dedupe" the results. For example, if we matched the
|
||||
// same region twice, we don't want that to be drawn
|
||||
|
||||
deduped = deduped[:0]
|
||||
|
||||
for j, m := range matches {
|
||||
// Always push the first one
|
||||
if j == 0 {
|
||||
deduped = append(deduped, m)
|
||||
continue
|
||||
}
|
||||
|
||||
prev := deduped[len(deduped)-1]
|
||||
switch {
|
||||
case matchContains(prev, m):
|
||||
// If the previous match contains this one, then
|
||||
// don't do anything
|
||||
continue
|
||||
case matchOverlaps(prev, m):
|
||||
// If the previous match overlaps with this one,
|
||||
// merge the results and make it a bigger one
|
||||
deduped[len(deduped)-1] = mergeMatches(prev, m)
|
||||
default:
|
||||
deduped = append(deduped, m)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) SupportsParallel() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (rf *Regexp) String() string {
|
||||
return rf.name
|
||||
}
|
||||
|
||||
// NewIgnoreCase creates a case-insensitive literal string filter.
|
||||
func NewIgnoreCase() *Regexp {
|
||||
return newRegexpFilter("IgnoreCase", ignoreCaseFlags, true)
|
||||
}
|
||||
|
||||
// NewCaseSensitive creates a case-sensitive literal string filter.
|
||||
func NewCaseSensitive() *Regexp {
|
||||
return newRegexpFilter("CaseSensitive", defaultFlags, true)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return newRegexpFilter("SmartCase", regexpFlagFunc(func(q string) []string {
|
||||
if util.ContainsUpper(q) {
|
||||
return defaultFlags
|
||||
}
|
||||
return []string{"i"}
|
||||
}), true)
|
||||
}
|
||||
80
filter/set.go
Normal file
80
filter/set.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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()
|
||||
fs.current++
|
||||
if fs.current >= len(fs.filters) {
|
||||
fs.current = 0
|
||||
}
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("Set.Rotate: now filter in effect is %s", fs.filters[fs.current])
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
for i, f := range fs.filters {
|
||||
if f.String() == name {
|
||||
fs.current = i
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrFilterNotFound
|
||||
}
|
||||
|
||||
func (fs *Set) Index() int {
|
||||
fs.mutex.Lock()
|
||||
defer fs.mutex.Unlock()
|
||||
return fs.current
|
||||
}
|
||||
|
||||
func (fs *Set) Current() Filter {
|
||||
fs.mutex.Lock()
|
||||
defer fs.mutex.Unlock()
|
||||
return fs.filters[fs.current]
|
||||
}
|
||||
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")
|
||||
}
|
||||
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=
|
||||
157
hub.go
157
hub.go
|
|
@ -1,157 +0,0 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
loopCh chan struct{}
|
||||
queryCh chan HubReq
|
||||
drawCh chan HubReq
|
||||
statusMsgCh chan HubReq
|
||||
clearStatusCh chan HubReq
|
||||
pagingCh chan HubReq
|
||||
}
|
||||
|
||||
// HubReq is a wrapper around the actual requst 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 HubReq struct {
|
||||
data interface{}
|
||||
replyCh chan struct{}
|
||||
}
|
||||
|
||||
// DataInterface returns the underlying data as interface{}
|
||||
func (hr HubReq) DataInterface() interface{} {
|
||||
return hr.data
|
||||
}
|
||||
|
||||
// DataString returns the underlying data as a string. Panics
|
||||
// if type conversion fails.
|
||||
func (hr HubReq) DataString() string {
|
||||
return hr.data.(string)
|
||||
}
|
||||
|
||||
// Done marks the request as done. If Hub is operating in
|
||||
// asynchronous mode (default), it's a no op. Otherwise it
|
||||
// sends a message back the reply channel to finish up the
|
||||
// synchronous communication
|
||||
func (hr HubReq) Done() {
|
||||
if hr.replyCh == nil {
|
||||
return
|
||||
}
|
||||
hr.replyCh <- struct{}{}
|
||||
}
|
||||
|
||||
// NewHub creates a new Hub struct
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
false,
|
||||
&sync.Mutex{},
|
||||
make(chan struct{}), // loopCh. You never send messages to this. no point in buffering
|
||||
make(chan HubReq, 5), // queryCh.
|
||||
make(chan HubReq, 5), // drawCh.
|
||||
make(chan HubReq, 5), // statusMsgCh
|
||||
make(chan HubReq, 5), // clearStatusCh
|
||||
make(chan HubReq, 5), // pagingCh
|
||||
}
|
||||
}
|
||||
|
||||
// Batch allows you to synchronously send messages during the
|
||||
// scope of f() being executed.
|
||||
func (h *Hub) Batch(f func()) {
|
||||
// lock during this operation
|
||||
h.mutex.Lock()
|
||||
defer h.mutex.Unlock()
|
||||
|
||||
// temporarily set isSync = true
|
||||
o := h.isSync
|
||||
h.isSync = true
|
||||
defer func() { h.isSync = o }()
|
||||
|
||||
// ignore panics
|
||||
defer func() { recover() }()
|
||||
|
||||
f()
|
||||
}
|
||||
|
||||
// low-level utility
|
||||
func send(ch chan HubReq, r HubReq, needReply bool) {
|
||||
if needReply {
|
||||
r.replyCh = make(chan struct{})
|
||||
defer func() { <-r.replyCh }()
|
||||
}
|
||||
|
||||
ch <- r
|
||||
}
|
||||
|
||||
// QueryCh returns the underlying channel for queries
|
||||
func (h *Hub) QueryCh() chan HubReq {
|
||||
return h.queryCh
|
||||
}
|
||||
|
||||
// SendQuery sends the query string to be processed by the Filter
|
||||
func (h *Hub) SendQuery(q string) {
|
||||
send(h.QueryCh(), HubReq{q, nil}, h.isSync)
|
||||
}
|
||||
|
||||
// LoopCh returns the channel to control the main execution loop.
|
||||
// Nothing should ever be sent through this channel. The only way
|
||||
// the channel communicates anything to its receivers is when
|
||||
// it is closed -- which is when peco is done.
|
||||
func (h *Hub) LoopCh() chan struct{} {
|
||||
return h.loopCh
|
||||
}
|
||||
|
||||
// DrawCh returns the channel to redraw the terminal display
|
||||
func (h *Hub) DrawCh() chan HubReq {
|
||||
return h.drawCh
|
||||
}
|
||||
|
||||
// SendDraw sends a request to redraw the terminal display
|
||||
func (h *Hub) SendDraw(matches []Match) {
|
||||
send(h.DrawCh(), HubReq{matches, nil}, h.isSync)
|
||||
}
|
||||
|
||||
// StatusMsgCh returns the channel to update the status message
|
||||
func (h *Hub) StatusMsgCh() chan HubReq {
|
||||
return h.statusMsgCh
|
||||
}
|
||||
|
||||
// SendStatusMsg sends a string to be displayed in the status message
|
||||
func (h *Hub) SendStatusMsg(q string) {
|
||||
send(h.StatusMsgCh(), HubReq{q, nil}, h.isSync)
|
||||
}
|
||||
|
||||
func (h *Hub) ClearStatusCh() chan HubReq {
|
||||
return h.clearStatusCh
|
||||
}
|
||||
|
||||
// SendClearStatus sends a request to clear the status message in
|
||||
// `d` duration. If a new status message is sent before the clear
|
||||
// request is executed, the clear instruction will be canceled
|
||||
func (h *Hub) SendClearStatus(d time.Duration) {
|
||||
send(h.ClearStatusCh(), HubReq{d, nil}, h.isSync)
|
||||
}
|
||||
|
||||
// PagingCh returns the channel to page through the results
|
||||
func (h *Hub) PagingCh() chan HubReq {
|
||||
return h.pagingCh
|
||||
}
|
||||
|
||||
// SendPaging sends a request to move the cursor around
|
||||
func (h *Hub) SendPaging(x PagingRequest) {
|
||||
send(h.PagingCh(), HubReq{x, nil}, h.isSync)
|
||||
}
|
||||
|
||||
// Stop closes the LoopCh so that peco shutsdown
|
||||
func (h *Hub) Stop() {
|
||||
close(h.LoopCh())
|
||||
}
|
||||
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
|
||||
}
|
||||
240
hub/hub.go
Normal file
240
hub/hub.go
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pdebug "github.com/lestrrat-go/pdebug"
|
||||
)
|
||||
|
||||
// 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]
|
||||
}
|
||||
|
||||
// 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. 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
|
||||
}
|
||||
p.done <- struct{}{}
|
||||
}
|
||||
|
||||
// New creates a new Hub struct
|
||||
func New(bufsiz int) *Hub {
|
||||
return &Hub{
|
||||
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. 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 (nested=%t)", nested)
|
||||
defer g.End()
|
||||
}
|
||||
|
||||
if !nested {
|
||||
h.mutex.Lock()
|
||||
defer h.mutex.Unlock()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}()
|
||||
|
||||
batchCtx := context.WithValue(ctx, batchCtxKey{}, true)
|
||||
f(batchCtx)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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[string] {
|
||||
return h.queryCh
|
||||
}
|
||||
|
||||
// SendQuery sends the query string to be processed by the Filter
|
||||
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[*DrawOptions] {
|
||||
return h.drawCh
|
||||
}
|
||||
|
||||
// SendDrawPrompt sends a request to redraw the prompt only
|
||||
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(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[StatusMsg] {
|
||||
return h.statusMsgCh
|
||||
}
|
||||
|
||||
// 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 {
|
||||
msg string
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (r statusMsgReq) Message() string {
|
||||
return r.msg
|
||||
}
|
||||
|
||||
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,
|
||||
delay: d,
|
||||
}
|
||||
}
|
||||
|
||||
// PagingCh returns the channel to page through the results
|
||||
func (h *Hub) PagingCh() chan *Payload[PagingRequest] {
|
||||
return h.pagingCh
|
||||
}
|
||||
|
||||
// SendPaging sends a request to move the cursor around
|
||||
func (h *Hub) SendPaging(ctx context.Context, x PagingRequest) {
|
||||
send(ctx, h.PagingCh(), NewPayload(x, isBatchCtx(ctx)))
|
||||
}
|
||||
187
hub/hub_test.go
Normal file
187
hub/hub_test.go
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
package hub_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/peco/peco/hub"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHub(t *testing.T) {
|
||||
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()
|
||||
// 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()
|
||||
// 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(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",
|
||||
"draw",
|
||||
"status",
|
||||
"paging",
|
||||
}
|
||||
|
||||
last := len(phases) - 1
|
||||
for i := range phases {
|
||||
if last == i {
|
||||
break
|
||||
}
|
||||
|
||||
cur := phases[i]
|
||||
next := phases[i+1]
|
||||
|
||||
t.Logf("Checkin if %s was fired before %s", cur, next)
|
||||
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())
|
||||
})
|
||||
}
|
||||
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]]
|
||||
}
|
||||
122
input.go
122
input.go
|
|
@ -1,94 +1,68 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
"context"
|
||||
|
||||
"github.com/nsf/termbox-go"
|
||||
"github.com/lestrrat-go/pdebug"
|
||||
)
|
||||
|
||||
// Input handles input events from termbox.
|
||||
// Input handles dispatching key events to actions.
|
||||
type Input struct {
|
||||
*Ctx
|
||||
mutex *sync.Mutex // Currently only used for protecting Alt/Esc workaround
|
||||
mod *time.Timer
|
||||
keymap Keymap
|
||||
currentKeySeq []string
|
||||
actions ActionMap
|
||||
evsrc chan Event
|
||||
state *Peco
|
||||
}
|
||||
|
||||
// Loop watches for incoming events from termbox, and pass them
|
||||
// to the appropriate handler when something arrives.
|
||||
func (i *Input) Loop() {
|
||||
defer i.ReleaseWaitGroup()
|
||||
// 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,
|
||||
state: state,
|
||||
}
|
||||
}
|
||||
|
||||
// XXX termbox.PollEvent() can get stuck on unexpected signal
|
||||
// handling cases. We still would like to wait until the user
|
||||
// (termbox) has some event for us to process, but we don't
|
||||
// want to allow termbox to control/block our input loop.
|
||||
//
|
||||
// Solution: put termbox polling in a separate goroutine,
|
||||
// and we just watch for a channel. The loop can now
|
||||
// safely be implemented in terms of select {} which is
|
||||
// safe from being stuck.
|
||||
evCh := make(chan termbox.Event)
|
||||
go func() {
|
||||
for {
|
||||
evCh <- termbox.PollEvent()
|
||||
}
|
||||
}()
|
||||
// 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()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-i.LoopCh(): // can only fall here if we closed c.loopCh
|
||||
return
|
||||
case ev := <-evCh:
|
||||
i.handleInputEvent(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Input) handleInputEvent(ev termbox.Event) {
|
||||
switch ev.Type {
|
||||
case termbox.EventError:
|
||||
//update = false
|
||||
case termbox.EventResize:
|
||||
i.DrawMatches(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 (juuuuse a few milliseconds) for
|
||||
// extra key events. If no extra events arrive, it should be Esc
|
||||
|
||||
// Smells like Esc or Alt. mod == nil checks for the presense
|
||||
// of a previous timer
|
||||
if ev.Ch == 0 && ev.Key == 27 && i.mod == nil {
|
||||
tmp := ev
|
||||
i.mutex.Lock()
|
||||
i.mod = time.AfterFunc(50*time.Millisecond, func() {
|
||||
i.mutex.Lock()
|
||||
i.mod = nil
|
||||
i.mutex.Unlock()
|
||||
i.handleKeyEvent(tmp)
|
||||
})
|
||||
i.mutex.Unlock()
|
||||
} else {
|
||||
// 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
|
||||
i.mutex.Lock()
|
||||
if i.mod != nil {
|
||||
i.mod.Stop()
|
||||
i.mod = nil
|
||||
ev.Mod |= termbox.ModAlt
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case ev, ok := <-i.evsrc:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := i.handleInputEvent(ctx, ev); err != nil {
|
||||
return nil
|
||||
}
|
||||
i.mutex.Unlock()
|
||||
i.handleKeyEvent(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Input) handleKeyEvent(ev termbox.Event) {
|
||||
if h := i.keymap.Handler(ev); h != nil {
|
||||
h.Execute(i, ev)
|
||||
return
|
||||
// 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 EventError:
|
||||
return nil
|
||||
case EventResize:
|
||||
i.state.Hub().SendDraw(ctx, nil)
|
||||
return nil
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
30
internal/buffer/line.go
Normal file
30
internal/buffer/line.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package buffer
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/peco/peco/line"
|
||||
)
|
||||
|
||||
const DefaultFilterBufSize = 1000
|
||||
|
||||
var lineListPool = sync.Pool{
|
||||
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) //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)
|
||||
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 {
|
||||
75
internal/keyseq/ahocorasick_test.go
Normal file
75
internal/keyseq/ahocorasick_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package keyseq
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func checkNode(t *testing.T, node Node, size int, data nodeData) {
|
||||
require.NotNil(t, node, "Nil node:", data)
|
||||
require.Equal(t, size, node.Size(), "Unexpected childrens")
|
||||
d := node.Value().(*nodeData)
|
||||
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.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 {
|
||||
return nodeData{
|
||||
failure: failure.(*TernaryNode),
|
||||
}
|
||||
}
|
||||
|
||||
func validData(pattern KeyList, value any, failure Node) nodeData {
|
||||
return nodeData{
|
||||
pattern: &pattern,
|
||||
value: value,
|
||||
failure: failure.(*TernaryNode),
|
||||
}
|
||||
}
|
||||
|
||||
func newTestMatcher() *Matcher {
|
||||
m := NewMatcher()
|
||||
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
|
||||
}
|
||||
|
||||
func TestTree(t *testing.T) {
|
||||
m := newTestMatcher()
|
||||
// Check tree structure.
|
||||
r := m.Root()
|
||||
checkNode(t, r, 3, invalidData(r))
|
||||
n1 := r.Get(NewKeyFromKey(KeyCtrlA))
|
||||
checkNode(t, n1, 1, invalidData(r))
|
||||
n3 := r.Get(NewKeyFromKey(KeyCtrlB))
|
||||
checkNode(t, n3, 2, invalidData(r))
|
||||
n7 := r.Get(NewKeyFromKey(KeyCtrlD))
|
||||
checkNode(t, n7, 0, invalidData(r))
|
||||
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(KeyCtrlC))
|
||||
checkNode(t, n8, 1, invalidData(n4))
|
||||
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(KeyCtrlE))
|
||||
checkNode(t, n10, 0, validData(KeyList{NewKeyFromKey(KeyCtrlA), NewKeyFromKey(KeyCtrlB), NewKeyFromKey(KeyCtrlC), NewKeyFromKey(KeyCtrlD), NewKeyFromKey(KeyCtrlE)}, 10, r))
|
||||
}
|
||||
293
internal/keyseq/keys.go
Normal file
293
internal/keyseq/keys.go
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
package keyseq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// KeyType represents a keyboard key. The adapter layer in screen.go maps
|
||||
// between peco's KeyType and tcell's key constants.
|
||||
type KeyType uint16
|
||||
|
||||
// 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 := KeyF1; k >= KeyF12; k-- {
|
||||
sk := fmt.Sprintf("F%d", fidx)
|
||||
mapkey(sk, k)
|
||||
fidx++
|
||||
}
|
||||
|
||||
names := []string{
|
||||
"Insert",
|
||||
"Delete",
|
||||
"Home",
|
||||
"End",
|
||||
"Pgup",
|
||||
"Pgdn",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
}
|
||||
for i, n := range names {
|
||||
mapkey(n, KeyType(int(KeyF12)-(i+1)))
|
||||
}
|
||||
|
||||
names = []string{
|
||||
"Left",
|
||||
"Middle",
|
||||
"Right",
|
||||
}
|
||||
for i, n := range names {
|
||||
sk := fmt.Sprintf("Mouse%s", n)
|
||||
mapkey(sk, KeyType(int(KeyArrowRight)-(i+2)))
|
||||
}
|
||||
|
||||
ctrlKeyNames := [][]string{
|
||||
{"~", "2", "Space"},
|
||||
{"a"},
|
||||
{"b"},
|
||||
{"c"},
|
||||
{"d"},
|
||||
{"e"},
|
||||
{"f"},
|
||||
{"g"},
|
||||
{"h"},
|
||||
{"i"},
|
||||
{"j"},
|
||||
{"k"},
|
||||
{"l"},
|
||||
{"m"},
|
||||
{"n"},
|
||||
{"o"},
|
||||
{"p"},
|
||||
{"q"},
|
||||
{"r"},
|
||||
{"s"},
|
||||
{"t"},
|
||||
{"u"},
|
||||
{"v"},
|
||||
{"w"},
|
||||
{"x"},
|
||||
{"y"},
|
||||
{"z"},
|
||||
{"[", "3"},
|
||||
{"4", "\\"},
|
||||
{"5", "]"},
|
||||
{"6"},
|
||||
{"7", "/", "_"},
|
||||
}
|
||||
for i, list := range ctrlKeyNames {
|
||||
for _, n := range list {
|
||||
sk := fmt.Sprintf("C-%s", n)
|
||||
mapkey(sk, KeyType(int(KeyCtrlTilde)+i))
|
||||
}
|
||||
}
|
||||
|
||||
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.SplitSeq(ksk, ",") {
|
||||
term = strings.TrimSpace(term)
|
||||
|
||||
k, m, ch, err := ToKey(term)
|
||||
if err != nil {
|
||||
return list, fmt.Errorf("failed to convert '%s': %w", term, err)
|
||||
}
|
||||
|
||||
list = append(list, Key{m, k, ch})
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// 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[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no such key %d (ch=%c)", key, ch)
|
||||
}
|
||||
|
||||
// Special case for ArrowUp/Down/Left/Right
|
||||
switch s {
|
||||
case "ArrowUp":
|
||||
s = "^"
|
||||
case "ArrowDown":
|
||||
s = "v"
|
||||
case "ArrowLeft":
|
||||
s = "<"
|
||||
case "ArrowRight":
|
||||
s = ">"
|
||||
}
|
||||
}
|
||||
|
||||
if m := mod.String(); m != "" {
|
||||
return m + "-" + s, nil
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return 0, modifier, rune(key[0]), nil
|
||||
}
|
||||
}
|
||||
|
||||
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 0, modifier, 0, fmt.Errorf("no such key %s", key)
|
||||
}
|
||||
254
internal/keyseq/keys_test.go
Normal file
254
internal/keyseq/keys_test.go
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
package keyseq
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKeymapStrToKeyValue(t *testing.T) {
|
||||
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)
|
||||
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 KeyType
|
||||
ch rune
|
||||
}{
|
||||
"M-v": {0, 'v'},
|
||||
"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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeymapStrToKeyValueCh(t *testing.T) {
|
||||
expected := []string{
|
||||
"q", "w", "e", "r", "t", "y",
|
||||
"🙏", "せ", "か", "い", "〠",
|
||||
}
|
||||
|
||||
t.Logf("Checking character mapping...")
|
||||
for _, n := range expected {
|
||||
t.Logf(" checking %s...", n)
|
||||
k, modifier, ch, err := ToKey(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)
|
||||
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,49 +1,55 @@
|
|||
package keyseq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nsf/termbox-go"
|
||||
)
|
||||
|
||||
var ErrInSequence = fmt.Errorf("Currently expecting a key sequence")
|
||||
var ErrNoMatch = fmt.Errorf("Could not match key to any action")
|
||||
var ErrInSequence = errors.New("expected a key sequence")
|
||||
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,13 +65,19 @@ func (k Key) String() string {
|
|||
return s
|
||||
}
|
||||
|
||||
func NewKeyFromKey(k termbox.Key) Key {
|
||||
return Key{0, k, rune(0)}
|
||||
// NewKeyFromKey creates a Key from a KeyType with no modifier and no rune.
|
||||
func NewKeyFromKey(k KeyType) Key {
|
||||
return Key{
|
||||
Modifier: 0,
|
||||
Key: k,
|
||||
Ch: rune(0),
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -88,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
|
||||
}
|
||||
}
|
||||
|
|
@ -108,19 +121,24 @@ 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{NewMatcher(), nil, &sync.Mutex{}, time.Time{}}
|
||||
return &Keyseq{
|
||||
Matcher: NewMatcher(),
|
||||
current: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
|
@ -139,11 +157,12 @@ func (k *Keyseq) Current() keyseqMatcher {
|
|||
return k.current
|
||||
}
|
||||
|
||||
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 func() { k.prevInputTime = time.Now() }()
|
||||
c := k.Current()
|
||||
n := c.Get(key)
|
||||
|
||||
|
|
@ -164,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)
|
||||
|
||||
|
|
@ -174,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
|
||||
}
|
||||
51
internal/keyseq/ternary_test.go
Normal file
51
internal/keyseq/ternary_test.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package keyseq
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBalance(t *testing.T) {
|
||||
trie := NewTernaryTrie()
|
||||
|
||||
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)
|
||||
}
|
||||
require.Equal(t, 15, trie.Size())
|
||||
trie.Balance()
|
||||
|
||||
// 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
|
||||
32
internal/keyseq/trie_test.go
Normal file
32
internal/keyseq/trie_test.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package keyseq
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func checkTrieNode(t *testing.T, n Node, k Key, value int) {
|
||||
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) {
|
||||
trie := NewTrie()
|
||||
for i := 1; i <= 5; i++ {
|
||||
trie.Put(KeyList{Key{0, 0, rune(i)}}, 111*i)
|
||||
}
|
||||
|
||||
nodes := Children(trie.Root())
|
||||
for i := range 5 {
|
||||
checkTrieNode(t, nodes[i], Key{0, 0, rune(i + 1)}, 111*(i+1))
|
||||
}
|
||||
|
||||
require.Equal(t, 5, trie.Size())
|
||||
}
|
||||
|
||||
func TestNotFound(t *testing.T) {
|
||||
trie := NewTrie()
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
15
internal/util/homedir_darwin.go
Normal file
15
internal/util/homedir_darwin.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
func Homedir() (string, error) {
|
||||
home := os.Getenv("HOME")
|
||||
if home == "" {
|
||||
return "", errors.New("environment variable HOME not set")
|
||||
}
|
||||
|
||||
return home, nil
|
||||
}
|
||||
18
internal/util/homedir_posix.go
Normal file
18
internal/util/homedir_posix.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
//go:build !darwin && !windows
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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("environment variable HOME not set")
|
||||
}
|
||||
|
||||
return home, nil
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
package peco
|
||||
package util
|
||||
|
||||
import "os/user"
|
||||
|
||||
func homedir() (string, error) {
|
||||
func Homedir() (string, error) {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return "", err
|
||||
22
internal/util/shell_unix.go
Normal file
22
internal/util/shell_unix.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
//go:build !windows
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// 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`
|
||||
|
||||
args := make([]string, len(cmd)+1)
|
||||
args[0] = shellopt
|
||||
for i := range cmd {
|
||||
args[i+1] = cmd[i]
|
||||
}
|
||||
|
||||
return exec.CommandContext(ctx, shellpath, args...)
|
||||
}
|
||||
21
internal/util/shell_windows.go
Normal file
21
internal/util/shell_windows.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
//go:build windows
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func Shell(ctx context.Context, cmd ...string) *exec.Cmd {
|
||||
const shellpath = `cmd`
|
||||
const shellopt = `/c`
|
||||
|
||||
args := make([]string, len(cmd)+1)
|
||||
args[0] = shellopt
|
||||
for i := range cmd {
|
||||
args[i+1] = cmd[i]
|
||||
}
|
||||
|
||||
return exec.CommandContext(ctx, shellpath, args...)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// +build darwin freebsd
|
||||
//go:build darwin || freebsd || openbsd || netbsd || dragonfly
|
||||
|
||||
package peco
|
||||
package util
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
|
@ -8,7 +8,13 @@ import (
|
|||
)
|
||||
|
||||
// IsTty checks if the given fd is a tty
|
||||
func IsTty(fd uintptr) bool {
|
||||
func IsTty(arg any) bool {
|
||||
fdsrc, ok := arg.(fder)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
fd := fdsrc.Fd()
|
||||
|
||||
var termios syscall.Termios
|
||||
_, _, err := syscall.Syscall6(syscall.SYS_IOCTL, fd, uintptr(syscall.TIOCGETA), uintptr(unsafe.Pointer(&termios)), 0, 0, 0)
|
||||
return err == 0
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// +build !darwin,!windows,!freebsd
|
||||
//go:build !darwin && !windows && !freebsd && !openbsd && !netbsd && !dragonfly
|
||||
|
||||
package peco
|
||||
package util
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
|
@ -8,7 +8,13 @@ import (
|
|||
)
|
||||
|
||||
// IsTty checks if the given fd is a tty
|
||||
func IsTty(fd uintptr) bool {
|
||||
func IsTty(arg any) bool {
|
||||
fdsrc, ok := arg.(fder)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
fd := fdsrc.Fd()
|
||||
|
||||
var termios syscall.Termios
|
||||
_, _, err := syscall.Syscall6(syscall.SYS_IOCTL, fd, uintptr(syscall.TCGETS), uintptr(unsafe.Pointer(&termios)), 0, 0, 0)
|
||||
return err == 0
|
||||
68
internal/util/tty_windows.go
Normal file
68
internal/util/tty_windows.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var (
|
||||
kernel32 = syscall.MustLoadDLL("kernel32.dll")
|
||||
procSetStdHandle = kernel32.MustFindProc("SetStdHandle")
|
||||
procGetConsoleMode = kernel32.MustFindProc("GetConsoleMode")
|
||||
)
|
||||
|
||||
func getStdHandle(h int) (fd syscall.Handle) {
|
||||
r, _ := syscall.GetStdHandle(h)
|
||||
syscall.CloseOnExec(r)
|
||||
return r
|
||||
}
|
||||
|
||||
func setStdHandle(stdhandle int32, handle syscall.Handle) error {
|
||||
r1, _, err := procSetStdHandle.Call(uintptr(stdhandle), uintptr(handle))
|
||||
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 any) bool {
|
||||
fdsrc, ok := arg.(fder)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
fd := fdsrc.Fd()
|
||||
|
||||
var st uint32
|
||||
r1, _, _ := procGetConsoleMode.Call(fd, uintptr(unsafe.Pointer(&st)))
|
||||
return r1 != 0
|
||||
}
|
||||
|
||||
var stdin = os.Stdin
|
||||
|
||||
// TtyReady checks if the tty is ready to go
|
||||
func TtyReady() error {
|
||||
var err error
|
||||
|
||||
_stdin, err := os.Open("CONIN$")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stdin = os.Stdin
|
||||
os.Stdin = _stdin
|
||||
syscall.Stdin = syscall.Handle(os.Stdin.Fd())
|
||||
|
||||
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
|
||||
func TtyTerm() {
|
||||
os.Stdin = stdin
|
||||
syscall.Stdin = syscall.Handle(os.Stdin.Fd())
|
||||
setStdHandle(syscall.STD_INPUT_HANDLE, syscall.Stdin)
|
||||
}
|
||||
98
internal/util/util.go
Normal file
98
internal/util/util.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
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 {
|
||||
return lr == unicode.ToUpper(v)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Global var used to strips ansi sequences
|
||||
var reANSIEscapeChars = regexp.MustCompile("\x1B\\[(?:[0-9]{1,2}(?:;[0-9]{1,2})?)*[a-zA-Z]")
|
||||
|
||||
// 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 ignorable interface {
|
||||
Ignorable() bool
|
||||
}
|
||||
|
||||
type collectResults interface {
|
||||
CollectResults() bool
|
||||
}
|
||||
|
||||
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; 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; 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; e = errors.Unwrap(e) {
|
||||
if ese, ok := e.(exitStatuser); ok {
|
||||
return ese.ExitStatus(), true
|
||||
}
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
161
issues_test.go
Normal file
161
issues_test.go
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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, 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
|
||||
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)
|
||||
|
||||
defstyle := config.StyleSet{}
|
||||
defstyle.Init()
|
||||
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 := 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)
|
||||
|
||||
io.WriteString(f, `{
|
||||
"Layout": "bottom-up"
|
||||
}`)
|
||||
f.Close()
|
||||
|
||||
state = newPeco()
|
||||
go state.Run(ctx)
|
||||
|
||||
<-state.Ready()
|
||||
|
||||
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) {
|
||||
cfg, err := newConfig(`{
|
||||
"Keymap": {
|
||||
"C-t": "my.ToggleSelectionInAboveLine"
|
||||
},
|
||||
"Action": {
|
||||
"my.ToggleSelectionInAboveLine": [
|
||||
"peco.SelectUp",
|
||||
"peco.ToggleSelectionAndSelectNext"
|
||||
]
|
||||
}
|
||||
}`)
|
||||
require.NoError(t, err, "newConfig should succeed")
|
||||
defer os.Remove(cfg)
|
||||
|
||||
state := newPeco()
|
||||
state.configReader = defaultConfigReader
|
||||
require.NoError(t, state.config.Init(), "Config.Init should succeed")
|
||||
|
||||
state.Argv = append(state.Argv, []string{"--rcfile", cfg}...)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go state.Run(ctx)
|
||||
defer cancel()
|
||||
|
||||
<-state.Ready()
|
||||
|
||||
ev := Event{
|
||||
Type: EventKey,
|
||||
Key: keyseq.KeyCtrlT,
|
||||
}
|
||||
require.NoError(t, state.Keymap().ExecuteAction(ctx, state, ev), "ExecuteAction should succeed")
|
||||
|
||||
// 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() {}
|
||||
149
keymap.go
149
keymap.go
|
|
@ -1,82 +1,133 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"maps"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nsf/termbox-go"
|
||||
"github.com/peco/peco/keyseq"
|
||||
"github.com/lestrrat-go/pdebug"
|
||||
"github.com/peco/peco/internal/keyseq"
|
||||
)
|
||||
|
||||
// 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
|
||||
Keyseq *keyseq.Keyseq
|
||||
seq Keyseq
|
||||
}
|
||||
|
||||
// NewKeymap creates a new Keymap struct
|
||||
func NewKeymap(config map[string]string, actions map[string][]string) Keymap {
|
||||
return Keymap{config, actions, keyseq.New()}
|
||||
|
||||
return Keymap{
|
||||
Config: config,
|
||||
Action: actions,
|
||||
seq: keyseq.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the appropriate action for the given termbox event
|
||||
func (km Keymap) Handler(ev termbox.Event) Action {
|
||||
modifier := keyseq.ModNone
|
||||
if (ev.Mod & termbox.ModAlt) != 0 {
|
||||
modifier = keyseq.ModAlt
|
||||
func (km Keymap) Sequence() Keyseq {
|
||||
return km.seq
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
key := keyseq.Key{modifier, ev.Key, ev.Ch}
|
||||
action, err := km.Keyseq.AcceptKey(key)
|
||||
a := km.LookupAction(ev)
|
||||
if a == nil {
|
||||
return errors.New("action not found")
|
||||
}
|
||||
|
||||
a.Execute(ctx, state, ev)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LookupAction returns the appropriate action for the given event
|
||||
func (km Keymap) LookupAction(ev Event) Action {
|
||||
key := keyseq.Key{
|
||||
Modifier: ev.Mod,
|
||||
Key: ev.Key,
|
||||
Ch: ev.Ch,
|
||||
}
|
||||
action, err := km.seq.AcceptKey(key)
|
||||
|
||||
switch err {
|
||||
case nil:
|
||||
// Found an action!
|
||||
return wrapClearSequence(action.(Action))
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("Keymap.Handler: Fetched 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...")
|
||||
}
|
||||
return wrapRememberSequence(ActionFunc(doNothing))
|
||||
default:
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("Keymap.Handler: Defaulting to doAcceptChar")
|
||||
}
|
||||
return wrapClearSequence(ActionFunc(doAcceptChar))
|
||||
}
|
||||
}
|
||||
|
||||
// wrapRememberSequence wraps an action to record the key press as part of a multi-key sequence.
|
||||
func wrapRememberSequence(a Action) Action {
|
||||
return ActionFunc(func(i *Input, ev termbox.Event) {
|
||||
s, err := keyseq.EventToString(ev)
|
||||
if err == nil {
|
||||
i.currentKeySeq = append(i.currentKeySeq, s)
|
||||
i.SendStatusMsg(strings.Join(i.currentKeySeq, " "))
|
||||
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(ctx, strings.Join(seq.KeyNames(), " "), 0)
|
||||
}
|
||||
a.Execute(i, ev)
|
||||
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(i *Input, ev termbox.Event) {
|
||||
s, err := keyseq.EventToString(ev)
|
||||
if err == nil {
|
||||
i.currentKeySeq = append(i.currentKeySeq, s)
|
||||
return ActionFunc(func(ctx context.Context, state *Peco, ev Event) {
|
||||
seq := state.Inputseq()
|
||||
if s, err := keyseq.KeyEventToString(ev.Key, ev.Ch, ev.Mod); err == nil {
|
||||
seq.Add(s)
|
||||
}
|
||||
|
||||
if len(i.currentKeySeq) > 0 {
|
||||
i.SendStatusMsg(strings.Join(i.currentKeySeq, " "))
|
||||
i.currentKeySeq = []string{}
|
||||
if seq.Len() > 0 {
|
||||
msg := strings.Join(seq.KeyNames(), " ")
|
||||
state.Hub().SendStatusMsg(ctx, msg, 500*time.Millisecond)
|
||||
seq.Reset()
|
||||
}
|
||||
|
||||
i.SendClearStatus(500 * time.Millisecond)
|
||||
a.Execute(i, ev)
|
||||
a.Execute(ctx, state, ev)
|
||||
})
|
||||
}
|
||||
|
||||
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, fmt.Errorf("error: 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 ?
|
||||
|
|
@ -97,24 +148,21 @@ 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, fmt.Errorf("error: 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
|
||||
// the default key bindings
|
||||
func (km Keymap) ApplyKeybinding() {
|
||||
k := km.Keyseq
|
||||
func (km *Keymap) ApplyKeybinding() error {
|
||||
k := km.seq
|
||||
k.Clear()
|
||||
|
||||
// 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 {
|
||||
|
|
@ -125,27 +173,32 @@ func (km Keymap) ApplyKeybinding() {
|
|||
|
||||
v, err := km.resolveActionName(as, 0)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
continue
|
||||
return fmt.Errorf("failed to resolve action name %s: %w", as, err)
|
||||
}
|
||||
kb[s] = v
|
||||
}
|
||||
|
||||
// now compile using kb
|
||||
for s, a := range kb {
|
||||
// there's no need to do this, but we sort keys here just to make
|
||||
// debugging easier
|
||||
keys := make([]string, 0, len(kb))
|
||||
for s := range kb {
|
||||
keys = append(keys, s)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, s := range keys {
|
||||
a := kb[s]
|
||||
list, err := keyseq.ToKeyList(s)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Unknown key %s: %s", s, err)
|
||||
continue
|
||||
return fmt.Errorf("unknown key %s: %w", s, err)
|
||||
}
|
||||
|
||||
k.Add(list, a)
|
||||
}
|
||||
|
||||
k.Compile()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
package peco
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSelection(t *testing.T) {
|
||||
s := Selection([]int{})
|
||||
|
||||
s.Add(10)
|
||||
if s.Len() != 1 {
|
||||
t.Errorf("expected Len = 1, got %d", s.Len())
|
||||
}
|
||||
s.Add(1)
|
||||
if s.Len() != 2 {
|
||||
t.Errorf("expected Len = 2, got %d", s.Len())
|
||||
}
|
||||
s.Add(1)
|
||||
if s.Len() != 2 {
|
||||
t.Errorf("expected Len = 2, got %d", s.Len())
|
||||
}
|
||||
s.Remove(1)
|
||||
if s.Len() != 1 {
|
||||
t.Errorf("expected Len = 1, got %d", s.Len())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
package keyseq
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nsf/termbox-go"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
d := node.Value().(*nodeData)
|
||||
if d == nil {
|
||||
t.Error("Nil data:", data, node)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func invalidData(failure Node) nodeData {
|
||||
return nodeData{
|
||||
failure: failure.(*TernaryNode),
|
||||
}
|
||||
}
|
||||
|
||||
func validData(pattern KeyList, value interface{}, failure Node) nodeData {
|
||||
return nodeData{
|
||||
pattern: &pattern,
|
||||
value: value,
|
||||
failure: failure.(*TernaryNode),
|
||||
}
|
||||
}
|
||||
|
||||
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.Compile()
|
||||
return m
|
||||
}
|
||||
|
||||
func TestTree(t *testing.T) {
|
||||
m := newTestMatcher()
|
||||
// Check tree structure.
|
||||
r := m.Root()
|
||||
checkNode(t, r, 3, invalidData(r))
|
||||
n1 := r.Get(NewKeyFromKey(termbox.KeyCtrlA))
|
||||
checkNode(t, n1, 1, invalidData(r))
|
||||
n3 := r.Get(NewKeyFromKey(termbox.KeyCtrlB))
|
||||
checkNode(t, n3, 2, invalidData(r))
|
||||
n7 := r.Get(NewKeyFromKey(termbox.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))
|
||||
checkNode(t, n5, 1, invalidData(n1))
|
||||
n8 := n2.Get(NewKeyFromKey(termbox.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))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
177
keyseq/keys.go
177
keyseq/keys.go
|
|
@ -1,177 +0,0 @@
|
|||
package keyseq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/nsf/termbox-go"
|
||||
)
|
||||
|
||||
// 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{}
|
||||
|
||||
func mapkey(n string, k termbox.Key) {
|
||||
stringToKey[n] = k
|
||||
keyToString[k] = n
|
||||
}
|
||||
|
||||
func init() {
|
||||
fidx := 12
|
||||
for k := termbox.KeyF1; k > termbox.KeyF12; k-- {
|
||||
sk := fmt.Sprintf("F%d", fidx)
|
||||
mapkey(sk, k)
|
||||
fidx--
|
||||
}
|
||||
|
||||
names := []string{
|
||||
"Insert",
|
||||
"Delete",
|
||||
"Home",
|
||||
"End",
|
||||
"Pgup",
|
||||
"Pgdn",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
}
|
||||
for i, n := range names {
|
||||
mapkey(n, termbox.Key(int(termbox.KeyF12)-(i+1)))
|
||||
}
|
||||
|
||||
names = []string{
|
||||
"Left",
|
||||
"Middle",
|
||||
"Right",
|
||||
}
|
||||
for i, n := range names {
|
||||
sk := fmt.Sprintf("Mouse%s", n)
|
||||
mapkey(sk, termbox.Key(int(termbox.KeyArrowRight)-(i+2)))
|
||||
}
|
||||
|
||||
whacky := [][]string{
|
||||
{"~", "2", "Space"},
|
||||
{"a"},
|
||||
{"b"},
|
||||
{"c"},
|
||||
{"d"},
|
||||
{"e"},
|
||||
{"f"},
|
||||
{"g"},
|
||||
{"h"},
|
||||
{"i"},
|
||||
{"j"},
|
||||
{"k"},
|
||||
{"l"},
|
||||
{"m"},
|
||||
{"n"},
|
||||
{"o"},
|
||||
{"p"},
|
||||
{"q"},
|
||||
{"r"},
|
||||
{"s"},
|
||||
{"t"},
|
||||
{"u"},
|
||||
{"v"},
|
||||
{"w"},
|
||||
{"x"},
|
||||
{"y"},
|
||||
{"z"},
|
||||
{"[", "3"},
|
||||
{"4", "\\"},
|
||||
{"5", "]"},
|
||||
{"6"},
|
||||
{"7", "/", "_"},
|
||||
}
|
||||
for i, list := range whacky {
|
||||
for _, n := range list {
|
||||
sk := fmt.Sprintf("C-%s", n)
|
||||
mapkey(sk, termbox.Key(int(termbox.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))
|
||||
}
|
||||
|
||||
func ToKeyList(ksk string) (KeyList, error) {
|
||||
list := KeyList{}
|
||||
for _, term := range strings.Split(ksk, ",") {
|
||||
term = strings.TrimSpace(term)
|
||||
|
||||
k, m, ch, err := ToKey(term)
|
||||
if err != nil {
|
||||
return list, fmt.Errorf("Failed to convert '%s': %s", term, err)
|
||||
}
|
||||
|
||||
list = append(list, Key{m, k, ch})
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func EventToString(ev termbox.Event) (string, error) {
|
||||
s := ""
|
||||
if ev.Key == 0 {
|
||||
s = string([]rune{ev.Ch})
|
||||
} else {
|
||||
var ok bool
|
||||
s, ok = keyToString[ev.Key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("error: No such key %#v", ev)
|
||||
}
|
||||
|
||||
// Special case for ArrowUp/Down/Left/Right
|
||||
switch s {
|
||||
case "ArrowUp":
|
||||
s = "↑"
|
||||
case "ArrowDown":
|
||||
s = "↓"
|
||||
case "ArrowLeft":
|
||||
s = "←"
|
||||
case "ArrowRight":
|
||||
s = "→"
|
||||
}
|
||||
}
|
||||
|
||||
if ev.Mod&termbox.ModAlt == 1 {
|
||||
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:]
|
||||
if len(key) == 1 {
|
||||
ch = rune(key[0])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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 = fmt.Errorf("No such key %s", key)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
package keyseq
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nsf/termbox-go"
|
||||
)
|
||||
|
||||
func TestKeymapStrToKeyValue(t *testing.T) {
|
||||
expected := map[string]termbox.Key{
|
||||
"Insert": termbox.KeyInsert,
|
||||
"MouseLeft": termbox.MouseLeft,
|
||||
"C-k": termbox.KeyCtrlK,
|
||||
"C-h": termbox.KeyCtrlH,
|
||||
"C-i": termbox.KeyCtrlI,
|
||||
"C-l": termbox.KeyCtrlL,
|
||||
"C-m": termbox.KeyCtrlM,
|
||||
"C-[": termbox.KeyCtrlLsqBracket,
|
||||
"C-\\": termbox.KeyCtrlBackslash,
|
||||
"C-_": termbox.KeyCtrlUnderscore,
|
||||
"C-8": termbox.KeyCtrl8,
|
||||
}
|
||||
|
||||
t.Logf("Checking key name -> actual key value mapping...")
|
||||
for n, v := range expected {
|
||||
t.Logf(" checking %s...", n)
|
||||
// TODO ch isn't being checked
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeymapStrToKeyValueWithAlt(t *testing.T) {
|
||||
expected := map[string]struct {
|
||||
key termbox.Key
|
||||
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)},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
package keyseq
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalance(t *testing.T) {
|
||||
trie := NewTernaryTrie()
|
||||
|
||||
list := []Key{}
|
||||
for i := 0; i < 15; i++ {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
*/
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue