Compare commits

..

778 commits

Author SHA1 Message Date
lestrrat 4368da0fbf
Merge pull request #843 from peco/dependabot/go_modules/github.com/stretchr/testify-1.12.1
Some checks failed
CI / Go ${{ matrix.go }} test (1.25) (push) Has been cancelled
CI / Check generated code (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
lint / lint (push) Has been cancelled
build(deps): bump github.com/stretchr/testify from 1.11.1 to 1.12.1
2026-09-05 10:24:55 +09:00
dependabot[bot] d473d30d3a
build(deps): bump github.com/stretchr/testify from 1.11.1 to 1.12.1
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.11.1 to 1.12.1.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.11.1...v1.12.1)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-version: 1.12.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-05 01:21:22 +00:00
lestrrat ea8d6f064d
Merge pull request #842 from peco/dependabot/go_modules/github.com/mattn/go-runewidth-0.0.28
build(deps): bump github.com/mattn/go-runewidth from 0.0.27 to 0.0.28
2026-09-05 10:20:16 +09:00
lestrrat 578b59851e
Merge pull request #844 from peco/dependabot/github_actions/github/codeql-action-4.37.9
build(deps): bump github/codeql-action from 4.37.3 to 4.37.9
2026-09-05 10:19:51 +09:00
dependabot[bot] beaf90d759
build(deps): bump github/codeql-action from 4.37.3 to 4.37.9
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.3 to 4.37.9.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4.37.3...v4.37.9)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-29 00:53:11 +00:00
dependabot[bot] ed3e39d9a2
build(deps): bump github.com/mattn/go-runewidth from 0.0.27 to 0.0.28
Bumps [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) from 0.0.27 to 0.0.28.
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.27...v0.0.28)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.28
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-22 10:02:31 +00:00
lestrrat 2e25a62fcd
Merge pull request #838 from peco/dependabot/github_actions/actions/checkout-7.0.1
Some checks failed
CI / Go ${{ matrix.go }} test (1.25) (push) Has been cancelled
CI / Check generated code (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
lint / lint (push) Has been cancelled
build(deps): bump actions/checkout from 7.0.0 to 7.0.1
2026-08-21 17:23:12 +09:00
dependabot[bot] e7ffb77baa
build(deps): bump actions/checkout from 7.0.0 to 7.0.1
Bumps [actions/checkout](https://github.com/actions/checkout) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](9c091bb21b...3d3c42e5aa)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-21 08:18:13 +00:00
lestrrat ed3929e8c4
Merge pull request #840 from peco/dependabot/go_modules/github.com/mattn/go-runewidth-0.0.27
build(deps): bump github.com/mattn/go-runewidth from 0.0.24 to 0.0.27
2026-08-21 17:18:09 +09:00
lestrrat 3752f7fc38
Merge pull request #839 from peco/dependabot/github_actions/github/codeql-action-4.37.3
build(deps): bump github/codeql-action from 4 to 4.37.3
2026-08-21 17:17:54 +09:00
lestrrat 40bcfa25d1
Merge pull request #835 from peco/dependabot/github_actions/actions/setup-go-7.0.0
build(deps): bump actions/setup-go from 6.5.0 to 7.0.0
2026-08-21 17:16:28 +09:00
lestrrat f730b08b5d
Merge pull request #825 from peco/update-manpage
docs: update man page
2026-08-21 17:15:36 +09:00
dependabot[bot] e947b755d7
build(deps): bump github.com/mattn/go-runewidth from 0.0.24 to 0.0.27
Bumps [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) from 0.0.24 to 0.0.27.
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.24...v0.0.27)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.27
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 10:02:37 +00:00
dependabot[bot] a45e60b285
build(deps): bump github/codeql-action from 4 to 4.37.3
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4 to 4.37.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.3)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 00:53:52 +00:00
dependabot[bot] a4afa02128
build(deps): bump actions/setup-go from 6.5.0 to 7.0.0
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.5.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](924ae3a1cd...b7ad1dad31)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 00:52:13 +00:00
lestrrat c3f424413e
Merge pull request #834 from peco/dependabot/github_actions/golangci/golangci-lint-action-9.3.0
build(deps): bump golangci/golangci-lint-action from 9.2.1 to 9.3.0
2026-07-12 19:46:16 +09:00
dependabot[bot] 51ff07275e
build(deps): bump golangci/golangci-lint-action from 9.2.1 to 9.3.0
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 9.2.1 to 9.3.0.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](82606bf257...ba0d7d2ec0)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-version: 9.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 10:44:52 +00:00
lestrrat 49d9793ea1
Merge pull request #833 from peco/dependabot/github_actions/goreleaser/goreleaser-action-7.2.3
build(deps): bump goreleaser/goreleaser-action from 7.2.2 to 7.2.3
2026-07-12 19:44:14 +09:00
dependabot[bot] 7966368c66
build(deps): bump goreleaser/goreleaser-action from 7.2.2 to 7.2.3
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 7.2.2 to 7.2.3.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](5daf1e915a...f06c13b6b1)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-version: 7.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 10:42:50 +00:00
lestrrat baf8773274
Merge pull request #832 from peco/dependabot/github_actions/actions/setup-go-6.5.0
build(deps): bump actions/setup-go from 6.4.0 to 6.5.0
2026-07-12 19:41:25 +09:00
dependabot[bot] f4fbedefa2
build(deps): bump actions/setup-go from 6.4.0 to 6.5.0
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.4.0 to 6.5.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](4a3601121d...924ae3a1cd)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 6.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 10:39:46 +00:00
lestrrat d9c865b45f
Merge pull request #831 from peco/dependabot/github_actions/actions/cache-6.1.0
build(deps): bump actions/cache from 5.0.5 to 6.1.0
2026-07-12 19:38:55 +09:00
dependabot[bot] 78dc0ea123
build(deps): bump actions/cache from 5.0.5 to 6.1.0
Bumps [actions/cache](https://github.com/actions/cache) from 5.0.5 to 6.1.0.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](27d5ce7f10...55cc834586)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 10:37:21 +00:00
lestrrat 55667855d4
Merge pull request #829 from peco/dependabot/github_actions/actions/checkout-7.0.0
build(deps): bump actions/checkout from 6.0.2 to 7.0.0
2026-07-12 19:35:46 +09:00
dependabot[bot] 08e3c514de
build(deps): bump actions/checkout from 6.0.2 to 7.0.0
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 7.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](de0fac2e45...9c091bb21b)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 00:52:16 +00:00
lestrrat 8762bd7da0 docs: regenerate man page from README 2026-06-05 05:10:47 +00:00
lestrrat 41a8422b22
Merge pull request #822 from peco/feat-follow-mode
Some checks failed
CI / Go ${{ matrix.go }} test (1.25) (push) Has been cancelled
CI / Check generated code (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
lint / lint (push) Has been cancelled
Update man page / update-manpage (push) Has been cancelled
add follow mode for streaming input
2026-06-05 14:10:24 +09:00
Daisuke Maki a66446d2e2 add follow mode for streaming input 2026-06-04 11:16:23 +09:00
lestrrat 5f6ec1b21c
Merge pull request #821 from peco/perf-source-buffer-cap
amortize capped Source.Append to O(1)
2026-06-04 11:15:14 +09:00
Daisuke Maki 643ad7881d modernize benchmark loop to b.Loop()
CI's golangci-lint modernize check flags `for range b.N`; switch to
`b.Loop()`, which also handles timer reset internally.
2026-06-04 11:13:46 +09:00
Daisuke Maki 886f02bdee fix data race in Source.linesInRange under in-place compaction
In-place compaction in Append overwrites and then clears positions in the
backing array. linesInRange returned a slice aliased into that array and
released the read lock before the caller iterated it, so a concurrent
compaction could nil out the caller's view (panic on DisplayString()) and
trip the race detector. Return a copy.

Add a -race regression test that pins the contract.
2026-06-04 10:14:55 +09:00
Daisuke Maki ac77b9f2ff amortize capped Source.Append to O(1) 2026-06-04 07:54:04 +09:00
lestrrat 7e657cfd9f
Merge pull request #818 from peco/dependabot/go_modules/github.com/mattn/go-runewidth-0.0.24
build(deps): bump github.com/mattn/go-runewidth from 0.0.23 to 0.0.24
2026-05-31 18:06:49 +09:00
lestrrat 35ea2ba494
Merge pull request #819 from peco/dependabot/go_modules/github.com/gdamore/tcell/v2-2.13.10
build(deps): bump github.com/gdamore/tcell/v2 from 2.13.9 to 2.13.10
2026-05-31 18:06:33 +09:00
dependabot[bot] b819e73236
build(deps): bump github.com/gdamore/tcell/v2 from 2.13.9 to 2.13.10
Bumps [github.com/gdamore/tcell/v2](https://github.com/gdamore/tcell) from 2.13.9 to 2.13.10.
- [Release notes](https://github.com/gdamore/tcell/releases)
- [Changelog](https://github.com/gdamore/tcell/blob/main/CHANGESv3.md)
- [Commits](https://github.com/gdamore/tcell/compare/v2.13.9...v2.13.10)

---
updated-dependencies:
- dependency-name: github.com/gdamore/tcell/v2
  dependency-version: 2.13.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 10:02:29 +00:00
dependabot[bot] 70243267cd
build(deps): bump github.com/mattn/go-runewidth from 0.0.23 to 0.0.24
Bumps [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) from 0.0.23 to 0.0.24.
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.23...v0.0.24)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 10:02:24 +00:00
lestrrat 4008f2e7bd
Merge pull request #817 from peco/dependabot/github_actions/goreleaser/goreleaser-action-7.2.2
build(deps): bump goreleaser/goreleaser-action from 7.2.1 to 7.2.2
2026-05-27 10:23:53 +09:00
lestrrat adc039f3a8
Merge pull request #816 from peco/dependabot/github_actions/golangci/golangci-lint-action-9.2.1
build(deps): bump golangci/golangci-lint-action from 9.2.0 to 9.2.1
2026-05-27 10:23:40 +09:00
dependabot[bot] 003bf6b456
build(deps): bump goreleaser/goreleaser-action from 7.2.1 to 7.2.2
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 7.2.1 to 7.2.2.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](1a80836c5c...5daf1e915a)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-version: 7.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 00:52:14 +00:00
dependabot[bot] 45c4bf09ac
build(deps): bump golangci/golangci-lint-action from 9.2.0 to 9.2.1
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 9.2.0 to 9.2.1.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](1e7e51e771...82606bf257)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-version: 9.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 00:52:10 +00:00
lestrrat 78c41943e8
Merge pull request #815 from peco/dependabot/github_actions/goreleaser/goreleaser-action-7.2.1
build(deps): bump goreleaser/goreleaser-action from 7.1.0 to 7.2.1
2026-05-11 09:10:48 +09:00
dependabot[bot] 68be2eeb7f
build(deps): bump goreleaser/goreleaser-action from 7.1.0 to 7.2.1
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 7.1.0 to 7.2.1.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](e24998b8b6...1a80836c5c)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-version: 7.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 03:26:37 +00:00
lestrrat 69c9ca8cd7
Merge pull request #812 from peco/dependabot/github_actions/actions/cache-5.0.5
build(deps): bump actions/cache from 5.0.4 to 5.0.5
2026-04-25 21:59:29 +09:00
lestrrat b461fd28cc
Merge pull request #814 from peco/dependabot/go_modules/github.com/gdamore/tcell/v2-2.13.9
build(deps): bump github.com/gdamore/tcell/v2 from 2.13.8 to 2.13.9
2026-04-25 21:59:20 +09:00
dependabot[bot] 3df3d9735f
build(deps): bump github.com/gdamore/tcell/v2 from 2.13.8 to 2.13.9
Bumps [github.com/gdamore/tcell/v2](https://github.com/gdamore/tcell) from 2.13.8 to 2.13.9.
- [Release notes](https://github.com/gdamore/tcell/releases)
- [Changelog](https://github.com/gdamore/tcell/blob/main/CHANGESv3.md)
- [Commits](https://github.com/gdamore/tcell/compare/v2.13.8...v2.13.9)

---
updated-dependencies:
- dependency-name: github.com/gdamore/tcell/v2
  dependency-version: 2.13.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-25 10:02:29 +00:00
lestrrat 45dae94e47
Merge pull request #813 from peco/dependabot/github_actions/goreleaser/goreleaser-action-7.1.0
build(deps): bump goreleaser/goreleaser-action from 7.0.0 to 7.1.0
2026-04-25 16:21:16 +09:00
dependabot[bot] 8c7dd793d9
build(deps): bump goreleaser/goreleaser-action from 7.0.0 to 7.1.0
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 7.0.0 to 7.1.0.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](ec59f474b9...e24998b8b6)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-25 00:52:29 +00:00
dependabot[bot] d842d4124e
build(deps): bump actions/cache from 5.0.4 to 5.0.5
Bumps [actions/cache](https://github.com/actions/cache) from 5.0.4 to 5.0.5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](668228422a...27d5ce7f10)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 5.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-18 00:52:13 +00:00
lestrrat cb1c747fe6
Merge pull request #811 from peco/dependabot/go_modules/github.com/mattn/go-runewidth-0.0.23
build(deps): bump github.com/mattn/go-runewidth from 0.0.21 to 0.0.23
2026-04-11 19:04:06 +09:00
lestrrat 0188a6534c
Merge pull request #810 from peco/dependabot/github_actions/peter-evans/create-pull-request-8.1.1
build(deps): bump peter-evans/create-pull-request from 8.1.0 to 8.1.1
2026-04-11 19:03:11 +09:00
lestrrat 8c9aca356b
Merge pull request #808 from peco/dependabot/github_actions/actions/setup-go-6.4.0
build(deps): bump actions/setup-go from 6.3.0 to 6.4.0
2026-04-11 19:03:00 +09:00
dependabot[bot] dcd727a5b2
build(deps): bump github.com/mattn/go-runewidth from 0.0.21 to 0.0.23
Bumps [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) from 0.0.21 to 0.0.23.
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.21...v0.0.23)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-11 10:02:25 +00:00
dependabot[bot] 8e925b8870
build(deps): bump peter-evans/create-pull-request from 8.1.0 to 8.1.1
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 8.1.0 to 8.1.1.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](c0f553fe54...5f6978faf0)

---
updated-dependencies:
- dependency-name: peter-evans/create-pull-request
  dependency-version: 8.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-11 00:52:20 +00:00
dependabot[bot] 93de8d0469
build(deps): bump actions/setup-go from 6.3.0 to 6.4.0
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.3.0 to 6.4.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](4b73464bb3...4a3601121d)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 6.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-04 00:52:14 +00:00
lestrrat 778ae4368e
Merge pull request #807 from peco/dependabot/github_actions/actions/cache-5.0.4
build(deps): bump actions/cache from 5.0.3 to 5.0.4
2026-03-24 17:13:52 +09:00
dependabot[bot] c2d3f453bf
build(deps): bump actions/cache from 5.0.3 to 5.0.4
Bumps [actions/cache](https://github.com/actions/cache) from 5.0.3 to 5.0.4.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](cdf6c1fa76...668228422a)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 5.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-21 00:52:19 +00:00
lestrrat db69779cf1
Merge pull request #806 from peco/docs-claude-init
add CLAUDE.md and .claude/docs for agent context
2026-03-08 10:47:52 +09:00
Daisuke Maki 3efedc3128 fix filterbench deps and exit code defaults in docs 2026-03-08 10:38:41 +09:00
Daisuke Maki 082d12b1f4 fix more doc inaccuracies from copilot review 2026-03-08 10:28:35 +09:00
Daisuke Maki df830dc80d fix inaccuracies in agent docs from copilot review 2026-03-08 09:38:22 +09:00
lestrrat 16d700eb28
Merge pull request #805 from peco/dependabot/go_modules/github.com/mattn/go-runewidth-0.0.21
build(deps): bump github.com/mattn/go-runewidth from 0.0.20 to 0.0.21
2026-03-08 09:17:30 +09:00
Daisuke Maki 1fbf855884 add CLAUDE.md and .claude/docs for agent context 2026-03-08 09:09:36 +09:00
dependabot[bot] 6bc81c893f
build(deps): bump github.com/mattn/go-runewidth from 0.0.20 to 0.0.21
Bumps [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) from 0.0.20 to 0.0.21.
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.20...v0.0.21)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-07 10:02:25 +00:00
lestrrat b1734bbc34
Merge pull request #804 from masawada/fix-ctrl-space-selection
fix: Ctrl+Space (ToggleSelection) not working after tcell migration
2026-03-02 12:32:41 +09:00
Masayoshi Wada 42c39e38c8 fix: Ctrl+Space (ToggleSelection) not working after tcell migration
tcellEventToEvent did not convert Ctrl+Space to KeyCtrlSpace for
CSI u terminals or traditional terminals (tcell emits KeyCtrlSpace
which fell outside the KeyCtrlA..KeyCtrlZ range).
2026-03-01 20:42:04 +09:00
lestrrat 1eab243c5e
Merge pull request #803 from peco/dependabot/github_actions/goreleaser/goreleaser-action-7.0.0
build(deps): bump goreleaser/goreleaser-action from 6.4.0 to 7.0.0
2026-02-28 22:13:23 +09:00
lestrrat d232db1e32
Merge pull request #802 from peco/dependabot/github_actions/actions/setup-go-6.3.0
build(deps): bump actions/setup-go from 6.2.0 to 6.3.0
2026-02-28 22:13:11 +09:00
dependabot[bot] 14ed0ef01e
build(deps): bump goreleaser/goreleaser-action from 6.4.0 to 7.0.0
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 6.4.0 to 7.0.0.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](e435ccd777...ec59f474b9)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-28 00:52:15 +00:00
dependabot[bot] 3ed0b3015e
build(deps): bump actions/setup-go from 6.2.0 to 6.3.0
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.2.0 to 6.3.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](7a3fe6cf4c...4b73464bb3)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-28 00:52:10 +00:00
Daisuke Maki 6ab3ff6d5d Actually update version number 2026-02-24 11:47:27 +09:00
lestrrat acdfd7ddba
Merge pull request #799 from peco/fix-release-workflow
ci: fix goreleaser dirty-state error by removing release-notes step
2026-02-24 10:54:02 +09:00
lestrrat a980ec02e4
Merge pull request #798 from peco/update-manpage
docs: update man page
2026-02-24 10:52:53 +09:00
Daisuke Maki efe1c5254f ci: fix goreleaser dirty-state error by removing release-notes step 2026-02-24 10:52:03 +09:00
Daisuke Maki e5d7c9ede6 Update Changes 2026-02-24 10:45:16 +09:00
lestrrat ce63a3fc81 docs: regenerate man page from README 2026-02-24 01:42:35 +00:00
lestrrat 70845827cd
Merge pull request #797 from peco/update-changes-v060
docs: fix v0.6.0 Changes and README gaps
2026-02-24 10:42:07 +09:00
Daisuke Maki dcad6fbf1b docs: fix v0.6.0 Changes and README gaps 2026-02-24 10:39:48 +09:00
lestrrat cf2ea47a52
Merge pull request #795 from peco/update-manpage
docs: update man page
2026-02-24 06:36:59 +09:00
lestrrat 600e20302d
Merge pull request #796 from peco/chore-default-build-target
Make build the default Makefile target
2026-02-24 06:36:05 +09:00
Daisuke Maki 2a39bf9315 Make build the default Makefile target 2026-02-23 22:26:18 +09:00
lestrrat 8f128cce45 docs: regenerate man page from README 2026-02-23 13:16:39 +00:00
lestrrat 1808f304ca
Merge pull request #794 from nekketsuuu/nekketsuuu-docs-make-build
docs fix: Build results will be at ./dist
2026-02-23 22:16:12 +09:00
nekketsuuu 8cd3fdfc62
docs fix: Build results will be at ./dist
The default output directory of goreleaser is `./dist`: https://goreleaser.com/customization/dist/

Also, the current default rule of Makefile is `check-goreleaser`, not `build`.
2026-02-22 23:30:07 +09:00
lestrrat e1453e217c
Merge pull request #793 from peco/update-manpage
docs: update man page
2026-02-22 11:45:43 +09:00
lestrrat 05d06f3a48
Merge pull request #791 from peco/docs-changelog
Tweak
2026-02-22 11:45:11 +09:00
lestrrat fdf0c9040a docs: regenerate man page from README 2026-02-22 00:07:02 +00:00
lestrrat 9a70e05aa9
Merge pull request #792 from peco/docs-readme-review-fixes
tweaks to README
2026-02-22 09:06:38 +09:00
Daisuke Maki 28039b1bd9 tweaks to README 2026-02-22 09:01:04 +09:00
Daisuke Maki f0ea966553 Tweak 2026-02-22 08:14:10 +09:00
lestrrat ba5c351e63
Merge pull request #790 from peco/docs-changelog
Update Changes
2026-02-22 06:44:39 +09:00
Daisuke Maki e37246c18e Update Changes 2026-02-22 06:42:32 +09:00
lestrrat b65eb6e7b1
Merge pull request #787 from peco/fix-matched-pool
fix: pool Matched structs with sync.Pool to reduce GC pressure
2026-02-22 00:33:31 +09:00
Daisuke Maki bf8595be01 merge master, resolve bench_test conflicts 2026-02-22 00:31:46 +09:00
Daisuke Maki 6be528ba24 fix: lint issues in matched pool 2026-02-22 00:29:33 +09:00
lestrrat 7a63934041
Merge pull request #789 from peco/fix-merge-matches-alloc
fix: in-place mergeMatches to avoid per-merge heap allocation
2026-02-22 00:29:32 +09:00
lestrrat bf02727ebd
Merge pull request #788 from peco/fix-displaystring-alloc
fix: avoid redundant displayString storage and alloc in DisplayString
2026-02-22 00:29:04 +09:00
lestrrat 43becc50fa
Merge pull request #786 from peco/fix-strip-ansi-alloc
fix: fast-path StripANSISequence to skip regexp when no ESC byte
2026-02-22 00:28:28 +09:00
Daisuke Maki a5c51bcc4b fix: pool Matched structs with sync.Pool to reduce GC pressure
Benchmark (10k lines x 3 cycles):
  pooled vs unpooled: 107KiB vs 562KiB (-81%), 2 vs 10k allocs
  end-to-end filter: 10.71MiB -> 10.14MiB (-5%), 150.6k -> 121.5k allocs (-19%)
2026-02-22 00:26:22 +09:00
Daisuke Maki b4c5d2e928 fix: avoid redundant displayString storage and alloc in DisplayString
Benchmark (10k lines, enableANSI=false):
  1244us -> 331us (-73%), 2541KiB -> 781KiB (-69%), 40k -> 10k allocs (-75%)
2026-02-22 00:26:11 +09:00
Daisuke Maki 4b6baa3751 fix: in-place mergeMatches to avoid per-merge heap allocation
Benchmark (10k lines, overlapping matches):
  47.4ms -> 42.8ms (-10%), 27.3MiB -> 21.3MiB (-22%), 861k -> 471k allocs (-45%)
2026-02-22 00:26:06 +09:00
Daisuke Maki 4b1dfa1c4d fix: fast-path StripANSISequence to skip regexp when no ESC byte
Benchmark (10k lines):
  plain text: 1023us -> 37us (-96%), 1.7MiB -> 0B (-100%), 30k -> 0 allocs
  95% plain:  1064us -> 162us (-85%), 1720KiB -> 63KiB (-96%), 30.5k -> 2k allocs
2026-02-22 00:26:02 +09:00
lestrrat a79df901f7
Merge pull request #784 from peco/fix-regexp-match-alloc
fix: reuse match/dedup slices across regexp filter loop iterations
2026-02-21 22:29:10 +09:00
Daisuke Maki 6ea14d47b9 fix: reuse match/dedup slices across regexp filter loop iterations
Move slice declarations outside loop and reset with [:0] each iteration.

| Metric     | Before  | After   | Delta  |
|------------|---------|---------|--------|
| allocs/op  | 2209    | 1813    | -396   |
| B/op       | 176836  | 147849  | -28987 |
| ns/op      | ~352k   | ~317k   | -10%   |
2026-02-21 22:28:36 +09:00
lestrrat a91172b458
Merge pull request #783 from peco/fix-closure-alloc
fix: eliminate closure allocation in fuzzy filter
2026-02-21 22:26:45 +09:00
Daisuke Maki 7588d33015 fix: eliminate closure allocation in fuzzy filter
Replace strings.IndexFunc with closure with direct CaseInsensitiveIndex.

| Metric     | Before  | After   | Delta  |
|------------|---------|---------|--------|
| allocs/op  | 3018    | 2018    | -1000  |
| B/op       | 123016  | 107014  | -16002 |
| ns/op      | ~113k   | ~88k    | -22%   |
2026-02-21 22:25:06 +09:00
lestrrat a19fa681e2
Merge pull request #785 from peco/fix-hub-batch-ctx
fix: merge two context keys into one in Hub.Batch
2026-02-21 22:24:19 +09:00
lestrrat 35e1681805
Merge pull request #782 from peco/fix-fuzzy-match-alloc
fix: pre-allocate flat backing array for fuzzy match pairs
2026-02-21 22:23:14 +09:00
lestrrat c0d97e2a49
Merge pull request #781 from peco/fix-ansi-split-alloc
fix: use stack-allocated array for SGR parameter parsing
2026-02-21 22:22:40 +09:00
lestrrat 06c4370c85
Merge pull request #780 from peco/test-ansi-color-boundary
Add tests for matching over color boundaries (me being paranoid)
2026-02-21 22:21:41 +09:00
Daisuke Maki e2bc390c68 fix: merge two context keys into one in Hub.Batch
Reduce context.WithValue calls from 2 to 1 per Batch invocation.

| Metric     | Before | After | Delta |
|------------|--------|-------|-------|
| allocs/op  | 3      | 2     | -1    |
| B/op       | 128    | 80    | -48   |
| ns/op      | ~372   | ~352  | -5%   |
2026-02-21 22:20:25 +09:00
Daisuke Maki 3c27da49ac fix: use stack-allocated array for SGR parameter parsing
Replace strings.Split with fixed [8]string array to avoid heap allocation.

| Metric     | Before | After | Delta |
|------------|--------|-------|-------|
| allocs/op  | 1      | 0     | -1    |
| B/op       | 48-80  | 0     | -100% |
| ns/op      | ~45    | ~20   | -55%  |
2026-02-21 22:20:21 +09:00
Daisuke Maki 580e062548 fix: pre-allocate flat backing array for fuzzy match pairs
Use a single backing []int with subslicing instead of per-match allocations.

| Metric     | Before  | After   | Delta  |
|------------|---------|---------|--------|
| allocs/op  | 3018    | 2018    | -1000  |
| B/op       | 123016  | 108616  | -14400 |
2026-02-21 22:20:19 +09:00
Daisuke Maki 8569cbd5bf appease linter 2026-02-21 22:18:04 +09:00
Daisuke Maki 40ac45f94d Add tests for matching over color boundaries (me being paranoid) 2026-02-21 21:30:18 +09:00
lestrrat ec3f2cb98b
Merge pull request #779 from peco/update-manpage
docs: update man page
2026-02-21 21:23:27 +09:00
lestrrat 5c39fff7ad docs: regenerate man page from README 2026-02-21 11:35:56 +00:00
lestrrat 113c0aee16
Merge pull request #778 from peco/docs-changelog-readme
Update docs
2026-02-21 20:35:33 +09:00
lestrrat 1a8dda7f4f
Merge pull request #777 from peco/dependabot/go_modules/github.com/mattn/go-runewidth-0.0.20
Bump github.com/mattn/go-runewidth from 0.0.19 to 0.0.20
2026-02-21 20:33:31 +09:00
Daisuke Maki 17536e1410 Update docs 2026-02-21 20:33:02 +09:00
dependabot[bot] e01669b48d
Bump github.com/mattn/go-runewidth from 0.0.19 to 0.0.20
Bumps [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) from 0.0.19 to 0.0.20.
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.19...v0.0.20)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-21 10:02:26 +00:00
lestrrat 6869d4dcfa
Merge pull request #776 from peco/feat-ansi-default
make color support default, change option name to color
2026-02-21 18:40:43 +09:00
Daisuke Maki 14530444b1 appease linter 2026-02-21 16:04:30 +09:00
Daisuke Maki f689006279 change cli option / config name validate better 2026-02-21 15:59:42 +09:00
Daisuke Maki 9ab2b5af84 make color support default, change option name to color 2026-02-21 15:43:34 +09:00
lestrrat f05b49ceef
Merge pull request #775 from peco/dependabot/github_actions/peter-evans/create-pull-request-8.1.0
Bump peter-evans/create-pull-request from 7.0.11 to 8.1.0
2026-02-21 15:24:05 +09:00
dependabot[bot] 1bfa7d2467
Bump peter-evans/create-pull-request from 7.0.11 to 8.1.0
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 7.0.11 to 8.1.0.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](22a9089034...c0f553fe54)

---
updated-dependencies:
- dependency-name: peter-evans/create-pull-request
  dependency-version: 8.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-21 00:52:16 +00:00
lestrrat 76a48722da
Merge pull request #774 from peco/fix-filteredbuffer-errmsg
fix FilteredBuffer.LineAt error message
2026-02-21 08:04:25 +09:00
lestrrat 82a4d605b7
Merge pull request #773 from peco/fix-hub-pool-comment
add comment explaining pool type assertion safety
2026-02-21 08:04:06 +09:00
lestrrat 673f8b0688
Merge pull request #772 from peco/fix-source-race
fix data race on Source.inClosed
2026-02-21 08:03:54 +09:00
lestrrat cc2d63bb15
Merge pull request #771 from peco/fix-test-require
convert t.Errorf/t.Fatal to testify/require
2026-02-21 08:03:03 +09:00
Daisuke Maki 8c4db9e026 convert t.Errorf/t.Fatal to testify/require 2026-02-21 07:58:26 +09:00
Daisuke Maki 44d5885a2f fix data race on Source.inClosed 2026-02-21 07:58:24 +09:00
Daisuke Maki e024c7a01e add comment explaining pool type assertion safety 2026-02-21 07:58:14 +09:00
Daisuke Maki 38ce584c21 fix FilteredBuffer.LineAt error message 2026-02-21 07:58:11 +09:00
lestrrat 4e58dad08a
Merge pull request #770 from peco/fix-filtered-buffer-bounds
add negative index check to FilteredBuffer.LineAt
2026-02-20 22:52:56 +09:00
lestrrat f90f5cd0c2
Merge pull request #769 from peco/fix-hub-test-require
use require instead of t.Errorf in hub tests
2026-02-20 22:52:41 +09:00
lestrrat 982d0d30cd
Merge pull request #768 from peco/fix-peco-test-require
use t.Fatal instead of t.Errorf in peco tests
2026-02-20 22:52:26 +09:00
lestrrat ba6d3a7b31
Merge pull request #767 from peco/fix-keyseq-named-returns
remove named return values from Dig and ToKey
2026-02-20 22:51:46 +09:00
Daisuke Maki 6d97fcaa0f remove named return values from Dig and ToKey 2026-02-20 22:48:08 +09:00
Daisuke Maki cef9453b46 use t.Fatal instead of t.Errorf in peco tests 2026-02-20 22:47:54 +09:00
Daisuke Maki c404f05d43 use require instead of t.Errorf in hub tests 2026-02-20 22:47:52 +09:00
Daisuke Maki 3bd446705d add negative index check to FilteredBuffer.LineAt 2026-02-20 22:43:56 +09:00
lestrrat 76f0724099
Merge pull request #764 from peco/fix-named-returns
remove named return values from Compile and MovePage
2026-02-20 22:31:52 +09:00
lestrrat 464fc30b3d
Merge pull request #766 from peco/fix-external-send-error
propagate out.Send error in external filter
2026-02-20 22:31:31 +09:00
lestrrat b5b1f523be
Merge pull request #765 from peco/fix-regexp-submatch
use FindAllStringIndex instead of FindAllStringSubmatchIndex
2026-02-20 22:31:17 +09:00
lestrrat 3c54a192a7
Merge pull request #763 from peco/fix-getlist-panic
guard GetList against empty KeyList
2026-02-20 22:31:04 +09:00
lestrrat 2914b94aaa
Merge pull request #762 from peco/fix-regexp-shadow
rename shadowed loop variable in regexp filter
2026-02-20 22:30:44 +09:00
lestrrat fbb737c72d
Merge pull request #761 from peco/fix-regexp-time-style
standardize on time.Since in regexp cache eviction
2026-02-20 22:30:30 +09:00
lestrrat 727e85afed
Merge pull request #760 from peco/fix-layout-test-require
use require instead of t.Errorf in layout tests
2026-02-20 22:30:15 +09:00
lestrrat 98f51cbbf5
Merge pull request #759 from peco/fix-resultch-race
fix data race on resultCh in PrintResults
2026-02-20 22:29:51 +09:00
Daisuke Maki 5fa4303b52 standardize on time.Since in regexp cache eviction 2026-02-20 22:27:23 +09:00
Daisuke Maki 0354f3071a rename shadowed loop variable in regexp filter 2026-02-20 22:27:20 +09:00
Daisuke Maki 3dec918f69 use require instead of t.Errorf in layout tests 2026-02-20 22:27:06 +09:00
Daisuke Maki 4f7978201b guard GetList against empty KeyList 2026-02-20 22:27:04 +09:00
Daisuke Maki e32edbda88 remove named return values from Compile and MovePage 2026-02-20 22:27:01 +09:00
Daisuke Maki 2f20cddcf6 use FindAllStringIndex instead of FindAllStringSubmatchIndex 2026-02-20 22:26:47 +09:00
Daisuke Maki deb12acf59 propagate out.Send error in external filter 2026-02-20 22:26:44 +09:00
Daisuke Maki 720f1b0be8 fix data race on resultCh in PrintResults 2026-02-20 22:26:41 +09:00
lestrrat e4e92ad5ac
Merge pull request #754 from peco/fix-selection-copy
prevent deadlock in Selection.Copy
2026-02-20 21:09:56 +09:00
lestrrat acbeb2e59a
Merge pull request #758 from peco/fix-loop-error-handling
log errors
2026-02-20 21:09:43 +09:00
lestrrat e0fe6b7375
Merge pull request #757 from peco/fix-query-bounds
Add bounds validation
2026-02-20 21:09:30 +09:00
lestrrat b00e80a94e
Merge pull request #756 from peco/fix-regexp-cache
use LRU eviction
2026-02-20 21:09:17 +09:00
lestrrat 2b6d042f93
Merge pull request #755 from peco/fix-fuzzy-redundant
use strings.IndexRun/IndexFunc
2026-02-20 21:09:02 +09:00
Daisuke Maki 174b0f1b30 prevent deadlock in Selection.Copy 2026-02-20 21:06:46 +09:00
lestrrat 01956a4c51
Merge pull request #753 from peco/fix-ignored-errors
very minor tidying
2026-02-20 21:03:38 +09:00
lestrrat e01c17be99
Merge pull request #752 from peco/fix-external-filter-errors
propagate cancellation and distinguish EOF from real I/O errors
2026-02-20 21:03:01 +09:00
lestrrat 8a2b5334de
Merge pull request #751 from peco/test-missing-coverage
increase test coverage
2026-02-20 21:02:35 +09:00
lestrrat c55bb30529
Merge pull request #750 from peco/chore-ci-workflows
tweak ci
2026-02-20 21:01:44 +09:00
Daisuke Maki 6e169570fe log errors 2026-02-20 21:00:02 +09:00
Daisuke Maki 136b4a80e9 Add bounds validation 2026-02-20 20:58:00 +09:00
Daisuke Maki 70975b098e use LRU eviction 2026-02-20 20:57:21 +09:00
Daisuke Maki 5fc35a050e use strings.IndexRun/IndexFunc 2026-02-20 20:56:09 +09:00
Daisuke Maki bb8a6310ea very minor tidying 2026-02-20 20:54:18 +09:00
Daisuke Maki ed55e27368 propagate cancellation and distinguish EOF from real I/O errors 2026-02-20 20:51:22 +09:00
Daisuke Maki d10fe55d8e 1.25 is fine 2026-02-20 20:43:07 +09:00
Daisuke Maki 43e5cf1ddc tweak ci 2026-02-20 20:38:37 +09:00
Daisuke Maki 0ccff01ddd increase test coverage 2026-02-20 20:37:44 +09:00
lestrrat 717dfca53b
Merge pull request #749 from peco/refactor-extract-config
extract config package
2026-02-20 18:44:31 +09:00
Daisuke Maki 11754d722b extract config package 2026-02-20 18:42:24 +09:00
lestrrat 14748ed33b
Merge pull request #748 from peco/fix-selection-bindings
bind GoToNextSelection/GoToPreviousSelection to Alt+j/Alt+k
2026-02-20 17:34:16 +09:00
lestrrat 09aacb6f50
Merge pull request #746 from peco/docs-custommatcher-migration
document CustomMatcher to CustomFilter migration format
2026-02-20 17:34:03 +09:00
Daisuke Maki d8d8e43b97 bind GoToNextSelection/GoToPreviousSelection to Alt+j/Alt+k
Resolves the Ctrl+K conflict with KillEndOfLine (#575, #579) by
giving both actions their own Alt-key defaults. Documents the
GoToPreviousSelection malformed name fix from v0.5.x.
2026-02-20 17:06:22 +09:00
Daisuke Maki 1baeb547b4 document CustomMatcher to CustomFilter migration format 2026-02-20 17:01:11 +09:00
lestrrat 7b54585c95
Merge pull request #745 from peco/extract-query-package
Extract query package
2026-02-20 15:18:36 +09:00
Daisuke Maki ebaf55dafa appease linter 2026-02-20 15:15:53 +09:00
Daisuke Maki 2a8d21774a extract query package 2026-02-20 15:13:44 +09:00
lestrrat 01ede393bb
Merge pull request #744 from peco/extract-selection-package
extract Selection object to its own package
2026-02-20 15:07:32 +09:00
Daisuke Maki bdf9b33e6d extract Selection object to its own package 2026-02-20 14:56:29 +09:00
lestrrat 37a9b23c61
Merge pull request #743 from peco/replace-applyfn-with-interface
change applyFn to interface
2026-02-20 14:08:01 +09:00
Daisuke Maki 8a9dfefb3a change applyFn to interface 2026-02-20 14:05:22 +09:00
lestrrat 19327037c9
Merge pull request #742 from peco/replace-callbacks-with-interfaces
Replace raw closure callbacks with interfaces
2026-02-20 13:44:51 +09:00
Daisuke Maki 88b5b7a39c appease linter 2026-02-20 13:42:55 +09:00
Daisuke Maki 1fea5cd5a0 Replace raw closure callbacks with interfaces 2026-02-20 13:35:31 +09:00
lestrrat 4d178a4675
Merge pull request #738 from peco/update-manpage
docs: update man page
2026-02-20 11:01:29 +09:00
lestrrat 0fe5e98477
Merge pull request #741 from peco/add-function-comments
Methodically add function comments
2026-02-20 11:01:14 +09:00
Daisuke Maki d8d010eafc run gofmt -w 2026-02-20 10:58:04 +09:00
lestrrat e06fae240a
Merge pull request #740 from peco/remove-hub-sub-interfaces
simplify
2026-02-20 10:56:24 +09:00
Daisuke Maki fc18c0d3de Methodically add function comments 2026-02-20 10:55:49 +09:00
Daisuke Maki 892882f69c simplify 2026-02-20 10:35:40 +09:00
lestrrat 93fcda4a08
Merge pull request #739 from peco/standardize-tests-use-require
standardize on using testify/require
2026-02-20 10:25:22 +09:00
Daisuke Maki 8b74d9421a standardize on using testify/require 2026-02-20 10:22:58 +09:00
lestrrat f918f68134 docs: regenerate man page from README 2026-02-20 01:09:23 +00:00
lestrrat ac64686192
Merge pull request #737 from peco/docs-negative-terms-breaking
docs: document negative-term query breaking change for v0.6.0
2026-02-20 10:08:57 +09:00
Daisuke Maki 21ceb9908a docs: document negative-term query breaking change for v0.6.0 2026-02-20 08:30:02 +09:00
lestrrat 7199f09552
Merge pull request #735 from peco/fix-mouse-support
fix: restore mouse support lost in tcell migration
2026-02-19 21:57:53 +09:00
lestrrat a74bdfe4d0
Merge pull request #736 from peco/update-manpage
docs: update man page
2026-02-19 21:57:30 +09:00
lestrrat 63473702b4 docs: regenerate man page from README 2026-02-19 12:56:57 +00:00
lestrrat 6f5b949656
Merge pull request #734 from peco/docs-shift-tab
docs: document Shift+Tab to Tab mapping after tcell migration
2026-02-19 21:56:36 +09:00
lestrrat 796f6b2d2b
Merge pull request #733 from peco/docs-tcell-altscreen
docs: document TCELL_ALTSCREEN env var in --height section
2026-02-19 21:56:22 +09:00
lestrrat bf6e2a78f2
Merge pull request #732 from peco/docs-layout-option
docs: add top-down-query-bottom to --layout option docs
2026-02-19 21:56:07 +09:00
lestrrat 2c9c5c0a41
Merge pull request #731 from peco/docs-missing-actions
docs: add ViewAround, GoToNextSelection, GoToPreviousSelection to act…
2026-02-19 21:55:56 +09:00
lestrrat e809133abe
Merge pull request #730 from peco/docs-exit0-selectall
docs: document --exit-0 and --select-all CLI flags
2026-02-19 21:55:45 +09:00
lestrrat 275bfe2f80
Merge pull request #729 from peco/docs-hex-colors
docs: document default, hex (#RRGGBB) color formats
2026-02-19 21:55:36 +09:00
Daisuke Maki 80f7560eb2 chore: fix gofmt trailing newline in screen_test.go 2026-02-19 21:55:00 +09:00
Daisuke Maki a4e1fcb1dc fix: restore mouse support lost in tcell migration 2026-02-19 21:51:51 +09:00
Daisuke Maki 62842229bf docs: document TCELL_ALTSCREEN env var in --height section 2026-02-19 21:43:39 +09:00
Daisuke Maki e5f12cb7f3 docs: document Shift+Tab to Tab mapping after tcell migration 2026-02-19 21:43:33 +09:00
Daisuke Maki 661c4fb5f6 docs: document default, hex (#RRGGBB) color formats 2026-02-19 21:34:07 +09:00
Daisuke Maki 041557f913 docs: add ViewAround, GoToNextSelection, GoToPreviousSelection to action table 2026-02-19 21:33:57 +09:00
Daisuke Maki 2ab4ac0567 docs: document --exit-0 and --select-all CLI flags 2026-02-19 21:32:49 +09:00
Daisuke Maki d3d4b313c3 docs: add top-down-query-bottom to --layout option docs 2026-02-19 21:32:15 +09:00
lestrrat 7e319f0b6d
Merge pull request #728 from peco/fix-esc-timer-redundancy
fix: remove redundant Esc timer, trust tcell disambiguation
2026-02-19 20:04:21 +09:00
lestrrat 8a74cc4a70
Merge pull request #727 from peco/fix-frozen-filter-cache
fix: invalidate filter cache on freeze/unfreeze
2026-02-19 20:03:36 +09:00
lestrrat 64027d9a2b
Merge pull request #726 from peco/fix-select1-streaming
fix: --select-1 triggers on interactive queries and respects context for streaming sources
2026-02-19 20:02:42 +09:00
Daisuke Maki f8694463a2 fix: remove redundant Esc timer, trust tcell disambiguation 2026-02-19 20:01:58 +09:00
Daisuke Maki a24479624d fix: invalidate filter cache on freeze/unfreeze 2026-02-19 19:59:37 +09:00
Daisuke Maki ea2ce9e909 fix: --select-1 triggers on interactive queries and respects context for streaming sources 2026-02-19 19:51:57 +09:00
lestrrat d9e46440b4
Merge pull request #724 from peco/chore-remove-use256color
Remove dead Use256Color config field
2026-02-19 19:46:48 +09:00
lestrrat e207eaa72f
Merge pull request #725 from peco/update-manpage
docs: update man page
2026-02-19 19:46:31 +09:00
lestrrat 83f154459a docs: regenerate man page from README 2026-02-19 10:45:52 +00:00
lestrrat 67de06c1e1
Merge pull request #723 from peco/docs-smartcase-negative-limitation
docs: note SmartCase + negative terms limitation
2026-02-19 19:45:30 +09:00
Daisuke Maki aa8bec3184 Remove dead Use256Color config field
After the tcell migration, 256-color support is automatic via
terminfo detection. The field was stored but never read by any
rendering code.
2026-02-19 19:44:19 +09:00
Daisuke Maki a84879b78e docs: note SmartCase + negative terms limitation 2026-02-19 19:29:11 +09:00
lestrrat 2238188798
Merge pull request #721 from peco/update-manpage
docs: update man page
2026-02-19 19:16:04 +09:00
lestrrat 9b0b83a302 docs: regenerate man page from README 2026-02-19 09:41:42 +00:00
lestrrat 76f0525a1a
Merge pull request #722 from peco/fix-external-filter-parallel
Fix external filter parallel
2026-02-19 18:41:16 +09:00
Daisuke Maki ebe017f5c1 docs: clarify external filters run sequentially unlike built-in filters 2026-02-19 18:37:46 +09:00
Daisuke Maki 8212acecae fix: external filters should not run in parallel 2026-02-19 18:15:01 +09:00
lestrrat 4d61e9c718
Merge pull request #720 from peco/goreleaser
use goreleaser
2026-02-19 16:20:24 +09:00
Daisuke Maki a28679d2df use make build/goreleaser 2026-02-19 15:18:46 +09:00
Daisuke Maki bbfff0f835 use goreleaser 2026-02-19 13:24:24 +09:00
lestrrat 8fc5813265
Merge pull request #719 from peco/feat-query-cursor-style
Update Changes
2026-02-19 11:36:59 +09:00
Daisuke Maki 8e84cfc875 Update Changes 2026-02-19 11:35:01 +09:00
lestrrat 8b35079480
Merge pull request #718 from peco/update-manpage
docs: update man page
2026-02-19 11:32:11 +09:00
lestrrat 40b743b04f docs: regenerate man page from README 2026-02-19 00:59:13 +00:00
lestrrat 4a76ffdaa6
Merge pull request #717 from peco/feat-query-cursor-style
Feat query cursor style
2026-02-19 09:58:49 +09:00
Daisuke Maki 7e206f13a7 Merge master into feat-query-cursor-style 2026-02-19 09:55:31 +09:00
Daisuke Maki 7041a265db Add QueryCursor config option 2026-02-19 09:48:14 +09:00
lestrrat fef7922a36
Merge pull request #716 from peco/fix-ctrl-c-exit
Fix #715. meta char handling
2026-02-19 08:41:35 +09:00
Daisuke Maki bae7ac32f2 Fix #715. meta char handling
tcell handles ctrl meta characters slightly differently than we expected
2026-02-19 08:38:28 +09:00
lestrrat 74ffb86a87
Merge pull request #714 from peco/docs-update-changes
tweak
2026-02-18 23:04:20 +09:00
Daisuke Maki cfd508a785 tweak 2026-02-18 23:00:19 +09:00
lestrrat a29d50cd0b
Merge pull request #713 from peco/docs-update-changes
Update Changes
2026-02-18 22:21:43 +09:00
Daisuke Maki 740db75767 Update Changes 2026-02-18 22:16:52 +09:00
lestrrat a1a3596848
Merge pull request #712 from peco/refactor-remove-interface-files
Do away with interface.go files
2026-02-18 22:00:15 +09:00
Daisuke Maki 95e13933c2 Do away with interface.go files 2026-02-18 21:12:19 +09:00
lestrrat be9d23d9ac
Merge pull request #707 from peco/fix-fuzzy-slice-literals
Use var declarations for appended-to slices
2026-02-18 16:03:33 +09:00
lestrrat 3df337e1f4
Merge pull request #708 from peco/fix-draw-ticker-constant
Extract drawRefreshInterval constant
2026-02-18 16:03:19 +09:00
lestrrat c5bf382061
Merge pull request #709 from peco/fix-esc-timeout-constant
Extract escKeyTimeout constant
2026-02-18 16:03:07 +09:00
lestrrat a24814affe
Merge pull request #711 from peco/fix-typo-source-test
Fix "lien" typo in test assertion message
2026-02-18 16:02:55 +09:00
lestrrat a01c747f02
Merge pull request #710 from peco/fix-len-check-style
Use idiomatic empty string check
2026-02-18 16:02:41 +09:00
lestrrat ebd05de222
Merge pull request #706 from peco/fix-keyseq-cleanup
Remove dead prevInputTime field and fix "rest" typo
2026-02-18 16:02:25 +09:00
Daisuke Maki c60329eca9 Use var declarations for appended-to slices 2026-02-18 15:57:20 +09:00
Daisuke Maki c8748a736e Extract drawRefreshInterval constant 2026-02-18 15:57:20 +09:00
Daisuke Maki cf77e13382 Extract escKeyTimeout constant 2026-02-18 15:57:20 +09:00
Daisuke Maki 37be8451e6 Use idiomatic empty string check 2026-02-18 15:57:19 +09:00
Daisuke Maki 3a4730167a Fix "lien" typo in test assertion message 2026-02-18 15:56:31 +09:00
Daisuke Maki 27cda7fc36 Remove dead prevInputTime field and fix "rest" typo 2026-02-18 15:56:19 +09:00
lestrrat 8f7038b5c2
Merge pull request #705 from peco/fix-tty-windows-redundant-err
Remove redundant err != nil in Windows syscall wrappers
2026-02-18 15:31:49 +09:00
lestrrat 52cb231084
Merge pull request #704 from peco/fix-simscreen-bare-recover
Log panics in SimScreen PollEvent instead of swallowing
2026-02-18 15:31:32 +09:00
lestrrat e20b3f4f34
Merge pull request #703 from peco/fix-pipeline-test-assertions
Add assertion to TestPipeline
2026-02-18 15:31:07 +09:00
lestrrat 2f74f1c7f0
Merge pull request #702 from peco/fix-pin-stringer-ci
Pin stringer to v0.42.0 in CI
2026-02-18 15:30:43 +09:00
Daisuke Maki 381ce7ca5b Pin stringer to v0.42.0 in CI 2026-02-18 15:28:14 +09:00
Daisuke Maki 34e539b7da Add assertion to TestPipeline 2026-02-18 15:28:06 +09:00
Daisuke Maki 7bbd53e775 Log panics in SimScreen PollEvent instead of swallowing 2026-02-18 15:27:46 +09:00
Daisuke Maki 66bf406024 Remove redundant err != nil in Windows syscall wrappers 2026-02-18 15:27:42 +09:00
lestrrat 51e6af6d36
Merge pull request #695 from peco/fix-config-test-coverage
Expand TestApplyConfig to cover all config options
2026-02-18 14:58:18 +09:00
Daisuke Maki 09f5ed6972 Expand TestApplyConfig to cover all config options 2026-02-18 14:55:35 +09:00
lestrrat 92724b8dc5
Merge pull request #697 from peco/fix-magic-numbers-prompt
Extract queryStartX variable in UserPrompt.Draw
2026-02-18 14:51:51 +09:00
lestrrat e09776384f
Merge pull request #696 from peco/fix-filter-dedup-cancellation
Extract checkCancelled helper for filter loops
2026-02-18 14:51:40 +09:00
lestrrat 441feb59a9
Merge pull request #698 from peco/fix-assert-to-require
Replace testify/assert with testify/require
2026-02-18 14:49:14 +09:00
lestrrat 6fdcd40b78
Merge pull request #699 from peco/fix-filter-dedup-exclusion
Extract isExcluded helper for negative term check
2026-02-18 14:48:45 +09:00
lestrrat 554a196a8b
Merge pull request #700 from peco/fix-filter-constructors
Extract newRegexpFilter helper for filter constructors
2026-02-18 14:48:25 +09:00
lestrrat dfbae22734
Merge pull request #694 from peco/fix-setdirty-add-pattern
Extract selectLine helper for SetDirty+Add pattern
2026-02-18 14:48:09 +09:00
Daisuke Maki b4141eca2a Extract queryStartX variable in UserPrompt.Draw 2026-02-18 14:46:08 +09:00
Daisuke Maki d1e5b18232 Replace testify/assert with testify/require 2026-02-18 14:46:06 +09:00
Daisuke Maki 1e90260188 Extract checkCancelled helper for filter loops 2026-02-18 14:46:05 +09:00
Daisuke Maki 0d55ffa4ae Extract isExcluded helper for negative term check 2026-02-18 14:46:04 +09:00
Daisuke Maki 44b78c5dd8 Extract newRegexpFilter helper for filter constructors 2026-02-18 14:46:03 +09:00
Daisuke Maki a547170580 Extract selectLine helper for SetDirty+Add pattern 2026-02-18 14:46:02 +09:00
lestrrat 6fe3e4516d
Merge pull request #701 from peco/fix-staticcheck-sa4016
Remove no-op bitwise OR with zero in test
2026-02-18 14:42:21 +09:00
Daisuke Maki de2d3465cb Remove no-op bitwise OR with zero in test 2026-02-18 14:40:17 +09:00
lestrrat 90f9c5ceca
Merge pull request #692 from peco/fix-ansi-parser-errors
Handle strconv.Atoi errors in ANSI SGR parser
2026-02-18 11:31:56 +09:00
lestrrat 22fff45193
Merge pull request #691 from peco/fix-test-time-sleep
Replace time.Sleep with require.Never/Eventually
2026-02-18 11:31:45 +09:00
lestrrat fa5cc8aa9d
Merge pull request #690 from peco/fix-hub-batch-locking
Replace shouldLock with context-based re-entrancy
2026-02-18 11:31:30 +09:00
lestrrat ef6c4839e3
Merge pull request #689 from peco/refactor-list-area-draw
Extract helpers from ListArea.Draw
2026-02-18 11:31:18 +09:00
lestrrat b96fe3ae01
Merge pull request #688 from peco/refactor-vertical-scroll
Extract helpers from verticalScroll
2026-02-18 11:30:56 +09:00
lestrrat 1fd9332873
Merge pull request #693 from peco/fix-enable-staticcheck-govet
Re-enable staticcheck and govet linters
2026-02-18 11:29:34 +09:00
Daisuke Maki 965feb2b8c Extract helpers from verticalScroll 2026-02-18 11:25:50 +09:00
Daisuke Maki 9425a01e3e Extract helpers from ListArea.Draw 2026-02-18 11:25:47 +09:00
Daisuke Maki 57d6761a9f Replace shouldLock with context-based re-entrancy 2026-02-18 11:25:42 +09:00
Daisuke Maki 0e71932a1d Replace time.Sleep with require.Never/Eventually 2026-02-18 11:25:35 +09:00
Daisuke Maki 0b4f9d0adf Handle strconv.Atoi errors in ANSI SGR parser 2026-02-18 11:25:30 +09:00
Daisuke Maki 6dfecd61c8 Re-enable staticcheck and govet linters 2026-02-18 11:25:24 +09:00
lestrrat 6058cbee57
Merge pull request #687 from peco/fix-dead-code
Remove dead code
2026-02-18 09:53:08 +09:00
lestrrat 22439d1b69
Merge pull request #686 from peco/fix-error-handling-minor
tweak handling
2026-02-18 09:52:52 +09:00
lestrrat 0c49ba8856
Merge pull request #685 from peco/fix-api-cleanup
remove error, avoid magic string
2026-02-18 09:52:35 +09:00
lestrrat 2acd138f66
Merge pull request #684 from peco/fix-code-style
style, minor tweaks
2026-02-18 09:52:13 +09:00
lestrrat 022c7ba4f4
Merge pull request #683 from peco/fix-test-ci
some modernization, remove debug prints, pin go 1.25
2026-02-18 09:51:56 +09:00
Daisuke Maki 6a89f1e352 some modernization, remove debug prints, pin go 1.25 2026-02-18 09:47:25 +09:00
Daisuke Maki e1c62ab701 style, minor tweaks 2026-02-18 09:46:54 +09:00
Daisuke Maki 8910d5bf17 remove error, avoid magic string 2026-02-18 09:46:27 +09:00
Daisuke Maki dfe029958c tweak handling 2026-02-18 09:45:26 +09:00
Daisuke Maki 1f218ae97e Remove dead code 2026-02-18 09:44:47 +09:00
lestrrat ced740f32d
Merge pull request #682 from peco/fix-filter-apply-error-handling
Report filter Apply errors to status bar
2026-02-18 09:37:45 +09:00
Daisuke Maki 73d5d370a4 appease linter 2026-02-18 09:35:29 +09:00
lestrrat c4e02ac1ce
Merge pull request #681 from peco/fix-screen-init-resume-error
Propagate screen Init error on resume
2026-02-18 09:35:10 +09:00
Daisuke Maki fade21e143 appease linter 2026-02-18 09:33:35 +09:00
Daisuke Maki e7db38fa07 Report filter Apply errors to status bar 2026-02-18 09:30:06 +09:00
Daisuke Maki 515e13fbf3 Propagate screen Init error on resume 2026-02-18 09:30:05 +09:00
lestrrat 3564a975b5
Merge pull request #680 from peco/fix-scanner-err-check
Check scanner.Err() after scan loop
2026-02-18 09:29:08 +09:00
Daisuke Maki a77cad279a Appease linter 2026-02-18 09:27:34 +09:00
Daisuke Maki 8d5a0f672c Check scanner.Err() after scan loop 2026-02-18 09:20:04 +09:00
lestrrat 77f66d5150
Merge pull request #679 from peco/fix-cancel-func-data-race
Fix data race on cancelFunc and err fields
2026-02-18 09:18:30 +09:00
Daisuke Maki b7e04a549a Fix data race on cancelFunc and err fields 2026-02-18 09:15:55 +09:00
lestrrat c73fa58511
Merge pull request #678 from peco/golangci-lint
Check for far wider range of things in lint
2026-02-18 08:38:47 +09:00
Daisuke Maki 464c1dc72a revert goroutine error wrapping 2026-02-18 08:36:39 +09:00
Daisuke Maki c877ae72d9 appease linter 2026-02-18 08:30:04 +09:00
Daisuke Maki e3005e7a14 Update lint version 2026-02-18 08:21:02 +09:00
Daisuke Maki 46331edcfd Check for far wider range of things in lint 2026-02-18 07:49:40 +09:00
lestrrat 6435d29b6d
Merge pull request #677 from peco/refactor-typed-context-key
convert missing context key refactoring
2026-02-17 21:42:33 +09:00
Daisuke Maki e61e114ded convert missing context key refactoring 2026-02-17 21:40:04 +09:00
lestrrat bcfd3bff83
Merge pull request #676 from peco/refactor-dedup-batch-loop
Batch flush refactoring
2026-02-17 21:35:39 +09:00
lestrrat d4744778bb
Merge pull request #675 from peco/refactor-remove-unnecessary-sort
remove unnecessary sort
2026-02-17 21:34:55 +09:00
lestrrat 761dd460a9
Merge pull request #674 from peco/fix-unguarded-pdebug-filter
guard debug print
2026-02-17 21:33:05 +09:00
lestrrat 78aff3a21b
Merge pull request #673 from peco/fix-options-write-error
explicitly ignore errors
2026-02-17 21:32:51 +09:00
lestrrat 970b3e545f
Merge pull request #672 from peco/fix-tab-rendering-off-by-one
fix off-by-one drawing tabs
2026-02-17 21:32:27 +09:00
Daisuke Maki 3869c3bd25 Batch flush refactoring 2026-02-17 21:24:40 +09:00
Daisuke Maki 28d16a4b1a remove unnecessary sort 2026-02-17 20:25:37 +09:00
Daisuke Maki c69a935ea8 explicitly ignore errors 2026-02-17 20:20:28 +09:00
Daisuke Maki ded5d7f305 guard debug print 2026-02-17 20:17:52 +09:00
Daisuke Maki 681d034bc4 fix off-by-one drawing tabs 2026-02-17 20:09:27 +09:00
lestrrat a2959c038a
Merge pull request #671 from peco/refactor-extract-run-helpers
refactor
2026-02-17 19:35:59 +09:00
Daisuke Maki 32765acb02 refactor 2026-02-17 19:34:01 +09:00
lestrrat ce6dac6800
Merge pull request #670 from peco/fix-viewarround-typo
Fix typo
2026-02-17 19:30:46 +09:00
lestrrat 32f69ee127
Merge pull request #669 from peco/fix-unguarded-pdebug
fix unguarded debug prints
2026-02-17 19:30:29 +09:00
lestrrat e34cd07e65
Merge pull request #668 from peco/fix-caret-naming
style: fix naming
2026-02-17 19:29:42 +09:00
lestrrat 2b7c9190f0
Merge pull request #667 from peco/fix-urnknown-typo
fix typo
2026-02-17 19:28:49 +09:00
lestrrat b0c340aca1
Merge pull request #666 from peco/fix-duplicate-attr-reverse
remove duplicate specification
2026-02-17 19:28:04 +09:00
lestrrat f6f3111df6
Merge pull request #665 from peco/fix-inline-buffer-size
simplify
2026-02-17 19:27:39 +09:00
lestrrat 45ba1585c0
Merge pull request #664 from peco/remove-filterquery
remove filter query struct
2026-02-17 19:27:17 +09:00
lestrrat b67e306752
Merge pull request #663 from peco/remove-cli-struct
remove unused struct
2026-02-17 19:24:34 +09:00
lestrrat a734850952
Merge pull request #662 from peco/remove-filter-outch
Remove unused outch
2026-02-17 19:24:06 +09:00
Daisuke Maki ec9ebb4f02 Fix typo 2026-02-17 19:22:00 +09:00
Daisuke Maki 4f795dc6cb fix unguarded debug prints 2026-02-17 19:16:39 +09:00
Daisuke Maki 1bb115f22b fix typo 2026-02-17 19:14:41 +09:00
Daisuke Maki 7f2815efab style: fix naming 2026-02-17 19:14:04 +09:00
Daisuke Maki 55c3397a6d remove duplicate specification 2026-02-17 19:10:32 +09:00
Daisuke Maki 4922d24a68 simplify 2026-02-17 19:05:51 +09:00
Daisuke Maki f3f29cf45d remove filter query struct 2026-02-17 19:04:52 +09:00
lestrrat 44780a2518
Merge pull request #661 from peco/remove-hub-issync
remove unused
2026-02-17 19:02:13 +09:00
Daisuke Maki aacc46f593 remove unused field 2026-02-17 19:01:41 +09:00
Daisuke Maki af981da581 remove unused struct 2026-02-17 19:01:11 +09:00
Daisuke Maki 939f75ee85 Remove unused outch 2026-02-17 18:58:18 +09:00
Daisuke Maki 055b7c3bbc remove unused 2026-02-17 18:54:39 +09:00
lestrrat b9702a35b8
Merge pull request #660 from peco/fix-select-remove-nil
avoid possibility of selection.Remove(nil)
2026-02-17 18:53:52 +09:00
lestrrat 02012db634
Merge pull request #659 from peco/fix-inline-pollevent-panic-swallow
don't just swallow the panic from inline tcell polling
2026-02-17 18:52:27 +09:00
Daisuke Maki bb004da328 avoid possibility of selection.Remove(nil) 2026-02-17 18:51:03 +09:00
lestrrat 01a3faccb5
Merge pull request #658 from peco/fix-hub-batch-panic-swallow
Avoid hub.Batch swalloing panics
2026-02-17 18:17:21 +09:00
Daisuke Maki 60d90d2ae1 don't just swallow the panic from inline tcell polling 2026-02-17 18:15:57 +09:00
Daisuke Maki 4ad0bc31c6 Avoid hub.Batch swalloing panics 2026-02-17 18:11:47 +09:00
lestrrat 5fe676efb6
Merge pull request #657 from peco/chore-generate-ci
Check for generated files
2026-02-17 18:06:05 +09:00
Daisuke Maki c3c4116dc0 Check for generated files 2026-02-17 18:03:46 +09:00
lestrrat 6bce9be413
Merge pull request #656 from peco/fix-global-action-registry
Fix global action registry
2026-02-17 14:53:25 +09:00
lestrrat 6be6200c0d
Merge pull request #655 from peco/decouple-layout-from-peco
remove direct access to peco from layout
2026-02-17 14:46:35 +09:00
Daisuke Maki 6037ac2ee5 Avoid action-at-a-distance when we don't need to 2026-02-17 14:44:49 +09:00
Daisuke Maki 65643706c7 remove direct access to peco from layout 2026-02-17 14:44:18 +09:00
Daisuke Maki 6c8ecd04fd remove direct access to peco from layout 2026-02-17 13:51:42 +09:00
lestrrat 7907224eda
Merge pull request #654 from peco/refactor-peco-god-object
Refactor Peco object
2026-02-17 13:46:52 +09:00
Daisuke Maki e2ddf46710 consolidate state objects into one file 2026-02-17 13:38:05 +09:00
Daisuke Maki de0b3bf338 Refactor query exec state 2026-02-17 13:31:10 +09:00
Daisuke Maki 4e0f004cb3 refactor frozen state 2026-02-17 13:28:21 +09:00
Daisuke Maki a813e7198e Refactor ZoomState 2026-02-17 13:25:21 +09:00
Daisuke Maki 24df34ddda refactor single key jump into its own state object 2026-02-17 13:20:10 +09:00
lestrrat 3f2817153b
Merge pull request #653 from peco/use-typed-struct-context-keys
Use saner struct key approach
2026-02-17 13:17:08 +09:00
Daisuke Maki 28d7583555 Use saner struct key approach 2026-02-17 13:13:28 +09:00
lestrrat 4ebbe37d8c
Merge pull request #650 from peco/complete-test-todos
more tests
2026-02-17 12:44:00 +09:00
Daisuke Maki 35f920b3f1 should do the trick 2026-02-17 12:39:28 +09:00
Daisuke Maki a861e13008 see if this helps 2026-02-17 12:36:20 +09:00
Daisuke Maki 7e43c9e1c9 tweak 2026-02-17 12:36:20 +09:00
Daisuke Maki 5c13375911 tweak caching 2026-02-17 12:36:20 +09:00
Daisuke Maki 4746c32231 more tests 2026-02-17 12:36:20 +09:00
lestrrat efac590a5c
Merge pull request #651 from peco/remove-matchindexer
Remove unused interface
2026-02-17 12:04:49 +09:00
lestrrat 0c55a69065
Merge pull request #652 from peco/document-buffer-sealing
doc tweak
2026-02-17 12:03:14 +09:00
lestrrat 785f8e264d
Merge pull request #649 from peco/extraoffset-to-const
Move variable to platform specific const
2026-02-17 12:02:57 +09:00
Daisuke Maki c373a303a8 Remove unused interface 2026-02-17 11:59:10 +09:00
Daisuke Maki e4de1c5504 doc tweak 2026-02-17 11:58:53 +09:00
Daisuke Maki 3a14f1f89b Move variable to platform specific const 2026-02-17 11:58:32 +09:00
lestrrat 20ad174000
Merge pull request #648 from peco/update-manpage
docs: update man page
2026-02-17 11:40:56 +09:00
lestrrat d0489cc5bd docs: regenerate man page from README 2026-02-17 02:27:49 +00:00
lestrrat 0986318381
Merge pull request #646 from peco/fix-skip-read-config
refactor how we skip config reads in tests
2026-02-17 11:27:42 +09:00
lestrrat aa89a3bc42
Merge pull request #647 from peco/remove-deprecated-config-fields
remove unused config entries
2026-02-17 11:27:28 +09:00
Daisuke Maki 8b87099a71 remove unused config entries 2026-02-17 11:25:22 +09:00
lestrrat e5d29e85be
Merge pull request #645 from peco/fix-filter-work-goroutine-wait
Wait for worker
2026-02-17 11:24:32 +09:00
Daisuke Maki 9d6d8a8531 refactor how we skip config reads in tests 2026-02-17 11:22:36 +09:00
Daisuke Maki e4da6d08b1 Wait for worker 2026-02-17 11:13:31 +09:00
lestrrat 3ba1cbdb01
Merge pull request #643 from peco/fix-input-alt-key-race
Handle esc in the input loop as well
2026-02-17 11:10:29 +09:00
lestrrat 84484a3213
Merge pull request #644 from peco/fix-hub-payload-waitdone
make this operation safer. (not necessarily a problem)
2026-02-17 11:10:12 +09:00
lestrrat 0aac27f2a5
Merge pull request #642 from peco/fix-context-background-propagation
properly propagate ctx
2026-02-17 11:07:10 +09:00
Daisuke Maki dfe6f1e775 make this operation safer. (not necessarily a problem) 2026-02-17 11:06:33 +09:00
Daisuke Maki 873d6c4236 Handle esc in the input loop as well 2026-02-17 11:04:13 +09:00
Daisuke Maki 02f7140f27 properly propagate ctx 2026-02-17 10:17:26 +09:00
lestrrat c76990e3f0
Merge pull request #641 from peco/fix-query-exec-timer-race
be more paranoid
2026-02-17 09:57:48 +09:00
lestrrat 382199aa53
Merge pull request #640 from peco/hub-remove-ctx-from-send
Refactor context usage around hub.send
2026-02-17 09:57:32 +09:00
Daisuke Maki fd89c1aefa be more paranoid 2026-02-17 09:55:12 +09:00
Daisuke Maki 7cd4b18817 Refactor context usage around hub.send 2026-02-17 09:53:29 +09:00
lestrrat 70123a95ae
Merge pull request #638 from peco/fix-screen-pollevent-panic-swallow
log pollevent errors
2026-02-17 07:51:19 +09:00
lestrrat 4285ef3779
Merge pull request #639 from peco/fix-external-filter-panic-swallow
don't throw away panics in external filters
2026-02-17 07:50:11 +09:00
Daisuke Maki 2f0d21d047 move the close outside 2026-02-17 07:49:27 +09:00
Daisuke Maki 8f0204320c don't throw away panics in external filters 2026-02-17 07:39:31 +09:00
Daisuke Maki cfc3736e8e log pollevent errors 2026-02-17 07:38:56 +09:00
lestrrat 2bd6fa1504
Merge pull request #637 from peco/dedup-test-setup
refactor test setup
2026-02-17 07:38:30 +09:00
Daisuke Maki fdf90a2121 refactor test setup 2026-02-17 07:36:21 +09:00
lestrrat 62e667cce8
Merge pull request #636 from peco/split-action-interface
Simplify Action interface
2026-02-17 07:30:52 +09:00
Daisuke Maki 27c8f08bea Simplify Action interface 2026-02-17 07:28:31 +09:00
lestrrat 79bc52f006
Merge pull request #635 from peco/split-messagehub
consolidate SendStatusMsg
2026-02-17 07:20:05 +09:00
lestrrat 90807c9142
Merge pull request #632 from peco/makefile-cleanup
cleanup Makefile
2026-02-17 07:18:11 +09:00
Daisuke Maki ffc3e85bd0 consolidate SendStatusMsg 2026-02-17 07:17:45 +09:00
lestrrat b8d9a859f7
Merge pull request #634 from peco/check-write-errors
Check for Write() errors
2026-02-17 07:16:00 +09:00
Daisuke Maki d0aadb6525 tweaks 2026-02-17 07:15:21 +09:00
lestrrat 48c7133f41
Merge pull request #633 from peco/replace-test-sleeps
use require.Eventually
2026-02-17 07:13:57 +09:00
lestrrat 06534679f1
Merge pull request #631 from peco/dedup-query-exec-draw
refactor query -> exec draw
2026-02-17 07:13:22 +09:00
lestrrat aff5a0d521
Merge pull request #630 from peco/fix-query-comments
fix comments
2026-02-17 07:13:03 +09:00
Daisuke Maki 446d0d332b Check for Write() errors 2026-02-17 07:11:18 +09:00
lestrrat c543201da9
Merge pull request #629 from peco/use-builtin-max
Use max()
2026-02-17 07:10:32 +09:00
Daisuke Maki 310e11700f use require.Eventually 2026-02-17 07:10:16 +09:00
Daisuke Maki 92a2b97712 cleanup Makefile 2026-02-17 07:09:12 +09:00
lestrrat e3472a53a9
Merge pull request #628 from peco/consolidate-postinit
remove PostInit
2026-02-17 07:08:54 +09:00
Daisuke Maki cd26822d4c refactor query -> exec draw 2026-02-17 07:08:20 +09:00
Daisuke Maki 576519b02f fix comments 2026-02-17 07:06:50 +09:00
Daisuke Maki a74e621fe0 Use max() 2026-02-17 07:05:24 +09:00
Daisuke Maki 6ecb5c777d remove PostInit 2026-02-17 07:04:32 +09:00
lestrrat 57a10a87a2
Merge pull request #627 from peco/add-test-coverage
Add test coverage
2026-02-17 07:03:02 +09:00
Daisuke Maki 4c439c71a8 Add test coverage 2026-02-17 07:00:32 +09:00
lestrrat e1164500e6
Merge pull request #626 from peco/remove-layout-panics
Don't panic for invalid values
2026-02-17 00:08:48 +09:00
lestrrat 51396c3c12
Merge pull request #625 from peco/dedup-paging-actions
Refactor paging related actions
2026-02-17 00:06:55 +09:00
Daisuke Maki 772c3ef5f1 Don't panic for invalid values 2026-02-17 00:06:20 +09:00
Daisuke Maki 4cd07c8dd3 Refactor paging related actions 2026-02-17 00:04:51 +09:00
lestrrat 5e9de3365b
Merge pull request #624 from peco/update-manpage
docs: update man page
2026-02-16 23:55:26 +09:00
lestrrat 470179cd60
Merge pull request #623 from peco/typed-pipeline-channels
use types in pipelines
2026-02-16 23:54:26 +09:00
lestrrat ac7821dce4 docs: regenerate man page from README 2026-02-16 14:53:04 +00:00
lestrrat 550d56c9a4
Merge pull request #622 from peco/rename-termbox
Replace Termbox (name references) with Tcell
2026-02-16 23:52:43 +09:00
Daisuke Maki f5f6262d82 use types in pipelines 2026-02-16 23:51:36 +09:00
Daisuke Maki a1d7596b1a Replace Termbox (name references) with Tcell 2026-02-16 23:50:19 +09:00
lestrrat dc53fb8909
Merge pull request #621 from peco/fix-input-modifier-race
Fix possible input modifier detection race
2026-02-16 23:11:50 +09:00
Daisuke Maki b775ac5dea Fix possible input modifier detection race 2026-02-16 23:08:47 +09:00
lestrrat c2c812db24
Merge pull request #620 from peco/fix-signal-stop
Fix possible race in signal handler
2026-02-16 23:04:00 +09:00
lestrrat 74adf55448
Merge pull request #619 from peco/fix-7.1-suspend-goroutine-leak
Fix possible goroutine leak during suspend
2026-02-16 23:01:43 +09:00
Daisuke Maki 78d7e4a5d8 Fix possible race in signal handler 2026-02-16 23:01:18 +09:00
lestrrat 4541ad2962
Merge pull request #618 from peco/dedup-batch-action
refactor batchAction
2026-02-16 22:55:49 +09:00
Daisuke Maki cdb31988d8 Fix possible goroutine leak during suspend 2026-02-16 22:55:20 +09:00
Daisuke Maki 3a85ae62e0 refactor batchAction 2026-02-16 22:52:18 +09:00
lestrrat deee60fad0
Merge pull request #617 from peco/fix-filter-duplication
Refactor filter
2026-02-16 22:45:24 +09:00
lestrrat b152854e6c
Merge pull request #616 from peco/dedup-freeze-unfreeze
slight refactor around freeze/unfreeze
2026-02-16 22:44:15 +09:00
lestrrat 5a88606205
Merge pull request #615 from peco/typed-hub-channels
Change Hub structure to take typed payloads
2026-02-16 22:43:44 +09:00
Daisuke Maki 7f62fe18b3 Refactor filter 2026-02-16 22:43:11 +09:00
Daisuke Maki 19f9bd1be9 slight refactor around freeze/unfreeze 2026-02-16 22:41:44 +09:00
Daisuke Maki 12a55491ef Change Hub structure to take typed payloads 2026-02-16 22:40:34 +09:00
lestrrat 53a69a609f
Merge pull request #614 from peco/dedup-selection-nav
refactor navigation code
2026-02-16 22:36:21 +09:00
Daisuke Maki d40ea73946 refactor navigation code 2026-02-16 22:32:25 +09:00
lestrrat fe611dde03
Merge pull request #613 from peco/typed-on-cancel
Use types for OnCancel values
2026-02-16 22:22:09 +09:00
Daisuke Maki 952884009c Use types for OnCancel values 2026-02-16 22:17:35 +09:00
lestrrat eb3da9c70f
Merge pull request #612 from peco/remove-unused-state-interface
Remove unused interface
2026-02-16 22:14:06 +09:00
Daisuke Maki 5bd247e9a6 Remove unused interface 2026-02-16 22:09:59 +09:00
lestrrat 2debeb5b28
Merge pull request #611 from peco/remove-pkg-errors
Do away with pkg/errors
2026-02-16 22:09:00 +09:00
Daisuke Maki 91339e96f9 Do away with pkg/errors 2026-02-16 22:06:21 +09:00
lestrrat b7f50d8a33
Merge pull request #610 from peco/fix-gomaxprocs
This is so 2010
2026-02-16 21:58:51 +09:00
Daisuke Maki 5f20cf3b7f This is so 2010 2026-02-16 21:56:04 +09:00
lestrrat 4c1065f284
Merge pull request #609 from peco/fix-external-filter-goroutine-leak
Wait for reader goroutine
2026-02-16 21:47:39 +09:00
lestrrat f0c35f429b
Merge pull request #608 from peco/fix-query-exec-timer-leak
stop the query exec timer
2026-02-16 21:47:25 +09:00
Daisuke Maki eb17420fee stop the query exec timer 2026-02-16 21:44:59 +09:00
lestrrat 6de9f7e58f
Merge pull request #607 from peco/fix-resume-deadlock
Remove the possibility of Resume() deadlocking
2026-02-16 21:42:52 +09:00
Daisuke Maki 090f44859a Wait for reader goroutine 2026-02-16 21:42:14 +09:00
Daisuke Maki 8710f98b1f Remove the possibility of Resume() deadlocking 2026-02-16 21:35:56 +09:00
lestrrat 82bc046253
Merge pull request #606 from peco/fix-frozen-done-channel
Create MarkComplete() method to avoid future us from deadlocking
2026-02-16 21:33:27 +09:00
Daisuke Maki d691144f96 Create MarkComplete() method to avoid future us from deadlocking 2026-02-16 21:26:27 +09:00
lestrrat 0fb619016d
Merge pull request #605 from peco/update-manpage
docs: update man page
2026-02-16 21:24:38 +09:00
lestrrat 731c3972b7
Merge branch 'master' into update-manpage 2026-02-16 21:22:55 +09:00
lestrrat d298cca584 docs: regenerate man page from README 2026-02-16 12:21:46 +00:00
lestrrat e0de14a560
Merge pull request #603 from peco/update-manpage
docs: update man page
2026-02-16 21:21:42 +09:00
lestrrat 8ff71a8c69
Merge pull request #604 from peco/remove-toc
We don't need a TOC in the age of AIs
2026-02-16 21:21:26 +09:00
Daisuke Maki 909635d588 We don't need a TOC in the age of AIs 2026-02-16 21:14:27 +09:00
lestrrat 4b49f65118 docs: regenerate man page from README 2026-02-16 12:00:38 +00:00
lestrrat dc696bc92f
Merge pull request #602 from peco/gh-398
Add ANSI color support
2026-02-16 21:00:18 +09:00
Daisuke Maki d8f557e108 Merge branch 'master' into gh-398 2026-02-16 20:57:37 +09:00
Daisuke Maki 49f7153598 Add ANSI color support 2026-02-16 20:06:53 +09:00
lestrrat e82a4a67e2
Merge pull request #601 from peco/update-manpage
docs: update man page
2026-02-16 19:58:04 +09:00
lestrrat 3aa22bd477 docs: regenerate man page from README 2026-02-16 10:56:30 +00:00
lestrrat 087900d58f
Merge pull request #600 from peco/gh-289
Implement zoom in/out
2026-02-16 19:56:10 +09:00
Daisuke Maki d4ef70813f Implement zoom in/out 2026-02-16 19:49:49 +09:00
lestrrat c33d0f50cb
Merge pull request #599 from peco/update-manpage
docs: update man page
2026-02-16 19:30:25 +09:00
lestrrat ba97c8394e docs: regenerate man page from README 2026-02-16 10:28:28 +00:00
lestrrat e97970e021
Merge pull request #598 from peco/gh-471
Implement --height for inline display
2026-02-16 19:28:09 +09:00
Daisuke Maki 479226adc2 Appease linter 2026-02-16 19:24:59 +09:00
Daisuke Maki ee131131c7 Implement --height for inline display 2026-02-16 19:17:09 +09:00
lestrrat 84017db910
Merge pull request #597 from peco/update-manpage
docs: update man page
2026-02-16 19:10:06 +09:00
lestrrat 6ec2528651 docs: regenerate man page from README 2026-02-16 09:13:49 +00:00
lestrrat e366f13607
Merge pull request #596 from peco/gh-424
Implement freeze/unfreeze
2026-02-16 18:13:26 +09:00
Daisuke Maki b3264ffd9a see if this fixes flaky tests 2026-02-16 18:11:15 +09:00
Daisuke Maki 68d17afc7c Implement freeze/unfreeze 2026-02-16 15:43:38 +09:00
lestrrat 757abc8b66
Merge pull request #595 from peco/gh-445
Add new layout, refactr layout lookup/registration
2026-02-16 15:10:22 +09:00
Daisuke Maki 4f1d149ceb Add new layout, refactr layout lookup/registration 2026-02-16 15:05:01 +09:00
lestrrat 35a014be56
Merge pull request #593 from peco/update-manpage
docs: update man page
2026-02-16 14:57:15 +09:00
lestrrat 97e97dffe1 docs: regenerate man page from README 2026-02-16 05:50:23 +00:00
lestrrat aae60b5083
Merge pull request #594 from peco/gh-480
Explicitly document horizontal scrolling
2026-02-16 14:50:04 +09:00
Daisuke Maki 7b9a0b2021 Explicitly document horizontal scrolling 2026-02-16 14:46:46 +09:00
lestrrat 637c5b3e07
Merge pull request #592 from peco/gh-434
Negative filters
2026-02-16 14:37:20 +09:00
Daisuke Maki 8ac38b0de6 appease linter 2026-02-16 14:35:30 +09:00
Daisuke Maki 87ce6ab7cb Negative filters 2026-02-16 14:31:16 +09:00
lestrrat 2e2c39bb8a
Merge pull request #591 from peco/update-manpage
docs: update man page
2026-02-16 11:51:45 +09:00
lestrrat f6fed6226f docs: regenerate man page from README 2026-02-16 02:49:46 +00:00
lestrrat 4b209aaf84
Merge pull request #559 from curry798/patch-1
docs: add x-cmd method to install peco
2026-02-16 11:49:27 +09:00
lestrrat fb07df89c0
Merge branch 'master' into patch-1 2026-02-16 11:49:19 +09:00
lestrrat b0b422152b
Merge pull request #590 from peco/gh-454
Performance optimizations for large buffers
2026-02-16 11:29:59 +09:00
Daisuke Maki 2c591ff8ed Batch sending source -> filter 2026-02-16 10:58:18 +09:00
Daisuke Maki 17549703ae pre-allocate buffer lines 2026-02-16 10:24:58 +09:00
Daisuke Maki fbba2f8dc3 batch append 2026-02-16 10:07:00 +09:00
Daisuke Maki 835f4698c2 Use ctx.Done() to check for Send() success/failure 2026-02-16 09:59:59 +09:00
Daisuke Maki 1a76db2e67 Skip processing in chunks for parallel workers 2026-02-16 09:27:33 +09:00
Daisuke Maki 97f408b99e First pass optimizing for large buffers 2026-02-16 09:00:07 +09:00
lestrrat e54c80ebbe
Merge pull request #589 from peco/gh-543
Fix #543
2026-02-15 19:45:08 +09:00
Daisuke Maki ca4a52ba53 Fix #543 2026-02-15 19:42:00 +09:00
lestrrat 1921422bf8
Merge pull request #588 from peco/gh-455
Fix #455
2026-02-15 18:21:58 +09:00
Daisuke Maki a35076da6f Fix #455 2026-02-15 18:13:54 +09:00
lestrrat 46ecbd1e57
Merge pull request #587 from peco/gh-467
Add man page. fixes #467
2026-02-15 16:56:01 +09:00
Daisuke Maki 31f2ecd178 Add man page. fixes #467 2026-02-15 16:47:48 +09:00
lestrrat 7715ff482f
Merge pull request #586 from peco/gh-294
fix #294
2026-02-15 15:42:44 +09:00
Daisuke Maki 56c81db24a gh-294 2026-02-15 15:35:21 +09:00
lestrrat ab653b3a53
Merge pull request #585 from peco/gh-511
Fix #511
2026-02-15 14:22:02 +09:00
Daisuke Maki 17b0aba8e8 Fix #511 2026-02-15 14:19:08 +09:00
lestrrat 8ecd1711e0
Merge pull request #584 from peco/gh-428
fix #428
2026-02-15 14:10:22 +09:00
Daisuke Maki aa1335ff70 fix #428 2026-02-15 14:07:29 +09:00
lestrrat 7c8c95b391
Merge pull request #583 from peco/gh-460
Fix #460
2026-02-15 13:18:35 +09:00
Daisuke Maki 214f2faeb1 Fix #460 2026-02-15 13:15:27 +09:00
lestrrat 59108b523e
Merge pull request #582 from peco/exit-0
fix #538
2026-02-15 09:24:53 +09:00
Daisuke Maki b560fcbbe7 fix #538 2026-02-15 09:11:18 +09:00
lestrrat 4ed16af465
Merge pull request #581 from peco/fix-ctrl-shift-modifiers
Fix #529
2026-02-15 08:48:57 +09:00
Daisuke Maki 0ef5cdb83d Fix #529 2026-02-15 08:44:21 +09:00
lestrrat 29d968e359
Merge pull request #580 from peco/gh-557
Fix #557
2026-02-15 08:16:14 +09:00
Daisuke Maki 3a7556f264 Fix #557 2026-02-15 08:14:33 +09:00
lestrrat c2f53b273d
Merge pull request #579 from peco/gh-575
Fix bindings, fix #575
2026-02-15 07:50:11 +09:00
Daisuke Maki f2c6c895b8 Fix bindings, fix #575 2026-02-15 07:46:14 +09:00
lestrrat 46f3bb7fe5
Merge pull request #578 from peco/gh-574
Fix #574
2026-02-15 07:39:19 +09:00
Daisuke Maki 0fb46c5d1e Fix #574 2026-02-15 07:37:26 +09:00
lestrrat 496c035741
Merge pull request #577 from peco/fix-null-external-filter
Fix #312
2026-02-14 23:01:48 +09:00
Daisuke Maki 1effee57e9 tweak 2026-02-14 22:55:48 +09:00
Daisuke Maki e228d8d366 tweak 2026-02-14 22:47:42 +09:00
Daisuke Maki be3d6cff25 Fix #312 2026-02-14 21:54:48 +09:00
lestrrat f701816be7
Merge pull request #573 from mattn/fix-bug
Remove leading tab from doGoToPreviousSelection function name
2026-02-14 19:24:59 +09:00
lestrrat 2253af3961
Merge pull request #576 from peco/yaml-config
Add YAML support
2026-02-14 19:24:23 +09:00
Daisuke Maki ed9a2aa56c Add YAML support 2026-02-14 19:20:25 +09:00
Yasuhiro Matsumoto 0d84ed1846
Remove leading tab from doGoToPreviousSelection function name 2026-02-14 17:37:13 +09:00
Yasuhiro Matsumoto c1ffd8b128
go fmt ./... 2026-02-14 17:36:53 +09:00
lestrrat 3723b65a5f
Merge pull request #572 from peco/memory-fixes
optimizations around memory usage
2026-02-14 17:13:49 +09:00
Daisuke Maki e4f2b4837d optimizations around memory usage 2026-02-14 15:47:37 +09:00
lestrrat 95f9dba8ad
Merge pull request #571 from peco/dependabot/go_modules/github.com/google/btree-1.1.3
Bump github.com/google/btree from 1.1.2 to 1.1.3
2026-02-14 15:27:04 +09:00
dependabot[bot] 3621c464d4
Bump github.com/google/btree from 1.1.2 to 1.1.3
Bumps [github.com/google/btree](https://github.com/google/btree) from 1.1.2 to 1.1.3.
- [Commits](https://github.com/google/btree/compare/v1.1.2...v1.1.3)

---
updated-dependencies:
- dependency-name: github.com/google/btree
  dependency-version: 1.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-14 06:25:06 +00:00
lestrrat d7fde6ac27
Merge pull request #570 from peco/dependabot/go_modules/github.com/jessevdk/go-flags-1.6.1
Bump github.com/jessevdk/go-flags from 1.5.0 to 1.6.1
2026-02-14 15:24:20 +09:00
dependabot[bot] 72dc9483ca
Bump github.com/jessevdk/go-flags from 1.5.0 to 1.6.1
Bumps [github.com/jessevdk/go-flags](https://github.com/jessevdk/go-flags) from 1.5.0 to 1.6.1.
- [Release notes](https://github.com/jessevdk/go-flags/releases)
- [Commits](https://github.com/jessevdk/go-flags/compare/v1.5.0...v1.6.1)

---
updated-dependencies:
- dependency-name: github.com/jessevdk/go-flags
  dependency-version: 1.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-14 00:27:11 +00:00
lestrrat 6caeefc551
Merge pull request #569 from peco/dependabot/go_modules/github.com/stretchr/testify-1.11.1
Bump github.com/stretchr/testify from 1.8.4 to 1.11.1
2026-02-14 09:26:28 +09:00
lestrrat e53e2c6213
Merge pull request #568 from peco/dependabot/go_modules/github.com/mattn/go-runewidth-0.0.19
Bump github.com/mattn/go-runewidth from 0.0.15 to 0.0.19
2026-02-14 09:26:19 +09:00
dependabot[bot] c3d9ee0527
Bump github.com/stretchr/testify from 1.8.4 to 1.11.1
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.8.4 to 1.11.1.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.8.4...v1.11.1)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-version: 1.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-14 00:23:44 +00:00
dependabot[bot] 247683e347
Bump github.com/mattn/go-runewidth from 0.0.15 to 0.0.19
Bumps [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) from 0.0.15 to 0.0.19.
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.15...v0.0.19)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-14 00:23:40 +00:00
lestrrat 1fc9c00698
Merge pull request #567 from peco/migrate-tcell
tcell migration
2026-02-14 09:23:07 +09:00
Daisuke Maki 0531cfd20a Properly reset screen 2026-02-14 09:13:18 +09:00
Daisuke Maki db63d9f380 Add linting fixes 2026-02-14 07:48:23 +09:00
Daisuke Maki 8356007755 Updae workflows 2026-02-13 20:07:44 +09:00
Daisuke Maki 7d42de7860 actual migration 2026-02-13 19:48:39 +09:00
Daisuke Maki e9c5582ba1 Initial pass 2026-02-13 12:35:06 +09:00
lestrrat 288862215c
Merge pull request #565 from ElementalWarrior/fix-makefile
Fix the makefile (dead glide refs, fix .phony)
2025-11-10 08:31:18 +09:00
James McDonnell 0c4112ee16 Add missing newline at the end of .gitignore 2025-11-09 15:02:10 -08:00
lestrrat 7b17e88d96
Merge branch 'master' into fix-makefile 2025-11-10 08:00:42 +09:00
lestrrat fa33145d71
Merge pull request #564 from ElementalWarrior/iregexp
Add case insensitive regex as a filter option
2025-11-10 08:00:30 +09:00
lestrrat 1ed60d275f
Merge branch 'master' into iregexp 2025-11-10 07:58:46 +09:00
lestrrat 5e01c7ce2f
Merge pull request #563 from ElementalWarrior/fix-query-toggle
Fix toggle query (C-t)
2025-11-10 07:58:07 +09:00
James McDonnell ccf33a8e97 Update readme to just use make 2025-11-09 14:45:52 -08:00
James McDonnell 5d9f2a78de Fix the makefile (dead glide refs, fix .phony)
Some of these variables were only referenced in old glide code,
which was partially removed.

Also the .PHONY line needed updating, for example, calling Building releases/peco_linux_amd64/peco
more than once would not output a new binary.

The release folder should be excluded from git, you don't want to commit
build artefacts.
2025-11-09 14:45:11 -08:00
James McDonnell 6d0ff86f90 Add case insensitive regex as a filter option
Regexp is the only way to do boolean OR searches.

Having the option to not have to type  is really convenient
2025-11-09 14:39:57 -08:00
James McDonnell 23707858fe Installdeps was removed from Makefile 2025-11-09 11:32:53 -08:00
James McDonnell 82faeab193 Fix toggle query
This logic makes no sense. It would just immediately undo the line preceeding
2025-11-09 11:32:36 -08:00
lestrrat a3f06f11be
Merge pull request #561 from eunos-1128/feature/readme-conda-installaion
Add installation instructions for the conda-format package
2025-06-22 05:56:28 +09:00
UENO, M. 1543952823
Add installation instructions for the conda-format package 2025-06-21 15:37:50 +09:00
curry798 b17182d9d2 docs: add x-cmd method to install peco 2024-10-10 15:35:23 +08:00
curry798 3a76bd164b docs: add x-cmd method to install tokei 2024-09-25 17:24:08 +08:00
lestrrat 51f38bf44c
Merge pull request #550 from peco/dependabot/go_modules/github.com/pkg/errors-0.9.1
Bump github.com/pkg/errors from 0.0.0-20161029093637-248dadf4e906 to 0.9.1
2023-09-17 07:43:47 +09:00
dependabot[bot] 330bd08291
Bump github.com/pkg/errors
Bumps [github.com/pkg/errors](https://github.com/pkg/errors) from 0.0.0-20161029093637-248dadf4e906 to 0.9.1.
- [Release notes](https://github.com/pkg/errors/releases)
- [Commits](https://github.com/pkg/errors/commits/v0.9.1)

---
updated-dependencies:
- dependency-name: github.com/pkg/errors
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-16 22:40:20 +00:00
lestrrat 06617b8768
Merge pull request #549 from peco/dependabot/go_modules/github.com/google/btree-1.1.2
Bump github.com/google/btree from 0.0.0-20161213163243-0c3044bc8bad to 1.1.2
2023-09-17 07:39:46 +09:00
dependabot[bot] 0fcedc33d9
Bump github.com/google/btree
Bumps [github.com/google/btree](https://github.com/google/btree) from 0.0.0-20161213163243-0c3044bc8bad to 1.1.2.
- [Commits](https://github.com/google/btree/commits/v1.1.2)

---
updated-dependencies:
- dependency-name: github.com/google/btree
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-16 22:32:30 +00:00
lestrrat 41a1b95a91
Merge pull request #548 from peco/dependabot/go_modules/github.com/jessevdk/go-flags-1.5.0
Bump github.com/jessevdk/go-flags from 1.1.0 to 1.5.0
2023-09-17 07:31:57 +09:00
lestrrat fd875d9739
Merge branch 'master' into dependabot/go_modules/github.com/jessevdk/go-flags-1.5.0 2023-09-17 07:27:36 +09:00
lestrrat 427ee9eb89
Merge pull request #547 from peco/dependabot/github_actions/actions/checkout-4
Bump actions/checkout from 2 to 4
2023-09-17 07:27:22 +09:00
lestrrat 73738a251b
Merge branch 'master' into dependabot/github_actions/actions/checkout-4 2023-09-17 07:20:22 +09:00
dependabot[bot] 49aa736533
Bump github.com/jessevdk/go-flags from 1.1.0 to 1.5.0
Bumps [github.com/jessevdk/go-flags](https://github.com/jessevdk/go-flags) from 1.1.0 to 1.5.0.
- [Release notes](https://github.com/jessevdk/go-flags/releases)
- [Commits](https://github.com/jessevdk/go-flags/compare/v1.1...v1.5.0)

---
updated-dependencies:
- dependency-name: github.com/jessevdk/go-flags
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-16 22:19:29 +00:00
lestrrat 2ae9bd4c59
Merge pull request #546 from peco/dependabot/go_modules/github.com/stretchr/testify-1.8.4
Bump github.com/stretchr/testify from 0.0.0-20161117074351-18a02ba4a312 to 1.8.4
2023-09-17 07:18:55 +09:00
dependabot[bot] d91744a24b
Bump actions/checkout from 2 to 4
Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-16 22:16:03 +00:00
lestrrat 16e8a9f97d
Merge branch 'master' into dependabot/go_modules/github.com/stretchr/testify-1.8.4 2023-09-17 07:15:53 +09:00
lestrrat 6468ffad6e
Merge pull request #545 from peco/dependabot/github_actions/actions/setup-go-4
Bump actions/setup-go from 2 to 4
2023-09-17 07:15:30 +09:00
dependabot[bot] 8f1e90e10e
Bump github.com/stretchr/testify
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 0.0.0-20161117074351-18a02ba4a312 to 1.8.4.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/commits/v1.8.4)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-16 22:00:49 +00:00
dependabot[bot] 819ab79a8a
Bump actions/setup-go from 2 to 4
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 2 to 4.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v2...v4)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-16 22:00:46 +00:00
lestrrat aa4c20d911
Merge pull request #544 from RincewindsHat/update_termbox_dependency
Dependency Update and automatic update checks
2023-09-17 07:00:29 +09:00
Lorenz Kästle f8145bb325 Add dependabot config 2023-09-15 14:48:16 +02:00
rincewind 2779a03cdd Update termbox-go dependency 2023-09-15 14:44:07 +02:00
Daisuke Maki 46703dc3aa Update Changes; version++ 2023-03-21 16:31:07 +09:00
lestrrat 7352294fa4 docs: update toc 2023-03-08 22:22:23 +00:00
lestrrat dd0d8ae00f
Merge pull request #539 from puhitaku/puhitaku/fuzzylongest
Add FuzzyLongest filter
2023-03-09 07:22:04 +09:00
Takumi Sueda 06a80bf8a9 Describe FuzzyLongestSort flag in the document 2023-03-08 23:46:19 +09:00
Takumi Sueda 8c3265f159 Add FuzzyLongest filter 2023-03-08 23:33:08 +09:00
Takumi Sueda 1e185a785f Add FuzzyLongestSort flag 2023-03-08 23:33:08 +09:00
Takumi Sueda 3af95d6d07 Bump Go version to 1.20.x 2023-03-08 23:32:32 +09:00
lestrrat ce38fa40be
Merge pull request #536 from cha147/patch-1
fix typos in readme
2022-07-14 07:30:04 +09:00
cha147 a7853fe85b fix typos in readme 2022-07-13 15:12:04 -07:00
lestrrat d4b3c115e9
Merge pull request #528 from peco/switch-github-actions
Switch to github actions from travis-ci
2021-07-30 12:30:04 +09:00
Shohei YOSHIDA 9c8756c26e Switch to github actions from travis-ci
Travis-CI is no longer available
2021-07-30 11:55:54 +09:00
Shohei YOSHIDA a1678931a9
Merge pull request #527 from shirayu/fix_typos
Fix typos
2021-07-26 12:27:08 +09:00
Yuta Hayashibe dc559668f3 Fix typos 2021-07-24 23:32:15 +09:00
lestrrat f39f746023 docs: update toc 2021-07-19 03:42:24 +00:00
lestrrat d350aae268
Merge pull request #526 from grawlinson/update_readme
docs: update reference to Arch Linux package
2021-07-19 12:42:03 +09:00
George Rawlinson 366ed21d52
docs: update reference to Arch Linux package 2021-07-19 02:38:50 +00:00
Daisuke Maki 27cc910092 Update Changes; v0.5.10 2021-06-08 18:15:34 +09:00
Daisuke Maki 21d3f70e05 Fix Makefile 2021-06-08 18:15:29 +09:00
Daisuke Maki bf09debbe1 Update Changes 2021-06-08 15:22:36 +09:00
Daisuke Maki 2550b108b1 do away with 386 2021-06-08 15:19:53 +09:00
Daisuke Maki ea18d1890f add go.sum 2021-06-07 22:48:32 +09:00
lestrrat af816b11c3
Merge pull request #520 from skaji/darwin-arm64
Add darwin-arm64
2021-04-30 12:06:22 +09:00
Shoichi Kaji 19f296dfa4 Use go1.16.x in travis ci 2021-04-30 09:52:06 +09:00
Shoichi Kaji cc161e0c4b Add darwin-arm64 2021-04-29 21:46:46 +09:00
lestrrat d8b6fb5601
Merge pull request #515 from neuenmuller/doctoc-official
switch back doctoc to official release
2021-03-15 18:48:25 +09:00
Sarisia d237add077 switch back doctoc to official release 2020-12-07 22:41:52 +09:00
lestrrat 4045ebad41
Merge pull request #514 from dthadi3/ppc64le
Travis-ci: added support for ppc64le
2020-12-03 07:53:22 +09:00
Devendranath Thadi 80cc79651d Travis-ci: added support for ppc64le 2020-12-02 11:23:36 +00:00
lestrrat 9e70017663
Merge pull request #513 from peco/syohex/fix-double-cursor-issue
Fix double cursor issue
2020-10-23 12:33:57 +09:00
Shohei YOSHIDA 1ee7eade47 Fix double cursor issue 2020-10-23 11:59:56 +09:00
lestrrat 8417a33e75 docs: update toc 2020-08-14 06:47:22 +00:00
lestrrat ed54c2d02d
Update README.md 2020-08-14 15:47:01 +09:00
lestrrat 21baa08c54
Merge pull request #509 from neuenmuller/toc-push
Use SSH deploy for TOC update
2020-08-14 15:25:11 +09:00
sarisia 7dce295f0e use ssh deploy 2020-08-12 15:42:07 +09:00
lestrrat 1b7d631c07
Merge pull request #508 from neuenmuller/fix-toc
Fix TOC in README.md
2020-08-12 13:18:52 +09:00
sarisia 653e368e2f add TOC section to README 2020-08-12 11:36:20 +09:00
sarisia 3c5fc2c915 use patched toc-generator in workflow 2020-08-12 11:36:01 +09:00
lestrrat e8c3231ea8
Merge pull request #506 from peco/release/v0.5.8
This be v0.5.8
2020-07-18 20:25:23 +09:00
Daisuke Maki e82bb34767 This be v0.5.8 2020-07-18 20:12:52 +09:00
lestrrat 8d38aa104c
Merge pull request #505 from narugit/feature/support-256colors
Support 256color
2020-07-18 20:09:30 +09:00
narugit e00862063c Add contributor (apply 256color) 2020-06-06 21:44:08 +09:00
narugit 5832d4aff3 Modify test to apply 256color feature 2020-06-06 21:44:00 +09:00
narugit a7cd43ff22 Apply 256color configuration 2020-06-06 21:38:11 +09:00
narugit a14f3abf35 Add configuration for 256color 2020-06-06 21:38:11 +09:00
narugit dc845d6028 Update README for 256color 2020-06-06 21:37:56 +09:00
Daisuke Maki 9c4464680c for whatever reason, I couldn't resolve #503, so here it is 2020-05-28 07:37:02 +09:00
Daisuke Maki 458682b9db Remove darwin-386, as it is no longer supported 2020-05-28 07:35:51 +09:00
lestrrat c1fffe19e8
Merge pull request #504 from imbsky/toc-gen
Use GitHub Actions to keep our ToC up to date
2020-05-28 07:31:54 +09:00
lestrrat 358871840d
Merge branch 'master' into toc-gen 2020-05-28 07:31:45 +09:00
lestrrat d2378d0d4c
Merge pull request #502 from imbsky/macos
Correct the name of macOS
2020-05-28 07:28:08 +09:00
Sora Morimoto fe2b4b56f8 Add toc.yml 2020-05-28 04:03:06 +09:00
Sora Morimoto 822a38601a Remove table of contents section from README 2020-05-28 04:03:06 +09:00
lestrrat ffc06167f8
Merge pull request #500 from pniedzwiedzinski/patch-1
Add AUR installation instruction
2020-05-27 06:25:34 +09:00
Sora Morimoto 1f0b384376 Correct the name of macOS 2020-05-27 05:35:03 +09:00
Patryk Niedźwiedziński 45e7fc6452
Add AUR installation instruction 2020-03-21 19:09:22 +01:00
lestrrat 9a06ebeedf
Merge pull request #496 from peco/release/v0.5.7
This be v0.5.7
2020-01-10 12:53:38 +09:00
Daisuke Maki 1b1d197911 This be v0.5.7 2020-01-10 12:45:00 +09:00
lestrrat 05485f5ce5
Merge pull request #495 from peco/topic/issue-494
Handle batch mode differently when the source is an infinite stream
2020-01-10 12:28:10 +09:00
Daisuke Maki b749b6191b some cleanups 2020-01-10 12:19:44 +09:00
Daisuke Maki 71c4c48efe properly set the requested value 2020-01-10 11:57:28 +09:00
Daisuke Maki 305a208ca2 remove non-applicable comments/commented sections/debug statements 2020-01-10 11:55:12 +09:00
Daisuke Maki 143638649c Add a new flag to pass to NewSource to detect infinite source
my previous detection for continueous input was completely wrong.
Now we just assume that anything coming in from Stdin could be infinite.
Additionally, in case we think it's infinite but we were actually able
to close the Reader stream, we consider it non-infinite after that
point.
2020-01-10 09:28:39 +09:00
Daisuke Maki 43ee9051f8 Fix blocking issue cased by #491
... by punting the problem when the input is an infinite stream.
This commit fixes the blocking, but the subsequent queries don't seem
to be properly running

refs #494
2020-01-09 20:22:18 +09:00
Daisuke Maki 6b42ace9d0 This be v0.5.6 2020-01-07 09:38:23 +09:00
Daisuke Maki 5aeaf9b2c0 This be v0.5.5 2020-01-02 08:26:10 +09:00
lestrrat 01cc2b3c6b
Merge pull request #492 from peco/topic/update-go-mod
Update go.mod (use new versioning, same termbox-go version)
2020-01-02 08:23:53 +09:00
Daisuke Maki 24136f4ffa Update go.mod (use new versioning, same termbox-go version) 2020-01-02 08:14:17 +09:00
lestrrat e7faada179
Merge pull request #491 from peco/topic/issue-490
Properly execute selectOneAndExit logic when an initial query is specified
2020-01-02 08:12:54 +09:00
Daisuke Maki 4d0335dbd5 Properly exec query even without --select-1 2020-01-01 16:08:33 +09:00
Daisuke Maki 55cdd5825f fix tests to more accurately reproduce user typing 2020-01-01 15:58:36 +09:00
Daisuke Maki 4ad576bc6e Properly execute selectOneAndExit logic when an initial query is specified 2020-01-01 15:27:28 +09:00
lestrrat 6bce15df53
Merge pull request #489 from shigemk2/remove_glide_readme
Delete description about glide because glide.yaml is already deleted
2019-12-24 11:02:41 +09:00
shigemk2 73fa36a33f Delete description about glide because glide.yaml is already deleted 2019-12-24 10:55:30 +09:00
Daisuke Maki 9a22355560 This be v0.5.4 2019-12-19 10:48:54 +09:00
lestrrat c4ad0a7ba4
Merge pull request #488 from peco/martinbrylski-master
fix minor go tip problem, and merge martinbrylski's fix
2019-12-19 10:06:37 +09:00
Daisuke Maki ff7b71a8b9 Update go version while we're at it 2019-12-19 09:46:07 +09:00
Daisuke Maki 927fba1ce1 redact pseudo-version for go 1.13~
https://tip.golang.org/doc/go1.13#version-validation
2019-12-19 09:38:30 +09:00
Martin Brylski 6972baf397 updated termbox. solves issue with tmux-256color TERM definition 2019-12-18 10:52:56 +01:00
lestrrat 82a3beea29
Merge pull request #486 from friederbluemle/fix-spelling
Fix GitHub spelling
2019-11-06 09:34:33 +09:00
Frieder Bluemle 2b7b521460
Fix GitHub spelling 2019-11-05 12:37:32 -08:00
lestrrat d289bb620a
Update README.md 2019-10-25 13:54:10 +09:00
lestrrat 742d55319e
Update FUNDING.yml 2019-10-25 13:49:51 +09:00
lestrrat a2f99967f2
Create FUNDING.yml 2019-10-25 13:48:51 +09:00
lestrrat 250632a9f4
Merge pull request #479 from tkainrad/patch-1
Add installation instructions for debian and ubuntu based distributions
2019-10-23 15:49:35 +09:00
lestrrat 4b06b9bd51
Merge branch 'master' into patch-1 2019-10-23 15:49:06 +09:00
lestrrat e6c113ee8f
Merge pull request #484 from peco/hisamura333-add-readme-comment
Hisamura333 add readme comment
2019-10-23 15:47:11 +09:00
Daisuke Maki c30492cb37 tweak 2019-10-23 15:41:36 +09:00
lestrrat 0a08098528
Merge branch 'master' into add-readme-comment 2019-10-23 15:40:54 +09:00
lestrrat 7753939751
Merge pull request #474 from lpmi-13/typofix
fix simple typos and formatting
2019-10-23 15:40:37 +09:00
lestrrat 9a4b60a41f
Merge branch 'master' into typofix 2019-10-23 15:34:34 +09:00
lestrrat 0f22620f6c
Merge pull request #482 from aviaviavi/master
APT and Scarf installation instructions
2019-09-21 16:44:30 +09:00
aviaviavi 8b90384f70 APT and Scarf installation 2019-09-20 13:34:23 -07:00
Thomas Kainrad 69a009ac0f
Add installation instructions for debian and ubuntu based distributions
As peco is available in the default Debian/Ubuntu repositories, I suggest to include the respective installation instructions here, so people do not think they have to build manually.
2019-06-11 19:05:52 +02:00
lestrrat 967233e253
Merge pull request #477 from watsonso/patch-1
action.go : fix typo
2019-03-15 09:54:38 +09:00
鈴木康文 6d1bce6bce action : fix typo 2019-03-15 09:44:04 +09:00
lestrrat 231cc2c1fe
Merge pull request #475 from peco/topic/mod
go module
2019-02-27 15:05:38 +09:00
Daisuke Maki 38fcb2ef1e set env var 2019-02-27 14:59:12 +09:00
Daisuke Maki e823aea441 set env var 2019-02-27 14:55:54 +09:00
Daisuke Maki 24e9b2c7b5 remove reference to glide 2019-02-27 14:53:31 +09:00
Daisuke Maki d8570b225e Use go.mod 2019-02-26 12:58:53 +09:00
lpmi-13 a243d95ff7 fix simple typos and formatting 2019-01-08 21:58:13 +00:00
Taiki HISAMURA 82edaa0f42 Add comment in Select Multiple Lines 2018-12-06 11:58:37 +09:00
lestrrat c93f2e424e
Merge pull request #465 from peco/topic/issue-462
Clarify building instructions
2018-10-25 23:28:48 +09:00
lestrrat 7e59e48412
Merge branch 'master' into topic/issue-462 2018-10-25 22:59:30 +09:00
Daisuke Maki eb778350a5 typo 2018-10-25 22:57:41 +09:00
Daisuke Maki 2255f2b54b apparently this fixes issues with pt! 2018-10-25 18:12:14 +09:00
lestrrat 6762b04883
Merge pull request #457 from taynaud/master
Add a few commands to use peco more like a pager
2018-10-25 16:56:57 +09:00
lestrrat 5027605f27
Merge branch 'master' into master 2018-10-25 16:50:15 +09:00
lestrrat dc15605aee
Merge pull request #463 from peco/topic/issue-456
Update dependency for pdebug
2018-10-25 16:46:46 +09:00
Daisuke Maki a53ba91609 enable ignore for "peco", but disable for "cmd/peco" 2018-10-25 16:14:37 +09:00
Daisuke Maki 00707e8727 Aaaarg, pt didn't find the last remaining reference to pdebug!
pt 2.2.0 used the line "peco" from .gitignore, and decided to
skip matching against the directory "cmd/peco". This made me
think that I changed all occurrances of github.com/lestrrat/go-pdebug
to github.com/lestrrat-go/pdebug, but apparently there was one
that was missing.
2018-10-25 15:59:57 +09:00
Daisuke Maki 053dde7342 see if totally renaming things will appease travis CI 2018-10-25 10:04:42 +09:00
Daisuke Maki a30b40c08f Remove old go versions 2018-10-25 10:00:27 +09:00
Daisuke Maki 7b62d0196d see if clearing the cache will appease travis CI 2018-10-25 09:59:54 +09:00
Daisuke Maki d02bb4122c Workaround glide being stubborn wrt lestrrat/go-pdebug -> lestrrat-go/pdebug 2018-10-25 09:50:16 +09:00
Daisuke Maki e6cbd7b122 Update README
fixes #462
2018-10-25 09:18:30 +09:00
lestrrat 0233a5f2a1
Merge pull request #461 from HaraldNordgren/go_versions
Bump Travis versions
2018-10-20 19:06:48 +09:00
Harald Nordgren 8c92244428 Bump Travis versions 2018-10-20 12:01:40 +02:00
Thomas Aynaud 601a7c7adc Add goToPreviousSelection 2018-06-22 15:17:31 +02:00
Thomas Aynaud 1cb1b137ac Add doGoToNextSelection action 2018-06-22 15:17:22 +02:00
Thomas Aynaud 90b5fa5d1c Add doToggleViewArround
Reset the query but do not move. This allows to select a line using peco
filters and view around, for instance for a log analysis
2018-06-22 15:16:25 +02:00
Thomas Aynaud 281db7f5e3 Go fmt 2018-06-18 13:50:20 +02:00
Daisuke Maki 9542898d4c Update changes 2018-03-31 05:27:26 +09:00
lestrrat 9385f4ec68
Merge pull request #451 from peco/topic/set-cursor
Call SetCursor when drawing the prompt
2018-03-31 05:23:53 +09:00
Daisuke Maki b60d067747 Call SetCursor when drawing the prompt 2018-03-30 10:07:42 +09:00
Daisuke Maki 814932819a version++; this be peco 0.5.3 2018-03-12 07:16:56 +09:00
lestrrat 48c72760de
Merge pull request #448 from peco/topic/go1.10
update travis to test with go1.10
2018-03-12 07:15:59 +09:00
Daisuke Maki 695ef530e9 test with go1.10 2018-03-12 07:06:44 +09:00
lestrrat 5c847fd6a9
Merge pull request #447 from sachaos/feature/update-termbox-go
Update termbox-go revision
2018-03-12 06:54:12 +09:00
Takumasa Sakao 06c6f59708 Update termbox-go revision
https://github.com/nsf/termbox-go/issues/185
2018-03-10 22:33:52 +09:00
lestrrat b80edba1de
Merge pull request #444 from bamchoh/patch-1
Correct a few links for CustomFilter/CustomMatcher
2018-02-26 20:14:42 +09:00
bamchoh 798152335b
Revise links for CustomFilter/CustomMatcher
1. Delete a deadlink of CustomMatcher
2. Correct a link to CustomFilter wiki page
2018-02-07 16:54:27 +09:00
Daisuke Maki c335455888 Update Changes 2017-12-27 10:37:33 +09:00
lestrrat 387272fa8e
Merge pull request #440 from peco/topic/print-query
Implement --print-query
2017-12-27 10:35:19 +09:00
lestrrat 7a70f43e5d
Merge branch 'master' into topic/print-query 2017-12-27 08:44:09 +09:00
lestrrat cc1aac9e9f
Merge pull request #441 from peco/topic/rename-GH439
Rename ScrollTop/ScrollBottom to ScrollFirstItem/ScrollLastItem
2017-12-27 08:43:45 +09:00
Daisuke Maki 972ecec7a2 Actually generate the stringer files
Previously I had manually created a stringer.go file from the output of
stringer command, because I had problems running it properly on my
environment for various reasons.

Taking a fresh look, this is no longer a problem, so I'm opting to
actually generate it manually.

Adds ScrollFirstItem and ScrollLastItem constants
2017-12-27 07:26:18 +09:00
Daisuke Maki a47c6ca159 Add description 2017-12-27 07:15:32 +09:00
Daisuke Maki d213c89cab Rename to Top -> FirstItem, Bottom -> LastItem 2017-12-27 07:14:11 +09:00
Daisuke Maki 4341024b1d Add README 2017-12-27 07:00:36 +09:00
Anders Eurenius 3058ce77f4 Add scroll to top and bottom 2017-12-26 15:28:00 +01:00
Daisuke Maki 2b0093b828 Test print-query 2017-12-26 21:58:25 +09:00
Daisuke Maki d25a14bf40 peco --print-query prints out the query as first line 2017-12-26 21:16:34 +09:00
Daisuke Maki b9a1bbec36 Merge branch 'nicolas-raoul-patch-1' 2017-12-26 07:22:50 +09:00
Daisuke Maki 3629a8cf99 Merge branch 'patch-1' of https://github.com/nicolas-raoul/peco into nicolas-raoul-patch-1 2017-12-26 07:22:24 +09:00
lestrrat 75f471e6f0
Merge pull request #437 from peco/topic/v0.5.2
version++; this be v0.5.2
2017-12-08 10:20:46 +09:00
Daisuke Maki bdb30f5fb7 version++; this be v0.5.2 2017-12-08 10:13:17 +09:00
lestrrat 47e1fb3c3b
Merge pull request #436 from peco/topic/glide-tweak
add --update-vendored flag
2017-12-08 10:02:22 +09:00
Daisuke Maki 6ee4fb7a0a add --update-vendored flag 2017-12-08 09:52:55 +09:00
lestrrat 3566702e4b
Merge pull request #435 from peco/topic/print-go-version
Print out the go version used to build the app
2017-12-08 09:52:08 +09:00
Daisuke Maki 149bf78df5 Print out the go version used to build the app 2017-12-08 09:42:55 +09:00
Nicolas Raoul a523adfc13
Fixed typos in filter name
Using `-initial-filter RegExp` would result in "specified filter was not found".
2017-11-20 16:12:20 +09:00
lestrrat 4498edd44f
Merge pull request #432 from Jagua/patch-1
Fix document to use new api "peco.RotateFilter"
2017-11-08 08:55:48 +09:00
Jagua bbc1646dfb
Fix document to use new api "peco.RotateFilter"
fix to use new api "peco.RotateFilter" instead of deprecated "peco.RotateMatcher" in Default Keymap section.
2017-11-07 23:53:55 +09:00
lestrrat 1403c233d2 Merge pull request #430 from dvrkps/patch-1
travis: update go versions
2017-10-04 06:57:29 +09:00
Davor Kapsa b47401a470 travis: update go versions 2017-09-27 12:57:35 +02:00
lestrrat 7b7749376f Merge pull request #426 from peco/topic/issue-432
Update glide version
2017-06-21 15:09:30 +09:00
Daisuke Maki 297ea2af36 oops 2017-06-21 15:03:37 +09:00
Daisuke Maki f1c639ef92 Update glide version 2017-06-21 15:03:37 +09:00
lestrrat 0329ae4a87 Merge pull request #418 from mash/faq-bracketed-paste-mode
Added FAQ entry about seeing bracket paste mode's escape sequences
2017-03-27 07:26:33 +09:00
Masakazu Ohtsuka e63b3a43a0 Added FAQ entry about seeing bracket paste mode's escape sequences 2017-03-27 00:10:10 +09:00
lestrrat 6157554008 Merge pull request #416 from zchee/makefile-glide
Makefile: remove unnecessary glide binary check
2017-03-22 12:19:38 +09:00
Koichi Shiraishi b35ae0fa92
Makefile: remove unnecessary glide binary check
Signed-off-by: Koichi Shiraishi <zchee.io@gmail.com>
2017-03-22 12:12:08 +09:00
Syohei YOSHIDA 81fb06c4f7 Merge pull request #415 from k0kubun/v051-changes-typo
Fix typo in Changes
2017-03-17 14:38:22 +09:00
Takashi Kokubun 1ab4a3576b Fix typo in Changes 2017-03-17 14:03:10 +09:00
lestrrat 492fad3a62 Merge pull request #414 from peco/topic/v0.5.1
version++; this be v0.5.1
2017-03-17 12:51:00 +09:00
133 changed files with 18369 additions and 4076 deletions

50
.claude/docs/cli.md Normal file
View 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

View 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
View 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
View 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
View file

@ -0,0 +1,58 @@
<!-- Agent-consumed file. Keep terse, unambiguous, machine-parseable. -->
# Testing
## Commands
```bash
make test # go test -v -race ./...
go test -v -run TestFoo ./... # single test, all packages
go test -v -run TestFoo ./filter/ # single test, specific package
go test -race -coverprofile=coverage.out ./... # coverage
```
## Test Package Convention
- Tests use same package name (not `_test` suffix) — white-box testing
- Exception: some packages use `_test` suffix for external testing
## Key Test Helpers
- `newPeco() → *Peco` — creates test instance with SimScreen, default config
- `NewDummyScreen() → *SimScreen` — mock terminal; supports `SendEvent(Event)` for input injection
- SimScreen has fixed size, no-op rendering, collects events
## Test Patterns
- Table-driven tests with `t.Run()` subtests
- Regression tests for GitHub issues in `issues_test.go`
- Filter tests in `filter/filter_test.go`, `filter/base_test.go`
- Hub tests in `hub/hub_test.go`
- Key sequence tests in `internal/keyseq/trie_test.go`, `ahocorasick_test.go`, `ternary_test.go`
- Pipeline tests in `pipeline/pipeline_test.go`
- Selection tests in `selection/selection_test.go`
- Query tests in `query/query_test.go`
## Benchmark Tests
- `filter/bench_test.go` — filter algorithm benchmarks
- `hub/bench_test.go` — hub message passing benchmarks
- `line/bench_test.go` — line allocation benchmarks
- `internal/ansi/bench_test.go` — ANSI parsing benchmarks
- `internal/util/bench_test.go` — utility benchmarks
- `cmd/filterbench/` — standalone filter benchmark CLI
## No Test Data Directory
- No `testdata/` or golden files
- Tests use inline data and programmatic setup
## Build Tags
- Platform-specific files: `_posix.go`, `_bsd.go`, `_windows.go`, `_darwin.go`
- No custom build tags for testing
## Code Generation
- `go generate ./...` — runs `stringer` for enum types
- Generated files: `vertical_anchor_gen.go`, `hub/paging_request_type_gen.go`

12
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,12 @@
# These are supported funding model platforms
github: ["lestrrat", "mattn", "syohex"]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

17
.github/dependabot.yml vendored Normal file
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,31 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
with:
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

5
.gitignore vendored
View file

@ -22,7 +22,7 @@ _testmain.go
*.exe
*.test
peco
/peco
build/src
@ -34,3 +34,6 @@ out
_internal_bin
vendor
github_token
releases
dist

98
.golangci.yml Normal file
View 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
View 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

View file

@ -1,14 +0,0 @@
language: go
go:
- 1.8.x
- tip
sudo: false
cache:
directories:
- vendor
- _internal_bin/$(go version | awk '{ print $NF }')
install:
- make installdeps
script:
- make test
- make all

1
AGENTS.md Symbolic link
View file

@ -0,0 +1 @@
CLAUDE.md

104
CLAUDE.md Normal file
View file

@ -0,0 +1,104 @@
<!-- Agent-consumed file. Keep terse, unambiguous, machine-parseable. -->
# CLAUDE.md
peco is an interactive filtering tool for the terminal, written in Go.
## Pre-Read Rules
Read the linked doc BEFORE working in that area. No exceptions.
| Trigger | Doc |
|---------|-----|
| Working with any package API or adding imports | `.claude/docs/packages.md` |
| Modifying cross-package dependencies | `.claude/docs/dependencies.md` |
| Writing or running tests | `.claude/docs/testing.md` |
| Modifying CLI flags, entry point, or output | `.claude/docs/cli.md` |
| Modifying concurrency, hub, buffers, filters, layout, screen, actions | `.claude/docs/internals.md` |
## Build & Test Commands
```bash
make # Build binary via goreleaser (default target)
make build # Build binary to dist/peco_<os>_<arch>/peco
make test # Run all tests: go test -v -race ./...
make deps # Download Go module dependencies
make clean # Remove build artifacts
```
Run a single test:
```bash
go test -v -run TestFunctionName ./...
go test -v -run TestFunctionName ./filter/ # for a specific package
```
The entry point is `cmd/peco/peco.go`.
## Architecture
### Concurrency Model
peco runs three main goroutines coordinated via context cancellation:
- **Input loop** (`input.go`) — reads tcell key events, resolves key sequences via Keymap, dispatches actions
- **View loop** (`view.go`) — renders screen in response to draw/paging/status messages
- **Filter loop** (`filter.go`) — executes queries against the line buffer when query text changes
These goroutines communicate through the **Hub** (`hub/`), a central message bus with typed channels: `QueryCh`, `DrawCh`, `PagingCh`, `StatusMsgCh`.
### Data Flow
1. **Source** (`source.go`) reads input lines (stdin or file), implements `pipeline.Source`
2. User keystrokes trigger actions that modify the query
3. Query changes are sent to the Filter loop via Hub
4. **Filter** applies the active filter algorithm to produce matched lines
5. Results flow through the **Pipeline** (`pipeline/`) as `Source → Acceptor → Destination`
6. **View** receives draw messages and delegates to **Layout** (`layout.go`) which composes `UserPrompt`, `ListArea`, and `StatusBar`
7. **Screen** (`screen.go`) wraps tcell/v2 for terminal cell rendering
### Key Interfaces
- **`Buffer`** — line storage (`LineAt`, `Size`); implemented by `MemoryBuffer`, `FilteredBuffer`, `Source`
- **`Filter`** (in `filter/`) — `Apply(ctx, []line.Line, ChanOutput)` for each filter algorithm (IgnoreCase, CaseSensitive, SmartCase, Regexp, IRegexp, Fuzzy, ExternalCmd)
- **`Line`** (`line/`) — represents a single line with `ID`, `Buffer`, `DisplayString`, `Output`
- **`Screen`** — terminal abstraction (`Init`, `SetCell`, `Flush`, `PollEvent`); `SimScreen` used in tests
- **`Layout`** — screen composition (`DrawScreen`, `DrawPrompt`, `MovePage`)
- **`Action`** — user actions bound to keys (`action.go`); ~40 built-in actions, supports combined action sequences
### Selection
Uses `google/btree` for ordered selection storage. Supports multi-select, range mode, and sticky selection (persists across query changes).
### Key Sequence Resolution
`internal/keyseq/` implements Trie, TernarySearch, and AhoCorasick for matching multi-key sequences to actions (longest-match-wins).
### Platform-Specific Code
Platform-specific behavior lives in `layout_any.go` / `layout_windows.go` (handling the `extraOffset` layout constant) and in files suffixed `_posix.go` / `_windows.go` under `internal/util/` (TTY detection, shell integration, home directory resolution).
### Code Generation
Uses `go:generate` with `stringer` for enum string representations.
## Testing Patterns
- `newPeco()` helper creates a test instance with `SimScreen` (mock terminal)
- `NewDummyScreen()` returns a `SimScreen` that supports event injection for simulating user input
- Table-driven tests with `t.Run()` subtests are the common pattern
- Regression tests for specific GitHub issues in `issues_test.go`
## Cache Maintenance
These docs cache repository state. Still read source before modifying code.
1. When your changes affect a doc below, update it in the same commit.
2. If you notice any doc is wrong or stale — even on an unrelated task — fix it immediately.
| Doc | Update trigger |
|-----|----------------|
| `packages.md` | Add/remove/rename exported functions, types, or packages |
| `dependencies.md` | Add/remove internal package imports |
| `testing.md` | Change test infrastructure, helpers, or test commands |
| `cli.md` | Add/remove CLI flags, change exit codes or output format |
| `internals.md` | Change concurrency model, hub channels, buffer types, layout system, action system |

231
Changes
View file

@ -1,6 +1,227 @@
Changes
=======
v0.6.0 - 24 Feb 2026
[Breaking Changes]
* Migrated terminal backend from termbox-go to tcell (#567).
* Removed deprecated config fields `Matcher`, `InitialMatcher`, and
`CustomMatcher`. Use `InitialFilter` and `CustomFilter` instead.
Note: `CustomFilter` uses a different format than `CustomMatcher`:
Before: "CustomMatcher": { "MyFilter": ["/path/to/cmd", "$QUERY"] }
After: "CustomFilter": { "MyFilter": { "Cmd": "/path/to/cmd", "Args": ["$QUERY"] } }
* Removed deprecated CLI option `--initial-matcher`.
Use `--initial-filter` instead.
* The action `ViewArround` has been renamed to `ViewAround` (fix typo).
The old name still works but shows a deprecation notice. If you have
`"ViewArround"` in your config, please update it to `"ViewAround"`.
* Removed `Use256Color` config field. After the tcell migration,
256-color and truecolor support is automatic via terminfo detection.
Existing configs with this field will continue to load without error.
* Shift+Tab is now mapped to Tab internally. Use `M-[,Z` in your
keymap config if you need a distinct Shift+Tab binding.
* Default key bindings for `GoToNextSelection` and
`GoToPreviousSelection` have changed. `GoToNextSelection` was
previously unbound (it silently conflicted with `KillEndOfLine` on
Ctrl+K in v0.5.x) and is now bound to Alt+j. `GoToPreviousSelection`
was previously bound to Ctrl+J and is now bound to Alt+k.
* ANSI color support is now enabled by default (`--color=auto`).
ANSI escape sequences in input are now parsed and rendered with
colors preserved. To disable, use `--color=none` or set
`"Color": "none"` in your config file.
* Query terms starting with `-` are now interpreted as negative
(exclusion) filters. In v0.5.x, `foo -bar` searched for lines
matching both "foo" and literal "-bar"; in v0.6.0 it searches for
"foo" while excluding lines containing "bar". To match a literal
hyphen prefix, escape it with a backslash: `\-bar`.
[Features]
* Added YAML configuration file support. You can now use
`~/.config/peco/config.yaml` in addition to JSON (#576).
* Added negative filter terms. Prefix a search term with `-` to exclude
matching lines, e.g. `-unittests SSO` matches "SSO" but excludes
lines containing "unittests" (#434, #592).
* Added `peco.FreezeResults` / `peco.UnfreezeResults` actions.
Freezing locks the current result set so you can clear the query,
switch filters, and continue filtering against the frozen subset (#424, #596).
* Added `--height` option for inline display. Accepts a line count
(e.g. `--height 5`) or a percentage (e.g. `--height 50%`) and
renders peco inline instead of taking over the full screen
(#471, #598).
* Added `peco.ZoomIn` / `peco.ZoomOut` actions that expand visible
context around matched lines, showing surrounding lines (#289, #600).
* Added ANSI color rendering support via `--color=auto|none` CLI flag
and `"Color"` config field. Colored output from tools like
`git log --color` or `rg --color` is now displayed with colors
preserved. Default is `auto` (enabled) (#398, #602).
* Added `IRegexp` filter for case-insensitive regexp matching, so you
no longer need to manually type `(?i)` in regex queries. `IRegexp`
can also be used as a value for `--initial-filter` / `InitialFilter`
(#564).
* Added `--exit-0` option to immediately exit with non-zero status
when the input is empty, analogous to `--select-1` (#538, #582).
* Added Ctrl+Shift key modifier and CSI u / fixterms escape sequence
support for modern terminals (#529, #581).
* Added Page Up/Down and Home/End key support (#428, #584).
* Added a new `top-down-query-bottom` layout: top-down list with
query prompt at the bottom. Use `--layout top-down-query-bottom`
or `"Layout": "top-down-query-bottom"` in config (#445, #595).
* Added man page (`peco.1`) for offline documentation (#467, #587).
* Added support for ANSI color codes in the `--prompt` string (#294, #586).
* Added `--select-all` option to select all input lines and immediately
exit without displaying the selection view. When combined with
`--query`, only matching lines are selected.
* Added `SuppressStatusMsg` config field to suppress the status message
bar at the bottom of the screen.
* Added `FilterBufSize` config field to control the internal buffer
size (in lines) used during filtering. Default is 1000.
* Added `Prompt` and `Context` style targets. `Prompt` controls the
appearance of the query prompt prefix (e.g. `QUERY>`). `Context`
controls the appearance of context lines shown by ZoomIn (default:
bold).
* Added `#RRGGBB` truecolor support in style configuration. You can
now use hex color values like `"#ff6600"` for foreground and
`"on_#003366"` for background colors.
* Added `QueryCursor` style for explicit control over the query prompt
cursor appearance. When not configured, the cursor is derived from
the `Query` style by swapping fg/bg colors.
[Bugs/Fixes]
* Fixed `ToggleQuery` (C-t) not working correctly (#563).
* Fixed fuzzy sort producing incorrect order with >1000 lines of
input due to chunk-size boundary (#557, #589).
* Fixed matched highlight style bleeding to the right edge of the
terminal (#460, #583).
* Fixed screen not being properly redrawn when stderr output from
piped commands corrupts the display (#455, #588).
* Suppressed flickering "Running query..." status message (#511, #585).
* Fixed `--null` not working correctly with custom filters (#312, #577).
* Fixed `doGoToNextSelection` and `doGoToPreviousSelection` sharing
the same default key binding (#575, #579). `GoToNextSelection` is
now bound to Alt+j and `GoToPreviousSelection` to Alt+k. The
previous Ctrl+J and Ctrl+K bindings have been removed (Ctrl+K
remains bound to `KillEndOfLine`). If you relied on Ctrl+J or
Ctrl+K for selection navigation, update your keymap config.
* Fixed `GoToPreviousSelection` being registered with a malformed
internal name (`\tdoGoToPreviousSelection`) in v0.5.x. Users who
had `"peco.GoToPreviousSelection"` in their config keymap will find
that it now correctly binds to the action.
* Fixed `lastLine` tracking variable not being updated (#574, #578).
* Fixed tab rendering off-by-one error (#672).
* Restored mouse support lost during the tcell migration (#735).
* Fixed Ctrl-C not properly exiting peco due to meta character
handling (#715, #716).
* Fixed `--select-1` not triggering on interactive queries and not
respecting context for streaming sources (#726).
* Fixed filter cache not being invalidated on freeze/unfreeze,
causing stale results (#727).
* Fixed external filters (`CustomFilter`) incorrectly running in
parallel, which could produce garbled results. They now run
sequentially (#722).
* Removed redundant Esc key timer; Escape should now feel more
responsive (#728).
* Filter errors are now reported to the status bar instead of being
silently swallowed (#682).
* Fixed screen Init error being swallowed on suspend/resume (#681).
* Fixed I/O errors during stdin read being silently ignored (#680).
* Fixed ANSI SGR parser not handling malformed escape sequences
gracefully (#692).
* Fixed potential panic on negative index in FilteredBuffer (#770).
* Fixed errors from external filters being silently dropped
(#752, #766).
* Fixed various internal race conditions, resource leaks, and
potential deadlocks.
[Performance]
* Optimized filtering for large inputs with parallelized workers,
achieving 7-30x speedup for ~1M lines (#454, #590).
* Reduced overall memory consumption (#572).
* Reduced per-query allocation overhead and GC pauses: sync.Pool for
match structs, in-place merge, slice reuse in regexp/fuzzy filters,
closure elimination, stack-allocated ANSI parsing, and fast-path
ANSI stripping (#781-#789, #765).
v0.5.11 - 21 Mar 2023
[Features]
* Added new option for "FuzzyLongestSort" flag that can be specified in your config file
as
```
{
"FuzzyLongestSort": true,
...
}
```
allows you to sort matched lines with the following ordering precedence (#539):
1. longer substring
2. earlier (left positioned) substring, and
3. shorter line.
[Miscellaneous]
* Posted project status at https://github.com/peco/peco/discussions/540
v0.5.10 - 08 Jun 2021
* Previous release was botched. No code change except for version string,
and Makefile typo.
v0.5.9 - 08 Jun 2021
* Remove support for 386 arch
* Add go.sum
* Add darwin-arm
* Fix double cursor issue (#513)
v0.5.8 - 18 Jul 2020
Features:
* Add support for 256-colors in the configuration. You must first enable
this in the config file by specifying "Use256Colors": true
v0.5.7 - 10 Jan 2020
Bugs/Fixes:
* Code introduced in v0.5.5 introduced a bug where if the input is coming
from standard input and is possibly infinite, the user would see the
query being "stuck" after the first character is entered.
If peco was applied on a finite set of data, this did not manifest itself.
(#494 fixed by #495)
v0.5.6 - 07 Jan 2020
Miscellaneous:
* I completely messed up the release process. Retagging and uploading
binaries properly. No code changes except for the version number (#493)
v0.5.5 - 02 Jan 2020
Features:
* Properly exit out of pec if --select-1 and --query are both specified,
and the query produces exactly one result (#490)
v0.5.4 - 19 Dec 2019
Miscellaneous:
* Properly set cursor position (#450)
* Update termbox-go version (#487/#488)
* Update go version to go 1.13
v0.5.3 - 12 Mar 2018
Features:
* New --print-query option prints out the query upon successful exit
* New actions ScrollFirstItem and ScrollLastItem
Miscellaneous:
* Update termbox-go version (#447)
* Documentation fixes (#444)
* Build peco using go1.10
v0.5.2 - 08 Dec 2017
Backwards Incompatible Change:
* --version flag now prints out the Go version used to build the binary
Micellaneous:
* Various doc fixes
* Build peco using go1.9.2.
* There have apparently been problems running the stock peco binary in
MacOS High Sierra, with an error message like "failed MSpanList_Insert...",
which can averted by compiling the binary with a newer Go.
Reports suggest that peco v0.5.1 is not affected by this problem, but
we're making this release anyway just to make sure that we're building
on a new-ish Go, and that users have a way to see what Go version was
used to build their binaries
v0.5.1 - 17 Mar 2017
Bugs/Fixes
* When --exec is used, and you come back from the external command,
@ -9,12 +230,12 @@ v0.5.1 - 17 Mar 2017
* --tty has been removed. it was not being used anyways.
Miscellaneous
* When scrolling right, you could potentially keep on scrolling
infinitely. This has bee addressed by #409, #412
infinitely. This has been addressed by #409, #412
* External commands specified in --exec now receive
PECO_FILENAME, PECO_LINE_COUNT, PECO_QUERY, and
PECO_MACHED_LINE_COUNT as environment variables
PECO_MATCHED_LINE_COUNT as environment variables
* Removed unused structs
* Fixed glide related Mkaefile actions
* Fixed glide related Makefile actions
v0.5.0 - 06 Mar 2017
Backwards Incompatible Change:
@ -354,7 +575,7 @@ v0.1.8 - 25 Jun 2014
v0.1.7 - 22 Jun 2014
Bugs/Fixes:
* Moved repository to a Github organization: http://github.com/peco/peco
* Moved repository to a GitHub organization: http://github.com/peco/peco
* Because of the above change, a lot of links, imports needed fixing.
Features:
* Automatically set GOMAXPROCS to NumCPU. If you would like to
@ -429,4 +650,4 @@ v0.1.1 - 12 Jun 2014
* Add peco --version
v0.1.0 - 12 Jun 2014
* Initial versioned release
* Initial versioned release

159
Makefile
View file

@ -1,147 +1,34 @@
INTERNAL_BIN_DIR=_internal_bin
GOVERSION=$(shell go version)
THIS_GOOS=$(word 1,$(subst /, ,$(lastword $(GOVERSION))))
THIS_GOARCH=$(word 2,$(subst /, ,$(lastword $(GOVERSION))))
GOOS=$(THIS_GOOS)
GOARCH=$(THIS_GOARCH)
VERSION=$(patsubst "%",%,$(lastword $(shell grep 'const version' peco.go)))
RELEASE_DIR=releases
ARTIFACTS_DIR=$(RELEASE_DIR)/artifacts/$(VERSION)
SRC_FILES = $(wildcard *.go cmd/peco/*.go internal/*/*.go)
HAVE_GLIDE:=$(shell (test -e $(INTERNAL_BIN_DIR)/$(THIS_GOOS)/$(THIS_GOARCH)/glide || which glide >/dev/null 2>&1 ) && echo "yes")
GITHUB_USERNAME=peco
BUILD_TARGETS= \
build-linux-arm64 \
build-linux-arm \
build-linux-amd64 \
build-linux-386 \
build-darwin-amd64 \
build-darwin-386 \
build-windows-amd64 \
build-windows-386
RELEASE_TARGETS=\
release-linux-arm64 \
release-linux-arm \
release-linux-amd64 \
release-linux-386 \
release-darwin-amd64 \
release-darwin-386 \
release-windows-amd64 \
release-windows-386
MAKEFLAGS += --no-print-directory
export PATH := $(HOME)/go/bin:$(PATH)
.PHONY: clean build $(RELEASE_TARGETS) $(BUILD_TARGETS) $(RELEASE_DIR)/$(GOOS)/$(GOARCH)/peco$(SUFFIX)
.DEFAULT_GOAL := build
build: $(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)/peco$(SUFFIX)
.PHONY: build snapshot test lint generate cover clean deps check-goreleaser
$(INTERNAL_BIN_DIR):
@echo "Creating $(INTERNAL_BIN_DIR)"
@mkdir -p $(INTERNAL_BIN_DIR)
check-goreleaser:
@command -v goreleaser >/dev/null 2>&1 || { echo "goreleaser not found. Install it: https://goreleaser.com/install/"; exit 1; }
$(INTERNAL_BIN_DIR)/$(THIS_GOOS)/$(THIS_GOARCH)/glide: $(INTERNAL_BIN_DIR)
ifeq ($(HAVE_GLIDE),)
@echo "Installing glide for $(THIS_GOOS)/$(THIS_GOARCH)..."
@mkdir -p $(INTERNAL_BIN_DIR)/$(THIS_GOOS)/$(THIS_GOARCH)
@wget -q -O - https://github.com/Masterminds/glide/releases/download/0.10.2/glide-0.10.2-$(THIS_GOOS)-$(THIS_GOARCH).tar.gz | tar xvz
@mv $(THIS_GOOS)-$(THIS_GOARCH)/glide $(INTERNAL_BIN_DIR)/$(THIS_GOOS)/$(THIS_GOARCH)/glide
@rm -rf $(THIS_GOOS)-$(THIS_GOARCH)
endif
build: check-goreleaser
goreleaser build --snapshot --single-target --clean
glide: $(INTERNAL_BIN_DIR)/$(THIS_GOOS)/$(THIS_GOARCH)/glide
snapshot: check-goreleaser
goreleaser release --snapshot --clean
installdeps: glide $(SRC_FILES)
@echo "Installing dependencies..."
@$(INTERNAL_BIN_DIR)/$(THIS_GOOS)/$(THIS_GOARCH)/glide install
test:
go test -v -race ./...
build-windows-amd64:
@$(MAKE) build GOOS=windows GOARCH=amd64 SUFFIX=.exe
lint:
golangci-lint run ./...
build-windows-386:
@$(MAKE) build GOOS=windows GOARCH=386 SUFFIX=.exe
generate:
go generate ./...
build-linux-amd64:
@$(MAKE) build GOOS=linux GOARCH=amd64
build-linux-arm:
@$(MAKE) build GOOS=linux GOARCH=arm
build-linux-arm64:
@$(MAKE) build GOOS=linux GOARCH=arm64
build-linux-386:
@$(MAKE) build GOOS=linux GOARCH=386
build-darwin-amd64:
@$(MAKE) build GOOS=darwin GOARCH=amd64
build-darwin-386:
@$(MAKE) build GOOS=darwin GOARCH=386
$(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)/peco$(SUFFIX): installdeps
go build -o $(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)/peco$(SUFFIX) cmd/peco/peco.go
all: $(BUILD_TARGETS)
release: $(RELEASE_TARGETS)
$(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)/Changes:
@cp Changes $(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)
$(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)/README.md:
@cp README.md $(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)
release-changes: $(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)/Changes
release-readme: $(RELEASE_DIR)/peco_$(GOOS)_$(GOARCH)/README.md
release-windows-amd64: build-windows-amd64
@$(MAKE) release-changes release-readme release-zip GOOS=windows GOARCH=amd64
release-windows-386: build-windows-386
@$(MAKE) release-changes release-readme release-zip GOOS=windows GOARCH=386
release-linux-amd64: build-linux-amd64
@$(MAKE) release-changes release-readme release-targz GOOS=linux GOARCH=amd64
release-linux-arm: build-linux-arm
@$(MAKE) release-changes release-readme release-targz GOOS=linux GOARCH=arm
release-linux-arm64: build-linux-arm64
@$(MAKE) release-changes release-readme release-targz GOOS=linux GOARCH=arm64
release-linux-386: build-linux-386
@$(MAKE) release-changes release-readme release-targz GOOS=linux GOARCH=386
release-darwin-amd64: build-darwin-amd64
@$(MAKE) release-changes release-readme release-zip GOOS=darwin GOARCH=amd64
release-darwin-386: build-darwin-386
@$(MAKE) release-changes release-readme release-zip GOOS=darwin GOARCH=386
$(ARTIFACTS_DIR):
@mkdir -p $(ARTIFACTS_DIR)
# note: I dreamt of using tar.bz2 for my releases, but then historically
# (for whatever reason that is unknwon to me now) I was creating .zip for
# darwin/windows, and .tar.gz for linux, so I guess we'll stick with those.
# (I think this is from goxc days)
release-tarbz: $(ARTIFACTS_DIR)
tar -cjf $(ARTIFACTS_DIR)/peco_$(GOOS)_$(GOARCH).tar.bz2 -C $(RELEASE_DIR) peco_$(GOOS)_$(GOARCH)
release-targz: $(ARTIFACTS_DIR)
tar -czf $(ARTIFACTS_DIR)/peco_$(GOOS)_$(GOARCH).tar.gz -C $(RELEASE_DIR) peco_$(GOOS)_$(GOARCH)
release-zip: $(ARTIFACTS_DIR)
cd $(RELEASE_DIR) && zip -9 $(CURDIR)/$(ARTIFACTS_DIR)/peco_$(GOOS)_$(GOARCH).zip peco_$(GOOS)_$(GOARCH)/*
release-github-token: github_token
@echo "file `github_token` is required"
release-upload: release release-github-token
ghr -u $(GITHUB_USERNAME) -t $(shell cat github_token) --draft --replace $(VERSION) $(ARTIFACTS_DIR)
test: installdeps
@echo "Running tests..."
@PATH=$(INTERNAL_BIN_DIR)/$(GOOS)/$(GOARCH):$(PATH) go test -v $(shell $(INTERNAL_BIN_DIR)/$(THIS_GOOS)/$(THIS_GOARCH)/glide nv)
cover:
go test -race -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
clean:
-rm -rf $(RELEASE_DIR)/*/*
-rm -rf $(ARTIFACTS_DIR)/*
rm -rf dist
deps:
go mod download

691
README.md
View file

@ -2,10 +2,9 @@
Simplistic interactive filtering tool
*NOTE*: If you are viewing this on Github, this document refers to the state of `peco` in whatever current branch you are viewing, _not_ necessarily the state of a currently released version. Please make sure to checkout the [Changes](./Changes) file for features and changes.
This README is long and comprehensive. Use the [Table of Contents](#table-of-contents) to navigate to the section that interests you. It has been placed at the bottom of the README file because of its length.
*NOTE*: If you are viewing this on GitHub, this document refers to the state of `peco` in whatever current branch you are viewing, _not_ necessarily the state of a currently released version. Please make sure to checkout the [Changes](./Changes) file for features and changes.
> If you use peco, please consider sponsoring the authors of this project from the "Sponsor" button on the project page at https://github.com/peco/peco. Sponsorship plans start at $1 :)
# Description
@ -44,9 +43,28 @@ Multiple terms turn the query into an "AND" query:
When you find that line that you want, press enter, and the resulting line
is printed to stdout, which allows you to pipe it to other tools
## Negative Matching
You can exclude lines from the results by prefixing a term with `-`. For example, the query `SSO -tests -javadoc` shows lines matching "SSO" that do NOT contain "tests" or "javadoc".
| Query | Meaning |
|-------|---------|
| `foo -bar` | Lines matching "foo" but not containing "bar" |
| `-foo -bar` | All lines not containing "foo" or "bar" |
| `\-foo` | Literal match for "-foo" (escaped with backslash) |
| `-` | Literal match for a hyphen character |
Negative matching works with all built-in filters (IgnoreCase, CaseSensitive, SmartCase, Regexp, IRegexp, and Fuzzy). For the Fuzzy filter, negative terms use regexp-based exclusion rather than fuzzy matching. External custom filters receive the query as-is and are responsible for their own parsing.
Only positive terms produce match highlighting. Lines matched solely by negative exclusion (e.g. an all-negative query like `-foo`) are shown without highlighting.
**Note:** When using the SmartCase filter with negative terms, results may be incomplete if the query transitions from all-lowercase to mixed-case (e.g. typing `foo -bar` then adding an uppercase character). If this happens, clearing the query and retyping it will produce the correct results.
**Upgrading from v0.5.x:** In v0.5.x, a query like `test -v` matched lines containing both "test" and literal "-v". From v0.6.0 onwards, `-v` is treated as a negative term, so the query matches "test" while excluding lines containing "v". To search for a literal hyphen-prefixed term, escape it with a backslash (e.g. `\-v`).
## Select Multiple Lines
You can select multiple lines!
You can select multiple lines! (this example uses C-Space)
![Executed `ls -l | peco`, then used peco.ToggleSelection to select multiple lines](http://peco.github.io/images/peco-demo-multiple-selection.gif)
@ -58,22 +76,148 @@ Not only can you select multiple lines one by one, you can select a range of lin
## Select Filters
Different types of filters are available. Default is case-insensitive filter, so lines with any case will match. You can toggle between IgnoreCase, CaseSensitive, SmartCase RegExp and Fuzzy filters.
Different types of filters are available. Default is case-insensitive filter, so lines with any case will match. You can toggle between IgnoreCase, CaseSensitive, SmartCase, Regexp case insensitive, Regexp and Fuzzy filters.
The SmartCase filter uses case-*insensitive* matching when all of the queries are lower case, and case-*sensitive* matching otherwise.
The RegExp filter allows you to use any valid regular expression to match lines
The Regexp filter allows you to use any valid regular expression to match lines.
The Fuzzy filter allows you to find matches using partial patterns. For example, when searching for `ALongString`, you can enable the Fuzzy filter and search `ALS` to find it. The Fuzzy filter uses smart case search like the SmartCase filter.
The Fuzzy filter allows you to find matches using partial patterns. For example, when searching for `ALongString`, you can enable the Fuzzy filter and search `ALS` to find it. The Fuzzy filter uses smart case search like the SmartCase filter. With the `FuzzyLongestSort` flag enabled in the configuration file, it does a smarter match. It sorts the matched lines by the following precedence: 1. longer substring, 2. earlier (left positioned) substring, and 3. shorter line.
![Executed `ps aux | peco`, then typed `google`, which matches the Chrome.app under IgnoreCase filter type. When you change it to Regexp filter, this is no longer the case. But you can type `(?i)google` instead to toggle case-insensitive mode](http://peco.github.io/images/peco-demo-matcher.gif)
## Multi-Stage Filtering (Freeze Results)
You can "freeze" the current filter results, clear the query, and continue filtering on top of the frozen results. This enables multi-stage filtering workflows -- for example, first filter by file extension, freeze, then filter by filename.
Use `peco.FreezeResults` to snapshot the current results and clear the query. Use `peco.UnfreezeResults` to discard the frozen results and revert to the original input. These actions are **not bound to any key by default** -- you need to add keybindings in your config file:
```json
{
"Keymap": {
"M-f": "peco.FreezeResults",
"M-u": "peco.UnfreezeResults"
}
}
```
You can freeze multiple times to progressively narrow down results. Unfreezing always reverts back to the original unfiltered input.
**Example:** Given this input via `ls | peco`:
```
QUERY>
app.go
app_test.go
filter.go
filter_test.go
main.go
readme.md
```
Type `_test` to filter:
```
QUERY> _test
app_test.go
filter_test.go
```
Press `M-f` to freeze. The two test files become the new base and the query clears:
```
QUERY>
app_test.go
filter_test.go
```
Now type `filter` to search within the frozen results:
```
QUERY> filter
filter_test.go
```
Press `Enter` to select `filter_test.go`, or press `M-u` to unfreeze and return to the original full list.
## Horizontal Scrolling
When input lines are longer than the terminal width, they are clipped at the edge of the screen. You can scroll horizontally to reveal the rest of the line using the `peco.ScrollLeft` and `peco.ScrollRight` actions. These actions are **not bound to any key by default** -- you need to add keybindings in your config file:
```json
{
"Keymap": {
"ArrowLeft": "peco.ScrollLeft",
"ArrowRight": "peco.ScrollRight"
}
}
```
Each scroll moves by half the terminal width.
If your input contains very long lines (e.g. minified files) and they do not appear at all, try increasing `MaxScanBufferSize` in your config. The default is 256 (KB), which limits the maximum length of a single input line.
## ANSI Color Support
By default (`--color=auto`), peco parses ANSI SGR (Select Graphic Rendition) escape sequences from the input and renders the original colors in the terminal. This lets you pipe colored output from tools like `git log --color`, `rg --color=always`, or `ls --color` through peco while preserving the visual formatting.
```
git log --color=always | peco
rg --color=always pattern | peco
ls --color=always | peco
```
To disable ANSI color rendering, use `--color=none`.
peco uses two independent color layers, both supporting 8 named colors, 256-color, and 24-bit truecolor. The first is peco's own **UI styling** — selection highlighting, match highlighting, prompt colors, etc. — configured via the `Style` section in the config file (see [Styles](#styles)). UI styling is always active and is not affected by the `--color` flag. The second is **input ANSI colors** — escape sequences already present in the piped input from tools like `git log --color`. This layer is controlled by the `--color` flag described above.
When input ANSI color support is enabled, filtering and matching operate against the **stripped** (plain text) version of each line, so escape codes do not interfere with your queries. Input ANSI colors are displayed as the **base layer**; peco's own UI styling takes precedence. Selected lines' output preserves the **original** ANSI codes, so downstream tools receive colored text.
ANSI color support can be controlled via the configuration file (see [Color](#color) under Global configuration).
## Context Lines (Zoom In/Out)
When filtering results (e.g. searching for "error" in a log file), you often need to see the surrounding lines to understand the context. peco supports expanding filtered results to show context lines around each match, similar to `grep -C`.
Two actions are available:
- **`peco.ZoomIn`** — Expands the current filtered view by showing 3 lines of context (before and after) around every matched line. Overlapping context ranges are merged automatically. Context lines are displayed with the `Context` style (bold by default) to visually distinguish them from matched lines.
- **`peco.ZoomOut`** — Collapses back to the original filtered view, restoring the cursor position.
These actions are **not bound to any key by default**. Add keybindings in your config file:
```json
{
"Keymap": {
"C-o": "peco.ZoomIn",
"C-i": "peco.ZoomOut"
}
}
```
ZoomIn only works when there is an active filter query; if you are viewing the unfiltered source, it is a no-op. You cannot zoom in twice — zooming in while already zoomed shows a status message. The cursor position is preserved: after ZoomIn, the cursor stays on the same matched line; after ZoomOut, it returns to where it was before zooming. Context lines cannot be selected — only the original matched lines participate in selection. The `Context` style can be customized in the config file (see [Styles](#styles)).
## Selectable Layout
As of v0.2.5, if you would rather not move your eyes off of the bottom of the screen, you can change the screen layout by either providing the `--layout=bottom-up` command line option, or set the `Layout` variable in your configuration file
![Executed `ps -ef | peco --layout=bottom-up` to toggle inverted layout mode](http://peco.github.io/images/peco-demo-layout-bottom-up.gif)
## Inline Mode (--height)
By default peco takes over the entire terminal screen using the alternate screen buffer. With `--height`, peco renders inline at the bottom of the terminal, preserving your scroll history above. This is similar to fzf's `--height` option.
```
# Render with 5 result lines at the bottom of the terminal
ls | peco --height 5
# Use 40% of the terminal height
ls | peco --height 40%
```
All layout modes (`top-down`, `bottom-up`, `top-down-query-bottom`) work with `--height`. See [--height](#--height-numpercentage) for details.
## Works on Windows!
I have been told that peco even works on windows :) Look ma! I'm not lying!
@ -86,16 +230,50 @@ I have been told that peco even works on windows :) Look ma! I'm not lying!
Go to the [releases page](https://github.com/peco/peco/releases), find the version you want, and download the zip file. Unpack the zip file, and put the binary to somewhere you want (on UNIX-y systems, /usr/local/bin or the like). Make sure it has execution bits turned on. Yes, it is a single binary! You can put it anywhere you want :)
_THIS IS THE RECOMMENDED WAY_ (except for OS X homebrew users)
_THIS IS THE RECOMMENDED WAY_ (except for macOS homebrew users)
### Mac OS X / Homebrew
### macOS (Homebrew, Scarf)
If you're on OS X and want to use homebrew:
If you're on macOS and want to use homebrew:
```
brew install peco
```
or with Scarf:
```
scarf install peco
```
### Debian and Ubuntu based distributions (APT, Scarf)
There is an official Debian package that can be installed via APT:
```
apt install peco
```
or with Scarf:
```
scarf install peco
```
### Void Linux (XBPS)
```
xbps-install -S peco
```
### Arch Linux
There is an official Arch Linux package that can be installed via `pacman`:
```
pacman -Syu peco
```
### Windows (Chocolatey NuGet Users)
There's a third-party [peco package available](https://chocolatey.org/packages/peco) for Chocolatey NuGet.
@ -104,25 +282,50 @@ There's a third-party [peco package available](https://chocolatey.org/packages/p
C:\> choco install peco
```
### X-CMD (Linux, macOS, Windows WSL, Windows GitBash)
peco is available from [x-cmd](https://www.x-cmd.com).
To install peco, run:
```shell
x env use peco
```
### Linux / macOS / Windows (Conda, Mamba, Pixi)
`conda`, `mamba` and `pixi` are platform-agnostic package managers for conda-format packages.
This means that the same command can be used to install peco across Windows, MacOS, and Linux.
```
# conda
conda install -c conda-forge peco
# mamba
mamba install -c conda-forge peco
# install user-globally using pixi
pixi global install peco
```
### Using go install
If you have a Go toolchain installed, you can install peco with:
```
go install github.com/peco/peco/cmd/peco@latest
```
### Building peco yourself
From the root of your cloned peco repository, run:
Clone the repository and run:
```
glide install
make build
```
This installs the correct versions of peco's dependencies. Then build it:
```
go build cmd/peco/peco.go
```
This compiles a peco binary in the root of the cloned peco repository. Copy this file to an appropriate location.
### go get IS NOT RECOMMENDED
Please DO NOT use `go get` to install this tool. It bypasses the developers' intention of controlling the dependency versioning.
This will build the binary into `dist/peco_<os>_<arch>/peco`. Copy it to somewhere in your `$PATH`.
# Command Line Options
@ -136,7 +339,11 @@ Display the version of peco
### --query <query>
Specifies the default query to be used upon startup. This is useful for scripts and functions where you can figure out before hand what the most likely query string is.
Specifies the default query to be used upon startup. This is useful for scripts and functions where you can figure out beforehand what the most likely query string is.
### --print-query
When exiting, prints out the query typed by the user as the first line of output. The query will be printed even if there are no matches, if the program is terminated normally (i.e. enter key). On the other hand, the query will NOT be printed if the user exits via a cancel (i.e. esc key).
### --rcfile <filename>
@ -148,27 +355,25 @@ Limits the buffer size to `num`. This is an important feature when you are using
### --null
WARNING: EXPERIMENTAL. This feature will probably stay, but the option name may change in the future.
Changes how peco interprets incoming data. When this flag is set, you may insert NUL ('\0') characters in your input. Anything before the NUL character is treated as the string to be displayed by peco and is used for matching against user query. Anything after the NUL character is used as the "result": i.e., when peco is about to exit, it displays this string instead of the original string displayed.
[Here's a simple example of how to use this feature](https://gist.github.com/mattn/3c7a14c1677ecb193acd)
### --initial-index
Specifies the initial line position upon start up. E.g. If you want to start out with the second line selected, set it to "1" (because the index is 0 based)
Specifies the initial line position upon start up. E.g. If you want to start out with the second line selected, set it to "1" (because the index is 0 based).
### --initial-filter `IgnoreCase|CaseSensitive|SmartCase|Regexp|Fuzzy`
### --initial-filter `IgnoreCase|CaseSensitive|SmartCase|IRegexp|Regexp|Fuzzy`
Specifies the initial filter to use upon start up. You should specify the name of the filter like `IgnoreCase`, `CaseSensitive`, `SmartCase`, `Regexp` and `Fuzzy`. Default is `IgnoreCase`.
Specifies the initial filter to use upon start up. You should specify the name of the filter like `IgnoreCase`, `CaseSensitive`, `SmartCase`, `IRegexp`, `Regexp` and `Fuzzy`. Default is `IgnoreCase`.
### --prompt
Specifies the query line's prompt string. When specified, takes precedence over the configuration file's `Prompt` section. The default value is `QUERY>`
Specifies the query line's prompt string. When specified, takes precedence over the configuration file's `Prompt` section. The default value is `QUERY>`.
### --layout `top-down|bottom-up`
### --layout `top-down|bottom-up|top-down-query-bottom`
Specifies the display layout. Default is `top-down`, where query prompt is at the top, followed by the list, then the system status message line. `bottom-up` changes this to the list first (displayed in reverse order), the query prompt, and then the system status message line.
Specifies the display layout. Default is `top-down`, where query prompt is at the top, followed by the list, then the system status message line. `bottom-up` changes this to the list first (displayed in reverse order), the query prompt, and then the system status message line. `top-down-query-bottom` places the list at the top with the query prompt at the bottom.
For `percol` users, `--layout=bottom-up` is almost equivalent of `--prompt-bottom --result-bottom-up`.
@ -178,6 +383,14 @@ When specified *and* the input contains exactly 1 line, peco skips prompting you
If there are multiple lines in the input, the usual selection view is displayed.
### --exit-0
When specified and the input is empty (zero lines), peco exits immediately with status 1 without displaying the selection view.
### --select-all
When specified, peco selects all input lines and immediately exits without displaying the selection view.
### --on-cancel `success|error`
Specifies the exit status to use when the user cancels the query execution.
@ -185,7 +398,7 @@ For historical and back-compatibility reasons, the default is `success`, meaning
### --selection-prefix `string`
When specified, peco uses the specified prefix instead of changing line color to indicate currently selected line(s). default is to use colors. This option is experimental
When specified, peco uses the specified prefix instead of changing line color to indicate currently selected line(s). Default is to use colors.
### --exec `string`
@ -195,6 +408,55 @@ Upon exiting from the external command, the control goes back to peco where you
To exit out of peco when running in this mode, you must execute the Cancel command, usually the escape key.
### --color `auto|none`
Controls ANSI color code support. `auto` (the default) enables parsing and rendering of ANSI SGR escape sequences from the input. `none` disables it, stripping escape sequences without rendering colors.
See [ANSI Color Support](#ansi-color-support) in the Features section for details.
### --height `num|percentage`
When specified, peco renders inline at the bottom of the terminal using only the requested number of lines, instead of taking over the full screen. This preserves your terminal scroll history above the peco interface.
The value can be:
- An absolute number of **result lines** (e.g. `--height 5`). The prompt and status bar are added automatically, so `--height 5` uses 7 terminal rows total (5 result lines + prompt + status bar).
- A percentage of the terminal height (e.g. `--height 50%`). This refers to the total height including prompt and status bar.
The minimum effective height is 3 rows (1 result line + prompt + status bar). Values that exceed the terminal height are clamped.
```
# Show 5 result lines inline
ls | peco --height 5
# Use 40% of the terminal
ls | peco --height 40%
```
Without `--height`, peco uses the full terminal screen (default behavior, unchanged).
**Note:** In inline mode, peco sets the environment variable `TCELL_ALTSCREEN=disable` to prevent tcell from using the alternate screen buffer, and restores the original value on exit. If peco is killed abnormally (e.g. `SIGKILL`), you may need to unset this variable manually: `unset TCELL_ALTSCREEN`.
### -f, --follow
When specified, peco follows streaming input, automatically scrolling to keep the newest lines visible at the bottom of the list, just like `tail -f`. This is useful for live log streams:
```
journalctl -f -n 1000 | peco --follow --layout top-down-query-bottom
```
Moving the cursor manually (e.g. with the arrow keys) turns follow mode off so you can scroll back through the history. The `ToggleFollow` action turns it back on; bind it to a key in your configuration file, for example:
```json
{
"Keymap": {
"C-f": "peco.ToggleFollow"
}
}
```
Follow mode can also be enabled by default with the `Follow` configuration variable. The `--follow` command line option takes precedence over the configuration file.
# Configuration File
peco by default consults a few locations for the config files.
@ -202,7 +464,7 @@ peco by default consults a few locations for the config files.
1. Location specified in --rcfile. If this doesn't exist, peco complains and exits
2. $XDG\_CONFIG\_HOME/peco/config.json
3. $HOME/.config/peco/config.json
4. for each directories listed in $XDG\_CONFIG\_DIRS, $DIR/peco/config.json
4. for each directory listed in $XDG\_CONFIG\_DIRS, $DIR/peco/config.json
5. If all else fails, $HOME/.peco/config.json
Below are configuration sections that you may specify in your config file:
@ -211,9 +473,8 @@ Below are configuration sections that you may specify in your config file:
* [Keymaps](#keymaps)
* [Styles](#styles)
* [CustomFilter](#customfilter)
* [CustomMatcher](#custommatcher)
* [Prompt](#prompt)
* [InitialMatcher](#initialmatcher)
* [Color](#color)
## Global
@ -229,13 +490,15 @@ You can change the query line's prompt, which is `QUERY>` by default.
}
```
### InitialMatcher
*InitialMatcher* has been deprecated. Please use `InitialFilter` instead.
### InitialFilter
Specifies the filter name to start peco with. You should specify the name of the filter, such as `IgnoreCase`, `CaseSensitive`, `SmartCase`, `Regexp` and `Fuzzy`.
Specifies the filter name to start peco with. You should specify the name of the filter, such as `IgnoreCase`, `CaseSensitive`, `SmartCase`, `IRegexp`, `Regexp` and `Fuzzy`.
### FuzzyLongestSort
Enables the longest substring match and sorts the output. It affects only the Fuzzy filter.
Default value for FuzzyLongestSort is false.
### StickySelection
@ -246,13 +509,26 @@ Specifies the filter name to start peco with. You should specify the name of the
```
StickySelection allows selections to persist even between changes to the query.
For example, when you set this to true you can select a few lines, type in a
For example, when you set this to true you can select a few lines, type in a
new query, select those lines, and then delete the query. The result is all
the lines that you selected before and after the modification to the query are
left in tact.
left intact.
Default value for StickySelection is false.
### SuppressStatusMsg
```json
{
"SuppressStatusMsg": true
}
```
SuppressStatusMsg suppresses the status message bar at the bottom of the screen.
When set to true, messages like "Running query..." will not be displayed.
Default value for SuppressStatusMsg is false.
### OnCancel
```json
@ -277,6 +553,44 @@ very long lines that prohibit peco from reading them, try increasing this number
The same time, the default MaxScanBuferSize is 256kb.
### FilterBufSize
```json
{
"FilterBufSize": 1000
}
```
Controls the number of lines buffered internally during filtering. If you are
working with very large inputs and notice sluggish filter updates, you can try
adjusting this value.
Default value for FilterBufSize is 1000.
### Color
```json
{
"Color": "none"
}
```
Controls ANSI color code support. Valid values are `"auto"` (parse and render ANSI SGR escape sequences) and `"none"` (disable). This is equivalent to using the `--color` command line flag. The command line flag takes precedence if both are specified.
Default value for Color is `"auto"` (enabled).
See [ANSI Color Support](#ansi-color-support) in the Features section for details.
### Height
```json
{
"Height": "10"
}
```
`Height` is equivalent to using `--height` on the command line. When set, peco renders inline at the bottom of the terminal instead of using the full screen. The value is the number of result lines (e.g. `"10"`) or a percentage of terminal height (e.g. `"50%"`). The command line `--height` option takes precedence over this config value.
## Keymaps
Example:
@ -336,9 +650,24 @@ As a similar example, a common idiom in emacs is that `C-c C-c` means "take the
### Available keys
Since v0.1.8, in addition to values below, you may put a `M-` prefix on any
Since v0.1.8, in addition to values below, you may put a `M-` prefix on any
key item to use Alt/Option key as a mask.
You can also use `C-` and `S-` prefixes on navigation keys to bind Ctrl and Shift modified keys. Multiple modifiers can be combined. For example:
```json
{
"Keymap": {
"C-ArrowLeft": "peco.BackwardWord",
"C-ArrowRight": "peco.ForwardWord",
"S-ArrowUp": "peco.SelectUp",
"C-M-Delete": "peco.DeleteForwardWord"
}
}
```
Note: `C-` on single characters (e.g. `C-a`) refers to ASCII control codes as before. `C-` as a modifier applies to navigation keys such as `ArrowLeft`, `Home`, `Delete`, etc.
| Name | Notes |
|-------------|-------|
| C-a ... C-z | Control + whatever character |
@ -376,56 +705,67 @@ key item to use Alt/Option key as a mask.
Some keys just... don't map correctly / too easily for various reasons. Here, we'll list possible workarounds for key sequences that are often asked for:
| You want this | Use this instead | Notes |
|---------------|------------------|------------------|
| Shift+Tab | M-\[,Z | Verified on OS X |
| You want this | Use this instead | Notes |
|---------------|------------------|-------------------|
| Shift+Tab | M-\[,Z | Verified on macOS |
**Note:** Due to the tcell migration, Shift+Tab is internally mapped to Tab. If you need a distinct Shift+Tab binding, use the `M-[,Z` key sequence in your config instead.
### Available actions
| Name | Notes |
|------|-------|
| peco.ForwardChar | Move caret forward 1 character |
| peco.BackToInitialFilter | Switch to first filter in the list |
| peco.BackwardChar | Move caret backward 1 character |
| peco.ForwardWord | Move caret forward 1 word |
| peco.BackwardWord | Move caret backward 1 word|
| peco.BackToInitialFilter| Switch to first filter in the list |
| peco.BackwardWord | Move caret backward 1 word |
| peco.BeginningOfLine | Move caret to the beginning of line |
| peco.EndOfLine | Move caret to the end of line |
| peco.EndOfFile | Delete one character forward, otherwise exit from peco with failure status |
| peco.DeleteForwardChar | Delete one character forward |
| peco.Cancel | Exits from peco with failure status, or cancel select mode |
| peco.CancelRangeMode | Finish selecting by range and cancel range selection |
| peco.CancelSelectMode | (DEPRECATED) Alias to CancelRangeMode |
| peco.DeleteAll | Delete all entered characters |
| peco.DeleteBackwardChar | Delete one character backward |
| peco.DeleteForwardWord | Delete one word forward |
| peco.DeleteBackwardWord | Delete one word backward |
| peco.DeleteForwardChar | Delete one character forward |
| peco.DeleteForwardWord | Delete one word forward |
| peco.EndOfFile | Delete one character forward, otherwise exit from peco with failure status |
| peco.EndOfLine | Move caret to the end of line |
| peco.Finish | Exits from peco with success status |
| peco.ForwardChar | Move caret forward 1 character |
| peco.ForwardWord | Move caret forward 1 word |
| peco.FreezeResults | Freeze current results and clear the query to start a new filter on top |
| peco.GoToNextSelection | Jump cursor to the next saved selection |
| peco.GoToPreviousSelection | Jump cursor to the previous saved selection |
| peco.InvertSelection | Inverts the selected lines |
| peco.KillBeginningOfLine | Delete the characters under the cursor backward until the beginning of the line |
| peco.KillEndOfLine | Delete the characters under the cursor until the end of the line |
| peco.DeleteAll | Delete all entered characters |
| peco.RefreshScreen | Redraws the screen. Note that this effectively re-runs your query |
| peco.SelectPreviousPage | (DEPRECATED) Alias to ScrollPageUp |
| peco.SelectNextPage | (DEPRECATED) Alias to ScrollPageDown |
| peco.RotateFilter | Rotate between filters (by default, ignore-case/no-ignore-case) |
| peco.ScrollFirstItem | Scrolls to the first item (in the entire buffer, not the current screen) |
| peco.ScrollLastItem | Scrolls to the last item (in the entire buffer, not the current screen) |
| peco.ScrollLeft | Scrolls the screen to the left |
| peco.ScrollPageDown | Moves the selected line cursor for an entire page, downwards |
| peco.ScrollPageUp | Moves the selected line cursor for an entire page, upwards |
| peco.SelectUp | Moves the selected line cursor to one line above |
| peco.SelectDown | Moves the selected line cursor to one line below |
| peco.SelectPrevious | (DEPRECATED) Alias to SelectUp |
| peco.SelectNext | (DEPRECATED) Alias to SelectDown |
| peco.ScrollLeft | Scrolls the screen to the left |
| peco.ScrollRight | Scrolls the screen to the right |
| peco.SelectAll | Selects the all line, and save it |
| peco.SelectDown | Moves the selected line cursor to one line below |
| peco.SelectNext | (DEPRECATED) Alias to SelectDown |
| peco.SelectNextPage | (DEPRECATED) Alias to ScrollPageDown |
| peco.SelectNone | Remove all saved selections |
| peco.SelectPrevious | (DEPRECATED) Alias to SelectUp |
| peco.SelectPreviousPage | (DEPRECATED) Alias to ScrollPageUp |
| peco.SelectUp | Moves the selected line cursor to one line above |
| peco.SelectVisible | Selects the all visible line, and save it |
| peco.ToggleQuery | Toggle list between filtered by query and not filtered |
| peco.ToggleRangeMode | Start selecting by range, or append selecting range to selections |
| peco.ToggleFollow | Toggle follow mode (auto-scroll to the newest lines, like tail -f) |
| peco.ToggleSelectMode | (DEPRECATED) Alias to ToggleRangeMode |
| peco.ToggleSelection | Selects the current line, and saves it |
| peco.ToggleSelectionAndSelectNext | Selects the current line, saves it, and proceeds to the next line |
| peco.ToggleSingleKeyJump | Enables SingleKeyJump mode a.k.a. "hit-a-hint" |
| peco.SelectNone | Remove all saved selections |
| peco.SelectAll | Selects the all line, and save it |
| peco.SelectVisible | Selects the all visible line, and save it |
| peco.ToggleSelectMode | (DEPRECATED) Alias to ToggleRangeMode |
| peco.CancelSelectMode | (DEPRECATED) Alias to CancelRangeMode |
| peco.ToggleQuery | Toggle list between filterd by query and not filterd. |
| peco.ToggleRangeMode | Start selecting by range, or append selecting range to selections |
| peco.CancelRangeMode | Finish selecting by range and cancel range selection |
| peco.RotateMatcher | (DEPRECATED) Use peco.RotateFilter |
| peco.RotateFilter | Rotate between filters (by default, ignore-case/no-ignore-case)|
| peco.Finish | Exits from peco with success status |
| peco.Cancel | Exits from peco with failure status, or cancel select mode |
| peco.UnfreezeResults | Discard frozen results and revert to the original input |
| peco.ViewAround | Toggle display of context lines around each match |
| peco.ZoomIn | Expand filtered results with context lines around each match |
| peco.ZoomOut | Collapse back to the filtered view (undo ZoomIn) |
### Default Keymap
@ -434,33 +774,35 @@ Note: If in case below keymap seems wrong, check the source code in [keymap.go](
|Key|Action|
|---|------|
|Esc|peco.Cancel|
|C-c|peco.Cancel|
|Enter|peco.Finish|
|C-f|peco.ForwardChar|
|C-a|peco.BeginningOfLine|
|C-b|peco.BackwardChar|
|C-d|peco.DeleteForwardChar|
|C-e|peco.EndOfLine|
|C-k|peco.KillEndOfLine|
|C-u|peco.KillBeginningOfLine|
|BS|peco.DeleteBackwardChar|
|C-8|peco.DeleteBackwardChar|
|C-w|peco.DeleteBackwardWord|
|C-g|peco.SelectNone|
|C-n|peco.SelectDown|
|C-p|peco.SelectUp|
|C-r|peco.RotateMatcher|
|C-t|peco.ToggleQuery|
|C-Space|peco.ToggleSelectionAndSelectNext|
|ArrowUp|peco.SelectUp|
|ArrowDown|peco.SelectDown|
|ArrowLeft|peco.ScrollPageUp|
|ArrowRight|peco.ScrollPageDown|
|ArrowUp|peco.SelectUp|
|BS|peco.DeleteBackwardChar|
|C-8|peco.DeleteBackwardChar|
|C-Space|peco.ToggleSelectionAndSelectNext|
|C-a|peco.BeginningOfLine|
|C-b|peco.BackwardChar|
|C-c|peco.Cancel|
|C-d|peco.DeleteForwardChar|
|C-e|peco.EndOfLine|
|C-f|peco.ForwardChar|
|C-g|peco.SelectNone|
|C-k|peco.KillEndOfLine|
|C-n|peco.SelectDown|
|C-p|peco.SelectUp|
|C-r|peco.RotateFilter|
|C-t|peco.ToggleQuery|
|C-u|peco.KillBeginningOfLine|
|C-w|peco.DeleteBackwardWord|
|Enter|peco.Finish|
|Esc|peco.Cancel|
|Pgdn|peco.ScrollPageDown|
|Pgup|peco.ScrollPageUp|
## Styles
For now, styles of following 5 items can be customized in `config.json`.
Styles can be customized in `config.json`.
```json
{
@ -469,7 +811,10 @@ For now, styles of following 5 items can be customized in `config.json`.
"SavedSelection": ["bold", "on_yellow", "white"],
"Selected": ["underline", "on_cyan", "black"],
"Query": ["yellow", "bold"],
"Matched": ["red", "on_blue"]
"QueryCursor": ["white", "on_red"],
"Matched": ["red", "on_blue"],
"Prompt": ["green", "bold"],
"Context": ["bold"]
}
}
```
@ -478,50 +823,68 @@ For now, styles of following 5 items can be customized in `config.json`.
- `SavedSelection` for lines of saved selection
- `Selected` for a currently selecting line
- `Query` for a query line
- `QueryCursor` for the cursor on the query line. If not specified, the cursor colors are derived automatically: when `Query` has custom colors, they are swapped (fg becomes bg and vice versa); otherwise, the terminal's reverse video attribute is used.
- `Matched` for a query matched word
- `Prompt` for the query prompt prefix (e.g., `QUERY>`)
- `Context` for context lines shown by ZoomIn (default: bold)
### Foreground Colors
- `"black"` for `termbox.ColorBlack`
- `"red"` for `termbox.ColorRed`
- `"green"` for `termbox.ColorGreen`
- `"yellow"` for `termbox.ColorYellow`
- `"blue"` for `termbox.ColorBlue`
- `"magenta"` for `termbox.ColorMagenta`
- `"cyan"` for `termbox.ColorCyan`
- `"white"` for `termbox.ColorWhite`
- `"default"` for the terminal's default foreground color
- `"black"` for `tcell.ColorBlack`
- `"red"` for `tcell.ColorRed`
- `"green"` for `tcell.ColorGreen`
- `"yellow"` for `tcell.ColorYellow`
- `"blue"` for `tcell.ColorBlue`
- `"magenta"` for `tcell.ColorMagenta`
- `"cyan"` for `tcell.ColorCyan`
- `"white"` for `tcell.ColorWhite`
- `"0"`-`"255"` for 256color (automatically supported via tcell)
- `"#RRGGBB"` for 24-bit truecolor (e.g. `"#ff6600"`)
### Background Colors
- `"on_black"` for `termbox.ColorBlack`
- `"on_red"` for `termbox.ColorRed`
- `"on_green"` for `termbox.ColorGreen`
- `"on_yellow"` for `termbox.ColorYellow`
- `"on_blue"` for `termbox.ColorBlue`
- `"on_magenta"` for `termbox.ColorMagenta`
- `"on_cyan"` for `termbox.ColorCyan`
- `"on_white"` for `termbox.ColorWhite`
- `"on_default"` for the terminal's default background color
- `"on_black"` for `tcell.ColorBlack`
- `"on_red"` for `tcell.ColorRed`
- `"on_green"` for `tcell.ColorGreen`
- `"on_yellow"` for `tcell.ColorYellow`
- `"on_blue"` for `tcell.ColorBlue`
- `"on_magenta"` for `tcell.ColorMagenta`
- `"on_cyan"` for `tcell.ColorCyan`
- `"on_white"` for `tcell.ColorWhite`
- `"on_0"`-`"on_255"` for 256color (automatically supported via tcell)
- `"on_#RRGGBB"` for 24-bit truecolor (e.g. `"on_#003366"`)
### Attributes
- `"bold"` for fg: `termbox.AttrBold`
- `"underline"` for fg: `termbox.AttrUnderline`
- `"reverse"` for fg: `termbox.AttrReverse`
- `"on_bold"` for bg: `termbox.AttrBold` (this attribute actually makes the background blink on some platforms/environments, e.g. linux console, xterm...)
- `"bold"` for fg: `tcell.AttrBold`
- `"underline"` for fg: `tcell.AttrUnderline`
- `"reverse"` for fg: `tcell.AttrReverse`
- `"on_bold"` for bg: `tcell.AttrBold` (this attribute actually makes the background blink on some platforms/environments, e.g. linux console, xterm...)
## CustomFilter
This is an experimental feature. Please note that some details of this specification may change
By default `peco` comes with `IgnoreCase`, `CaseSensitive`, `SmartCase`, `IRegexp`, `Regexp` and `Fuzzy` filters, but since v0.1.3, it is possible to create your own custom filter.
By default `peco` comes with `IgnoreCase`, `CaseSensitive`, `SmartCase`, `Regexp` and `Fuzzy` filters, but since v0.1.3, it is possible to create your own custom filter.
The filter will be executed via `Command.Run()` as an external process, and it will be passed the query values in the command line, and the original unaltered buffer is passed via `os.Stdin`. Your filter must perform the matching, and print out to `os.Stdout` matched lines. You filter MAY be called multiple times if the buffer
given to peco is big enough. See `BufferThreshold` below.
The filter will be executed via `Command.Run()` as an external process, and it will be passed the query values in the command line, and the original unaltered buffer is passed via `os.Stdin`. Your filter must perform the matching, and print out to `os.Stdout` matched lines. Your filter MAY be called multiple times if the buffer given to peco is big enough. See `BufferThreshold` below.
Note that currently there is no way for the custom filter to specify where in the line the match occurred, so matched portions in the string WILL NOT BE HIGHLIGHTED.
The filter does not need to be a go program. It can be a perl/ruby/python/bash script, or anything else that is executable.
### Batching Behavior
Unlike the built-in filters (which process batches in parallel), external filters are invoked **sequentially**, one batch at a time. Each invocation receives a subset of the input lines on stdin, not the complete input. `BufferThreshold` controls how many lines are buffered before each invocation.
Because of this batching, your filter **must be stateless** — it cannot assume it sees all input lines in a single invocation. Each invocation is independent. Filters that require global context (e.g., sorting the entire input or counting total lines) will not work correctly, as they only see one batch per invocation.
A larger `BufferThreshold` means fewer invocations but a longer wait before results appear. A smaller threshold means more invocations but faster feedback.
Note that negative query terms (e.g., `-foo`) are NOT parsed by peco for external filters; the raw query string including any `-` prefixes is passed as-is to the external command via `$QUERY`.
### Configuration
Once you have a filter, you must specify how the matcher is spawned:
```json
@ -536,19 +899,18 @@ Once you have a filter, you must specify how the matcher is spawned:
}
```
`Cmd` specifies the command name. This must be searcheable via `exec.LookPath`.
`Cmd` specifies the command name. This must be searchable via `exec.LookPath`.
Elements in the `Args` section are string keys to array of program arguments. The special token `$QUERY` will be replaced with the unaltered query as the user typed in (i.e. multiple-word queries will be passed as a single string). You may pass in any other arguments in this array. If you omit this in your config, a default value of `[]string{"$QUERY"}` will be used
Elements in the `Args` section are string keys to array of program arguments. The special token `$QUERY` will be replaced with the unaltered query as the user typed in (i.e. multiple-word queries will be passed as a single string). You may pass in any other arguments in this array. If you omit this in your config, a default value of `[]string{"$QUERY"}` will be used.
`BufferThreshold` specifies that the filter command should be invoked when peco has this many lines to process
in the buffer. For example, if you are using peco against a 1000-line input, and your `BufferThreshold` is 100 (which is the default), then your filter will be invoked 10 times. For obvious reasons, the larger this threshold is, the faster the overall performance will be, but the longer you will have to wait to see the filter results.
`BufferThreshold` specifies that the filter command should be invoked when peco has this many lines to process in the buffer. For example, if you are using peco against a 1000-line input, and your `BufferThreshold` is 100 (which is the default), then your filter will be invoked 10 times. The larger this threshold is, the faster the overall performance will be, but the longer you will have to wait to see the filter results.
You may specify as many filters as you like in the `CustomFilter` section.
### Examples
* [An example of a simple perl regexp matcher](https://gist.github.com/mattn/24712964da6e3112251c)
* [An example using migemogrep Japanese grep using latin-1 chars](https://github.com/peco/peco/wiki/CustomMatcher)
* [An example using migemogrep Japanese grep using latin-1 chars](https://github.com/peco/peco/wiki/CustomFilter)
## Layout
@ -585,13 +947,16 @@ No. https://github.com/peco/peco/issues/336#issuecomment-243939696
Are you using raster fonts? https://github.com/peco/peco/issues/341
## Seeing escape sequences `[200~` and `[201~` when pasting text?
Disable bracketed paste mode. https://github.com/peco/peco/issues/417
# Hacking
First, fork this repo, and get your clone locally.
1. Make sure you have [go](http://golang.org) installed, with GOPATH appropriately set
2. Make sure you have `make` installed
3. Run `make installdeps` (You only need to do this once, or when you update glide.yml/glide.lock)
To test, run
@ -615,9 +980,7 @@ which will create the binary in the local directory.
# TODO
Test it. In doing so, we may change the repo structure
Implement all(?) of the original percol options
Unit test it.
# AUTHORS
@ -649,74 +1012,10 @@ Implement all(?) of the original percol options
* Yousuke Ushiki
* Linda\_pp
* Tomohiro Nishimura (Sixeight)
* Naruki Tanabe (narugit)
# Notes
Obviously, kudos to the original percol: https://github.com/mooz/percol
Much code stolen from https://github.com/mattn/gof
# Table of Contents
* [peco](#peco)
* [Description](#description)
* [Demo](#demo)
* [Features](#features)
* [Incremental Search](#incremental-search)
* [Select Multiple Lines](#select-multiple-lines)
* [Select Range Of Lines](#select-range-of-lines)
* [Select Filters](#select-filters)
* [Selectable Layout](#selectable-layout)
* [Works on Windows!](#works-on-windows)
* [Installation](#installation)
* [Just want the binary?](#just-want-the-binary)
* [Mac OS X / Homebrew](#mac-os-x--homebrew)
* [Windows (Chocolatey NuGet Users)](#windows-chocolatey-nuget-users)
* [Building peco yourself](#building-peco-yourself)
* [go get IS NOT RECOMMENDED](#go-get-is-not-recommended)
* [Command Line Options](#command-line-options)
* [-h, --help](#-h---help)
* [--version](#--version)
* [--query <query>](#--query-query)
* [--rcfile <filename>](#--rcfile-filename)
* [-b, --buffer-size <num>](#-b---buffer-size-num)
* [--null](#--null)
* [--initial-index](#--initial-index)
* [--initial-filter `IgnoreCase|CaseSensitive|SmartCase|Regexp|Fuzzy`](#--initial-filter-ignorecasecasesensitivesmartcaseregexpfuzzy)
* [--prompt](#--prompt)
* [--layout `top-down|bottom-up`](#--layout-top-downbottom-up)
* [--select-1](#--select-1)
* [--on-cancel `success|error`](#--on-cancel-successerror)
* [--selection-prefix `string`](#--selection-prefix-string)
* [--exec `string`](#--exec-string)
* [Configuration File](#configuration-file)
* [Global](#global)
* [Prompt](#prompt)
* [InitialMatcher](#initialmatcher)
* [InitialFilter](#initialfilter)
* [StickySelection](#stickyselection)
* [OnCancel](#oncancel)
* [Keymaps](#keymaps)
* [Key sequences](#key-sequences)
* [Combined actions](#combined-actions)
* [Available keys](#available-keys)
* [Key workarounds](#key-workarounds)
* [Available actions](#available-actions)
* [Default Keymap](#default-keymap)
* [Styles](#styles)
* [Foreground Colors](#foreground-colors)
* [Background Colors](#background-colors)
* [Attributes](#attributes)
* [CustomFilter](#customfilter)
* [Examples](#examples)
* [Layout](#layout)
* [SingleKeyJump](#singlekeyjump)
* [SelectionPrefix](#selectionprefix)
* [FAQ](#faq)
* [Does peco work on (msys2|cygwin)?](#does-peco-work-on-msys2cygwin)
* [Non-latin fonts (e.g. Japanese) look weird on my Windows machine...?](#non-latin-fonts-eg-japanese-look-weird-on-my-windows-machine)
* [Hacking](#hacking)
* [TODO](#todo)
* [AUTHORS](#authors)
* [CONTRIBUTORS](#contributors)
* [Notes](#notes)
* [Table of Contents](#table-of-contents)

766
action.go

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

369
buffer.go
View file

@ -1,25 +1,74 @@
package peco
import (
"errors"
"fmt"
"sync"
"time"
"context"
"github.com/lestrrat/go-pdebug"
"github.com/lestrrat-go/pdebug"
runewidth "github.com/mattn/go-runewidth"
"github.com/peco/peco/line"
"github.com/peco/peco/pipeline"
"github.com/pkg/errors"
)
// Buffer interface is used for containers for lines to be
// processed by peco. The unexported linesInRange method seals this
// interface to the peco package — external packages cannot implement it.
// This is intentional: linesInRange is an internal optimization for
// efficient pagination in NewFilteredBuffer, not part of the public contract.
type Buffer interface {
linesInRange(int, int) []line.Line
LineAt(int) (line.Line, error)
Size() int
}
// FilteredBuffer holds a "filtered" buffer. It holds a reference to
// the source buffer (note: should be immutable) and a list of indices
// into the source buffer
type FilteredBuffer struct {
maxcols int
src Buffer
selection []int // maps from our index to src's index
}
// MemoryBuffer is an implementation of Buffer
type MemoryBuffer struct {
done chan struct{}
doneOnce sync.Once
lines []line.Line
mutex sync.RWMutex
}
// ContextLine wraps a line.Line to mark it as a context line (non-matched
// surrounding line shown during ZoomIn). Detected via type assertion in
// ListArea.Draw() to apply the Context style.
type ContextLine struct {
line.Line
}
// NewFilteredBuffer creates a FilteredBuffer containing one page of lines from
// the source buffer, computing the maximum column width for horizontal scrolling.
func NewFilteredBuffer(src Buffer, page, perPage int) *FilteredBuffer {
return newFilteredBufferRange(src, perPage*(page-1), perPage)
}
// newFilteredBufferRange creates a FilteredBuffer holding up to count lines
// from the source buffer starting at the given index. This is the shared core
// behind both page-aligned cropping (NewFilteredBuffer) and the sliding-window
// crop used by follow mode (WindowCrop), where start is not page-aligned.
func newFilteredBufferRange(src Buffer, start, count int) *FilteredBuffer {
fb := FilteredBuffer{
src: src,
}
start := perPage * (page - 1)
if start < 0 {
start = 0
}
// if for whatever reason we wanted a page that goes over the
// if for whatever reason we wanted a range that goes over the
// capacity of the original buffer, we don't need to do any more
// calculations. bail out
if start > src.Size() {
@ -27,11 +76,8 @@ func NewFilteredBuffer(src Buffer, page, perPage int) *FilteredBuffer {
}
// Copy over the selections that are applicable to this filtered buffer.
selection := make([]int, 0, src.Size())
end := start + perPage
if end >= src.Size() {
end = src.Size()
}
end := min(start+count, src.Size())
selection := make([]int, 0, end-start)
lines := src.linesInRange(start, end)
var maxcols int
@ -58,8 +104,8 @@ func (flb *FilteredBuffer) MaxColumn() int {
// in this filtered buffer may actually correspond to a totally
// different line number in the source buffer.
func (flb FilteredBuffer) LineAt(i int) (line.Line, error) {
if i >= len(flb.selection) {
return nil, errors.Errorf("specified index %d is out of range", len(flb.selection))
if i < 0 || i >= len(flb.selection) {
return nil, fmt.Errorf("specified index %d is out of range (size=%d)", i, len(flb.selection))
}
return flb.src.LineAt(flb.selection[i])
}
@ -69,22 +115,30 @@ func (flb FilteredBuffer) Size() int {
return len(flb.selection)
}
func NewMemoryBuffer() *MemoryBuffer {
const defaultMemoryBufferCap = 1024
// NewMemoryBuffer creates a new MemoryBuffer. If cap > 0, the lines
// slice is pre-allocated with that capacity; otherwise it defaults to
// defaultMemoryBufferCap.
func NewMemoryBuffer(capacity int) *MemoryBuffer {
if capacity <= 0 {
capacity = defaultMemoryBufferCap
}
mb := &MemoryBuffer{}
mb.Reset()
mb.done = make(chan struct{})
mb.lines = make([]line.Line, 0, capacity)
return mb
}
// Size returns the number of lines currently held in the buffer, thread-safe.
func (mb *MemoryBuffer) Size() int {
mb.mutex.RLock()
defer mb.mutex.RUnlock()
return bufferSize(mb.lines)
}
func bufferSize(lines []line.Line) int {
return len(lines)
return len(mb.lines)
}
// Reset clears the buffer, reinitializing the done channel and lines slice
// so the buffer can be reused for a new pipeline run.
func (mb *MemoryBuffer) Reset() {
mb.mutex.Lock()
defer mb.mutex.Unlock()
@ -92,26 +146,49 @@ func (mb *MemoryBuffer) Reset() {
g := pdebug.Marker("MemoryBuffer.Reset")
defer g.End()
}
// Return pooled Matched objects before discarding the slice
for _, l := range mb.lines {
if m, ok := l.(*line.Matched); ok {
line.ReleaseMatched(m)
}
}
mb.done = make(chan struct{})
mb.doneOnce = sync.Once{}
mb.lines = []line.Line(nil)
}
// MarkComplete signals that the buffer is fully populated. It is safe
// to call multiple times; only the first call closes the done channel.
// Use this instead of manually closing the done channel when populating
// a MemoryBuffer outside of the pipeline (e.g. freeze).
func (mb *MemoryBuffer) MarkComplete() {
mb.doneOnce.Do(func() {
mb.mutex.Lock()
close(mb.done)
mb.mutex.Unlock()
})
}
// Done returns a channel that is closed when the buffer has been fully populated.
func (mb *MemoryBuffer) Done() <-chan struct{} {
mb.mutex.RLock()
defer mb.mutex.RUnlock()
return mb.done
}
func (mb *MemoryBuffer) Accept(ctx context.Context, in chan interface{}, _ pipeline.ChanOutput) {
// Accept receives lines from a pipeline input channel and appends them to the
// buffer in batches. It marks the buffer complete when the channel closes or
// the context is cancelled.
func (mb *MemoryBuffer) Accept(ctx context.Context, in <-chan line.Line, _ pipeline.ChanOutput) {
if pdebug.Enabled {
g := pdebug.Marker("MemoryBuffer.Accept")
defer g.End()
}
defer func() {
mb.mutex.Lock()
close(mb.done)
mb.mutex.Unlock()
}()
defer mb.MarkComplete()
// batch collects lines from the channel so we can append them
// under a single lock acquisition instead of locking per line.
batch := make([]line.Line, 0, 256)
start := time.Now()
for {
@ -121,36 +198,75 @@ func (mb *MemoryBuffer) Accept(ctx context.Context, in chan interface{}, _ pipel
pdebug.Printf("MemoryBuffer received context done")
}
return
case v := <-in:
switch v.(type) {
case error:
if pipeline.IsEndMark(v.(error)) {
if pdebug.Enabled {
pdebug.Printf("MemoryBuffer received end mark (read %d lines, %s since starting accept loop)", len(mb.lines), time.Since(start).String())
}
return
case v, ok := <-in:
if !ok {
if pdebug.Enabled {
pdebug.Printf("MemoryBuffer input channel closed (read %d lines, %s since starting accept loop)", len(mb.lines)+len(batch), time.Since(start).String())
}
case line.Line:
mb.mutex.Lock()
mb.lines = append(mb.lines, v.(line.Line))
mb.mutex.Unlock()
// Flush remaining batch
if len(batch) > 0 {
mb.mutex.Lock()
mb.lines = append(mb.lines, batch...)
mb.mutex.Unlock()
}
return
}
batch = append(batch, v)
// Drain any additional ready values without blocking
drain:
for {
select {
case v2, ok2 := <-in:
if !ok2 {
if pdebug.Enabled {
pdebug.Printf("MemoryBuffer input channel closed (read %d lines, %s since starting accept loop)", len(mb.lines)+len(batch), time.Since(start).String())
}
mb.mutex.Lock()
mb.lines = append(mb.lines, batch...)
mb.mutex.Unlock()
return
}
batch = append(batch, v2)
default:
break drain
}
}
// Flush the batch
mb.mutex.Lock()
mb.lines = append(mb.lines, batch...)
mb.mutex.Unlock()
batch = batch[:0]
}
}
}
// AppendLine adds a line to the buffer. This is used by the benchmark tool
// to populate a MemoryBuffer that will be used as a pipeline source.
func (mb *MemoryBuffer) AppendLine(l line.Line) {
mb.mutex.Lock()
mb.lines = append(mb.lines, l)
mb.mutex.Unlock()
}
// LineAt returns the line at the given index, thread-safe.
func (mb *MemoryBuffer) LineAt(n int) (line.Line, error) {
mb.mutex.RLock()
defer mb.mutex.RUnlock()
return bufferLineAt(mb.lines, n)
}
// linesInRange returns a slice of lines between start and end indices, thread-safe.
func (mb *MemoryBuffer) linesInRange(start, end int) []line.Line {
mb.mutex.RLock()
defer mb.mutex.RUnlock()
return mb.lines[start:end]
}
// bufferLineAt is a shared helper that retrieves a line by index from a raw
// line slice, returning an error if the index is out of bounds.
func bufferLineAt(lines []line.Line, n int) (line.Line, error) {
if s := len(lines); s <= 0 || n >= s {
return nil, errors.New("empty buffer")
@ -158,3 +274,184 @@ func bufferLineAt(lines []line.Line, n int) (line.Line, error) {
return lines[n], nil
}
// ContextBuffer holds an expanded view of a filtered buffer, inserting
// context lines from the source around each match (similar to grep -C).
type ContextBuffer struct {
entries []line.Line
maxcols int
filtered Buffer // original filtered buffer, for reference
matchEntryIndices []int // maps filtered buffer index -> entries index
}
// NewContextBuffer builds a ContextBuffer by expanding the filtered buffer
// with contextSize lines of surrounding context from the source.
func NewContextBuffer(filtered Buffer, source Buffer, contextSize int) *ContextBuffer {
cb := &ContextBuffer{
filtered: filtered,
}
filteredSize := filtered.Size()
if filteredSize == 0 {
return cb
}
sourceSize := source.Size()
// Step 1: Collect source indices of matched lines
type matchInfo struct {
srcIdx int
filteredIdx int
line line.Line
}
matches := make([]matchInfo, 0, filteredSize)
for i := range filteredSize {
l, err := filtered.LineAt(i)
if err != nil {
continue
}
srcIdx := int(l.ID())
matches = append(matches, matchInfo{srcIdx: srcIdx, filteredIdx: i, line: l})
}
if len(matches) == 0 {
return cb
}
// Step 2: Compute context ranges and merge overlapping ones
type contextRange struct {
start int // inclusive
end int // inclusive
}
ranges := make([]contextRange, 0, len(matches))
for _, m := range matches {
start := max(m.srcIdx-contextSize, 0)
end := m.srcIdx + contextSize
if end >= sourceSize {
end = sourceSize - 1
}
ranges = append(ranges, contextRange{start: start, end: end})
}
// Merge overlapping/adjacent ranges
merged := []contextRange{ranges[0]}
for i := 1; i < len(ranges); i++ {
last := &merged[len(merged)-1]
if ranges[i].start <= last.end+1 {
if ranges[i].end > last.end {
last.end = ranges[i].end
}
} else {
merged = append(merged, ranges[i])
}
}
// Step 3: Build a set of matched source indices for quick lookup
matchedSet := make(map[int]int, len(matches)) // srcIdx -> matches slice index
for i, m := range matches {
matchedSet[m.srcIdx] = i
}
// Step 4: Build entries
cb.entries = make([]line.Line, 0, merged[len(merged)-1].end-merged[0].start+1)
cb.matchEntryIndices = make([]int, filteredSize)
// Initialize to -1 so unmapped entries are obvious
for i := range cb.matchEntryIndices {
cb.matchEntryIndices[i] = -1
}
for _, r := range merged {
for idx := r.start; idx <= r.end; idx++ {
if mi, ok := matchedSet[idx]; ok {
// Use the filtered (matched) line, preserving match highlighting
cb.matchEntryIndices[matches[mi].filteredIdx] = len(cb.entries)
cb.entries = append(cb.entries, matches[mi].line)
} else {
// Context line from source
srcLine, err := source.LineAt(idx)
if err != nil {
continue
}
cb.entries = append(cb.entries, &ContextLine{srcLine})
}
}
}
// Step 5: Compute maxcols
for _, l := range cb.entries {
cols := runewidth.StringWidth(l.DisplayString())
if cols > cb.maxcols {
cb.maxcols = cols
}
}
return cb
}
// Size returns the number of lines in the context buffer.
func (cb *ContextBuffer) Size() int {
return len(cb.entries)
}
// LineAt returns the line at index i.
func (cb *ContextBuffer) LineAt(i int) (line.Line, error) {
if i < 0 || i >= len(cb.entries) {
return nil, fmt.Errorf("specified index %d is out of range (size=%d)", i, len(cb.entries))
}
return cb.entries[i], nil
}
// linesInRange returns a slice of entries between start and end indices.
func (cb *ContextBuffer) linesInRange(start, end int) []line.Line {
return cb.entries[start:end]
}
// MaxColumn returns the max column size for horizontal scrolling.
func (cb *ContextBuffer) MaxColumn() int {
return cb.maxcols
}
// MatchEntryIndices returns the mapping from filtered buffer index to
// entries index. Used by ZoomIn to map the cursor position.
func (cb *ContextBuffer) MatchEntryIndices() []int {
return cb.matchEntryIndices
}
// MemoryBufferSource wraps a completed MemoryBuffer as a pipeline.Source,
// allowing previous filter results to be reused as the input for
// incremental filtering.
type MemoryBufferSource struct {
buf *MemoryBuffer
}
// NewMemoryBufferSource creates a new MemoryBufferSource from an existing
// MemoryBuffer. The buffer should be fully populated (pipeline completed).
func NewMemoryBufferSource(buf *MemoryBuffer) *MemoryBufferSource {
return &MemoryBufferSource{buf: buf}
}
// Start iterates through the MemoryBuffer's lines and sends them
// individually to the output channel, implementing pipeline.Source.
// The output channel is closed when all lines have been sent.
func (s *MemoryBufferSource) Start(ctx context.Context, out pipeline.ChanOutput) {
defer close(out)
s.buf.mutex.RLock()
lines := s.buf.lines
s.buf.mutex.RUnlock()
for _, l := range lines {
select {
case <-ctx.Done():
return
default:
}
if err := out.Send(ctx, l); err != nil {
return
}
}
}
// Reset is a no-op for MemoryBufferSource since the underlying buffer
// is immutable (from a completed pipeline run).
func (s *MemoryBufferSource) Reset() {}

View file

@ -1,19 +0,0 @@
package peco
func (c *Caret) Pos() int {
c.mutex.Lock()
defer c.mutex.Unlock()
return c.pos
}
func (c *Caret) SetPos(p int) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.pos = p
}
func (c *Caret) Move(diff int) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.pos = c.pos + diff
}

462
cmd/filterbench/main.go Normal file
View 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)
}

View file

@ -3,11 +3,10 @@ package main
import (
"fmt"
"os"
"runtime"
"context"
pdebug "github.com/lestrrat/go-pdebug"
"github.com/lestrrat-go/pdebug"
"github.com/peco/peco"
"github.com/peco/peco/internal/util"
)
@ -26,9 +25,6 @@ func _main() int {
if pdebug.Enabled {
pdebug.DefaultCtx.Writer = os.Stderr
}
if envvar := os.Getenv("GOMAXPROCS"); envvar == "" {
runtime.GOMAXPROCS(runtime.NumCPU())
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@ -47,7 +43,6 @@ func _main() int {
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
return 1
}
}
return 0

210
config.go
View file

@ -1,210 +0,0 @@
package peco
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/nsf/termbox-go"
"github.com/peco/peco/filter"
"github.com/peco/peco/internal/util"
"github.com/pkg/errors"
)
var homedirFunc = util.Homedir
// NewConfig creates a new Config
func (c *Config) Init() error {
c.Keymap = make(map[string]string)
c.InitialMatcher = IgnoreCaseMatch
c.Style.Init()
c.Prompt = "QUERY>"
c.Layout = LayoutTypeTopDown
return nil
}
// ReadFilename reads the config from the given file, and
// does the appropriate processing, if any
func (c *Config) ReadFilename(filename string) error {
f, err := os.Open(filename)
if err != nil {
return errors.Wrapf(err, "failed to open file %s", filename)
}
defer f.Close()
err = json.NewDecoder(f).Decode(c)
if err != nil {
return errors.Wrap(err, "failed to decode JSON")
}
if !IsValidLayoutType(LayoutType(c.Layout)) {
return errors.Errorf("invalid layout type: %s", c.Layout)
}
if len(c.CustomMatcher) > 0 {
fmt.Fprintf(os.Stderr, "'CustomMatcher' is deprecated. Use CustomFilter instead\n")
for n, cfg := range c.CustomMatcher {
if _, ok := c.CustomFilter[n]; ok {
return errors.Errorf("failed to create CustomFilter: '%s' already exists. Refusing to overwrite with deprecated CustomMatcher config", n)
}
c.CustomFilter[n] = CustomFilterConfig{
Cmd: cfg[0],
Args: cfg[1:],
BufferThreshold: filter.DefaultCustomFilterBufferThreshold,
}
}
}
return nil
}
var (
stringToFg = map[string]termbox.Attribute{
"default": termbox.ColorDefault,
"black": termbox.ColorBlack,
"red": termbox.ColorRed,
"green": termbox.ColorGreen,
"yellow": termbox.ColorYellow,
"blue": termbox.ColorBlue,
"magenta": termbox.ColorMagenta,
"cyan": termbox.ColorCyan,
"white": termbox.ColorWhite,
}
stringToBg = map[string]termbox.Attribute{
"on_default": termbox.ColorDefault,
"on_black": termbox.ColorBlack,
"on_red": termbox.ColorRed,
"on_green": termbox.ColorGreen,
"on_yellow": termbox.ColorYellow,
"on_blue": termbox.ColorBlue,
"on_magenta": termbox.ColorMagenta,
"on_cyan": termbox.ColorCyan,
"on_white": termbox.ColorWhite,
}
stringToFgAttr = map[string]termbox.Attribute{
"bold": termbox.AttrBold,
"underline": termbox.AttrUnderline,
"reverse": termbox.AttrReverse,
}
stringToBgAttr = map[string]termbox.Attribute{
"on_bold": termbox.AttrBold,
}
)
// NewStyleSet creates a new StyleSet struct
func NewStyleSet() *StyleSet {
ss := &StyleSet{}
ss.Init()
return ss
}
func (ss *StyleSet) Init() {
ss.Basic.fg = termbox.ColorDefault
ss.Basic.bg = termbox.ColorDefault
ss.Query.fg = termbox.ColorDefault
ss.Query.bg = termbox.ColorDefault
ss.Matched.fg = termbox.ColorCyan
ss.Matched.bg = termbox.ColorDefault
ss.SavedSelection.fg = termbox.ColorBlack | termbox.AttrBold
ss.SavedSelection.bg = termbox.ColorCyan
ss.Selected.fg = termbox.ColorDefault | termbox.AttrUnderline
ss.Selected.bg = termbox.ColorMagenta
}
// UnmarshalJSON satisfies json.RawMessage.
func (s *Style) UnmarshalJSON(buf []byte) error {
raw := []string{}
if err := json.Unmarshal(buf, &raw); err != nil {
return errors.Wrapf(err, "failed to unmarshal Style")
}
return stringsToStyle(s, raw)
}
func stringsToStyle(style *Style, raw []string) error {
style.fg = termbox.ColorDefault
style.bg = termbox.ColorDefault
for _, s := range raw {
fg, ok := stringToFg[s]
if ok {
style.fg = fg
}
bg, ok := stringToBg[s]
if ok {
style.bg = bg
}
}
for _, s := range raw {
if fgAttr, ok := stringToFgAttr[s]; ok {
style.fg |= fgAttr
}
if bgAttr, ok := stringToBgAttr[s]; ok {
style.bg |= bgAttr
}
}
return nil
}
// This is a variable because we want to change its behavior
// when we run tests.
type configLocateFunc func(string) (string, error)
func locateRcfileIn(dir string) (string, error) {
const basename = "config.json"
file := filepath.Join(dir, basename)
if _, err := os.Stat(file); err != nil {
return "", errors.Wrapf(err, "failed to stat file %s", file)
}
return file, nil
}
// LocateRcfile attempts to find the config file in various locations
func LocateRcfile(locater configLocateFunc) (string, error) {
// http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
//
// Try in this order:
// $XDG_CONFIG_HOME/peco/config.json
// $XDG_CONFIG_DIR/peco/config.json (where XDG_CONFIG_DIR is listed in $XDG_CONFIG_DIRS)
// ~/.peco/config.json
home, uErr := homedirFunc()
// Try dir supplied via env var
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
if file, err := locater(filepath.Join(dir, "peco")); err == nil {
return file, nil
}
} else if uErr == nil { // silently ignore failure for homedir()
// Try "default" XDG location, is user is available
if file, err := locater(filepath.Join(home, ".config", "peco")); err == nil {
return file, nil
}
}
// this standard does not take into consideration windows (duh)
// while the spec says use ":" as the separator, Go provides us
// with filepath.ListSeparator, so use it
if dirs := os.Getenv("XDG_CONFIG_DIRS"); dirs != "" {
for _, dir := range strings.Split(dirs, fmt.Sprintf("%c", filepath.ListSeparator)) {
if file, err := locater(filepath.Join(dir, "peco")); err == nil {
return file, nil
}
}
}
if uErr == nil { // silently ignore failure for homedir()
if file, err := locater(filepath.Join(home, ".peco")); err == nil {
return file, nil
}
}
return "", errors.New("config file not found")
}

241
config/config.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
}

View file

@ -1,166 +0,0 @@
package peco
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"testing"
"github.com/nsf/termbox-go"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
)
func TestReadRC(t *testing.T) {
txt := `
{
"Keymap": {
"C-j": "peco.Finish",
"C-x,C-c": "peco.Finish"
},
"Style": {
"Basic": ["on_default", "default"],
"Selected": ["underline", "on_cyan", "black"],
"Query": ["yellow", "bold"],
"Matched": ["cyan", "bold", "on_red"]
},
"Prompt": "[peco]"
}
`
var cfg Config
if !assert.NoError(t, cfg.Init(), "Config.Init should succeed") {
return
}
if !assert.NoError(t, json.Unmarshal([]byte(txt), &cfg), "Unmarshalling config should succeed") {
return
}
expected := Config{
Keymap: map[string]string{
"C-j": "peco.Finish",
"C-x,C-c": "peco.Finish",
},
InitialMatcher: IgnoreCaseMatch,
Layout: DefaultLayoutType,
Prompt: "[peco]",
Style: StyleSet{
Matched: Style{
fg: termbox.ColorCyan | termbox.AttrBold,
bg: termbox.ColorRed,
},
Query: Style{
fg: termbox.ColorYellow | termbox.AttrBold,
bg: termbox.ColorDefault,
},
Selected: Style{
fg: termbox.ColorBlack | termbox.AttrUnderline,
bg: termbox.ColorCyan,
},
SavedSelection: Style{
fg: termbox.ColorBlack | termbox.AttrBold,
bg: termbox.ColorCyan,
},
},
}
if !assert.Equal(t, expected, cfg, "configuration matches expected") {
return
}
}
type stringsToStyleTest struct {
strings []string
style *Style
}
func TestStringsToStyle(t *testing.T) {
tests := []stringsToStyleTest{
stringsToStyleTest{
strings: []string{"on_default", "default"},
style: &Style{fg: termbox.ColorDefault, bg: termbox.ColorDefault},
},
stringsToStyleTest{
strings: []string{"bold", "on_blue", "yellow"},
style: &Style{fg: termbox.ColorYellow | termbox.AttrBold, bg: termbox.ColorBlue},
},
stringsToStyleTest{
strings: []string{"underline", "on_cyan", "black"},
style: &Style{fg: termbox.ColorBlack | termbox.AttrUnderline, bg: termbox.ColorCyan},
},
stringsToStyleTest{
strings: []string{"reverse", "on_red", "white"},
style: &Style{fg: termbox.ColorWhite | termbox.AttrReverse, bg: termbox.ColorRed},
},
stringsToStyleTest{
strings: []string{"on_bold", "on_magenta", "green"},
style: &Style{fg: termbox.ColorGreen, bg: termbox.ColorMagenta | termbox.AttrBold},
},
}
t.Logf("Checking strings -> color mapping...")
var a Style
for _, test := range tests {
t.Logf(" checking %s...", test.strings)
if !assert.NoError(t, stringsToStyle(&a, test.strings), "stringsToStyle should succeed") {
return
}
if !assert.Equal(t, test.style, &a, "Expected '%s' to be '%#v', but got '%#v'", test.strings, test.style, a) {
return
}
}
}
func TestLocateRcfile(t *testing.T) {
dir, err := ioutil.TempDir("", "peco-")
if !assert.NoError(t, err, "Failed to create temporary directory: %s", err) {
return
}
homedirFunc = func() (string, error) {
return dir, nil
}
expected := []string{
filepath.Join(dir, "peco"),
filepath.Join(dir, "1", "peco"),
filepath.Join(dir, "2", "peco"),
filepath.Join(dir, "3", "peco"),
filepath.Join(dir, ".peco"),
}
i := 0
locater := func(dir string) (string, error) {
t.Logf("looking for file in %s", dir)
if !assert.True(t, i <= len(expected)-1, "Got %d directories, only have %d", i+1, len(expected)) {
return "", errors.New("error: Not found")
}
if !assert.Equal(t, expected[i], dir, "Expected %s, got %s", expected[i], dir) {
return "", errors.New("error: Not found")
}
i++
return "", errors.New("error: Not found")
}
os.Setenv("XDG_CONFIG_HOME", dir)
os.Setenv("XDG_CONFIG_DIRS", strings.Join(
[]string{
filepath.Join(dir, "1"),
filepath.Join(dir, "2"),
filepath.Join(dir, "3"),
},
fmt.Sprintf("%c", filepath.ListSeparator),
))
LocateRcfile(locater)
expected[0] = filepath.Join(dir, ".config", "peco")
os.Setenv("XDG_CONFIG_HOME", "")
i = 0
LocateRcfile(locater)
}

10
contrib/man/generate.sh Executable file
View 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

File diff suppressed because it is too large Load diff

23
event.go Normal file
View file

@ -0,0 +1,23 @@
package peco
import "github.com/peco/peco/internal/keyseq"
// EventType classifies the type of terminal event.
type EventType uint8
const (
// EventKey is a keyboard event
EventKey EventType = iota
// EventResize is a terminal resize event
EventResize
// EventError is an error event
EventError
)
// Event is peco's internal event type, decoupled from any terminal library.
type Event struct {
Type EventType
Key keyseq.KeyType
Ch rune
Mod keyseq.ModifierKey
}

490
filter.go
View file

@ -1,12 +1,13 @@
package peco
import (
"context"
"runtime"
"strings"
"sync"
"time"
"context"
"github.com/lestrrat/go-pdebug"
"github.com/lestrrat-go/pdebug"
"github.com/peco/peco/filter"
"github.com/peco/peco/hub"
"github.com/peco/peco/internal/buffer"
@ -14,27 +15,87 @@ import (
"github.com/peco/peco/pipeline"
)
func newFilterProcessor(f filter.Filter, q string) *filterProcessor {
// FilterErrorHandler handles errors that occur during filter execution.
type FilterErrorHandler interface {
HandleError(error)
}
// FilterErrorHandlerFunc is a function that implements FilterErrorHandler.
type FilterErrorHandlerFunc func(error)
// HandleError calls the underlying function.
func (f FilterErrorHandlerFunc) HandleError(err error) {
f(err)
}
// nopFilterErrorHandler is a FilterErrorHandler that silently discards errors.
var nopFilterErrorHandler = FilterErrorHandlerFunc(func(error) {})
// Filter is responsible for the actual "grep" part of peco
type Filter struct {
state *Peco
prevQuery string
prevResults *MemoryBuffer
prevFilterName string
prevFrozenSrc *MemoryBuffer // frozen source at cache time
prevMu sync.Mutex
}
type filterProcessor struct {
filter filter.Filter
query string
bufSize int
errorHandler FilterErrorHandler
}
// newFilterProcessor creates a filterProcessor that runs the given filter
// against a query, managing result buffering and error reporting.
func newFilterProcessor(f filter.Filter, q string, bufSize int, eh FilterErrorHandler) *filterProcessor {
return &filterProcessor{
filter: f,
query: q,
filter: f,
query: q,
bufSize: bufSize,
errorHandler: eh,
}
}
func (fp *filterProcessor) Accept(ctx context.Context, in chan interface{}, out pipeline.ChanOutput) {
acceptAndFilter(ctx, fp.filter, in, out)
// Accept receives lines from the pipeline, applies the filter, and forwards matches.
func (fp *filterProcessor) Accept(ctx context.Context, in <-chan line.Line, out pipeline.ChanOutput) {
acceptAndFilter(ctx, fp.filter, fp.bufSize, fp.errorHandler, in, out)
}
// This flusher is run in a separate goroutine so that the filter can
// run separately from accepting incoming messages
func flusher(ctx context.Context, f filter.Filter, incoming chan []line.Line, done chan struct{}, out pipeline.ChanOutput) {
// orderedChunk is a batch of lines tagged with a sequence number
// so that parallel filter results can be merged back in order.
type orderedChunk struct {
seq int
lines []line.Line
}
// orderedResult is a filtered chunk tagged with the original sequence number.
type orderedResult struct {
seq int
matched []line.Line
}
// reportFilterError reports a filter error to the given handler.
// If the context is already cancelled or err is nil, it does nothing.
func reportFilterError(ctx context.Context, err error, eh FilterErrorHandler) {
if err == nil || ctx.Err() != nil {
return
}
eh.HandleError(err)
}
// flusher is the single-threaded fallback used when the filter does not
// support parallel execution (e.g. Fuzzy with sortLongest).
func flusher(ctx context.Context, f filter.Filter, incoming chan []line.Line, done chan struct{}, out pipeline.ChanOutput, eh FilterErrorHandler) {
if pdebug.Enabled {
g := pdebug.Marker("flusher goroutine")
defer g.End()
}
defer close(done)
defer out.SendEndMark("end of filter")
defer close(out)
for {
select {
@ -44,26 +105,210 @@ func flusher(ctx context.Context, f filter.Filter, incoming chan []line.Line, do
if !ok {
return
}
pdebug.Printf("flusher: %#v", buf)
f.Apply(ctx, buf, out)
if pdebug.Enabled {
pdebug.Printf("flusher: %#v", buf)
}
if err := f.Apply(ctx, buf, out); err != nil {
reportFilterError(ctx, err, eh)
}
buffer.ReleaseLineListBuf(buf)
}
}
}
func acceptAndFilter(ctx context.Context, f filter.Filter, in chan interface{}, out pipeline.ChanOutput) {
flush := make(chan []line.Line)
flushDone := make(chan struct{})
go flusher(ctx, f, flush, flushDone, out)
// parallelFlusher distributes filter work across multiple goroutines
// and merges the results back in sequence order.
func parallelFlusher(ctx context.Context, f filter.Filter, incoming chan orderedChunk, done chan struct{}, out pipeline.ChanOutput, eh FilterErrorHandler) {
if pdebug.Enabled {
g := pdebug.Marker("parallelFlusher goroutine")
defer g.End()
}
defer close(done)
defer close(out)
numWorkers := max(runtime.GOMAXPROCS(0), 1)
// workCh distributes chunks to workers
workCh := make(chan orderedChunk, numWorkers*2)
// resultCh collects filtered results from workers
resultCh := make(chan orderedResult, numWorkers*2)
// Check once whether the filter supports direct collection (bypasses
// per-chunk channel allocation and goroutine spawn).
collector, canCollect := f.(filter.Collector)
// Start workers
var workerWg sync.WaitGroup
workerWg.Add(numWorkers)
for range numWorkers {
go func() {
defer workerWg.Done()
for chunk := range workCh {
select {
case <-ctx.Done():
buffer.ReleaseLineListBuf(chunk.lines)
continue
default:
}
var matched []line.Line
if canCollect {
// Fast path: collect results directly into a slice
var err error
matched, err = collector.ApplyCollect(ctx, chunk.lines)
if err != nil {
reportFilterError(ctx, err, eh)
}
} else {
// Fallback: use channel-based Apply for filters that
// don't implement Collector (e.g. ExternalCmd)
collectCh := make(chan line.Line, len(chunk.lines))
go func(chunk orderedChunk) {
if err := f.Apply(ctx, chunk.lines, pipeline.ChanOutput(collectCh)); err != nil {
reportFilterError(ctx, err, eh)
}
close(collectCh)
}(chunk)
matched = make([]line.Line, 0, len(chunk.lines)/2)
for l := range collectCh {
matched = append(matched, l)
}
}
buffer.ReleaseLineListBuf(chunk.lines)
select {
case resultCh <- orderedResult{seq: chunk.seq, matched: matched}:
case <-ctx.Done():
}
}
}()
}
// Close resultCh when all workers are done
go func() {
workerWg.Wait()
close(resultCh)
}()
// Merger goroutine: reorder results and send to out in sequence order
mergerDone := make(chan struct{})
go func() {
defer close(mergerDone)
pending := make(map[int]orderedResult)
nextSeq := 0
for result := range resultCh {
pending[result.seq] = result
// Flush as many in-order results as possible
for {
r, ok := pending[nextSeq]
if !ok {
break
}
delete(pending, nextSeq)
nextSeq++
for _, l := range r.matched {
if err := out.Send(ctx, l); err != nil {
return
}
}
}
}
// Flush any remaining (shouldn't happen if producers are well-behaved)
for seq := nextSeq; ; seq++ {
r, ok := pending[seq]
if !ok {
break
}
for _, l := range r.matched {
if err := out.Send(ctx, l); err != nil {
return
}
}
}
}()
// Dispatch incoming chunks to workers
for chunk := range incoming {
select {
case <-ctx.Done():
buffer.ReleaseLineListBuf(chunk.lines)
case workCh <- chunk:
}
}
close(workCh)
// Wait for merger to finish
<-mergerDone
}
// AcceptAndFilter is the exported entry point for the filter pipeline stage.
// It batches incoming lines and dispatches them to the filter, using parallel
// workers when the filter supports it.
func AcceptAndFilter(ctx context.Context, f filter.Filter, configBufSize int, in <-chan line.Line, out pipeline.ChanOutput) {
acceptAndFilter(ctx, f, configBufSize, nopFilterErrorHandler, in, out)
}
// acceptAndFilter is the core filtering loop: it reads lines from in, batches
// them, applies the filter function, and buffers matches for output.
func acceptAndFilter(ctx context.Context, f filter.Filter, configBufSize int, eh FilterErrorHandler, in <-chan line.Line, out pipeline.ChanOutput) {
useParallel := f.SupportsParallel() && runtime.GOMAXPROCS(0) > 1
buf := buffer.GetLineListBuf()
bufsiz := f.BufSize()
if bufsiz <= 0 {
bufsiz = cap(buf)
if configBufSize > 0 {
bufsiz = configBufSize
} else {
bufsiz = cap(buf)
}
}
defer func() { <-flushDone }() // Wait till the flush goroutine is done
defer close(flush) // Kill the flush goroutine
if useParallel {
acceptAndFilterParallel(ctx, f, bufsiz, buf, eh, in, out)
} else {
acceptAndFilterSerial(ctx, f, bufsiz, buf, eh, in, out)
}
}
// acceptAndFilterSerial runs the filter in a single goroutine, used as the
// fallback when the filter does not support parallel execution.
func acceptAndFilterSerial(ctx context.Context, f filter.Filter, bufsiz int, buf []line.Line, eh FilterErrorHandler, in <-chan line.Line, out pipeline.ChanOutput) {
flush := make(chan []line.Line)
flushDone := make(chan struct{})
go flusher(ctx, f, flush, flushDone, out, eh)
defer func() { <-flushDone }()
defer close(flush)
batchAndFlush(ctx, bufsiz, buf, in, flush, func(b []line.Line) []line.Line { return b })
}
// acceptAndFilterParallel distributes filter work across multiple goroutines,
// tagging each batch with a sequence number for ordered result merging.
func acceptAndFilterParallel(ctx context.Context, f filter.Filter, bufsiz int, buf []line.Line, eh FilterErrorHandler, in <-chan line.Line, out pipeline.ChanOutput) {
flush := make(chan orderedChunk)
flushDone := make(chan struct{})
go parallelFlusher(ctx, f, flush, flushDone, out, eh)
defer func() { <-flushDone }()
defer close(flush)
seq := 0
batchAndFlush(ctx, bufsiz, buf, in, flush, func(b []line.Line) orderedChunk {
chunk := orderedChunk{seq: seq, lines: b}
seq++
return chunk
})
}
// batchAndFlush reads lines from in, batches them into slices of up to bufsiz,
// and sends each batch to flushCh via the wrap function. Batches are flushed
// when full or every 50ms, whichever comes first.
func batchAndFlush[T any](ctx context.Context, bufsiz int, buf []line.Line, in <-chan line.Line, flushCh chan T, wrap func([]line.Line) T) {
flushTicker := time.NewTicker(50 * time.Millisecond)
defer flushTicker.Stop()
@ -78,65 +323,105 @@ func acceptAndFilter(ctx context.Context, f filter.Filter, in chan interface{},
return
case <-flushTicker.C:
if len(buf) > 0 {
flush <- buf
flushCh <- wrap(buf)
buf = buffer.GetLineListBuf()
}
case v := <-in:
switch v.(type) {
case error:
if pipeline.IsEndMark(v.(error)) {
if pdebug.Enabled {
pdebug.Printf("filter received end mark (read %d lines, %s since starting accept loop)", lines+len(buf), time.Since(start).String())
}
if len(buf) > 0 {
flush <- buf
buf = nil
}
case v, ok := <-in:
if !ok {
if pdebug.Enabled {
pdebug.Printf("filter input closed (read %d lines, %s since starting accept loop)", lines+len(buf), time.Since(start).String())
}
if len(buf) > 0 {
flushCh <- wrap(buf)
}
return
case line.Line:
if pdebug.Enabled {
pdebug.Printf("incoming line")
lines++
}
// We buffer the lines so that we can receive more lines to
// process while we filter what we already have. The buffer
// size is fairly big, because this really only makes a
// difference if we have a lot of lines to process.
buf = append(buf, v.(line.Line))
if len(buf) >= bufsiz {
flush <- buf
buf = buffer.GetLineListBuf()
}
}
if pdebug.Enabled {
pdebug.Printf("incoming line")
lines++
}
buf = append(buf, v)
if len(buf) >= bufsiz {
flushCh <- wrap(buf)
buf = buffer.GetLineListBuf()
}
}
}
}
// NewFilter creates a new Filter bound to the given Peco state.
func NewFilter(state *Peco) *Filter {
return &Filter{
state: state,
}
}
// Work is the actual work horse that that does the matching
// in a goroutine of its own. It wraps Matcher.Match().
func (f *Filter) Work(ctx context.Context, q hub.Payload) {
defer q.Done()
query, ok := q.Data().(string)
if !ok {
return
// isQueryRefinement returns true if newQuery is a refinement of prevQuery,
// meaning the new query can only produce a subset of the previous results.
// With negative terms, refinement requires:
// 1. Positive portion of prev is a prefix of positive portion of new
// 2. All previous negative terms are still present in new
// 3. New query may have additional positive or negative terms
func isQueryRefinement(prev, cur string) bool {
prev = strings.TrimSpace(prev)
cur = strings.TrimSpace(cur)
if prev == "" || cur == "" {
return false
}
prevPos, prevNeg := filter.SplitQueryTerms(prev)
newPos, newNeg := filter.SplitQueryTerms(cur)
// Positive portion: the joined prev positive terms must be a prefix of the joined new positive terms
prevPosStr := strings.Join(prevPos, " ")
newPosStr := strings.Join(newPos, " ")
if prevPosStr != "" && !strings.HasPrefix(newPosStr, prevPosStr) {
return false
}
// All previous negative terms must still be present in new negative terms
if len(prevNeg) > 0 {
newNegSet := make(map[string]struct{}, len(newNeg))
for _, t := range newNeg {
newNegSet[t] = struct{}{}
}
for _, t := range prevNeg {
if _, ok := newNegSet[t]; !ok {
return false
}
}
}
// At least one positive or negative term must exist in both
if len(prevPos) == 0 && len(prevNeg) == 0 {
return false
}
return true
}
// Work is the actual work horse that does the matching
// in a goroutine of its own. It wraps Matcher.Match().
func (f *Filter) Work(ctx context.Context, q *hub.Payload[string]) {
defer q.Done()
query := q.Data()
if pdebug.Enabled {
g := pdebug.Marker("Filter.Work query '%s'", query)
g := pdebug.Marker("Filter.Work (query=%#v, batch=%#v)", query, q.Batch())
defer g.End()
}
state := f.state
if query == "" {
state.ResetCurrentLineBuffer()
f.prevMu.Lock()
f.prevQuery = ""
f.prevResults = nil
f.prevFilterName = ""
f.prevFrozenSrc = nil
f.prevMu.Unlock()
state.ResetCurrentLineBuffer(ctx)
if !state.config.StickySelection {
state.Selection().Reset()
}
@ -145,45 +430,90 @@ func (f *Filter) Work(ctx context.Context, q hub.Payload) {
// Create a new pipeline
p := pipeline.New()
p.SetSource(state.Source())
// Wraps the actual filter
// Determine the source: use incremental filtering if possible
selectedFilter := state.Filters().Current()
ctx = selectedFilter.NewContext(ctx, query)
p.Add(newFilterProcessor(selectedFilter, query))
filterName := selectedFilter.String()
buf := NewMemoryBuffer()
p.SetDestination(buf)
state.SetCurrentLineBuffer(buf)
go func() {
defer state.Hub().SendDraw(&DrawOptions{RunningQuery: true})
if err := p.Run(ctx); err != nil {
state.Hub().SendStatusMsg(err.Error())
var src pipeline.Source
var srcSize int
f.prevMu.Lock()
if f.prevResults != nil &&
f.prevFilterName == filterName &&
f.prevFrozenSrc == state.Frozen().Source() &&
isQueryRefinement(f.prevQuery, query) {
if pdebug.Enabled {
pdebug.Printf("Using incremental source (prev=%q, new=%q, prevSize=%d)", f.prevQuery, query, f.prevResults.Size())
}
}()
srcSize = f.prevResults.Size()
src = NewMemoryBufferSource(f.prevResults)
}
f.prevMu.Unlock()
if src == nil {
if fs := state.Frozen().Source(); fs != nil {
src = NewMemoryBufferSource(fs)
srcSize = fs.Size()
} else {
src = state.Source()
if sizer, ok := src.(interface{ Size() int }); ok {
srcSize = sizer.Size()
}
}
}
p.SetSource(src)
ctx = selectedFilter.NewContext(ctx, query)
// Report non-cancellation filter errors (e.g. regex compilation failures)
// to the status bar so the user can see why results are missing.
onFilterError := FilterErrorHandlerFunc(func(err error) {
state.Hub().SendStatusMsg(ctx, err.Error(), 5*time.Second)
})
p.Add(newFilterProcessor(selectedFilter, query, state.config.FilterBufSize, onFilterError))
buf := NewMemoryBuffer(srcSize / 4)
p.SetDestination(buf)
state.SetCurrentLineBuffer(ctx, buf)
go func(ctx context.Context) {
defer state.Hub().SendDraw(ctx, &hub.DrawOptions{RunningQuery: true})
if err := p.Run(ctx); err != nil {
state.Hub().SendStatusMsg(ctx, err.Error(), 0)
}
}(ctx)
go func() {
if pdebug.Enabled {
g := pdebug.Marker("Periodic draw request for '%s'", query)
defer g.End()
}
t := time.NewTicker(5 * time.Millisecond)
t := time.NewTicker(50 * time.Millisecond)
defer t.Stop()
defer state.Hub().SendStatusMsg("")
defer state.Hub().SendDraw(&DrawOptions{RunningQuery: true})
defer state.Hub().SendStatusMsg(ctx, "", 0)
defer state.Hub().SendDraw(ctx, &hub.DrawOptions{RunningQuery: true})
for {
select {
case <-p.Done():
return
case <-t.C:
state.Hub().SendDraw(&DrawOptions{RunningQuery: true})
state.Hub().SendDraw(ctx, &hub.DrawOptions{RunningQuery: true})
}
}
}()
<-p.Done()
// Save results for incremental filtering only if pipeline completed
// successfully (context not cancelled)
if ctx.Err() == nil {
f.prevMu.Lock()
f.prevQuery = query
f.prevResults = buf
f.prevFilterName = filterName
f.prevFrozenSrc = state.Frozen().Source()
f.prevMu.Unlock()
}
if !state.config.StickySelection {
state.Selection().Reset()
}
@ -196,6 +526,12 @@ func (f *Filter) Work(ctx context.Context, q hub.Payload) {
func (f *Filter) Loop(ctx context.Context, cancel func()) error {
defer cancel()
// wg tracks in-flight Work goroutines so Loop doesn't return
// while they are still running. This ensures clean shutdown
// without blocking new queries from starting immediately.
var wg sync.WaitGroup
defer wg.Wait()
// previous holds the function that can cancel the previous
// query. This is used when multiple queries come in succession
// and the previous query is discarded anyway
@ -218,9 +554,11 @@ func (f *Filter) Loop(ctx context.Context, cancel func()) error {
previous = workcancel
mutex.Unlock()
f.state.Hub().SendStatusMsg("Running query...")
f.state.Hub().SendStatusMsg(ctx, "Running query...", 0)
go f.Work(workctx, q)
wg.Go(func() {
f.Work(workctx, q)
})
}
}
}

77
filter/base.go Normal file
View 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
View 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
View 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()
}
}

View file

@ -4,19 +4,39 @@ import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os/exec"
"sync"
pdebug "github.com/lestrrat/go-pdebug"
pdebug "github.com/lestrrat-go/pdebug"
"github.com/peco/peco/line"
"github.com/peco/peco/pipeline"
"github.com/pkg/errors"
)
// DefaultCustomFilterBufferThreshold is the default value
// for BufferThreshold setting on CustomFilters.
const DefaultCustomFilterBufferThreshold = 100
// queryPlaceholder is the placeholder string in external command arguments
// that gets replaced with the actual query at filter execution time.
const queryPlaceholder = "$QUERY"
// ExternalCmd is a filter that delegates matching to an external command.
type ExternalCmd struct {
args []string
cmd string
enableSep bool
idgen line.IDGenerator
name string
thresholdBufsiz int
}
// NewExternalCmd creates a new filter that uses an external
// command to filter the input
func NewExternalCmd(name string, cmd string, args []string, threshold int, idgen line.IDGenerator, enableSep bool) *ExternalCmd {
if len(args) == 0 {
args = []string{"$QUERY"}
args = []string{queryPlaceholder}
}
if threshold <= 0 {
@ -29,7 +49,6 @@ func NewExternalCmd(name string, cmd string, args []string, threshold int, idgen
enableSep: enableSep,
idgen: idgen,
name: name,
outCh: pipeline.ChanOutput(make(chan interface{})),
thresholdBufsiz: threshold,
}
}
@ -38,62 +57,91 @@ func (ecf ExternalCmd) BufSize() int {
return ecf.thresholdBufsiz
}
// NewContext returns a context initialized with the given query for pipeline use.
func (ecf *ExternalCmd) NewContext(ctx context.Context, query string) context.Context {
return newContext(ctx, query)
}
func (ecf ExternalCmd) SupportsParallel() bool {
return false
}
func (ecf ExternalCmd) String() string {
return ecf.name
}
// Apply pipes the buffered lines through the external command and sends
// matching output lines to out.
func (ecf *ExternalCmd) Apply(ctx context.Context, buf []line.Line, out pipeline.ChanOutput) (err error) {
var readerPanicErr error
defer func() {
if err := recover(); err != nil {
if pdebug.Enabled {
pdebug.Printf("err: %s", err)
}
if r := recover(); r != nil {
err = fmt.Errorf("panic in external filter %q: %v", ecf.name, r)
}
}() // ignore errors
if err == nil && readerPanicErr != nil {
err = readerPanicErr
}
}()
if pdebug.Enabled {
g := pdebug.Marker("ExternalCmd.Apply").BindError(&err)
defer g.End()
}
query := ctx.Value(queryKey).(string)
query := pipeline.QueryFromContext(ctx)
args := append([]string(nil), ecf.args...)
for i, v := range args {
if v == "$QUERY" {
if v == queryPlaceholder {
args[i] = query
}
}
cmd := exec.Command(ecf.cmd, args...)
cmd := exec.CommandContext(ctx, ecf.cmd, args...)
if pdebug.Enabled {
pdebug.Printf("Executing command %s %v", cmd.Path, cmd.Args)
}
// When enableSep is true (--null mode), we need to preserve the original
// lines so that Output() returns the correct post-separator text.
// Build an index of display string -> original lines for lookup.
var originalLines map[string][]line.Line
if ecf.enableSep {
originalLines = make(map[string][]line.Line)
for _, l := range buf {
ds := l.DisplayString()
originalLines[ds] = append(originalLines[ds], l)
}
}
inbuf := &bytes.Buffer{}
for _, l := range buf {
inbuf.WriteString(l.DisplayString() + "\n")
inbuf.WriteString(l.DisplayString())
inbuf.WriteByte('\n')
}
cmd.Stdin = inbuf
r, err := cmd.StdoutPipe()
if err != nil {
return errors.Wrap(err, `failed to get stdout pipe`)
return fmt.Errorf("failed to get stdout pipe: %w", err)
}
err = cmd.Start()
if err != nil {
return errors.Wrap(err, `failed to start command`)
return fmt.Errorf("failed to start command: %w", err)
}
go cmd.Wait()
var wg sync.WaitGroup
cmdCh := make(chan line.Line)
wg.Add(1)
go func(ctx context.Context, cmdCh chan line.Line, rdr *bufio.Reader) {
defer func() { recover() }()
defer wg.Done()
defer func() {
if r := recover(); r != nil {
readerPanicErr = fmt.Errorf("panic in external filter %q reader: %v", ecf.name, r)
}
}()
defer close(cmdCh)
defer func() { _ = cmd.Wait() }()
for {
select {
case <-ctx.Done():
@ -103,17 +151,33 @@ func (ecf *ExternalCmd) Apply(ctx context.Context, buf []line.Line, out pipeline
b, _, err := rdr.ReadLine()
if len(b) > 0 {
// TODO: need to redo the spec for custom matchers
// This is the ONLY location where we need to actually
// RECREATE a Raw, and thus the only place where
// ctx.enableSep is required.
s := string(b)
var l line.Line
if originalLines != nil {
if candidates, ok := originalLines[s]; ok && len(candidates) > 0 {
// Pop the first matching original line to preserve order
l = candidates[0]
originalLines[s] = candidates[1:]
}
}
if l == nil {
// No original line found (or enableSep is false):
// create a new Raw line as before
l = line.NewRaw(ecf.idgen.Next(), s, ecf.enableSep, false)
}
select {
case cmdCh <- line.NewRaw(ecf.idgen.Next(), string(b), ecf.enableSep):
case cmdCh <- l:
case <-ctx.Done():
return
}
}
if err != nil {
if err != io.EOF {
readerPanicErr = fmt.Errorf("failed to read from external filter %q: %w", ecf.name, err)
}
return
}
}
@ -121,20 +185,22 @@ func (ecf *ExternalCmd) Apply(ctx context.Context, buf []line.Line, out pipeline
defer func() {
if p := cmd.Process; p != nil {
p.Kill()
_ = p.Kill()
}
wg.Wait()
}()
for {
select {
case <-ctx.Done():
return nil
return ctx.Err()
case l, ok := <-cmdCh:
if l == nil || !ok {
return nil
}
out.Send(l)
if err := out.Send(ctx, l); err != nil {
return err
}
}
}
return nil
}

226
filter/external_test.go Normal file
View 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())
})
}

View file

@ -1,11 +1,61 @@
package filter
import "context"
import (
"context"
"regexp"
"github.com/peco/peco/line"
"github.com/peco/peco/pipeline"
)
// Filter is the interface that all filter implementations must satisfy.
type Filter interface {
Apply(context.Context, []line.Line, pipeline.ChanOutput) error
BufSize() int
NewContext(context.Context, string) context.Context
String() string
// SupportsParallel returns true if this filter can safely be invoked
// concurrently on independent chunks of lines. Filters that require
// global state across all lines (e.g. sorted output) should return false.
SupportsParallel() bool
}
// Collector is an optional interface that filters can implement to return
// matched lines directly as a slice, bypassing channel-based output.
// This avoids per-chunk channel allocation and goroutine overhead in the
// parallel filter path.
type Collector interface {
ApplyCollect(context.Context, []line.Line) ([]line.Line, error)
}
// isExcluded reports whether text matches any of the given negative regexps.
func isExcluded(negRegexps []*regexp.Regexp, text string) bool {
for _, rx := range negRegexps {
if rx.MatchString(text) {
return true
}
}
return false
}
// newContext initializes the context so that it is suitable
// to be passed to `Run()`
func newContext(ctx context.Context, query string) context.Context {
return context.WithValue(ctx, queryKey, query)
return pipeline.NewQueryContext(ctx, query)
}
// checkCancelled returns ctx.Err() every 1000 iterations so that
// long-running filter loops can bail out promptly on cancellation
// without paying the cost of a channel receive on every single line.
func checkCancelled(ctx context.Context, i int) error {
if i%1000 == 0 {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
return nil
}
// sort related stuff
@ -30,31 +80,22 @@ func (m byMatchStart) Less(i, j int) bool {
return false
}
// matchContains reports whether match range a fully contains match range b.
func matchContains(a []int, b []int) bool {
return a[0] <= b[0] && a[1] >= b[1]
}
// matchOverlaps reports whether two match ranges overlap.
func matchOverlaps(a []int, b []int) bool {
return a[0] <= b[0] && a[1] >= b[0] ||
a[0] <= b[1] && a[1] >= b[1]
}
// mergeMatches combines two overlapping match ranges into a single range
// spanning both. It mutates and returns a to avoid a heap allocation.
func mergeMatches(a []int, b []int) []int {
ret := make([]int, 2)
// Note: In practice this should never happen
// because we're sorting by N[0] before calling
// this routine, but for completeness' sake...
if a[0] < b[0] {
ret[0] = a[0]
} else {
ret[0] = b[0]
}
if a[1] < b[1] {
ret[1] = b[1]
} else {
ret[1] = a[1]
}
return ret
a[0] = min(a[0], b[0])
a[1] = max(a[1], b[1])
return a
}

View file

@ -3,23 +3,71 @@ package filter
import (
"context"
"fmt"
"regexp"
"testing"
"time"
"github.com/peco/peco/line"
"github.com/peco/peco/pipeline"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type indexer interface {
Indices() [][]int
}
// TestFuzzy tests a fuzzy filter against various inputs
func TestFuzzy(t *testing.T) {
octx, ocancel := context.WithCancel(context.Background())
defer ocancel()
func TestCheckCancelled(t *testing.T) {
t.Run("returns nil for non-boundary iterations", func(t *testing.T) {
ctx := context.Background()
for _, i := range []int{1, 2, 999, 1001, 2999} {
require.NoError(t, checkCancelled(ctx, i))
}
})
t.Run("returns nil at boundary when context is active", func(t *testing.T) {
ctx := context.Background()
for _, i := range []int{0, 1000, 2000, 3000} {
require.NoError(t, checkCancelled(ctx, i))
}
})
t.Run("returns error at boundary when context is cancelled", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
require.ErrorIs(t, checkCancelled(ctx, 0), context.Canceled)
require.ErrorIs(t, checkCancelled(ctx, 1000), context.Canceled)
})
t.Run("returns nil off-boundary even when context is cancelled", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
require.NoError(t, checkCancelled(ctx, 1))
require.NoError(t, checkCancelled(ctx, 999))
})
}
// TestFuzzy tests the Fuzzy filter against various inputs.
//
// testFuzzy: simple substring match
// testFuzzyLongest: sorted longest substring match
// testFuzzyMatch: match position without sorting
// testFuzzyLongestMatch: match position with sorting
func TestFuzzy(t *testing.T) {
octx := t.Context()
testFuzzy(octx, t, NewFuzzy(false))
testFuzzyLongest(octx, t, NewFuzzy(true))
testFuzzyMatch(octx, t, NewFuzzy(false))
}
// testFuzzy tests if given filter matches/rejects the query.
// This test checks the following functionalities:
// - Fuzzy substring match
// - Case-insensitive match
// - Multi-byte rune match
func testFuzzy(octx context.Context, t *testing.T, filter Filter) {
testValues := []struct {
input string
query string
@ -38,35 +86,621 @@ func TestFuzzy(t *testing.T) {
{"🚴🏻 abcd efgh", "🚴🏻e", true}, // unicode
{"This is a test to Test the fuzzy filteR", "TTR", true},
}
filter := NewFuzzy()
for i, v := range testValues {
t.Run(fmt.Sprintf(`"%s" against "%s", expect "%t"`, v.input, v.query, v.selected), func(t *testing.T) {
ctx, cancel := context.WithTimeout(filter.NewContext(octx, v.query), 10*time.Second)
defer cancel()
ch := make(chan interface{}, 1)
l := line.NewRaw(uint64(i), v.input, false)
ch := make(chan line.Line, 1)
l := line.NewRaw(uint64(i), v.input, false, false)
err := filter.Apply(ctx, []line.Line{l}, pipeline.ChanOutput(ch))
if !assert.NoError(t, err, `filter.Apply should succeed`) {
return
}
require.NoError(t, err, `filter.Apply should succeed`)
select {
case l, ok := <-ch:
if !assert.True(t, ok, `channel read should succeed`) {
return
}
if !assert.Implements(t, (*line.Line)(nil), l, "result is a line") {
return
}
require.True(t, ok, `channel read should succeed`)
t.Logf("%#v", l.(indexer).Indices())
case <-ctx.Done():
if !assert.False(t, v.selected, "did NOT expect to timeout") { // shouldn't happen if we're expecting a result
return
require.False(t, v.selected, "did NOT expect to timeout")
}
})
}
}
// testFuzzyLongest tests if given filter matches/rejects the query.
// This test check the following functionalities:
// - Longest substring match
// - Ordering the match result by precedence (substring length > match index > string length)
func testFuzzyLongest(octx context.Context, t *testing.T, filter Filter) {
testValues := []struct {
name string
query string
input []string
expect []string
}{
{
name: "The longer the matched string, the higher it ranks",
query: "abcd",
input: []string{
"abc-d",
"ab-cd",
"abcd",
"a-bcd",
},
expect: []string{
"abcd",
"abc-d",
"a-bcd",
"ab-cd",
},
},
{
name: "The earlier it matches, the higher it ranks",
query: "abcd",
input: []string{
"___abcd",
"_abcd",
"abcd",
"__abcd",
},
expect: []string{
"abcd",
"_abcd",
"__abcd",
"___abcd",
},
},
{
name: "The shorter the original string, the higher it ranks",
query: "abcd",
input: []string{
"abcdef",
"abcdefg",
"abcd",
"abcde",
},
expect: []string{
"abcd",
"abcde",
"abcdef",
"abcdefg",
},
},
{
name: "Mixed precedence",
query: "abcd",
input: []string{
"abc-d",
"ab-cd",
"abcd",
"ab_abcd",
"a-bcd",
"___abcd",
"_abcd",
"abcd",
"__abcd",
"abcdef",
"abcdefg",
"abcd",
"abcde",
},
expect: []string{
"abcd",
"abcd",
"abcd",
"abcde",
"abcdef",
"abcdefg",
"_abcd",
"__abcd",
"ab_abcd", // ab_abcd shall be above ___abcd because matched lines are stable-sorted
"___abcd",
"abc-d",
"a-bcd",
"ab-cd",
},
},
}
for i, v := range testValues {
t.Run(v.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(filter.NewContext(octx, v.query), 10*time.Second)
defer cancel()
var lines []line.Line
for _, raw := range v.input {
lines = append(lines, line.NewRaw(uint64(i), raw, false, false))
}
var actual []string
lc := make(chan line.Line)
ec := make(chan error)
go func() {
ec <- filter.Apply(ctx, lines, pipeline.ChanOutput(lc))
}()
OUTER:
for {
select {
case l := <-lc:
actual = append(actual, l.DisplayString())
case err := <-ec:
require.NoError(t, err, `filter.Apply should succeed`)
break OUTER
case <-ctx.Done():
require.Fail(t, "unexpected timeout")
}
}
require.Equal(t, v.expect, actual, "result is ordered in expected order")
})
}
}
// testFuzzyMatch tests if non-sorted & sorted Fuzzy filter returns the expected result
func TestSplitQueryTerms(t *testing.T) {
tests := []struct {
name string
query string
wantPos []string
wantNeg []string
}{
{
name: "simple positive",
query: "foo bar",
wantPos: []string{"foo", "bar"},
},
{
name: "single negative",
query: "-foo",
wantNeg: []string{"foo"},
},
{
name: "mixed positive and negative",
query: "foo -bar baz",
wantPos: []string{"foo", "baz"},
wantNeg: []string{"bar"},
},
{
name: "all negative",
query: "-foo -bar",
wantNeg: []string{"foo", "bar"},
},
{
name: "escaped negative becomes positive",
query: `\-foo`,
wantPos: []string{"-foo"},
},
{
name: "bare hyphen is positive literal",
query: "-",
wantPos: []string{"-"},
},
{
name: "double hyphen is positive literal",
query: "--",
wantPos: []string{"--"},
},
{
name: "mixed with escaping",
query: `foo -bar \-baz`,
wantPos: []string{"foo", "-baz"},
wantNeg: []string{"bar"},
},
{
name: "extra spaces are skipped",
query: " foo -bar ",
wantPos: []string{"foo"},
wantNeg: []string{"bar"},
},
{
name: "empty query",
query: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotPos, gotNeg := SplitQueryTerms(tt.query)
require.Equal(t, tt.wantPos, gotPos, "positive terms")
require.Equal(t, tt.wantNeg, gotNeg, "negative terms")
})
}
}
// collectFilterResults runs the filter and collects all emitted lines.
func collectFilterResults(t *testing.T, f Filter, query string, inputLines []line.Line) []line.Line {
t.Helper()
ctx, cancel := context.WithTimeout(f.NewContext(context.Background(), query), 10*time.Second)
defer cancel()
ch := make(chan line.Line, len(inputLines)+1)
err := f.Apply(ctx, inputLines, pipeline.ChanOutput(ch))
require.NoError(t, err, "filter.Apply should succeed")
close(ch)
var results []line.Line
for l := range ch {
results = append(results, l)
}
return results
}
func makeLines(inputs ...string) []line.Line {
lines := make([]line.Line, len(inputs))
for i, s := range inputs {
lines[i] = line.NewRaw(uint64(i), s, false, false)
}
return lines
}
func makeANSILines(inputs ...string) []line.Line {
lines := make([]line.Line, len(inputs))
for i, s := range inputs {
lines[i] = line.NewRaw(uint64(i), s, false, true)
}
return lines
}
func TestNegativeMatchingRegexp(t *testing.T) {
filters := map[string]Filter{
"IgnoreCase": NewIgnoreCase(),
"CaseSensitive": NewCaseSensitive(),
"SmartCase": NewSmartCase(),
"Regexp": NewRegexp(),
}
for name, f := range filters {
t.Run(name, func(t *testing.T) {
lines := makeLines(
"hello world",
"hello tests",
"goodbye world",
"goodbye tests",
)
t.Run("positive with negative exclusion", func(t *testing.T) {
results := collectFilterResults(t, f, "hello -tests", lines)
require.Len(t, results, 1)
require.Equal(t, "hello world", results[0].DisplayString())
})
t.Run("multiple negative terms", func(t *testing.T) {
results := collectFilterResults(t, f, "hello -world -tests", lines)
require.Len(t, results, 0)
})
})
}
}
func TestAllNegativeQuery(t *testing.T) {
filters := map[string]Filter{
"IgnoreCase": NewIgnoreCase(),
"Regexp": NewRegexp(),
"Fuzzy": NewFuzzy(false),
}
lines := makeLines(
"alpha",
"beta",
"gamma",
)
for name, f := range filters {
t.Run(name, func(t *testing.T) {
results := collectFilterResults(t, f, "-beta", lines)
require.Len(t, results, 2)
names := make([]string, 0, len(results))
for _, r := range results {
names = append(names, r.DisplayString())
}
require.Contains(t, names, "alpha")
require.Contains(t, names, "gamma")
})
}
}
func TestNegativeNoHighlight(t *testing.T) {
f := NewIgnoreCase()
lines := makeLines("alpha", "beta", "gamma")
results := collectFilterResults(t, f, "-beta", lines)
require.Len(t, results, 2)
for _, r := range results {
idx, ok := r.(indexer)
require.True(t, ok, "result should implement indexer")
require.Nil(t, idx.Indices(), "all-negative query should produce nil indices")
}
}
func TestNegativeMatchingFuzzy(t *testing.T) {
f := NewFuzzy(false)
lines := makeLines(
"hello world",
"hello tests",
"goodbye world",
"goodbye tests",
)
t.Run("fuzzy positive with negative exclusion", func(t *testing.T) {
// Fuzzy query "hlo" should match "hello" lines; -tests excludes one
results := collectFilterResults(t, f, "hlo -tests", lines)
require.Len(t, results, 1)
require.Equal(t, "hello world", results[0].DisplayString())
})
t.Run("fuzzy all-negative", func(t *testing.T) {
results := collectFilterResults(t, f, "-world", lines)
require.Len(t, results, 2)
names := make([]string, 0, len(results))
for _, r := range results {
names = append(names, r.DisplayString())
}
require.Contains(t, names, "hello tests")
require.Contains(t, names, "goodbye tests")
})
}
func TestLiteralHyphenMatching(t *testing.T) {
lines := makeLines(
"hello-world",
"hello world",
"-foo bar",
"foo bar",
"--verbose flag",
"verbose flag",
)
f := NewIgnoreCase()
t.Run("escaped negative matches literal hyphen-prefixed term", func(t *testing.T) {
// \-foo should match lines containing literal "-foo"
results := collectFilterResults(t, f, `\-foo`, lines)
require.Len(t, results, 1)
require.Equal(t, "-foo bar", results[0].DisplayString())
})
t.Run("bare hyphen matches lines containing hyphen", func(t *testing.T) {
// bare "-" should be a positive literal matching any line with a hyphen
results := collectFilterResults(t, f, "-", lines)
require.Len(t, results, 3)
names := make([]string, 0, len(results))
for _, r := range results {
names = append(names, r.DisplayString())
}
require.Contains(t, names, "hello-world")
require.Contains(t, names, "-foo bar")
require.Contains(t, names, "--verbose flag")
})
t.Run("double hyphen matches lines containing double hyphen", func(t *testing.T) {
results := collectFilterResults(t, f, "--", lines)
require.Len(t, results, 1)
require.Equal(t, "--verbose flag", results[0].DisplayString())
})
t.Run("escaped negative with positive term", func(t *testing.T) {
// Search for lines containing both "bar" and literal "-foo"
results := collectFilterResults(t, f, `bar \-foo`, lines)
require.Len(t, results, 1)
require.Equal(t, "-foo bar", results[0].DisplayString())
})
}
func TestIsExcluded(t *testing.T) {
rxFoo := regexp.MustCompile(`(?i)foo`)
rxBar := regexp.MustCompile(`(?i)bar`)
tests := []struct {
name string
negRegexps []*regexp.Regexp
text string
want bool
}{
{
name: "nil regexps never excludes",
negRegexps: nil,
text: "anything",
want: false,
},
{
name: "empty regexps never excludes",
negRegexps: []*regexp.Regexp{},
text: "anything",
want: false,
},
{
name: "single match excludes",
negRegexps: []*regexp.Regexp{rxFoo},
text: "contains foo here",
want: true,
},
{
name: "no match does not exclude",
negRegexps: []*regexp.Regexp{rxFoo},
text: "contains baz here",
want: false,
},
{
name: "second regexp matches",
negRegexps: []*regexp.Regexp{rxFoo, rxBar},
text: "has bar in it",
want: true,
},
{
name: "neither regexp matches",
negRegexps: []*regexp.Regexp{rxFoo, rxBar},
text: "has baz in it",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, isExcluded(tt.negRegexps, tt.text))
})
}
}
// testFuzzyMatch tests if non-sorted & sorted Fuzzy filter returns the expected result
func testFuzzyMatch(octx context.Context, t *testing.T, filter Filter) {
testValues := []struct {
name string
sort bool
query string
input string
expect [][]int
}{
{
name: "Fuzzy: exact match",
sort: false,
query: "asdf",
input: "___asdf",
// ^^^^
expect: [][]int{
{3, 4},
{4, 5},
{5, 6},
{6, 7},
},
},
{
name: "Fuzzy: scattered match",
sort: false,
query: "asdf",
input: "as_asdf",
// ^^ ^^
expect: [][]int{
{0, 1},
{1, 2},
{5, 6},
{6, 7},
},
},
{
name: "FuzzyLongest: exact match",
sort: true,
query: "asdf",
input: "___asdf",
// ^^^^
expect: [][]int{
{3, 4},
{4, 5},
{5, 6},
{6, 7},
},
},
{
name: "FuzzyLongest: scattered match",
sort: true,
query: "asdf",
input: "as_asdf",
// ^^^^
expect: [][]int{
{3, 4},
{4, 5},
{5, 6},
{6, 7},
},
},
}
for i, v := range testValues {
t.Run(v.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(filter.NewContext(octx, v.query), 10*time.Second)
defer cancel()
filter := NewFuzzy(v.sort)
lc := make(chan line.Line)
ec := make(chan error)
go func() {
ec <- filter.Apply(ctx, []line.Line{line.NewRaw(uint64(i), v.input, false, false)}, pipeline.ChanOutput(lc))
}()
OUTER:
for {
select {
case l := <-lc:
require.Implements(t, (*indexer)(nil), l, "result is an indexer")
require.Equal(t, v.expect, l.(indexer).Indices(), "result has expected indices")
case err := <-ec:
require.NoError(t, err, `filter.Apply should succeed`)
break OUTER
case <-ctx.Done():
require.Fail(t, "unexpected timeout")
}
}
})
}
}
func TestMergeMatches(t *testing.T) {
t.Parallel()
tests := []struct {
name string
a []int
b []int
want []int
}{
{"a before b overlapping", []int{1, 5}, []int{3, 7}, []int{1, 7}},
{"b before a overlapping", []int{3, 7}, []int{1, 5}, []int{1, 7}},
{"identical ranges", []int{2, 4}, []int{2, 4}, []int{2, 4}},
{"a contains b", []int{0, 10}, []int{3, 7}, []int{0, 10}},
{"adjacent ranges", []int{0, 3}, []int{3, 6}, []int{0, 6}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := mergeMatches(tt.a, tt.b)
require.Equal(t, tt.want, got)
// Verify in-place mutation: got should be the same slice as a
require.Same(t, &tt.a[0], &got[0], "mergeMatches should mutate a in place")
})
}
}
// TestMatchAcrossANSIColorBoundary verifies that filter queries match
// against the ANSI-stripped text, so a pattern spanning characters
// rendered in different colors still produces a match.
func TestMatchAcrossANSIColorBoundary(t *testing.T) {
// "\033[31mfoo\033[34mbar\033[0m" renders as red "foo" + blue "bar",
// but the stripped text is "foobar". A query "oba" spans the boundary.
lines := makeANSILines(
"\x1b[31mfoo\x1b[34mbar\x1b[0m", // red "foo" + blue "bar"
"\x1b[32mhello\x1b[0m world", // green "hello" + plain " world"
"plain text no ansi", // no ANSI at all
"\x1b[1m\x1b[33mBRI\x1b[35mGHT\x1b[0m", // bold yellow "BRI" + bold magenta "GHT"
)
filters := map[string]Filter{
"IgnoreCase": NewIgnoreCase(),
"CaseSensitive": NewCaseSensitive(),
"Regexp": NewRegexp(),
"Fuzzy": NewFuzzy(false),
}
for name, f := range filters {
t.Run(name, func(t *testing.T) {
t.Run("match spans two color regions", func(t *testing.T) {
// "oba" spans red "foo" and blue "bar"
results := collectFilterResults(t, f, "oba", lines)
require.Len(t, results, 1)
require.Equal(t, "foobar", results[0].DisplayString())
})
t.Run("match spans color and plain regions", func(t *testing.T) {
// "lo w" spans green "hello" and plain " world"
results := collectFilterResults(t, f, "lo w", lines)
require.Len(t, results, 1)
require.Equal(t, "hello world", results[0].DisplayString())
})
t.Run("match spans bold color change", func(t *testing.T) {
// "RIGH" spans bold-yellow "BRI" and bold-magenta "GHT"
results := collectFilterResults(t, f, "RIGH", lines)
require.Len(t, results, 1)
require.Equal(t, "BRIGHT", results[0].DisplayString())
})
})
}
}

View file

@ -2,6 +2,10 @@ package filter
import (
"context"
"fmt"
"math"
"regexp"
"sort"
"strings"
"unicode/utf8"
@ -10,60 +14,252 @@ import (
"github.com/peco/peco/pipeline"
)
// Fuzzy is a filter that performs fuzzy matching.
type Fuzzy struct {
baseFilter
sortLongest bool
}
// NewFuzzy builds a fuzzy-finder type of filter.
// In effect, this uses a smart case filter, and for q query
// like "ABC" it matches the equivalent of "A(.*)B(.*)C(.*)"
func NewFuzzy() *Fuzzy {
return &Fuzzy{}
// like "ABC" it matches the equivalent of "A(.*)B(.*)C(.*)".
//
// With sortLongest = true, Fuzzy filter outputs the result
// sorted in the following precedence:
// 1. Longer match
// 2. Earlier match
// 3. Shorter line length
func NewFuzzy(sortLongest bool) *Fuzzy {
ff := &Fuzzy{
sortLongest: sortLongest,
}
ff.impl = ff
return ff
}
func (ff Fuzzy) BufSize() int {
return 0
}
func (ff *Fuzzy) NewContext(ctx context.Context, query string) context.Context {
return newContext(ctx, query)
func (ff Fuzzy) SupportsParallel() bool {
return !ff.sortLongest
}
func (ff Fuzzy) String() string {
return "Fuzzy"
}
func (ff *Fuzzy) Apply(ctx context.Context, lines []line.Line, out pipeline.ChanOutput) error {
originalQuery := ctx.Value(queryKey).(string)
hasUpper := util.ContainsUpper(originalQuery)
// applyInternal performs fuzzy matching on each line, emitting matches with
// their character-level match indices for highlighting.
func (ff *Fuzzy) applyInternal(ctx context.Context, lines []line.Line, em LineEmitter) error {
originalQuery := pipeline.QueryFromContext(ctx)
OUTER:
for _, l := range lines {
base := 0
matches := [][]int{}
txt := l.DisplayString()
query := originalQuery
for len(query) > 0 {
r, n := utf8.DecodeRuneInString(query)
query = query[n:]
if r == utf8.RuneError {
// "Silently" ignore
continue OUTER
}
var i int
if hasUpper { // explicit match
i = strings.IndexRune(txt, r)
} else {
i = strings.IndexFunc(txt, util.CaseInsensitiveIndexFunc(r))
}
if i == -1 {
continue OUTER
}
// otherwise we have a match, but the next match must match against
// something AFTER the current match
txt = txt[i+n:]
matches = append(matches, []int{base + i, base + i + n})
base = base + i + n
// Parse negative terms and compile them as case-insensitive regexps
posTerms, negTerms := SplitQueryTerms(originalQuery)
var negRegexps []*regexp.Regexp
for _, t := range negTerms {
re, err := regexpFor(t, []string{"i"}, true)
if err != nil {
return fmt.Errorf("failed to compile negative term regexp '%s': %w", t, err)
}
out.Send(line.NewMatched(l, matches))
negRegexps = append(negRegexps, re)
}
// Reconstruct the fuzzy query from positive terms joined together
fuzzyQuery := strings.Join(posTerms, "")
hasUpper := util.ContainsUpper(fuzzyQuery)
var matched []fuzzyMatchedItem
LINE:
for i, l := range lines {
if err := checkCancelled(ctx, i); err != nil {
return err
}
txt := l.DisplayString()
// Check negative terms first — skip if any match
if isExcluded(negRegexps, txt) {
continue LINE
}
// All-negative query: emit all non-excluded lines with nil indices
if len(fuzzyQuery) == 0 {
em.Emit(ctx, line.GetMatched(l, nil))
continue LINE
}
// Find the first valid rune of the query
firstRune := utf8.RuneError
for _, r := range fuzzyQuery {
if r != utf8.RuneError {
firstRune = r
break
}
}
if firstRune == utf8.RuneError {
return fmt.Errorf("the query has no valid character")
}
// Find all byte offsets where the first query rune appears,
// reusing txt from line 77 instead of re-fetching DisplayString().
var firstRuneOffsets []int
remaining := txt
base := 0
for len(remaining) > 0 {
var idx int
if hasUpper {
idx = strings.IndexRune(remaining, firstRune)
} else {
idx = util.CaseInsensitiveIndex(remaining, firstRune)
}
if idx == -1 {
break
}
firstRuneOffsets = append(firstRuneOffsets, base+idx)
if !ff.sortLongest {
break
}
_, n := utf8.DecodeRuneInString(remaining[idx:])
remaining = remaining[idx+n:]
base += idx + n
}
if len(firstRuneOffsets) == 0 {
continue LINE
}
// Find all candidate matches
var candidates []fuzzyMatchedItem
// Pre-allocate backing array for match pairs to avoid per-match
// heap allocations. Each match is a [start, end] pair, so we need
// 2 ints per query rune (upper bound).
queryRuneCount := utf8.RuneCountInString(fuzzyQuery)
matchBacking := make([]int, 0, queryRuneCount*2)
OUTER:
for _, offset := range firstRuneOffsets {
q := fuzzyQuery
candidateTxt := txt[offset:]
candidateBase := offset
matchBacking = matchBacking[:0]
matches := make([][]int, 0, queryRuneCount)
for len(q) > 0 {
var r rune
var n int
q, r, n = popRune(q)
if r == utf8.RuneError {
continue OUTER
}
var idx int
if hasUpper {
idx = strings.IndexRune(candidateTxt, r)
} else {
idx = util.CaseInsensitiveIndex(candidateTxt, r)
}
if idx == -1 {
continue OUTER
}
candidateTxt = candidateTxt[idx+n:]
start := candidateBase + idx
matchBacking = append(matchBacking, start, start+n)
matches = append(matches, matchBacking[len(matchBacking)-2:len(matchBacking):len(matchBacking)])
candidateBase = start + n
}
candidates = append(candidates, newFuzzyMatchedItem(l, matches))
}
if len(candidates) == 0 {
continue
}
if ff.sortLongest {
// Sort the candidate matches of a line and pick the best one
sort.SliceStable(candidates, less(candidates))
}
matched = append(matched, candidates[0])
}
if ff.sortLongest {
// Sort all matched lines
sort.SliceStable(matched, less(matched))
}
for i := range matched {
em.Emit(ctx, line.GetMatched(matched[i].line, matched[i].matches))
}
return nil
}
// popRune decodes and removes the first rune from s, returning the remainder,
// the decoded rune, and its byte width.
func popRune(s string) (string, rune, int) {
r, n := utf8.DecodeRuneInString(s)
return s[n:], r, n
}
// less returns a comparison function that orders fuzzy matches by longest
// contiguous match, earliest position, then shortest line length.
func less(s []fuzzyMatchedItem) func(i, j int) bool {
return func(i, j int) bool {
if s[i].longest != s[j].longest {
// Longer match is better
return s[i].longest > s[j].longest
}
if s[i].earliest != s[j].earliest {
// Earlier match is better
return s[i].earliest < s[j].earliest
}
// Shorter line is better
return s[i].Len() < s[j].Len()
}
}
type fuzzyMatchedItem struct {
line line.Line
matches [][]int
longest int
earliest int
}
// newFuzzyMatchedItem creates a fuzzyMatchedItem, computing the longest
// contiguous match length and earliest match position from the given indices.
func newFuzzyMatchedItem(line line.Line, matches [][]int) fuzzyMatchedItem {
longest := 0
count := 0
lastEnd := 0
earliest := math.MaxInt
for i := range matches {
length := matches[i][1] - matches[i][0]
if matches[i][0] == lastEnd {
count += length
} else {
count = length
}
if count > longest {
longest = count
}
lastEnd = matches[i][1]
if matches[i][0] < earliest {
earliest = matches[i][0]
}
}
return fuzzyMatchedItem{
line: line,
matches: matches,
longest: longest,
earliest: earliest,
}
}
func (f fuzzyMatchedItem) Len() int {
return len(f.line.DisplayString())
}

View file

@ -1,78 +0,0 @@
package filter
import (
"context"
"errors"
"regexp"
"sync"
"time"
"github.com/peco/peco/line"
"github.com/peco/peco/pipeline"
)
var ErrFilterNotFound = errors.New("specified filter was not found")
var ignoreCaseFlags = regexpFlagList([]string{"i"})
var defaultFlags = regexpFlagList{}
var queryKey = &struct{}{}
var incomingBufferKey = &struct{}{}
// DefaultCustomFilterBufferThreshold is the default value
// for BufferThreshold setting on CustomFilters.
const DefaultCustomFilterBufferThreshold = 100
type Set struct {
current int
filters []Filter
mutex sync.Mutex
}
// internal stuff
type regexpFlags interface {
flags(string) []string
}
type regexpFlagList []string
type regexpFlagFunc func(string) []string
type regexpQueryFactory struct {
compiled map[string]regexpQuery
mutex sync.Mutex
threshold time.Duration
}
type regexpQuery struct {
rx []*regexp.Regexp
lastUsed time.Time
}
type Fuzzy struct {
}
type Regexp struct {
factory *regexpQueryFactory
flags regexpFlags
quotemeta bool
mutex sync.Mutex
name string
onEnd func()
outCh pipeline.ChanOutput
}
type ExternalCmd struct {
args []string
cmd string
enableSep bool
idgen line.IDGenerator
outCh pipeline.ChanOutput
name string
thresholdBufsiz int
}
type Filter interface {
Apply(context.Context, []line.Line, pipeline.ChanOutput) error
BufSize() int
NewContext(context.Context, string) context.Context
String() string
}

121
filter/parallel_test.go Normal file
View 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)
}

View file

@ -6,14 +6,47 @@ import (
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/peco/peco/internal/util"
"github.com/peco/peco/line"
"github.com/peco/peco/pipeline"
"github.com/pkg/errors"
)
// internal stuff
type regexpFlags interface {
flags(string) []string
}
type regexpFlagList []string
type regexpFlagFunc func(string) []string
type regexpQueryFactory struct {
compiled map[string]regexpQuery
mutex sync.Mutex
threshold time.Duration
}
type regexpQuery struct {
positive []*regexp.Regexp
negative []*regexp.Regexp
lastUsed time.Time
}
var ignoreCaseFlags = regexpFlagList([]string{"i"})
var defaultFlags = regexpFlagList{}
// Regexp is a filter that matches lines using regular expressions.
type Regexp struct {
baseFilter
factory *regexpQueryFactory
flags regexpFlags
quotemeta bool
name string
}
func (r regexpFlagList) flags(_ string) []string {
return []string(r)
}
@ -22,103 +55,207 @@ func (r regexpFlagFunc) flags(s string) []string {
return r(s)
}
// regexpFor compiles q into a regexp, optionally quoting meta characters
// and prepending inline flags (e.g. case-insensitive).
func regexpFor(q string, flags []string, quotemeta bool) (*regexp.Regexp, error) {
reTxt := q
if quotemeta {
reTxt = regexp.QuoteMeta(q)
}
if flags != nil && len(flags) > 0 {
if len(flags) > 0 {
reTxt = fmt.Sprintf("(?%s)%s", strings.Join(flags, ""), reTxt)
}
re, err := regexp.Compile(reTxt)
if err != nil {
return nil, errors.Wrapf(err, "failed to compile regular expression '%s'", reTxt)
return nil, fmt.Errorf("failed to compile regular expression '%s': %w", reTxt, err)
}
return re, nil
}
func queryToRegexps(query string, flags regexpFlags, quotemeta bool) ([]*regexp.Regexp, error) {
queries := strings.Split(strings.TrimSpace(query), " ")
regexps := make([]*regexp.Regexp, 0)
// SplitQueryTerms splits a query string into positive and negative term slices.
// Terms starting with `-` (followed by at least one non-hyphen char) are negative (the `-` is stripped).
// Terms starting with `\-` are positive literals (the `\` is stripped).
// Bare `-` or `--` are positive literals.
// Empty tokens are skipped.
func SplitQueryTerms(query string) (positive, negative []string) {
tokens := strings.SplitSeq(strings.TrimSpace(query), " ")
for tok := range tokens {
if tok == "" {
continue
}
if strings.HasPrefix(tok, `\-`) {
// Escaped negative: treat as literal positive term (strip the backslash)
positive = append(positive, tok[1:])
} else if tok == "-" || tok == "--" {
// Bare hyphen(s): literal positive
positive = append(positive, tok)
} else if strings.HasPrefix(tok, "-") {
// Negative term: strip the leading hyphen
negative = append(negative, tok[1:])
} else {
positive = append(positive, tok)
}
}
return
}
for _, q := range queries {
re, err := regexpFor(q, flags.flags(query), quotemeta)
// termsToRegexps compiles a slice of terms into regexps, using the full
// original query for flag computation (needed for SmartCase).
func termsToRegexps(terms []string, fullQuery string, flags regexpFlags, quotemeta bool) ([]*regexp.Regexp, error) {
regexps := make([]*regexp.Regexp, 0, len(terms))
for _, t := range terms {
re, err := regexpFor(t, flags.flags(fullQuery), quotemeta)
if err != nil {
return nil, errors.Wrapf(err, "failed to compile regular expression '%s'", q)
return nil, fmt.Errorf("failed to compile regular expression '%s': %w", t, err)
}
regexps = append(regexps, re)
}
return regexps, nil
}
func (rf *Regexp) NewContext(ctx context.Context, query string) context.Context {
return newContext(ctx, query)
}
// NewRegexp creates a new regexp based filter
func NewRegexp() *Regexp {
return &Regexp{
// newRegexpFilter is an internal helper that constructs a Regexp filter
// with the given name, flags, and quotemeta setting.
func newRegexpFilter(name string, flags regexpFlags, quotemeta bool) *Regexp {
rf := &Regexp{
factory: &regexpQueryFactory{
compiled: make(map[string]regexpQuery),
threshold: time.Minute,
},
flags: regexpFlagList(defaultFlags),
quotemeta: false,
name: "Regexp",
outCh: pipeline.ChanOutput(make(chan interface{})),
flags: flags,
quotemeta: quotemeta,
name: name,
}
rf.impl = rf
return rf
}
// NewRegexp creates a new regexp based filter
func NewRegexp() *Regexp {
return newRegexpFilter("Regexp", defaultFlags, false)
}
// NewIRegexp creates a new case-insensitive regexp based filter
func NewIRegexp() *Regexp {
return newRegexpFilter("IRegexp", ignoreCaseFlags, false)
}
const maxRegexpCacheSize = 100
// evictLRU removes the least recently used entries from the cache until its
// size is at or below targetSize. The caller must hold f.mutex.
func (f *regexpQueryFactory) evictLRU(targetSize int) {
type cacheEntry struct {
key string
lastUsed time.Time
}
entries := make([]cacheEntry, 0, len(f.compiled))
for k, v := range f.compiled {
entries = append(entries, cacheEntry{key: k, lastUsed: v.lastUsed})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].lastUsed.Before(entries[j].lastUsed)
})
toRemove := len(f.compiled) - targetSize
for i := range toRemove {
delete(f.compiled, entries[i].key)
}
}
func (rf Regexp) BufSize() int {
return 0
}
func (rf *Regexp) OutCh() <-chan interface{} {
rf.mutex.Lock()
defer rf.mutex.Unlock()
return rf.outCh
}
func (f *regexpQueryFactory) Compile(s string, flags regexpFlags, quotemeta bool) ([]*regexp.Regexp, error) {
// Compile parses the query string into positive and negative regexp slices,
// caching compiled results for reuse within the expiry threshold.
func (f *regexpQueryFactory) Compile(s string, flags regexpFlags, quotemeta bool) ([]*regexp.Regexp, []*regexp.Regexp, error) {
f.mutex.Lock()
defer f.mutex.Unlock()
rq, ok := f.compiled[s]
if ok {
if time.Since(rq.lastUsed) < f.threshold {
return rq.rx, nil
return rq.positive, rq.negative, nil
}
delete(f.compiled, s)
}
rxs, err := queryToRegexps(s, flags, quotemeta)
if err != nil {
return nil, errors.Wrap(err, `failed to compile regular expression`)
posTerms, negTerms := SplitQueryTerms(s)
var posRxs, negRxs []*regexp.Regexp
if len(posTerms) > 0 {
var err error
posRxs, err = termsToRegexps(posTerms, s, flags, quotemeta)
if err != nil {
return nil, nil, fmt.Errorf("failed to compile positive regular expressions: %w", err)
}
}
if len(negTerms) > 0 {
var err error
negRxs, err = termsToRegexps(negTerms, s, flags, quotemeta)
if err != nil {
return nil, nil, fmt.Errorf("failed to compile negative regular expressions: %w", err)
}
}
// Evict stale entries if cache is over the size limit
if len(f.compiled) >= maxRegexpCacheSize {
for k, v := range f.compiled {
if time.Since(v.lastUsed) >= f.threshold {
delete(f.compiled, k)
}
}
// If still over limit after evicting stale entries, evict the
// least recently used entries to bring cache down to half capacity.
if len(f.compiled) >= maxRegexpCacheSize {
f.evictLRU(maxRegexpCacheSize / 2)
}
}
rq.lastUsed = time.Now()
rq.rx = rxs
rq.positive = posRxs
rq.negative = negRxs
f.compiled[s] = rq
return rxs, nil
return posRxs, negRxs, nil
}
func (rf *Regexp) Apply(ctx context.Context, lines []line.Line, out pipeline.ChanOutput) error {
query := ctx.Value(queryKey).(string)
regexps, err := rf.factory.Compile(query, rf.flags, rf.quotemeta)
// applyInternal matches each line against the compiled positive and negative
// regexps, deduplicating overlapping match ranges before emitting results.
func (rf *Regexp) applyInternal(ctx context.Context, lines []line.Line, em LineEmitter) error {
query := pipeline.QueryFromContext(ctx)
posRegexps, negRegexps, err := rf.factory.Compile(query, rf.flags, rf.quotemeta)
if err != nil {
return errors.Wrap(err, "failed to compile queries as regular expression")
return fmt.Errorf("failed to compile queries as regular expression: %w", err)
}
for _, l := range lines {
// Pre-allocate match and dedup slices outside the loop so they can
// be reused across lines, avoiding per-line heap allocations.
var matches [][]int
var deduped [][]int
for i, l := range lines {
if err := checkCancelled(ctx, i); err != nil {
return err
}
v := l.DisplayString()
// Check negative terms first (fail-fast, no index collection)
if isExcluded(negRegexps, v) {
continue
}
// All-negative query: emit line with nil indices (no highlighting)
if len(posRegexps) == 0 {
em.Emit(ctx, line.GetMatched(l, nil))
continue
}
// Positive matching (existing AND logic)
allMatched := true
matches := [][]int{}
matches = matches[:0]
TryRegexps:
for _, rx := range regexps {
match := rx.FindAllStringSubmatchIndex(v, -1)
for _, rx := range posRegexps {
match := rx.FindAllStringIndex(v, -1)
if match == nil {
allMatched = false
break TryRegexps
@ -135,11 +272,11 @@ func (rf *Regexp) Apply(ctx context.Context, lines []line.Line, out pipeline.Cha
// We need to "dedupe" the results. For example, if we matched the
// same region twice, we don't want that to be drawn
deduped := make([][]int, 0, len(matches))
deduped = deduped[:0]
for i, m := range matches {
for j, m := range matches {
// Always push the first one
if i == 0 {
if j == 0 {
deduped = append(deduped, m)
continue
}
@ -158,41 +295,40 @@ func (rf *Regexp) Apply(ctx context.Context, lines []line.Line, out pipeline.Cha
deduped = append(deduped, m)
}
}
out.Send(line.NewMatched(l, deduped))
// Make a copy of deduped for emission since we reuse the slice
result := make([][]int, len(deduped))
copy(result, deduped)
em.Emit(ctx, line.GetMatched(l, result))
}
return nil
}
func (rf Regexp) String() string {
func (rf *Regexp) SupportsParallel() bool {
return true
}
func (rf *Regexp) String() string {
return rf.name
}
// NewIgnoreCase creates a case-insensitive literal string filter.
func NewIgnoreCase() *Regexp {
rf := NewRegexp()
rf.flags = ignoreCaseFlags
rf.quotemeta = true
rf.name = "IgnoreCase"
return rf
return newRegexpFilter("IgnoreCase", ignoreCaseFlags, true)
}
// NewCaseSensitive creates a case-sensitive literal string filter.
func NewCaseSensitive() *Regexp {
rf := NewRegexp()
rf.quotemeta = true
rf.name = "CaseSensitive"
return rf
return newRegexpFilter("CaseSensitive", defaultFlags, true)
}
// SmartCase turns ON the ignore-case flag in the regexp
// if the query contains a upper-case character
// NewSmartCase creates a filter that turns ON the ignore-case flag in the regexp
// if the query contains no upper-case character
func NewSmartCase() *Regexp {
rf := NewRegexp()
rf.quotemeta = true
rf.name = "SmartCase"
rf.flags = regexpFlagFunc(func(q string) []string {
return newRegexpFilter("SmartCase", regexpFlagFunc(func(q string) []string {
if util.ContainsUpper(q) {
return defaultFlags
}
return []string{"i"}
})
return rf
}), true)
}

View file

@ -1,28 +1,46 @@
package filter
import (
pdebug "github.com/lestrrat/go-pdebug"
"errors"
"sync"
pdebug "github.com/lestrrat-go/pdebug"
)
// ErrFilterNotFound is returned when a filter name does not match any
// registered filter in the Set.
var ErrFilterNotFound = errors.New("specified filter was not found")
// Set holds the collection of available filters and tracks which one
// is currently active.
type Set struct {
current int
filters []Filter
mutex sync.Mutex
}
// Reset sets the active filter back to the first one in the set.
func (fs *Set) Reset() {
fs.mutex.Lock()
defer fs.mutex.Unlock()
fs.current = 0
}
// Size returns the number of filters in the set.
func (fs *Set) Size() int {
fs.mutex.Lock()
defer fs.mutex.Unlock()
return len(fs.filters)
}
func (fs *Set) Add(lf Filter) error {
// Add appends a new filter to the set.
func (fs *Set) Add(lf Filter) {
fs.mutex.Lock()
defer fs.mutex.Unlock()
fs.filters = append(fs.filters, lf)
return nil
}
// Rotate cycles to the next filter in the set, wrapping around to the first.
func (fs *Set) Rotate() {
fs.mutex.Lock()
defer fs.mutex.Unlock()
@ -35,6 +53,8 @@ func (fs *Set) Rotate() {
}
}
// SetCurrentByName switches the active filter to the one matching the given
// name, returning ErrFilterNotFound if no filter matches.
func (fs *Set) SetCurrentByName(name string) error {
fs.mutex.Lock()
defer fs.mutex.Unlock()

386
filter_incremental_test.go Normal file
View 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
View file

@ -0,0 +1,194 @@
package peco
import (
"fmt"
"strings"
"testing"
"github.com/peco/peco/filter"
"github.com/peco/peco/hub"
"github.com/peco/peco/line"
"github.com/stretchr/testify/require"
)
// makeFollowBuffer builds a MemoryBuffer with n lines labelled "lineN".
func makeFollowBuffer(n int) *MemoryBuffer {
mb := NewMemoryBuffer(0)
for i := range n {
mb.lines = append(mb.lines, line.NewRaw(uint64(i), fmt.Sprintf("line%d", i), false, false))
}
return mb
}
// readScreenRow returns the text rendered on row y of the simulation screen,
// trailing blanks trimmed.
func readScreenRow(t *testing.T, s *SimScreen, y int) string {
t.Helper()
cells, w, _ := s.screen.GetContents()
var b strings.Builder
for x := range w {
c := cells[y*w+x]
if len(c.Runes) == 0 || c.Runes[0] == 0 {
b.WriteRune(' ')
continue
}
b.WriteRune(c.Runes[0])
}
return strings.TrimRight(b.String(), " ")
}
func newFollowState(t *testing.T, buf Buffer) (*Peco, *SimScreen) {
t.Helper()
screen := NewDummyScreen()
state := New()
state.screen = screen
state.Filters().Add(filter.NewIgnoreCase())
state.currentLineBuffer = buf
return state, screen
}
func TestWindowCrop(t *testing.T) {
buf := makeFollowBuffer(100)
t.Run("tail window", func(t *testing.T) {
fb := WindowCrop{offset: 90, perPage: 10}.Crop(buf)
require.Equal(t, 10, fb.Size())
first, err := fb.LineAt(0)
require.NoError(t, err)
require.Equal(t, "line90", first.DisplayString())
last, err := fb.LineAt(9)
require.NoError(t, err)
require.Equal(t, "line99", last.DisplayString())
})
t.Run("partial window smaller than perPage", func(t *testing.T) {
small := makeFollowBuffer(5)
fb := WindowCrop{offset: 0, perPage: 10}.Crop(small)
require.Equal(t, 5, fb.Size())
})
t.Run("negative offset clamps to zero", func(t *testing.T) {
fb := WindowCrop{offset: -5, perPage: 3}.Crop(buf)
require.Equal(t, 3, fb.Size())
first, err := fb.LineAt(0)
require.NoError(t, err)
require.Equal(t, "line0", first.DisplayString())
})
t.Run("offset past end yields empty", func(t *testing.T) {
fb := WindowCrop{offset: 200, perPage: 10}.Crop(buf)
require.Equal(t, 0, fb.Size())
})
}
// TestGHIssue820_FollowKeepsNewestVisible verifies that with follow mode on,
// DrawScreen pins the viewport to the tail of the buffer so the newest line
// is rendered on the last visible row (like tail -f), regardless of page
// alignment.
func TestGHIssue820_FollowKeepsNewestVisible(t *testing.T) {
buf := makeFollowBuffer(50)
state, screen := newFollowState(t, buf)
state.Follow().Set(true)
// top-down-query-bottom is the layout from the issue: list anchored to
// the top (row 0), query at the bottom.
layout, err := TopDownQueryBottomLayout(state)
require.NoError(t, err)
layout.DrawScreen(state, nil)
loc := state.Location()
perPage := layout.linesPerPage()
require.Equal(t, 49, loc.LineNumber(), "cursor should be pinned to the newest line")
require.Equal(t, 50, loc.Total())
require.Equal(t, max(50-perPage, 0), loc.Offset(),
"offset should be a sliding window over the tail, not page-aligned")
// The list area is anchored at row 0; the newest line sits on the last
// visible row.
lastRow := perPage - 1
require.Equal(t, "line49", readScreenRow(t, screen, lastRow),
"newest line should be on the bottom row of the list area")
}
// TestFollowDefaultShowsHead verifies that without follow mode the viewport
// stays at the head of the buffer (unchanged behavior).
func TestFollowDefaultShowsHead(t *testing.T) {
buf := makeFollowBuffer(50)
state, screen := newFollowState(t, buf)
require.False(t, state.IsFollowing())
layout, err := TopDownQueryBottomLayout(state)
require.NoError(t, err)
layout.DrawScreen(state, nil)
loc := state.Location()
require.Equal(t, 0, loc.LineNumber())
require.Equal(t, 0, loc.Offset())
require.Equal(t, "line0", readScreenRow(t, screen, 0),
"oldest line should be on the top row when not following")
}
// TestFollowDisabledByManualScroll verifies that any manual vertical
// navigation turns follow mode off.
func TestFollowDisabledByManualScroll(t *testing.T) {
buf := makeFollowBuffer(50)
state, _ := newFollowState(t, buf)
state.Follow().Set(true)
layout, err := TopDownQueryBottomLayout(state)
require.NoError(t, err)
// Pin to the tail first.
layout.DrawScreen(state, nil)
require.True(t, state.IsFollowing())
moved := layout.MovePage(state, hub.ToLineAbove)
require.True(t, moved)
require.False(t, state.IsFollowing(),
"manual vertical scroll should disable follow mode")
}
// TestToggleFollowAction verifies the ToggleFollow action flips follow state.
func TestToggleFollowAction(t *testing.T) {
state := New()
state.hub = nullHub{}
require.False(t, state.IsFollowing())
doToggleFollow(t.Context(), state, Event{})
require.True(t, state.IsFollowing(), "follow should be on after first toggle")
doToggleFollow(t.Context(), state, Event{})
require.False(t, state.IsFollowing(), "follow should be off after second toggle")
}
// TestFollowConfigAndFlag verifies that --follow and the Follow config field
// both enable follow mode, with the CLI flag taking precedence.
func TestFollowConfigAndFlag(t *testing.T) {
t.Run("CLI flag", func(t *testing.T) {
p := newPeco()
require.NoError(t, p.ApplyConfig(CLIOptions{OptFollow: true}))
require.True(t, p.IsFollowing())
})
t.Run("config field", func(t *testing.T) {
p := newPeco()
p.config.Follow = true
require.NoError(t, p.ApplyConfig(CLIOptions{}))
require.True(t, p.IsFollowing())
})
t.Run("default off", func(t *testing.T) {
p := newPeco()
require.NoError(t, p.ApplyConfig(CLIOptions{}))
require.False(t, p.IsFollowing())
})
}
// TestToggleFollowRegistered verifies the action is wired into the registry so
// it can be bound from a keymap.
func TestToggleFollowRegistered(t *testing.T) {
_, ok := nameToActions["peco.ToggleFollow"]
require.True(t, ok, "peco.ToggleFollow should be registered")
}

24
glide.lock generated
View file

@ -1,24 +0,0 @@
hash: 3ce65eedf36489cf8210f31a499d744db368aeb0add09df5483a87c185b344b5
updated: 2016-12-14T18:28:21.292809662+09:00
imports:
- name: github.com/davecgh/go-spew
version: 346938d642f2ec3594ed81d874461961cd0faa76
subpackages:
- spew
- name: github.com/google/btree
version: 0c3044bc8bada22db67b93f5760fe3f05d6a5c25
- name: github.com/jessevdk/go-flags
version: 8bc97d602c3bfeb5fc6fc9b5a9c898f245495637
- name: github.com/lestrrat/go-pdebug
version: 2e6eaaa5717f81bda41d27070d3c966f40a1e75f
- name: github.com/mattn/go-runewidth
version: 737072b4e32b7a5018b4a7125da8d12de90e8045
- name: github.com/nsf/termbox-go
version: abe82ce5fb7a42fbd6784a5ceb71aff977e09ed8
- name: github.com/pkg/errors
version: 248dadf4e9068a0b3e79f02ed0a610d935de5302
- name: github.com/stretchr/testify
version: 18a02ba4a312f95da08ff4cfc0055750ce50ae9e
subpackages:
- assert
devImports: []

View file

@ -1,11 +0,0 @@
package: github.com/peco/peco
import:
- package: github.com/google/btree
- package: github.com/jessevdk/go-flags
- package: github.com/mattn/go-runewidth
- package: github.com/nsf/termbox-go
- package: github.com/pkg/errors
- package: github.com/stretchr/testify
subpackages:
- assert
- package: github.com/lestrrat/go-pdebug

25
go.mod Normal file
View 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
View file

@ -0,0 +1,63 @@
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA=
github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
github.com/lestrrat-go/pdebug v0.0.0-20180220043849-39f9a71bcabe h1:S7XSBlgc/eI2v47LkPPVa+infH3FuTS4tPJbqCtJovo=
github.com/lestrrat-go/pdebug v0.0.0-20180220043849-39f9a71bcabe/go.mod h1:zvUY6gZZVL2nu7NM+/3b51Z/hxyFZCZxV0hvfZ3NJlg=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU=
github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

29
hub/bench_test.go Normal file
View 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
View 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
}

View file

@ -1,111 +1,211 @@
package hub
import (
"context"
"fmt"
"os"
"runtime/debug"
"sync"
"time"
pdebug "github.com/lestrrat/go-pdebug"
pdebug "github.com/lestrrat-go/pdebug"
)
func NewPayload(data interface{}) *payload {
p := &payload{data: data}
return p
// Hub acts as the messaging hub between components -- that is,
// it controls how the communication that goes through channels
// are handled.
type Hub struct {
mutex sync.Mutex
queryCh chan *Payload[string]
drawCh chan *Payload[*DrawOptions]
statusMsgCh chan *Payload[StatusMsg]
pagingCh chan *Payload[PagingRequest]
}
// Data returns the underlying data
func (p payload) Data() interface{} {
// Payload is a wrapper around the actual request value that needs
// to be passed. It contains an optional channel field which can
// be filled to force synchronous communication between the
// sender and receiver
type Payload[T any] struct {
batch bool
data T
done chan struct{}
}
// NewPayload creates a new Payload with the given data and batch flag.
func NewPayload[T any](data T, batch bool) *Payload[T] {
return &Payload[T]{
data: data,
batch: batch,
}
}
// Batch returns true if this payload is part of a batch operation.
func (p *Payload[T]) Batch() bool {
return p.batch
}
// Data returns the underlying data.
func (p *Payload[T]) Data() T {
return p.data
}
// Done marks the request as done. If Hub is operating in
// asynchronous mode (default), it's a no op. Otherwise it
// closes the reply channel to finish up the synchronous communication
func (p payload) Done() {
// Done marks the request as done. In non-batch mode it's a no-op.
// In batch mode it signals the sender that the receiver has finished
// processing this payload.
func (p *Payload[T]) Done() {
if p.done == nil {
return
}
close(p.done)
p.done <- struct{}{}
}
// NewHub creates a new Hub struct
// New creates a new Hub struct
func New(bufsiz int) *Hub {
return &Hub{
isSync: false,
queryCh: make(chan Payload, bufsiz),
drawCh: make(chan Payload, bufsiz),
statusMsgCh: make(chan Payload, bufsiz),
pagingCh: make(chan Payload, bufsiz),
queryCh: make(chan *Payload[string], bufsiz),
drawCh: make(chan *Payload[*DrawOptions], bufsiz),
statusMsgCh: make(chan *Payload[StatusMsg], bufsiz),
pagingCh: make(chan *Payload[PagingRequest], bufsiz),
}
}
// batchCtxKey is a single context key that signals both "this is a batch
// payload" and "the hub mutex is already held" for re-entrant detection.
// Using one key instead of two avoids a second context.WithValue allocation.
type batchCtxKey struct{}
// Batch allows you to synchronously send messages during the
// scope of f() being executed.
func (h *Hub) Batch(f func(), shouldLock bool) {
// scope of f() being executed. The mutex is acquired automatically
// unless this is a nested Batch call (detected via context).
func (h *Hub) Batch(ctx context.Context, f func(ctx context.Context)) {
nested, _ := ctx.Value(batchCtxKey{}).(bool)
if pdebug.Enabled {
g := pdebug.Marker("Batch %t", shouldLock)
g := pdebug.Marker("Batch (nested=%t)", nested)
defer g.End()
}
if shouldLock {
// lock during this operation
if !nested {
h.mutex.Lock()
defer h.mutex.Unlock()
}
// temporarily set isSync = true
o := h.isSync
h.isSync = true
defer func() { h.isSync = o }()
// Log and re-panic instead of silently swallowing. The mutex
// unlock defer (above) still runs during re-panic, so the hub
// won't deadlock.
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "peco: panic in Hub.Batch: %v\n%s", r, debug.Stack())
panic(r)
}
}()
// ignore panics
defer func() { recover() }()
f()
batchCtx := context.WithValue(ctx, batchCtxKey{}, true)
f(batchCtx)
}
// low-level utility
func send(ch chan Payload, r *payload, needReply bool) {
if needReply {
r.done = make(chan struct{})
defer func() { <-r.done }()
var doneChPool = sync.Pool{
New: func() any {
return make(chan struct{})
},
}
// waitDone blocks until the receiver signals completion by calling Done.
func (p *Payload[T]) waitDone() {
// Save the channel reference before blocking. This read is safe because
// p.done was set by send() on this same goroutine before the payload
// was sent on the hub channel.
ch := p.done
// Block until the receiver calls Done(), which sends on ch.
<-ch
// After the receive, the receiver is finished with p.done (it already
// sent on it), so this goroutine has exclusive access. Clear the field
// and return the channel to the pool.
p.done = nil
doneChPool.Put(ch)
}
// isBatchCtx reports whether the context was created by a Batch call.
func isBatchCtx(ctx context.Context) bool {
v, _ := ctx.Value(batchCtxKey{}).(bool)
return v
}
// send is the low-level generic utility for sending typed payloads.
// Batch mode is determined from the payload's Batch() flag, which is
// set by each Send* method via isBatchCtx before calling send.
// The context is used for cancellation so sends don't block forever
// during shutdown.
func send[T any](ctx context.Context, ch chan *Payload[T], r *Payload[T]) {
if pdebug.Enabled {
g := pdebug.Marker("hub.send (isBatchMode=%t)", r.Batch())
defer g.End()
}
ch <- r
if r.Batch() {
// The pool only ever contains chan struct{} values (see doneChPool.Put
// in Payload.Done), so this assertion cannot fail in practice.
r.done, _ = doneChPool.Get().(chan struct{})
if pdebug.Enabled {
defer pdebug.Printf("request is part of batch operation. waiting")
}
defer r.waitDone()
}
select {
case ch <- r:
case <-ctx.Done():
}
}
// QueryCh returns the underlying channel for queries
func (h *Hub) QueryCh() chan Payload {
func (h *Hub) QueryCh() chan *Payload[string] {
return h.queryCh
}
// SendQuery sends the query string to be processed by the Filter
func (h *Hub) SendQuery(q string) {
send(h.QueryCh(), NewPayload(q), h.isSync)
func (h *Hub) SendQuery(ctx context.Context, q string) {
send(ctx, h.QueryCh(), NewPayload(q, isBatchCtx(ctx)))
}
// DrawCh returns the channel to redraw the terminal display
func (h *Hub) DrawCh() chan Payload {
func (h *Hub) DrawCh() chan *Payload[*DrawOptions] {
return h.drawCh
}
// SendDrawPrompt sends a request to redraw the prompt only
func (h *Hub) SendDrawPrompt() {
send(h.DrawCh(), NewPayload("prompt"), h.isSync)
func (h *Hub) SendDrawPrompt(ctx context.Context) {
send(ctx, h.DrawCh(), NewPayload(&DrawOptions{Prompt: true}, isBatchCtx(ctx)))
}
// SendDraw sends a request to redraw the terminal display
func (h *Hub) SendDraw(options interface{}) {
pdebug.Printf("START Hub.SendDraw %v", options)
defer pdebug.Printf("END Hub.SendDraw %v", options)
send(h.DrawCh(), NewPayload(options), h.isSync)
func (h *Hub) SendDraw(ctx context.Context, options *DrawOptions) {
if pdebug.Enabled {
pdebug.Printf("START Hub.SendDraw %v", options)
defer pdebug.Printf("END Hub.SendDraw %v", options)
}
send(ctx, h.DrawCh(), NewPayload(options, isBatchCtx(ctx)))
}
// StatusMsgCh returns the channel to update the status message
func (h *Hub) StatusMsgCh() chan Payload {
func (h *Hub) StatusMsgCh() chan *Payload[StatusMsg] {
return h.statusMsgCh
}
// SendStatusMsg sends a string to be displayed in the status message
func (h *Hub) SendStatusMsg(q string) {
h.SendStatusMsgAndClear(q, 0)
// SendStatusMsg sends a string to be displayed in the status message.
// If clearDelay is non-zero, the message will be cleared after that duration.
func (h *Hub) SendStatusMsg(ctx context.Context, q string, clearDelay time.Duration) {
msg := newStatusMsgReq(q, clearDelay)
send(ctx, h.StatusMsgCh(), NewPayload[StatusMsg](msg, isBatchCtx(ctx)))
}
// StatusMsg is an interface for status message requests.
type StatusMsg interface {
Message() string
Delay() time.Duration
}
type statusMsgReq struct {
@ -121,6 +221,7 @@ func (r statusMsgReq) Delay() time.Duration {
return r.delay
}
// newStatusMsgReq creates a StatusMsg with the given message text and display duration.
func newStatusMsgReq(s string, d time.Duration) *statusMsgReq {
return &statusMsgReq{
msg: s,
@ -128,23 +229,12 @@ func newStatusMsgReq(s string, d time.Duration) *statusMsgReq {
}
}
// SendStatusMsgAndClear sends a string to be displayed in the status message,
// as well as a delay until the message should be cleared
func (h *Hub) SendStatusMsgAndClear(q string, clearDelay time.Duration) {
msg := newStatusMsgReq(q, clearDelay)
send(h.StatusMsgCh(), NewPayload(msg), h.isSync)
}
func (h *Hub) SendPurgeDisplayCache() {
send(h.DrawCh(), NewPayload("purgeCache"), h.isSync)
}
// PagingCh returns the channel to page through the results
func (h *Hub) PagingCh() chan Payload {
func (h *Hub) PagingCh() chan *Payload[PagingRequest] {
return h.pagingCh
}
// SendPaging sends a request to move the cursor around
func (h *Hub) SendPaging(x interface{}) {
send(h.PagingCh(), NewPayload(x), h.isSync)
func (h *Hub) SendPaging(ctx context.Context, x PagingRequest) {
send(ctx, h.PagingCh(), NewPayload(x, isBatchCtx(ctx)))
}

View file

@ -1,56 +1,61 @@
package hub
package hub_test
import (
"context"
"testing"
"time"
"github.com/peco/peco/hub"
"github.com/stretchr/testify/require"
)
func TestHub(t *testing.T) {
h := New(5)
ctx := context.Background()
h := hub.New(5)
done := make(map[string]time.Time)
go func() {
hr := <-h.QueryCh()
require.Equal(t, "Hello World!", hr.Data())
time.Sleep(100 * time.Millisecond)
done["query"] = time.Now()
hr.Done()
}()
go func() {
hr := <-h.DrawCh()
switch v := hr.Data(); v.(type) {
case string, bool, nil:
// OK
default:
t.Errorf("Expected data to be nil, got %s", v)
}
// Data() returns *hub.DrawOptions directly — no type assertion needed
_ = hr.Data()
time.Sleep(100 * time.Millisecond)
done["draw"] = time.Now()
hr.Done()
}()
go func() {
hr := <-h.StatusMsgCh()
r := hr.Data().(*statusMsgReq)
if r.Message() != "Hello, World!" {
t.Errorf("Expected data to be 'Hello World!', got '%s'", r.Message())
}
// Data() returns hub.StatusMsg directly — no type assertion needed
r := hr.Data()
require.Equal(t, "Hello, World!", r.Message())
time.Sleep(100 * time.Millisecond)
done["status"] = time.Now()
hr.Done()
}()
go func() {
hr := <-h.PagingCh()
// Data() returns hub.PagingRequest directly — no type assertion needed
r := hr.Data()
require.Equal(t, hub.PagingRequestType(1), r.Type())
time.Sleep(100 * time.Millisecond)
done["paging"] = time.Now()
hr.Done()
}()
h.Batch(func() {
h.SendQuery("Hello World!")
h.SendDraw(true)
h.SendStatusMsg("Hello, World!")
h.SendPaging(1)
}, true)
h.Batch(ctx, func(ctx context.Context) {
h.SendQuery(ctx, "Hello World!")
h.SendDraw(ctx, &hub.DrawOptions{})
h.SendStatusMsg(ctx, "Hello, World!", 0)
h.SendPaging(ctx, hub.PagingRequestType(1))
})
phases := []string{
"query",
@ -59,9 +64,9 @@ func TestHub(t *testing.T) {
"paging",
}
max := len(phases) - 1
last := len(phases) - 1
for i := range phases {
if max == i {
if last == i {
break
}
@ -69,8 +74,114 @@ func TestHub(t *testing.T) {
next := phases[i+1]
t.Logf("Checkin if %s was fired before %s", cur, next)
if done[next].Before(done[cur]) {
t.Errorf("%s executed before %s?!", next, cur)
}
require.False(t, done[next].Before(done[cur]),
"%s executed before %s", next, cur)
}
}
func TestBatchPanicPropagates(t *testing.T) {
h := hub.New(5)
ctx := context.Background()
// A panic inside a Batch callback must propagate to the caller,
// not be silently swallowed.
require.Panics(t, func() {
h.Batch(ctx, func(_ context.Context) {
panic("bug in callback")
})
}, "Batch must not silently swallow panics")
}
func TestBatchPanicReleasesLock(t *testing.T) {
h := hub.New(5)
ctx := context.Background()
// First call: panic inside a locked Batch.
func() {
defer func() { recover() }()
h.Batch(ctx, func(_ context.Context) {
panic("first call panics")
})
}()
// Second call: if the mutex was not released by the first panic,
// this will deadlock. Use a timeout to detect that.
done := make(chan struct{})
go func() {
h.Batch(ctx, func(ctx context.Context) {
// Drain the message so the synchronous send completes.
go func() {
p := <-h.QueryCh()
p.Done()
}()
h.SendQuery(ctx, "after panic")
})
close(done)
}()
select {
case <-done:
// success — mutex was properly released
case <-time.After(2 * time.Second):
require.Fail(t, "Batch deadlocked — mutex was not released after panic")
}
}
func TestBatchNestedDoesNotDeadlock(t *testing.T) {
h := hub.New(5)
ctx := context.Background()
done := make(chan struct{})
go func() {
h.Batch(ctx, func(ctx context.Context) {
// Nested Batch call — must not deadlock
h.Batch(ctx, func(ctx context.Context) {
go func() {
p := <-h.QueryCh()
p.Done()
}()
h.SendQuery(ctx, "nested")
})
})
close(done)
}()
select {
case <-done:
// success — nested Batch did not deadlock
case <-time.After(2 * time.Second):
require.Fail(t, "nested Batch deadlocked")
}
}
func TestSendStatusMsg(t *testing.T) {
t.Run("zero delay", func(t *testing.T) {
h := hub.New(5)
ctx := context.Background()
go func() {
h.SendStatusMsg(ctx, "hello", 0)
}()
p := <-h.StatusMsgCh()
defer p.Done()
require.Equal(t, "hello", p.Data().Message())
require.Equal(t, time.Duration(0), p.Data().Delay())
})
t.Run("non-zero delay", func(t *testing.T) {
h := hub.New(5)
ctx := context.Background()
go func() {
h.SendStatusMsg(ctx, "temporary", 500*time.Millisecond)
}()
p := <-h.StatusMsgCh()
defer p.Done()
require.Equal(t, "temporary", p.Data().Message())
require.Equal(t, 500*time.Millisecond, p.Data().Delay())
})
}

View file

@ -1,35 +0,0 @@
package hub
import "sync"
// Hub acts as the messaging hub between components -- that is,
// it controls how the communication that goes through channels
// are handled.
type Hub struct {
isSync bool
mutex sync.Mutex
queryCh chan Payload
drawCh chan Payload
statusMsgCh chan Payload
pagingCh chan Payload
}
// Payload is a wrapper around the actual request value that needs
// to be passed. It contains an optional channel field which can
// be filled to force synchronous communication between the
// sender and receiver
type Payload interface {
// Data allows you to retrieve the data that's embedded in the payload.
Data() interface{}
// Done is called when the payload has been processed. There are cases
// where the payload processing is expected to be synchronized with the
// caller, and this method signals the caller that it is safe to proceed
// to whatever next action it was expecting
Done()
}
type payload struct {
data interface{}
done chan struct{}
}

41
hub/paging.go Normal file
View 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)
}

View 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]]
}

View file

@ -1,13 +1,20 @@
package peco
import (
"time"
"github.com/nsf/termbox-go"
"context"
"github.com/lestrrat-go/pdebug"
)
func NewInput(state *Peco, am ActionMap, src chan termbox.Event) *Input {
// Input handles dispatching key events to actions.
type Input struct {
actions ActionMap
evsrc chan Event
state *Peco
}
// NewInput creates and returns a new Input instance for handling keyboard events.
func NewInput(state *Peco, am ActionMap, src chan Event) *Input {
return &Input{
actions: am,
evsrc: src,
@ -15,6 +22,7 @@ func NewInput(state *Peco, am ActionMap, src chan termbox.Event) *Input {
}
}
// Loop runs the main input event loop, reading terminal events and dispatching them.
func (i *Input) Loop(ctx context.Context, cancel func()) error {
defer cancel()
@ -22,7 +30,10 @@ func (i *Input) Loop(ctx context.Context, cancel func()) error {
select {
case <-ctx.Done():
return nil
case ev := <-i.evsrc:
case ev, ok := <-i.evsrc:
if !ok {
return nil
}
if err := i.handleInputEvent(ctx, ev); err != nil {
return nil
}
@ -30,49 +41,26 @@ func (i *Input) Loop(ctx context.Context, cancel func()) error {
}
}
func (i *Input) handleInputEvent(ctx context.Context, ev termbox.Event) error {
// handleInputEvent processes a single terminal input event, mapping it to an action.
func (i *Input) handleInputEvent(ctx context.Context, ev Event) error {
if pdebug.Enabled {
g := pdebug.Marker("event received from user: %#v", ev)
defer g.End()
}
switch ev.Type {
case termbox.EventError:
case EventError:
return nil
case termbox.EventResize:
i.state.Hub().SendDraw(nil)
case EventResize:
i.state.Hub().SendDraw(ctx, nil)
return nil
case termbox.EventKey:
// ModAlt is a sequence of letters with a leading \x1b (=Esc).
// It would be nice if termbox differentiated this for us, but
// we workaround it by waiting (juuuust a few milliseconds) for
// extra key events. If no extra events arrive, it should be Esc
m := &i.mutex
// Smells like Esc or Alt. mod == nil checks for the presence
// of a previous timer
m.Lock()
if ev.Ch == 0 && ev.Key == 27 && i.mod == nil {
tmp := ev
i.mod = time.AfterFunc(50*time.Millisecond, func() {
m.Lock()
i.mod = nil
m.Unlock()
i.state.Keymap().ExecuteAction(ctx, i.state, tmp)
})
m.Unlock()
return nil
}
m.Unlock()
// it doesn't look like this is Esc or Alt. If we have a previous
// timer, stop it because this is probably Alt+ this new key
m.Lock()
if i.mod != nil {
i.mod.Stop()
i.mod = nil
ev.Mod |= termbox.ModAlt
}
m.Unlock()
i.state.Keymap().ExecuteAction(ctx, i.state, ev)
case EventKey:
// tcell already disambiguates standalone Escape from Alt+key
// sequences using its own internal escape timer. By the time
// we receive a KeyEsc event here, tcell has already determined
// it is a standalone Escape. Alt+key events arrive with ModAlt
// already set. No application-layer timer is needed.
_ = i.state.Keymap().ExecuteAction(ctx, i.state, ev)
return nil
}

72
input_test.go Normal file
View 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")
}

View file

@ -1,468 +0,0 @@
package peco
import (
"io"
"sync"
"time"
"context"
"github.com/google/btree"
"github.com/nsf/termbox-go"
"github.com/peco/peco/filter"
"github.com/peco/peco/hub"
"github.com/peco/peco/internal/keyseq"
"github.com/peco/peco/line"
"github.com/peco/peco/pipeline"
)
const (
successKey = "success"
errorKey = "error"
)
const (
ToLineAbove PagingRequestType = iota // ToLineAbove moves the selection to the line above
ToScrollPageDown // ToScrollPageDown moves the selection to the next page
ToLineBelow // ToLineBelow moves the selection to the line below
ToScrollPageUp // ToScrollPageUp moves the selection to the previous page
ToScrollLeft // ToScrollLeft scrolls screen to the left
ToScrollRight // ToScrollRight scrolls screen to the right
ToLineInPage // ToLineInPage jumps to a particular line on the page
)
const (
DefaultLayoutType = LayoutTypeTopDown // LayoutTypeTopDown makes the layout so the items read from top to bottom
LayoutTypeTopDown = "top-down" // LayoutTypeBottomUp changes the layout to read from bottom to up
LayoutTypeBottomUp = "bottom-up"
)
const (
AnchorTop VerticalAnchor = iota + 1 // AnchorTop anchors elements towards the top of the screen
AnchorBottom // AnchorBottom anchors elements towards the bottom of the screen
)
// These are used as keys in the config file
const (
IgnoreCaseMatch = "IgnoreCase"
CaseSensitiveMatch = "CaseSensitive"
SmartCaseMatch = "SmartCase"
RegexpMatch = "Regexp"
)
type idgen struct {
ch chan uint64
}
// Peco is the global object containing everything required to run peco.
// It also contains the global state of the program.
type Peco struct {
Argv []string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
hub MessageHub
args []string
bufferSize int
caret Caret
// Config contains the values read in from config file
config Config
currentLineBuffer Buffer
enableSep bool // Enable parsing on separators
execOnFinish string
filters filter.Set
idgen *idgen
initialFilter string
initialQuery string // populated if --query is specified
inputseq Inputseq // current key sequence (just the names)
keymap Keymap
layoutType string
location Location
maxScanBufferSize int
mutex sync.Mutex
onCancel string
prompt string
query Query
queryExecDelay time.Duration
queryExecMutex sync.Mutex
queryExecTimer *time.Timer
readyCh chan struct{}
resultCh chan line.Line
screen Screen
selection *Selection
selectionPrefix string
selectionRangeStart RangeStart
selectOneAndExit bool // True if --select-1 is enabled
singleKeyJumpMode bool
singleKeyJumpPrefixes []rune
singleKeyJumpPrefixMap map[rune]uint
singleKeyJumpShowPrefix bool
skipReadConfig bool
styles StyleSet
// Source is where we buffer input. It gets reused when a new query is
// executed.
source *Source
// cancelFunc is called for Exit()
cancelFunc func()
// Errors are stored here
err error
}
type MatchIndexer interface {
// Indices return the matched portion(s) of a string after filtering.
// Note that while Indices may return nil, that just means that there are
// no substrings to be highlighted. It doesn't mean there were no matches
Indices() [][]int
}
type Keyseq interface {
Add(keyseq.KeyList, interface{})
AcceptKey(keyseq.Key) (interface{}, error)
CancelChain()
Clear()
Compile() error
InMiddleOfChain() bool
}
// PagingRequest can be sent to move the selection cursor
type PagingRequestType int
type PagingRequest interface {
Type() PagingRequestType
}
type JumpToLineRequest int
// Selection stores the line ids that were selected by the user.
// The contents of the Selection is always sorted from smallest to
// largest line ID
type Selection struct {
mutex sync.Mutex
tree *btree.BTree
}
// Screen hides termbox from the consuming code so that
// it can be swapped out for testing
type Screen interface {
Init() error
Close() error
Flush() error
PollEvent(context.Context) chan termbox.Event
Print(PrintArgs) int
Resume()
SetCell(int, int, rune, termbox.Attribute, termbox.Attribute)
Size() (int, int)
SendEvent(termbox.Event)
Suspend()
}
// Termbox just hands out the processing to the termbox library
type Termbox struct {
mutex sync.Mutex
resumeCh chan chan struct{}
suspendCh chan struct{}
}
// View handles the drawing/updating the screen
type View struct {
layout Layout
state *Peco
}
// PageCrop filters out a new LineBuffer based on entries
// per page and the page number
type PageCrop struct {
perPage int
currentPage int
}
// LayoutType describes the types of layout that peco can take
type LayoutType string
// VerticalAnchor describes the direction to which elements in the
// layout are anchored to
type VerticalAnchor int
// Layout represents the component that controls where elements are placed on screen
type Layout interface {
PrintStatus(string, time.Duration)
DrawPrompt(*Peco)
DrawScreen(*Peco, *DrawOptions)
MovePage(*Peco, PagingRequest) (moved bool)
PurgeDisplayCache()
}
// AnchorSettings groups items that are required to control
// where an anchored item is actually placed
type AnchorSettings struct {
anchor VerticalAnchor // AnchorTop or AnchorBottom
anchorOffset int // offset this many lines from the anchor
screen Screen
}
// UserPrompt draws the prompt line
type UserPrompt struct {
*AnchorSettings
prompt string
promptLen int
styles *StyleSet
}
// StatusBar draws the status message bar
type StatusBar struct {
*AnchorSettings
clearTimer *time.Timer
styles *StyleSet
timerMutex sync.Mutex
}
// ListArea represents the area where the actual line buffer is
// displayed in the screen
type ListArea struct {
*AnchorSettings
sortTopDown bool
displayCache []line.Line
dirty bool
styles *StyleSet
}
// BasicLayout is... the basic layout :) At this point this is the
// only struct for layouts, which means that while the position
// of components may be configurable, the actual types of components
// that are used are set and static
type BasicLayout struct {
*StatusBar
prompt *UserPrompt
list *ListArea
}
// Keymap holds all the key sequence to action map
type Keymap struct {
Config map[string]string
Action map[string][]string // custom actions
seq Keyseq
}
// Filter is responsible for the actual "grep" part of peco
type Filter struct {
state *Peco
}
// Action describes an action that can be executed upon receiving user
// input. It's an interface so you can create any kind of Action you need,
// but most everything is implemented in terms of ActionFunc, which is
// callback based Action
type Action interface {
Register(string, ...termbox.Key)
RegisterKeySequence(string, keyseq.KeyList)
Execute(context.Context, *Peco, termbox.Event)
}
// ActionFunc is a type of Action that is basically just a callback.
type ActionFunc func(context.Context, *Peco, termbox.Event)
// FilteredBuffer holds a "filtered" buffer. It holds a reference to
// the source buffer (note: should be immutable) and a list of indices
// into the source buffer
type FilteredBuffer struct {
maxcols int
src Buffer
selection []int // maps from our index to src's index
}
// Config holds all the data that can be configured in the
// external configuration file
type Config struct {
Action map[string][]string `json:"Action"`
// Keymap used to be directly responsible for dispatching
// events against user input, but since then this has changed
// into something that just records the user's config input
Keymap map[string]string `json:"Keymap"`
Matcher string `json:"Matcher"` // Deprecated.
InitialMatcher string `json:"InitialMatcher"` // Use this instead of Matcher
InitialFilter string `json:"InitialFilter"`
Style StyleSet `json:"Style"`
Prompt string `json:"Prompt"`
Layout string `json:"Layout"`
OnCancel string `json:"OnCancel"`
CustomMatcher map[string][]string
CustomFilter map[string]CustomFilterConfig
QueryExecutionDelay int
StickySelection bool
MaxScanBufferSize int
// If this is true, then the prefix for single key jump mode
// is displayed by default.
SingleKeyJump SingleKeyJumpConfig `json:"SingleKeyJump"`
// Use this prefix to denote currently selected line
SelectionPrefix string `json:"SelectionPrefix"`
}
type SingleKeyJumpConfig struct {
ShowPrefix bool `json:"ShowPrefix"`
}
// CustomFilterConfig is used to specify configuration parameters
// to CustomFilters
type CustomFilterConfig struct {
// Cmd is the name of the command to invoke
Cmd string
// TODO: need to check if how we use this is correct
Args []string
// BufferThreshold defines how many lines peco buffers before
// invoking the external command. If this value is big, we
// will execute the external command fewer times, but the
// results will not be generated for longer periods of time.
// If this value is small, we will execute the external command
// more often, but you pay the penalty of invoking that command
// more times.
BufferThreshold int
}
// StyleSet holds styles for various sections
type StyleSet struct {
Basic Style `json:"Basic"`
SavedSelection Style `json:"SavedSelection"`
Selected Style `json:"Selected"`
Query Style `json:"Query"`
Matched Style `json:"Matched"`
}
// Style describes termbox styles
type Style struct {
fg termbox.Attribute
bg termbox.Attribute
}
type Caret struct {
mutex sync.Mutex
pos int
}
type Location struct {
col int
lineno int
maxPage int
page int
perPage int
offset int
total int
}
type Query struct {
query []rune
savedQuery []rune
mutex sync.Mutex
}
type FilterQuery Query
// Source implements pipeline.Source, and is the buffer for the input
type Source struct {
pipeline.ChanOutput
capacity int
enableSep bool
idgen line.IDGenerator
in io.Reader
lines []line.Line
name string
mutex sync.RWMutex
ready chan struct{}
setupDone chan struct{}
setupOnce sync.Once
}
type State interface {
Keymap() *Keymap
Query() Query
Screen() Screen
SetCurrentCol(int)
CurrentCol() int
SetCurrentLine(int)
CurrentLine() int
SetSingleKeyJumpMode(bool)
SingleKeyJumpMode() bool
}
type CLIOptions struct {
OptHelp bool `short:"h" long:"help" description:"show this help message and exit"`
OptQuery string `long:"query" description:"initial value for query"`
OptRcfile string `long:"rcfile" description:"path to the settings file"`
OptVersion bool `long:"version" description:"print the version and exit"`
OptBufferSize int `long:"buffer-size" short:"b" description:"number of lines to keep in search buffer"`
OptEnableNullSep bool `long:"null" description:"expect NUL (\\0) as separator for target/output"`
OptInitialIndex int `long:"initial-index" description:"position of the initial index of the selection (0 base)"`
OptInitialMatcher string `long:"initial-matcher" description:"specify the default matcher (deprecated)"`
OptInitialFilter string `long:"initial-filter" description:"specify the default filter"`
OptPrompt string `long:"prompt" description:"specify the prompt string"`
OptLayout string `long:"layout" description:"layout to be used. 'top-down' or 'bottom-up'. default is 'top-down'"`
OptSelect1 bool `long:"select-1" description:"select first item and immediately exit if the input contains only 1 item"`
OptOnCancel string `long:"on-cancel" description:"specify action on user cancel. 'success' or 'error'.\ndefault is 'success'. This may change in future versions"`
OptSelectionPrefix string `long:"selection-prefix" description:"use a prefix instead of changing line color to indicate currently selected lines.\ndefault is to use colors. This option is experimental"`
OptExec string `long:"exec" description:"execute command instead of finishing/terminating peco.\nPlease note that this command will receive selected line(s) from stdin,\nand will be executed via '/bin/sh -c' or 'cmd /c'"`
}
type CLI struct {
}
type RangeStart struct {
val int
valid bool
}
// Buffer interface is used for containers for lines to be
// processed by peco.
type Buffer interface {
linesInRange(int, int) []line.Line
LineAt(int) (line.Line, error)
Size() int
}
// MemoryBuffer is an implementation of Buffer
type MemoryBuffer struct {
done chan struct{}
lines []line.Line
mutex sync.RWMutex
PeriodicFunc func()
}
type ActionMap interface {
ExecuteAction(context.Context, *Peco, termbox.Event) error
}
type Input struct {
actions ActionMap
evsrc chan termbox.Event
mod *time.Timer
mutex sync.Mutex
state *Peco
}
// MessageHub is the interface that must be satisfied by the
// message hub component. Unless we're in testing, github.com/peco/peco/hub.Hub
// is used.
type MessageHub interface {
Batch(func(), bool)
DrawCh() chan hub.Payload
PagingCh() chan hub.Payload
QueryCh() chan hub.Payload
SendDraw(interface{})
SendDrawPrompt()
SendPaging(interface{})
SendQuery(string)
SendStatusMsg(string)
SendStatusMsgAndClear(string, time.Duration)
StatusMsgCh() chan hub.Payload
}
type filterProcessor struct {
filter filter.Filter
query string
}

View 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
View 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
}

View 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)
}

View file

@ -6,23 +6,25 @@ import (
"github.com/peco/peco/line"
)
const filterBufSize = 1000
const DefaultFilterBufSize = 1000
var lineListPool = sync.Pool{
New: func() interface{} {
return make([]line.Line, 0, filterBufSize)
New: func() any {
return make([]line.Line, 0, DefaultFilterBufSize)
},
}
// ReleaseLineListBuf returns a line list buffer to the sync.Pool for reuse.
func ReleaseLineListBuf(l []line.Line) {
if l == nil {
return
}
l = l[0:0]
lineListPool.Put(l)
lineListPool.Put(l) //nolint:staticcheck // SA6002: converting to pointer-based pool breaks tests
}
// GetLineListBuf retrieves a line list buffer from the sync.Pool, allocating if needed.
func GetLineListBuf() []line.Line {
l := lineListPool.Get().([]line.Line)
l, _ := lineListPool.Get().([]line.Line)
return l
}

View 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)
}
}

View file

@ -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 {

View file

@ -3,32 +3,23 @@ package keyseq
import (
"testing"
"github.com/nsf/termbox-go"
"github.com/stretchr/testify/require"
)
func checkNode(t *testing.T, node Node, size int, data nodeData) {
if node == nil {
t.Error("Nil node:", data)
}
if node.Size() != size {
t.Errorf("Unexpected childrens: %d != %d", node.Size(), size)
}
require.NotNil(t, node, "Nil node:", data)
require.Equal(t, size, node.Size(), "Unexpected childrens")
d := node.Value().(*nodeData)
if d == nil {
t.Error("Nil data:", data, node)
require.NotNil(t, d, "Nil data:", data, node)
if data.pattern != nil {
require.True(t, d.pattern.Equals(*data.pattern), "Pattern unmatched:", data, node, *d.pattern)
}
if data.pattern != nil && !d.pattern.Equals(*data.pattern) {
t.Error("Pattern unmatched:", data, node, *d.pattern)
}
if data.value != nil && d.value != data.value {
t.Error("Value unmatched:", data, node, d.value)
}
if d.failure == nil {
t.Error("Nil failure:", data, node)
} else if d.failure != data.failure {
t.Errorf("Failure unmatched: data=%+v node=%+v d.failure=%+v",
data, node, d.failure)
if data.value != nil {
require.Equal(t, data.value, d.value, "Value unmatched:", data, node, d.value)
}
require.NotNil(t, d.failure, "Nil failure:", data, node)
require.Equal(t, data.failure, d.failure, "Failure unmatched: data=%+v node=%+v d.failure=%+v",
data, node, d.failure)
}
func invalidData(failure Node) nodeData {
@ -37,7 +28,7 @@ func invalidData(failure Node) nodeData {
}
}
func validData(pattern KeyList, value interface{}, failure Node) nodeData {
func validData(pattern KeyList, value any, failure Node) nodeData {
return nodeData{
pattern: &pattern,
value: value,
@ -47,11 +38,11 @@ func validData(pattern KeyList, value interface{}, failure Node) nodeData {
func newTestMatcher() *Matcher {
m := NewMatcher()
m.Add(KeyList{Key{0, termbox.KeyCtrlA, rune(0)}, Key{0, termbox.KeyCtrlB, rune(0)}}, 2)
m.Add(KeyList{Key{0, termbox.KeyCtrlB, rune(0)}, Key{0, termbox.KeyCtrlC, rune(0)}}, 4)
m.Add(KeyList{Key{0, termbox.KeyCtrlB, rune(0)}, Key{0, termbox.KeyCtrlA, rune(0)}, Key{0, termbox.KeyCtrlB, rune(0)}}, 6)
m.Add(KeyList{Key{0, termbox.KeyCtrlD, rune(0)}}, 7)
m.Add(KeyList{Key{0, termbox.KeyCtrlA, rune(0)}, Key{0, termbox.KeyCtrlB, rune(0)}, Key{0, termbox.KeyCtrlC, rune(0)}, Key{0, termbox.KeyCtrlD, rune(0)}, Key{0, termbox.KeyCtrlE, rune(0)}}, 10)
m.Add(KeyList{Key{0, KeyCtrlA, rune(0)}, Key{0, KeyCtrlB, rune(0)}}, 2)
m.Add(KeyList{Key{0, KeyCtrlB, rune(0)}, Key{0, KeyCtrlC, rune(0)}}, 4)
m.Add(KeyList{Key{0, KeyCtrlB, rune(0)}, Key{0, KeyCtrlA, rune(0)}, Key{0, KeyCtrlB, rune(0)}}, 6)
m.Add(KeyList{Key{0, KeyCtrlD, rune(0)}}, 7)
m.Add(KeyList{Key{0, KeyCtrlA, rune(0)}, Key{0, KeyCtrlB, rune(0)}, Key{0, KeyCtrlC, rune(0)}, Key{0, KeyCtrlD, rune(0)}, Key{0, KeyCtrlE, rune(0)}}, 10)
m.Compile()
return m
}
@ -61,52 +52,24 @@ func TestTree(t *testing.T) {
// Check tree structure.
r := m.Root()
checkNode(t, r, 3, invalidData(r))
n1 := r.Get(NewKeyFromKey(termbox.KeyCtrlA))
n1 := r.Get(NewKeyFromKey(KeyCtrlA))
checkNode(t, n1, 1, invalidData(r))
n3 := r.Get(NewKeyFromKey(termbox.KeyCtrlB))
n3 := r.Get(NewKeyFromKey(KeyCtrlB))
checkNode(t, n3, 2, invalidData(r))
n7 := r.Get(NewKeyFromKey(termbox.KeyCtrlD))
n7 := r.Get(NewKeyFromKey(KeyCtrlD))
checkNode(t, n7, 0, invalidData(r))
n2 := n1.Get(NewKeyFromKey(termbox.KeyCtrlB))
checkNode(t, n2, 1, validData(KeyList{NewKeyFromKey(termbox.KeyCtrlA), NewKeyFromKey(termbox.KeyCtrlB)}, 2, n3))
n4 := n3.Get(NewKeyFromKey(termbox.KeyCtrlC))
checkNode(t, n4, 0, validData(KeyList{NewKeyFromKey(termbox.KeyCtrlB), NewKeyFromKey(termbox.KeyCtrlC)}, 4, r))
n5 := n3.Get(NewKeyFromKey(termbox.KeyCtrlA))
n2 := n1.Get(NewKeyFromKey(KeyCtrlB))
checkNode(t, n2, 1, validData(KeyList{NewKeyFromKey(KeyCtrlA), NewKeyFromKey(KeyCtrlB)}, 2, n3))
n4 := n3.Get(NewKeyFromKey(KeyCtrlC))
checkNode(t, n4, 0, validData(KeyList{NewKeyFromKey(KeyCtrlB), NewKeyFromKey(KeyCtrlC)}, 4, r))
n5 := n3.Get(NewKeyFromKey(KeyCtrlA))
checkNode(t, n5, 1, invalidData(n1))
n8 := n2.Get(NewKeyFromKey(termbox.KeyCtrlC))
n8 := n2.Get(NewKeyFromKey(KeyCtrlC))
checkNode(t, n8, 1, invalidData(n4))
n6 := n5.Get(NewKeyFromKey(termbox.KeyCtrlB))
checkNode(t, n6, 0, validData(KeyList{NewKeyFromKey(termbox.KeyCtrlB), NewKeyFromKey(termbox.KeyCtrlA), NewKeyFromKey(termbox.KeyCtrlB)}, 6, n2))
n9 := n8.Get(NewKeyFromKey(termbox.KeyCtrlD))
n6 := n5.Get(NewKeyFromKey(KeyCtrlB))
checkNode(t, n6, 0, validData(KeyList{NewKeyFromKey(KeyCtrlB), NewKeyFromKey(KeyCtrlA), NewKeyFromKey(KeyCtrlB)}, 6, n2))
n9 := n8.Get(NewKeyFromKey(KeyCtrlD))
checkNode(t, n9, 1, invalidData(n7))
n10 := n9.Get(NewKeyFromKey(termbox.KeyCtrlE))
checkNode(t, n10, 0, validData(KeyList{NewKeyFromKey(termbox.KeyCtrlA), NewKeyFromKey(termbox.KeyCtrlB), NewKeyFromKey(termbox.KeyCtrlC), NewKeyFromKey(termbox.KeyCtrlD), NewKeyFromKey(termbox.KeyCtrlE)}, 10, r))
}
func assertMatches(t *testing.T, exp, act []Match) {
if len(act) != len(exp) {
t.Errorf("[]Match length is not %d (%d)", len(exp), len(act))
t.Logf(" expected: %v", exp)
t.Logf(" actually: %v", act)
}
for i, e := range exp {
dump := false
a := act[i]
if a.Index != e.Index {
t.Errorf("Index not matched at #%d\n", i)
dump = true
}
if !a.Pattern.Equals(e.Pattern) {
t.Errorf("Pattern not matched at #%d\n", i)
dump = true
}
if a.Value != e.Value {
t.Errorf("Value not matched at #%d\n", i)
dump = true
}
if dump {
t.Logf(" expected: %+v", e)
t.Logf(" actually: %+v", a)
}
}
n10 := n9.Get(NewKeyFromKey(KeyCtrlE))
checkNode(t, n10, 0, validData(KeyList{NewKeyFromKey(KeyCtrlA), NewKeyFromKey(KeyCtrlB), NewKeyFromKey(KeyCtrlC), NewKeyFromKey(KeyCtrlD), NewKeyFromKey(KeyCtrlE)}, 10, r))
}

View file

@ -4,25 +4,115 @@ import (
"fmt"
"strings"
"unicode/utf8"
"github.com/nsf/termbox-go"
"github.com/pkg/errors"
)
// This map is populated using some magic numbers, which must match
// the values defined in termbox-go. Verification against the actual
// termbox constants are done in the test
var stringToKey = map[string]termbox.Key{}
var keyToString = map[termbox.Key]string{}
// KeyType represents a keyboard key. The adapter layer in screen.go maps
// between peco's KeyType and tcell's key constants.
type KeyType uint16
func mapkey(n string, k termbox.Key) {
// Function keys
const (
KeyF1 KeyType = 0xFFFF - iota
KeyF2
KeyF3
KeyF4
KeyF5
KeyF6
KeyF7
KeyF8
KeyF9
KeyF10
KeyF11
KeyF12
KeyInsert
KeyDelete
KeyHome
KeyEnd
KeyPgup
KeyPgdn
KeyArrowUp
KeyArrowDown
KeyArrowLeft
KeyArrowRight
)
// Mouse keys.
// There is an intentional gap between KeyArrowRight and MouseLeft
// (MouseLeft = KeyArrowRight - 2, not -1) for historical reasons.
const (
MouseLeft KeyType = 0xFFFF - iota - 23 // KeyArrowRight - 2
MouseMiddle // KeyArrowRight - 3
MouseRight // KeyArrowRight - 4
)
// Ctrl keys and special keys.
// These correspond to ASCII control codes.
const (
KeyCtrlTilde KeyType = 0x00
KeyCtrl2 KeyType = 0x00
KeyCtrlSpace KeyType = 0x00
KeyCtrlA KeyType = 0x01
KeyCtrlB KeyType = 0x02
KeyCtrlC KeyType = 0x03
KeyCtrlD KeyType = 0x04
KeyCtrlE KeyType = 0x05
KeyCtrlF KeyType = 0x06
KeyCtrlG KeyType = 0x07
KeyCtrlH KeyType = 0x08
KeyCtrlI KeyType = 0x09
KeyCtrlJ KeyType = 0x0A
KeyCtrlK KeyType = 0x0B
KeyCtrlL KeyType = 0x0C
KeyCtrlM KeyType = 0x0D
KeyCtrlN KeyType = 0x0E
KeyCtrlO KeyType = 0x0F
KeyCtrlP KeyType = 0x10
KeyCtrlQ KeyType = 0x11
KeyCtrlR KeyType = 0x12
KeyCtrlS KeyType = 0x13
KeyCtrlT KeyType = 0x14
KeyCtrlU KeyType = 0x15
KeyCtrlV KeyType = 0x16
KeyCtrlW KeyType = 0x17
KeyCtrlX KeyType = 0x18
KeyCtrlY KeyType = 0x19
KeyCtrlZ KeyType = 0x1A
KeyCtrlLsqBracket KeyType = 0x1B
KeyCtrl3 KeyType = 0x1B
KeyEsc KeyType = 0x1B
KeyCtrl4 KeyType = 0x1C
KeyCtrlBackslash KeyType = 0x1C
KeyCtrl5 KeyType = 0x1D
KeyCtrlRsqBracket KeyType = 0x1D
KeyCtrl6 KeyType = 0x1E
KeyCtrl7 KeyType = 0x1F
KeyCtrlSlash KeyType = 0x1F
KeyCtrlUnderscore KeyType = 0x1F
KeySpace KeyType = 0x20
KeyBackspace2 KeyType = 0x7F
KeyCtrl8 KeyType = 0x7F
)
// Aliases
const (
KeyBackspace KeyType = 0x08 // = KeyCtrlH
KeyTab KeyType = 0x09 // = KeyCtrlI
KeyEnter KeyType = 0x0D // = KeyCtrlM
)
// This map is populated in init() below.
var stringToKey = map[string]KeyType{}
var keyToString = map[KeyType]string{}
// mapkey registers a bidirectional mapping between a key name string and its KeyType constant.
func mapkey(n string, k KeyType) {
stringToKey[n] = k
keyToString[k] = n
}
func init() {
fidx := 1
for k := termbox.KeyF1; k >= termbox.KeyF12; k-- {
for k := KeyF1; k >= KeyF12; k-- {
sk := fmt.Sprintf("F%d", fidx)
mapkey(sk, k)
fidx++
@ -41,7 +131,7 @@ func init() {
"ArrowRight",
}
for i, n := range names {
mapkey(n, termbox.Key(int(termbox.KeyF12)-(i+1)))
mapkey(n, KeyType(int(KeyF12)-(i+1)))
}
names = []string{
@ -51,10 +141,10 @@ func init() {
}
for i, n := range names {
sk := fmt.Sprintf("Mouse%s", n)
mapkey(sk, termbox.Key(int(termbox.KeyArrowRight)-(i+2)))
mapkey(sk, KeyType(int(KeyArrowRight)-(i+2)))
}
whacky := [][]string{
ctrlKeyNames := [][]string{
{"~", "2", "Space"},
{"a"},
{"b"},
@ -88,32 +178,31 @@ func init() {
{"6"},
{"7", "/", "_"},
}
for i, list := range whacky {
for i, list := range ctrlKeyNames {
for _, n := range list {
sk := fmt.Sprintf("C-%s", n)
mapkey(sk, termbox.Key(int(termbox.KeyCtrlTilde)+i))
mapkey(sk, KeyType(int(KeyCtrlTilde)+i))
}
}
mapkey("BS", termbox.KeyBackspace)
mapkey("Tab", termbox.KeyTab)
mapkey("Enter", termbox.KeyEnter)
mapkey("Esc", termbox.KeyEsc)
mapkey("Space", termbox.KeySpace)
mapkey("BS2", termbox.KeyBackspace2)
mapkey("C-8", termbox.KeyCtrl8)
// panic(fmt.Sprintf("%#q", stringToKey))
mapkey("BS", KeyBackspace)
mapkey("Tab", KeyTab)
mapkey("Enter", KeyEnter)
mapkey("Esc", KeyEsc)
mapkey("Space", KeySpace)
mapkey("BS2", KeyBackspace2)
mapkey("C-8", KeyCtrl8)
}
// ToKeyList parses a comma-separated key binding string (e.g. "C-x,C-c") into a list of KeySeq values.
func ToKeyList(ksk string) (KeyList, error) {
list := KeyList{}
for _, term := range strings.Split(ksk, ",") {
for term := range strings.SplitSeq(ksk, ",") {
term = strings.TrimSpace(term)
k, m, ch, err := ToKey(term)
if err != nil {
return list, errors.Wrapf(err, "failed to convert '%s'", term)
return list, fmt.Errorf("failed to convert '%s': %w", term, err)
}
list = append(list, Key{m, k, ch})
@ -121,16 +210,17 @@ func ToKeyList(ksk string) (KeyList, error) {
return list, nil
}
// EventToString returns human readable name for a given termbox.Event
func EventToString(ev termbox.Event) (string, error) {
s := ""
if ev.Key == 0 {
s = string([]rune{ev.Ch})
// KeyEventToString returns a human-readable name for a key event described
// by the given key type, character, and modifier.
func KeyEventToString(key KeyType, ch rune, mod ModifierKey) (string, error) {
var s string
if key == 0 {
s = string([]rune{ch})
} else {
var ok bool
s, ok = keyToString[ev.Key]
s, ok = keyToString[key]
if !ok {
return "", errors.Errorf("no such key %#v", ev)
return "", fmt.Errorf("no such key %d (ch=%c)", key, ch)
}
// Special case for ArrowUp/Down/Left/Right
@ -146,34 +236,58 @@ func EventToString(ev termbox.Event) (string, error) {
}
}
if ev.Mod&termbox.ModAlt == 1 {
return "M-" + s, nil
if m := mod.String(); m != "" {
return m + "-" + s, nil
}
return s, nil
}
func ToKey(key string) (k termbox.Key, modifier ModifierKey, ch rune, err error) {
modifier = ModNone
if strings.HasPrefix(key, "M-") {
modifier = ModAlt
key = key[2:]
// ToKey parses a single key name string into its KeyType, modifier, and rune components.
func ToKey(key string) (KeyType, ModifierKey, rune, error) {
modifier := ModNone
// Try full string first. This handles legacy key names like "C-a",
// "C-v", "Home", "ArrowLeft", etc. that are registered in stringToKey.
if k, ok := stringToKey[key]; ok {
return k, modifier, 0, nil
}
// Parse modifier prefixes (C-, S-, M-) iteratively.
// After each prefix is stripped, try the remainder as a key name.
for {
switch {
case strings.HasPrefix(key, "C-"):
modifier |= ModCtrl
key = key[2:]
case strings.HasPrefix(key, "S-"):
modifier |= ModShift
key = key[2:]
case strings.HasPrefix(key, "M-"):
modifier |= ModAlt
key = key[2:]
default:
goto done
}
// After stripping a prefix, try as a registered key name.
// This handles e.g. "M-C-v" → strip M-, then "C-v" is found.
if k, ok := stringToKey[key]; ok {
return k, modifier, 0, nil
}
// Single ASCII char after modifier(s) → treat as rune
if len(key) == 1 {
ch = rune(key[0])
return
return 0, modifier, rune(key[0]), nil
}
}
var ok bool
k, ok = stringToKey[key]
if !ok {
// If this is a single rune, just allow it
ch, _ = utf8.DecodeRuneInString(key)
if ch != utf8.RuneError {
return
}
err = errors.Errorf("no such key %s", key)
done:
// Try as a single rune (handles multi-byte chars like "せ")
ch, _ := utf8.DecodeRuneInString(key)
if ch != utf8.RuneError {
return 0, modifier, ch, nil
}
return
return 0, modifier, 0, fmt.Errorf("no such key %s", key)
}

View file

@ -4,127 +4,113 @@ import (
"testing"
"unicode/utf8"
"github.com/nsf/termbox-go"
"github.com/stretchr/testify/require"
)
func TestKeymapStrToKeyValue(t *testing.T) {
expected := map[string]termbox.Key{
"F1": termbox.KeyF1,
"F2": termbox.KeyF2,
"F3": termbox.KeyF3,
"F4": termbox.KeyF4,
"F5": termbox.KeyF5,
"F6": termbox.KeyF6,
"F7": termbox.KeyF7,
"F8": termbox.KeyF8,
"F9": termbox.KeyF9,
"F10": termbox.KeyF10,
"F11": termbox.KeyF11,
"F12": termbox.KeyF12,
"Insert": termbox.KeyInsert,
"Delete": termbox.KeyDelete,
"Home": termbox.KeyHome,
"End": termbox.KeyEnd,
"Pgup": termbox.KeyPgup,
"Pgdn": termbox.KeyPgdn,
"ArrowUp": termbox.KeyArrowUp,
"ArrowDown": termbox.KeyArrowDown,
"ArrowLeft": termbox.KeyArrowLeft,
"ArrowRight": termbox.KeyArrowRight,
"MouseLeft": termbox.MouseLeft,
"MouseRight": termbox.MouseRight,
"MouseMiddle": termbox.MouseMiddle,
"BS": termbox.KeyBackspace,
"Tab": termbox.KeyTab,
"Enter": termbox.KeyEnter,
"Esc": termbox.KeyEsc,
"Space": termbox.KeySpace,
"BS2": termbox.KeyBackspace2,
"C-8": termbox.KeyCtrl8,
"C-~": termbox.KeyCtrlTilde,
"C-2": termbox.KeyCtrl2,
"C-Space": termbox.KeyCtrlSpace,
"C-a": termbox.KeyCtrlA,
"C-b": termbox.KeyCtrlB,
"C-c": termbox.KeyCtrlC,
"C-d": termbox.KeyCtrlD,
"C-e": termbox.KeyCtrlE,
"C-f": termbox.KeyCtrlF,
"C-g": termbox.KeyCtrlG,
"C-h": termbox.KeyCtrlH,
"C-i": termbox.KeyCtrlI,
"C-j": termbox.KeyCtrlJ,
"C-k": termbox.KeyCtrlK,
"C-l": termbox.KeyCtrlL,
"C-m": termbox.KeyCtrlM,
"C-n": termbox.KeyCtrlN,
"C-o": termbox.KeyCtrlO,
"C-p": termbox.KeyCtrlP,
"C-q": termbox.KeyCtrlQ,
"C-r": termbox.KeyCtrlR,
"C-s": termbox.KeyCtrlS,
"C-t": termbox.KeyCtrlT,
"C-u": termbox.KeyCtrlU,
"C-v": termbox.KeyCtrlV,
"C-w": termbox.KeyCtrlW,
"C-x": termbox.KeyCtrlX,
"C-y": termbox.KeyCtrlY,
"C-z": termbox.KeyCtrlZ,
"C-[": termbox.KeyCtrlLsqBracket,
"C-3": termbox.KeyCtrl3,
"C-4": termbox.KeyCtrl4,
"C-\\": termbox.KeyCtrlBackslash,
"C-5": termbox.KeyCtrl5,
"C-]": termbox.KeyCtrlRsqBracket,
"C-6": termbox.KeyCtrl6,
"C-7": termbox.KeyCtrl7,
"C-/": termbox.KeyCtrlSlash,
"C-_": termbox.KeyCtrlUnderscore,
expected := map[string]KeyType{
"F1": KeyF1,
"F2": KeyF2,
"F3": KeyF3,
"F4": KeyF4,
"F5": KeyF5,
"F6": KeyF6,
"F7": KeyF7,
"F8": KeyF8,
"F9": KeyF9,
"F10": KeyF10,
"F11": KeyF11,
"F12": KeyF12,
"Insert": KeyInsert,
"Delete": KeyDelete,
"Home": KeyHome,
"End": KeyEnd,
"Pgup": KeyPgup,
"Pgdn": KeyPgdn,
"ArrowUp": KeyArrowUp,
"ArrowDown": KeyArrowDown,
"ArrowLeft": KeyArrowLeft,
"ArrowRight": KeyArrowRight,
"MouseLeft": MouseLeft,
"MouseRight": MouseRight,
"MouseMiddle": MouseMiddle,
"BS": KeyBackspace,
"Tab": KeyTab,
"Enter": KeyEnter,
"Esc": KeyEsc,
"Space": KeySpace,
"BS2": KeyBackspace2,
"C-8": KeyCtrl8,
"C-~": KeyCtrlTilde,
"C-2": KeyCtrl2,
"C-Space": KeyCtrlSpace,
"C-a": KeyCtrlA,
"C-b": KeyCtrlB,
"C-c": KeyCtrlC,
"C-d": KeyCtrlD,
"C-e": KeyCtrlE,
"C-f": KeyCtrlF,
"C-g": KeyCtrlG,
"C-h": KeyCtrlH,
"C-i": KeyCtrlI,
"C-j": KeyCtrlJ,
"C-k": KeyCtrlK,
"C-l": KeyCtrlL,
"C-m": KeyCtrlM,
"C-n": KeyCtrlN,
"C-o": KeyCtrlO,
"C-p": KeyCtrlP,
"C-q": KeyCtrlQ,
"C-r": KeyCtrlR,
"C-s": KeyCtrlS,
"C-t": KeyCtrlT,
"C-u": KeyCtrlU,
"C-v": KeyCtrlV,
"C-w": KeyCtrlW,
"C-x": KeyCtrlX,
"C-y": KeyCtrlY,
"C-z": KeyCtrlZ,
"C-[": KeyCtrlLsqBracket,
"C-3": KeyCtrl3,
"C-4": KeyCtrl4,
"C-\\": KeyCtrlBackslash,
"C-5": KeyCtrl5,
"C-]": KeyCtrlRsqBracket,
"C-6": KeyCtrl6,
"C-7": KeyCtrl7,
"C-/": KeyCtrlSlash,
"C-_": KeyCtrlUnderscore,
}
t.Logf("Checking key name -> actual key value mapping...")
for n, v := range expected {
t.Logf(" checking %s...", n)
e, modifier, _, err := ToKey(n)
if err != nil {
t.Errorf("Key name %s not found", n)
}
if e != v {
t.Errorf("Expected '%s' to be '%d', but got '%d'", n, v, stringToKey[n])
}
if modifier != 0 {
t.Errorf("Key name '%s' is not Alt-prefixed", n)
}
require.NoError(t, err, "Key name %s not found", n)
require.Equal(t, v, e, "Expected '%s' to be '%d', but got '%d'", n, v, stringToKey[n])
require.Equal(t, ModifierKey(0), modifier, "Key name '%s' is not Alt-prefixed", n)
}
}
func TestKeymapStrToKeyValueWithAlt(t *testing.T) {
expected := map[string]struct {
key termbox.Key
key KeyType
ch rune
}{
"M-v": {0, 'v'},
"M-C-v": {termbox.KeyCtrlV, rune(0)},
"M-Space": {termbox.KeySpace, rune(0)},
"M-MouseLeft": {termbox.MouseLeft, rune(0)},
"M-C-v": {KeyCtrlV, rune(0)},
"M-Space": {KeySpace, rune(0)},
"M-MouseLeft": {MouseLeft, rune(0)},
}
t.Logf("Checking Alt prefixed key name mapping...")
for n, v := range expected {
t.Logf(" checking %s...", n)
k, modifier, ch, err := ToKey(n)
if err != nil {
t.Errorf("Failed ToKey: Key name %s", n)
}
if modifier != 1 {
t.Errorf("Key name %s has Alt prefix", n)
}
if k != v.key {
t.Errorf("Expected '%s' to be '%d', but got '%d'", n, v.key, k)
}
if ch != v.ch {
t.Errorf("Expected '%s' to be '%c', but got '%c'", n, v.ch, ch)
}
require.NoError(t, err, "Failed ToKey: Key name %s", n)
require.Equal(t, ModifierKey(1), modifier, "Key name %s has Alt prefix", n)
require.Equal(t, v.key, k, "Expected '%s' to be '%d', but got '%d'", n, v.key, k)
require.Equal(t, v.ch, ch, "Expected '%s' to be '%c', but got '%c'", n, v.ch, ch)
}
}
@ -138,19 +124,131 @@ func TestKeymapStrToKeyValueCh(t *testing.T) {
for _, n := range expected {
t.Logf(" checking %s...", n)
k, modifier, ch, err := ToKey(n)
if err != nil {
t.Errorf("Failed ToKey: Key name %s", n)
}
if k != 0 {
t.Errorf("Key name %s is mapped key", n)
}
if modifier == 1 {
t.Errorf("Key name %s has Alt prefix", n)
}
require.NoError(t, err, "Failed ToKey: Key name %s", n)
require.Equal(t, KeyType(0), k, "Key name %s is mapped key", n)
require.NotEqual(t, ModifierKey(1), modifier, "Key name %s has Alt prefix", n)
r, _ := utf8.DecodeRuneInString(n)
if ch != r {
t.Errorf("key name %s cannot convert to rune", n)
}
require.Equal(t, r, ch, "key name %s cannot convert to rune", n)
}
}
func TestKeymapStrToKeyValueWithCtrl(t *testing.T) {
tests := []struct {
name string
key KeyType
}{
{"C-ArrowLeft", KeyArrowLeft},
{"C-ArrowRight", KeyArrowRight},
{"C-ArrowUp", KeyArrowUp},
{"C-ArrowDown", KeyArrowDown},
{"C-Home", KeyHome},
{"C-End", KeyEnd},
{"C-Delete", KeyDelete},
{"C-Insert", KeyInsert},
{"C-Pgup", KeyPgup},
{"C-Pgdn", KeyPgdn},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
k, modifier, ch, err := ToKey(tc.name)
require.NoError(t, err)
require.Equal(t, tc.key, k)
require.Equal(t, ModCtrl, modifier)
require.Equal(t, rune(0), ch)
})
}
}
func TestKeymapStrToKeyValueWithShift(t *testing.T) {
tests := []struct {
name string
key KeyType
}{
{"S-ArrowUp", KeyArrowUp},
{"S-ArrowDown", KeyArrowDown},
{"S-ArrowLeft", KeyArrowLeft},
{"S-ArrowRight", KeyArrowRight},
{"S-Home", KeyHome},
{"S-End", KeyEnd},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
k, modifier, ch, err := ToKey(tc.name)
require.NoError(t, err)
require.Equal(t, tc.key, k)
require.Equal(t, ModShift, modifier)
require.Equal(t, rune(0), ch)
})
}
}
func TestKeymapStrToKeyValueWithCombinedModifiers(t *testing.T) {
tests := []struct {
name string
key KeyType
modifier ModifierKey
}{
{"C-M-ArrowLeft", KeyArrowLeft, ModCtrl | ModAlt},
{"M-C-ArrowLeft", KeyArrowLeft, ModCtrl | ModAlt},
{"C-S-Delete", KeyDelete, ModCtrl | ModShift},
{"C-S-M-Home", KeyHome, ModCtrl | ModShift | ModAlt},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
k, modifier, ch, err := ToKey(tc.name)
require.NoError(t, err)
require.Equal(t, tc.key, k)
require.Equal(t, tc.modifier, modifier)
require.Equal(t, rune(0), ch)
})
}
}
func TestModifierKeyString(t *testing.T) {
tests := []struct {
mod ModifierKey
expected string
}{
{ModNone, ""},
{ModAlt, "M"},
{ModCtrl, "C"},
{ModShift, "S"},
{ModCtrl | ModAlt, "C-M"},
{ModCtrl | ModShift, "C-S"},
{ModShift | ModAlt, "S-M"},
{ModCtrl | ModShift | ModAlt, "C-S-M"},
}
for _, tc := range tests {
t.Run(tc.expected, func(t *testing.T) {
require.Equal(t, tc.expected, tc.mod.String())
})
}
}
func TestKeyEventToStringWithModifiers(t *testing.T) {
tests := []struct {
name string
key KeyType
ch rune
mod ModifierKey
expected string
}{
{"Ctrl+Left", KeyArrowLeft, 0, ModCtrl, "C-<"},
{"Shift+Right", KeyArrowRight, 0, ModShift, "S->"},
{"Ctrl+Alt+Delete", KeyDelete, 0, ModCtrl | ModAlt, "C-M-Delete"},
{"Alt+char", 0, 'v', ModAlt, "M-v"},
{"no modifier", KeyHome, 0, ModNone, "Home"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s, err := KeyEventToString(tc.key, tc.ch, tc.mod)
require.NoError(t, err)
require.Equal(t, tc.expected, s)
})
}
}

View file

@ -1,12 +1,9 @@
package keyseq
import (
"errors"
"strings"
"sync"
"time"
"github.com/nsf/termbox-go"
"github.com/pkg/errors"
)
var ErrInSequence = errors.New("expected a key sequence")
@ -15,35 +12,44 @@ var ErrNoMatch = errors.New("could not match key to any action")
type ModifierKey int
const (
ModNone ModifierKey = iota
ModAlt
ModMax
ModNone ModifierKey = 0
ModAlt ModifierKey = 1 << 0 // 0x01
ModCtrl ModifierKey = 1 << 1 // 0x02
ModShift ModifierKey = 1 << 2 // 0x04
)
// Key is data in one trie node in the KeySequence
type Key struct {
Modifier ModifierKey // Alt, etc
Key termbox.Key
Key KeyType
Ch rune
}
// String returns a comma-separated string representation of the key list.
func (kl KeyList) String() string {
list := make([]string, len(kl))
for i := 0; i < len(kl); i++ {
for i := range kl {
list[i] = kl[i].String()
}
return strings.Join(list, ",")
}
// String returns the modifier key as a dash-separated string (e.g. "C-S-M").
func (m ModifierKey) String() string {
switch m {
case ModAlt:
return "M"
default:
return ""
var parts []string
if m&ModCtrl != 0 {
parts = append(parts, "C")
}
if m&ModShift != 0 {
parts = append(parts, "S")
}
if m&ModAlt != 0 {
parts = append(parts, "M")
}
return strings.Join(parts, "-")
}
// String returns a human-readable representation of the key, including any modifiers.
func (k Key) String() string {
var s string
if m := k.Modifier.String(); m != "" {
@ -59,7 +65,8 @@ func (k Key) String() string {
return s
}
func NewKeyFromKey(k termbox.Key) Key {
// NewKeyFromKey creates a Key from a KeyType with no modifier and no rune.
func NewKeyFromKey(k KeyType) Key {
return Key{
Modifier: 0,
Key: k,
@ -70,6 +77,7 @@ func NewKeyFromKey(k termbox.Key) Key {
// KeyList is just the list of keys
type KeyList []Key
// Compare returns -1, 0, or 1 comparing k and x by modifier, key type, and character.
func (k Key) Compare(x Key) int {
if k.Modifier < x.Modifier {
return -1
@ -92,13 +100,14 @@ func (k Key) Compare(x Key) int {
return 0
}
func (k KeyList) Equals(x KeyList) bool {
if len(k) != len(x) {
// Equals reports whether kl and x contain the same keys in the same order.
func (kl KeyList) Equals(x KeyList) bool {
if len(kl) != len(x) {
return false
}
for i := 0; i < len(k); i++ {
if k[i].Compare(x[i]) != 0 {
for i := range kl {
if kl[i].Compare(x[i]) != 0 {
return false
}
}
@ -112,11 +121,11 @@ type keyseqMatcher interface {
type Keyseq struct {
*Matcher
current keyseqMatcher
mutex sync.Mutex
prevInputTime time.Time
current keyseqMatcher
mutex sync.Mutex
}
// New creates a new Keyseq matcher for resolving multi-key bindings.
func New() *Keyseq {
return &Keyseq{
Matcher: NewMatcher(),
@ -124,10 +133,12 @@ func New() *Keyseq {
}
}
// InMiddleOfChain reports whether the matcher is partway through a multi-key sequence.
func (k *Keyseq) InMiddleOfChain() bool {
return k.current != nil && k.current != k.Matcher
}
// CancelChain resets the matcher to the root, abandoning any in-progress key sequence.
func (k *Keyseq) CancelChain() {
k.mutex.Lock()
defer k.mutex.Unlock()
@ -146,15 +157,12 @@ func (k *Keyseq) Current() keyseqMatcher {
return k.current
}
func (k *Keyseq) updateInputTime() {
k.prevInputTime = time.Now()
}
func (k *Keyseq) AcceptKey(key Key) (interface{}, error) {
// AcceptKey advances the key sequence matcher with the given key, returning the bound action
// if a complete sequence is matched, or ErrInSequence if more keys are expected.
func (k *Keyseq) AcceptKey(key Key) (any, error) {
// XXX should we return Action instead of interface{}?
k.mutex.Lock()
defer k.mutex.Unlock()
defer k.updateInputTime()
c := k.Current()
n := c.Get(key)
@ -175,7 +183,7 @@ func (k *Keyseq) AcceptKey(key Key) (interface{}, error) {
return nil, ErrInSequence
}
// If it got here, we should just rest the matcher, and return
// If it got here, we should just reset the matcher, and return
// whatever we matched
k.setCurrent(k.Matcher)
@ -185,5 +193,6 @@ func (k *Keyseq) AcceptKey(key Key) (interface{}, error) {
if data == nil {
return nil, ErrNoMatch
}
return data.(*nodeData).Value(), nil
nd, _ := data.(*nodeData)
return nd.Value(), nil
}

View file

@ -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
}

View file

@ -1,70 +1,51 @@
package keyseq
import "testing"
import (
"testing"
func assertNilBoth(t *testing.T, n *TernaryNode) {
if n.low != nil {
t.Errorf("low node has value: %v", &n.low)
}
if n.high != nil {
t.Errorf("high node has value: %v", &n.high)
}
}
"github.com/stretchr/testify/require"
)
func TestBalance(t *testing.T) {
trie := NewTernaryTrie()
list := []Key{}
for i := 0; i < 15; i++ {
list := make([]Key, 0, 15)
for i := range 15 {
list = append(list, Key{0, 0, rune(i)})
}
for i, k := range list {
trie.Put(KeyList{k}, i)
}
if s := trie.Size(); s != 15 {
t.Fatalf("Size() returns not 15: %d", s)
}
require.Equal(t, 15, trie.Size())
trie.Balance()
/*
n8 := trie.Root().(*TernaryNode).firstChild
checkTrieNode(t, n8, '8', 7)
n4 := n8.low
checkTrieNode(t, n4, '4', 3)
n12 := n8.high
checkTrieNode(t, n12, 'C', 11)
n2 := n4.low
checkTrieNode(t, n2, '2', 1)
n6 := n4.high
checkTrieNode(t, n6, '6', 5)
n10 := n12.low
checkTrieNode(t, n10, 'A', 9)
n14 := n12.high
checkTrieNode(t, n14, 'E', 13)
n1 := n2.low
checkTrieNode(t, n1, '1', 0)
n3 := n2.high
checkTrieNode(t, n3, '3', 2)
n5 := n6.low
checkTrieNode(t, n5, '5', 4)
n7 := n6.high
checkTrieNode(t, n7, '7', 6)
n9 := n10.low
checkTrieNode(t, n9, '9', 8)
n11 := n10.high
checkTrieNode(t, n11, 'B', 10)
n13 := n14.low
checkTrieNode(t, n13, 'D', 12)
n15 := n14.high
checkTrieNode(t, n15, 'F', 14)
assertNilBoth(t, n1)
assertNilBoth(t, n3)
assertNilBoth(t, n5)
assertNilBoth(t, n7)
assertNilBoth(t, n9)
assertNilBoth(t, n11)
assertNilBoth(t, n13)
assertNilBoth(t, n15)
*/
// After balancing, all keys must still be retrievable with correct values.
for i, k := range list {
node := trie.Get(k)
require.NotNil(t, node, "key %d should be found after Balance", i)
require.Equal(t, i, node.Value(), "value for key %d should be %d", i, i)
}
// Size must be unchanged after balancing.
require.Equal(t, 15, trie.Size())
}
func TestBalancePreservesMultiKeySequences(t *testing.T) {
trie := NewTernaryTrie()
// Insert multi-key sequences
k1 := KeyList{{0, 0, 'a'}, {0, 0, 'b'}}
k2 := KeyList{{0, 0, 'a'}, {0, 0, 'c'}}
k3 := KeyList{{0, 0, 'x'}}
trie.Put(k1, "ab")
trie.Put(k2, "ac")
trie.Put(k3, "x")
trie.Balance()
require.Equal(t, "ab", trie.GetList(k1).Value())
require.Equal(t, "ac", trie.GetList(k2).Value())
require.Equal(t, "x", trie.GetList(k3).Value())
}

View file

@ -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

View file

@ -2,18 +2,14 @@ package keyseq
import (
"testing"
"github.com/stretchr/testify/require"
)
func checkTrieNode(t *testing.T, n Node, k Key, value int) {
if n == nil {
t.Fatal("TrieNode is null")
}
if l := n.Label(); l != k {
t.Errorf("TrieNode.Label() expected:'%c' actual:'%c'", k, l)
}
if v := n.Value().(int); v != value {
t.Errorf("TrieNode.Value() expected:%d actual:%d", value, v)
}
require.NotNil(t, n, "TrieNode is null")
require.Equal(t, k, n.Label(), "TrieNode.Label()")
require.Equal(t, value, n.Value().(int), "TrieNode.Value()")
}
func TestTrie(t *testing.T) {
@ -23,18 +19,14 @@ func TestTrie(t *testing.T) {
}
nodes := Children(trie.Root())
for i := 0; i < 5; i++ {
for i := range 5 {
checkTrieNode(t, nodes[i], Key{0, 0, rune(i + 1)}, 111*(i+1))
}
if s := trie.Size(); s != 5 {
t.Errorf("trie.Size() returns not 5: %d", s)
}
require.Equal(t, 5, trie.Size())
}
func TestNotFound(t *testing.T) {
trie := NewTrie()
if trie.Get(Key{999, 999, 'a'}) != nil {
t.Errorf("found 'not_exist' in empty trie")
}
require.Nil(t, trie.Get(Key{999, 999, 'a'}))
}

View 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)
}
}
})
}

View file

@ -1,4 +1,4 @@
// +build !darwin,!windows
//go:build !darwin && !windows
package util
@ -7,10 +7,11 @@ import (
"os"
)
// Homedir returns the current user's home directory from the HOME environment variable.
func Homedir() (string, error) {
home := os.Getenv("HOME")
if home == "" {
return "", errors.New("error: Environment variable HOME not set")
return "", errors.New("environment variable HOME not set")
}
return home, nil

View file

@ -1,18 +1,22 @@
// +build !windows
//go:build !windows
package util
import "os/exec"
import (
"context"
"os/exec"
)
func Shell(cmd ...string) *exec.Cmd {
// Shell creates an exec.Cmd that runs the given command strings via /bin/sh -c.
func Shell(ctx context.Context, cmd ...string) *exec.Cmd {
const shellpath = `/bin/sh`
const shellopt = `-c`
const shellopt = `-c`
args := make([]string, len(cmd) + 1)
args := make([]string, len(cmd)+1)
args[0] = shellopt
for i := 0; i < len(cmd); i++ {
for i := range cmd {
args[i+1] = cmd[i]
}
return exec.Command(shellpath, args...)
return exec.CommandContext(ctx, shellpath, args...)
}

View file

@ -1,18 +1,21 @@
// +build windows
//go:build windows
package util
import "os/exec"
import (
"context"
"os/exec"
)
func Shell(cmd ...string) *exec.Cmd {
func Shell(ctx context.Context, cmd ...string) *exec.Cmd {
const shellpath = `cmd`
const shellopt = `/c`
const shellopt = `/c`
args := make([]string, len(cmd) + 1)
args := make([]string, len(cmd)+1)
args[0] = shellopt
for i := 0; i < len(cmd); i++ {
for i := range cmd {
args[i+1] = cmd[i]
}
return exec.Command(shellpath, args...)
return exec.CommandContext(ctx, shellpath, args...)
}

View file

@ -1,4 +1,4 @@
// +build darwin freebsd openbsd netbsd dragonfly
//go:build darwin || freebsd || openbsd || netbsd || dragonfly
package util
@ -8,7 +8,7 @@ import (
)
// IsTty checks if the given fd is a tty
func IsTty(arg interface{}) bool {
func IsTty(arg any) bool {
fdsrc, ok := arg.(fder)
if !ok {
return false

View file

@ -1,4 +1,4 @@
// +build !darwin,!windows,!freebsd,!openbsd,!netbsd,!dragonfly
//go:build !darwin && !windows && !freebsd && !openbsd && !netbsd && !dragonfly
package util
@ -8,7 +8,7 @@ import (
)
// IsTty checks if the given fd is a tty
func IsTty(arg interface{}) bool {
func IsTty(arg any) bool {
fdsrc, ok := arg.(fder)
if !ok {
return false

View file

@ -1,11 +1,10 @@
package util
import (
"fmt"
"os"
"syscall"
"unsafe"
"github.com/pkg/errors"
)
var (
@ -22,14 +21,14 @@ func getStdHandle(h int) (fd syscall.Handle) {
func setStdHandle(stdhandle int32, handle syscall.Handle) error {
r1, _, err := procSetStdHandle.Call(uintptr(stdhandle), uintptr(handle))
if r1 == 0 && err != nil {
return errors.Wrap(err, "failed to call SetStdHandle")
if r1 == 0 {
return fmt.Errorf("failed to call SetStdHandle: %w", err)
}
return nil
}
// IsTty checks if the given fd is a tty
func IsTty(arg interface{}) bool {
func IsTty(arg any) bool {
fdsrc, ok := arg.(fder)
if !ok {
return false
@ -37,8 +36,8 @@ func IsTty(arg interface{}) bool {
fd := fdsrc.Fd()
var st uint32
r1, _, err := procGetConsoleMode.Call(fd, uintptr(unsafe.Pointer(&st)))
return r1 != 0 && err != nil
r1, _, _ := procGetConsoleMode.Call(fd, uintptr(unsafe.Pointer(&st)))
return r1 != 0
}
var stdin = os.Stdin
@ -55,7 +54,10 @@ func TtyReady() error {
os.Stdin = _stdin
syscall.Stdin = syscall.Handle(os.Stdin.Fd())
return errors.Wrap(setStdHandle(syscall.STD_INPUT_HANDLE, syscall.Stdin), "failed to check for TtyReady")
if err = setStdHandle(syscall.STD_INPUT_HANDLE, syscall.Stdin); err != nil {
return fmt.Errorf("failed to check for TtyReady: %w", err)
}
return nil
}
// TtyTerm restores any state, if necessary

View file

@ -1,7 +1,9 @@
package util
import (
"errors"
"regexp"
"strings"
"unicode"
)
@ -9,6 +11,7 @@ type fder interface {
Fd() uintptr
}
// CaseInsensitiveIndexFunc returns a function that matches runes equal to r, ignoring case.
func CaseInsensitiveIndexFunc(r rune) func(rune) bool {
lr := unicode.ToUpper(r)
return func(v rune) bool {
@ -16,6 +19,20 @@ func CaseInsensitiveIndexFunc(r rune) func(rune) bool {
}
}
// CaseInsensitiveIndex returns the byte index of the first rune in s that
// is case-insensitively equal to r. Returns -1 if not found. This avoids
// the closure allocation of CaseInsensitiveIndexFunc + strings.IndexFunc.
func CaseInsensitiveIndex(s string, r rune) int {
upper := unicode.ToUpper(r)
for i, c := range s {
if unicode.ToUpper(c) == upper {
return i
}
}
return -1
}
// ContainsUpper reports whether the string contains any uppercase letter.
func ContainsUpper(query string) bool {
for _, c := range query {
if unicode.IsUpper(c) {
@ -28,15 +45,16 @@ func ContainsUpper(query string) bool {
// Global var used to strips ansi sequences
var reANSIEscapeChars = regexp.MustCompile("\x1B\\[(?:[0-9]{1,2}(?:;[0-9]{1,2})?)*[a-zA-Z]")
// Function who strips ansi sequences
// StripANSISequence strips ANSI escape sequences from the given string.
// Fast-path: if the string contains no ESC byte, return it unchanged
// to avoid a regexp allocation.
func StripANSISequence(s string) string {
if !strings.Contains(s, "\x1b") {
return s
}
return reANSIEscapeChars.ReplaceAllString(s, "")
}
type causer interface {
Cause() error
}
type ignorable interface {
Ignorable() bool
}
@ -49,44 +67,32 @@ type exitStatuser interface {
ExitStatus() int
}
// IsIgnorableError checks whether err implements the Ignorable interface and returns true.
func IsIgnorableError(err error) bool {
for e := err; e != nil; {
switch e.(type) {
case ignorable:
return e.(ignorable).Ignorable()
case causer:
e = e.(causer).Cause()
default:
return false
for e := err; e != nil; e = errors.Unwrap(e) {
if v, ok := e.(ignorable); ok {
return v.Ignorable()
}
}
return false
}
// IsCollectResultsError checks whether err signals that results should be collected.
func IsCollectResultsError(err error) bool {
for e := err; e != nil; {
switch e.(type) {
case collectResults:
return e.(collectResults).CollectResults()
case causer:
e = e.(causer).Cause()
default:
return false
for e := err; e != nil; e = errors.Unwrap(e) {
if v, ok := e.(collectResults); ok {
return v.CollectResults()
}
}
return false
}
// GetExitStatus extracts the exit status code from an error, returning 1 and false if not found.
func GetExitStatus(err error) (int, bool) {
for e := err; e != nil; {
for e := err; e != nil; e = errors.Unwrap(e) {
if ese, ok := e.(exitStatuser); ok {
return ese.ExitStatus(), true
}
if cerr, ok := e.(causer); ok {
e = cerr.Cause()
continue
}
break
}
return 1, false
}

173
internal/util/util_test.go Normal file
View 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)
})
}
}

View file

@ -1,55 +1,39 @@
package peco
import (
"fmt"
"io"
"io/ioutil"
"os"
"testing"
"time"
termbox "github.com/nsf/termbox-go"
"github.com/stretchr/testify/assert"
"context"
"github.com/peco/peco/config"
"github.com/peco/peco/filter"
"github.com/peco/peco/internal/keyseq"
"github.com/peco/peco/line"
"github.com/peco/peco/pipeline"
"github.com/stretchr/testify/require"
)
func TestIssue212_SanityCheck(t *testing.T) {
state := newPeco()
ctx, cancel := context.WithCancel(context.Background())
go state.Run(ctx)
defer cancel()
<-state.Ready()
state, ctx := setupPecoTest(t)
// Check if the default layout type is honored */
// This the main issue on 212, but while we're at it, we're just
// going to check that all the default values are as expected
if !assert.Equal(t, state.config.Layout, "top-down", "Default layout type should be 'top-down', got '%s'", state.config.Layout) {
return
}
require.Equal(t, state.config.Layout, "top-down", "Default layout type should be 'top-down', got '%s'", state.config.Layout)
require.Equal(t, len(state.config.Keymap), 0, "Default keymap should be empty, but got '%#v'", state.config.Keymap)
if !assert.Equal(t, len(state.config.Keymap), 0, "Default keymap should be empty, but got '%#v'", state.config.Keymap) {
return
}
if !assert.Equal(t, state.config.InitialMatcher, IgnoreCaseMatch, "Default matcher should be IgnoreCaseMatch, but got '%s'", state.config.InitialMatcher) {
return
}
defstyle := StyleSet{}
defstyle := config.StyleSet{}
defstyle.Init()
if !assert.Equal(t, state.config.Style, defstyle, "should be default style") {
return
}
if !assert.Equal(t, state.config.Prompt, "QUERY>", "Default prompt should be 'QUERY>', but got '%s'", state.config.Prompt) {
return
}
require.Equal(t, state.config.Style, defstyle, "should be default style")
require.Equal(t, state.config.Prompt, "QUERY>", "Default prompt should be 'QUERY>', but got '%s'", state.config.Prompt)
// Okay, this time create a dummy config file, and read that in
f, err := ioutil.TempFile("", "peco-test-config")
if !assert.NoError(t, err, "Failed to create temporary config file: %s", err) {
return
}
f, err := os.CreateTemp(t.TempDir(), "peco-test-config")
require.NoError(t, err, "Failed to create temporary config file: %s", err)
fn := f.Name()
defer os.Remove(fn)
@ -63,12 +47,8 @@ func TestIssue212_SanityCheck(t *testing.T) {
<-state.Ready()
if !assert.NoError(t, state.config.ReadFilename(fn), "Failed to read config: %s", err) {
return
}
if !assert.Equal(t, state.config.Layout, "bottom-up", "Default layout type should be 'bottom-up', got '%s'", state.config.Layout) {
return
}
require.NoError(t, state.config.ReadFilename(fn), "Failed to read config: %s", err)
require.Equal(t, state.config.Layout, "bottom-up", "Default layout type should be 'bottom-up', got '%s'", state.config.Layout)
}
func TestIssue345(t *testing.T) {
@ -83,16 +63,12 @@ func TestIssue345(t *testing.T) {
]
}
}`)
if !assert.NoError(t, err, "newConfig should succeed") {
return
}
require.NoError(t, err, "newConfig should succeed")
defer os.Remove(cfg)
state := newPeco()
state.skipReadConfig = false
if !assert.NoError(t, state.config.Init(), "Config.Init should succeed") {
return
}
state.configReader = defaultConfigReader
require.NoError(t, state.config.Init(), "Config.Init should succeed")
state.Argv = append(state.Argv, []string{"--rcfile", cfg}...)
@ -102,14 +78,84 @@ func TestIssue345(t *testing.T) {
<-state.Ready()
ev := termbox.Event{
Type: termbox.EventKey,
Key: termbox.KeyCtrlT,
}
if !assert.NoError(t, state.Keymap().ExecuteAction(ctx, state, ev), "ExecuteAction should succeed") {
return
ev := Event{
Type: EventKey,
Key: keyseq.KeyCtrlT,
}
require.NoError(t, state.Keymap().ExecuteAction(ctx, state, ev), "ExecuteAction should succeed")
time.Sleep(time.Second)
// Brief pause to let async hub messages from the combined action
// be processed before context cancellation tears down goroutines.
time.Sleep(50 * time.Millisecond)
}
// TestIssue557_FilterBufSize verifies that configuring FilterBufSize allows
// FuzzyLongestSort to correctly sort results across what would otherwise be
// separate chunks. With the default buf size of 1000, lines are batched into
// 1000-line chunks and sorting only happens within each chunk. An exact match
// in chunk 2 would always appear after all chunk 1 results.
func TestIssue557_FilterBufSize(t *testing.T) {
const totalLines = 1100
const query = "exact"
// Build lines: 1 partial match, then 1098 non-matching lines, then 1 exact match.
// The exact match ("exact") is at index 1099, which with the default buf size of
// 1000 would land in the second chunk.
var lines []line.Line
lines = append(lines, line.NewRaw(0, "e_x_a_c_t filler text", false, false)) // fuzzy match
for i := 1; i < totalLines-1; i++ {
lines = append(lines, line.NewRaw(uint64(i), fmt.Sprintf("no match line %d", i), false, false))
}
lines = append(lines, line.NewRaw(uint64(totalLines-1), "exact", false, false)) // exact match, best result
f := filter.NewFuzzy(true)
// Use a configBufSize large enough to hold all lines in a single batch.
// This ensures the fuzzy filter sees all lines at once and can sort globally.
configBufSize := totalLines + 100
fp := newFilterProcessor(f, query, configBufSize, nil)
// Set up pipeline: source -> filterProcessor -> destination
p := pipeline.New()
src := &sliceSource{lines: lines}
p.SetSource(src)
p.Add(fp)
dst := NewMemoryBuffer(0)
p.SetDestination(dst)
ctx := f.NewContext(context.Background(), query)
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
err := p.Run(ctx)
require.NoError(t, err, "pipeline.Run should succeed")
require.True(t, dst.Size() >= 2, "expected at least 2 matched lines, got %d", dst.Size())
// The first result should be the exact match "exact" since it has the
// longest continuous match and shortest line length.
first, err := dst.LineAt(0)
require.NoError(t, err)
require.Equal(t, "exact", first.DisplayString(), "best match should be first")
}
// sliceSource is a simple pipeline.Source backed by a slice of lines.
type sliceSource struct {
lines []line.Line
}
func (s *sliceSource) Start(ctx context.Context, out pipeline.ChanOutput) {
defer close(out)
for _, l := range s.lines {
select {
case <-ctx.Done():
return
default:
out.Send(ctx, l)
}
}
}
func (s *sliceSource) Reset() {}

View file

@ -1,17 +1,35 @@
package peco
import (
"context"
"errors"
"fmt"
"maps"
"sort"
"strings"
"time"
"github.com/lestrrat/go-pdebug"
"github.com/nsf/termbox-go"
"github.com/lestrrat-go/pdebug"
"github.com/peco/peco/internal/keyseq"
"github.com/pkg/errors"
"context"
)
// Keyseq is the interface for key sequence matching engines.
type Keyseq interface {
Add(keyseq.KeyList, any)
AcceptKey(keyseq.Key) (any, error)
CancelChain()
Clear()
Compile() error
InMiddleOfChain() bool
}
// Keymap holds all the key sequence to action map
type Keymap struct {
Config map[string]string
Action map[string][]string // custom actions
seq Keyseq
}
// NewKeymap creates a new Keymap struct
func NewKeymap(config map[string]string, actions map[string][]string) Keymap {
return Keymap{
@ -25,9 +43,8 @@ func (km Keymap) Sequence() Keyseq {
return km.seq
}
const isTopLevelActionCall = "peco.isTopLevelActionCall"
func (km Keymap) ExecuteAction(ctx context.Context, state *Peco, ev termbox.Event) (err error) {
// ExecuteAction looks up and executes the action(s) bound to the given key event.
func (km Keymap) ExecuteAction(ctx context.Context, state *Peco, ev Event) (err error) {
if pdebug.Enabled {
g := pdebug.Marker("Keymap.ExecuteAction %v", ev).BindError(&err)
defer g.End()
@ -38,20 +55,14 @@ func (km Keymap) ExecuteAction(ctx context.Context, state *Peco, ev termbox.Even
return errors.New("action not found")
}
ctx = context.WithValue(ctx, isTopLevelActionCall, true)
a.Execute(ctx, state, ev)
return nil
}
// LookupAction returns the appropriate action for the given termbox event
func (km Keymap) LookupAction(ev termbox.Event) Action {
modifier := keyseq.ModNone
if (ev.Mod & termbox.ModAlt) != 0 {
modifier = keyseq.ModAlt
}
// LookupAction returns the appropriate action for the given event
func (km Keymap) LookupAction(ev Event) Action {
key := keyseq.Key{
Modifier: modifier,
Modifier: ev.Mod,
Key: ev.Key,
Ch: ev.Ch,
}
@ -63,7 +74,11 @@ func (km Keymap) LookupAction(ev termbox.Event) Action {
if pdebug.Enabled {
pdebug.Printf("Keymap.Handler: Fetched action")
}
return wrapClearSequence(action.(Action))
a, ok := action.(Action)
if !ok {
return ActionFunc(doNothing)
}
return wrapClearSequence(a)
case keyseq.ErrInSequence:
if pdebug.Enabled {
pdebug.Printf("Keymap.Handler: Waiting for more commands...")
@ -77,27 +92,29 @@ func (km Keymap) LookupAction(ev termbox.Event) Action {
}
}
// wrapRememberSequence wraps an action to record the key press as part of a multi-key sequence.
func wrapRememberSequence(a Action) Action {
return ActionFunc(func(ctx context.Context, state *Peco, ev termbox.Event) {
if s, err := keyseq.EventToString(ev); err == nil {
return ActionFunc(func(ctx context.Context, state *Peco, ev Event) {
if s, err := keyseq.KeyEventToString(ev.Key, ev.Ch, ev.Mod); err == nil {
seq := state.Inputseq()
seq.Add(s)
state.Hub().SendStatusMsg(strings.Join(seq.KeyNames(), " "))
state.Hub().SendStatusMsg(ctx, strings.Join(seq.KeyNames(), " "), 0)
}
a.Execute(ctx, state, ev)
})
}
// wrapClearSequence wraps an action to clear the accumulated key sequence after execution.
func wrapClearSequence(a Action) Action {
return ActionFunc(func(ctx context.Context, state *Peco, ev termbox.Event) {
return ActionFunc(func(ctx context.Context, state *Peco, ev Event) {
seq := state.Inputseq()
if s, err := keyseq.EventToString(ev); err == nil {
if s, err := keyseq.KeyEventToString(ev.Key, ev.Ch, ev.Mod); err == nil {
seq.Add(s)
}
if seq.Len() > 0 {
msg := strings.Join(seq.KeyNames(), " ")
state.Hub().SendStatusMsgAndClear(msg, 500*time.Millisecond)
state.Hub().SendStatusMsg(ctx, msg, 500*time.Millisecond)
seq.Reset()
}
@ -107,9 +124,10 @@ func wrapClearSequence(a Action) Action {
const maxResolveActionDepth = 100
// resolveActionName maps a string action name from config to the corresponding action function.
func (km Keymap) resolveActionName(name string, depth int) (Action, error) {
if depth >= maxResolveActionDepth {
return nil, errors.Errorf("could not resolve %s: deep recursion", name)
return nil, fmt.Errorf("could not resolve %s: deep recursion", name)
}
// Can it be resolved via regular nameToActions ?
@ -130,11 +148,10 @@ func (km Keymap) resolveActionName(name string, depth int) (Action, error) {
actions = append(actions, child)
}
v = makeCombinedAction(actions...)
nameToActions[name] = v
return v, nil
}
return nil, errors.Errorf("could not resolve %s: no such action", name)
return nil, fmt.Errorf("could not resolve %s: no such action", name)
}
// ApplyKeybinding applies all of the custom key bindings on top of
@ -145,9 +162,7 @@ func (km *Keymap) ApplyKeybinding() error {
// Copy the map
kb := map[string]Action{}
for s, a := range defaultKeyBinding {
kb[s] = a
}
maps.Copy(kb, defaultKeyBinding)
// munge the map using config
for s, as := range km.Config {
@ -158,7 +173,7 @@ func (km *Keymap) ApplyKeybinding() error {
v, err := km.resolveActionName(as, 0)
if err != nil {
return errors.Wrapf(err, "failed to resolve action name %s", as)
return fmt.Errorf("failed to resolve action name %s: %w", as, err)
}
kb[s] = v
}
@ -176,16 +191,14 @@ func (km *Keymap) ApplyKeybinding() error {
a := kb[s]
list, err := keyseq.ToKeyList(s)
if err != nil {
return errors.Wrapf(err, "urnknown key %s: %s", s, err)
return fmt.Errorf("unknown key %s: %w", s, err)
}
k.Add(list, a)
}
return errors.Wrap(k.Compile(), "failed to compile key binding patterns")
}
// TODO: this needs to be fixed.
func (km Keymap) hasModifierMaps() bool {
return false
if err := k.Compile(); err != nil {
return fmt.Errorf("failed to compile key binding patterns: %w", err)
}
return nil
}

1006
layout.go

File diff suppressed because it is too large Load diff

5
layout_any.go Normal file
View file

@ -0,0 +1,5 @@
//go:build !windows
package peco
const extraOffset = 0

View file

@ -5,27 +5,27 @@ import (
"unicode/utf8"
"github.com/mattn/go-runewidth"
"github.com/nsf/termbox-go"
"github.com/peco/peco/config"
"github.com/peco/peco/filter"
"github.com/peco/peco/hub"
"github.com/peco/peco/line"
"github.com/stretchr/testify/require"
)
func TestLayoutType(t *testing.T) {
layouts := []struct {
value LayoutType
value string
expectOK bool
}{
{LayoutTypeTopDown, true},
{LayoutTypeBottomUp, true},
{config.LayoutTypeTopDown, true},
{config.LayoutTypeBottomUp, true},
{config.LayoutTypeTopDownQueryBottom, true},
{"foobar", false},
}
for _, l := range layouts {
valid := IsValidLayoutType(l.value)
if valid != l.expectOK {
t.Errorf("LayoutType %s, expected IsValidLayoutType to return %t, but got %t",
l.value,
l.expectOK,
valid,
)
}
valid := config.IsValidLayoutType(l.value)
require.Equal(t, l.expectOK, valid,
"IsValidLayoutType(%s)", l.value)
}
}
@ -34,25 +34,21 @@ func TestPrintScreen(t *testing.T) {
makeVerifier := func(initX, initY int, fill bool) func(string) {
return func(msg string) {
screen.interceptor.reset()
screen.reset()
t.Logf("Checking printScreen(%d, %d, %s, %t)", initX, initY, msg, fill)
width := utf8.RuneCountInString(msg)
screen.Print(PrintArgs{
X: initX,
Y: initY,
Fg: termbox.ColorDefault,
Bg: termbox.ColorDefault,
Fg: config.ColorDefault,
Bg: config.ColorDefault,
Msg: msg,
Fill: fill,
})
events := screen.interceptor.events["SetCell"]
events := screen.events["SetCell"]
if !fill {
if len(events) != width {
t.Errorf("Expected %d SetCell events, got %d",
width,
len(events),
)
}
require.Equal(t, width, len(events),
"SetCell event count for %q (fill=false)", msg)
return
}
@ -61,13 +57,8 @@ func TestPrintScreen(t *testing.T) {
if rw := runewidth.StringWidth(msg); rw != width {
w -= rw - width
}
if len(events) != w {
t.Errorf("Expected %d SetCell events, got %d",
w,
len(events),
)
return
}
require.Equal(t, w, len(events),
"SetCell event count for %q (fill=true)", msg)
}
}
@ -80,20 +71,27 @@ func TestPrintScreen(t *testing.T) {
verify("日本語")
}
func TestStatusBar(t *testing.T) {
func TestScreenStatusBar(t *testing.T) {
screen := NewDummyScreen()
st := NewStatusBar(screen, AnchorBottom, 0, NewStyleSet())
st, err := newScreenStatusBar(screen, AnchorBottom, 0, config.NewStyleSet())
require.NoError(t, err)
st.PrintStatus("Hello, World!", 0)
events := screen.interceptor.events
if l := len(events["Flush"]); l != 1 {
t.Errorf("Expected 1 Flush event, got %d", l)
return
}
events := screen.events
require.Equal(t, 1, len(events["Flush"]), "Flush event count")
}
func TestNullStatusBar(t *testing.T) {
screen := NewDummyScreen()
var st StatusBar = nullStatusBar{}
st.PrintStatus("Hello, World!", 0)
events := screen.events
require.Equal(t, 0, len(events["Flush"]), "nullStatusBar should not Flush")
}
func TestMergeAttribute(t *testing.T) {
colors := stringToFg
colors := config.StringToFg
// merge colors
tests := [][]string{
@ -109,21 +107,407 @@ func TestMergeAttribute(t *testing.T) {
}
for _, c := range tests {
if m := mergeAttribute(colors[c[0]], colors[c[1]]); m != colors[c[2]] {
t.Errorf("(%s + %s) expected %d(%s), got %d", c[0], c[1], colors[c[2]], c[2], m)
}
m := mergeAttribute(colors[c[0]], colors[c[1]])
require.Equal(t, colors[c[2]], m, "(%s + %s) should be %s", c[0], c[1], c[2])
}
// merge with white
for _, c := range colors {
if m := mergeAttribute(c, colors["white"]); m != colors["white"] {
t.Errorf("expected white(%d), got %d", colors["white"], m)
}
for name, c := range colors {
m := mergeAttribute(c, colors["white"])
require.Equal(t, colors["white"], m, "%s + white should be white", name)
}
// merge attributes
if m := mergeAttribute(termbox.AttrBold|colors["red"], termbox.AttrUnderline|colors["cyan"]); m != termbox.AttrBold|termbox.AttrUnderline|colors["white"] {
t.Errorf("expected %d, got %d", termbox.AttrBold|termbox.AttrUnderline|colors["white"], m)
m := mergeAttribute(config.AttrBold|colors["red"], config.AttrUnderline|colors["cyan"])
require.Equal(t, config.AttrBold|config.AttrUnderline|colors["white"], m)
}
// TestGHIssue294_PromptStyleUsedForPromptPrefix verifies that UserPrompt.Draw
// uses the Prompt style (not Basic) when rendering the prompt prefix string.
func TestGHIssue294_PromptStyleUsedForPromptPrefix(t *testing.T) {
styles := config.NewStyleSet()
styles.Prompt.Fg = config.ColorGreen | config.AttrBold
styles.Prompt.Bg = config.ColorBlue
// Make sure Basic is different so we can distinguish them.
styles.Basic.Fg = config.ColorDefault
styles.Basic.Bg = config.ColorDefault
screen := NewDummyScreen()
prompt, err := NewUserPrompt(screen, AnchorTop, 0, "QUERY>", styles)
require.NoError(t, err)
state := New()
state.screen = screen
state.Filters().Add(filter.NewIgnoreCase())
prompt.Draw(state)
// Collect SetCell events for y=0 (the prompt row).
events := screen.events["SetCell"]
promptStr := "QUERY>"
promptLen := len(promptStr)
require.True(t, len(events) >= promptLen,
"expected at least %d SetCell events, got %d", promptLen, len(events))
// The first promptLen cells should use the Prompt style colors.
for i := range promptLen {
ev := events[i]
x := ev[0].(int)
ch := ev[2].(rune)
fg := ev[3].(config.Attribute)
bg := ev[4].(config.Attribute)
require.Equal(t, i, x, "expected x=%d", i)
require.Equal(t, rune(promptStr[i]), ch, "expected character %c at position %d", promptStr[i], i)
require.Equal(t, styles.Prompt.Fg, fg,
"cell at x=%d should use Prompt.Fg, got %v", i, fg)
require.Equal(t, styles.Prompt.Bg, bg,
"cell at x=%d should use Prompt.Bg, got %v", i, bg)
}
// The cells after the prompt should NOT use the Prompt style —
// they should use the Query style (for the query text area).
if len(events) > promptLen {
ev := events[promptLen]
fg := ev[3].(config.Attribute)
require.NotEqual(t, styles.Prompt.Fg, fg,
"cell after prompt should not use Prompt style")
}
}
// TestGHIssue460_MatchedStyleDoesNotBleedToEndOfLine verifies that matched
// text highlighting in ListArea.Draw does not extend to the screen edge.
func TestGHIssue460_MatchedStyleDoesNotBleedToEndOfLine(t *testing.T) {
// Use a distinct Matched.Bg so we can detect it in SetCell events.
styles := config.NewStyleSet()
styles.Matched.Bg = config.ColorBlue
matchedBg := mergeAttribute(styles.Basic.Bg, styles.Matched.Bg) // config.ColorBlue
basicBg := styles.Basic.Bg // config.ColorDefault
// Helper: set up a Peco state with one matched line and draw it,
// returning the SetCell events for the line's row (y=0).
drawAndCollect := func(t *testing.T, text string, matches [][]int) []interceptorArgs {
t.Helper()
screen := NewDummyScreen()
listArea, err := NewListArea(screen, AnchorTop, 0, true, styles)
require.NoError(t, err)
state := New()
state.screen = screen
mb := NewMemoryBuffer(0)
raw := line.NewRaw(0, text, false, false)
matched := line.NewMatched(raw, matches)
mb.lines = append(mb.lines, matched)
// Add a second line so we can set the cursor on it,
// keeping line 0 in Basic (non-selected) style.
mb.lines = append(mb.lines, line.NewRaw(1, "other", false, false))
state.currentLineBuffer = mb
loc := state.Location()
loc.SetPage(1)
loc.SetPerPage(10)
loc.SetLineNumber(1) // select line 1, so line 0 uses Basic style
listArea.Draw(state, nil, 10, &hub.DrawOptions{DisableCache: true})
// Collect SetCell events for y=0 (our matched line).
var row []interceptorArgs
for _, ev := range screen.events["SetCell"] {
if ev[1].(int) == 0 {
row = append(row, ev)
}
}
return row
}
t.Run("match at end of line", func(t *testing.T) {
// "hello world" with "world" matched at end [6,11].
row := drawAndCollect(t, "hello world", [][]int{{6, 11}})
screenWidth := 80
require.Equal(t, screenWidth, len(row),
"expected SetCell events to cover the full screen width")
for _, ev := range row {
x := ev[0].(int)
bg := ev[4].(config.Attribute)
if x >= 6 && x <= 10 {
require.Equal(t, matchedBg, bg,
"cell at x=%d should have matched bg", x)
} else {
require.Equal(t, basicBg, bg,
"cell at x=%d should have basic bg, not matched bg", x)
}
}
})
t.Run("match in middle of line", func(t *testing.T) {
// "hello world, goodbye" with "world" matched at [6,11].
row := drawAndCollect(t, "hello world, goodbye", [][]int{{6, 11}})
screenWidth := 80
require.Equal(t, screenWidth, len(row),
"expected SetCell events to cover the full screen width")
for _, ev := range row {
x := ev[0].(int)
bg := ev[4].(config.Attribute)
if x >= 6 && x <= 10 {
require.Equal(t, matchedBg, bg,
"cell at x=%d should have matched bg", x)
} else {
require.Equal(t, basicBg, bg,
"cell at x=%d should have basic bg, not matched bg", x)
}
}
})
}
// TestGHIssue455_DrawScreenForceSync verifies that BasicLayout.DrawScreen
// calls Sync() (full redraw) instead of Flush() (differential) when
// DrawOptions.ForceSync is true.
func TestGHIssue455_DrawScreenForceSync(t *testing.T) {
setupState := func(t *testing.T) (*Peco, *SimScreen) {
t.Helper()
screen := NewDummyScreen()
state := New()
state.screen = screen
state.Filters().Add(filter.NewIgnoreCase())
mb := NewMemoryBuffer(0)
mb.lines = append(mb.lines, line.NewRaw(0, "line one", false, false))
state.currentLineBuffer = mb
loc := state.Location()
loc.SetPage(1)
loc.SetPerPage(10)
loc.SetLineNumber(0)
return state, screen
}
t.Run("ForceSync true calls Sync instead of final Flush", func(t *testing.T) {
state, screen := setupState(t)
layout, err := DefaultLayout(state)
require.NoError(t, err)
screen.reset()
layout.DrawScreen(state, &hub.DrawOptions{DisableCache: true, ForceSync: true})
syncEvents := screen.events["Sync"]
flushEvents := screen.events["Flush"]
require.Len(t, syncEvents, 1, "expected exactly 1 Sync call")
// DrawPrompt internally calls Flush, but the final DrawScreen
// Flush should be replaced by Sync.
for i, ev := range screen.events["Flush"] {
t.Logf("Flush event %d: %v", i, ev)
}
for i, ev := range screen.events["Sync"] {
t.Logf("Sync event %d: %v", i, ev)
}
// The prompt's Flush still fires, but the final screen Flush
// is replaced by Sync. So Flush count should be 1 less than
// the non-ForceSync case.
flushCountWithSync := len(flushEvents)
// Compare against the non-ForceSync case
screen.reset()
layout.DrawScreen(state, &hub.DrawOptions{DisableCache: true, ForceSync: false})
flushCountWithout := len(screen.events["Flush"])
require.Equal(t, flushCountWithout-1, flushCountWithSync,
"ForceSync should replace exactly one Flush call with Sync")
})
t.Run("ForceSync false does not call Sync", func(t *testing.T) {
state, screen := setupState(t)
layout, err := DefaultLayout(state)
require.NoError(t, err)
screen.reset()
layout.DrawScreen(state, &hub.DrawOptions{DisableCache: true, ForceSync: false})
syncEvents := screen.events["Sync"]
require.Empty(t, syncEvents, "expected no Sync calls when ForceSync is false")
})
t.Run("nil options does not call Sync", func(t *testing.T) {
state, screen := setupState(t)
layout, err := DefaultLayout(state)
require.NoError(t, err)
screen.reset()
layout.DrawScreen(state, nil)
syncEvents := screen.events["Sync"]
require.Empty(t, syncEvents, "expected no Sync calls with nil options")
})
}
// TestNewLayout verifies the layout registry returns correct layout types.
func TestNewLayout(t *testing.T) {
makeState := func() *Peco {
state := New()
state.screen = NewDummyScreen()
state.Filters().Add(filter.NewIgnoreCase())
return state
}
t.Run("top-down", func(t *testing.T) {
state := makeState()
layout, err := NewLayout(config.LayoutTypeTopDown, state)
require.NoError(t, err)
require.True(t, layout.SortTopDown(), "top-down layout should sort top-down")
require.Equal(t, AnchorTop, layout.prompt.anchor, "top-down prompt should be anchored at top")
})
t.Run("bottom-up", func(t *testing.T) {
state := makeState()
layout, err := NewLayout(config.LayoutTypeBottomUp, state)
require.NoError(t, err)
require.False(t, layout.SortTopDown(), "bottom-up layout should not sort top-down")
require.Equal(t, AnchorBottom, layout.prompt.anchor, "bottom-up prompt should be anchored at bottom")
})
t.Run("top-down-query-bottom", func(t *testing.T) {
state := makeState()
layout, err := NewLayout(config.LayoutTypeTopDownQueryBottom, state)
require.NoError(t, err)
require.True(t, layout.SortTopDown(), "top-down-query-bottom layout should sort top-down")
require.Equal(t, AnchorBottom, layout.prompt.anchor, "top-down-query-bottom prompt should be anchored at bottom")
require.Equal(t, AnchorTop, layout.list.anchor, "top-down-query-bottom list should be anchored at top")
})
t.Run("unknown falls back to top-down", func(t *testing.T) {
state := makeState()
layout, err := NewLayout("unknown-layout", state)
require.NoError(t, err)
require.True(t, layout.SortTopDown(), "fallback layout should sort top-down")
require.Equal(t, AnchorTop, layout.prompt.anchor, "fallback prompt should be anchored at top")
})
}
// TestTopDownQueryBottomLayout verifies the specific properties of the
// top-down-query-bottom layout.
func TestTopDownQueryBottomLayout(t *testing.T) {
state := New()
state.screen = NewDummyScreen()
state.Filters().Add(filter.NewIgnoreCase())
layout, err := TopDownQueryBottomLayout(state)
require.NoError(t, err)
require.Equal(t, AnchorBottom, layout.prompt.anchor,
"prompt should be anchored at bottom")
require.Equal(t, 1+extraOffset, layout.prompt.anchorOffset,
"prompt anchor offset should be 1+extraOffset")
require.Equal(t, AnchorTop, layout.list.anchor,
"list should be anchored at top")
require.Equal(t, 0, layout.list.anchorOffset,
"list anchor offset should be 0")
require.True(t, layout.list.sortTopDown,
"list should sort top-down")
require.True(t, layout.SortTopDown(),
"SortTopDown() should return true")
}
// TestCursorStyle verifies that UserPrompt.cursorStyle returns the correct
// fg/bg attributes depending on QueryCursor and Query style configuration.
func TestCursorStyle(t *testing.T) {
makePrompt := func(t *testing.T, styles *config.StyleSet) UserPrompt {
t.Helper()
screen := NewDummyScreen()
p, err := NewUserPrompt(screen, AnchorTop, 0, "QUERY>", styles)
require.NoError(t, err)
return *p
}
t.Run("both default falls back to config.AttrReverse", func(t *testing.T) {
styles := config.NewStyleSet()
// Query and QueryCursor are both config.ColorDefault (zero value)
p := makePrompt(t, styles)
fg, bg := p.cursorStyle()
require.Equal(t, config.ColorDefault|config.AttrReverse, fg)
require.Equal(t, config.ColorDefault|config.AttrReverse, bg)
})
t.Run("Query has custom colors and QueryCursor is default swaps fg/bg", func(t *testing.T) {
styles := config.NewStyleSet()
styles.Query.Fg = config.ColorYellow
styles.Query.Bg = config.ColorBlue
p := makePrompt(t, styles)
fg, bg := p.cursorStyle()
require.Equal(t, config.ColorBlue, fg, "should use Query.Bg as cursor fg")
require.Equal(t, config.ColorYellow, bg, "should use Query.Fg as cursor bg")
})
t.Run("QueryCursor explicitly set takes precedence", func(t *testing.T) {
styles := config.NewStyleSet()
styles.Query.Fg = config.ColorYellow
styles.Query.Bg = config.ColorBlue
styles.QueryCursor.Fg = config.ColorWhite
styles.QueryCursor.Bg = config.ColorRed
p := makePrompt(t, styles)
fg, bg := p.cursorStyle()
require.Equal(t, config.ColorWhite, fg)
require.Equal(t, config.ColorRed, bg)
})
t.Run("QueryCursor with only fg set takes precedence", func(t *testing.T) {
styles := config.NewStyleSet()
styles.Query.Fg = config.ColorYellow
styles.Query.Bg = config.ColorBlue
styles.QueryCursor.Fg = config.ColorGreen
// bg remains config.ColorDefault
p := makePrompt(t, styles)
fg, bg := p.cursorStyle()
require.Equal(t, config.ColorGreen, fg)
require.Equal(t, config.ColorDefault, bg)
})
t.Run("Query has only fg set swaps correctly", func(t *testing.T) {
styles := config.NewStyleSet()
styles.Query.Fg = config.ColorRed
// Query.Bg remains config.ColorDefault
p := makePrompt(t, styles)
fg, bg := p.cursorStyle()
require.Equal(t, config.ColorDefault, fg, "should use Query.Bg (default) as cursor fg")
require.Equal(t, config.ColorRed, bg, "should use Query.Fg as cursor bg")
})
}
// TestInvalidAnchorReturnsError verifies that invalid vertical anchors
// produce errors instead of panics.
func TestInvalidAnchorReturnsError(t *testing.T) {
screen := NewDummyScreen()
invalidAnchor := VerticalAnchor(99)
t.Run("NewAnchorSettings", func(t *testing.T) {
_, err := NewAnchorSettings(screen, invalidAnchor, 0)
require.Error(t, err)
require.Contains(t, err.Error(), "invalid vertical anchor")
})
t.Run("NewUserPrompt", func(t *testing.T) {
_, err := NewUserPrompt(screen, invalidAnchor, 0, "QUERY>", config.NewStyleSet())
require.Error(t, err)
})
t.Run("NewListArea", func(t *testing.T) {
_, err := NewListArea(screen, invalidAnchor, 0, true, config.NewStyleSet())
require.Error(t, err)
})
t.Run("newScreenStatusBar", func(t *testing.T) {
_, err := newScreenStatusBar(screen, invalidAnchor, 0, config.NewStyleSet())
require.Error(t, err)
})
}

View file

@ -1,7 +1,5 @@
package peco
func init() {
// This is the value we pass to anchor offset when we're running
// on windows platform
extraOffset = 1
}
// extraOffset reserves one additional line at the bottom on Windows
// to account for platform-specific terminal rendering behavior.
const extraOffset = 1

76
line/bench_test.go Normal file
View file

@ -0,0 +1,76 @@
package line
import (
"fmt"
"testing"
)
// BenchmarkMatchedLifecycle benchmarks the create-use-release cycle for
// Matched objects, simulating multiple keystroke filter runs. On the first
// iteration all objects are freshly allocated; on subsequent iterations
// they come from the pool.
func BenchmarkMatchedLifecycle(b *testing.B) {
const numLines = 10_000
raws := make([]*Raw, numLines)
for i := range raws {
raws[i] = NewRaw(uint64(i), fmt.Sprintf("line %d content here", i), false, false)
}
indices := [][]int{{0, 4}, {5, 10}}
b.Run("pooled_GetMatched", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
// Simulate a filter run: create 10k Matched objects
matched := make([]*Matched, numLines)
for i, r := range raws {
matched[i] = GetMatched(r, indices)
}
// Simulate Reset(): release all back to pool
for _, m := range matched {
ReleaseMatched(m)
}
}
})
b.Run("unpooled_NewMatched", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
// Simulate a filter run: create 10k Matched objects (no pooling)
matched := make([]*Matched, numLines)
for i, r := range raws {
matched[i] = NewMatched(r, indices)
}
// No release -- objects become garbage
_ = matched
}
})
}
// BenchmarkNewRawAndDisplay benchmarks creating Raw lines and calling
// DisplayString(), simulating peco's startup + render path for 10k lines.
func BenchmarkNewRawAndDisplay(b *testing.B) {
texts := make([]string, 10_000)
for i := range texts {
texts[i] = fmt.Sprintf("line %d: this is a typical plain text line without any ansi codes", i)
}
b.Run("enableANSI_true_plain_text", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
for i, t := range texts {
r := NewRaw(uint64(i), t, false, true)
_ = r.DisplayString()
}
}
})
b.Run("enableANSI_false_plain_text", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
for i, t := range texts {
r := NewRaw(uint64(i), t, false, false)
_ = r.DisplayString()
}
}
})
}

View file

@ -1,53 +0,0 @@
package line
import "github.com/google/btree"
// IDGenerator defines an interface for things that generate
// unique IDs for lines used within peco.
type IDGenerator interface {
Next() uint64
}
// Line represents each of the line that peco uses to display
// and match against queries.
type Line interface {
btree.Item
ID() uint64
// Buffer returns the raw buffer
Buffer() string
// DisplayString returns the string to be displayed. This means if you have
// a null separator, the contents after the separator are not included
// in this string
DisplayString() string
// Output returns the string to be display as peco finishes up doing its
// thing. This means if you have null separator, the contents before the
// separator are not included in this string
Output() string
// IsDirty returns true if this line should be forcefully redrawn
IsDirty() bool
// SetDirty sets the dirty flag on or off
SetDirty(bool)
}
// Raw is the input line as sent to peco, before filtering and what not.
type Raw struct {
id uint64
buf string
sepLoc int
displayString string
dirty bool
}
// Matched contains the indices to the matches
type Matched struct {
Line
indices [][]int
}

View file

@ -1,5 +1,39 @@
package line
import (
"sync"
"github.com/peco/peco/internal/ansi"
)
var matchedPool = sync.Pool{
New: func() any { return &Matched{} },
}
// GetMatched retrieves a Matched from the pool and initializes it.
func GetMatched(rl Line, matches [][]int) *Matched {
m, ok := matchedPool.Get().(*Matched)
if !ok {
m = &Matched{}
}
m.Line = rl
m.indices = matches
return m
}
// ReleaseMatched returns a Matched to the pool after clearing its fields.
func ReleaseMatched(m *Matched) {
m.Line = nil
m.indices = nil
matchedPool.Put(m)
}
// Matched contains the indices to the matches
type Matched struct {
Line
indices [][]int
}
// NewMatched creates a new Matched
func NewMatched(rl Line, matches [][]int) *Matched {
return &Matched{rl, matches}
@ -10,3 +44,10 @@ func (ml Matched) Indices() [][]int {
return ml.indices
}
// ANSIAttrs returns the ANSI attributes from the underlying line, if available.
func (ml Matched) ANSIAttrs() []ansi.AttrSpan {
if r, ok := ml.Line.(*Raw); ok {
return r.ANSIAttrs()
}
return nil
}

28
line/matched_test.go Normal file
View file

@ -0,0 +1,28 @@
package line
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetAndReleaseMatched(t *testing.T) {
t.Parallel()
raw := NewRaw(1, "hello", false, false)
indices := [][]int{{0, 5}}
m := GetMatched(raw, indices)
require.Equal(t, raw, m.Line)
require.Equal(t, indices, m.Indices())
require.Equal(t, "hello", m.DisplayString())
ReleaseMatched(m)
require.Nil(t, m.Line)
require.Nil(t, m.indices)
// Get again — should reuse the pooled object
m2 := GetMatched(raw, nil)
require.Equal(t, raw, m2.Line)
require.Nil(t, m2.Indices())
ReleaseMatched(m2)
}

Some files were not shown because too many files have changed in this diff Show more