fzp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fzp-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 r1cardohj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
fzp-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: fzp
3
+ Version: 0.1.0
4
+ Summary: A minimal fzf clone in Python: custom fuzzy scoring + prompt_toolkit interactive UI
5
+ Keywords: fzf,fuzzy,finder,cli,terminal
6
+ Author: r1cardohj
7
+ Author-email: r1cardohj <2413302357@qq.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Utilities
19
+ Requires-Dist: click>=8.5.1
20
+ Requires-Dist: prompt-toolkit>=3.0.53
21
+ Requires-Python: >=3.12
22
+ Project-URL: Homepage, https://github.com/r1cardohj/fzp
23
+ Project-URL: Repository, https://github.com/r1cardohj/fzp
24
+ Project-URL: Issues, https://github.com/r1cardohj/fzp/issues
25
+ Description-Content-Type: text/markdown
26
+
27
+ # fzp
28
+
29
+ A minimal [fzf](https://github.com/junegunn/fzf) clone written in Python. The first version (MVP) focuses on the **core algorithm** and a minimal usable interaction: feed it file paths or piped input, fuzzy search, press Enter to print the result.
30
+
31
+ ![CI](https://github.com/r1cardohj/fzp/actions/workflows/ci.yml/badge.svg)
32
+ ![PyPI](https://img.shields.io/pypi/v/fzp)
33
+ ![Python](https://img.shields.io/badge/python-3.12%2B-blue)
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install fzp
39
+ # Or install an isolated CLI with uv:
40
+ uv tool install fzp
41
+ ```
42
+
43
+ ## Quick Start
44
+
45
+ ```bash
46
+ fzp # scan the current directory, print the selected path
47
+ ls | fzp # read candidates from a pipe
48
+ vim $(ls | fzp) # use with command substitution
49
+ fzp -q cli # start with an initial query
50
+ fzp setup # install the Ctrl-T key-binding into ~/.bashrc
51
+ ```
52
+
53
+ ### From source
54
+
55
+ ```bash
56
+ git clone https://github.com/r1cardohj/fzp.git && cd fzp
57
+ uv sync
58
+
59
+ uv run fzp # scan the current directory, print the selected path to stdout
60
+ ls | uv run fzp # read candidates from a pipe
61
+ vim $(ls | uv run fzp) # use with command substitution
62
+ uv run fzp -q cli # start with an initial query
63
+ uv run fzp setup # install the Ctrl-T key-binding into ~/.bashrc
64
+ ```
65
+
66
+ ## Shell Integration (`fzp setup`)
67
+
68
+ Just like `fzf`'s key bindings, `fzp setup` writes a small block into your shell rc file
69
+ (the file is chosen from `$SHELL`: `.bashrc` for bash, `.zshrc` for zsh) and prints a
70
+ reminder to re-source it:
71
+
72
+ ```bash
73
+ fzp setup # write to ~/.bashrc (or ~/.zshrc) and hint: source ~/.bashrc
74
+ fzp setup --show # print the snippet that would be written, don't touch any file
75
+ ```
76
+
77
+ After restarting your shell (or `source ~/.bashrc`), press **Ctrl-T** on the command line
78
+ to open fzp: the selected path is pasted at the cursor, followed by a space, ready for the
79
+ next argument. Ending the line with `^` appends the selection instead of replacing
80
+ (`cat notes.md ^` + Ctrl-T → `cat notes.md src/fzp/cli.py`).
81
+
82
+ The block is wrapped in `# >>> fzp key-bindings >>>` / `# <<< fzp key-bindings <<<`
83
+ markers, so re-running `fzp setup` is idempotent (it replaces the old block in place),
84
+ and deleting the block uninstalls the binding.
85
+
86
+ > Only bash gets a live binding for now (`bind -x '\C-t'` via readline); on zsh the block
87
+ > is written but inert until zsh integration lands.
88
+
89
+ Key bindings:
90
+
91
+ | Key | Action |
92
+ | --- | --- |
93
+ | `↑` / `Ctrl-P` | Move up |
94
+ | `↓` / `Ctrl-N` / `Ctrl-J` | Move down |
95
+ | `PgUp` / `PgDn` | Page up / down |
96
+ | `Enter` | Confirm, print the selected item to stdout |
97
+ | `Esc` / `Ctrl-C` / `Ctrl-D` / `Ctrl-G` | Cancel, exit code 130 |
98
+
99
+ The UI is drawn inline below the current cursor, with up to 10 visible candidates. It does
100
+ not clear the terminal or enter the alternate screen, and the picker is erased when done.
101
+ The UI is drawn on stderr, and stdout only carries the final result, so it works seamlessly with pipes and `$( )`.
102
+ All UI text is in English (`❯ ` prompt, `<no match>`, `1/3 (4 items)` status bar).
103
+ Matched characters are highlighted with a **text color** (bright green + underline) — no background color or reverse video;
104
+ the selected line is shown in bright cyan and bold.
105
+
106
+ ## Core Algorithm (`src/fzp/fuzzy.py`)
107
+
108
+ No third-party fuzzy library is used. The approach is inspired by fzf v1:
109
+
110
+ 1. **Subsequence matching**: a candidate matches if the pattern's characters appear in order; case-insensitive.
111
+ 2. **Two alignments, pick the better one**: forward greedy (each character takes its earliest occurrence, favoring consecutive matches) and backward greedy (each character takes its latest occurrence, favoring matches near the end of the filename); the higher-scoring one wins.
112
+ 3. **Scoring rules**:
113
+
114
+ | Rule | Score |
115
+ | --- | --- |
116
+ | Each matched character | +16 |
117
+ | Match at the start of the string | +16 |
118
+ | Match right after a delimiter (`/ . _ - : ; , |`, space, etc.) or a camelCase / letter-digit boundary | +8 |
119
+ | Adjacent to the previous match | +16 |
120
+ | Exact case match | +2 |
121
+ | Each skipped gap between matches | -3, then -1 for each additional skipped character |
122
+
123
+ 4. **Sorting**: by score descending; ties prefer shorter items and earlier input order; an empty query preserves the input order (same as fzf).
124
+
125
+ So `app` ranks `app.py` above `a_p_p.py`:
126
+
127
+ ```python
128
+ >>> from fzp import fuzzy_filter
129
+ >>> [item for item, _ in fuzzy_filter("app", ["a_p_p.py", "src/app.py", "app.py"])]
130
+ ['app.py', 'src/app.py', 'a_p_p.py']
131
+ ```
132
+
133
+ ## Project Structure
134
+
135
+ ```
136
+ src/fzp/
137
+ ├── cli.py # click entrypoint: pipe detection, directory scan, result output
138
+ ├── fuzzy.py # core fuzzy matching + scoring + sorting (pure functions, easy to test)
139
+ ├── files.py # scans the current directory when there's no piped input
140
+ ├── setup.py # shell integration and idempotent rc-file installation
141
+ └── tui.py # prompt_toolkit interactive UI (query line / result list / status bar)
142
+ tests/ # pytest: algorithm, directory scan, CLI
143
+ scripts/pty_smoke.py # smoke test driving a real interactive session via pty
144
+ ```
145
+
146
+ ## Development
147
+
148
+ ```bash
149
+ uv run pytest # unit tests
150
+ uv run python scripts/pty_smoke.py # interactive smoke test (requires a pty)
151
+ uv build # build the sdist and wheel
152
+ ```
153
+
154
+ ## Release
155
+
156
+ Releases use [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/),
157
+ so no long-lived API token is stored in GitHub. After registering the pending
158
+ publisher described in [RELEASING.md](RELEASING.md), push a version tag:
159
+
160
+ ```bash
161
+ git tag v0.1.0
162
+ git push origin v0.1.0
163
+ ```
164
+
165
+ The release workflow runs the test matrix, validates the built packages, and
166
+ publishes them to PyPI.
167
+
168
+ ## Roadmap
169
+
170
+ - zsh integration (`bindkey` widget) to match the bash Ctrl-T binding
171
+ - Multi-select (Tab to toggle, `--multi`)
172
+ - Result preview (`--preview`)
173
+ - Candidate caching / incremental reading for huge inputs
174
+ - DP (Smith-Waterman) scoring to get even closer to fzf v2
fzp-0.1.0/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # fzp
2
+
3
+ A minimal [fzf](https://github.com/junegunn/fzf) clone written in Python. The first version (MVP) focuses on the **core algorithm** and a minimal usable interaction: feed it file paths or piped input, fuzzy search, press Enter to print the result.
4
+
5
+ ![CI](https://github.com/r1cardohj/fzp/actions/workflows/ci.yml/badge.svg)
6
+ ![PyPI](https://img.shields.io/pypi/v/fzp)
7
+ ![Python](https://img.shields.io/badge/python-3.12%2B-blue)
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install fzp
13
+ # Or install an isolated CLI with uv:
14
+ uv tool install fzp
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```bash
20
+ fzp # scan the current directory, print the selected path
21
+ ls | fzp # read candidates from a pipe
22
+ vim $(ls | fzp) # use with command substitution
23
+ fzp -q cli # start with an initial query
24
+ fzp setup # install the Ctrl-T key-binding into ~/.bashrc
25
+ ```
26
+
27
+ ### From source
28
+
29
+ ```bash
30
+ git clone https://github.com/r1cardohj/fzp.git && cd fzp
31
+ uv sync
32
+
33
+ uv run fzp # scan the current directory, print the selected path to stdout
34
+ ls | uv run fzp # read candidates from a pipe
35
+ vim $(ls | uv run fzp) # use with command substitution
36
+ uv run fzp -q cli # start with an initial query
37
+ uv run fzp setup # install the Ctrl-T key-binding into ~/.bashrc
38
+ ```
39
+
40
+ ## Shell Integration (`fzp setup`)
41
+
42
+ Just like `fzf`'s key bindings, `fzp setup` writes a small block into your shell rc file
43
+ (the file is chosen from `$SHELL`: `.bashrc` for bash, `.zshrc` for zsh) and prints a
44
+ reminder to re-source it:
45
+
46
+ ```bash
47
+ fzp setup # write to ~/.bashrc (or ~/.zshrc) and hint: source ~/.bashrc
48
+ fzp setup --show # print the snippet that would be written, don't touch any file
49
+ ```
50
+
51
+ After restarting your shell (or `source ~/.bashrc`), press **Ctrl-T** on the command line
52
+ to open fzp: the selected path is pasted at the cursor, followed by a space, ready for the
53
+ next argument. Ending the line with `^` appends the selection instead of replacing
54
+ (`cat notes.md ^` + Ctrl-T → `cat notes.md src/fzp/cli.py`).
55
+
56
+ The block is wrapped in `# >>> fzp key-bindings >>>` / `# <<< fzp key-bindings <<<`
57
+ markers, so re-running `fzp setup` is idempotent (it replaces the old block in place),
58
+ and deleting the block uninstalls the binding.
59
+
60
+ > Only bash gets a live binding for now (`bind -x '\C-t'` via readline); on zsh the block
61
+ > is written but inert until zsh integration lands.
62
+
63
+ Key bindings:
64
+
65
+ | Key | Action |
66
+ | --- | --- |
67
+ | `↑` / `Ctrl-P` | Move up |
68
+ | `↓` / `Ctrl-N` / `Ctrl-J` | Move down |
69
+ | `PgUp` / `PgDn` | Page up / down |
70
+ | `Enter` | Confirm, print the selected item to stdout |
71
+ | `Esc` / `Ctrl-C` / `Ctrl-D` / `Ctrl-G` | Cancel, exit code 130 |
72
+
73
+ The UI is drawn inline below the current cursor, with up to 10 visible candidates. It does
74
+ not clear the terminal or enter the alternate screen, and the picker is erased when done.
75
+ The UI is drawn on stderr, and stdout only carries the final result, so it works seamlessly with pipes and `$( )`.
76
+ All UI text is in English (`❯ ` prompt, `<no match>`, `1/3 (4 items)` status bar).
77
+ Matched characters are highlighted with a **text color** (bright green + underline) — no background color or reverse video;
78
+ the selected line is shown in bright cyan and bold.
79
+
80
+ ## Core Algorithm (`src/fzp/fuzzy.py`)
81
+
82
+ No third-party fuzzy library is used. The approach is inspired by fzf v1:
83
+
84
+ 1. **Subsequence matching**: a candidate matches if the pattern's characters appear in order; case-insensitive.
85
+ 2. **Two alignments, pick the better one**: forward greedy (each character takes its earliest occurrence, favoring consecutive matches) and backward greedy (each character takes its latest occurrence, favoring matches near the end of the filename); the higher-scoring one wins.
86
+ 3. **Scoring rules**:
87
+
88
+ | Rule | Score |
89
+ | --- | --- |
90
+ | Each matched character | +16 |
91
+ | Match at the start of the string | +16 |
92
+ | Match right after a delimiter (`/ . _ - : ; , |`, space, etc.) or a camelCase / letter-digit boundary | +8 |
93
+ | Adjacent to the previous match | +16 |
94
+ | Exact case match | +2 |
95
+ | Each skipped gap between matches | -3, then -1 for each additional skipped character |
96
+
97
+ 4. **Sorting**: by score descending; ties prefer shorter items and earlier input order; an empty query preserves the input order (same as fzf).
98
+
99
+ So `app` ranks `app.py` above `a_p_p.py`:
100
+
101
+ ```python
102
+ >>> from fzp import fuzzy_filter
103
+ >>> [item for item, _ in fuzzy_filter("app", ["a_p_p.py", "src/app.py", "app.py"])]
104
+ ['app.py', 'src/app.py', 'a_p_p.py']
105
+ ```
106
+
107
+ ## Project Structure
108
+
109
+ ```
110
+ src/fzp/
111
+ ├── cli.py # click entrypoint: pipe detection, directory scan, result output
112
+ ├── fuzzy.py # core fuzzy matching + scoring + sorting (pure functions, easy to test)
113
+ ├── files.py # scans the current directory when there's no piped input
114
+ ├── setup.py # shell integration and idempotent rc-file installation
115
+ └── tui.py # prompt_toolkit interactive UI (query line / result list / status bar)
116
+ tests/ # pytest: algorithm, directory scan, CLI
117
+ scripts/pty_smoke.py # smoke test driving a real interactive session via pty
118
+ ```
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ uv run pytest # unit tests
124
+ uv run python scripts/pty_smoke.py # interactive smoke test (requires a pty)
125
+ uv build # build the sdist and wheel
126
+ ```
127
+
128
+ ## Release
129
+
130
+ Releases use [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/),
131
+ so no long-lived API token is stored in GitHub. After registering the pending
132
+ publisher described in [RELEASING.md](RELEASING.md), push a version tag:
133
+
134
+ ```bash
135
+ git tag v0.1.0
136
+ git push origin v0.1.0
137
+ ```
138
+
139
+ The release workflow runs the test matrix, validates the built packages, and
140
+ publishes them to PyPI.
141
+
142
+ ## Roadmap
143
+
144
+ - zsh integration (`bindkey` widget) to match the bash Ctrl-T binding
145
+ - Multi-select (Tab to toggle, `--multi`)
146
+ - Result preview (`--preview`)
147
+ - Candidate caching / incremental reading for huge inputs
148
+ - DP (Smith-Waterman) scoring to get even closer to fzf v2
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "fzp"
3
+ version = "0.1.0"
4
+ description = "A minimal fzf clone in Python: custom fuzzy scoring + prompt_toolkit interactive UI"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ keywords = [
9
+ "fzf",
10
+ "fuzzy",
11
+ "finder",
12
+ "cli",
13
+ "terminal",
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Topic :: Utilities",
25
+ ]
26
+ requires-python = ">=3.12"
27
+ dependencies = [
28
+ "click>=8.5.1",
29
+ "prompt-toolkit>=3.0.53",
30
+ ]
31
+
32
+ [[project.authors]]
33
+ name = "r1cardohj"
34
+ email = "2413302357@qq.com"
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/r1cardohj/fzp"
38
+ Repository = "https://github.com/r1cardohj/fzp"
39
+ Issues = "https://github.com/r1cardohj/fzp/issues"
40
+
41
+ [project.scripts]
42
+ fzp = "fzp.cli:main"
43
+
44
+ [dependency-groups]
45
+ dev = ["pytest>=9.0.1"]
46
+
47
+ [build-system]
48
+ requires = ["uv_build>=0.11.23,<0.12.0"]
49
+ build-backend = "uv_build"
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "fzp"
3
+ version = "0.1.0"
4
+ description = "A minimal fzf clone in Python: custom fuzzy scoring + prompt_toolkit interactive UI"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ keywords = ["fzf", "fuzzy", "finder", "cli", "terminal"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Environment :: Console",
12
+ "Intended Audience :: Developers",
13
+ "Operating System :: OS Independent",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Topic :: Utilities",
19
+ ]
20
+ authors = [
21
+ { name = "r1cardohj", email = "2413302357@qq.com" }
22
+ ]
23
+ requires-python = ">=3.12"
24
+ dependencies = [
25
+ "click>=8.5.1",
26
+ "prompt-toolkit>=3.0.53",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/r1cardohj/fzp"
31
+ Repository = "https://github.com/r1cardohj/fzp"
32
+ Issues = "https://github.com/r1cardohj/fzp/issues"
33
+
34
+ [project.scripts]
35
+ fzp = "fzp.cli:main"
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "pytest>=9.0.1",
40
+ ]
41
+
42
+ [build-system]
43
+ requires = ["uv_build>=0.11.23,<0.12.0"]
44
+ build-backend = "uv_build"
@@ -0,0 +1,9 @@
1
+ """fzp —— 用 Python 写的极简 fzf。
2
+
3
+ 核心是自研的 fuzzy 匹配打分算法,见 :mod:`fzp.fuzzy`。
4
+ """
5
+
6
+ from .fuzzy import Match, fuzzy_filter, fuzzy_match
7
+
8
+ __all__ = ["Match", "fuzzy_filter", "fuzzy_match"]
9
+ __version__ = "0.1.0"
@@ -0,0 +1,122 @@
1
+ """fzp 命令行入口。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from .files import read_candidates, walk_files
11
+ from .fuzzy import fuzzy_filter
12
+
13
+
14
+ def _has_tty() -> bool:
15
+ streams = (sys.stdin, sys.stdout, sys.stderr)
16
+ return any(stream is not None and stream.isatty() for stream in streams)
17
+
18
+
19
+ @click.group(
20
+ invoke_without_command=True,
21
+ no_args_is_help=False,
22
+ context_settings={"help_option_names": ["-h", "--help"]},
23
+ )
24
+ @click.version_option(package_name="fzp", prog_name="fzp")
25
+ @click.option("-q", "--query", default="", help="Initial query string.")
26
+ @click.option(
27
+ "--hidden/--no-hidden",
28
+ default=False,
29
+ show_default=True,
30
+ help="Include hidden files when scanning the current directory.",
31
+ )
32
+ @click.pass_context
33
+ def main(ctx: click.Context, query: str, hidden: bool) -> None:
34
+ """fzp - a tiny fzf in Python: fuzzy-search files or piped input.
35
+
36
+ \b
37
+ Examples:
38
+ fzp # scan the current directory, print the selection
39
+ ls | fzp # read candidates from a pipe
40
+ vim $(ls | fzp) # hand the selection to another command
41
+ fzp setup # install Ctrl-T key-binding into your ~/.bashrc
42
+ """
43
+ if ctx.invoked_subcommand is not None:
44
+ return
45
+ _run_picker(query, hidden)
46
+
47
+
48
+ def _run_picker(query: str, hidden: bool) -> None:
49
+ items: list[str] = []
50
+ if sys.stdin is not None and not sys.stdin.isatty():
51
+ items = list(read_candidates(sys.stdin))
52
+
53
+ if not items:
54
+ items = walk_files(".", hidden=hidden)
55
+
56
+ items = [item for item in items if item.strip()]
57
+ if not items:
58
+ raise click.ClickException("No candidates found")
59
+
60
+ # 没有终端(比如在脚本里跑)时退化成纯过滤,方便测试和自动化
61
+ if not _has_tty():
62
+ for item, _ in fuzzy_filter(query, items):
63
+ click.echo(item)
64
+ return
65
+
66
+ from .tui import Picker # 延迟导入:非交互模式不需要 prompt_toolkit
67
+
68
+ selected = Picker(items, query=query).pick()
69
+ if selected is None:
70
+ raise SystemExit(130)
71
+ click.echo(selected)
72
+
73
+
74
+ @main.command()
75
+ @click.option(
76
+ "--show",
77
+ "show_only",
78
+ is_flag=True,
79
+ help="Print the snippet that would be written, don't touch any file.",
80
+ )
81
+ @click.option(
82
+ "--rc",
83
+ "rc_path",
84
+ type=click.Path(path_type=Path),
85
+ default=None,
86
+ help="Write to this rc file instead of the one detected from $SHELL.",
87
+ )
88
+ @click.pass_context
89
+ def setup(ctx: click.Context, show_only: bool, rc_path: Path | None) -> None:
90
+ """Install shell integration: press Ctrl-T on the command line to open fzp.
91
+
92
+ Writes a key-binding block (idempotent, wrapped in marker comments) into your
93
+ shell rc file. bash gets a real Ctrl-T binding; other shells keep the block
94
+ inert until their integration lands.
95
+ """
96
+ from . import setup as setup_mod
97
+
98
+ try:
99
+ rc = rc_path or setup_mod.detect_shell_rc()
100
+ except RuntimeError as exc:
101
+ raise click.ClickException(str(exc)) from exc
102
+
103
+ if show_only:
104
+ old = rc.read_text(encoding="utf-8") if rc.exists() else ""
105
+ click.echo(setup_mod.render_preview(old), nl=False)
106
+ return
107
+
108
+ try:
109
+ setup_mod.setup_key_bindings(rc)
110
+ except OSError as exc:
111
+ raise click.ClickException(f"Failed to write {rc}: {exc}") from exc
112
+
113
+ click.secho(f"✔ Shell integration written to {rc}", fg="green")
114
+ click.secho(
115
+ " Restart your shell, or run: "
116
+ + click.style(f"source {rc}", fg="cyan", bold=True),
117
+ )
118
+ click.secho(" Then press Ctrl-T on the command line to fuzzy-pick a file.", dim=True)
119
+
120
+
121
+ if __name__ == "__main__": # python -m fzp.cli
122
+ main()
@@ -0,0 +1,56 @@
1
+ """没有管道输入时,扫描当前目录下的文件,给 fzf 式的体验。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Iterator
7
+
8
+ # 明显没有意义的目录,直接跳过
9
+ SKIP_DIRS = frozenset(
10
+ {
11
+ ".git",
12
+ ".hg",
13
+ ".svn",
14
+ ".venv",
15
+ "venv",
16
+ "node_modules",
17
+ "__pycache__",
18
+ ".mypy_cache",
19
+ ".pytest_cache",
20
+ ".ruff_cache",
21
+ ".tox",
22
+ ".idea",
23
+ ".gradle",
24
+ "target",
25
+ "dist",
26
+ "build",
27
+ }
28
+ )
29
+
30
+ MAX_FILES = 200_000
31
+
32
+
33
+ def walk_files(root: str = ".", hidden: bool = False) -> list[str]:
34
+ """返回 root 下所有文件的相对路径,顺序稳定(目录/文件名排序)。"""
35
+ results: list[str] = []
36
+ root = os.path.abspath(root)
37
+
38
+ for current, dirs, files in os.walk(root):
39
+ if not hidden:
40
+ dirs[:] = [d for d in dirs if not d.startswith(".")]
41
+ files = [f for f in files if not f.startswith(".")]
42
+ dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
43
+ for name in sorted(files):
44
+ # 统一用 POSIX 分隔符,Windows 下也输出 src/app.py。
45
+ path = os.path.relpath(os.path.join(current, name), root)
46
+ results.append(path.replace(os.sep, "/"))
47
+ if len(results) >= MAX_FILES:
48
+ return results
49
+
50
+ return results
51
+
52
+
53
+ def read_candidates(stream) -> Iterator[str]:
54
+ """把 stdin 的每一行当成一个候选项。"""
55
+ for line in stream:
56
+ yield line.rstrip("\n")
@@ -0,0 +1,144 @@
1
+ """fzp 的核心:fuzzy 匹配 + 打分。
2
+
3
+ 不依赖任何第三方 fuzzy 库,算法思路参考 fzf v1:
4
+
5
+ 1. 子序列匹配(大小写不敏感)。
6
+ 2. 取两种对齐方式里得分更高的那个:
7
+ - 正向贪心:每个 pattern 字符取最早出现的位置(容易得到连续命中);
8
+ - 反向贪心:每个 pattern 字符取最晚出现的位置(容易贴近文件名)。
9
+ 3. 按下面的规则打分:
10
+ - 每命中一个字符 +SCORE_MATCH
11
+ - 命中在串首 +BONUS_HEAD
12
+ - 命中在分隔符 / 驼峰 / 数字字母边界 +BONUS_BOUNDARY
13
+ - 与前一个命中相邻 +BONUS_CONSECUTIVE
14
+ - 大小写也一致 +BONUS_CASE_MATCH
15
+ - 中间跳过字符 -PENALTY_GAP_START - n * PENALTY_GAP_EXTENSION
16
+
17
+ 连续命中 + 词首命中的组合得分最高,所以 "app" 会优先命中 `app.py`,
18
+ 而不是 `a_pp_p.py`。
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from typing import Sequence
25
+
26
+ SCORE_MATCH = 16
27
+ BONUS_BOUNDARY = SCORE_MATCH // 2 # 分隔符/驼峰之后
28
+ BONUS_HEAD = 2 * BONUS_BOUNDARY # 串首命中
29
+ BONUS_CONSECUTIVE = SCORE_MATCH # 连续命中
30
+ BONUS_CASE_MATCH = 2 # 大小写一致
31
+ PENALTY_GAP_START = 3 # 第一次跳字符
32
+ PENALTY_GAP_EXTENSION = 1 # 之后每多跳一个字符
33
+
34
+ # 词边界分隔符
35
+ SEPARATORS = frozenset("/\\._-:;,| \t[](){}<>+=$#@!*?\"'`~&%")
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Match:
40
+ """一次成功匹配:总分 + 命中字符下标(用于高亮)。"""
41
+
42
+ score: int
43
+ positions: tuple[int, ...]
44
+
45
+
46
+ def _position_bonus(text: str, index: int) -> int:
47
+ """命中 text[index] 的“位置加成”。"""
48
+ if index == 0:
49
+ return BONUS_HEAD
50
+ prev, cur = text[index - 1], text[index]
51
+ if prev in SEPARATORS:
52
+ return BONUS_BOUNDARY
53
+ if prev.islower() and cur.isupper(): # camelCase
54
+ return BONUS_BOUNDARY
55
+ if prev.isdigit() != cur.isdigit() and cur.isalnum(): # a1 / 1a
56
+ return BONUS_BOUNDARY
57
+ return 0
58
+
59
+
60
+ def _forward_positions(lowered: str, haystack: str) -> list[int] | None:
61
+ """每个字符取最早出现的位置。"""
62
+ positions: list[int] = []
63
+ start = 0
64
+ for char in lowered:
65
+ found = haystack.find(char, start)
66
+ if found < 0:
67
+ return None
68
+ positions.append(found)
69
+ start = found + 1
70
+ return positions
71
+
72
+
73
+ def _backward_positions(lowered: str, haystack: str) -> list[int] | None:
74
+ """每个字符取最晚出现的位置(从右往左贪心)。"""
75
+ positions = [0] * len(lowered)
76
+ limit = len(haystack)
77
+ for i in range(len(lowered) - 1, -1, -1):
78
+ found = haystack.rfind(lowered[i], 0, limit)
79
+ if found < 0:
80
+ return None
81
+ positions[i] = found
82
+ limit = found
83
+ return positions
84
+
85
+
86
+ def _score(text: str, pattern: str, positions: Sequence[int]) -> int:
87
+ score = 0
88
+ previous = -2
89
+ for index, char in zip(positions, pattern):
90
+ score += SCORE_MATCH
91
+ if index == previous + 1:
92
+ score += BONUS_CONSECUTIVE
93
+ elif previous >= 0:
94
+ gap = index - previous - 1
95
+ score -= PENALTY_GAP_START + PENALTY_GAP_EXTENSION * (gap - 1)
96
+ score += _position_bonus(text, index)
97
+ if text[index] == char:
98
+ score += BONUS_CASE_MATCH
99
+ previous = index
100
+ return score
101
+
102
+
103
+ def fuzzy_match(pattern: str, text: str) -> Match | None:
104
+ """pattern 是 text 的子序列则返回 Match,否则 None。空 pattern 恒命中。"""
105
+ if not pattern:
106
+ return Match(0, ())
107
+
108
+ lowered = pattern.lower()
109
+ haystack = text.lower()
110
+ if len(lowered) > len(haystack):
111
+ return None
112
+
113
+ candidates = [
114
+ positions
115
+ for positions in (
116
+ _forward_positions(lowered, haystack),
117
+ _backward_positions(lowered, haystack),
118
+ )
119
+ if positions is not None
120
+ ]
121
+ if not candidates:
122
+ return None
123
+
124
+ best = max(candidates, key=lambda positions: _score(text, pattern, positions))
125
+ return Match(_score(text, pattern, best), tuple(best))
126
+
127
+
128
+ def fuzzy_filter(pattern: str, items: Sequence[str]) -> list[tuple[str, Match]]:
129
+ """过滤 + 排序,返回 [(原始条目, Match), ...]。
130
+
131
+ 空 pattern 保持输入顺序(和 fzf 一样),非空 pattern 按分数降序,
132
+ 同分时更短、更靠前的条目优先。
133
+ """
134
+ if not pattern:
135
+ return [(item, Match(0, ())) for item in items]
136
+
137
+ scored: list[tuple[int, int, int, str, Match]] = []
138
+ for order, item in enumerate(items):
139
+ match = fuzzy_match(pattern, item)
140
+ if match is not None:
141
+ scored.append((-match.score, len(item), order, item, match))
142
+
143
+ scored.sort()
144
+ return [(item, match) for _, _, _, item, match in scored]
@@ -0,0 +1,109 @@
1
+ """`fzp setup`:把 key-bindings(默认 Ctrl-T)写入 shell 配置文件。
2
+
3
+ 机制与 fzf 的 `key-bindings.bash` 相同:readline 收到 Ctrl-T 时调用
4
+ `__fzp_select`,它先保存当前行内容,清空命令行后运行 fzp,选中结果
5
+ (相对路径)再填回命令行(默认补一个空格,可用 ^ 前缀追加模式)。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shlex
11
+ import shutil
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ HEADER = "# >>> fzp key-bindings >>>"
16
+ FOOTER = "# <<< fzp key-bindings <<<"
17
+
18
+ # 注意:这段脚本由 bash 执行(readline 只支持 bash),
19
+ # 里面用条件判断保证在 zsh 下安静地不生效(zsh 用户请用 fzf 的 zsh 集成)。
20
+ SHELL_SNIPPET = r"""# >>> fzp key-bindings >>>
21
+ # Auto-generated by `fzp setup`. Remove this block to uninstall.
22
+ if [ -n "$BASH_VERSION" ]; then
23
+ __fzp_select() {
24
+ local item
25
+ item="$(__FZP_COMMAND__)" || return
26
+ if [ -n "$item" ]; then
27
+ local prefix=${READLINE_LINE:0:READLINE_POINT}
28
+ local suffix=${READLINE_LINE:READLINE_POINT}
29
+ if [[ "$prefix" == *\^ ]]; then
30
+ READLINE_LINE="${prefix%\^} ${item}${suffix}"
31
+ READLINE_POINT=$(( ${#READLINE_LINE} - ${#suffix} ))
32
+ else
33
+ READLINE_LINE="${prefix}${item} ${suffix}"
34
+ READLINE_POINT=$(( ${#READLINE_LINE} - ${#suffix} ))
35
+ fi
36
+ fi
37
+ }
38
+ bind -x '"\C-t": __fzp_select'
39
+ fi
40
+ # <<< fzp key-bindings <<<"""
41
+
42
+
43
+ def build_shell_snippet(command: str | None = None) -> str:
44
+ """生成绑定脚本;命令使用安装时的绝对路径,避免交互 shell 的 PATH 差异。"""
45
+ return SHELL_SNIPPET.replace(
46
+ "__FZP_COMMAND__", command or detect_fzp_command()
47
+ )
48
+
49
+
50
+ def detect_shell_rc(home: Path | None = None) -> Path:
51
+ """根据 $SHELL 找到对应的 rc 文件,写不进去的 shell 直接报错。"""
52
+ import os
53
+
54
+ home = home or Path.home()
55
+ shell_path = os.environ.get("SHELL", "")
56
+ shell_name = Path(shell_path).name
57
+ candidates = {
58
+ "bash": home / ".bashrc",
59
+ "zsh": home / ".zshrc",
60
+ }
61
+ rc = candidates.get(shell_name)
62
+ if rc is None:
63
+ raise RuntimeError(
64
+ f"Unsupported shell {shell_path!r} (supported: bash, zsh). "
65
+ "You can still use fzp without shell integration."
66
+ )
67
+ return rc
68
+
69
+
70
+ def inject_snippet(content: str, snippet: str | None = None) -> str:
71
+ """把 snippet 注入到内容中:已存在则整体替换(幂等),否则追加到末尾。"""
72
+ snippet = snippet or build_shell_snippet()
73
+ if HEADER in content:
74
+ start = content.index(HEADER)
75
+ end = content.index(FOOTER) + len(FOOTER)
76
+ return content[:start] + snippet + content[end:]
77
+ if content and not content.endswith("\n"):
78
+ content += "\n"
79
+ return content + "\n" + snippet + "\n"
80
+
81
+
82
+ def setup_key_bindings(rc: Path | None = None, home: Path | None = None) -> Path:
83
+ """写入 rc 文件,返回 rc 路径。重复执行是幂等的。"""
84
+ rc = rc or detect_shell_rc(home)
85
+ old = rc.read_text(encoding="utf-8") if rc.exists() else ""
86
+ rc.write_text(inject_snippet(old), encoding="utf-8")
87
+ return rc
88
+
89
+
90
+ def render_preview(content: str) -> str:
91
+ """预览将要写入 rc 的内容(setup --show / --preview 用)。"""
92
+ return inject_snippet(content)
93
+
94
+
95
+ def detect_fzp_command() -> str:
96
+ """返回在交互 shell 中也能工作的 fzp 命令,并进行 shell quoting。
97
+
98
+ 直接运行入口脚本时记录它的绝对路径;通过 ``python -m fzp.cli``
99
+ 安装时则记录当前解释器,避免依赖虚拟环境是否已加入 PATH。
100
+ """
101
+ argv0 = Path(sys.argv[0])
102
+ if argv0.name in {"fzp", "fzp.exe"} and argv0.is_file():
103
+ return shlex.quote(str(argv0.resolve()))
104
+
105
+ executable = shutil.which("fzp")
106
+ if executable:
107
+ return shlex.quote(str(Path(executable).resolve()))
108
+
109
+ return f"{shlex.quote(sys.executable)} -m fzp.cli"
@@ -0,0 +1,235 @@
1
+ """极简 fzf 式交互界面(prompt_toolkit)。界面文案统一用英文。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Sequence
7
+
8
+ from prompt_toolkit.application import Application, get_app
9
+ from prompt_toolkit.buffer import Buffer
10
+ from prompt_toolkit.input import create_input
11
+ from prompt_toolkit.key_binding import KeyBindings
12
+ from prompt_toolkit.layout import HSplit, Layout, VSplit, Window
13
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
14
+ from prompt_toolkit.output import create_output
15
+ from prompt_toolkit.styles import Style
16
+
17
+ from .fuzzy import Match, fuzzy_filter
18
+
19
+ STYLE = Style.from_dict(
20
+ {
21
+ "prompt": "fg:#5f87d7 bold",
22
+ "pointer": "fg:ansibrightcyan bold",
23
+ # 选中的行:换颜色 + 加粗(不用背景色)
24
+ "current": "fg:ansibrightcyan bold",
25
+ # fuzzy 命中的字符:纯文字高亮(换前景色 + 加粗,不用背景色)
26
+ "match": "fg:ansibrightgreen bold",
27
+ "current match": "fg:ansibrightgreen bold underline",
28
+ "empty": "fg:#808080",
29
+ "info": "fg:#808080",
30
+ }
31
+ )
32
+
33
+ # 界面文案集中在这里,保持全英文
34
+ PROMPT = "❯ "
35
+ NO_MATCH = "<no match>"
36
+ MAX_VISIBLE_RESULTS = 10
37
+
38
+
39
+ def status_text(position: int, matches: int, total: int) -> str:
40
+ """状态栏:当前第几个/匹配数/总数。"""
41
+ if matches == 0:
42
+ return f" 0/{total} ({total} items)"
43
+ return f" {position}/{matches} ({total} items)"
44
+
45
+
46
+ class Picker:
47
+ """在候选列表里 fuzzy 搜索、上下选择、回车确认。
48
+
49
+ pick() 返回选中的条目;用户按 Ctrl-C / Esc 时返回 None。
50
+ """
51
+
52
+ def __init__(self, items: Sequence[str], query: str = "") -> None:
53
+ self.items = list(items)
54
+ self.results: list[tuple[str, Match]] = []
55
+ self.pointer = 0
56
+ self.offset = 0
57
+ self.app: Application[str] | None = None
58
+
59
+ self.query_buffer = Buffer(multiline=False)
60
+ self.query_buffer.text = query
61
+ self.query_buffer.on_text_changed += self._on_query_changed
62
+
63
+ prompt = Window(
64
+ FormattedTextControl([("class:prompt", PROMPT)]),
65
+ width=len(PROMPT),
66
+ height=1,
67
+ )
68
+ query_line = VSplit(
69
+ [prompt, Window(BufferControl(buffer=self.query_buffer), height=1)]
70
+ )
71
+ self.results_window = Window(
72
+ FormattedTextControl(self._render_results),
73
+ wrap_lines=False,
74
+ )
75
+ status = Window(
76
+ FormattedTextControl(self._render_status), height=1, style="class:info"
77
+ )
78
+
79
+ layout = Layout(HSplit([query_line, self.results_window, status]))
80
+ self.app = Application(
81
+ layout=layout,
82
+ key_bindings=self._key_bindings(),
83
+ full_screen=False,
84
+ erase_when_done=True,
85
+ mouse_support=False,
86
+ style=STYLE,
87
+ input=create_input(always_prefer_tty=True),
88
+ output=create_output(self._ui_stream()),
89
+ )
90
+ self._refresh()
91
+
92
+ # ------------------------------------------------------------------ 状态
93
+
94
+ def _ui_stream(self):
95
+ """界面画到 stderr,stdout 只留给最终结果(方便管道/命令替换)。"""
96
+ if sys.stderr is not None and sys.stderr.isatty():
97
+ return sys.stderr
98
+ return sys.stdout
99
+
100
+ def _visible_rows(self) -> int:
101
+ """候选区高度:最多 10 行,少候选时不撑出多余空白。"""
102
+ if not self.results:
103
+ return 1
104
+ try:
105
+ rows = get_app().output.get_size().rows
106
+ except Exception:
107
+ rows = 24
108
+ return max(1, min(len(self.results), MAX_VISIBLE_RESULTS, rows - 2))
109
+
110
+ def _on_query_changed(self, _buffer: Buffer) -> None:
111
+ self._refresh()
112
+
113
+ def _refresh(self) -> None:
114
+ self.results = fuzzy_filter(self.query_buffer.text, self.items)
115
+ self.pointer = 0
116
+ self.offset = 0
117
+ if self.app is not None:
118
+ self.app.invalidate()
119
+
120
+ def _current(self) -> str | None:
121
+ if not self.results:
122
+ return None
123
+ return self.results[self.pointer][0]
124
+
125
+ def _move(self, delta: int) -> None:
126
+ if not self.results:
127
+ return
128
+ self.pointer = min(max(self.pointer + delta, 0), len(self.results) - 1)
129
+ visible = self._visible_rows()
130
+ if self.pointer < self.offset:
131
+ self.offset = self.pointer
132
+ elif self.pointer >= self.offset + visible:
133
+ self.offset = self.pointer - visible + 1
134
+
135
+ # ------------------------------------------------------------------ 渲染
136
+
137
+ @staticmethod
138
+ def _fragments(text: str, positions: tuple[int, ...], selected: bool):
139
+ """把一行拆成普通字符 / 命中字符(用于高亮)。"""
140
+ plain = "class:current" if selected else ""
141
+ hit = "class:current class:match" if selected else "class:match"
142
+ fragments = []
143
+ last = 0
144
+ for position in positions:
145
+ if position > last:
146
+ fragments.append((plain, text[last:position]))
147
+ fragments.append((hit, text[position]))
148
+ last = position + 1
149
+ if last < len(text):
150
+ fragments.append((plain, text[last:]))
151
+ return fragments
152
+
153
+ def _render_results(self):
154
+ visible = self._visible_rows()
155
+
156
+ if not self.results:
157
+ return [("class:empty", f" {NO_MATCH}")]
158
+
159
+ self.offset = max(0, min(self.offset, len(self.results) - 1))
160
+ if self.pointer < self.offset:
161
+ self.offset = self.pointer
162
+ elif self.pointer >= self.offset + visible:
163
+ self.offset = self.pointer - visible + 1
164
+
165
+ lines = []
166
+ end = min(self.offset + visible, len(self.results))
167
+ for index in range(self.offset, end):
168
+ text, match = self.results[index]
169
+ selected = index == self.pointer
170
+ marker = "> " if selected else " "
171
+ lines.append(
172
+ (
173
+ "class:current class:pointer" if selected else "class:pointer",
174
+ marker,
175
+ )
176
+ )
177
+ lines.extend(self._fragments(text, match.positions, selected))
178
+ if index != end - 1:
179
+ lines.append(("", "\n"))
180
+ return lines
181
+
182
+ def _render_status(self):
183
+ return status_text(self.pointer + 1, len(self.results), len(self.items))
184
+
185
+ # -------------------------------------------------------------- 键位绑定
186
+
187
+ def _key_bindings(self) -> KeyBindings:
188
+ bindings = KeyBindings()
189
+
190
+ @bindings.add("down")
191
+ @bindings.add("c-n")
192
+ @bindings.add("c-j")
193
+ def _down(event) -> None:
194
+ self._move(1)
195
+
196
+ @bindings.add("up")
197
+ @bindings.add("c-p")
198
+ def _up(event) -> None:
199
+ self._move(-1)
200
+
201
+ @bindings.add("pageup")
202
+ def _page_up(event) -> None:
203
+ self._move(-self._visible_rows())
204
+
205
+ @bindings.add("pagedown")
206
+ def _page_down(event) -> None:
207
+ self._move(self._visible_rows())
208
+
209
+ @bindings.add("enter")
210
+ def _accept(event) -> None:
211
+ self._exit(self._current())
212
+
213
+ @bindings.add("c-c")
214
+ @bindings.add("c-d")
215
+ @bindings.add("c-g")
216
+ @bindings.add("escape")
217
+ def _abort(event) -> None:
218
+ self._exit(None)
219
+
220
+ return bindings
221
+
222
+ def _exit(self, result: str | None) -> None:
223
+ """幂等退出:快速连按退出键时避免重复调用 Application.exit() 抛异常。"""
224
+ assert self.app is not None
225
+ if self.app.future is not None and not self.app.future.done():
226
+ self.app.exit(result=result)
227
+
228
+ # ------------------------------------------------------------------- 入口
229
+
230
+ def pick(self) -> str | None:
231
+ assert self.app is not None
232
+ try:
233
+ return self.app.run()
234
+ except (EOFError, KeyboardInterrupt):
235
+ return None