aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-20 12:57:36 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-20 12:57:36 +0200
commit73be69cd99f5a4784fe69f5d78f423c72e837284 (patch)
treecb47246d2c7edd8cd828abb25a033b732d2289ae /crates
parentchore: Move more stuff out of atuin-client (diff)
downloadatuin-73be69cd99f5a4784fe69f5d78f423c72e837284.zip
chore: Commit
Diffstat (limited to '')
-rw-r--r--crates/atuin-nucleo/.gitignore10
-rw-r--r--crates/atuin-nucleo/CHANGELOG.md54
-rw-r--r--crates/atuin-nucleo/Cargo.toml17
-rw-r--r--crates/atuin-nucleo/LICENSE373
-rw-r--r--crates/atuin-nucleo/README.md209
-rw-r--r--crates/atuin-nucleo/bench/Cargo.toml10
-rw-r--r--crates/atuin-nucleo/bench/src/main.rs76
-rw-r--r--crates/atuin-nucleo/matcher/Cargo.toml19
l---------crates/atuin-nucleo/matcher/LICENSE1
-rwxr-xr-xcrates/atuin-nucleo/matcher/fuzz.sh3
-rw-r--r--crates/atuin-nucleo/matcher/fuzz/.gitignore4
-rw-r--r--crates/atuin-nucleo/matcher/fuzz/Cargo.toml29
-rw-r--r--crates/atuin-nucleo/matcher/fuzz/fuzz_targets/fuzz_target_1.rs78
-rwxr-xr-xcrates/atuin-nucleo/matcher/generate_case_fold_table.sh13
-rw-r--r--crates/atuin-nucleo/matcher/src/chars.rs207
-rw-r--r--crates/atuin-nucleo/matcher/src/chars/case_fold.rs347
-rw-r--r--crates/atuin-nucleo/matcher/src/chars/normalize.rs972
-rw-r--r--crates/atuin-nucleo/matcher/src/config.rs70
-rw-r--r--crates/atuin-nucleo/matcher/src/debug.rs14
-rw-r--r--crates/atuin-nucleo/matcher/src/exact.rs275
-rw-r--r--crates/atuin-nucleo/matcher/src/fuzzy_greedy.rs51
-rw-r--r--crates/atuin-nucleo/matcher/src/fuzzy_optimal.rs348
-rw-r--r--crates/atuin-nucleo/matcher/src/lib.rs782
-rw-r--r--crates/atuin-nucleo/matcher/src/matrix.rs198
-rw-r--r--crates/atuin-nucleo/matcher/src/pattern.rs568
-rw-r--r--crates/atuin-nucleo/matcher/src/pattern/tests.rs149
-rw-r--r--crates/atuin-nucleo/matcher/src/prefilter.rs98
-rw-r--r--crates/atuin-nucleo/matcher/src/score.rs158
-rw-r--r--crates/atuin-nucleo/matcher/src/tests.rs771
-rw-r--r--crates/atuin-nucleo/matcher/src/utf32_str.rs428
-rw-r--r--crates/atuin-nucleo/matcher/src/utf32_str/tests.rs44
-rw-r--r--crates/atuin-nucleo/src/boxcar.rs786
-rw-r--r--crates/atuin-nucleo/src/lib.rs522
-rw-r--r--crates/atuin-nucleo/src/par_sort.rs895
-rw-r--r--crates/atuin-nucleo/src/pattern.rs100
-rw-r--r--crates/atuin-nucleo/src/pattern/tests.rs14
-rw-r--r--crates/atuin-nucleo/src/tests.rs267
-rw-r--r--crates/atuin-nucleo/src/worker.rs408
-rw-r--r--crates/atuin-nucleo/tarpaulin.toml1
-rw-r--r--crates/atuin-nucleo/typos.toml3
-rw-r--r--crates/client/Cargo.toml2
-rw-r--r--crates/client/src/atuin_client/mod.rs10
-rw-r--r--crates/client/src/atuin_client/settings/meta.rs2
-rw-r--r--crates/client/src/atuin_client/settings/mod.rs (renamed from crates/client/src/atuin_client/settings.rs)15
-rw-r--r--crates/client/src/atuin_client/settings/watcher.rs4
-rw-r--r--crates/client/src/command/client.rs198
-rw-r--r--crates/client/src/command/client/init.rs129
-rw-r--r--crates/client/src/command/client/init/bash.rs13
-rw-r--r--crates/client/src/command/client/init/fish.rs74
-rw-r--r--crates/client/src/command/client/init/powershell.rs20
-rw-r--r--crates/client/src/command/client/init/xonsh.rs19
-rw-r--r--crates/client/src/command/client/init/zsh.rs26
-rw-r--r--crates/client/src/command/client/search.rs366
-rw-r--r--crates/client/src/command/client/search/cursor.rs404
-rw-r--r--crates/client/src/command/client/search/duration.rs63
-rw-r--r--crates/client/src/command/client/search/engines.rs86
-rw-r--r--crates/client/src/command/client/search/engines/daemon.rs213
-rw-r--r--crates/client/src/command/client/search/engines/db.rs107
-rw-r--r--crates/client/src/command/client/search/engines/skim.rs222
-rw-r--r--crates/client/src/command/client/search/history_list.rs431
-rw-r--r--crates/client/src/command/client/search/inspector.rs414
-rw-r--r--crates/client/src/command/client/search/interactive.rs3024
-rw-r--r--crates/client/src/command/client/search/keybindings/actions.rs322
-rw-r--r--crates/client/src/command/client/search/keybindings/conditions.rs801
-rw-r--r--crates/client/src/command/client/search/keybindings/defaults.rs1274
-rw-r--r--crates/client/src/command/client/search/keybindings/key.rs633
-rw-r--r--crates/client/src/command/client/search/keybindings/keymap.rs233
-rw-r--r--crates/client/src/command/client/search/keybindings/mod.rs14
-rw-r--r--crates/client/src/command/client/stats.rs5
-rw-r--r--crates/client/src/shell/.gitattributes1
-rw-r--r--crates/client/src/shell/atuin.bash672
-rw-r--r--crates/client/src/shell/atuin.fish102
-rw-r--r--crates/client/src/shell/atuin.nu121
-rw-r--r--crates/client/src/shell/atuin.ps1240
-rw-r--r--crates/client/src/shell/atuin.xsh86
-rw-r--r--crates/client/src/shell/atuin.zsh167
-rw-r--r--crates/common/Cargo.toml2
-rw-r--r--crates/daemon/Cargo.toml2
-rw-r--r--crates/daemon/build.rs1
-rw-r--r--crates/daemon/proto/search.proto35
-rw-r--r--crates/daemon/src/aclient/api_client.rs (renamed from crates/client/src/atuin_client/api_client.rs)4
-rw-r--r--crates/daemon/src/aclient/database/mod.rs (renamed from crates/client/src/atuin_client/database.rs)11
-rw-r--r--crates/daemon/src/aclient/encryption.rs (renamed from crates/client/src/atuin_client/encryption.rs)2
-rw-r--r--crates/daemon/src/aclient/history/builder.rs (renamed from crates/client/src/atuin_client/history/builder.rs)0
-rw-r--r--crates/daemon/src/aclient/history/mod.rs (renamed from crates/client/src/atuin_client/history.rs)26
-rw-r--r--crates/daemon/src/aclient/history/store.rs (renamed from crates/client/src/atuin_client/history/store.rs)8
-rw-r--r--crates/daemon/src/aclient/meta.rs (renamed from crates/client/src/atuin_client/meta.rs)4
-rw-r--r--crates/daemon/src/aclient/mod.rs11
-rw-r--r--crates/daemon/src/aclient/ordering.rs (renamed from crates/client/src/atuin_client/ordering.rs)0
-rw-r--r--crates/daemon/src/aclient/record/encryption.rs (renamed from crates/client/src/atuin_client/record/encryption.rs)4
-rw-r--r--crates/daemon/src/aclient/record/mod.rs (renamed from crates/client/src/atuin_client/record/mod.rs)3
-rw-r--r--crates/daemon/src/aclient/record/sqlite_store.rs (renamed from crates/client/src/atuin_client/record/sqlite_store.rs)10
-rw-r--r--crates/daemon/src/aclient/record/sync.rs (renamed from crates/client/src/atuin_client/record/sync.rs)18
-rw-r--r--crates/daemon/src/aclient/secrets.rs (renamed from crates/client/src/atuin_client/secrets.rs)2
-rw-r--r--crates/daemon/src/aclient/settings/meta.rs17
-rw-r--r--crates/daemon/src/aclient/settings/mod.rs1544
-rw-r--r--crates/daemon/src/aclient/settings/watcher.rs260
-rw-r--r--crates/daemon/src/aclient/utils.rs (renamed from crates/client/src/atuin_client/utils.rs)2
-rw-r--r--crates/daemon/src/client.rs134
-rw-r--r--crates/daemon/src/components/history.rs10
-rw-r--r--crates/daemon/src/components/mod.rs15
-rw-r--r--crates/daemon/src/components/search.rs407
-rw-r--r--crates/daemon/src/components/semantic.rs10
-rw-r--r--crates/daemon/src/components/sync.rs6
-rw-r--r--crates/daemon/src/control/mod.rs22
-rw-r--r--crates/daemon/src/daemon.rs50
-rw-r--r--crates/daemon/src/events.rs2
-rw-r--r--crates/daemon/src/generated.rs16
-rw-r--r--crates/daemon/src/lib.rs32
-rw-r--r--crates/daemon/src/main.rs (renamed from crates/client/src/command/client/daemon.rs)270
-rw-r--r--crates/daemon/src/search/mod.rs557
-rw-r--r--crates/daemon/src/server.rs23
-rw-r--r--crates/server/Cargo.toml2
113 files changed, 2032 insertions, 21375 deletions
diff --git a/crates/atuin-nucleo/.gitignore b/crates/atuin-nucleo/.gitignore
deleted file mode 100644
index 73fab072..00000000
--- a/crates/atuin-nucleo/.gitignore
+++ /dev/null
@@ -1,10 +0,0 @@
-# Generated by Cargo
-# will have compiled files and executables
-debug/
-target/
-
-# These are backup files generated by rustfmt
-**/*.rs.bk
-
-# MSVC Windows builds of rustc generate these, which store debugging information
-*.pdb
diff --git a/crates/atuin-nucleo/CHANGELOG.md b/crates/atuin-nucleo/CHANGELOG.md
deleted file mode 100644
index 4ea54b2c..00000000
--- a/crates/atuin-nucleo/CHANGELOG.md
+++ /dev/null
@@ -1,54 +0,0 @@
-# Changelog
-
-# [0.5.0] - 2024-4-2
-
-## **Breaking Changes**
-
-* `Injector::push` now passes a reference to the push value to the closure generating the columns
-
-
-# [0.4.1] - 2024-3-11
-
-## Bugfixes
-
-* crash when restarting picker with fast active stream
-
-# [0.4.0] - 2024-2-20
-
-## Added
-
-* `active_injectors()` to retrieve the number of injectors that can potentially add new items to the matcher in the future.
-
-## Bugfixes
-
-* fix Unicode substring matcher expecting an exact match (rejecting trailing characters)
-* fix crashes and false positives in unicode substring matcher
-
-# [0.3.0] - 2023-12-22
-
-## **Breaking Changes**
-
-* Pattern API method now requires a Unicode `Normalization` strategy in addition to a `CaseMatching` strategy.
-
-## Bugfixes
-
-* avoid incorrect matches when searching for ASCII needles in a Unicode haystack
-* correctly handle Unicode normalization when there are normalizable characters in the pattern, for example characters with umlauts
-* when the needle is composed of a single char, return the score and index
- of the best position instead of always returning the first matched character
- in the haystack
-
-# [0.2.1] - 2023-09-02
-
-## Bugfixes
-
-* ensure matcher runs on first call to `tick`
-
-# [0.2.0] - 2023-09-01
-
-*initial public release*
-
-
-[0.3.0]: https://github.com/helix-editor/nucleo/releases/tag/nucleo-v0.3.0
-[0.2.1]: https://github.com/helix-editor/nucleo/releases/tag/nucleo-v0.2.1
-[0.2.0]: https://github.com/helix-editor/nucleo/releases/tag/nucleo-v0.2.0
diff --git a/crates/atuin-nucleo/Cargo.toml b/crates/atuin-nucleo/Cargo.toml
deleted file mode 100644
index cd53e578..00000000
--- a/crates/atuin-nucleo/Cargo.toml
+++ /dev/null
@@ -1,17 +0,0 @@
-[package]
-name = "atuin-nucleo"
-description = "A fork of helix-editor/nucleo with filtering and custom scoring for Atuin"
-authors = ["Pascal Kuthe <pascalkuthe@pm.me>", "Michelle Tilley <michelle@atuin.sh>"]
-version = "0.6.0"
-edition = "2021"
-license = "MPL-2.0"
-repository = "https://github.com/atuinsh/atuin"
-readme = "README.md"
-exclude = ["/typos.toml", "/tarpaulin.toml"]
-
-[lib]
-
-[dependencies]
-atuin-nucleo-matcher = { version = "0.3.1", path = "matcher" }
-parking_lot = { version = "0.12.1", features = ["send_guard", "arc_lock"] }
-rayon = "1.7.0"
diff --git a/crates/atuin-nucleo/LICENSE b/crates/atuin-nucleo/LICENSE
deleted file mode 100644
index a612ad98..00000000
--- a/crates/atuin-nucleo/LICENSE
+++ /dev/null
@@ -1,373 +0,0 @@
-Mozilla Public License Version 2.0
-==================================
-
-1. Definitions
---------------
-
-1.1. "Contributor"
- means each individual or legal entity that creates, contributes to
- the creation of, or owns Covered Software.
-
-1.2. "Contributor Version"
- means the combination of the Contributions of others (if any) used
- by a Contributor and that particular Contributor's Contribution.
-
-1.3. "Contribution"
- means Covered Software of a particular Contributor.
-
-1.4. "Covered Software"
- means Source Code Form to which the initial Contributor has attached
- the notice in Exhibit A, the Executable Form of such Source Code
- Form, and Modifications of such Source Code Form, in each case
- including portions thereof.
-
-1.5. "Incompatible With Secondary Licenses"
- means
-
- (a) that the initial Contributor has attached the notice described
- in Exhibit B to the Covered Software; or
-
- (b) that the Covered Software was made available under the terms of
- version 1.1 or earlier of the License, but not also under the
- terms of a Secondary License.
-
-1.6. "Executable Form"
- means any form of the work other than Source Code Form.
-
-1.7. "Larger Work"
- means a work that combines Covered Software with other material, in
- a separate file or files, that is not Covered Software.
-
-1.8. "License"
- means this document.
-
-1.9. "Licensable"
- means having the right to grant, to the maximum extent possible,
- whether at the time of the initial grant or subsequently, any and
- all of the rights conveyed by this License.
-
-1.10. "Modifications"
- means any of the following:
-
- (a) any file in Source Code Form that results from an addition to,
- deletion from, or modification of the contents of Covered
- Software; or
-
- (b) any new file in Source Code Form that contains any Covered
- Software.
-
-1.11. "Patent Claims" of a Contributor
- means any patent claim(s), including without limitation, method,
- process, and apparatus claims, in any patent Licensable by such
- Contributor that would be infringed, but for the grant of the
- License, by the making, using, selling, offering for sale, having
- made, import, or transfer of either its Contributions or its
- Contributor Version.
-
-1.12. "Secondary License"
- means either the GNU General Public License, Version 2.0, the GNU
- Lesser General Public License, Version 2.1, the GNU Affero General
- Public License, Version 3.0, or any later versions of those
- licenses.
-
-1.13. "Source Code Form"
- means the form of the work preferred for making modifications.
-
-1.14. "You" (or "Your")
- means an individual or a legal entity exercising rights under this
- License. For legal entities, "You" includes any entity that
- controls, is controlled by, or is under common control with You. For
- purposes of this definition, "control" means (a) the power, direct
- or indirect, to cause the direction or management of such entity,
- whether by contract or otherwise, or (b) ownership of more than
- fifty percent (50%) of the outstanding shares or beneficial
- ownership of such entity.
-
-2. License Grants and Conditions
---------------------------------
-
-2.1. Grants
-
-Each Contributor hereby grants You a world-wide, royalty-free,
-non-exclusive license:
-
-(a) under intellectual property rights (other than patent or trademark)
- Licensable by such Contributor to use, reproduce, make available,
- modify, display, perform, distribute, and otherwise exploit its
- Contributions, either on an unmodified basis, with Modifications, or
- as part of a Larger Work; and
-
-(b) under Patent Claims of such Contributor to make, use, sell, offer
- for sale, have made, import, and otherwise transfer either its
- Contributions or its Contributor Version.
-
-2.2. Effective Date
-
-The licenses granted in Section 2.1 with respect to any Contribution
-become effective for each Contribution on the date the Contributor first
-distributes such Contribution.
-
-2.3. Limitations on Grant Scope
-
-The licenses granted in this Section 2 are the only rights granted under
-this License. No additional rights or licenses will be implied from the
-distribution or licensing of Covered Software under this License.
-Notwithstanding Section 2.1(b) above, no patent license is granted by a
-Contributor:
-
-(a) for any code that a Contributor has removed from Covered Software;
- or
-
-(b) for infringements caused by: (i) Your and any other third party's
- modifications of Covered Software, or (ii) the combination of its
- Contributions with other software (except as part of its Contributor
- Version); or
-
-(c) under Patent Claims infringed by Covered Software in the absence of
- its Contributions.
-
-This License does not grant any rights in the trademarks, service marks,
-or logos of any Contributor (except as may be necessary to comply with
-the notice requirements in Section 3.4).
-
-2.4. Subsequent Licenses
-
-No Contributor makes additional grants as a result of Your choice to
-distribute the Covered Software under a subsequent version of this
-License (see Section 10.2) or under the terms of a Secondary License (if
-permitted under the terms of Section 3.3).
-
-2.5. Representation
-
-Each Contributor represents that the Contributor believes its
-Contributions are its original creation(s) or it has sufficient rights
-to grant the rights to its Contributions conveyed by this License.
-
-2.6. Fair Use
-
-This License is not intended to limit any rights You have under
-applicable copyright doctrines of fair use, fair dealing, or other
-equivalents.
-
-2.7. Conditions
-
-Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
-in Section 2.1.
-
-3. Responsibilities
--------------------
-
-3.1. Distribution of Source Form
-
-All distribution of Covered Software in Source Code Form, including any
-Modifications that You create or to which You contribute, must be under
-the terms of this License. You must inform recipients that the Source
-Code Form of the Covered Software is governed by the terms of this
-License, and how they can obtain a copy of this License. You may not
-attempt to alter or restrict the recipients' rights in the Source Code
-Form.
-
-3.2. Distribution of Executable Form
-
-If You distribute Covered Software in Executable Form then:
-
-(a) such Covered Software must also be made available in Source Code
- Form, as described in Section 3.1, and You must inform recipients of
- the Executable Form how they can obtain a copy of such Source Code
- Form by reasonable means in a timely manner, at a charge no more
- than the cost of distribution to the recipient; and
-
-(b) You may distribute such Executable Form under the terms of this
- License, or sublicense it under different terms, provided that the
- license for the Executable Form does not attempt to limit or alter
- the recipients' rights in the Source Code Form under this License.
-
-3.3. Distribution of a Larger Work
-
-You may create and distribute a Larger Work under terms of Your choice,
-provided that You also comply with the requirements of this License for
-the Covered Software. If the Larger Work is a combination of Covered
-Software with a work governed by one or more Secondary Licenses, and the
-Covered Software is not Incompatible With Secondary Licenses, this
-License permits You to additionally distribute such Covered Software
-under the terms of such Secondary License(s), so that the recipient of
-the Larger Work may, at their option, further distribute the Covered
-Software under the terms of either this License or such Secondary
-License(s).
-
-3.4. Notices
-
-You may not remove or alter the substance of any license notices
-(including copyright notices, patent notices, disclaimers of warranty,
-or limitations of liability) contained within the Source Code Form of
-the Covered Software, except that You may alter any license notices to
-the extent required to remedy known factual inaccuracies.
-
-3.5. Application of Additional Terms
-
-You may choose to offer, and to charge a fee for, warranty, support,
-indemnity or liability obligations to one or more recipients of Covered
-Software. However, You may do so only on Your own behalf, and not on
-behalf of any Contributor. You must make it absolutely clear that any
-such warranty, support, indemnity, or liability obligation is offered by
-You alone, and You hereby agree to indemnify every Contributor for any
-liability incurred by such Contributor as a result of warranty, support,
-indemnity or liability terms You offer. You may include additional
-disclaimers of warranty and limitations of liability specific to any
-jurisdiction.
-
-4. Inability to Comply Due to Statute or Regulation
----------------------------------------------------
-
-If it is impossible for You to comply with any of the terms of this
-License with respect to some or all of the Covered Software due to
-statute, judicial order, or regulation then You must: (a) comply with
-the terms of this License to the maximum extent possible; and (b)
-describe the limitations and the code they affect. Such description must
-be placed in a text file included with all distributions of the Covered
-Software under this License. Except to the extent prohibited by statute
-or regulation, such description must be sufficiently detailed for a
-recipient of ordinary skill to be able to understand it.
-
-5. Termination
---------------
-
-5.1. The rights granted under this License will terminate automatically
-if You fail to comply with any of its terms. However, if You become
-compliant, then the rights granted under this License from a particular
-Contributor are reinstated (a) provisionally, unless and until such
-Contributor explicitly and finally terminates Your grants, and (b) on an
-ongoing basis, if such Contributor fails to notify You of the
-non-compliance by some reasonable means prior to 60 days after You have
-come back into compliance. Moreover, Your grants from a particular
-Contributor are reinstated on an ongoing basis if such Contributor
-notifies You of the non-compliance by some reasonable means, this is the
-first time You have received notice of non-compliance with this License
-from such Contributor, and You become compliant prior to 30 days after
-Your receipt of the notice.
-
-5.2. If You initiate litigation against any entity by asserting a patent
-infringement claim (excluding declaratory judgment actions,
-counter-claims, and cross-claims) alleging that a Contributor Version
-directly or indirectly infringes any patent, then the rights granted to
-You by any and all Contributors for the Covered Software under Section
-2.1 of this License shall terminate.
-
-5.3. In the event of termination under Sections 5.1 or 5.2 above, all
-end user license agreements (excluding distributors and resellers) which
-have been validly granted by You or Your distributors under this License
-prior to termination shall survive termination.
-
-************************************************************************
-* *
-* 6. Disclaimer of Warranty *
-* ------------------------- *
-* *
-* Covered Software is provided under this License on an "as is" *
-* basis, without warranty of any kind, either expressed, implied, or *
-* statutory, including, without limitation, warranties that the *
-* Covered Software is free of defects, merchantable, fit for a *
-* particular purpose or non-infringing. The entire risk as to the *
-* quality and performance of the Covered Software is with You. *
-* Should any Covered Software prove defective in any respect, You *
-* (not any Contributor) assume the cost of any necessary servicing, *
-* repair, or correction. This disclaimer of warranty constitutes an *
-* essential part of this License. No use of any Covered Software is *
-* authorized under this License except under this disclaimer. *
-* *
-************************************************************************
-
-************************************************************************
-* *
-* 7. Limitation of Liability *
-* -------------------------- *
-* *
-* Under no circumstances and under no legal theory, whether tort *
-* (including negligence), contract, or otherwise, shall any *
-* Contributor, or anyone who distributes Covered Software as *
-* permitted above, be liable to You for any direct, indirect, *
-* special, incidental, or consequential damages of any character *
-* including, without limitation, damages for lost profits, loss of *
-* goodwill, work stoppage, computer failure or malfunction, or any *
-* and all other commercial damages or losses, even if such party *
-* shall have been informed of the possibility of such damages. This *
-* limitation of liability shall not apply to liability for death or *
-* personal injury resulting from such party's negligence to the *
-* extent applicable law prohibits such limitation. Some *
-* jurisdictions do not allow the exclusion or limitation of *
-* incidental or consequential damages, so this exclusion and *
-* limitation may not apply to You. *
-* *
-************************************************************************
-
-8. Litigation
--------------
-
-Any litigation relating to this License may be brought only in the
-courts of a jurisdiction where the defendant maintains its principal
-place of business and such litigation shall be governed by laws of that
-jurisdiction, without reference to its conflict-of-law provisions.
-Nothing in this Section shall prevent a party's ability to bring
-cross-claims or counter-claims.
-
-9. Miscellaneous
-----------------
-
-This License represents the complete agreement concerning the subject
-matter hereof. If any provision of this License is held to be
-unenforceable, such provision shall be reformed only to the extent
-necessary to make it enforceable. Any law or regulation which provides
-that the language of a contract shall be construed against the drafter
-shall not be used to construe this License against a Contributor.
-
-10. Versions of the License
----------------------------
-
-10.1. New Versions
-
-Mozilla Foundation is the license steward. Except as provided in Section
-10.3, no one other than the license steward has the right to modify or
-publish new versions of this License. Each version will be given a
-distinguishing version number.
-
-10.2. Effect of New Versions
-
-You may distribute the Covered Software under the terms of the version
-of the License under which You originally received the Covered Software,
-or under the terms of any subsequent version published by the license
-steward.
-
-10.3. Modified Versions
-
-If you create software not governed by this License, and you want to
-create a new license for such software, you may create and use a
-modified version of this License if you rename the license and remove
-any references to the name of the license steward (except to note that
-such modified license differs from this License).
-
-10.4. Distributing Source Code Form that is Incompatible With Secondary
-Licenses
-
-If You choose to distribute Source Code Form that is Incompatible With
-Secondary Licenses under the terms of this version of the License, the
-notice described in Exhibit B of this License must be attached.
-
-Exhibit A - Source Code Form License Notice
--------------------------------------------
-
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/.
-
-If it is not possible or desirable to put the notice in a particular
-file, then You may include the notice in a location (such as a LICENSE
-file in a relevant directory) where a recipient would be likely to look
-for such a notice.
-
-You may add additional accurate notices of copyright ownership.
-
-Exhibit B - "Incompatible With Secondary Licenses" Notice
----------------------------------------------------------
-
- This Source Code Form is "Incompatible With Secondary Licenses", as
- defined by the Mozilla Public License, v. 2.0.
diff --git a/crates/atuin-nucleo/README.md b/crates/atuin-nucleo/README.md
deleted file mode 100644
index d7c10735..00000000
--- a/crates/atuin-nucleo/README.md
+++ /dev/null
@@ -1,209 +0,0 @@
-# nucleo-ext
-
-This is a fork of [helix-editor/nucleo](https://github.com/helix-editor/nucleo) that includes filtering and custom scoring built into the matching loop.
-
-## Fork Changes
-
-### Filter Callback
-
-Filter items before fuzzy matching. Useful for category/facet filtering (e.g., filter by directory, host, session).
-
-```rust
-use std::sync::Arc;
-
-// Filter to only include items where category == 1
-nucleo.set_filter(Some(Arc::new(|item: &MyItem| item.category == 1)));
-
-// Remove filter
-nucleo.set_filter(None);
-```
-
-Filters are applied:
-- During initial matching of new items
-- When rescoring existing matches after pattern changes
-- When resetting matches for empty patterns
-
-Setting a filter triggers a rescore on the next `tick()`.
-
-### Scorer Callback
-
-Compute custom scores after fuzzy matching. The scorer receives the item and the fuzzy score, and returns the final score used for sorting.
-
-```rust
-use std::sync::Arc;
-
-// Use item priority as the score (ignoring fuzzy score)
-nucleo.set_scorer(Some(Arc::new(|item: &MyItem, _fuzzy_score| {
- item.priority
-})));
-
-// Combine fuzzy score with frecency
-nucleo.set_scorer(Some(Arc::new(|item: &MyItem, fuzzy_score| {
- fuzzy_score + item.frecency_score
-})));
-
-// Remove scorer (use raw fuzzy score)
-nucleo.set_scorer(None);
-```
-
-The scorer output is stored in `Match::external_score` and used as the primary sort key.
-
-### Match Struct Changes
-
-The `Match` struct now has an additional field:
-
-```rust
-pub struct Match {
- pub score: u32, // Raw fuzzy match score
- pub external_score: u32, // Scorer output (used for sorting)
- pub idx: u32, // Item index
-}
-```
-
-Results are sorted by `external_score` (descending), with tie-breakers on item length and index.
-
-### Use Case: Frecency Ranking
-
-This fork was created to support frecency-based ranking in [Atuin](https://github.com/atuinsh/atuin). The typical pattern is:
-
-1. Store metadata (frecency scores, categories) in a separate data structure
-2. Use filter callback to exclude items that don't match the current context
-3. Use scorer callback to combine fuzzy score with frecency at query time
-
-```rust
-// External data store
-let metadata: Arc<DashMap<String, ItemMetadata>> = /* ... */;
-
-// Filter: only show items used in current directory
-let dir = current_dir.clone();
-let meta = metadata.clone();
-nucleo.set_filter(Some(Arc::new(move |cmd: &String| {
- meta.get(cmd).map(|m| m.used_in_dir(&dir)).unwrap_or(false)
-})));
-
-// Scorer: combine fuzzy score with frecency
-let meta = metadata.clone();
-nucleo.set_scorer(Some(Arc::new(move |cmd: &String, fuzzy_score| {
- let frecency = meta.get(cmd).map(|m| m.frecency()).unwrap_or(0);
- fuzzy_score + (frecency * 10)
-})));
-```
-
----
-
-The original Nucleo readme follows.
-
----
-
-
-`nucleo` is a highly performant fuzzy matcher written in Rust. It aims to fill the same use case as `fzf` and `skim`. Compared to `fzf` `nucleo` has a significantly faster matching algorithm. This mainly makes a difference when matching patterns with low selectivity on many items. An (unscientific) comparison is shown in the benchmark section below.
-
-> Note: If you are looking for a replacement of the `fuzzy-matcher` crate and not a fully managed fuzzy picker, you should use the [`nucleo-matcher`](https://crates.io/crates/nucleo-matcher) crate.
-
-`nucleo` uses the exact **same scoring system as fzf**. That means you should get the same ranking quality (or better) as you are used to from fzf. However, `nucleo` has a more faithful implementation of the Smith-Waterman algorithm which is normally used in DNA sequence alignment (see https://www.cs.cmu.edu/~ckingsf/bioinfo-lectures/gaps.pdf) with two separate matrices (instead of one like fzf). This means that `nucleo` finds the optimal match more often. For example if you match `foo` in `xf foo` `nucleo` will match `x__foo` but `fzf` will match `xf_oo` (you can increase the word length the result will stay the same). The former is the more intuitive match and has a higher score according to the ranking system that both `nucleo` and fzf.
-
-**Compared to `skim`** (and the `fuzzy-matcher` crate) `nucleo` has an even larger performance advantage and is often around **six times faster** (see benchmarks below). Furthermore, the bonus system used by nucleo and fzf is (in my opinion) more consistent/superior. `nucleo` also handles non-ascii text much better. (`skim`s bonus system and even case insensitivity only work for ASCII).
-
-Nucleo also handles Unicode graphemes more correctly. `Fzf` and `skim` both operate on Unicode code points (chars). That means that multi codepoint graphemes can have weird effects (match multiple times, weirdly change the score, ...). `nucleo` will always use the first codepoint of the grapheme for matching instead (and reports grapheme indices, so they can be highlighted correctly).
-
-## Status
-
-Nucleo is used in the helix-editor and therefore has a large user base with lots of real world testing. The core matcher implementation is considered complete and is unlikely to see major changes. The `nucleo-matcher` crate is finished and ready for widespread use, breaking changes should be very rare (a 1.0 release should not be far away).
-
-While the high level `nucleo` crate also works well (and is also used in helix), there are still additional features that will be added in the future. The high level crate also need better documentation and will likely see a few API changes in the future.
-
-## Benchmarks
-
-> WIP currently more of a demonstration than a comprehensive benchmark suit
-> most notably scientific comparisons with `fzf` are missing (a pain because it can't be called as a library)
-
-
-### Matcher micro benchmarks
-
-Benchmark comparing the runtime of various patterns matched against all files in the source of the linux kernel. Repeat on your system with `BENCHMARK_DIR=<path_to_linux> cargo run -p benches --release` (you can specify an empty directory and the kernel is cloned automatically).
-
-Method | Mean | Samples
------------------------|-----------|-----------
-nucleo "never_matches" | 2.30 ms |2,493/2,500
-skim "never_matches" | 17.44 ms | 574/574
-nucleo "copying" | 2.12 ms |2,496/2,500
-skim "copying" | 16.85 ms | 593/594
-nucleo "/doc/kernel" | 2.59 ms |2,499/2,500
-skim "/doc/kernel" | 18.32 ms | 546/546
-nucleo "//.h" | 9.53 ms |1,049/1,049
-skim "//.h" | 35.46 ms | 282/282
-
-
-### Comparison with fzf
-
-For example in the following two screencasts the pattern `///.` is pasted into `fzf` and `nucleo` (both with about 3 million items open).
-
-`fzf` takes a while to filter the text (about 1 second) while `nucleo` has barely any noticeable delay (a single frame in the screencast so about 1/30 seconds). This comparison was made on a very beefy CPU (Ryzen 5950x) so on slower systems the difference may be larger:
-
-[![asciicast](https://asciinema.org/a/600517.svg)](https://asciinema.org/a/600517)
-[![asciicast](https://asciinema.org/a/600516.svg)](https://asciinema.org/a/600516)
-
-
-
-# Future Work
-
-* [x] merge integration into helix
-* [ ] build a standalone CLI application
- * [ ] reach feature parity with `fzf` (mostly `--no-sort` and `--tac`)
- * [ ] add a way to allow columnar matching
-* [ ] expose C API so both the high level API and the matching algorithm itself can be used in other applications (like various nvim plugins)
-
-# Naming
-
-The name `nucleo` plays on the fact that the `Smith-Waterman` algorithm (that it's based on) was originally developed for matching DNA/RNA sequences. The elements of DNA/RNA that are matched are called *nucleotides* which was shortened to `nucleo` here.
-
-The name also indicates its close relationship with the *helix* editor (sticking with the DNA theme).
-
-# Implementation Details
-
-> This is only intended for those interested and will not be relevant to most people. I plan to turn this into a blog post when I have more time
-
-<!-- Nucleo matching algorithm has `O(N-M)` space complexity while ranking/filtering (and not computing indices) compared to the `O(MN)` space complexity of fzf. -->
-
-<!-- Furthermore, `nucleo` also features fully lock-free multithreaded streaming so if used as a library its possible to performantly scale streaming to a practically unlimited number of producer threads (for example running `ignore` or `jwalk` across all cores) without any buffering or other additional logic. -->
-
-
-The fuzzy matching algorithm is based on the `Smith-Waterman` (with affine gaps) as described in https://www.cs.cmu.edu/~ckingsf/bioinfo-lectures/gaps.pdf (TODO: explain). `Nucleo` faithfully implements this algorithm and therefore has two separate matrices. However, by precomputing the next `m-matrix` row we can avoid storing the p-matrix at all and instead just store the value in a variable as we iterate the row.
-
-Nucleo also never really stores the `m-matrix` instead we only ever store the current row (which simultaneously serves as the next row). During index calculation a full matrix is however required to backtrack which indices were actually matched. We only store two bools here (to indicate where we came from in the matrix).
-
-By comparison `skim` stores the full p and m matrix in that case. `fzf` always allocates a full `mn` matrix (even during matching!).
-
-`nucleo`s' matrix is only width `n-m+1` instead of width `n`. This comes from the observation that the `p` char requires `p-1` chars before it and `m-p` chars after it, so there are always `p-1 + m-p = m+1` chars that can never match the current char. This works especially well with only using a single row because the first relevant char is always at the same position even though it's technically further to the right. This is particularly nice because we precalculate the m-matrix row. The m-matrix is computed from diagonal elements, so the precalculated values stay in the same matrix cell.
-
-Compared to `skim` nucleo does couple simpler (but arguably even more impactful) optimizations:
-* *Presegment Unicode*: Unicode segmentation is somewhat slow and matcher will filter the same elements quite often so only doing it once is nice. It also prevents a very common source of bugs (mixing of char indices which we use here and utf8 indices) and makes the code a lot simpler as a result. Fzf does the same.
-* *Aggressive prefiltering*: Especially for ASCII this works very well, but we also do this for Unicode to a lesser extent. This ensures we reject non-matching haystacks as fast as possible. Usually most haystacks will not match when fuzzy matching large lists so having fast path for that case is a huge win.
-* *Special-case ASCII*: 90% of practical text is ASCII. ASCII can be stored as bytes instead of `chars`, so cache locality is improved a lot, and we can use `memchar` for superfast prefilters (even case-insensitive prefilter are possible that way)
-* *Fallback for very long matches*: We fall back to greedy matcher which runs in `O(N)` (and `O(1)` space complexity) to avoid the `O(mn)` blowup for large matches. This is fzfs old algorithm and yields decent (but not great) results.
-
-
-
-<!-- There is a misunderstanding in both `skim` and fzf. Basically what they do is give a bonus to each character (like word boundaries). That makes senes and is reasonable, but the problem is that they use the **maximum bonus** when multiple chars match in sequence. That means that the bonus of a character depends on which characters exactly matched around it. But the fundamental assumption of this algorithm (and why it doesn't require backtracking) is that the score of each character is independent of what other chars matched (this is the difference between the affine gap and the generic gap case shown in the paper too). During fuzzing I found many cases where this mechanism leads to a non-optimal match being reported (so the sort order and fuzzy indices would be wrong). In my testing removing this mechanism and slightly tweaking the bonus calculation results in similar match quality but made sure the algorithm always worked correctly (and removed a bunch of weird edges cases). -->
- <!-- * [ ] it seems this makes us overemphasize word boundaries for small search strings, this is likely okay as the consecutive bonus wins fairly quickly. Maybe we just do a greedy search for the first 2 chars to reduce visual noise? -->
-<!-- * [x] substring/prefix/postfix/exact matcher -->
-<!-- * [ ] case mismatch penalty. This doesn't seem like a good idea to me. `FZF` doesn't do this (only skin), smart case should cover most cases. .would be nice for fully case-insensitive matching without smart case like in autocompletion tough. Realistically there won't be more than 3 items that are identical with different casing tough, so I don't think it matters too much. It is a bit annoying to implement since you can no longer pre-normalize queries(or need two queries) :/ -->
-<!-- * [ ] high level API (worker thread, query parsing, sorting), in progress -->
- <!-- * apparently sorting is superfast (at most 5% of match time for `nucleo` matcher with a highly selective query, otherwise its completely negligible compared to fuzzy matching). All the bending over backwards `fzf` does (and `skim` copied but way worse) seems a little silly. I think `fzf` does it because go doesn't have a good parallel sort. `Fzf` divides the matches into a couple fairly large chunks and sorts those on each worker thread and then lazily merges the result. That makes the sorting without the merging `Nlog(N/M)` which is basically equivalent for large `N` and small `M` as is the case here. At least its parallel tough. In rust we have a great pattern defeating parallel quicksort tough (rayon) which is way easier. -->
- <!-- * [x] basic implementation (workers, streaming, invalidation) -->
- <!-- * [x] verify it actually works -->
- <!-- * [x] query paring -->
- <!-- * [x] hook up to helix -->
- <!-- * [x] currently I simply use a tick system (called on every redraw), together with a redraw/tick nofication (ideally debounced) is that enough? yes works nicely -->
- <!-- * [x] for streaming callers should buffer their data. Can we provide a better API for that beyond what is currently there? yes lock-free stream -->
- <!-- * [ ] cleanup code, improve API -->
- <!-- * [ ] write docs -->
-
-<!-- * tests -->
- <!-- * [x] fuzz the fuzzy matcher -->
- <!-- * [x] port the full `fzf` test suite for fuzzy matching -->
- <!-- * [ ] port the full `skim` test suite for fuzzy matching -->
- <!-- * [ ] highlevel API -->
- <!-- * [~] test substring/exact/prefix/postfix match -->
- <!-- * [ ] coverage report (fuzzy matcher was at 86%) -->
-
diff --git a/crates/atuin-nucleo/bench/Cargo.toml b/crates/atuin-nucleo/bench/Cargo.toml
deleted file mode 100644
index d71734e1..00000000
--- a/crates/atuin-nucleo/bench/Cargo.toml
+++ /dev/null
@@ -1,10 +0,0 @@
-[package]
-name = "atuin-nucleo-bench"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-atuin-nucleo = { version = "*", path = "../" }
-brunch = "0.5.0"
-fuzzy-matcher = "0.3.7"
-walkdir = "2"
diff --git a/crates/atuin-nucleo/bench/src/main.rs b/crates/atuin-nucleo/bench/src/main.rs
deleted file mode 100644
index 43e2ed26..00000000
--- a/crates/atuin-nucleo/bench/src/main.rs
+++ /dev/null
@@ -1,76 +0,0 @@
-use std::hint::black_box;
-use std::path::PathBuf;
-use std::process::Command;
-
-use atuin_nucleo::{Utf32Str, Utf32String};
-use brunch::{Bench, Benches};
-use fuzzy_matcher::FuzzyMatcher;
-
-fn bench_dir() -> PathBuf {
- std::env::var_os("BENCHMARK_DIR")
- .expect("the BENCHMARK_DIR must be set to the directory to traverse for the benchmark")
- .into()
-}
-
-fn checkout_linux_if_needed() {
- let linux_dir = bench_dir();
- if !linux_dir.exists() {
- println!("will git clone linux...");
- let output = Command::new("git")
- .arg("clone")
- .arg("https://github.com/BurntSushi/linux.git")
- .arg("--depth")
- .arg("1")
- .arg("--branch")
- .arg("master")
- .arg("--single-branch")
- .arg(&linux_dir)
- .stdout(std::process::Stdio::inherit())
- .status()
- .expect("failed to git clone linux");
- println!("did git clone linux...{:?}", output);
- }
-}
-
-fn main() {
- checkout_linux_if_needed();
- let dir = bench_dir();
- let paths: (Vec<Utf32String>, Vec<String>) = walkdir::WalkDir::new(dir)
- .into_iter()
- .filter_map(|path| {
- let dent = path.ok()?;
- let path = dent.into_path().to_string_lossy().into_owned();
- Some((path.as_str().into(), path))
- })
- .unzip();
- let mut nucleo = atuin_nucleo::Matcher::new(atuin_nucleo::Config::DEFAULT.match_paths());
- let skim = fuzzy_matcher::skim::SkimMatcherV2::default();
-
- // TODO: unicode?
- let needles = ["never_matches", "copying", "/doc/kernel", "//.h"];
- // Announce that we've started.
- ::std::eprint!("\x1b[1;38;5;199mStarting:\x1b[0m Running benchmark(s). Stand by!\n\n");
- let mut benches = Benches::default();
- // let mut scores = Vec::with_capacity(paths.0.len());
- for needle in needles {
- println!("running {needle:?}...");
- benches.push(Bench::new(format!("nucleo {needle:?}")).run(|| {
- // scores.clear();
- // scores.extend(paths.0.iter().filter_map(|haystack| {
- for haystack in &paths.0 {
- black_box(
- nucleo.fuzzy_match(haystack.slice(..), Utf32Str::Ascii(needle.as_bytes())),
- );
- }
- // }));
- // scores.sort_unstable();
- }));
- benches.push(Bench::new(format!("skim {needle:?}")).run(|| {
- for haystack in &paths.1 {
- let res = skim.fuzzy_match(haystack, needle);
- let _ = black_box(res);
- }
- }));
- }
- benches.finish();
-}
diff --git a/crates/atuin-nucleo/matcher/Cargo.toml b/crates/atuin-nucleo/matcher/Cargo.toml
deleted file mode 100644
index feb10310..00000000
--- a/crates/atuin-nucleo/matcher/Cargo.toml
+++ /dev/null
@@ -1,19 +0,0 @@
-[package]
-name = "atuin-nucleo-matcher"
-description = "plug and play high performance fuzzy matcher"
-authors = ["Pascal Kuthe <pascalkuthe@pm.me>"]
-version = "0.3.1"
-edition = "2021"
-license = "MPL-2.0"
-repository = "https://github.com/atuinsh/atuin"
-readme = "../README.md"
-
-[dependencies]
-memchr = "2.5.0"
-unicode-segmentation = { version = "1.10", optional = true }
-
-[features]
-default = ["unicode-normalization", "unicode-casefold", "unicode-segmentation"]
-unicode-normalization = []
-unicode-casefold = []
-unicode-segmentation = ["dep:unicode-segmentation"]
diff --git a/crates/atuin-nucleo/matcher/LICENSE b/crates/atuin-nucleo/matcher/LICENSE
deleted file mode 120000
index ea5b6064..00000000
--- a/crates/atuin-nucleo/matcher/LICENSE
+++ /dev/null
@@ -1 +0,0 @@
-../LICENSE \ No newline at end of file
diff --git a/crates/atuin-nucleo/matcher/fuzz.sh b/crates/atuin-nucleo/matcher/fuzz.sh
deleted file mode 100755
index d3ffa2c9..00000000
--- a/crates/atuin-nucleo/matcher/fuzz.sh
+++ /dev/null
@@ -1,3 +0,0 @@
-#!/usr/bin/env bash
-
-cargo +nightly fuzz "${1}" fuzz_target_1 "${@:2:99}"
diff --git a/crates/atuin-nucleo/matcher/fuzz/.gitignore b/crates/atuin-nucleo/matcher/fuzz/.gitignore
deleted file mode 100644
index 1a45eee7..00000000
--- a/crates/atuin-nucleo/matcher/fuzz/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-target
-corpus
-artifacts
-coverage
diff --git a/crates/atuin-nucleo/matcher/fuzz/Cargo.toml b/crates/atuin-nucleo/matcher/fuzz/Cargo.toml
deleted file mode 100644
index 1b9d8a7f..00000000
--- a/crates/atuin-nucleo/matcher/fuzz/Cargo.toml
+++ /dev/null
@@ -1,29 +0,0 @@
-[package]
-name = "fzf_oxide-fuzz"
-version = "0.0.0"
-publish = false
-edition = "2021"
-
-[package.metadata]
-cargo-fuzz = true
-
-[dependencies]
-libfuzzer-sys = "0.4"
-arbitrary = { version = "1", features = ["derive"] }
-
-[dependencies.fzf_oxide]
-path = ".."
-
-# Prevent this from interfering with workspaces
-[workspace]
-members = ["."]
-
-[profile.release]
-debug = 1
-
-[[bin]]
-name = "fuzz_target_1"
-path = "fuzz_targets/fuzz_target_1.rs"
-test = false
-doc = false
-
diff --git a/crates/atuin-nucleo/matcher/fuzz/fuzz_targets/fuzz_target_1.rs b/crates/atuin-nucleo/matcher/fuzz/fuzz_targets/fuzz_target_1.rs
deleted file mode 100644
index 00940d58..00000000
--- a/crates/atuin-nucleo/matcher/fuzz/fuzz_targets/fuzz_target_1.rs
+++ /dev/null
@@ -1,78 +0,0 @@
-#![no_main]
-
-use fzf_oxide::{chars, Matcher, MatcherConfig, Utf32Str};
-use libfuzzer_sys::arbitrary::Arbitrary;
-use libfuzzer_sys::fuzz_target;
-
-#[derive(Arbitrary, Debug)]
-pub struct Input<'a> {
- haystack: &'a str,
- needle: &'a str,
- ignore_case: bool,
- normalize: bool,
-}
-
-fuzz_target!(|data: Input<'_>| {
- let mut data = data;
- let mut config = MatcherConfig::DEFAULT;
- config.ignore_case = data.ignore_case;
- config.normalize = data.normalize;
- let mut matcher = Matcher::new(config);
- let mut indices_optimal = Vec::new();
- let mut indices_greedy = Vec::new();
- let mut needle_buf = Vec::new();
- let mut haystack_buf = Vec::new();
- let normalize = |mut c: char| {
- if config.normalize {
- c = chars::normalize(c);
- }
- if config.ignore_case {
- c = chars::to_lower_case(c);
- }
- c
- };
- let needle: String = data.needle.chars().map(normalize).collect();
- let needle_chars: Vec<_> = needle.chars().collect();
- let needle = Utf32Str::new(&needle, &mut needle_buf);
- let haystack = Utf32Str::new(data.haystack, &mut haystack_buf);
-
- let greedy_score = matcher.fuzzy_indices_greedy(haystack, needle, &mut indices_greedy);
- if greedy_score.is_some() {
- let match_chars: Vec<_> = indices_greedy
- .iter()
- .map(|&i| normalize(haystack.get(i)))
- .collect();
- assert_eq!(
- match_chars, needle_chars,
- "failed match, found {indices_greedy:?} {match_chars:?} (greedy)"
- );
- }
- let optimal_score = matcher.fuzzy_indices(haystack, needle, &mut indices_optimal);
- if optimal_score.is_some() {
- let match_chars: Vec<_> = indices_optimal
- .iter()
- .map(|&i| normalize(haystack.get(i)))
- .collect();
- assert_eq!(
- match_chars, needle_chars,
- "failed match, found {indices_optimal:?} {match_chars:?}"
- );
- }
- match (greedy_score, optimal_score) {
- (None, Some(score)) => unreachable!("optimal matched {score} but greedy did not match"),
- (Some(score), None) => unreachable!("greedy matched {score} but optimal did not match"),
- (Some(greedy), Some(optimal)) => {
- assert!(
- greedy <= optimal,
- "optimal score must be at least the same as greedy score {greedy} {optimal}"
- );
- if indices_greedy == indices_optimal {
- assert_eq!(
- greedy, optimal,
- "if matching same char greedy and optimal score should be identical"
- )
- }
- }
- (None, None) => (),
- }
-});
diff --git a/crates/atuin-nucleo/matcher/generate_case_fold_table.sh b/crates/atuin-nucleo/matcher/generate_case_fold_table.sh
deleted file mode 100755
index 32a26697..00000000
--- a/crates/atuin-nucleo/matcher/generate_case_fold_table.sh
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/usr/bin/env bash
-set -e
-
-dir=$(pwd)
-mkdir /tmp/ucd-15.0.0
-cd /tmp/ucd-15.0.0
-curl -LO https://www.unicode.org/Public/zipped/15.0.0/UCD.zip
-unzip UCD.zip
-
-cd "${dir}"
-cargo install ucd-generate
-ucd-generate case-folding-simple /tmp/ucd-15.0.0 --chars > src/chars/case_fold.rs
-rm -rf /tmp/ucd-15.0.0
diff --git a/crates/atuin-nucleo/matcher/src/chars.rs b/crates/atuin-nucleo/matcher/src/chars.rs
deleted file mode 100644
index 045d9b2d..00000000
--- a/crates/atuin-nucleo/matcher/src/chars.rs
+++ /dev/null
@@ -1,207 +0,0 @@
-//! Utilities for working with (unicode) characters/codepoints
-
-use std::fmt::{self, Debug, Display};
-
-#[cfg(feature = "unicode-casefold")]
-use crate::chars::case_fold::CASE_FOLDING_SIMPLE;
-use crate::Config;
-
-//autogenerated by generate-ucd
-#[expect(warnings)]
-#[rustfmt::skip]
-#[cfg(feature = "unicode-casefold")]
-mod case_fold;
-#[cfg(feature = "unicode-normalization")]
-mod normalize;
-
-pub(crate) trait Char: Copy + Eq + Ord + fmt::Display {
- const ASCII: bool;
- fn char_class(self, config: &Config) -> CharClass;
- fn char_class_and_normalize(self, config: &Config) -> (Self, CharClass);
- fn normalize(self, config: &Config) -> Self;
-}
-
-/// repr tansparent wrapper around u8 with better formatting and `PartialEq<char>` implementation
-#[repr(transparent)]
-#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
-pub(crate) struct AsciiChar(pub u8);
-
-impl AsciiChar {
- pub fn cast(bytes: &[u8]) -> &[AsciiChar] {
- unsafe { &*(bytes as *const [u8] as *const [AsciiChar]) }
- }
-}
-
-impl fmt::Display for AsciiChar {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- Display::fmt(&(self.0 as char), f)
- }
-}
-
-impl PartialEq<AsciiChar> for char {
- fn eq(&self, other: &AsciiChar) -> bool {
- other.0 as char == *self
- }
-}
-
-impl Char for AsciiChar {
- const ASCII: bool = true;
- #[inline]
- fn char_class(self, config: &Config) -> CharClass {
- let c = self.0;
- // using manual if conditions instead optimizes better
- if c >= b'a' && c <= b'z' {
- CharClass::Lower
- } else if c >= b'A' && c <= b'Z' {
- CharClass::Upper
- } else if c >= b'0' && c <= b'9' {
- CharClass::Number
- } else if c.is_ascii_whitespace() {
- CharClass::Whitespace
- } else if config.delimiter_chars.contains(&c) {
- CharClass::Delimiter
- } else {
- CharClass::NonWord
- }
- }
-
- #[inline(always)]
- fn char_class_and_normalize(mut self, config: &Config) -> (Self, CharClass) {
- let char_class = self.char_class(config);
- if config.ignore_case && char_class == CharClass::Upper {
- self.0 += 32
- }
- (self, char_class)
- }
-
- #[inline(always)]
- fn normalize(mut self, config: &Config) -> Self {
- if config.ignore_case && self.0 >= b'A' && self.0 <= b'Z' {
- self.0 += 32
- }
- self
- }
-}
-fn char_class_non_ascii(c: char) -> CharClass {
- if c.is_lowercase() {
- CharClass::Lower
- } else if is_upper_case(c) {
- CharClass::Upper
- } else if c.is_numeric() {
- CharClass::Number
- } else if c.is_alphabetic() {
- CharClass::Letter
- } else if c.is_whitespace() {
- CharClass::Whitespace
- } else {
- CharClass::NonWord
- }
-}
-impl Char for char {
- const ASCII: bool = false;
- #[inline(always)]
- fn char_class(self, config: &Config) -> CharClass {
- if self.is_ascii() {
- return AsciiChar(self as u8).char_class(config);
- }
- char_class_non_ascii(self)
- }
-
- #[inline(always)]
- fn char_class_and_normalize(mut self, config: &Config) -> (Self, CharClass) {
- if self.is_ascii() {
- let (c, class) = AsciiChar(self as u8).char_class_and_normalize(config);
- return (c.0 as char, class);
- }
- let char_class = char_class_non_ascii(self);
- #[cfg(feature = "unicode-casefold")]
- let mut case_fold = char_class == CharClass::Upper;
- #[cfg(feature = "unicode-normalization")]
- if config.normalize {
- self = normalize::normalize(self);
- case_fold = true
- }
- #[cfg(feature = "unicode-casefold")]
- if case_fold && config.ignore_case {
- self = CASE_FOLDING_SIMPLE
- .binary_search_by_key(&self, |(upper, _)| *upper)
- .map_or(self, |idx| CASE_FOLDING_SIMPLE[idx].1)
- }
- (self, char_class)
- }
-
- #[inline(always)]
- fn normalize(mut self, config: &Config) -> Self {
- #[cfg(feature = "unicode-normalization")]
- if config.normalize {
- self = normalize::normalize(self);
- }
- #[cfg(feature = "unicode-casefold")]
- if config.ignore_case {
- self = to_lower_case(self)
- }
- self
- }
-}
-
-#[cfg(feature = "unicode-normalization")]
-pub use normalize::normalize;
-#[cfg(feature = "unicode-segmentation")]
-use unicode_segmentation::UnicodeSegmentation;
-
-/// Converts a character to lower case using simple unicode case folding
-#[cfg(feature = "unicode-casefold")]
-#[inline(always)]
-pub fn to_lower_case(c: char) -> char {
- CASE_FOLDING_SIMPLE
- .binary_search_by_key(&c, |(upper, _)| *upper)
- .map_or(c, |idx| CASE_FOLDING_SIMPLE[idx].1)
-}
-
-/// Checks if a character is upper case according to simple unicode case folding.
-/// if the `unicode-casefold` feature is disable the equivalent std function is used
-#[inline(always)]
-pub fn is_upper_case(c: char) -> bool {
- #[cfg(feature = "unicode-casefold")]
- let val = CASE_FOLDING_SIMPLE
- .binary_search_by_key(&c, |(upper, _)| *upper)
- .is_ok();
- #[cfg(not(feature = "unicode-casefold"))]
- let val = c.is_uppercase();
- val
-}
-
-#[derive(Debug, Eq, PartialEq, PartialOrd, Ord, Copy, Clone, Hash)]
-pub(crate) enum CharClass {
- Whitespace,
- NonWord,
- Delimiter,
- Lower,
- Upper,
- Letter,
- Number,
-}
-
-/// Nucleo cannot match graphemes as single units. To work around
-/// that we only use the first codepoint of each grapheme. This
-/// iterator returns the first character of each unicode grapheme
-/// in a string and is used for constructing `Utf32Str(ing)`.
-pub fn graphemes(text: &str) -> impl Iterator<Item = char> + '_ {
- #[cfg(feature = "unicode-segmentation")]
- let res = text.graphemes(true).map(|grapheme| {
- // we need to special-case this check since `\r\n` is a single grapheme and is
- // therefore the exception to the rule that normalization of a grapheme should
- // map to the first character.
- if grapheme == "\r\n" {
- '\n'
- } else {
- grapheme
- .chars()
- .next()
- .expect("graphemes must be non-empty")
- }
- });
- #[cfg(not(feature = "unicode-segmentation"))]
- let res = text.chars();
- res
-}
diff --git a/crates/atuin-nucleo/matcher/src/chars/case_fold.rs b/crates/atuin-nucleo/matcher/src/chars/case_fold.rs
deleted file mode 100644
index aacbe461..00000000
--- a/crates/atuin-nucleo/matcher/src/chars/case_fold.rs
+++ /dev/null
@@ -1,347 +0,0 @@
-// DO NOT EDIT THIS FILE. IT WAS AUTOMATICALLY GENERATED BY:
-//
-// ucd-generate case-folding-simple /tmp/ucd-15.0.0 --chars
-//
-// Unicode version: 15.0.0.
-//
-// ucd-generate 0.3.0 is available on crates.io.
-
-pub const CASE_FOLDING_SIMPLE: &'static [(char, char)] = &[
- ('A', 'a'), ('B', 'b'), ('C', 'c'), ('D', 'd'), ('E', 'e'), ('F', 'f'),
- ('G', 'g'), ('H', 'h'), ('I', 'i'), ('J', 'j'), ('K', 'k'), ('L', 'l'),
- ('M', 'm'), ('N', 'n'), ('O', 'o'), ('P', 'p'), ('Q', 'q'), ('R', 'r'),
- ('S', 's'), ('T', 't'), ('U', 'u'), ('V', 'v'), ('W', 'w'), ('X', 'x'),
- ('Y', 'y'), ('Z', 'z'), ('µ', 'μ'), ('À', 'à'), ('Á', 'á'),
- ('Â', 'â'), ('Ã', 'ã'), ('Ä', 'ä'), ('Å', 'å'), ('Æ', 'æ'),
- ('Ç', 'ç'), ('È', 'è'), ('É', 'é'), ('Ê', 'ê'), ('Ë', 'ë'),
- ('Ì', 'ì'), ('Í', 'í'), ('Î', 'î'), ('Ï', 'ï'), ('Ð', 'ð'),
- ('Ñ', 'ñ'), ('Ò', 'ò'), ('Ó', 'ó'), ('Ô', 'ô'), ('Õ', 'õ'),
- ('Ö', 'ö'), ('Ø', 'ø'), ('Ù', 'ù'), ('Ú', 'ú'), ('Û', 'û'),
- ('Ü', 'ü'), ('Ý', 'ý'), ('Þ', 'þ'), ('Ā', 'ā'), ('Ă', 'ă'),
- ('Ą', 'ą'), ('Ć', 'ć'), ('Ĉ', 'ĉ'), ('Ċ', 'ċ'), ('Č', 'č'),
- ('Ď', 'ď'), ('Đ', 'đ'), ('Ē', 'ē'), ('Ĕ', 'ĕ'), ('Ė', 'ė'),
- ('Ę', 'ę'), ('Ě', 'ě'), ('Ĝ', 'ĝ'), ('Ğ', 'ğ'), ('Ġ', 'ġ'),
- ('Ģ', 'ģ'), ('Ĥ', 'ĥ'), ('Ħ', 'ħ'), ('Ĩ', 'ĩ'), ('Ī', 'ī'),
- ('Ĭ', 'ĭ'), ('Į', 'į'), ('IJ', 'ij'), ('Ĵ', 'ĵ'), ('Ķ', 'ķ'),
- ('Ĺ', 'ĺ'), ('Ļ', 'ļ'), ('Ľ', 'ľ'), ('Ŀ', 'ŀ'), ('Ł', 'ł'),
- ('Ń', 'ń'), ('Ņ', 'ņ'), ('Ň', 'ň'), ('Ŋ', 'ŋ'), ('Ō', 'ō'),
- ('Ŏ', 'ŏ'), ('Ő', 'ő'), ('Œ', 'œ'), ('Ŕ', 'ŕ'), ('Ŗ', 'ŗ'),
- ('Ř', 'ř'), ('Ś', 'ś'), ('Ŝ', 'ŝ'), ('Ş', 'ş'), ('Š', 'š'),
- ('Ţ', 'ţ'), ('Ť', 'ť'), ('Ŧ', 'ŧ'), ('Ũ', 'ũ'), ('Ū', 'ū'),
- ('Ŭ', 'ŭ'), ('Ů', 'ů'), ('Ű', 'ű'), ('Ų', 'ų'), ('Ŵ', 'ŵ'),
- ('Ŷ', 'ŷ'), ('Ÿ', 'ÿ'), ('Ź', 'ź'), ('Ż', 'ż'), ('Ž', 'ž'),
- ('ſ', 's'), ('Ɓ', 'ɓ'), ('Ƃ', 'ƃ'), ('Ƅ', 'ƅ'), ('Ɔ', 'ɔ'),
- ('Ƈ', 'ƈ'), ('Ɖ', 'ɖ'), ('Ɗ', 'ɗ'), ('Ƌ', 'ƌ'), ('Ǝ', 'ǝ'),
- ('Ə', 'ə'), ('Ɛ', 'ɛ'), ('Ƒ', 'ƒ'), ('Ɠ', 'ɠ'), ('Ɣ', 'ɣ'),
- ('Ɩ', 'ɩ'), ('Ɨ', 'ɨ'), ('Ƙ', 'ƙ'), ('Ɯ', 'ɯ'), ('Ɲ', 'ɲ'),
- ('Ɵ', 'ɵ'), ('Ơ', 'ơ'), ('Ƣ', 'ƣ'), ('Ƥ', 'ƥ'), ('Ʀ', 'ʀ'),
- ('Ƨ', 'ƨ'), ('Ʃ', 'ʃ'), ('Ƭ', 'ƭ'), ('Ʈ', 'ʈ'), ('Ư', 'ư'),
- ('Ʊ', 'ʊ'), ('Ʋ', 'ʋ'), ('Ƴ', 'ƴ'), ('Ƶ', 'ƶ'), ('Ʒ', 'ʒ'),
- ('Ƹ', 'ƹ'), ('Ƽ', 'ƽ'), ('DŽ', 'dž'), ('Dž', 'dž'), ('LJ', 'lj'),
- ('Lj', 'lj'), ('NJ', 'nj'), ('Nj', 'nj'), ('Ǎ', 'ǎ'), ('Ǐ', 'ǐ'),
- ('Ǒ', 'ǒ'), ('Ǔ', 'ǔ'), ('Ǖ', 'ǖ'), ('Ǘ', 'ǘ'), ('Ǚ', 'ǚ'),
- ('Ǜ', 'ǜ'), ('Ǟ', 'ǟ'), ('Ǡ', 'ǡ'), ('Ǣ', 'ǣ'), ('Ǥ', 'ǥ'),
- ('Ǧ', 'ǧ'), ('Ǩ', 'ǩ'), ('Ǫ', 'ǫ'), ('Ǭ', 'ǭ'), ('Ǯ', 'ǯ'),
- ('DZ', 'dz'), ('Dz', 'dz'), ('Ǵ', 'ǵ'), ('Ƕ', 'ƕ'), ('Ƿ', 'ƿ'),
- ('Ǹ', 'ǹ'), ('Ǻ', 'ǻ'), ('Ǽ', 'ǽ'), ('Ǿ', 'ǿ'), ('Ȁ', 'ȁ'),
- ('Ȃ', 'ȃ'), ('Ȅ', 'ȅ'), ('Ȇ', 'ȇ'), ('Ȉ', 'ȉ'), ('Ȋ', 'ȋ'),
- ('Ȍ', 'ȍ'), ('Ȏ', 'ȏ'), ('Ȑ', 'ȑ'), ('Ȓ', 'ȓ'), ('Ȕ', 'ȕ'),
- ('Ȗ', 'ȗ'), ('Ș', 'ș'), ('Ț', 'ț'), ('Ȝ', 'ȝ'), ('Ȟ', 'ȟ'),
- ('Ƞ', 'ƞ'), ('Ȣ', 'ȣ'), ('Ȥ', 'ȥ'), ('Ȧ', 'ȧ'), ('Ȩ', 'ȩ'),
- ('Ȫ', 'ȫ'), ('Ȭ', 'ȭ'), ('Ȯ', 'ȯ'), ('Ȱ', 'ȱ'), ('Ȳ', 'ȳ'),
- ('Ⱥ', 'ⱥ'), ('Ȼ', 'ȼ'), ('Ƚ', 'ƚ'), ('Ⱦ', 'ⱦ'), ('Ɂ', 'ɂ'),
- ('Ƀ', 'ƀ'), ('Ʉ', 'ʉ'), ('Ʌ', 'ʌ'), ('Ɇ', 'ɇ'), ('Ɉ', 'ɉ'),
- ('Ɋ', 'ɋ'), ('Ɍ', 'ɍ'), ('Ɏ', 'ɏ'), ('\u{345}', 'ι'), ('Ͱ', 'ͱ'),
- ('Ͳ', 'ͳ'), ('Ͷ', 'ͷ'), ('Ϳ', 'ϳ'), ('Ά', 'ά'), ('Έ', 'έ'),
- ('Ή', 'ή'), ('Ί', 'ί'), ('Ό', 'ό'), ('Ύ', 'ύ'), ('Ώ', 'ώ'),
- ('Α', 'α'), ('Β', 'β'), ('Γ', 'γ'), ('Δ', 'δ'), ('Ε', 'ε'),
- ('Ζ', 'ζ'), ('Η', 'η'), ('Θ', 'θ'), ('Ι', 'ι'), ('Κ', 'κ'),
- ('Λ', 'λ'), ('Μ', 'μ'), ('Ν', 'ν'), ('Ξ', 'ξ'), ('Ο', 'ο'),
- ('Π', 'π'), ('Ρ', 'ρ'), ('Σ', 'σ'), ('Τ', 'τ'), ('Υ', 'υ'),
- ('Φ', 'φ'), ('Χ', 'χ'), ('Ψ', 'ψ'), ('Ω', 'ω'), ('Ϊ', 'ϊ'),
- ('Ϋ', 'ϋ'), ('ς', 'σ'), ('Ϗ', 'ϗ'), ('ϐ', 'β'), ('ϑ', 'θ'),
- ('ϕ', 'φ'), ('ϖ', 'π'), ('Ϙ', 'ϙ'), ('Ϛ', 'ϛ'), ('Ϝ', 'ϝ'),
- ('Ϟ', 'ϟ'), ('Ϡ', 'ϡ'), ('Ϣ', 'ϣ'), ('Ϥ', 'ϥ'), ('Ϧ', 'ϧ'),
- ('Ϩ', 'ϩ'), ('Ϫ', 'ϫ'), ('Ϭ', 'ϭ'), ('Ϯ', 'ϯ'), ('ϰ', 'κ'),
- ('ϱ', 'ρ'), ('ϴ', 'θ'), ('ϵ', 'ε'), ('Ϸ', 'ϸ'), ('Ϲ', 'ϲ'),
- ('Ϻ', 'ϻ'), ('Ͻ', 'ͻ'), ('Ͼ', 'ͼ'), ('Ͽ', 'ͽ'), ('Ѐ', 'ѐ'),
- ('Ё', 'ё'), ('Ђ', 'ђ'), ('Ѓ', 'ѓ'), ('Є', 'є'), ('Ѕ', 'ѕ'),
- ('І', 'і'), ('Ї', 'ї'), ('Ј', 'ј'), ('Љ', 'љ'), ('Њ', 'њ'),
- ('Ћ', 'ћ'), ('Ќ', 'ќ'), ('Ѝ', 'ѝ'), ('Ў', 'ў'), ('Џ', 'џ'),
- ('А', 'а'), ('Б', 'б'), ('В', 'в'), ('Г', 'г'), ('Д', 'д'),
- ('Е', 'е'), ('Ж', 'ж'), ('З', 'з'), ('И', 'и'), ('Й', 'й'),
- ('К', 'к'), ('Л', 'л'), ('М', 'м'), ('Н', 'н'), ('О', 'о'),
- ('П', 'п'), ('Р', 'р'), ('С', 'с'), ('Т', 'т'), ('У', 'у'),
- ('Ф', 'ф'), ('Х', 'х'), ('Ц', 'ц'), ('Ч', 'ч'), ('Ш', 'ш'),
- ('Щ', 'щ'), ('Ъ', 'ъ'), ('Ы', 'ы'), ('Ь', 'ь'), ('Э', 'э'),
- ('Ю', 'ю'), ('Я', 'я'), ('Ѡ', 'ѡ'), ('Ѣ', 'ѣ'), ('Ѥ', 'ѥ'),
- ('Ѧ', 'ѧ'), ('Ѩ', 'ѩ'), ('Ѫ', 'ѫ'), ('Ѭ', 'ѭ'), ('Ѯ', 'ѯ'),
- ('Ѱ', 'ѱ'), ('Ѳ', 'ѳ'), ('Ѵ', 'ѵ'), ('Ѷ', 'ѷ'), ('Ѹ', 'ѹ'),
- ('Ѻ', 'ѻ'), ('Ѽ', 'ѽ'), ('Ѿ', 'ѿ'), ('Ҁ', 'ҁ'), ('Ҋ', 'ҋ'),
- ('Ҍ', 'ҍ'), ('Ҏ', 'ҏ'), ('Ґ', 'ґ'), ('Ғ', 'ғ'), ('Ҕ', 'ҕ'),
- ('Җ', 'җ'), ('Ҙ', 'ҙ'), ('Қ', 'қ'), ('Ҝ', 'ҝ'), ('Ҟ', 'ҟ'),
- ('Ҡ', 'ҡ'), ('Ң', 'ң'), ('Ҥ', 'ҥ'), ('Ҧ', 'ҧ'), ('Ҩ', 'ҩ'),
- ('Ҫ', 'ҫ'), ('Ҭ', 'ҭ'), ('Ү', 'ү'), ('Ұ', 'ұ'), ('Ҳ', 'ҳ'),
- ('Ҵ', 'ҵ'), ('Ҷ', 'ҷ'), ('Ҹ', 'ҹ'), ('Һ', 'һ'), ('Ҽ', 'ҽ'),
- ('Ҿ', 'ҿ'), ('Ӏ', 'ӏ'), ('Ӂ', 'ӂ'), ('Ӄ', 'ӄ'), ('Ӆ', 'ӆ'),
- ('Ӈ', 'ӈ'), ('Ӊ', 'ӊ'), ('Ӌ', 'ӌ'), ('Ӎ', 'ӎ'), ('Ӑ', 'ӑ'),
- ('Ӓ', 'ӓ'), ('Ӕ', 'ӕ'), ('Ӗ', 'ӗ'), ('Ә', 'ә'), ('Ӛ', 'ӛ'),
- ('Ӝ', 'ӝ'), ('Ӟ', 'ӟ'), ('Ӡ', 'ӡ'), ('Ӣ', 'ӣ'), ('Ӥ', 'ӥ'),
- ('Ӧ', 'ӧ'), ('Ө', 'ө'), ('Ӫ', 'ӫ'), ('Ӭ', 'ӭ'), ('Ӯ', 'ӯ'),
- ('Ӱ', 'ӱ'), ('Ӳ', 'ӳ'), ('Ӵ', 'ӵ'), ('Ӷ', 'ӷ'), ('Ӹ', 'ӹ'),
- ('Ӻ', 'ӻ'), ('Ӽ', 'ӽ'), ('Ӿ', 'ӿ'), ('Ԁ', 'ԁ'), ('Ԃ', 'ԃ'),
- ('Ԅ', 'ԅ'), ('Ԇ', 'ԇ'), ('Ԉ', 'ԉ'), ('Ԋ', 'ԋ'), ('Ԍ', 'ԍ'),
- ('Ԏ', 'ԏ'), ('Ԑ', 'ԑ'), ('Ԓ', 'ԓ'), ('Ԕ', 'ԕ'), ('Ԗ', 'ԗ'),
- ('Ԙ', 'ԙ'), ('Ԛ', 'ԛ'), ('Ԝ', 'ԝ'), ('Ԟ', 'ԟ'), ('Ԡ', 'ԡ'),
- ('Ԣ', 'ԣ'), ('Ԥ', 'ԥ'), ('Ԧ', 'ԧ'), ('Ԩ', 'ԩ'), ('Ԫ', 'ԫ'),
- ('Ԭ', 'ԭ'), ('Ԯ', 'ԯ'), ('Ա', 'ա'), ('Բ', 'բ'), ('Գ', 'գ'),
- ('Դ', 'դ'), ('Ե', 'ե'), ('Զ', 'զ'), ('Է', 'է'), ('Ը', 'ը'),
- ('Թ', 'թ'), ('Ժ', 'ժ'), ('Ի', 'ի'), ('Լ', 'լ'), ('Խ', 'խ'),
- ('Ծ', 'ծ'), ('Կ', 'կ'), ('Հ', 'հ'), ('Ձ', 'ձ'), ('Ղ', 'ղ'),
- ('Ճ', 'ճ'), ('Մ', 'մ'), ('Յ', 'յ'), ('Ն', 'ն'), ('Շ', 'շ'),
- ('Ո', 'ո'), ('Չ', 'չ'), ('Պ', 'պ'), ('Ջ', 'ջ'), ('Ռ', 'ռ'),
- ('Ս', 'ս'), ('Վ', 'վ'), ('Տ', 'տ'), ('Ր', 'ր'), ('Ց', 'ց'),
- ('Ւ', 'ւ'), ('Փ', 'փ'), ('Ք', 'ք'), ('Օ', 'օ'), ('Ֆ', 'ֆ'),
- ('Ⴀ', 'ⴀ'), ('Ⴁ', 'ⴁ'), ('Ⴂ', 'ⴂ'), ('Ⴃ', 'ⴃ'),
- ('Ⴄ', 'ⴄ'), ('Ⴅ', 'ⴅ'), ('Ⴆ', 'ⴆ'), ('Ⴇ', 'ⴇ'),
- ('Ⴈ', 'ⴈ'), ('Ⴉ', 'ⴉ'), ('Ⴊ', 'ⴊ'), ('Ⴋ', 'ⴋ'),
- ('Ⴌ', 'ⴌ'), ('Ⴍ', 'ⴍ'), ('Ⴎ', 'ⴎ'), ('Ⴏ', 'ⴏ'),
- ('Ⴐ', 'ⴐ'), ('Ⴑ', 'ⴑ'), ('Ⴒ', 'ⴒ'), ('Ⴓ', 'ⴓ'),
- ('Ⴔ', 'ⴔ'), ('Ⴕ', 'ⴕ'), ('Ⴖ', 'ⴖ'), ('Ⴗ', 'ⴗ'),
- ('Ⴘ', 'ⴘ'), ('Ⴙ', 'ⴙ'), ('Ⴚ', 'ⴚ'), ('Ⴛ', 'ⴛ'),
- ('Ⴜ', 'ⴜ'), ('Ⴝ', 'ⴝ'), ('Ⴞ', 'ⴞ'), ('Ⴟ', 'ⴟ'),
- ('Ⴠ', 'ⴠ'), ('Ⴡ', 'ⴡ'), ('Ⴢ', 'ⴢ'), ('Ⴣ', 'ⴣ'),
- ('Ⴤ', 'ⴤ'), ('Ⴥ', 'ⴥ'), ('Ⴧ', 'ⴧ'), ('Ⴭ', 'ⴭ'),
- ('ᏸ', 'Ᏸ'), ('ᏹ', 'Ᏹ'), ('ᏺ', 'Ᏺ'), ('ᏻ', 'Ᏻ'),
- ('ᏼ', 'Ᏼ'), ('ᏽ', 'Ᏽ'), ('ᲀ', 'в'), ('ᲁ', 'д'), ('ᲂ', 'о'),
- ('ᲃ', 'с'), ('ᲄ', 'т'), ('ᲅ', 'т'), ('ᲆ', 'ъ'), ('ᲇ', 'ѣ'),
- ('ᲈ', 'ꙋ'), ('Ა', 'ა'), ('Ბ', 'ბ'), ('Გ', 'გ'),
- ('Დ', 'დ'), ('Ე', 'ე'), ('Ვ', 'ვ'), ('Ზ', 'ზ'),
- ('Თ', 'თ'), ('Ი', 'ი'), ('Კ', 'კ'), ('Ლ', 'ლ'),
- ('Მ', 'მ'), ('Ნ', 'ნ'), ('Ო', 'ო'), ('Პ', 'პ'),
- ('Ჟ', 'ჟ'), ('Რ', 'რ'), ('Ს', 'ს'), ('Ტ', 'ტ'),
- ('Უ', 'უ'), ('Ფ', 'ფ'), ('Ქ', 'ქ'), ('Ღ', 'ღ'),
- ('Ყ', 'ყ'), ('Შ', 'შ'), ('Ჩ', 'ჩ'), ('Ც', 'ც'),
- ('Ძ', 'ძ'), ('Წ', 'წ'), ('Ჭ', 'ჭ'), ('Ხ', 'ხ'),
- ('Ჯ', 'ჯ'), ('Ჰ', 'ჰ'), ('Ჱ', 'ჱ'), ('Ჲ', 'ჲ'),
- ('Ჳ', 'ჳ'), ('Ჴ', 'ჴ'), ('Ჵ', 'ჵ'), ('Ჶ', 'ჶ'),
- ('Ჷ', 'ჷ'), ('Ჸ', 'ჸ'), ('Ჹ', 'ჹ'), ('Ჺ', 'ჺ'),
- ('Ჽ', 'ჽ'), ('Ჾ', 'ჾ'), ('Ჿ', 'ჿ'), ('Ḁ', 'ḁ'),
- ('Ḃ', 'ḃ'), ('Ḅ', 'ḅ'), ('Ḇ', 'ḇ'), ('Ḉ', 'ḉ'),
- ('Ḋ', 'ḋ'), ('Ḍ', 'ḍ'), ('Ḏ', 'ḏ'), ('Ḑ', 'ḑ'),
- ('Ḓ', 'ḓ'), ('Ḕ', 'ḕ'), ('Ḗ', 'ḗ'), ('Ḙ', 'ḙ'),
- ('Ḛ', 'ḛ'), ('Ḝ', 'ḝ'), ('Ḟ', 'ḟ'), ('Ḡ', 'ḡ'),
- ('Ḣ', 'ḣ'), ('Ḥ', 'ḥ'), ('Ḧ', 'ḧ'), ('Ḩ', 'ḩ'),
- ('Ḫ', 'ḫ'), ('Ḭ', 'ḭ'), ('Ḯ', 'ḯ'), ('Ḱ', 'ḱ'),
- ('Ḳ', 'ḳ'), ('Ḵ', 'ḵ'), ('Ḷ', 'ḷ'), ('Ḹ', 'ḹ'),
- ('Ḻ', 'ḻ'), ('Ḽ', 'ḽ'), ('Ḿ', 'ḿ'), ('Ṁ', 'ṁ'),
- ('Ṃ', 'ṃ'), ('Ṅ', 'ṅ'), ('Ṇ', 'ṇ'), ('Ṉ', 'ṉ'),
- ('Ṋ', 'ṋ'), ('Ṍ', 'ṍ'), ('Ṏ', 'ṏ'), ('Ṑ', 'ṑ'),
- ('Ṓ', 'ṓ'), ('Ṕ', 'ṕ'), ('Ṗ', 'ṗ'), ('Ṙ', 'ṙ'),
- ('Ṛ', 'ṛ'), ('Ṝ', 'ṝ'), ('Ṟ', 'ṟ'), ('Ṡ', 'ṡ'),
- ('Ṣ', 'ṣ'), ('Ṥ', 'ṥ'), ('Ṧ', 'ṧ'), ('Ṩ', 'ṩ'),
- ('Ṫ', 'ṫ'), ('Ṭ', 'ṭ'), ('Ṯ', 'ṯ'), ('Ṱ', 'ṱ'),
- ('Ṳ', 'ṳ'), ('Ṵ', 'ṵ'), ('Ṷ', 'ṷ'), ('Ṹ', 'ṹ'),
- ('Ṻ', 'ṻ'), ('Ṽ', 'ṽ'), ('Ṿ', 'ṿ'), ('Ẁ', 'ẁ'),
- ('Ẃ', 'ẃ'), ('Ẅ', 'ẅ'), ('Ẇ', 'ẇ'), ('Ẉ', 'ẉ'),
- ('Ẋ', 'ẋ'), ('Ẍ', 'ẍ'), ('Ẏ', 'ẏ'), ('Ẑ', 'ẑ'),
- ('Ẓ', 'ẓ'), ('Ẕ', 'ẕ'), ('ẛ', 'ṡ'), ('ẞ', 'ß'),
- ('Ạ', 'ạ'), ('Ả', 'ả'), ('Ấ', 'ấ'), ('Ầ', 'ầ'),
- ('Ẩ', 'ẩ'), ('Ẫ', 'ẫ'), ('Ậ', 'ậ'), ('Ắ', 'ắ'),
- ('Ằ', 'ằ'), ('Ẳ', 'ẳ'), ('Ẵ', 'ẵ'), ('Ặ', 'ặ'),
- ('Ẹ', 'ẹ'), ('Ẻ', 'ẻ'), ('Ẽ', 'ẽ'), ('Ế', 'ế'),
- ('Ề', 'ề'), ('Ể', 'ể'), ('Ễ', 'ễ'), ('Ệ', 'ệ'),
- ('Ỉ', 'ỉ'), ('Ị', 'ị'), ('Ọ', 'ọ'), ('Ỏ', 'ỏ'),
- ('Ố', 'ố'), ('Ồ', 'ồ'), ('Ổ', 'ổ'), ('Ỗ', 'ỗ'),
- ('Ộ', 'ộ'), ('Ớ', 'ớ'), ('Ờ', 'ờ'), ('Ở', 'ở'),
- ('Ỡ', 'ỡ'), ('Ợ', 'ợ'), ('Ụ', 'ụ'), ('Ủ', 'ủ'),
- ('Ứ', 'ứ'), ('Ừ', 'ừ'), ('Ử', 'ử'), ('Ữ', 'ữ'),
- ('Ự', 'ự'), ('Ỳ', 'ỳ'), ('Ỵ', 'ỵ'), ('Ỷ', 'ỷ'),
- ('Ỹ', 'ỹ'), ('Ỻ', 'ỻ'), ('Ỽ', 'ỽ'), ('Ỿ', 'ỿ'),
- ('Ἀ', 'ἀ'), ('Ἁ', 'ἁ'), ('Ἂ', 'ἂ'), ('Ἃ', 'ἃ'),
- ('Ἄ', 'ἄ'), ('Ἅ', 'ἅ'), ('Ἆ', 'ἆ'), ('Ἇ', 'ἇ'),
- ('Ἐ', 'ἐ'), ('Ἑ', 'ἑ'), ('Ἒ', 'ἒ'), ('Ἓ', 'ἓ'),
- ('Ἔ', 'ἔ'), ('Ἕ', 'ἕ'), ('Ἠ', 'ἠ'), ('Ἡ', 'ἡ'),
- ('Ἢ', 'ἢ'), ('Ἣ', 'ἣ'), ('Ἤ', 'ἤ'), ('Ἥ', 'ἥ'),
- ('Ἦ', 'ἦ'), ('Ἧ', 'ἧ'), ('Ἰ', 'ἰ'), ('Ἱ', 'ἱ'),
- ('Ἲ', 'ἲ'), ('Ἳ', 'ἳ'), ('Ἴ', 'ἴ'), ('Ἵ', 'ἵ'),
- ('Ἶ', 'ἶ'), ('Ἷ', 'ἷ'), ('Ὀ', 'ὀ'), ('Ὁ', 'ὁ'),
- ('Ὂ', 'ὂ'), ('Ὃ', 'ὃ'), ('Ὄ', 'ὄ'), ('Ὅ', 'ὅ'),
- ('Ὑ', 'ὑ'), ('Ὓ', 'ὓ'), ('Ὕ', 'ὕ'), ('Ὗ', 'ὗ'),
- ('Ὠ', 'ὠ'), ('Ὡ', 'ὡ'), ('Ὢ', 'ὢ'), ('Ὣ', 'ὣ'),
- ('Ὤ', 'ὤ'), ('Ὥ', 'ὥ'), ('Ὦ', 'ὦ'), ('Ὧ', 'ὧ'),
- ('ᾈ', 'ᾀ'), ('ᾉ', 'ᾁ'), ('ᾊ', 'ᾂ'), ('ᾋ', 'ᾃ'),
- ('ᾌ', 'ᾄ'), ('ᾍ', 'ᾅ'), ('ᾎ', 'ᾆ'), ('ᾏ', 'ᾇ'),
- ('ᾘ', 'ᾐ'), ('ᾙ', 'ᾑ'), ('ᾚ', 'ᾒ'), ('ᾛ', 'ᾓ'),
- ('ᾜ', 'ᾔ'), ('ᾝ', 'ᾕ'), ('ᾞ', 'ᾖ'), ('ᾟ', 'ᾗ'),
- ('ᾨ', 'ᾠ'), ('ᾩ', 'ᾡ'), ('ᾪ', 'ᾢ'), ('ᾫ', 'ᾣ'),
- ('ᾬ', 'ᾤ'), ('ᾭ', 'ᾥ'), ('ᾮ', 'ᾦ'), ('ᾯ', 'ᾧ'),
- ('Ᾰ', 'ᾰ'), ('Ᾱ', 'ᾱ'), ('Ὰ', 'ὰ'), ('Ά', 'ά'),
- ('ᾼ', 'ᾳ'), ('ι', 'ι'), ('Ὲ', 'ὲ'), ('Έ', 'έ'),
- ('Ὴ', 'ὴ'), ('Ή', 'ή'), ('ῌ', 'ῃ'), ('Ῐ', 'ῐ'),
- ('Ῑ', 'ῑ'), ('Ὶ', 'ὶ'), ('Ί', 'ί'), ('Ῠ', 'ῠ'),
- ('Ῡ', 'ῡ'), ('Ὺ', 'ὺ'), ('Ύ', 'ύ'), ('Ῥ', 'ῥ'),
- ('Ὸ', 'ὸ'), ('Ό', 'ό'), ('Ὼ', 'ὼ'), ('Ώ', 'ώ'),
- ('ῼ', 'ῳ'), ('Ω', 'ω'), ('K', 'k'), ('Å', 'å'), ('Ⅎ', 'ⅎ'),
- ('Ⅰ', 'ⅰ'), ('Ⅱ', 'ⅱ'), ('Ⅲ', 'ⅲ'), ('Ⅳ', 'ⅳ'),
- ('Ⅴ', 'ⅴ'), ('Ⅵ', 'ⅵ'), ('Ⅶ', 'ⅶ'), ('Ⅷ', 'ⅷ'),
- ('Ⅸ', 'ⅸ'), ('Ⅹ', 'ⅹ'), ('Ⅺ', 'ⅺ'), ('Ⅻ', 'ⅻ'),
- ('Ⅼ', 'ⅼ'), ('Ⅽ', 'ⅽ'), ('Ⅾ', 'ⅾ'), ('Ⅿ', 'ⅿ'),
- ('Ↄ', 'ↄ'), ('Ⓐ', 'ⓐ'), ('Ⓑ', 'ⓑ'), ('Ⓒ', 'ⓒ'),
- ('Ⓓ', 'ⓓ'), ('Ⓔ', 'ⓔ'), ('Ⓕ', 'ⓕ'), ('Ⓖ', 'ⓖ'),
- ('Ⓗ', 'ⓗ'), ('Ⓘ', 'ⓘ'), ('Ⓙ', 'ⓙ'), ('Ⓚ', 'ⓚ'),
- ('Ⓛ', 'ⓛ'), ('Ⓜ', 'ⓜ'), ('Ⓝ', 'ⓝ'), ('Ⓞ', 'ⓞ'),
- ('Ⓟ', 'ⓟ'), ('Ⓠ', 'ⓠ'), ('Ⓡ', 'ⓡ'), ('Ⓢ', 'ⓢ'),
- ('Ⓣ', 'ⓣ'), ('Ⓤ', 'ⓤ'), ('Ⓥ', 'ⓥ'), ('Ⓦ', 'ⓦ'),
- ('Ⓧ', 'ⓧ'), ('Ⓨ', 'ⓨ'), ('Ⓩ', 'ⓩ'), ('Ⰰ', 'ⰰ'),
- ('Ⰱ', 'ⰱ'), ('Ⰲ', 'ⰲ'), ('Ⰳ', 'ⰳ'), ('Ⰴ', 'ⰴ'),
- ('Ⰵ', 'ⰵ'), ('Ⰶ', 'ⰶ'), ('Ⰷ', 'ⰷ'), ('Ⰸ', 'ⰸ'),
- ('Ⰹ', 'ⰹ'), ('Ⰺ', 'ⰺ'), ('Ⰻ', 'ⰻ'), ('Ⰼ', 'ⰼ'),
- ('Ⰽ', 'ⰽ'), ('Ⰾ', 'ⰾ'), ('Ⰿ', 'ⰿ'), ('Ⱀ', 'ⱀ'),
- ('Ⱁ', 'ⱁ'), ('Ⱂ', 'ⱂ'), ('Ⱃ', 'ⱃ'), ('Ⱄ', 'ⱄ'),
- ('Ⱅ', 'ⱅ'), ('Ⱆ', 'ⱆ'), ('Ⱇ', 'ⱇ'), ('Ⱈ', 'ⱈ'),
- ('Ⱉ', 'ⱉ'), ('Ⱊ', 'ⱊ'), ('Ⱋ', 'ⱋ'), ('Ⱌ', 'ⱌ'),
- ('Ⱍ', 'ⱍ'), ('Ⱎ', 'ⱎ'), ('Ⱏ', 'ⱏ'), ('Ⱐ', 'ⱐ'),
- ('Ⱑ', 'ⱑ'), ('Ⱒ', 'ⱒ'), ('Ⱓ', 'ⱓ'), ('Ⱔ', 'ⱔ'),
- ('Ⱕ', 'ⱕ'), ('Ⱖ', 'ⱖ'), ('Ⱗ', 'ⱗ'), ('Ⱘ', 'ⱘ'),
- ('Ⱙ', 'ⱙ'), ('Ⱚ', 'ⱚ'), ('Ⱛ', 'ⱛ'), ('Ⱜ', 'ⱜ'),
- ('Ⱝ', 'ⱝ'), ('Ⱞ', 'ⱞ'), ('Ⱟ', 'ⱟ'), ('Ⱡ', 'ⱡ'),
- ('Ɫ', 'ɫ'), ('Ᵽ', 'ᵽ'), ('Ɽ', 'ɽ'), ('Ⱨ', 'ⱨ'),
- ('Ⱪ', 'ⱪ'), ('Ⱬ', 'ⱬ'), ('Ɑ', 'ɑ'), ('Ɱ', 'ɱ'), ('Ɐ', 'ɐ'),
- ('Ɒ', 'ɒ'), ('Ⱳ', 'ⱳ'), ('Ⱶ', 'ⱶ'), ('Ȿ', 'ȿ'), ('Ɀ', 'ɀ'),
- ('Ⲁ', 'ⲁ'), ('Ⲃ', 'ⲃ'), ('Ⲅ', 'ⲅ'), ('Ⲇ', 'ⲇ'),
- ('Ⲉ', 'ⲉ'), ('Ⲋ', 'ⲋ'), ('Ⲍ', 'ⲍ'), ('Ⲏ', 'ⲏ'),
- ('Ⲑ', 'ⲑ'), ('Ⲓ', 'ⲓ'), ('Ⲕ', 'ⲕ'), ('Ⲗ', 'ⲗ'),
- ('Ⲙ', 'ⲙ'), ('Ⲛ', 'ⲛ'), ('Ⲝ', 'ⲝ'), ('Ⲟ', 'ⲟ'),
- ('Ⲡ', 'ⲡ'), ('Ⲣ', 'ⲣ'), ('Ⲥ', 'ⲥ'), ('Ⲧ', 'ⲧ'),
- ('Ⲩ', 'ⲩ'), ('Ⲫ', 'ⲫ'), ('Ⲭ', 'ⲭ'), ('Ⲯ', 'ⲯ'),
- ('Ⲱ', 'ⲱ'), ('Ⲳ', 'ⲳ'), ('Ⲵ', 'ⲵ'), ('Ⲷ', 'ⲷ'),
- ('Ⲹ', 'ⲹ'), ('Ⲻ', 'ⲻ'), ('Ⲽ', 'ⲽ'), ('Ⲿ', 'ⲿ'),
- ('Ⳁ', 'ⳁ'), ('Ⳃ', 'ⳃ'), ('Ⳅ', 'ⳅ'), ('Ⳇ', 'ⳇ'),
- ('Ⳉ', 'ⳉ'), ('Ⳋ', 'ⳋ'), ('Ⳍ', 'ⳍ'), ('Ⳏ', 'ⳏ'),
- ('Ⳑ', 'ⳑ'), ('Ⳓ', 'ⳓ'), ('Ⳕ', 'ⳕ'), ('Ⳗ', 'ⳗ'),
- ('Ⳙ', 'ⳙ'), ('Ⳛ', 'ⳛ'), ('Ⳝ', 'ⳝ'), ('Ⳟ', 'ⳟ'),
- ('Ⳡ', 'ⳡ'), ('Ⳣ', 'ⳣ'), ('Ⳬ', 'ⳬ'), ('Ⳮ', 'ⳮ'),
- ('Ⳳ', 'ⳳ'), ('Ꙁ', 'ꙁ'), ('Ꙃ', 'ꙃ'), ('Ꙅ', 'ꙅ'),
- ('Ꙇ', 'ꙇ'), ('Ꙉ', 'ꙉ'), ('Ꙋ', 'ꙋ'), ('Ꙍ', 'ꙍ'),
- ('Ꙏ', 'ꙏ'), ('Ꙑ', 'ꙑ'), ('Ꙓ', 'ꙓ'), ('Ꙕ', 'ꙕ'),
- ('Ꙗ', 'ꙗ'), ('Ꙙ', 'ꙙ'), ('Ꙛ', 'ꙛ'), ('Ꙝ', 'ꙝ'),
- ('Ꙟ', 'ꙟ'), ('Ꙡ', 'ꙡ'), ('Ꙣ', 'ꙣ'), ('Ꙥ', 'ꙥ'),
- ('Ꙧ', 'ꙧ'), ('Ꙩ', 'ꙩ'), ('Ꙫ', 'ꙫ'), ('Ꙭ', 'ꙭ'),
- ('Ꚁ', 'ꚁ'), ('Ꚃ', 'ꚃ'), ('Ꚅ', 'ꚅ'), ('Ꚇ', 'ꚇ'),
- ('Ꚉ', 'ꚉ'), ('Ꚋ', 'ꚋ'), ('Ꚍ', 'ꚍ'), ('Ꚏ', 'ꚏ'),
- ('Ꚑ', 'ꚑ'), ('Ꚓ', 'ꚓ'), ('Ꚕ', 'ꚕ'), ('Ꚗ', 'ꚗ'),
- ('Ꚙ', 'ꚙ'), ('Ꚛ', 'ꚛ'), ('Ꜣ', 'ꜣ'), ('Ꜥ', 'ꜥ'),
- ('Ꜧ', 'ꜧ'), ('Ꜩ', 'ꜩ'), ('Ꜫ', 'ꜫ'), ('Ꜭ', 'ꜭ'),
- ('Ꜯ', 'ꜯ'), ('Ꜳ', 'ꜳ'), ('Ꜵ', 'ꜵ'), ('Ꜷ', 'ꜷ'),
- ('Ꜹ', 'ꜹ'), ('Ꜻ', 'ꜻ'), ('Ꜽ', 'ꜽ'), ('Ꜿ', 'ꜿ'),
- ('Ꝁ', 'ꝁ'), ('Ꝃ', 'ꝃ'), ('Ꝅ', 'ꝅ'), ('Ꝇ', 'ꝇ'),
- ('Ꝉ', 'ꝉ'), ('Ꝋ', 'ꝋ'), ('Ꝍ', 'ꝍ'), ('Ꝏ', 'ꝏ'),
- ('Ꝑ', 'ꝑ'), ('Ꝓ', 'ꝓ'), ('Ꝕ', 'ꝕ'), ('Ꝗ', 'ꝗ'),
- ('Ꝙ', 'ꝙ'), ('Ꝛ', 'ꝛ'), ('Ꝝ', 'ꝝ'), ('Ꝟ', 'ꝟ'),
- ('Ꝡ', 'ꝡ'), ('Ꝣ', 'ꝣ'), ('Ꝥ', 'ꝥ'), ('Ꝧ', 'ꝧ'),
- ('Ꝩ', 'ꝩ'), ('Ꝫ', 'ꝫ'), ('Ꝭ', 'ꝭ'), ('Ꝯ', 'ꝯ'),
- ('Ꝺ', 'ꝺ'), ('Ꝼ', 'ꝼ'), ('Ᵹ', 'ᵹ'), ('Ꝿ', 'ꝿ'),
- ('Ꞁ', 'ꞁ'), ('Ꞃ', 'ꞃ'), ('Ꞅ', 'ꞅ'), ('Ꞇ', 'ꞇ'),
- ('Ꞌ', 'ꞌ'), ('Ɥ', 'ɥ'), ('Ꞑ', 'ꞑ'), ('Ꞓ', 'ꞓ'),
- ('Ꞗ', 'ꞗ'), ('Ꞙ', 'ꞙ'), ('Ꞛ', 'ꞛ'), ('Ꞝ', 'ꞝ'),
- ('Ꞟ', 'ꞟ'), ('Ꞡ', 'ꞡ'), ('Ꞣ', 'ꞣ'), ('Ꞥ', 'ꞥ'),
- ('Ꞧ', 'ꞧ'), ('Ꞩ', 'ꞩ'), ('Ɦ', 'ɦ'), ('Ɜ', 'ɜ'), ('Ɡ', 'ɡ'),
- ('Ɬ', 'ɬ'), ('Ɪ', 'ɪ'), ('Ʞ', 'ʞ'), ('Ʇ', 'ʇ'), ('Ʝ', 'ʝ'),
- ('Ꭓ', 'ꭓ'), ('Ꞵ', 'ꞵ'), ('Ꞷ', 'ꞷ'), ('Ꞹ', 'ꞹ'),
- ('Ꞻ', 'ꞻ'), ('Ꞽ', 'ꞽ'), ('Ꞿ', 'ꞿ'), ('Ꟁ', 'ꟁ'),
- ('Ꟃ', 'ꟃ'), ('Ꞔ', 'ꞔ'), ('Ʂ', 'ʂ'), ('Ᶎ', 'ᶎ'),
- ('Ꟈ', 'ꟈ'), ('Ꟊ', 'ꟊ'), ('Ꟑ', 'ꟑ'), ('Ꟗ', 'ꟗ'),
- ('Ꟙ', 'ꟙ'), ('Ꟶ', 'ꟶ'), ('ꭰ', 'Ꭰ'), ('ꭱ', 'Ꭱ'),
- ('ꭲ', 'Ꭲ'), ('ꭳ', 'Ꭳ'), ('ꭴ', 'Ꭴ'), ('ꭵ', 'Ꭵ'),
- ('ꭶ', 'Ꭶ'), ('ꭷ', 'Ꭷ'), ('ꭸ', 'Ꭸ'), ('ꭹ', 'Ꭹ'),
- ('ꭺ', 'Ꭺ'), ('ꭻ', 'Ꭻ'), ('ꭼ', 'Ꭼ'), ('ꭽ', 'Ꭽ'),
- ('ꭾ', 'Ꭾ'), ('ꭿ', 'Ꭿ'), ('ꮀ', 'Ꮀ'), ('ꮁ', 'Ꮁ'),
- ('ꮂ', 'Ꮂ'), ('ꮃ', 'Ꮃ'), ('ꮄ', 'Ꮄ'), ('ꮅ', 'Ꮅ'),
- ('ꮆ', 'Ꮆ'), ('ꮇ', 'Ꮇ'), ('ꮈ', 'Ꮈ'), ('ꮉ', 'Ꮉ'),
- ('ꮊ', 'Ꮊ'), ('ꮋ', 'Ꮋ'), ('ꮌ', 'Ꮌ'), ('ꮍ', 'Ꮍ'),
- ('ꮎ', 'Ꮎ'), ('ꮏ', 'Ꮏ'), ('ꮐ', 'Ꮐ'), ('ꮑ', 'Ꮑ'),
- ('ꮒ', 'Ꮒ'), ('ꮓ', 'Ꮓ'), ('ꮔ', 'Ꮔ'), ('ꮕ', 'Ꮕ'),
- ('ꮖ', 'Ꮖ'), ('ꮗ', 'Ꮗ'), ('ꮘ', 'Ꮘ'), ('ꮙ', 'Ꮙ'),
- ('ꮚ', 'Ꮚ'), ('ꮛ', 'Ꮛ'), ('ꮜ', 'Ꮜ'), ('ꮝ', 'Ꮝ'),
- ('ꮞ', 'Ꮞ'), ('ꮟ', 'Ꮟ'), ('ꮠ', 'Ꮠ'), ('ꮡ', 'Ꮡ'),
- ('ꮢ', 'Ꮢ'), ('ꮣ', 'Ꮣ'), ('ꮤ', 'Ꮤ'), ('ꮥ', 'Ꮥ'),
- ('ꮦ', 'Ꮦ'), ('ꮧ', 'Ꮧ'), ('ꮨ', 'Ꮨ'), ('ꮩ', 'Ꮩ'),
- ('ꮪ', 'Ꮪ'), ('ꮫ', 'Ꮫ'), ('ꮬ', 'Ꮬ'), ('ꮭ', 'Ꮭ'),
- ('ꮮ', 'Ꮮ'), ('ꮯ', 'Ꮯ'), ('ꮰ', 'Ꮰ'), ('ꮱ', 'Ꮱ'),
- ('ꮲ', 'Ꮲ'), ('ꮳ', 'Ꮳ'), ('ꮴ', 'Ꮴ'), ('ꮵ', 'Ꮵ'),
- ('ꮶ', 'Ꮶ'), ('ꮷ', 'Ꮷ'), ('ꮸ', 'Ꮸ'), ('ꮹ', 'Ꮹ'),
- ('ꮺ', 'Ꮺ'), ('ꮻ', 'Ꮻ'), ('ꮼ', 'Ꮼ'), ('ꮽ', 'Ꮽ'),
- ('ꮾ', 'Ꮾ'), ('ꮿ', 'Ꮿ'), ('A', 'a'), ('B', 'b'),
- ('C', 'c'), ('D', 'd'), ('E', 'e'), ('F', 'f'),
- ('G', 'g'), ('H', 'h'), ('I', 'i'), ('J', 'j'),
- ('K', 'k'), ('L', 'l'), ('M', 'm'), ('N', 'n'),
- ('O', 'o'), ('P', 'p'), ('Q', 'q'), ('R', 'r'),
- ('S', 's'), ('T', 't'), ('U', 'u'), ('V', 'v'),
- ('W', 'w'), ('X', 'x'), ('Y', 'y'), ('Z', 'z'),
- ('𐐀', '𐐨'), ('𐐁', '𐐩'), ('𐐂', '𐐪'), ('𐐃', '𐐫'),
- ('𐐄', '𐐬'), ('𐐅', '𐐭'), ('𐐆', '𐐮'), ('𐐇', '𐐯'),
- ('𐐈', '𐐰'), ('𐐉', '𐐱'), ('𐐊', '𐐲'), ('𐐋', '𐐳'),
- ('𐐌', '𐐴'), ('𐐍', '𐐵'), ('𐐎', '𐐶'), ('𐐏', '𐐷'),
- ('𐐐', '𐐸'), ('𐐑', '𐐹'), ('𐐒', '𐐺'), ('𐐓', '𐐻'),
- ('𐐔', '𐐼'), ('𐐕', '𐐽'), ('𐐖', '𐐾'), ('𐐗', '𐐿'),
- ('𐐘', '𐑀'), ('𐐙', '𐑁'), ('𐐚', '𐑂'), ('𐐛', '𐑃'),
- ('𐐜', '𐑄'), ('𐐝', '𐑅'), ('𐐞', '𐑆'), ('𐐟', '𐑇'),
- ('𐐠', '𐑈'), ('𐐡', '𐑉'), ('𐐢', '𐑊'), ('𐐣', '𐑋'),
- ('𐐤', '𐑌'), ('𐐥', '𐑍'), ('𐐦', '𐑎'), ('𐐧', '𐑏'),
- ('𐒰', '𐓘'), ('𐒱', '𐓙'), ('𐒲', '𐓚'), ('𐒳', '𐓛'),
- ('𐒴', '𐓜'), ('𐒵', '𐓝'), ('𐒶', '𐓞'), ('𐒷', '𐓟'),
- ('𐒸', '𐓠'), ('𐒹', '𐓡'), ('𐒺', '𐓢'), ('𐒻', '𐓣'),
- ('𐒼', '𐓤'), ('𐒽', '𐓥'), ('𐒾', '𐓦'), ('𐒿', '𐓧'),
- ('𐓀', '𐓨'), ('𐓁', '𐓩'), ('𐓂', '𐓪'), ('𐓃', '𐓫'),
- ('𐓄', '𐓬'), ('𐓅', '𐓭'), ('𐓆', '𐓮'), ('𐓇', '𐓯'),
- ('𐓈', '𐓰'), ('𐓉', '𐓱'), ('𐓊', '𐓲'), ('𐓋', '𐓳'),
- ('𐓌', '𐓴'), ('𐓍', '𐓵'), ('𐓎', '𐓶'), ('𐓏', '𐓷'),
- ('𐓐', '𐓸'), ('𐓑', '𐓹'), ('𐓒', '𐓺'), ('𐓓', '𐓻'),
- ('𐕰', '𐖗'), ('𐕱', '𐖘'), ('𐕲', '𐖙'), ('𐕳', '𐖚'),
- ('𐕴', '𐖛'), ('𐕵', '𐖜'), ('𐕶', '𐖝'), ('𐕷', '𐖞'),
- ('𐕸', '𐖟'), ('𐕹', '𐖠'), ('𐕺', '𐖡'), ('𐕼', '𐖣'),
- ('𐕽', '𐖤'), ('𐕾', '𐖥'), ('𐕿', '𐖦'), ('𐖀', '𐖧'),
- ('𐖁', '𐖨'), ('𐖂', '𐖩'), ('𐖃', '𐖪'), ('𐖄', '𐖫'),
- ('𐖅', '𐖬'), ('𐖆', '𐖭'), ('𐖇', '𐖮'), ('𐖈', '𐖯'),
- ('𐖉', '𐖰'), ('𐖊', '𐖱'), ('𐖌', '𐖳'), ('𐖍', '𐖴'),
- ('𐖎', '𐖵'), ('𐖏', '𐖶'), ('𐖐', '𐖷'), ('𐖑', '𐖸'),
- ('𐖒', '𐖹'), ('𐖔', '𐖻'), ('𐖕', '𐖼'), ('𐲀', '𐳀'),
- ('𐲁', '𐳁'), ('𐲂', '𐳂'), ('𐲃', '𐳃'), ('𐲄', '𐳄'),
- ('𐲅', '𐳅'), ('𐲆', '𐳆'), ('𐲇', '𐳇'), ('𐲈', '𐳈'),
- ('𐲉', '𐳉'), ('𐲊', '𐳊'), ('𐲋', '𐳋'), ('𐲌', '𐳌'),
- ('𐲍', '𐳍'), ('𐲎', '𐳎'), ('𐲏', '𐳏'), ('𐲐', '𐳐'),
- ('𐲑', '𐳑'), ('𐲒', '𐳒'), ('𐲓', '𐳓'), ('𐲔', '𐳔'),
- ('𐲕', '𐳕'), ('𐲖', '𐳖'), ('𐲗', '𐳗'), ('𐲘', '𐳘'),
- ('𐲙', '𐳙'), ('𐲚', '𐳚'), ('𐲛', '𐳛'), ('𐲜', '𐳜'),
- ('𐲝', '𐳝'), ('𐲞', '𐳞'), ('𐲟', '𐳟'), ('𐲠', '𐳠'),
- ('𐲡', '𐳡'), ('𐲢', '𐳢'), ('𐲣', '𐳣'), ('𐲤', '𐳤'),
- ('𐲥', '𐳥'), ('𐲦', '𐳦'), ('𐲧', '𐳧'), ('𐲨', '𐳨'),
- ('𐲩', '𐳩'), ('𐲪', '𐳪'), ('𐲫', '𐳫'), ('𐲬', '𐳬'),
- ('𐲭', '𐳭'), ('𐲮', '𐳮'), ('𐲯', '𐳯'), ('𐲰', '𐳰'),
- ('𐲱', '𐳱'), ('𐲲', '𐳲'), ('𑢠', '𑣀'), ('𑢡', '𑣁'),
- ('𑢢', '𑣂'), ('𑢣', '𑣃'), ('𑢤', '𑣄'), ('𑢥', '𑣅'),
- ('𑢦', '𑣆'), ('𑢧', '𑣇'), ('𑢨', '𑣈'), ('𑢩', '𑣉'),
- ('𑢪', '𑣊'), ('𑢫', '𑣋'), ('𑢬', '𑣌'), ('𑢭', '𑣍'),
- ('𑢮', '𑣎'), ('𑢯', '𑣏'), ('𑢰', '𑣐'), ('𑢱', '𑣑'),
- ('𑢲', '𑣒'), ('𑢳', '𑣓'), ('𑢴', '𑣔'), ('𑢵', '𑣕'),
- ('𑢶', '𑣖'), ('𑢷', '𑣗'), ('𑢸', '𑣘'), ('𑢹', '𑣙'),
- ('𑢺', '𑣚'), ('𑢻', '𑣛'), ('𑢼', '𑣜'), ('𑢽', '𑣝'),
- ('𑢾', '𑣞'), ('𑢿', '𑣟'), ('𖹀', '𖹠'), ('𖹁', '𖹡'),
- ('𖹂', '𖹢'), ('𖹃', '𖹣'), ('𖹄', '𖹤'), ('𖹅', '𖹥'),
- ('𖹆', '𖹦'), ('𖹇', '𖹧'), ('𖹈', '𖹨'), ('𖹉', '𖹩'),
- ('𖹊', '𖹪'), ('𖹋', '𖹫'), ('𖹌', '𖹬'), ('𖹍', '𖹭'),
- ('𖹎', '𖹮'), ('𖹏', '𖹯'), ('𖹐', '𖹰'), ('𖹑', '𖹱'),
- ('𖹒', '𖹲'), ('𖹓', '𖹳'), ('𖹔', '𖹴'), ('𖹕', '𖹵'),
- ('𖹖', '𖹶'), ('𖹗', '𖹷'), ('𖹘', '𖹸'), ('𖹙', '𖹹'),
- ('𖹚', '𖹺'), ('𖹛', '𖹻'), ('𖹜', '𖹼'), ('𖹝', '𖹽'),
- ('𖹞', '𖹾'), ('𖹟', '𖹿'), ('𞤀', '𞤢'), ('𞤁', '𞤣'),
- ('𞤂', '𞤤'), ('𞤃', '𞤥'), ('𞤄', '𞤦'), ('𞤅', '𞤧'),
- ('𞤆', '𞤨'), ('𞤇', '𞤩'), ('𞤈', '𞤪'), ('𞤉', '𞤫'),
- ('𞤊', '𞤬'), ('𞤋', '𞤭'), ('𞤌', '𞤮'), ('𞤍', '𞤯'),
- ('𞤎', '𞤰'), ('𞤏', '𞤱'), ('𞤐', '𞤲'), ('𞤑', '𞤳'),
- ('𞤒', '𞤴'), ('𞤓', '𞤵'), ('𞤔', '𞤶'), ('𞤕', '𞤷'),
- ('𞤖', '𞤸'), ('𞤗', '𞤹'), ('𞤘', '𞤺'), ('𞤙', '𞤻'),
- ('𞤚', '𞤼'), ('𞤛', '𞤽'), ('𞤜', '𞤾'), ('𞤝', '𞤿'),
- ('𞤞', '𞥀'), ('𞤟', '𞥁'), ('𞤠', '𞥂'), ('𞤡', '𞥃'),
-];
diff --git a/crates/atuin-nucleo/matcher/src/chars/normalize.rs b/crates/atuin-nucleo/matcher/src/chars/normalize.rs
deleted file mode 100644
index cb3abe66..00000000
--- a/crates/atuin-nucleo/matcher/src/chars/normalize.rs
+++ /dev/null
@@ -1,972 +0,0 @@
-/// Normalize a Unicode character by converting Latin characters which are variants
-/// of ASCII characters to their latin equivalent.
-///
-/// Note that this method acts on single `char`s: if you want to perform full normalization, you
-/// should first split on graphemes, and then normalize each grapheme by normalizing the first
-/// `char` in the grapheme.
-///
-/// If a character does not normalize to a single ASCII character, no normalization is performed.
-///
-/// This performs normalization within the following Unicode blocks:
-///
-/// - [Latin-1 Supplement](https://en.wikipedia.org/wiki/Latin-1_Supplement)
-/// - [Latin Extended-A](https://en.wikipedia.org/wiki/Latin_Extended-A)
-/// - [Latin Extended-B](https://en.wikipedia.org/wiki/Latin_Extended-B)
-/// - [Latin Extended Additional](https://en.wikipedia.org/wiki/Latin_Extended_Additional)
-/// - [Superscripts and Subscripts](https://en.wikipedia.org/wiki/Superscripts_and_Subscripts)
-///
-/// If the character does not fall in this block, it is not normalized.
-///
-/// # Example
-/// ```
-/// # use atuin_nucleo_matcher::chars::normalize;
-/// assert_eq!(normalize('ä'), 'a');
-/// assert_eq!(normalize('Æ'), 'Æ');
-/// assert_eq!(normalize('ữ'), 'u');
-/// ```
-pub fn normalize(c: char) -> char {
- // outside checked blocks
- if c < '\u{a0}' || c >= '\u{20A0}' {
- return c;
- }
- // Latin-1 Supplement, Extended-A, Extended-B
- if c <= '\u{29f}' {
- return LATIN_1AB[c as usize - '\u{a0}' as usize];
- }
- // between blocks
- if c < '\u{1e00}' {
- return c;
- }
- // Latin Extended Additional
- if c <= '\u{1eff}' {
- return LATIN_EXTENDED_ADDITIONAL[c as usize - '\u{1e00}' as usize];
- }
- // between blocks
- if c < '\u{2070}' {
- return c;
- }
- // Superscripts and subscripts
- SUPERSCRIPTS_AND_SUBSCRIPTS[c as usize - '\u{2070}' as usize]
-}
-
-/// A char array corresponding to the following contiguous Unicode blocks:
-///
-/// - [Latin-1 Supplement](https://en.wikipedia.org/wiki/Latin-1_Supplement)
-/// - [Latin Extended-A](https://en.wikipedia.org/wiki/Latin_Extended-A)
-/// - [Latin Extended-B](https://en.wikipedia.org/wiki/Latin_Extended-B)
-///
-/// This covers the range `'\u{a0}'..='\u{29f}'`.
-static LATIN_1AB: [char; 512] = [
- '\u{a0}', // invisible NON BREAKING SPACE
- '!', // '¡'; '\u{a1}'
- '¢', // '¢'; '\u{a2}'
- '£', // '£'; '\u{a3}'
- '¤', // '¤'; '\u{a4}'
- '¥', // '¥'; '\u{a5}'
- '¦', // '¦'; '\u{a6}'
- '§', // '§'; '\u{a7}'
- '¨', // '¨'; '\u{a8}'
- '©', // '©'; '\u{a9}'
- 'a', // 'ª'; '\u{aa}'
- '«', // '«'; '\u{ab}'
- '¬', // '¬'; '\u{ac}'
- '\u{ad}', // invisible SOFT HYPHEN
- '®', // '®'; '\u{ae}'
- '¯', // '¯'; '\u{af}'
- '°', // '°'; '\u{b0}'
- '±', // '±'; '\u{b1}'
- '2', // '²'; '\u{b2}'
- '3', // '³'; '\u{b3}'
- '´', // '´'; '\u{b4}'
- 'µ', // 'µ'; '\u{b5}'
- '¶', // '¶'; '\u{b6}'
- '·', // '·'; '\u{b7}'
- '¸', // '¸'; '\u{b8}'
- '1', // '¹'; '\u{b9}'
- '0', // 'º'; '\u{ba}'
- '»', // '»'; '\u{bb}'
- '¼', // '¼'; '\u{bc}'
- '½', // '½'; '\u{bd}'
- '¾', // '¾'; '\u{be}'
- '?', // '¿'; '\u{bf}'
- 'A', // 'À'; '\u{c0}'
- 'A', // 'Á'; '\u{c1}'
- 'A', // 'Â'; '\u{c2}'
- 'A', // 'Ã'; '\u{c3}'
- 'A', // 'Ä'; '\u{c4}'
- 'A', // 'Å'; '\u{c5}'
- 'Æ', // 'Æ'; '\u{c6}'
- 'C', // 'Ç'; '\u{c7}'
- 'E', // 'È'; '\u{c8}'
- 'E', // 'É'; '\u{c9}'
- 'E', // 'Ê'; '\u{ca}'
- 'E', // 'Ë'; '\u{cb}'
- 'I', // 'Ì'; '\u{cc}'
- 'I', // 'Í'; '\u{cd}'
- 'I', // 'Î'; '\u{ce}'
- 'I', // 'Ï'; '\u{cf}'
- 'D', // 'Ð'; '\u{d0}'
- 'N', // 'Ñ'; '\u{d1}'
- 'O', // 'Ò'; '\u{d2}'
- 'O', // 'Ó'; '\u{d3}'
- 'O', // 'Ô'; '\u{d4}'
- 'O', // 'Õ'; '\u{d5}'
- 'O', // 'Ö'; '\u{d6}'
- '×', // '×'; '\u{d7}'
- 'O', // 'Ø'; '\u{d8}'
- 'U', // 'Ù'; '\u{d9}'
- 'U', // 'Ú'; '\u{da}'
- 'U', // 'Û'; '\u{db}'
- 'U', // 'Ü'; '\u{dc}'
- 'Y', // 'Ý'; '\u{dd}'
- 'Þ', // 'Þ'; '\u{de}'
- 's', // 'ß'; '\u{df}'
- 'a', // 'à'; '\u{e0}'
- 'a', // 'á'; '\u{e1}'
- 'a', // 'â'; '\u{e2}'
- 'a', // 'ã'; '\u{e3}'
- 'a', // 'ä'; '\u{e4}'
- 'a', // 'å'; '\u{e5}'
- 'æ', // 'æ'; '\u{e6}'
- 'c', // 'ç'; '\u{e7}'
- 'e', // 'è'; '\u{e8}'
- 'e', // 'é'; '\u{e9}'
- 'e', // 'ê'; '\u{ea}'
- 'e', // 'ë'; '\u{eb}'
- 'i', // 'ì'; '\u{ec}'
- 'i', // 'í'; '\u{ed}'
- 'i', // 'î'; '\u{ee}'
- 'i', // 'ï'; '\u{ef}'
- 'd', // 'ð'; '\u{f0}'
- 'n', // 'ñ'; '\u{f1}'
- 'o', // 'ò'; '\u{f2}'
- 'o', // 'ó'; '\u{f3}'
- 'o', // 'ô'; '\u{f4}'
- 'o', // 'õ'; '\u{f5}'
- 'o', // 'ö'; '\u{f6}'
- '÷', // '÷'; '\u{f7}'
- 'o', // 'ø'; '\u{f8}'
- 'u', // 'ù'; '\u{f9}'
- 'u', // 'ú'; '\u{fa}'
- 'u', // 'û'; '\u{fb}'
- 'u', // 'ü'; '\u{fc}'
- 'y', // 'ý'; '\u{fd}'
- 'þ', // 'þ'; '\u{fe}'
- 'y', // 'ÿ'; '\u{ff}'
- 'A', // 'Ā'; '\u{100}'
- 'a', // 'ā'; '\u{101}'
- 'A', // 'Ă'; '\u{102}'
- 'a', // 'ă'; '\u{103}'
- 'A', // 'Ą'; '\u{104}'
- 'a', // 'ą'; '\u{105}'
- 'C', // 'Ć'; '\u{106}'
- 'c', // 'ć'; '\u{107}'
- 'C', // 'Ĉ'; '\u{108}'
- 'c', // 'ĉ'; '\u{109}'
- 'C', // 'Ċ'; '\u{10a}'
- 'c', // 'ċ'; '\u{10b}'
- 'C', // 'Č'; '\u{10c}'
- 'c', // 'č'; '\u{10d}'
- 'D', // 'Ď'; '\u{10e}'
- 'd', // 'ď'; '\u{10f}'
- 'D', // 'Đ'; '\u{110}'
- 'd', // 'đ'; '\u{111}'
- 'E', // 'Ē'; '\u{112}'
- 'e', // 'ē'; '\u{113}'
- 'E', // 'Ĕ'; '\u{114}'
- 'e', // 'ĕ'; '\u{115}'
- 'E', // 'Ė'; '\u{116}'
- 'e', // 'ė'; '\u{117}'
- 'E', // 'Ę'; '\u{118}'
- 'e', // 'ę'; '\u{119}'
- 'E', // 'Ě'; '\u{11a}'
- 'e', // 'ě'; '\u{11b}'
- 'G', // 'Ĝ'; '\u{11c}'
- 'g', // 'ĝ'; '\u{11d}'
- 'G', // 'Ğ'; '\u{11e}'
- 'g', // 'ğ'; '\u{11f}'
- 'G', // 'Ġ'; '\u{120}'
- 'g', // 'ġ'; '\u{121}'
- 'G', // 'Ģ'; '\u{122}'
- 'g', // 'ģ'; '\u{123}'
- 'H', // 'Ĥ'; '\u{124}'
- 'h', // 'ĥ'; '\u{125}'
- 'H', // 'Ħ'; '\u{126}'
- 'h', // 'ħ'; '\u{127}'
- 'I', // 'Ĩ'; '\u{128}'
- 'i', // 'ĩ'; '\u{129}'
- 'I', // 'Ī'; '\u{12a}'
- 'i', // 'ī'; '\u{12b}'
- 'I', // 'Ĭ'; '\u{12c}'
- 'i', // 'ĭ'; '\u{12d}'
- 'I', // 'Į'; '\u{12e}'
- 'i', // 'į'; '\u{12f}'
- 'I', // 'İ'; '\u{130}'
- 'i', // 'ı'; '\u{131}'
- 'IJ', // 'IJ'; '\u{132}'
- 'ij', // 'ij'; '\u{133}'
- 'J', // 'Ĵ'; '\u{134}'
- 'j', // 'ĵ'; '\u{135}'
- 'K', // 'Ķ'; '\u{136}'
- 'k', // 'ķ'; '\u{137}'
- 'ĸ', // 'ĸ'; '\u{138}'
- 'L', // 'Ĺ'; '\u{139}'
- 'l', // 'ĺ'; '\u{13a}'
- 'L', // 'Ļ'; '\u{13b}'
- 'l', // 'ļ'; '\u{13c}'
- 'L', // 'Ľ'; '\u{13d}'
- 'l', // 'ľ'; '\u{13e}'
- 'L', // 'Ŀ'; '\u{13f}'
- 'l', // 'ŀ'; '\u{140}'
- 'L', // 'Ł'; '\u{141}'
- 'l', // 'ł'; '\u{142}'
- 'N', // 'Ń'; '\u{143}'
- 'n', // 'ń'; '\u{144}'
- 'N', // 'Ņ'; '\u{145}'
- 'n', // 'ņ'; '\u{146}'
- 'N', // 'Ň'; '\u{147}'
- 'n', // 'ň'; '\u{148}'
- 'n', // 'ʼn'; '\u{149}'
- 'N', // 'Ŋ'; '\u{14a}'
- 'n', // 'ŋ'; '\u{14b}'
- 'O', // 'Ō'; '\u{14c}'
- 'o', // 'ō'; '\u{14d}'
- 'O', // 'Ŏ'; '\u{14e}'
- 'o', // 'ŏ'; '\u{14f}'
- 'O', // 'Ő'; '\u{150}'
- 'o', // 'ő'; '\u{151}'
- 'Œ', // 'Œ'; '\u{152}'
- 'œ', // 'œ'; '\u{153}'
- 'R', // 'Ŕ'; '\u{154}'
- 'r', // 'ŕ'; '\u{155}'
- 'R', // 'Ŗ'; '\u{156}'
- 'r', // 'ŗ'; '\u{157}'
- 'R', // 'Ř'; '\u{158}'
- 'r', // 'ř'; '\u{159}'
- 'S', // 'Ś'; '\u{15a}'
- 's', // 'ś'; '\u{15b}'
- 'S', // 'Ŝ'; '\u{15c}'
- 's', // 'ŝ'; '\u{15d}'
- 'S', // 'Ş'; '\u{15e}'
- 's', // 'ş'; '\u{15f}'
- 'S', // 'Š'; '\u{160}'
- 's', // 'š'; '\u{161}'
- 'T', // 'Ţ'; '\u{162}'
- 't', // 'ţ'; '\u{163}'
- 'T', // 'Ť'; '\u{164}'
- 't', // 'ť'; '\u{165}'
- 'T', // 'Ŧ'; '\u{166}'
- 't', // 'ŧ'; '\u{167}'
- 'U', // 'Ũ'; '\u{168}'
- 'u', // 'ũ'; '\u{169}'
- 'U', // 'Ū'; '\u{16a}'
- 'u', // 'ū'; '\u{16b}'
- 'U', // 'Ŭ'; '\u{16c}'
- 'u', // 'ŭ'; '\u{16d}'
- 'U', // 'Ů'; '\u{16e}'
- 'u', // 'ů'; '\u{16f}'
- 'U', // 'Ű'; '\u{170}'
- 'u', // 'ű'; '\u{171}'
- 'U', // 'Ų'; '\u{172}'
- 'u', // 'ų'; '\u{173}'
- 'W', // 'Ŵ'; '\u{174}'
- 'w', // 'ŵ'; '\u{175}'
- 'Y', // 'Ŷ'; '\u{176}'
- 'y', // 'ŷ'; '\u{177}'
- 'Y', // 'Ÿ'; '\u{178}'
- 'Z', // 'Ź'; '\u{179}'
- 'z', // 'ź'; '\u{17a}'
- 'Z', // 'Ż'; '\u{17b}'
- 'z', // 'ż'; '\u{17c}'
- 'Z', // 'Ž'; '\u{17d}'
- 'z', // 'ž'; '\u{17e}'
- 's', // 'ſ'; '\u{17f}'
- 'b', // 'ƀ'; '\u{180}'
- 'B', // 'Ɓ'; '\u{181}'
- 'b', // 'Ƃ'; '\u{182}'
- 'b', // 'ƃ'; '\u{183}'
- 'b', // 'Ƅ'; '\u{184}'
- 'ƅ', // 'ƅ'; '\u{185}'
- 'O', // 'Ɔ'; '\u{186}'
- 'C', // 'Ƈ'; '\u{187}'
- 'c', // 'ƈ'; '\u{188}'
- 'D', // 'Ɖ'; '\u{189}'
- 'D', // 'Ɗ'; '\u{18a}'
- 'd', // 'Ƌ'; '\u{18b}'
- 'd', // 'ƌ'; '\u{18c}'
- 'ƍ', // 'ƍ'; '\u{18d}'
- 'E', // 'Ǝ'; '\u{18e}'
- 'e', // 'Ə'; '\u{18f}'
- 'E', // 'Ɛ'; '\u{190}'
- 'F', // 'Ƒ'; '\u{191}'
- 'f', // 'ƒ'; '\u{192}'
- 'G', // 'Ɠ'; '\u{193}'
- 'Ɣ', // 'Ɣ'; '\u{194}'
- 'h', // 'ƕ'; '\u{195}'
- 'I', // 'Ɩ'; '\u{196}'
- 'I', // 'Ɨ'; '\u{197}'
- 'Ƙ', // 'Ƙ'; '\u{198}'
- 'k', // 'ƙ'; '\u{199}'
- 'l', // 'ƚ'; '\u{19a}'
- 'ƛ', // 'ƛ'; '\u{19b}'
- 'M', // 'Ɯ'; '\u{19c}'
- 'N', // 'Ɲ'; '\u{19d}'
- 'n', // 'ƞ'; '\u{19e}'
- 'O', // 'Ɵ'; '\u{19f}'
- 'O', // 'Ơ'; '\u{1a0}'
- 'o', // 'ơ'; '\u{1a1}'
- 'Ƣ', // 'Ƣ'; '\u{1a2}'
- 'ƣ', // 'ƣ'; '\u{1a3}'
- 'P', // 'Ƥ'; '\u{1a4}'
- 'p', // 'ƥ'; '\u{1a5}'
- 'R', // 'Ʀ'; '\u{1a6}'
- 'S', // 'Ƨ'; '\u{1a7}'
- 's', // 'ƨ'; '\u{1a8}'
- 'Ʃ', // 'Ʃ'; '\u{1a9}'
- 'l', // 'ƪ'; '\u{1aa}'
- 't', // 'ƫ'; '\u{1ab}'
- 'T', // 'Ƭ'; '\u{1ac}'
- 't', // 'ƭ'; '\u{1ad}'
- 'T', // 'Ʈ'; '\u{1ae}'
- 'U', // 'Ư'; '\u{1af}'
- 'u', // 'ư'; '\u{1b0}'
- 'Ʊ', // 'Ʊ'; '\u{1b1}'
- 'V', // 'Ʋ'; '\u{1b2}'
- 'Y', // 'Ƴ'; '\u{1b3}'
- 'y', // 'ƴ'; '\u{1b4}'
- 'Z', // 'Ƶ'; '\u{1b5}'
- 'z', // 'ƶ'; '\u{1b6}'
- 'Ʒ', // 'Ʒ'; '\u{1b7}'
- 'Ƹ', // 'Ƹ'; '\u{1b8}'
- 'ƹ', // 'ƹ'; '\u{1b9}'
- 'ƺ', // 'ƺ'; '\u{1ba}'
- 'ƻ', // 'ƻ'; '\u{1bb}'
- 'Ƽ', // 'Ƽ'; '\u{1bc}'
- 'ƽ', // 'ƽ'; '\u{1bd}'
- 'ƾ', // 'ƾ'; '\u{1be}'
- 'ƿ', // 'ƿ'; '\u{1bf}'
- 'ǀ', // 'ǀ'; '\u{1c0}'
- 'ǁ', // 'ǁ'; '\u{1c1}'
- 'ǂ', // 'ǂ'; '\u{1c2}'
- '!', // 'ǃ'; '\u{1c3}'
- 'DŽ', // 'DŽ'; '\u{1c4}'
- 'Dž', // 'Dž'; '\u{1c5}'
- 'dž', // 'dž'; '\u{1c6}'
- 'LJ', // 'LJ'; '\u{1c7}'
- 'Lj', // 'Lj'; '\u{1c8}'
- 'lj', // 'lj'; '\u{1c9}'
- 'NJ', // 'NJ'; '\u{1ca}'
- 'Nj', // 'Nj'; '\u{1cb}'
- 'nj', // 'nj'; '\u{1cc}'
- 'A', // 'Ǎ'; '\u{1cd}'
- 'a', // 'ǎ'; '\u{1ce}'
- 'I', // 'Ǐ'; '\u{1cf}'
- 'i', // 'ǐ'; '\u{1d0}'
- 'O', // 'Ǒ'; '\u{1d1}'
- 'o', // 'ǒ'; '\u{1d2}'
- 'U', // 'Ǔ'; '\u{1d3}'
- 'u', // 'ǔ'; '\u{1d4}'
- 'U', // 'Ǖ'; '\u{1d5}'
- 'u', // 'ǖ'; '\u{1d6}'
- 'U', // 'Ǘ'; '\u{1d7}'
- 'u', // 'ǘ'; '\u{1d8}'
- 'U', // 'Ǚ'; '\u{1d9}'
- 'u', // 'ǚ'; '\u{1da}'
- 'U', // 'Ǜ'; '\u{1db}'
- 'u', // 'ǜ'; '\u{1dc}'
- 'e', // 'ǝ'; '\u{1dd}'
- 'A', // 'Ǟ'; '\u{1de}'
- 'a', // 'ǟ'; '\u{1df}'
- 'A', // 'Ǡ'; '\u{1e0}'
- 'a', // 'ǡ'; '\u{1e1}'
- 'Æ', // 'Ǣ'; '\u{1e2}'
- 'æ', // 'ǣ'; '\u{1e3}'
- 'G', // 'Ǥ'; '\u{1e4}'
- 'g', // 'ǥ'; '\u{1e5}'
- 'G', // 'Ǧ'; '\u{1e6}'
- 'g', // 'ǧ'; '\u{1e7}'
- 'K', // 'Ǩ'; '\u{1e8}'
- 'k', // 'ǩ'; '\u{1e9}'
- 'O', // 'Ǫ'; '\u{1ea}'
- 'o', // 'ǫ'; '\u{1eb}'
- 'O', // 'Ǭ'; '\u{1ec}'
- 'o', // 'ǭ'; '\u{1ed}'
- 'Ǯ', // 'Ǯ'; '\u{1ee}'
- 'ǯ', // 'ǯ'; '\u{1ef}'
- 'j', // 'ǰ'; '\u{1f0}'
- 'DZ', // 'DZ'; '\u{1f1}'
- 'Dz', // 'Dz'; '\u{1f2}'
- 'dz', // 'dz'; '\u{1f3}'
- 'G', // 'Ǵ'; '\u{1f4}'
- 'g', // 'ǵ'; '\u{1f5}'
- 'Ƕ', // 'Ƕ'; '\u{1f6}'
- 'Ƿ', // 'Ƿ'; '\u{1f7}'
- 'N', // 'Ǹ'; '\u{1f8}'
- 'n', // 'ǹ'; '\u{1f9}'
- 'A', // 'Ǻ'; '\u{1fa}'
- 'a', // 'ǻ'; '\u{1fb}'
- 'Æ', // 'Ǽ'; '\u{1fc}'
- 'æ', // 'ǽ'; '\u{1fd}'
- 'O', // 'Ǿ'; '\u{1fe}'
- 'o', // 'ǿ'; '\u{1ff}'
- 'A', // 'Ȁ'; '\u{200}'
- 'a', // 'ȁ'; '\u{201}'
- 'A', // 'Ȃ'; '\u{202}'
- 'a', // 'ȃ'; '\u{203}'
- 'E', // 'Ȅ'; '\u{204}'
- 'e', // 'ȅ'; '\u{205}'
- 'E', // 'Ȇ'; '\u{206}'
- 'e', // 'ȇ'; '\u{207}'
- 'I', // 'Ȉ'; '\u{208}'
- 'i', // 'ȉ'; '\u{209}'
- 'I', // 'Ȋ'; '\u{20a}'
- 'i', // 'ȋ'; '\u{20b}'
- 'O', // 'Ȍ'; '\u{20c}'
- 'o', // 'ȍ'; '\u{20d}'
- 'O', // 'Ȏ'; '\u{20e}'
- 'o', // 'ȏ'; '\u{20f}'
- 'R', // 'Ȑ'; '\u{210}'
- 'r', // 'ȑ'; '\u{211}'
- 'R', // 'Ȓ'; '\u{212}'
- 'r', // 'ȓ'; '\u{213}'
- 'U', // 'Ȕ'; '\u{214}'
- 'u', // 'ȕ'; '\u{215}'
- 'U', // 'Ȗ'; '\u{216}'
- 'u', // 'ȗ'; '\u{217}'
- 'S', // 'Ș'; '\u{218}'
- 's', // 'ș'; '\u{219}'
- 'T', // 'Ț'; '\u{21a}'
- 't', // 'ț'; '\u{21b}'
- 'Ȝ', // 'Ȝ'; '\u{21c}'
- 'ȝ', // 'ȝ'; '\u{21d}'
- 'H', // 'Ȟ'; '\u{21e}'
- 'h', // 'ȟ'; '\u{21f}'
- 'N', // 'Ƞ'; '\u{220}'
- 'd', // 'ȡ'; '\u{221}'
- 'Ȣ', // 'Ȣ'; '\u{222}'
- 'ȣ', // 'ȣ'; '\u{223}'
- 'Z', // 'Ȥ'; '\u{224}'
- 'z', // 'ȥ'; '\u{225}'
- 'A', // 'Ȧ'; '\u{226}'
- 'a', // 'ȧ'; '\u{227}'
- 'E', // 'Ȩ'; '\u{228}'
- 'e', // 'ȩ'; '\u{229}'
- 'O', // 'Ȫ'; '\u{22a}'
- 'o', // 'ȫ'; '\u{22b}'
- 'O', // 'Ȭ'; '\u{22c}'
- 'o', // 'ȭ'; '\u{22d}'
- 'O', // 'Ȯ'; '\u{22e}'
- 'o', // 'ȯ'; '\u{22f}'
- 'O', // 'Ȱ'; '\u{230}'
- 'o', // 'ȱ'; '\u{231}'
- 'Y', // 'Ȳ'; '\u{232}'
- 'y', // 'ȳ'; '\u{233}'
- 'l', // 'ȴ'; '\u{234}'
- 'n', // 'ȵ'; '\u{235}'
- 't', // 'ȶ'; '\u{236}'
- 'j', // 'ȷ'; '\u{237}'
- 'ȸ', // 'ȸ'; '\u{238}'
- 'ȹ', // 'ȹ'; '\u{239}'
- 'A', // 'Ⱥ'; '\u{23a}'
- 'C', // 'Ȼ'; '\u{23b}'
- 'c', // 'ȼ'; '\u{23c}'
- 'L', // 'Ƚ'; '\u{23d}'
- 'T', // 'Ⱦ'; '\u{23e}'
- 's', // 'ȿ'; '\u{23f}'
- 'z', // 'ɀ'; '\u{240}'
- 'Ɂ', // 'Ɂ'; '\u{241}'
- 'ɂ', // 'ɂ'; '\u{242}'
- 'B', // 'Ƀ'; '\u{243}'
- 'U', // 'Ʉ'; '\u{244}'
- 'V', // 'Ʌ'; '\u{245}'
- 'E', // 'Ɇ'; '\u{246}'
- 'e', // 'ɇ'; '\u{247}'
- 'J', // 'Ɉ'; '\u{248}'
- 'j', // 'ɉ'; '\u{249}'
- 'Q', // 'Ɋ'; '\u{24a}'
- 'q', // 'ɋ'; '\u{24b}'
- 'R', // 'Ɍ'; '\u{24c}'
- 'r', // 'ɍ'; '\u{24d}'
- 'Y', // 'Ɏ'; '\u{24e}'
- 'y', // 'ɏ'; '\u{24f}'
- 'a', // 'ɐ'; '\u{250}'
- 'a', // 'ɑ'; '\u{251}'
- 'a', // 'ɒ'; '\u{252}'
- 'b', // 'ɓ'; '\u{253}'
- 'c', // 'ɔ'; '\u{254}'
- 'c', // 'ɕ'; '\u{255}'
- 'd', // 'ɖ'; '\u{256}'
- 'd', // 'ɗ'; '\u{257}'
- 'e', // 'ɘ'; '\u{258}'
- 'e', // 'ə'; '\u{259}'
- 'e', // 'ɚ'; '\u{25a}'
- 'e', // 'ɛ'; '\u{25b}'
- 'e', // 'ɜ'; '\u{25c}'
- 'e', // 'ɝ'; '\u{25d}'
- 'e', // 'ɞ'; '\u{25e}'
- 'j', // 'ɟ'; '\u{25f}'
- 'g', // 'ɠ'; '\u{260}'
- 'g', // 'ɡ'; '\u{261}'
- 'G', // 'ɢ'; '\u{262}'
- 'g', // 'ɣ'; '\u{263}'
- 'u', // 'ɤ'; '\u{264}'
- 'h', // 'ɥ'; '\u{265}'
- 'h', // 'ɦ'; '\u{266}'
- 'h', // 'ɧ'; '\u{267}'
- 'i', // 'ɨ'; '\u{268}'
- 'i', // 'ɩ'; '\u{269}'
- 'I', // 'ɪ'; '\u{26a}'
- 'l', // 'ɫ'; '\u{26b}'
- 'l', // 'ɬ'; '\u{26c}'
- 'l', // 'ɭ'; '\u{26d}'
- 'ɮ', // 'ɮ'; '\u{26e}'
- 'm', // 'ɯ'; '\u{26f}'
- 'm', // 'ɰ'; '\u{270}'
- 'm', // 'ɱ'; '\u{271}'
- 'n', // 'ɲ'; '\u{272}'
- 'n', // 'ɳ'; '\u{273}'
- 'N', // 'ɴ'; '\u{274}'
- 'o', // 'ɵ'; '\u{275}'
- 'ɶ', // 'ɶ'; '\u{276}'
- 'ɷ', // 'ɷ'; '\u{277}'
- 'ɸ', // 'ɸ'; '\u{278}'
- 'r', // 'ɹ'; '\u{279}'
- 'r', // 'ɺ'; '\u{27a}'
- 'r', // 'ɻ'; '\u{27b}'
- 'r', // 'ɼ'; '\u{27c}'
- 'r', // 'ɽ'; '\u{27d}'
- 'r', // 'ɾ'; '\u{27e}'
- 'r', // 'ɿ'; '\u{27f}'
- 'R', // 'ʀ'; '\u{280}'
- 'R', // 'ʁ'; '\u{281}'
- 's', // 'ʂ'; '\u{282}'
- 'ʃ', // 'ʃ'; '\u{283}'
- 'ʄ', // 'ʄ'; '\u{284}'
- 'ʅ', // 'ʅ'; '\u{285}'
- 'ʆ', // 'ʆ'; '\u{286}'
- 't', // 'ʇ'; '\u{287}'
- 't', // 'ʈ'; '\u{288}'
- 'u', // 'ʉ'; '\u{289}'
- 'ʊ', // 'ʊ'; '\u{28a}'
- 'v', // 'ʋ'; '\u{28b}'
- 'v', // 'ʌ'; '\u{28c}'
- 'w', // 'ʍ'; '\u{28d}'
- 'y', // 'ʎ'; '\u{28e}'
- 'Y', // 'ʏ'; '\u{28f}'
- 'z', // 'ʐ'; '\u{290}'
- 'z', // 'ʑ'; '\u{291}'
- 'ʒ', // 'ʒ'; '\u{292}'
- 'ʓ', // 'ʓ'; '\u{293}'
- 'ʔ', // 'ʔ'; '\u{294}'
- 'ʕ', // 'ʕ'; '\u{295}'
- 'ʖ', // 'ʖ'; '\u{296}'
- 'c', // 'ʗ'; '\u{297}'
- 'ʘ', // 'ʘ'; '\u{298}'
- 'B', // 'ʙ'; '\u{299}'
- 'e', // 'ʚ'; '\u{29a}'
- 'G', // 'ʛ'; '\u{29b}'
- 'H', // 'ʜ'; '\u{29c}'
- 'j', // 'ʝ'; '\u{29d}'
- 'k', // 'ʞ'; '\u{29e}'
- 'L', // 'ʟ'; '\u{29f}'
-];
-
-/// A char array corresponding to the following Unicode block:
-///
-/// - [Latin Extended Additional](https://en.wikipedia.org/wiki/Latin_Extended_Additional)
-///
-/// This covers the range `'\u{1e00}'..='\u{1eff}'`.
-static LATIN_EXTENDED_ADDITIONAL: [char; 256] = [
- 'A', // 'Ḁ'; '\u{1e00}'
- 'a', // 'ḁ'; '\u{1e01}'
- 'B', // 'Ḃ'; '\u{1e02}'
- 'b', // 'ḃ'; '\u{1e03}'
- 'B', // 'Ḅ'; '\u{1e04}'
- 'b', // 'ḅ'; '\u{1e05}'
- 'B', // 'Ḇ'; '\u{1e06}'
- 'b', // 'ḇ'; '\u{1e07}'
- 'C', // 'Ḉ'; '\u{1e08}'
- 'c', // 'ḉ'; '\u{1e09}'
- 'D', // 'Ḋ'; '\u{1e0a}'
- 'e', // 'ḋ'; '\u{1e0b}'
- 'D', // 'Ḍ'; '\u{1e0c}'
- 'd', // 'ḍ'; '\u{1e0d}'
- 'D', // 'Ḏ'; '\u{1e0e}'
- 'd', // 'ḏ'; '\u{1e0f}'
- 'D', // 'Ḑ'; '\u{1e10}'
- 'd', // 'ḑ'; '\u{1e11}'
- 'D', // 'Ḓ'; '\u{1e12}'
- 'd', // 'ḓ'; '\u{1e13}'
- 'E', // 'Ḕ'; '\u{1e14}'
- 'e', // 'ḕ'; '\u{1e15}'
- 'E', // 'Ḗ'; '\u{1e16}'
- 'e', // 'ḗ'; '\u{1e17}'
- 'E', // 'Ḙ'; '\u{1e18}'
- 'e', // 'ḙ'; '\u{1e19}'
- 'E', // 'Ḛ'; '\u{1e1a}'
- 'e', // 'ḛ'; '\u{1e1b}'
- 'E', // 'Ḝ'; '\u{1e1c}'
- 'e', // 'ḝ'; '\u{1e1d}'
- 'F', // 'Ḟ'; '\u{1e1e}'
- 'f', // 'ḟ'; '\u{1e1f}'
- 'G', // 'Ḡ'; '\u{1e20}'
- 'g', // 'ḡ'; '\u{1e21}'
- 'H', // 'Ḣ'; '\u{1e22}'
- 'g', // 'ḣ'; '\u{1e23}'
- 'H', // 'Ḥ'; '\u{1e24}'
- 'g', // 'ḥ'; '\u{1e25}'
- 'H', // 'Ḧ'; '\u{1e26}'
- 'g', // 'ḧ'; '\u{1e27}'
- 'H', // 'Ḩ'; '\u{1e28}'
- 'g', // 'ḩ'; '\u{1e29}'
- 'H', // 'Ḫ'; '\u{1e2a}'
- 'h', // 'ḫ'; '\u{1e2b}'
- 'I', // 'Ḭ'; '\u{1e2c}'
- 'i', // 'ḭ'; '\u{1e2d}'
- 'I', // 'Ḯ'; '\u{1e2e}'
- 'i', // 'ḯ'; '\u{1e2f}'
- 'K', // 'Ḱ'; '\u{1e30}'
- 'k', // 'ḱ'; '\u{1e31}'
- 'K', // 'Ḳ'; '\u{1e32}'
- 'k', // 'ḳ'; '\u{1e33}'
- 'K', // 'Ḵ'; '\u{1e34}'
- 'k', // 'ḵ'; '\u{1e35}'
- 'L', // 'Ḷ'; '\u{1e36}'
- 'l', // 'ḷ'; '\u{1e37}'
- 'L', // 'Ḹ'; '\u{1e38}'
- 'l', // 'ḹ'; '\u{1e39}'
- 'L', // 'Ḻ'; '\u{1e3a}'
- 'l', // 'ḻ'; '\u{1e3b}'
- 'L', // 'Ḽ'; '\u{1e3c}'
- 'l', // 'ḽ'; '\u{1e3d}'
- 'M', // 'Ḿ'; '\u{1e3e}'
- 'm', // 'ḿ'; '\u{1e3f}'
- 'M', // 'Ṁ'; '\u{1e40}'
- 'm', // 'ṁ'; '\u{1e41}'
- 'M', // 'Ṃ'; '\u{1e42}'
- 'm', // 'ṃ'; '\u{1e43}'
- 'N', // 'Ṅ'; '\u{1e44}'
- 'n', // 'ṅ'; '\u{1e45}'
- 'N', // 'Ṇ'; '\u{1e46}'
- 'n', // 'ṇ'; '\u{1e47}'
- 'N', // 'Ṉ'; '\u{1e48}'
- 'n', // 'ṉ'; '\u{1e49}'
- 'N', // 'Ṋ'; '\u{1e4a}'
- 'n', // 'ṋ'; '\u{1e4b}'
- 'O', // 'Ṍ'; '\u{1e4c}'
- 'o', // 'ṍ'; '\u{1e4d}'
- 'O', // 'Ṏ'; '\u{1e4e}'
- 'o', // 'ṏ'; '\u{1e4f}'
- 'O', // 'Ṑ'; '\u{1e50}'
- 'o', // 'ṑ'; '\u{1e51}'
- 'O', // 'Ṓ'; '\u{1e52}'
- 'o', // 'ṓ'; '\u{1e53}'
- 'P', // 'Ṕ'; '\u{1e54}'
- 'p', // 'ṕ'; '\u{1e55}'
- 'P', // 'Ṗ'; '\u{1e56}'
- 'p', // 'ṗ'; '\u{1e57}'
- 'R', // 'Ṙ'; '\u{1e58}'
- 'r', // 'ṙ'; '\u{1e59}'
- 'R', // 'Ṛ'; '\u{1e5a}'
- 'r', // 'ṛ'; '\u{1e5b}'
- 'R', // 'Ṝ'; '\u{1e5c}'
- 'r', // 'ṝ'; '\u{1e5d}'
- 'R', // 'Ṟ'; '\u{1e5e}'
- 'r', // 'ṟ'; '\u{1e5f}'
- 'S', // 'Ṡ'; '\u{1e60}'
- 's', // 'ṡ'; '\u{1e61}'
- 'S', // 'Ṣ'; '\u{1e62}'
- 's', // 'ṣ'; '\u{1e63}'
- 'S', // 'Ṥ'; '\u{1e64}'
- 's', // 'ṥ'; '\u{1e65}'
- 'S', // 'Ṧ'; '\u{1e66}'
- 's', // 'ṧ'; '\u{1e67}'
- 'S', // 'Ṩ'; '\u{1e68}'
- 's', // 'ṩ'; '\u{1e69}'
- 'T', // 'Ṫ'; '\u{1e6a}'
- 't', // 'ṫ'; '\u{1e6b}'
- 'T', // 'Ṭ'; '\u{1e6c}'
- 't', // 'ṭ'; '\u{1e6d}'
- 'T', // 'Ṯ'; '\u{1e6e}'
- 't', // 'ṯ'; '\u{1e6f}'
- 'T', // 'Ṱ'; '\u{1e70}'
- 't', // 'ṱ'; '\u{1e71}'
- 'U', // 'Ṳ'; '\u{1e72}'
- 'u', // 'ṳ'; '\u{1e73}'
- 'U', // 'Ṵ'; '\u{1e74}'
- 'u', // 'ṵ'; '\u{1e75}'
- 'U', // 'Ṷ'; '\u{1e76}'
- 'u', // 'ṷ'; '\u{1e77}'
- 'U', // 'Ṹ'; '\u{1e78}'
- 'u', // 'ṹ'; '\u{1e79}'
- 'U', // 'Ṻ'; '\u{1e7a}'
- 'u', // 'ṻ'; '\u{1e7b}'
- 'V', // 'Ṽ'; '\u{1e7c}'
- 'v', // 'ṽ'; '\u{1e7d}'
- 'V', // 'Ṿ'; '\u{1e7e}'
- 'v', // 'ṿ'; '\u{1e7f}'
- 'W', // 'Ẁ'; '\u{1e80}'
- 'w', // 'ẁ'; '\u{1e81}'
- 'W', // 'Ẃ'; '\u{1e82}'
- 'w', // 'ẃ'; '\u{1e83}'
- 'W', // 'Ẅ'; '\u{1e84}'
- 'w', // 'ẅ'; '\u{1e85}'
- 'W', // 'Ẇ'; '\u{1e86}'
- 'w', // 'ẇ'; '\u{1e87}'
- 'W', // 'Ẉ'; '\u{1e88}'
- 'j', // 'ẉ'; '\u{1e89}'
- 'X', // 'Ẋ'; '\u{1e8a}'
- 'x', // 'ẋ'; '\u{1e8b}'
- 'X', // 'Ẍ'; '\u{1e8c}'
- 'x', // 'ẍ'; '\u{1e8d}'
- 'Y', // 'Ẏ'; '\u{1e8e}'
- 'y', // 'ẏ'; '\u{1e8f}'
- 'Z', // 'Ẑ'; '\u{1e90}'
- 'z', // 'ẑ'; '\u{1e91}'
- 'Z', // 'Ẓ'; '\u{1e92}'
- 'z', // 'ẓ'; '\u{1e93}'
- 'Z', // 'Ẕ'; '\u{1e94}'
- 'z', // 'ẕ'; '\u{1e95}'
- 'h', // 'ẖ'; '\u{1e96}'
- 't', // 'ẗ'; '\u{1e97}'
- 'w', // 'ẘ'; '\u{1e98}'
- 'y', // 'ẙ'; '\u{1e99}'
- 'a', // 'ẚ'; '\u{1e9a}'
- 'i', // 'ẛ'; '\u{1e9b}'
- 'f', // 'ẜ'; '\u{1e9c}'
- 'f', // 'ẝ'; '\u{1e9d}'
- 'ẞ', // 'ẞ'; '\u{1e9e}'
- 'ẟ', // 'ẟ'; '\u{1e9f}'
- 'A', // 'Ạ'; '\u{1ea0}'
- 'a', // 'ạ'; '\u{1ea1}'
- 'A', // 'Ả'; '\u{1ea2}'
- 'a', // 'ả'; '\u{1ea3}'
- 'A', // 'Ấ'; '\u{1ea4}'
- 'a', // 'ấ'; '\u{1ea5}'
- 'A', // 'Ầ'; '\u{1ea6}'
- 'a', // 'ầ'; '\u{1ea7}'
- 'A', // 'Ẩ'; '\u{1ea8}'
- 'a', // 'ẩ'; '\u{1ea9}'
- 'A', // 'Ẫ'; '\u{1eaa}'
- 'a', // 'ẫ'; '\u{1eab}'
- 'A', // 'Ậ'; '\u{1eac}'
- 'a', // 'ậ'; '\u{1ead}'
- 'A', // 'Ắ'; '\u{1eae}'
- 'a', // 'ắ'; '\u{1eaf}'
- 'A', // 'Ằ'; '\u{1eb0}'
- 'a', // 'ằ'; '\u{1eb1}'
- 'A', // 'Ẳ'; '\u{1eb2}'
- 'a', // 'ẳ'; '\u{1eb3}'
- 'A', // 'Ẵ'; '\u{1eb4}'
- 'a', // 'ẵ'; '\u{1eb5}'
- 'A', // 'Ặ'; '\u{1eb6}'
- 'a', // 'ặ'; '\u{1eb7}'
- 'E', // 'Ẹ'; '\u{1eb8}'
- 'e', // 'ẹ'; '\u{1eb9}'
- 'E', // 'Ẻ'; '\u{1eba}'
- 'e', // 'ẻ'; '\u{1ebb}'
- 'E', // 'Ẽ'; '\u{1ebc}'
- 'e', // 'ẽ'; '\u{1ebd}'
- 'E', // 'Ế'; '\u{1ebe}'
- 'e', // 'ế'; '\u{1ebf}'
- 'E', // 'Ề'; '\u{1ec0}'
- 'e', // 'ề'; '\u{1ec1}'
- 'E', // 'Ể'; '\u{1ec2}'
- 'e', // 'ể'; '\u{1ec3}'
- 'E', // 'Ễ'; '\u{1ec4}'
- 'e', // 'ễ'; '\u{1ec5}'
- 'E', // 'Ệ'; '\u{1ec6}'
- 'e', // 'ệ'; '\u{1ec7}'
- 'I', // 'Ỉ'; '\u{1ec8}'
- 'i', // 'ỉ'; '\u{1ec9}'
- 'I', // 'Ị'; '\u{1eca}'
- 'i', // 'ị'; '\u{1ecb}'
- 'O', // 'Ọ'; '\u{1ecc}'
- 'o', // 'ọ'; '\u{1ecd}'
- 'O', // 'Ỏ'; '\u{1ece}'
- 'o', // 'ỏ'; '\u{1ecf}'
- 'O', // 'Ố'; '\u{1ed0}'
- 'o', // 'ố'; '\u{1ed1}'
- 'O', // 'Ồ'; '\u{1ed2}'
- 'o', // 'ồ'; '\u{1ed3}'
- 'O', // 'Ổ'; '\u{1ed4}'
- 'o', // 'ổ'; '\u{1ed5}'
- 'O', // 'Ỗ'; '\u{1ed6}'
- 'o', // 'ỗ'; '\u{1ed7}'
- 'O', // 'Ộ'; '\u{1ed8}'
- 'o', // 'ộ'; '\u{1ed9}'
- 'O', // 'Ớ'; '\u{1eda}'
- 'o', // 'ớ'; '\u{1edb}'
- 'O', // 'Ờ'; '\u{1edc}'
- 'o', // 'ờ'; '\u{1edd}'
- 'O', // 'Ở'; '\u{1ede}'
- 'o', // 'ở'; '\u{1edf}'
- 'O', // 'Ỡ'; '\u{1ee0}'
- 'o', // 'ỡ'; '\u{1ee1}'
- 'O', // 'Ợ'; '\u{1ee2}'
- 'o', // 'ợ'; '\u{1ee3}'
- 'U', // 'Ụ'; '\u{1ee4}'
- 'u', // 'ụ'; '\u{1ee5}'
- 'U', // 'Ủ'; '\u{1ee6}'
- 'u', // 'ủ'; '\u{1ee7}'
- 'U', // 'Ứ'; '\u{1ee8}'
- 'u', // 'ứ'; '\u{1ee9}'
- 'U', // 'Ừ'; '\u{1eea}'
- 'u', // 'ừ'; '\u{1eeb}'
- 'U', // 'Ử'; '\u{1eec}'
- 'u', // 'ử'; '\u{1eed}'
- 'U', // 'Ữ'; '\u{1eee}'
- 'u', // 'ữ'; '\u{1eef}'
- 'U', // 'Ự'; '\u{1ef0}'
- 'u', // 'ự'; '\u{1ef1}'
- 'Y', // 'Ỳ'; '\u{1ef2}'
- 'y', // 'ỳ'; '\u{1ef3}'
- 'Y', // 'Ỵ'; '\u{1ef4}'
- 'y', // 'ỵ'; '\u{1ef5}'
- 'Y', // 'Ỷ'; '\u{1ef6}'
- 'y', // 'ỷ'; '\u{1ef7}'
- 'Y', // 'Ỹ'; '\u{1ef8}'
- 'y', // 'ỹ'; '\u{1ef9}'
- 'Ỻ', // 'Ỻ'; '\u{1efa}'
- 'ỻ', // 'ỻ'; '\u{1efb}'
- 'Ỽ', // 'Ỽ'; '\u{1efc}'
- 'ỽ', // 'ỽ'; '\u{1efd}'
- 'Ỿ', // 'Ỿ'; '\u{1efe}'
- 'ỿ', // 'ỿ'; '\u{1eff}'
-];
-
-/// A char array corresponding to the following Unicode block:
-///
-/// - [Superscripts and Subscripts](https://en.wikipedia.org/wiki/Superscripts_and_Subscripts)
-///
-/// This covers the range `'\u{2070}'..='\u{209f}'`.
-static SUPERSCRIPTS_AND_SUBSCRIPTS: [char; 48] = [
- '0', // '⁰'; '\u{2070}'
- 'i', // 'ⁱ'; '\u{2071}'
- '⁲', // '⁲'; '\u{2072}'
- '⁳', // '⁳'; '\u{2073}'
- '4', // '⁴'; '\u{2074}'
- '5', // '⁵'; '\u{2075}'
- '6', // '⁶'; '\u{2076}'
- '7', // '⁷'; '\u{2077}'
- '8', // '⁸'; '\u{2078}'
- '0', // '⁹'; '\u{2079}'
- '+', // '⁺'; '\u{207a}'
- '-', // '⁻'; '\u{207b}'
- '=', // '⁼'; '\u{207c}'
- '(', // '⁽'; '\u{207d}'
- ')', // '⁾'; '\u{207e}'
- 'n', // 'ⁿ'; '\u{207f}'
- '0', // '₀'; '\u{2080}'
- '1', // '₁'; '\u{2081}'
- '2', // '₂'; '\u{2082}'
- '3', // '₃'; '\u{2083}'
- '4', // '₄'; '\u{2084}'
- '5', // '₅'; '\u{2085}'
- '6', // '₆'; '\u{2086}'
- '7', // '₇'; '\u{2087}'
- '8', // '₈'; '\u{2088}'
- '9', // '₉'; '\u{2089}'
- '+', // '₊'; '\u{208a}'
- '-', // '₋'; '\u{208b}'
- '=', // '₌'; '\u{208c}'
- '(', // '₍'; '\u{208d}'
- ')', // '₎'; '\u{208e}'
- '₏', // '₏'; '\u{208f}'
- 'a', // 'ₐ'; '\u{2090}'
- 'e', // 'ₑ'; '\u{2091}'
- 'o', // 'ₒ'; '\u{2092}'
- 'x', // 'ₓ'; '\u{2093}'
- 'e', // 'ₔ'; '\u{2094}'
- 'h', // 'ₕ'; '\u{2095}'
- 'k', // 'ₖ'; '\u{2096}'
- 'l', // 'ₗ'; '\u{2097}'
- 'm', // 'ₘ'; '\u{2098}'
- 'n', // 'ₙ'; '\u{2099}'
- 'p', // 'ₚ'; '\u{209a}'
- 's', // 'ₛ'; '\u{209b}'
- 't', // 'ₜ'; '\u{209c}'
- '₝', // '₝'; '\u{209d}'
- '₞', // '₞'; '\u{209e}'
- '₟', // '₟'; '\u{209f}'
-];
-
-#[cfg(test)]
-mod tests {
- use super::normalize;
-
- /// Helper function for test assertions.
- fn check_conversions(pairs: &[(char, char)]) {
- for (original, normalized) in pairs {
- assert_eq!(normalize(*original), *normalized);
- }
- }
-
- /// General conversion checks
- #[test]
- fn general() {
- check_conversions(&[
- ('ą', 'a'),
- ('À', 'A'),
- ('ć', 'c'),
- ('ę', 'e'),
- ('ł', 'l'),
- ('ń', 'n'),
- ('ó', 'o'),
- ('ś', 's'),
- ('ź', 'z'),
- ('ż', 'z'),
- ('Ą', 'A'),
- ('Ć', 'C'),
- ('Ę', 'E'),
- ('ł', 'l'),
- ('Ł', 'L'),
- ('Ń', 'N'),
- ('Ó', 'O'),
- ('Ś', 'S'),
- ('Ź', 'Z'),
- ('Ż', 'Z'),
- ('¡', '!'),
- ]);
- }
-
- /// Some checks for characters which are not visible.
- #[test]
- fn invisible_chars() {
- check_conversions(&[('\u{a0}', '\u{a0}'), ('\u{ad}', '\u{ad}')]);
- }
-
- /// Check boundary cases in case ranges are modified.
- #[test]
- fn boundary_cases() {
- check_conversions(&[
- ('\u{9f}', '\u{9f}'),
- ('\u{a0}', '\u{a0}'),
- ('¡', '!'),
- ('ʟ', 'L'),
- ('\u{2a0}', '\u{2a0}'),
- ('\u{1dff}', '\u{1dff}'),
- ('Ḁ', 'A'),
- ('ỹ', 'y'),
- ('\u{1eff}', '\u{1eff}'),
- ('\u{1f00}', '\u{1f00}'),
- ('⁰', '0'),
- ('\u{209c}', 't'),
- ('\u{209f}', '\u{209f}'),
- ('\u{20a0}', '\u{20a0}'),
- ]);
- }
-
- /// Check that conversions outside the blocks are unchanged.
- #[test]
- fn unchanged_outside_blocks() {
- check_conversions(&[
- ('a', 'a'),
- ('⟁', '⟁'),
- ('┍', '┍'),
- ('ω', 'ω'),
- ('⁕', '⁕'),
- ('ה', 'ה'),
- ]);
- }
-}
diff --git a/crates/atuin-nucleo/matcher/src/config.rs b/crates/atuin-nucleo/matcher/src/config.rs
deleted file mode 100644
index eca7ae38..00000000
--- a/crates/atuin-nucleo/matcher/src/config.rs
+++ /dev/null
@@ -1,70 +0,0 @@
-use crate::chars::CharClass;
-use crate::score::BONUS_BOUNDARY;
-
-/// Configuration data that controls how a matcher behaves
-#[non_exhaustive]
-#[derive(PartialEq, Eq, Debug, Clone)]
-pub struct Config {
- /// Characters that act as delimiters and provide bonus
- /// for matching the following char
- pub(crate) delimiter_chars: &'static [u8],
- /// Extra bonus for word boundary after whitespace character or beginning of the string
- pub(crate) bonus_boundary_white: u16,
- /// Extra bonus for word boundary after slash, colon, semi-colon, and comma
- pub(crate) bonus_boundary_delimiter: u16,
- pub(crate) initial_char_class: CharClass,
-
- /// Whether to normalize latin script characters to ASCII (enabled by default)
- pub normalize: bool,
- /// whether to ignore casing
- pub ignore_case: bool,
- /// Whether to provide a bonus to matches by their distance from the start
- /// of the haystack. The bonus is fairly small compared to the normal gap
- /// penalty to avoid messing with the normal score heuristic. This setting
- /// is not turned on by default and only recommended for autocompletion
- /// usecases where the expectation is that the user is typing the entire
- /// match. For a full fzf-like fuzzy matcher/picker word segmentation and
- /// explicit prefix literals should be used instead.
- pub prefer_prefix: bool,
-}
-
-impl Config {
- /// The default config for nucleo, implemented as a constant since
- /// Default::default can not be called in a const context
- pub const DEFAULT: Self = {
- Config {
- delimiter_chars: b"/,:;|",
- bonus_boundary_white: BONUS_BOUNDARY + 2,
- bonus_boundary_delimiter: BONUS_BOUNDARY + 1,
- initial_char_class: CharClass::Whitespace,
- normalize: true,
- ignore_case: true,
- prefer_prefix: false,
- }
- };
-}
-
-impl Config {
- /// Configures the matcher with bonuses appropriate for matching file paths.
- pub fn set_match_paths(&mut self) {
- if cfg!(windows) {
- self.delimiter_chars = b"/:\\";
- } else {
- self.delimiter_chars = b"/:";
- }
- self.bonus_boundary_white = BONUS_BOUNDARY;
- self.initial_char_class = CharClass::Delimiter;
- }
-
- /// Configures the matcher with bonuses appropriate for matching file paths.
- pub const fn match_paths(mut self) -> Self {
- if cfg!(windows) {
- self.delimiter_chars = b"/\\";
- } else {
- self.delimiter_chars = b"/";
- }
- self.bonus_boundary_white = BONUS_BOUNDARY;
- self.initial_char_class = CharClass::Delimiter;
- self
- }
-}
diff --git a/crates/atuin-nucleo/matcher/src/debug.rs b/crates/atuin-nucleo/matcher/src/debug.rs
deleted file mode 100644
index b8369f32..00000000
--- a/crates/atuin-nucleo/matcher/src/debug.rs
+++ /dev/null
@@ -1,14 +0,0 @@
-use crate::matrix::{MatrixCell, ScoreCell};
-use std::fmt::{Debug, Formatter, Result};
-
-impl Debug for ScoreCell {
- fn fmt(&self, f: &mut Formatter<'_>) -> Result {
- write!(f, "({}, {})", self.score, self.matched)
- }
-}
-
-impl Debug for MatrixCell {
- fn fmt(&self, f: &mut Formatter<'_>) -> Result {
- write!(f, "({}, {})", (self.0 & 1) != 0, (self.0 & 2) != 0)
- }
-}
diff --git a/crates/atuin-nucleo/matcher/src/exact.rs b/crates/atuin-nucleo/matcher/src/exact.rs
deleted file mode 100644
index 3cb3ceb2..00000000
--- a/crates/atuin-nucleo/matcher/src/exact.rs
+++ /dev/null
@@ -1,275 +0,0 @@
-use memchr::memmem;
-use memchr::{Memchr, Memchr2};
-
-use crate::chars::{AsciiChar, Char};
-use crate::score::{BONUS_FIRST_CHAR_MULTIPLIER, SCORE_MATCH};
-use crate::Matcher;
-
-impl Matcher {
- pub(crate) fn substring_match_1_ascii<const INDICES: bool>(
- &mut self,
- haystack: &[u8],
- c: u8,
- indices: &mut Vec<u32>,
- ) -> Option<u16> {
- let mut max_score = 0;
- let mut max_pos = 0;
- if self.config.ignore_case && c >= b'a' && c <= b'z' {
- for i in Memchr2::new(c, c - 32, haystack) {
- let prev_char_class = i
- .checked_sub(1)
- .map(|i| AsciiChar(haystack[i]).char_class(&self.config))
- .unwrap_or(self.config.initial_char_class);
- let char_class = AsciiChar(haystack[i]).char_class(&self.config);
- let bonus = self.config.bonus_for(prev_char_class, char_class);
- let score = bonus * BONUS_FIRST_CHAR_MULTIPLIER + SCORE_MATCH;
- if score > max_score {
- max_pos = i as u32;
- max_score = score;
- // can't get better than this
- if bonus >= self.config.bonus_boundary_white {
- break;
- }
- }
- }
- } else {
- let char_class = AsciiChar(c).char_class(&self.config);
- for i in Memchr::new(c, haystack) {
- let prev_char_class = i
- .checked_sub(1)
- .map(|i| AsciiChar(haystack[i]).char_class(&self.config))
- .unwrap_or(self.config.initial_char_class);
- let bonus = self.config.bonus_for(prev_char_class, char_class);
- let score = bonus * BONUS_FIRST_CHAR_MULTIPLIER + SCORE_MATCH;
- if score > max_score {
- max_pos = i as u32;
- max_score = score;
- // can't get better than this
- if bonus >= self.config.bonus_boundary_white {
- break;
- }
- }
- }
- }
- if max_score == 0 {
- return None;
- }
-
- if INDICES {
- indices.push(max_pos);
- }
- Some(max_score)
- }
-
- pub(crate) fn substring_match_ascii_with_prefilter(
- &mut self,
- haystack: &[u8],
- needle: &[u8],
- prefilter_len: usize,
- prefilter: impl Iterator<Item = usize>,
- ) -> (u16, usize) {
- let needle_without_prefilter = &needle[prefilter_len..];
- let mut max_score = 0;
- let mut max_pos = 0;
- for i in prefilter {
- let prev_char_class = i
- .checked_sub(1)
- .map(|i| AsciiChar(haystack[i]).char_class(&self.config))
- .unwrap_or(self.config.initial_char_class);
- let char_class = AsciiChar(haystack[i]).char_class(&self.config);
- let bonus = self.config.bonus_for(prev_char_class, char_class);
- let score = bonus * BONUS_FIRST_CHAR_MULTIPLIER + SCORE_MATCH;
- if score > max_score
- && haystack[i + prefilter_len..(i + needle.len()).min(haystack.len())]
- .iter()
- .map(|&c| AsciiChar(c).normalize(&self.config).0)
- .eq(needle_without_prefilter.iter().copied())
- {
- max_pos = i;
- max_score = score;
- // can't get better than this
- if bonus >= self.config.bonus_boundary_white {
- break;
- }
- }
- }
- (max_score, max_pos)
- }
-
- pub(crate) fn substring_match_ascii<const INDICES: bool>(
- &mut self,
- haystack: &[u8],
- needle: &[u8],
- indices: &mut Vec<u32>,
- ) -> Option<u16> {
- let mut max_score = 0;
- let mut max_pos = 0;
- if self.config.ignore_case {
- match needle.iter().position(|&c| c >= b'a' && c <= b'z') {
- // start with char do case insensitive search
- Some(0) => {
- (max_score, max_pos) = self.substring_match_ascii_with_prefilter(
- haystack,
- needle,
- 1,
- Memchr2::new(
- needle[0],
- needle[0] - 32,
- &haystack[..haystack.len() - needle.len() + 1],
- ),
- );
- if max_score == 0 {
- return None;
- }
- }
- Some(1) => {
- (max_score, max_pos) = self.substring_match_ascii_with_prefilter(
- haystack,
- needle,
- 1,
- Memchr::new(needle[0], &haystack[..haystack.len() - needle.len() + 1]),
- );
- if max_score == 0 {
- return None;
- }
- }
- Some(len) => {
- (max_score, max_pos) = self.substring_match_ascii_with_prefilter(
- haystack,
- needle,
- 1,
- memmem::find_iter(&haystack[..haystack.len() - needle.len() + len], needle),
- );
- if max_score == 0 {
- return None;
- }
- }
- // in case we don't have any letter in the needle
- // we can treat the search as case sensitive and use memmem directly which is way faster
- None => (),
- }
- }
-
- if max_score == 0 {
- let char_class = AsciiChar(needle[0]).char_class(&self.config);
- for i in memmem::find_iter(haystack, needle) {
- let prev_char_class = i
- .checked_sub(1)
- .map(|i| AsciiChar(haystack[i]).char_class(&self.config))
- .unwrap_or(self.config.initial_char_class);
- let bonus = self.config.bonus_for(prev_char_class, char_class);
- let score = bonus * BONUS_FIRST_CHAR_MULTIPLIER + SCORE_MATCH;
- if score > max_score {
- max_pos = i;
- max_score = score;
- // can't get better than this
- if bonus >= self.config.bonus_boundary_white {
- break;
- }
- }
- }
- if max_score == 0 {
- return None;
- }
- }
- let score = self.calculate_score::<INDICES, _, _>(
- AsciiChar::cast(haystack),
- AsciiChar::cast(needle),
- max_pos,
- max_pos + needle.len(),
- indices,
- );
- Some(score)
- }
-
- pub(crate) fn substring_match_1_non_ascii<const INDICES: bool>(
- &mut self,
- haystack: &[char],
- needle: char,
- start: usize,
- indices: &mut Vec<u32>,
- ) -> u16 {
- let mut max_score = 0;
- let mut max_pos = 0;
- let mut prev_class = start
- .checked_sub(1)
- .map(|i| haystack[i].char_class(&self.config))
- .unwrap_or(self.config.initial_char_class);
- for (i, &c) in haystack[start..].iter().enumerate() {
- let (c, char_class) = c.char_class_and_normalize(&self.config);
- if c != needle {
- continue;
- }
- let bonus = self.config.bonus_for(prev_class, char_class);
- prev_class = char_class;
- let score = bonus * BONUS_FIRST_CHAR_MULTIPLIER + SCORE_MATCH;
- if score > max_score {
- max_pos = i as u32;
- max_score = score;
- // can't get better than this
- if bonus >= self.config.bonus_boundary_white {
- break;
- }
- }
- }
-
- if INDICES {
- indices.push(max_pos + start as u32);
- }
- max_score
- }
-
- pub(crate) fn substring_match_non_ascii<const INDICES: bool, N>(
- &mut self,
- haystack: &[char],
- needle: &[N],
- start: usize,
- indices: &mut Vec<u32>,
- ) -> Option<u16>
- where
- N: Char,
- char: PartialEq<N>,
- {
- let mut max_score = 0;
- let mut max_pos = 0;
- let mut prev_class = start
- .checked_sub(1)
- .map(|i| haystack[i].char_class(&self.config))
- .unwrap_or(self.config.initial_char_class);
- let end = haystack.len() - needle.len();
- for (i, &c) in haystack[start..end].iter().enumerate() {
- let (c, char_class) = c.char_class_and_normalize(&self.config);
- if c != needle[0] {
- continue;
- }
- let bonus = self.config.bonus_for(prev_class, char_class);
- prev_class = char_class;
- let score = bonus * BONUS_FIRST_CHAR_MULTIPLIER + SCORE_MATCH;
- if score > max_score
- && haystack[start + i + 1..start + i + needle.len()]
- .iter()
- .map(|c| c.normalize(&self.config))
- .eq(needle[1..].iter().copied())
- {
- max_pos = i;
- max_score = score;
- // can't get better than this
- if bonus >= self.config.bonus_boundary_white {
- break;
- }
- }
- }
- if max_score == 0 {
- return None;
- }
-
- let score = self.calculate_score::<INDICES, _, _>(
- haystack,
- needle,
- start + max_pos,
- start + max_pos + needle.len(),
- indices,
- );
- Some(score)
- }
-}
diff --git a/crates/atuin-nucleo/matcher/src/fuzzy_greedy.rs b/crates/atuin-nucleo/matcher/src/fuzzy_greedy.rs
deleted file mode 100644
index 386d289c..00000000
--- a/crates/atuin-nucleo/matcher/src/fuzzy_greedy.rs
+++ /dev/null
@@ -1,51 +0,0 @@
-use crate::chars::Char;
-use crate::Matcher;
-
-impl Matcher {
- /// greedy fallback algorithm, much faster (linear time) but reported scores/indices
- /// might not be the best match
- pub(crate) fn fuzzy_match_greedy_<const INDICES: bool, H: Char + PartialEq<N>, N: Char>(
- &mut self,
- haystack: &[H],
- needle: &[N],
- mut start: usize,
- mut end: usize,
- indices: &mut Vec<u32>,
- ) -> Option<u16> {
- let first_char_end = if H::ASCII && N::ASCII { start + 1 } else { end };
- 'nonascii: {
- if !H::ASCII || !N::ASCII {
- let mut needle_iter = needle[1..].iter().copied();
- if let Some(mut needle_char) = needle_iter.next() {
- for (i, &c) in haystack[first_char_end..].iter().enumerate() {
- if c.normalize(&self.config) == needle_char {
- let Some(next_needle_char) = needle_iter.next() else {
- // we found a match so we are now in the same state
- // as the prefilter would produce
- end = first_char_end + i + 1;
- break 'nonascii;
- };
- needle_char = next_needle_char;
- }
- }
- // some needle chars were not matched bail out
- return None;
- }
- }
- } // minimize the greedly match by greedy matching in reverse
-
- let mut needle_iter = needle.iter().rev().copied();
- let mut needle_char = needle_iter.next().unwrap();
- for (i, &c) in haystack[start..end].iter().enumerate().rev() {
- let c = c.normalize(&self.config);
- if c == needle_char {
- let Some(next_needle_char) = needle_iter.next() else {
- start += i;
- break;
- };
- needle_char = next_needle_char;
- }
- }
- Some(self.calculate_score::<INDICES, H, N>(haystack, needle, start, end, indices))
- }
-}
diff --git a/crates/atuin-nucleo/matcher/src/fuzzy_optimal.rs b/crates/atuin-nucleo/matcher/src/fuzzy_optimal.rs
deleted file mode 100644
index c3aad8c3..00000000
--- a/crates/atuin-nucleo/matcher/src/fuzzy_optimal.rs
+++ /dev/null
@@ -1,348 +0,0 @@
-use std::cmp::max;
-
-use crate::chars::{Char, CharClass};
-use crate::matrix::{MatcherDataView, MatrixCell, ScoreCell};
-use crate::score::{
- BONUS_BOUNDARY, BONUS_CONSECUTIVE, BONUS_FIRST_CHAR_MULTIPLIER, MAX_PREFIX_BONUS,
- PENALTY_GAP_EXTENSION, PENALTY_GAP_START, PREFIX_BONUS_SCALE, SCORE_MATCH,
-};
-use crate::{Config, Matcher};
-
-impl Matcher {
- pub(crate) fn fuzzy_match_optimal<const INDICES: bool, H: Char + PartialEq<N>, N: Char>(
- &mut self,
- haystack: &[H],
- needle: &[N],
- start: usize,
- greedy_end: usize,
- end: usize,
- indices: &mut Vec<u32>,
- ) -> Option<u16> {
- // construct a matrix (and copy the haystack), the matrix and haystack size are bounded
- // to avoid the slow O(mn) time complexity for large inputs. Furthermore, it allows
- // us to treat needle indices as u16
- let Some(mut matrix) = self.slab.alloc(&haystack[start..end], needle.len()) else {
- return self.fuzzy_match_greedy_::<INDICES, H, N>(
- haystack, needle, start, greedy_end, indices,
- );
- };
-
- let prev_class = start
- .checked_sub(1)
- .map(|i| haystack[i].char_class(&self.config))
- .unwrap_or(self.config.initial_char_class);
- let matched = matrix.setup::<INDICES, _>(needle, prev_class, &self.config, start as u32);
- // this only happened with unicode haystacks, for ASCII the prefilter handles all rejects
- if !matched {
- assert!(
- !N::ASCII || !H::ASCII,
- "Non-match should have been caught by prefilter. Maybe `needle` is not normalized?"
- );
- return None;
- }
-
- // populate the matrix and find the best score
- let matrix_len = matrix.populate_matrix::<INDICES, _>(needle);
- let last_row_off = matrix.row_offs[needle.len() - 1];
- let relative_last_row_off = last_row_off as usize + 1 - needle.len();
- let (match_end, match_score_cell) = matrix.current_row[relative_last_row_off..]
- .iter()
- .enumerate()
- .max_by_key(|(_, cell)| cell.score)
- .expect("there must be at least one match");
- if INDICES {
- matrix.reconstruct_optimal_path(match_end as u16, indices, matrix_len, start as u32);
- }
- Some(match_score_cell.score)
- }
-}
-
-const UNMATCHED: ScoreCell = ScoreCell {
- score: 0,
- // if matched is true then the consecutive bonus
- // is always at least BONUS_CONSECUTIVE so
- // this constant can never occur naturally
- consecutive_bonus: 0,
- matched: true,
-};
-
-fn next_m_cell(p_score: u16, bonus: u16, m_cell: ScoreCell) -> ScoreCell {
- if m_cell == UNMATCHED {
- return ScoreCell {
- score: p_score + bonus + SCORE_MATCH,
- matched: false,
- consecutive_bonus: bonus as u8,
- };
- }
-
- let mut consecutive_bonus = max(m_cell.consecutive_bonus as u16, BONUS_CONSECUTIVE);
- if bonus >= BONUS_BOUNDARY && bonus > consecutive_bonus {
- consecutive_bonus = bonus
- }
-
- let score_match = m_cell.score + max(consecutive_bonus, bonus);
- let score_skip = p_score + bonus;
- if score_match > score_skip {
- ScoreCell {
- score: score_match + SCORE_MATCH,
- matched: true,
- consecutive_bonus: consecutive_bonus as u8,
- }
- } else {
- ScoreCell {
- score: score_skip + SCORE_MATCH,
- matched: false,
- consecutive_bonus: bonus as u8,
- }
- }
-}
-
-fn p_score(prev_p_score: u16, prev_m_score: u16) -> (u16, bool) {
- let score_match = prev_m_score.saturating_sub(PENALTY_GAP_START);
- let score_skip = prev_p_score.saturating_sub(PENALTY_GAP_EXTENSION);
- if score_match > score_skip {
- (score_match, true)
- } else {
- (score_skip, false)
- }
-}
-
-impl<H: Char> MatcherDataView<'_, H> {
- fn setup<const INDICES: bool, N: Char>(
- &mut self,
- needle: &[N],
- mut prev_class: CharClass,
- config: &Config,
- start: u32,
- ) -> bool
- where
- H: PartialEq<N>,
- {
- let mut row_iter = needle.iter().copied().zip(self.row_offs.iter_mut());
- let (mut needle_char, mut row_start) = row_iter.next().unwrap();
-
- let col_iter = self
- .haystack
- .iter_mut()
- .zip(self.bonus.iter_mut())
- .enumerate();
-
- let mut matched = false;
- for (i, (c_, bonus_)) in col_iter {
- let (c, class) = c_.char_class_and_normalize(config);
- *c_ = c;
-
- let bonus = config.bonus_for(prev_class, class);
- // save bonus for later so we don't have to recompute it each time
- *bonus_ = bonus as u8;
- prev_class = class;
-
- let i = i as u16;
- if c == needle_char {
- // save the first idx of each char
- if let Some(next) = row_iter.next() {
- *row_start = i;
- (needle_char, row_start) = next;
- } else if !matched {
- *row_start = i;
- // we have at least one match
- matched = true;
- }
- }
- }
- if !matched {
- return false;
- }
- debug_assert_eq!(self.row_offs[0], 0);
- Self::score_row::<true, INDICES, _>(
- self.current_row,
- self.matrix_cells,
- self.haystack,
- self.bonus,
- 0,
- self.row_offs[1],
- 0,
- needle[0],
- needle[1],
- if config.prefer_prefix {
- if start == 0 {
- MAX_PREFIX_BONUS * PREFIX_BONUS_SCALE
- } else {
- (MAX_PREFIX_BONUS * PREFIX_BONUS_SCALE - PENALTY_GAP_START).saturating_sub(
- (start - 1).min(u16::MAX as u32) as u16 * PENALTY_GAP_EXTENSION,
- )
- }
- } else {
- 0
- },
- );
- true
- }
-
- #[expect(clippy::too_many_arguments)]
- fn score_row<const FIRST_ROW: bool, const INDICES: bool, N: Char>(
- current_row: &mut [ScoreCell],
- matrix_cells: &mut [MatrixCell],
- haystack: &[H],
- bonus: &[u8],
- row_off: u16,
- mut next_row_off: u16,
- needle_idx: u16,
- needle_char: N,
- next_needle_char: N,
- mut prefix_bonus: u16,
- ) where
- H: PartialEq<N>,
- {
- next_row_off -= 1;
- let relative_row_off = row_off - needle_idx;
- let next_relative_row_off = next_row_off - needle_idx;
- let skipped_col_iter = haystack[row_off as usize..next_row_off as usize]
- .iter()
- .zip(bonus[row_off as usize..next_row_off as usize].iter())
- .zip(current_row[relative_row_off as usize..next_relative_row_off as usize].iter_mut())
- .zip(matrix_cells.iter_mut());
- let mut prev_p_score = 0;
- let mut prev_m_score = 0;
- for (((&c, bonus), score_cell), matrix_cell) in skipped_col_iter {
- let (p_score, p_matched) = p_score(prev_p_score, prev_m_score);
- let m_cell = if FIRST_ROW {
- let cell = if c == needle_char {
- ScoreCell {
- score: *bonus as u16 * BONUS_FIRST_CHAR_MULTIPLIER
- + SCORE_MATCH
- + prefix_bonus / PREFIX_BONUS_SCALE,
- matched: false,
- consecutive_bonus: *bonus,
- }
- } else {
- UNMATCHED
- };
- prefix_bonus = prefix_bonus.saturating_sub(PENALTY_GAP_EXTENSION);
- cell
- } else {
- *score_cell
- };
- if INDICES {
- matrix_cell.set(p_matched, m_cell.matched);
- }
- prev_p_score = p_score;
- prev_m_score = m_cell.score;
- }
- let col_iter = haystack[next_row_off as usize..]
- .windows(2)
- .zip(bonus[next_row_off as usize..].windows(2))
- .zip(current_row[next_relative_row_off as usize..].iter_mut())
- .zip(matrix_cells[(next_relative_row_off - relative_row_off) as usize..].iter_mut());
- for (((c, bonus), score_cell), matrix_cell) in col_iter {
- let (p_score, p_matched) = p_score(prev_p_score, prev_m_score);
- let m_cell = if FIRST_ROW {
- let cell = if c[0] == needle_char {
- ScoreCell {
- score: bonus[0] as u16 * BONUS_FIRST_CHAR_MULTIPLIER
- + SCORE_MATCH
- + prefix_bonus / PREFIX_BONUS_SCALE,
- matched: false,
- consecutive_bonus: bonus[0],
- }
- } else {
- UNMATCHED
- };
- prefix_bonus = prefix_bonus.saturating_sub(PENALTY_GAP_EXTENSION);
- cell
- } else {
- *score_cell
- };
- *score_cell = if c[1] == next_needle_char {
- next_m_cell(p_score, bonus[1] as u16, m_cell)
- } else {
- UNMATCHED
- };
- if INDICES {
- matrix_cell.set(p_matched, m_cell.matched);
- }
- prev_p_score = p_score;
- prev_m_score = m_cell.score;
- }
- }
-
- fn populate_matrix<const INDICES: bool, N: Char>(&mut self, needle: &[N]) -> usize
- where
- H: PartialEq<N>,
- {
- let mut matrix_cells = &mut self.matrix_cells[self.current_row.len()..];
- let mut row_iter = needle[1..]
- .iter()
- .copied()
- .zip(self.row_offs[1..].iter().copied())
- .enumerate();
- let (mut needle_idx, (mut needle_char, mut row_off)) = row_iter.next().unwrap();
- for (next_needle_idx, (next_needle_char, next_row_off)) in row_iter {
- Self::score_row::<false, INDICES, _>(
- self.current_row,
- matrix_cells,
- self.haystack,
- self.bonus,
- row_off,
- next_row_off,
- needle_idx as u16 + 1,
- needle_char,
- next_needle_char,
- 0,
- );
- let len = self.current_row.len() + needle_idx + 1 - row_off as usize;
- matrix_cells = &mut matrix_cells[len..];
- (needle_idx, needle_char, row_off) = (next_needle_idx, next_needle_char, next_row_off);
- }
- matrix_cells.as_ptr() as usize - self.matrix_cells.as_ptr() as usize
- }
-
- fn reconstruct_optimal_path(
- &self,
- max_score_end: u16,
- indices: &mut Vec<u32>,
- matrix_len: usize,
- start: u32,
- ) {
- let indices_start = indices.len();
- indices.resize(indices_start + self.row_offs.len(), 0);
- let indices = &mut indices[indices_start..];
- let last_row_off = *self.row_offs.last().unwrap();
- indices[self.row_offs.len() - 1] = start + max_score_end as u32 + last_row_off as u32;
-
- let mut matrix_cells = &self.matrix_cells[..matrix_len];
- let width = self.current_row.len();
- let mut row_iter = self.row_offs[..self.row_offs.len() - 1]
- .iter()
- .copied()
- .enumerate()
- .rev()
- .map(|(i, off)| {
- let relative_off = off as usize - i;
- let row;
- (matrix_cells, row) =
- matrix_cells.split_at(matrix_cells.len() - (width - relative_off));
- (i, off, row)
- });
- let (mut row_idx, mut row_off, mut row) = row_iter.next().unwrap();
- let mut col = max_score_end;
- let relative_last_row_off = last_row_off as usize + 1 - self.row_offs.len();
- let mut matched = self.current_row[col as usize + relative_last_row_off].matched;
- col += last_row_off - row_off - 1;
- loop {
- if matched {
- indices[row_idx] = start + col as u32 + row_off as u32;
- }
- let next_matched = row[col as usize].get(matched);
- if matched {
- let Some((next_row_idx, next_row_off, next_row)) = row_iter.next() else {
- break;
- };
- col += row_off - next_row_off;
- (row_idx, row_off, row) = (next_row_idx, next_row_off, next_row)
- }
- col -= 1;
- matched = next_matched;
- }
- }
-}
diff --git a/crates/atuin-nucleo/matcher/src/lib.rs b/crates/atuin-nucleo/matcher/src/lib.rs
deleted file mode 100644
index e40c5be7..00000000
--- a/crates/atuin-nucleo/matcher/src/lib.rs
+++ /dev/null
@@ -1,782 +0,0 @@
-#![allow(clippy::needless_return, mismatched_lifetime_syntaxes)]
-
-/*!
-`atuin_nucleo_matcher` is a low level crate that contains the matcher implementation
-used by the high level `nucleo` crate.
-
-**NOTE**: If you are building an fzf-like interactive fuzzy finder that is
-meant to match a reasonably large number of items (> 100) using the high level
-`nucleo` crate is highly recommended. Using `nucleo-matcher` directly in you ui
-loop will be very slow. Implementing this logic yourself is very complex.
-
-The matcher is hightly optimized and can significantly outperform `fzf` and
-`skim` (the `fuzzy-matcher` crate). However some of these optimizations require
-a slightly less convenient API. Be sure to carefully read the documentation of
-the [`Matcher`] to avoid unexpected behaviour.
-# Examples
-
-For almost all usecases the [`pattern`] API should be used instead of calling
-the matcher methods directly. [`Pattern::parse`](pattern::Pattern::parse) will
-construct a single Atom (a single match operation) for each word. The pattern
-can contain special characters to control what kind of match is performed (see
-[`AtomKind`](crate::pattern::AtomKind)).
-
-```
-# use atuin_nucleo_matcher::{Matcher, Config};
-# use atuin_nucleo_matcher::pattern::{Pattern, Normalization, CaseMatching};
-let paths = ["foo/bar", "bar/foo", "foobar"];
-let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
-let matches = Pattern::parse("foo bar", CaseMatching::Ignore, Normalization::Smart).match_list(paths, &mut matcher);
-assert_eq!(matches, vec![("foo/bar", 168), ("bar/foo", 168), ("foobar", 140)]);
-let matches = Pattern::parse("^foo bar", CaseMatching::Ignore, Normalization::Smart).match_list(paths, &mut matcher);
-assert_eq!(matches, vec![("foo/bar", 168), ("foobar", 140)]);
-```
-
-If the pattern should be matched literally (without this special parsing)
-[`Pattern::new`](pattern::Pattern::new) can be used instead.
-
-```
-# use atuin_nucleo_matcher::{Matcher, Config};
-# use atuin_nucleo_matcher::pattern::{Pattern, CaseMatching, AtomKind, Normalization};
-let paths = ["foo/bar", "bar/foo", "foobar"];
-let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
-let matches = Pattern::new("foo bar", CaseMatching::Ignore, Normalization::Smart, AtomKind::Fuzzy).match_list(paths, &mut matcher);
-assert_eq!(matches, vec![("foo/bar", 168), ("bar/foo", 168), ("foobar", 140)]);
-let paths = ["^foo/bar", "bar/^foo", "foobar"];
-let matches = Pattern::new("^foo bar", CaseMatching::Ignore, Normalization::Smart, AtomKind::Fuzzy).match_list(paths, &mut matcher);
-assert_eq!(matches, vec![("^foo/bar", 188), ("bar/^foo", 188)]);
-```
-
-Word segmentation is performed automatically on any unescaped character for which [`is_whitespace`](char::is_whitespace) returns true.
-This is relevant, for instance, with non-english keyboard input.
-
-```
-# use atuin_nucleo_matcher::pattern::{Atom, Pattern, Normalization, CaseMatching};
-assert_eq!(
- // double-width 'Ideographic Space', i.e. `'\u{3000}'`
- Pattern::parse("ほげ ふが", CaseMatching::Smart, Normalization::Smart).atoms,
- vec![
- Atom::parse("ほげ", CaseMatching::Smart, Normalization::Smart),
- Atom::parse("ふが", CaseMatching::Smart, Normalization::Smart),
- ],
-);
-```
-
-If word segmentation is also not desired, a single `Atom` can be constructed directly.
-
-```
-# use atuin_nucleo_matcher::{Matcher, Config};
-# use atuin_nucleo_matcher::pattern::{Pattern, Atom, CaseMatching, Normalization, AtomKind};
-let paths = ["foobar", "foo bar"];
-let mut matcher = Matcher::new(Config::DEFAULT);
-let matches = Atom::new("foo bar", CaseMatching::Ignore, Normalization::Smart, AtomKind::Fuzzy, false).match_list(paths, &mut matcher);
-assert_eq!(matches, vec![("foo bar", 192)]);
-```
-
-
-# Status
-
-Nucleo is used in the helix-editor and therefore has a large user base with lots or real world testing. The core matcher implementation is considered complete and is unlikely to see major changes. The `nucleo-matcher` crate is finished and ready for widespread use, breaking changes should be very rare (a 1.0 release should not be far away).
-
-*/
-
-// sadly ranges don't optmimzie well
-#![allow(clippy::manual_range_contains)]
-#![warn(missing_docs)]
-
-pub mod chars;
-mod config;
-#[cfg(test)]
-mod debug;
-mod exact;
-mod fuzzy_greedy;
-mod fuzzy_optimal;
-mod matrix;
-pub mod pattern;
-mod prefilter;
-mod score;
-mod utf32_str;
-
-#[cfg(test)]
-mod tests;
-
-pub use crate::config::Config;
-pub use crate::utf32_str::{Utf32Str, Utf32String};
-
-use crate::chars::{AsciiChar, Char};
-use crate::matrix::MatrixSlab;
-
-/// A matcher engine that can execute (fuzzy) matches.
-///
-/// A matches contains **heap allocated** scratch memory that is reused during
-/// matching. This scratch memory allows the matcher to guarantee that it will
-/// **never allocate** during matching (with the exception of pushing to the
-/// `indices` vector if there isn't enough capacity). However this scratch
-/// memory is fairly large (around 135KB) so creating a matcher is expensive.
-///
-/// All `.._match` functions will not compute the indices of the matched
-/// characters. These should be used to prefilter to filter and rank all
-/// matches. All `.._indices` functions will also compute the indices of the
-/// matched characters but are slower compared to the `..match` variant. These
-/// should be used when rendering the best N matches. Note that the `indices`
-/// argument is **never cleared**. This allows running multiple different
-/// matches on the same haystack and merging the indices by sorting and
-/// deduplicating the vector.
-///
-/// The `needle` argument for each function must always be normalized by the
-/// caller (unicode normalization and case folding). Otherwise, the matcher
-/// may fail to produce a match. The [`pattern`] modules provides utilities
-/// to preprocess needles and **should usually be preferred over invoking the
-/// matcher directly**. Additionally it's recommend to perform separate matches
-/// for each word in the needle. Consider the folloling example:
-///
-/// If `foo bar` is used as the needle it matches both `foo test baaar` and
-/// `foo hello-world bar`. However, `foo test baaar` will receive a higher
-/// score than `foo hello-world bar`. `baaar` contains a 2 character gap which
-/// will receive a penalty and therefore the user will likely expect it to rank
-/// lower. However, if `foo bar` is matched as a single query `hello-world` and
-/// `test` are both considered gaps too. As `hello-world` is a much longer gap
-/// then `test` the extra penalty for `baaar` is canceled out. If both words
-/// are matched individually the interspersed words do not receive a penalty and
-/// `foo hello-world bar` ranks higher.
-///
-/// In general nucleo is a **substring matching tool** (except for the prefix/
-/// postfix matching modes) with no penalty assigned to matches that start
-/// later within the same pattern (which enables matching words individually
-/// as shown above). If patterns show a large variety in length and the syntax
-/// described above is not used it may be preferable to give preference to
-/// matches closer to the start of a haystack. To accommodate that usecase the
-/// [`prefer_prefix`](Config::prefer_prefix) option can be set to true.
-///
-/// Matching is limited to 2^32-1 codepoints, if the haystack is longer than
-/// that the matcher **will panic**. The caller must decide whether it wants to
-/// filter out long haystacks or truncate them.
-pub struct Matcher {
- #[expect(missing_docs)]
- pub config: Config,
- slab: MatrixSlab,
-}
-
-// this is just here for convenience not sure if we should implement this
-impl Clone for Matcher {
- fn clone(&self) -> Self {
- Matcher {
- config: self.config.clone(),
- slab: MatrixSlab::new(),
- }
- }
-}
-
-impl std::fmt::Debug for Matcher {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- f.debug_struct("Matcher")
- .field("config", &self.config)
- .finish_non_exhaustive()
- }
-}
-
-impl Default for Matcher {
- fn default() -> Self {
- Matcher {
- config: Config::DEFAULT,
- slab: MatrixSlab::new(),
- }
- }
-}
-
-impl Matcher {
- /// Creates a new matcher instance, note that this will eagerly allocate a
- /// fairly large chunk of heap memory (around 135KB currently but subject to
- /// change) so matchers should be reused if called often (like in a loop).
- pub fn new(config: Config) -> Self {
- Self {
- config,
- slab: MatrixSlab::new(),
- }
- }
-
- /// Find the fuzzy match with the highest score in the `haystack`.
- ///
- /// This functions has `O(mn)` time complexity for short inputs.
- /// To avoid slowdowns it automatically falls back to
- /// [greedy matching](crate::Matcher::fuzzy_match_greedy) for large
- /// needles and haystacks.
- ///
- /// See the [matcher documentation](crate::Matcher) for more details.
- pub fn fuzzy_match(&mut self, haystack: Utf32Str<'_>, needle: Utf32Str<'_>) -> Option<u16> {
- assert!(haystack.len() <= u32::MAX as usize);
- self.fuzzy_matcher_impl::<false>(haystack, needle, &mut Vec::new())
- }
-
- /// Find the fuzzy match with the highest score in the `haystack` and
- /// compute its indices.
- ///
- /// This functions has `O(mn)` time complexity for short inputs. To
- /// avoid slowdowns it automatically falls back to
- /// [greedy matching](crate::Matcher::fuzzy_match_greedy) for large needles
- /// and haystacks
- ///
- /// See the [matcher documentation](crate::Matcher) for more details.
- pub fn fuzzy_indices(
- &mut self,
- haystack: Utf32Str<'_>,
- needle: Utf32Str<'_>,
- indices: &mut Vec<u32>,
- ) -> Option<u16> {
- assert!(haystack.len() <= u32::MAX as usize);
- self.fuzzy_matcher_impl::<true>(haystack, needle, indices)
- }
-
- fn fuzzy_matcher_impl<const INDICES: bool>(
- &mut self,
- haystack_: Utf32Str<'_>,
- needle_: Utf32Str<'_>,
- indices: &mut Vec<u32>,
- ) -> Option<u16> {
- if needle_.len() > haystack_.len() {
- return None;
- }
- if needle_.is_empty() {
- return Some(0);
- }
- if needle_.len() == haystack_.len() {
- return self.exact_match_impl::<INDICES>(
- haystack_,
- needle_,
- 0,
- haystack_.len(),
- indices,
- );
- }
- assert!(
- haystack_.len() <= u32::MAX as usize,
- "fuzzy matching is only support for up to 2^32-1 codepoints"
- );
- match (haystack_, needle_) {
- (Utf32Str::Ascii(haystack), Utf32Str::Ascii(needle)) => {
- if let &[needle] = needle {
- return self.substring_match_1_ascii::<INDICES>(haystack, needle, indices);
- }
- let (start, greedy_end, end) = self.prefilter_ascii(haystack, needle, false)?;
- if needle_.len() == end - start {
- return Some(self.calculate_score::<INDICES, _, _>(
- AsciiChar::cast(haystack),
- AsciiChar::cast(needle),
- start,
- greedy_end,
- indices,
- ));
- }
- self.fuzzy_match_optimal::<INDICES, AsciiChar, AsciiChar>(
- AsciiChar::cast(haystack),
- AsciiChar::cast(needle),
- start,
- greedy_end,
- end,
- indices,
- )
- }
- (Utf32Str::Ascii(_), Utf32Str::Unicode(_)) => {
- // a purely ascii haystack can never be transformed to match
- // a needle that contains non-ascii chars since we don't allow gaps
- None
- }
- (Utf32Str::Unicode(haystack), Utf32Str::Ascii(needle)) => {
- if let &[needle] = needle {
- let (start, _) = self.prefilter_non_ascii(haystack, needle_, true)?;
- let res = self.substring_match_1_non_ascii::<INDICES>(
- haystack,
- needle as char,
- start,
- indices,
- );
- return Some(res);
- }
- let (start, end) = self.prefilter_non_ascii(haystack, needle_, false)?;
- if needle_.len() == end - start {
- return self
- .exact_match_impl::<INDICES>(haystack_, needle_, start, end, indices);
- }
- self.fuzzy_match_optimal::<INDICES, char, AsciiChar>(
- haystack,
- AsciiChar::cast(needle),
- start,
- start + 1,
- end,
- indices,
- )
- }
- (Utf32Str::Unicode(haystack), Utf32Str::Unicode(needle)) => {
- if let &[needle] = needle {
- let (start, _) = self.prefilter_non_ascii(haystack, needle_, true)?;
- let res = self
- .substring_match_1_non_ascii::<INDICES>(haystack, needle, start, indices);
- return Some(res);
- }
- let (start, end) = self.prefilter_non_ascii(haystack, needle_, false)?;
- if needle_.len() == end - start {
- return self
- .exact_match_impl::<INDICES>(haystack_, needle_, start, end, indices);
- }
- self.fuzzy_match_optimal::<INDICES, char, char>(
- haystack,
- needle,
- start,
- start + 1,
- end,
- indices,
- )
- }
- }
- }
-
- /// Greedly find a fuzzy match in the `haystack`.
- ///
- /// This functions has `O(n)` time complexity but may provide unintutive (non-optimal)
- /// indices and scores. Usually [fuzzy_match](crate::Matcher::fuzzy_match) should
- /// be preferred.
- ///
- /// See the [matcher documentation](crate::Matcher) for more details.
- pub fn fuzzy_match_greedy(
- &mut self,
- haystack: Utf32Str<'_>,
- needle: Utf32Str<'_>,
- ) -> Option<u16> {
- assert!(haystack.len() <= u32::MAX as usize);
- self.fuzzy_match_greedy_impl::<false>(haystack, needle, &mut Vec::new())
- }
-
- /// Greedly find a fuzzy match in the `haystack` and compute its indices.
- ///
- /// This functions has `O(n)` time complexity but may provide unintuitive (non-optimal)
- /// indices and scores. Usually [fuzzy_indices](crate::Matcher::fuzzy_indices) should
- /// be preferred.
- ///
- /// See the [matcher documentation](crate::Matcher) for more details.
- pub fn fuzzy_indices_greedy(
- &mut self,
- haystack: Utf32Str<'_>,
- needle: Utf32Str<'_>,
- indices: &mut Vec<u32>,
- ) -> Option<u16> {
- assert!(haystack.len() <= u32::MAX as usize);
- self.fuzzy_match_greedy_impl::<true>(haystack, needle, indices)
- }
-
- fn fuzzy_match_greedy_impl<const INDICES: bool>(
- &mut self,
- haystack: Utf32Str<'_>,
- needle_: Utf32Str<'_>,
- indices: &mut Vec<u32>,
- ) -> Option<u16> {
- if needle_.len() > haystack.len() {
- return None;
- }
- if needle_.is_empty() {
- return Some(0);
- }
- if needle_.len() == haystack.len() {
- return self.exact_match_impl::<INDICES>(haystack, needle_, 0, haystack.len(), indices);
- }
- assert!(
- haystack.len() <= u32::MAX as usize,
- "matching is only support for up to 2^32-1 codepoints"
- );
- match (haystack, needle_) {
- (Utf32Str::Ascii(haystack), Utf32Str::Ascii(needle)) => {
- let (start, greedy_end, _) = self.prefilter_ascii(haystack, needle, true)?;
- if needle_.len() == greedy_end - start {
- return Some(self.calculate_score::<INDICES, _, _>(
- AsciiChar::cast(haystack),
- AsciiChar::cast(needle),
- start,
- greedy_end,
- indices,
- ));
- }
- self.fuzzy_match_greedy_::<INDICES, AsciiChar, AsciiChar>(
- AsciiChar::cast(haystack),
- AsciiChar::cast(needle),
- start,
- greedy_end,
- indices,
- )
- }
- (Utf32Str::Ascii(_), Utf32Str::Unicode(_)) => {
- // a purely ascii haystack can never be transformed to match
- // a needle that contains non-ascii chars since we don't allow gaps
- None
- }
- (Utf32Str::Unicode(haystack), Utf32Str::Ascii(needle)) => {
- let (start, _) = self.prefilter_non_ascii(haystack, needle_, true)?;
- self.fuzzy_match_greedy_::<INDICES, char, AsciiChar>(
- haystack,
- AsciiChar::cast(needle),
- start,
- start + 1,
- indices,
- )
- }
- (Utf32Str::Unicode(haystack), Utf32Str::Unicode(needle)) => {
- let (start, _) = self.prefilter_non_ascii(haystack, needle_, true)?;
- self.fuzzy_match_greedy_::<INDICES, char, char>(
- haystack,
- needle,
- start,
- start + 1,
- indices,
- )
- }
- }
- }
-
- /// Finds the substring match with the highest score in the `haystack`.
- ///
- /// This functions has `O(nm)` time complexity. However many cases can
- /// be significantly accelerated using prefilters so it's usually very fast
- /// in practice.
- ///
- /// See the [matcher documentation](crate::Matcher) for more details.
- pub fn substring_match(
- &mut self,
- haystack: Utf32Str<'_>,
- needle_: Utf32Str<'_>,
- ) -> Option<u16> {
- self.substring_match_impl::<false>(haystack, needle_, &mut Vec::new())
- }
-
- /// Finds the substring match with the highest score in the `haystack` and
- /// compute its indices.
- ///
- /// This functions has `O(nm)` time complexity. However many cases can
- /// be significantly accelerated using prefilters so it's usually fast
- /// in practice.
- ///
- /// See the [matcher documentation](crate::Matcher) for more details.
- pub fn substring_indices(
- &mut self,
- haystack: Utf32Str<'_>,
- needle_: Utf32Str<'_>,
- indices: &mut Vec<u32>,
- ) -> Option<u16> {
- self.substring_match_impl::<true>(haystack, needle_, indices)
- }
-
- fn substring_match_impl<const INDICES: bool>(
- &mut self,
- haystack: Utf32Str<'_>,
- needle_: Utf32Str<'_>,
- indices: &mut Vec<u32>,
- ) -> Option<u16> {
- if needle_.len() > haystack.len() {
- return None;
- }
- if needle_.is_empty() {
- return Some(0);
- }
- if needle_.len() == haystack.len() {
- return self.exact_match_impl::<INDICES>(haystack, needle_, 0, haystack.len(), indices);
- }
- assert!(
- haystack.len() <= u32::MAX as usize,
- "matching is only support for up to 2^32-1 codepoints"
- );
- match (haystack, needle_) {
- (Utf32Str::Ascii(haystack), Utf32Str::Ascii(needle)) => {
- if let &[needle] = needle {
- return self.substring_match_1_ascii::<INDICES>(haystack, needle, indices);
- }
- self.substring_match_ascii::<INDICES>(haystack, needle, indices)
- }
- (Utf32Str::Ascii(_), Utf32Str::Unicode(_)) => {
- // a purely ascii haystack can never be transformed to match
- // a needle that contains non-ascii chars since we don't allow gaps
- None
- }
- (Utf32Str::Unicode(haystack), Utf32Str::Ascii(needle)) => {
- if let &[needle] = needle {
- let (start, _) = self.prefilter_non_ascii(haystack, needle_, true)?;
- let res = self.substring_match_1_non_ascii::<INDICES>(
- haystack,
- needle as char,
- start,
- indices,
- );
- return Some(res);
- }
- let (start, _) = self.prefilter_non_ascii(haystack, needle_, false)?;
- self.substring_match_non_ascii::<INDICES, _>(
- haystack,
- AsciiChar::cast(needle),
- start,
- indices,
- )
- }
- (Utf32Str::Unicode(haystack), Utf32Str::Unicode(needle)) => {
- if let &[needle] = needle {
- let (start, _) = self.prefilter_non_ascii(haystack, needle_, true)?;
- let res = self
- .substring_match_1_non_ascii::<INDICES>(haystack, needle, start, indices);
- return Some(res);
- }
- let (start, _) = self.prefilter_non_ascii(haystack, needle_, false)?;
- self.substring_match_non_ascii::<INDICES, _>(haystack, needle, start, indices)
- }
- }
- }
-
- /// Checks whether needle and haystack match exactly.
- ///
- /// This functions has `O(n)` time complexity.
- ///
- /// See the [matcher documentation](crate::Matcher) for more details.
- pub fn exact_match(&mut self, haystack: Utf32Str<'_>, needle: Utf32Str<'_>) -> Option<u16> {
- if needle.is_empty() {
- return Some(0);
- }
- let mut leading_space = 0;
- let mut trailing_space = 0;
- if !needle.first().is_whitespace() {
- leading_space = haystack.leading_white_space()
- }
- if !needle.last().is_whitespace() {
- trailing_space = haystack.trailing_white_space()
- }
- // avoid wraparound in size check
- if trailing_space == haystack.len() {
- return None;
- }
- self.exact_match_impl::<false>(
- haystack,
- needle,
- leading_space,
- haystack.len() - trailing_space,
- &mut Vec::new(),
- )
- }
-
- /// Checks whether needle and haystack match exactly and compute the matches indices.
- ///
- /// This functions has `O(n)` time complexity.
- ///
- /// See the [matcher documentation](crate::Matcher) for more details.
- pub fn exact_indices(
- &mut self,
- haystack: Utf32Str<'_>,
- needle: Utf32Str<'_>,
- indices: &mut Vec<u32>,
- ) -> Option<u16> {
- if needle.is_empty() {
- return Some(0);
- }
- let mut leading_space = 0;
- let mut trailing_space = 0;
- if !needle.first().is_whitespace() {
- leading_space = haystack.leading_white_space()
- }
- if !needle.last().is_whitespace() {
- trailing_space = haystack.trailing_white_space()
- }
- // avoid wraparound in size check
- if trailing_space == haystack.len() {
- return None;
- }
- self.exact_match_impl::<true>(
- haystack,
- needle,
- leading_space,
- haystack.len() - trailing_space,
- indices,
- )
- }
-
- /// Checks whether needle is a prefix of the haystack.
- ///
- /// This functions has `O(n)` time complexity.
- ///
- /// See the [matcher documentation](crate::Matcher) for more details.
- pub fn prefix_match(&mut self, haystack: Utf32Str<'_>, needle: Utf32Str<'_>) -> Option<u16> {
- if needle.is_empty() {
- return Some(0);
- }
- let mut leading_space = 0;
- if !needle.first().is_whitespace() {
- leading_space = haystack.leading_white_space()
- }
- if haystack.len() - leading_space < needle.len() {
- None
- } else {
- self.exact_match_impl::<false>(
- haystack,
- needle,
- leading_space,
- needle.len() + leading_space,
- &mut Vec::new(),
- )
- }
- }
-
- /// Checks whether needle is a prefix of the haystack and compute the matches indices.
- ///
- /// This functions has `O(n)` time complexity.
- ///
- /// See the [matcher documentation](crate::Matcher) for more details.
- pub fn prefix_indices(
- &mut self,
- haystack: Utf32Str<'_>,
- needle: Utf32Str<'_>,
- indices: &mut Vec<u32>,
- ) -> Option<u16> {
- if needle.is_empty() {
- return Some(0);
- }
- let mut leading_space = 0;
- if !needle.first().is_whitespace() {
- leading_space = haystack.leading_white_space()
- }
- if haystack.len() - leading_space < needle.len() {
- None
- } else {
- self.exact_match_impl::<true>(
- haystack,
- needle,
- leading_space,
- needle.len() + leading_space,
- indices,
- )
- }
- }
-
- /// Checks whether needle is a postfix of the haystack.
- ///
- /// This functions has `O(n)` time complexity.
- ///
- /// See the [matcher documentation](crate::Matcher) for more details.
- pub fn postfix_match(&mut self, haystack: Utf32Str<'_>, needle: Utf32Str<'_>) -> Option<u16> {
- if needle.is_empty() {
- return Some(0);
- }
- let mut trailing_spaces = 0;
- if !needle.last().is_whitespace() {
- trailing_spaces = haystack.trailing_white_space()
- }
- if haystack.len() - trailing_spaces < needle.len() {
- None
- } else {
- self.exact_match_impl::<false>(
- haystack,
- needle,
- haystack.len() - needle.len() - trailing_spaces,
- haystack.len() - trailing_spaces,
- &mut Vec::new(),
- )
- }
- }
-
- /// Checks whether needle is a postfix of the haystack and compute the matches indices.
- ///
- /// This functions has `O(n)` time complexity.
- ///
- /// See the [matcher documentation](crate::Matcher) for more details.
- pub fn postfix_indices(
- &mut self,
- haystack: Utf32Str<'_>,
- needle: Utf32Str<'_>,
- indices: &mut Vec<u32>,
- ) -> Option<u16> {
- if needle.is_empty() {
- return Some(0);
- }
- let mut trailing_spaces = 0;
- if !needle.last().is_whitespace() {
- trailing_spaces = haystack.trailing_white_space()
- }
- if haystack.len() - trailing_spaces < needle.len() {
- None
- } else {
- self.exact_match_impl::<true>(
- haystack,
- needle,
- haystack.len() - needle.len() - trailing_spaces,
- haystack.len() - trailing_spaces,
- indices,
- )
- }
- }
-
- fn exact_match_impl<const INDICES: bool>(
- &mut self,
- haystack: Utf32Str<'_>,
- needle_: Utf32Str<'_>,
- start: usize,
- end: usize,
- indices: &mut Vec<u32>,
- ) -> Option<u16> {
- if needle_.len() != end - start {
- return None;
- }
- assert!(
- haystack.len() <= u32::MAX as usize,
- "matching is only support for up to 2^32-1 codepoints"
- );
- let score = match (haystack, needle_) {
- (Utf32Str::Ascii(haystack), Utf32Str::Ascii(needle)) => {
- let matched = if self.config.ignore_case {
- AsciiChar::cast(haystack)[start..end]
- .iter()
- .map(|c| c.normalize(&self.config))
- .eq(AsciiChar::cast(needle)
- .iter()
- .map(|c| c.normalize(&self.config)))
- } else {
- &haystack[start..end] == needle
- };
- if !matched {
- return None;
- }
- self.calculate_score::<INDICES, _, _>(
- AsciiChar::cast(haystack),
- AsciiChar::cast(needle),
- start,
- end,
- indices,
- )
- }
- (Utf32Str::Ascii(_), Utf32Str::Unicode(_)) => {
- // a purely ascii haystack can never be transformed to match
- // a needle that contains non-ascii chars since we don't allow gaps
- return None;
- }
- (Utf32Str::Unicode(haystack), Utf32Str::Ascii(needle)) => {
- let matched = haystack[start..end]
- .iter()
- .map(|c| c.normalize(&self.config))
- .eq(AsciiChar::cast(needle)
- .iter()
- .map(|c| c.normalize(&self.config)));
- if !matched {
- return None;
- }
-
- self.calculate_score::<INDICES, _, _>(
- haystack,
- AsciiChar::cast(needle),
- start,
- end,
- indices,
- )
- }
- (Utf32Str::Unicode(haystack), Utf32Str::Unicode(needle)) => {
- let matched = haystack[start..end]
- .iter()
- .map(|c| c.normalize(&self.config))
- .eq(needle.iter().map(|c| c.normalize(&self.config)));
- if !matched {
- return None;
- }
- self.calculate_score::<INDICES, _, _>(haystack, needle, start, end, indices)
- }
- };
- Some(score)
- }
-}
diff --git a/crates/atuin-nucleo/matcher/src/matrix.rs b/crates/atuin-nucleo/matcher/src/matrix.rs
deleted file mode 100644
index 5c68f2fd..00000000
--- a/crates/atuin-nucleo/matcher/src/matrix.rs
+++ /dev/null
@@ -1,198 +0,0 @@
-use std::alloc::{alloc_zeroed, dealloc, handle_alloc_error, Layout};
-use std::marker::PhantomData;
-use std::mem::size_of;
-use std::panic::{RefUnwindSafe, UnwindSafe};
-use std::ptr::{slice_from_raw_parts_mut, NonNull};
-
-use crate::chars::Char;
-
-const MAX_MATRIX_SIZE: usize = 100 * 1024; // 100*1024 = 100KB
-
-// these two aren't hard maxima, instead we simply allow whatever will fit into memory
-const MAX_HAYSTACK_LEN: usize = 2048; // 64KB
-const MAX_NEEDLE_LEN: usize = 2048; // 64KB
-
-struct MatrixLayout<C: Char> {
- haystack_len: usize,
- needle_len: usize,
- layout: Layout,
- haystack_off: usize,
- bonus_off: usize,
- rows_off: usize,
- score_off: usize,
- matrix_off: usize,
- _phantom: PhantomData<C>,
-}
-impl<C: Char> MatrixLayout<C> {
- fn new(haystack_len: usize, needle_len: usize) -> MatrixLayout<C> {
- assert!(haystack_len >= needle_len);
- assert!(haystack_len <= u32::MAX as usize);
- let mut layout = Layout::from_size_align(0, 1).unwrap();
- let haystack_layout = Layout::array::<C>(haystack_len).unwrap();
- let bonus_layout = Layout::array::<u8>(haystack_len).unwrap();
- let rows_layout = Layout::array::<u16>(needle_len).unwrap();
- let score_layout = Layout::array::<ScoreCell>(haystack_len + 1 - needle_len).unwrap();
- let matrix_layout =
- Layout::array::<MatrixCell>((haystack_len + 1 - needle_len) * needle_len).unwrap();
-
- let haystack_off;
- (layout, haystack_off) = layout.extend(haystack_layout).unwrap();
- let bonus_off;
- (layout, bonus_off) = layout.extend(bonus_layout).unwrap();
- let rows_off;
- (layout, rows_off) = layout.extend(rows_layout).unwrap();
- let score_off;
- (layout, score_off) = layout.extend(score_layout).unwrap();
- let matrix_off;
- (layout, matrix_off) = layout.extend(matrix_layout).unwrap();
- MatrixLayout {
- haystack_len,
- needle_len,
- layout,
- haystack_off,
- bonus_off,
- rows_off,
- score_off,
- matrix_off,
- _phantom: PhantomData,
- }
- }
- /// # Safety
- ///
- /// `ptr` must point at an allocated with MARTIX_ALLOC_LAYOUT
- #[expect(clippy::type_complexity)]
- unsafe fn fieds_from_ptr(
- &self,
- ptr: NonNull<u8>,
- ) -> (
- *mut [C],
- *mut [u8],
- *mut [u16],
- *mut [ScoreCell],
- *mut [MatrixCell],
- ) {
- let base = ptr.as_ptr();
- let haystack = base.add(self.haystack_off) as *mut C;
- let haystack = slice_from_raw_parts_mut(haystack, self.haystack_len);
- let bonus = base.add(self.bonus_off);
- let bonus = slice_from_raw_parts_mut(bonus, self.haystack_len);
- let rows = base.add(self.rows_off) as *mut u16;
- let rows = slice_from_raw_parts_mut(rows, self.needle_len);
- let cells = base.add(self.score_off) as *mut ScoreCell;
- let cells = slice_from_raw_parts_mut(cells, self.haystack_len + 1 - self.needle_len);
- let matrix = base.add(self.matrix_off) as *mut MatrixCell;
- let matrix = slice_from_raw_parts_mut(
- matrix,
- (self.haystack_len + 1 - self.needle_len) * self.haystack_len,
- );
- (haystack, bonus, rows, cells, matrix)
- }
-}
-
-const _SIZE_CHECK: () = {
- if size_of::<ScoreCell>() != 8 {
- panic!()
- }
-};
-
-// make this act like a u64
-#[repr(align(8))]
-#[derive(Clone, Copy, PartialEq, Eq)]
-pub(crate) struct ScoreCell {
- pub score: u16,
- pub consecutive_bonus: u8,
- pub matched: bool,
-}
-
-pub(crate) struct MatcherDataView<'a, C: Char> {
- pub haystack: &'a mut [C],
- // stored as a separate array instead of struct
- // to avoid padding since char is too large and u8 too small :/
- pub bonus: &'a mut [u8],
- pub current_row: &'a mut [ScoreCell],
- pub row_offs: &'a mut [u16],
- pub matrix_cells: &'a mut [MatrixCell],
-}
-#[repr(transparent)]
-pub struct MatrixCell(pub(crate) u8);
-
-impl MatrixCell {
- pub fn set(&mut self, p_match: bool, m_match: bool) {
- self.0 = p_match as u8 | ((m_match as u8) << 1);
- }
-
- pub fn get(&self, m_matrix: bool) -> bool {
- let mask = m_matrix as u8 + 1;
- (self.0 & mask) != 0
- }
-}
-
-// we only use this to construct the layout for the slab allocation
-#[expect(unused)]
-struct MatcherData {
- haystack: [char; MAX_HAYSTACK_LEN],
- bonus: [u8; MAX_HAYSTACK_LEN],
- row_offs: [u16; MAX_NEEDLE_LEN],
- scratch_space: [ScoreCell; MAX_HAYSTACK_LEN],
- matrix: [u8; MAX_MATRIX_SIZE],
-}
-
-pub(crate) struct MatrixSlab(NonNull<u8>);
-unsafe impl Sync for MatrixSlab {}
-unsafe impl Send for MatrixSlab {}
-impl UnwindSafe for MatrixSlab {}
-impl RefUnwindSafe for MatrixSlab {}
-
-impl MatrixSlab {
- pub fn new() -> Self {
- let layout = Layout::new::<MatcherData>();
- // safety: the matrix is never zero sized (hardcoded constants)
- let ptr = unsafe { alloc_zeroed(layout) };
- let Some(ptr) = NonNull::new(ptr) else {
- handle_alloc_error(layout)
- };
- MatrixSlab(ptr.cast())
- }
-
- pub(crate) fn alloc<C: Char>(
- &mut self,
- haystack_: &[C],
- needle_len: usize,
- ) -> Option<MatcherDataView<'_, C>> {
- let cells = haystack_.len() * needle_len;
- if cells > MAX_MATRIX_SIZE
- || haystack_.len() > u16::MAX as usize
- // ensures that scores never overflow
- || needle_len > MAX_NEEDLE_LEN
- {
- return None;
- }
- let matrix_layout = MatrixLayout::<C>::new(haystack_.len(), needle_len);
- if matrix_layout.layout.size() > size_of::<MatcherData>() {
- return None;
- }
- unsafe {
- // safely: this allocation is valid for MATRIX_ALLOC_LAYOUT
- let (haystack, bonus, rows, current_row, matrix_cells) =
- matrix_layout.fieds_from_ptr(self.0);
- // copy haystack before creating references to ensure we don't create
- // references to invalid chars (which may or may not be UB)
- haystack_
- .as_ptr()
- .copy_to_nonoverlapping(haystack as *mut _, haystack_.len());
- Some(MatcherDataView {
- haystack: &mut *haystack,
- row_offs: &mut *rows,
- bonus: &mut *bonus,
- current_row: &mut *current_row,
- matrix_cells: &mut *matrix_cells,
- })
- }
- }
-}
-
-impl Drop for MatrixSlab {
- fn drop(&mut self) {
- unsafe { dealloc(self.0.as_ptr(), Layout::new::<MatcherData>()) };
- }
-}
diff --git a/crates/atuin-nucleo/matcher/src/pattern.rs b/crates/atuin-nucleo/matcher/src/pattern.rs
deleted file mode 100644
index 89affdde..00000000
--- a/crates/atuin-nucleo/matcher/src/pattern.rs
+++ /dev/null
@@ -1,568 +0,0 @@
-//! This module provides a slightly higher level API for matching strings.
-
-use std::cmp::Reverse;
-
-use crate::{chars, Matcher, Utf32Str};
-
-#[cfg(test)]
-mod tests;
-
-use crate::Utf32String;
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
-#[non_exhaustive]
-/// How to treat a case mismatch between two characters.
-pub enum CaseMatching {
- /// Characters never match their case folded version (`a != A`).
- #[cfg_attr(not(feature = "unicode-casefold"), default)]
- Respect,
-
- /// Characters always match their case folded version (`a == A`).
- #[cfg(feature = "unicode-casefold")]
- Ignore,
-
- /// Acts like [`Ignore`](CaseMatching::Ignore) if all characters in a pattern atom are
- /// lowercase and like [`Respect`](CaseMatching::Respect) otherwise.
- #[default]
- #[cfg(feature = "unicode-casefold")]
- Smart,
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
-#[non_exhaustive]
-/// How to handle unicode normalization,
-pub enum Normalization {
- /// Characters never match their normalized version (`a != ä`).
- #[cfg_attr(not(feature = "unicode-normalization"), default)]
- Never,
- /// Acts like [`Never`](Normalization::Never) if any character in a pattern atom
- /// would need to be normalized. Otherwise normalization occurs (`a == ä` but `ä != a`).
- #[default]
- #[cfg(feature = "unicode-normalization")]
- Smart,
-}
-
-#[derive(Debug, PartialEq, Eq, Clone, Copy)]
-#[non_exhaustive]
-/// The kind of matching algorithm to run for an atom.
-pub enum AtomKind {
- /// Fuzzy matching where the needle must match any haystack characters
- /// (match can contain gaps). This atom kind is used by default if no
- /// special syntax is used. There is no negated fuzzy matching (too
- /// many false positives).
- ///
- /// See also [`Matcher::fuzzy_match`](crate::Matcher::fuzzy_match).
- Fuzzy,
- /// The needle must match a contiguous sequence of haystack characters
- /// without gaps. This atom kind is parsed from the following syntax:
- /// `'foo` and `!foo` (negated).
- ///
- /// See also [`Matcher::substring_match`](crate::Matcher::substring_match).
- Substring,
- /// The needle must match all leading haystack characters without gaps or
- /// prefix. This atom kind is parsed from the following syntax: `^foo` and
- /// `!^foo` (negated).
- ///
- /// See also [`Matcher::prefix_match`](crate::Matcher::prefix_match).
- Prefix,
- /// The needle must match all trailing haystack characters without gaps or
- /// postfix. This atom kind is parsed from the following syntax: `foo$` and
- /// `!foo$` (negated).
- ///
- /// See also [`Matcher::postfix_match`](crate::Matcher::postfix_match).
- Postfix,
- /// The needle must match all haystack characters without gaps or prefix.
- /// This atom kind is parsed from the following syntax: `^foo$` and `!^foo$`
- /// (negated).
- ///
- /// See also [`Matcher::exact_match`](crate::Matcher::exact_match).
- Exact,
-}
-
-/// A single pattern component that is matched with a single [`Matcher`] function
-#[derive(Debug, PartialEq, Eq, Clone)]
-pub struct Atom {
- /// Whether this pattern atom is a negative match.
- /// A negative pattern atom will prevent haystacks matching it from
- /// being matchend. It does not contribute to scoring/indices
- pub negative: bool,
- /// The kind of match that this pattern performs
- pub kind: AtomKind,
- needle: Utf32String,
- ignore_case: bool,
- normalize: bool,
-}
-
-impl Atom {
- /// Creates a single [`Atom`] from a string by performing unicode
- /// normalization and case folding (if necessary). Optionally `\ ` can
- /// be escaped to ` `.
- pub fn new(
- needle: &str,
- case: CaseMatching,
- normalize: Normalization,
- kind: AtomKind,
- escape_whitespace: bool,
- ) -> Atom {
- Atom::new_inner(needle, case, normalize, kind, escape_whitespace, false)
- }
-
- fn new_inner(
- needle: &str,
- case: CaseMatching,
- normalization: Normalization,
- kind: AtomKind,
- escape_whitespace: bool,
- append_dollar: bool,
- ) -> Atom {
- let mut ignore_case;
- let mut normalize;
- #[cfg(feature = "unicode-normalization")]
- {
- normalize = matches!(normalization, Normalization::Smart);
- }
- #[cfg(not(feature = "unicode-normalization"))]
- {
- normalize = false;
- }
- let needle = if needle.is_ascii() {
- let mut needle = if escape_whitespace {
- if let Some((start, rem)) = needle.split_once("\\ ") {
- let mut needle = start.to_owned();
- for rem in rem.split("\\ ") {
- needle.push(' ');
- needle.push_str(rem);
- }
- needle
- } else {
- needle.to_owned()
- }
- } else {
- needle.to_owned()
- };
-
- match case {
- #[cfg(feature = "unicode-casefold")]
- CaseMatching::Ignore => {
- ignore_case = true;
- needle.make_ascii_lowercase()
- }
- #[cfg(feature = "unicode-casefold")]
- CaseMatching::Smart => {
- ignore_case = !needle.bytes().any(|b| b.is_ascii_uppercase())
- }
- CaseMatching::Respect => ignore_case = false,
- }
- if append_dollar {
- needle.push('$');
- }
- Utf32String::Ascii(needle.into_boxed_str())
- } else {
- let mut needle_ = Vec::with_capacity(needle.len());
- #[cfg(feature = "unicode-casefold")]
- {
- ignore_case = matches!(case, CaseMatching::Ignore | CaseMatching::Smart);
- }
- #[cfg(not(feature = "unicode-casefold"))]
- {
- ignore_case = false;
- }
- #[cfg(feature = "unicode-normalization")]
- {
- normalize = matches!(normalization, Normalization::Smart);
- }
- if escape_whitespace {
- let mut saw_backslash = false;
- for mut c in chars::graphemes(needle) {
- if saw_backslash {
- if c == ' ' {
- needle_.push(' ');
- saw_backslash = false;
- continue;
- } else {
- needle_.push('\\');
- }
- }
- saw_backslash = c == '\\';
- match case {
- #[cfg(feature = "unicode-casefold")]
- CaseMatching::Ignore => c = chars::to_lower_case(c),
- #[cfg(feature = "unicode-casefold")]
- CaseMatching::Smart => {
- ignore_case = ignore_case && !chars::is_upper_case(c)
- }
- CaseMatching::Respect => (),
- }
- match normalization {
- #[cfg(feature = "unicode-normalization")]
- Normalization::Smart => {
- normalize = normalize && chars::normalize(c) == c;
- }
- Normalization::Never => (),
- }
- needle_.push(c);
- }
- } else {
- let chars = chars::graphemes(needle).map(|mut c| {
- match case {
- #[cfg(feature = "unicode-casefold")]
- CaseMatching::Ignore => c = chars::to_lower_case(c),
- #[cfg(feature = "unicode-casefold")]
- CaseMatching::Smart => {
- ignore_case = ignore_case && !chars::is_upper_case(c);
- }
- CaseMatching::Respect => (),
- }
- match normalization {
- #[cfg(feature = "unicode-normalization")]
- Normalization::Smart => {
- normalize = normalize && chars::normalize(c) == c;
- }
- Normalization::Never => (),
- }
- c
- });
- needle_.extend(chars);
- };
- if append_dollar {
- needle_.push('$');
- }
- Utf32String::Unicode(needle_.into_boxed_slice())
- };
- Atom {
- kind,
- needle,
- negative: false,
- ignore_case,
- normalize,
- }
- }
-
- /// Parse a pattern atom from a string. Some special trailing and leading
- /// characters can be used to control the atom kind. See [`AtomKind`] for
- /// details.
- pub fn parse(raw: &str, case: CaseMatching, normalize: Normalization) -> Atom {
- let mut atom = raw;
- let invert = match atom.as_bytes() {
- [b'!', ..] => {
- atom = &atom[1..];
- true
- }
- [b'\\', b'!', ..] => {
- atom = &atom[1..];
- false
- }
- _ => false,
- };
-
- let mut kind = match atom.as_bytes() {
- [b'^', ..] => {
- atom = &atom[1..];
- AtomKind::Prefix
- }
- [b'\'', ..] => {
- atom = &atom[1..];
- AtomKind::Substring
- }
- [b'\\', b'^' | b'\'', ..] => {
- atom = &atom[1..];
- AtomKind::Fuzzy
- }
- _ => AtomKind::Fuzzy,
- };
-
- let mut append_dollar = false;
- match atom.as_bytes() {
- [.., b'\\', b'$'] => {
- append_dollar = true;
- atom = &atom[..atom.len() - 2]
- }
- [.., b'$'] => {
- kind = if kind == AtomKind::Fuzzy {
- AtomKind::Postfix
- } else {
- AtomKind::Exact
- };
- atom = &atom[..atom.len() - 1]
- }
- _ => (),
- }
-
- if invert && kind == AtomKind::Fuzzy {
- kind = AtomKind::Substring
- }
-
- let mut pattern = Atom::new_inner(atom, case, normalize, kind, true, append_dollar);
- pattern.negative = invert;
- pattern
- }
-
- /// Matches this pattern against `haystack` (using the allocation and configuration
- /// from `matcher`) and calculates a ranking score. See the [`Matcher`].
- /// Documentation for more details.
- ///
- /// *Note:* The `ignore_case` setting is overwritten to match the casing of
- /// each pattern atom.
- pub fn score(&self, haystack: Utf32Str<'_>, matcher: &mut Matcher) -> Option<u16> {
- matcher.config.ignore_case = self.ignore_case;
- matcher.config.normalize = self.normalize;
- let pattern_score = match self.kind {
- AtomKind::Exact => matcher.exact_match(haystack, self.needle.slice(..)),
- AtomKind::Fuzzy => matcher.fuzzy_match(haystack, self.needle.slice(..)),
- AtomKind::Substring => matcher.substring_match(haystack, self.needle.slice(..)),
- AtomKind::Prefix => matcher.prefix_match(haystack, self.needle.slice(..)),
- AtomKind::Postfix => matcher.postfix_match(haystack, self.needle.slice(..)),
- };
- if self.negative {
- if pattern_score.is_some() {
- return None;
- }
- Some(0)
- } else {
- pattern_score
- }
- }
-
- /// Matches this pattern against `haystack` (using the allocation and
- /// configuration from `matcher`), calculates a ranking score and the match
- /// indices. See the [`Matcher`]. Documentation for more
- /// details.
- ///
- /// *Note:* The `ignore_case` setting is overwritten to match the casing of
- /// each pattern atom.
- ///
- /// *Note:* The `indices` vector is not cleared by this function.
- pub fn indices(
- &self,
- haystack: Utf32Str<'_>,
- matcher: &mut Matcher,
- indices: &mut Vec<u32>,
- ) -> Option<u16> {
- matcher.config.ignore_case = self.ignore_case;
- matcher.config.normalize = self.normalize;
- if self.negative {
- let pattern_score = match self.kind {
- AtomKind::Exact => matcher.exact_match(haystack, self.needle.slice(..)),
- AtomKind::Fuzzy => matcher.fuzzy_match(haystack, self.needle.slice(..)),
- AtomKind::Substring => matcher.substring_match(haystack, self.needle.slice(..)),
- AtomKind::Prefix => matcher.prefix_match(haystack, self.needle.slice(..)),
- AtomKind::Postfix => matcher.postfix_match(haystack, self.needle.slice(..)),
- };
- pattern_score.is_none().then_some(0)
- } else {
- match self.kind {
- AtomKind::Exact => matcher.exact_indices(haystack, self.needle.slice(..), indices),
- AtomKind::Fuzzy => matcher.fuzzy_indices(haystack, self.needle.slice(..), indices),
- AtomKind::Substring => {
- matcher.substring_indices(haystack, self.needle.slice(..), indices)
- }
- AtomKind::Prefix => {
- matcher.prefix_indices(haystack, self.needle.slice(..), indices)
- }
- AtomKind::Postfix => {
- matcher.postfix_indices(haystack, self.needle.slice(..), indices)
- }
- }
- }
- }
-
- /// Returns the needle text that is passed to the matcher. All indices
- /// produced by the `indices` functions produce char indices used to index
- /// this text
- pub fn needle_text(&self) -> Utf32Str<'_> {
- self.needle.slice(..)
- }
- /// Convenience function to easily match (and sort) a (relatively small)
- /// list of inputs.
- ///
- /// *Note* This function is not recommended for building a full fuzzy
- /// matching application that can match large numbers of matches (like all
- /// files in a directory) as all matching is done on the current thread,
- /// effectively blocking the UI. For such applications the high level
- /// `nucleo` crate can be used instead.
- pub fn match_list<T: AsRef<str>>(
- &self,
- items: impl IntoIterator<Item = T>,
- matcher: &mut Matcher,
- ) -> Vec<(T, u16)> {
- if self.needle.is_empty() {
- return items.into_iter().map(|item| (item, 0)).collect();
- }
- let mut buf = Vec::new();
- let mut items: Vec<_> = items
- .into_iter()
- .filter_map(|item| {
- self.score(Utf32Str::new(item.as_ref(), &mut buf), matcher)
- .map(|score| (item, score))
- })
- .collect();
- items.sort_by_key(|(_, score)| Reverse(*score));
- items
- }
-}
-
-fn pattern_atoms(pattern: &str) -> impl Iterator<Item = &str> + '_ {
- let mut saw_backslash = false;
- pattern.split(move |c| {
- saw_backslash = match c {
- c if c.is_whitespace() && !saw_backslash => return true,
- '\\' => true,
- _ => false,
- };
- false
- })
-}
-
-#[derive(Debug, Default)]
-/// A text pattern made up of (potentially multiple) [atoms](crate::pattern::Atom).
-#[non_exhaustive]
-pub struct Pattern {
- /// The individual pattern (words) in this pattern
- pub atoms: Vec<Atom>,
-}
-
-impl Pattern {
- /// Creates a pattern where each word is matched individually (whitespaces
- /// can be escaped with `\`). Otherwise no parsing is performed (so `$`, `!`,
- /// `'` and `^` don't receive special treatment). If you want to match the entire
- /// pattern as a single needle use a single [`Atom`] instead.
- pub fn new(
- pattern: &str,
- case_matching: CaseMatching,
- normalize: Normalization,
- kind: AtomKind,
- ) -> Pattern {
- let atoms = pattern_atoms(pattern)
- .filter_map(|pat| {
- let pat = Atom::new(pat, case_matching, normalize, kind, true);
- (!pat.needle.is_empty()).then_some(pat)
- })
- .collect();
- Pattern { atoms }
- }
- /// Creates a pattern where each word is matched individually (whitespaces
- /// can be escaped with `\`). And `$`, `!`, `'` and `^` at word boundaries will
- /// cause different matching behaviour (see [`AtomKind`]). These can be
- /// escaped with backslash.
- pub fn parse(pattern: &str, case_matching: CaseMatching, normalize: Normalization) -> Pattern {
- let atoms = pattern_atoms(pattern)
- .filter_map(|pat| {
- let pat = Atom::parse(pat, case_matching, normalize);
- (!pat.needle.is_empty()).then_some(pat)
- })
- .collect();
- Pattern { atoms }
- }
-
- /// Convenience function to easily match (and sort) a (relatively small)
- /// list of inputs.
- ///
- /// *Note* This function is not recommended for building a full fuzzy
- /// matching application that can match large numbers of matches (like all
- /// files in a directory) as all matching is done on the current thread,
- /// effectively blocking the UI. For such applications the high level
- /// `nucleo` crate can be used instead.
- pub fn match_list<T: AsRef<str>>(
- &self,
- items: impl IntoIterator<Item = T>,
- matcher: &mut Matcher,
- ) -> Vec<(T, u32)> {
- if self.atoms.is_empty() {
- return items.into_iter().map(|item| (item, 0)).collect();
- }
- let mut buf = Vec::new();
- let mut items: Vec<_> = items
- .into_iter()
- .filter_map(|item| {
- self.score(Utf32Str::new(item.as_ref(), &mut buf), matcher)
- .map(|score| (item, score))
- })
- .collect();
- items.sort_by_key(|(_, score)| Reverse(*score));
- items
- }
-
- /// Matches this pattern against `haystack` (using the allocation and configuration
- /// from `matcher`) and calculates a ranking score. See the [`Matcher`]
- /// documentation for more details.
- ///
- /// *Note:* The `ignore_case` setting is overwritten to match the casing of
- /// each pattern atom.
- pub fn score(&self, haystack: Utf32Str<'_>, matcher: &mut Matcher) -> Option<u32> {
- if self.atoms.is_empty() {
- return Some(0);
- }
- let mut score = 0;
- for pattern in &self.atoms {
- score += pattern.score(haystack, matcher)? as u32;
- }
- Some(score)
- }
-
- /// Matches this pattern against `haystack` (using the allocation and
- /// configuration from `matcher`), calculates a ranking score and the match
- /// indices. See the [`Matcher`] documentation for more
- /// details.
- ///
- /// *Note:* The `ignore_case` setting is overwritten to match the casing of
- /// each pattern atom.
- ///
- /// *Note:* The indices for each pattern are calculated individually
- /// and simply appended to the `indices` vector and not deduplicated/sorted.
- /// This allows associating the match indices to their source pattern. If
- /// required (like for highlighting) unique/sorted indices can be obtained
- /// as follows:
- ///
- /// ```
- /// # let mut indices: Vec<u32> = Vec::new();
- /// indices.sort_unstable();
- /// indices.dedup();
- /// ```
- pub fn indices(
- &self,
- haystack: Utf32Str<'_>,
- matcher: &mut Matcher,
- indices: &mut Vec<u32>,
- ) -> Option<u32> {
- if self.atoms.is_empty() {
- return Some(0);
- }
- let mut score = 0;
- for pattern in &self.atoms {
- score += pattern.indices(haystack, matcher, indices)? as u32;
- }
- Some(score)
- }
-
- /// Refreshes this pattern by reparsing it from a string. This is mostly
- /// equivalent to just constructing a new pattern using [`Pattern::parse`]
- /// but is slightly more efficient by reusing some allocations
- pub fn reparse(
- &mut self,
- pattern: &str,
- case_matching: CaseMatching,
- normalize: Normalization,
- ) {
- self.atoms.clear();
- let atoms = pattern_atoms(pattern).filter_map(|atom| {
- let atom = Atom::parse(atom, case_matching, normalize);
- if atom.needle.is_empty() {
- return None;
- }
- Some(atom)
- });
- self.atoms.extend(atoms);
- }
-}
-
-impl Clone for Pattern {
- fn clone(&self) -> Self {
- Self {
- atoms: self.atoms.clone(),
- }
- }
-
- fn clone_from(&mut self, source: &Self) {
- self.atoms.clone_from(&source.atoms);
- }
-}
diff --git a/crates/atuin-nucleo/matcher/src/pattern/tests.rs b/crates/atuin-nucleo/matcher/src/pattern/tests.rs
deleted file mode 100644
index 88880ba9..00000000
--- a/crates/atuin-nucleo/matcher/src/pattern/tests.rs
+++ /dev/null
@@ -1,149 +0,0 @@
-use crate::pattern::{Atom, AtomKind, CaseMatching, Normalization, Pattern};
-
-#[test]
-fn negative() {
- let pat = Atom::parse("!foo", CaseMatching::Smart, Normalization::Smart);
- assert!(pat.negative);
- assert_eq!(pat.kind, AtomKind::Substring);
- assert_eq!(pat.needle.to_string(), "foo");
- let pat = Atom::parse("!^foo", CaseMatching::Smart, Normalization::Smart);
- assert!(pat.negative);
- assert_eq!(pat.kind, AtomKind::Prefix);
- assert_eq!(pat.needle.to_string(), "foo");
- let pat = Atom::parse("!foo$", CaseMatching::Smart, Normalization::Smart);
- assert!(pat.negative);
- assert_eq!(pat.kind, AtomKind::Postfix);
- assert_eq!(pat.needle.to_string(), "foo");
- let pat = Atom::parse("!^foo$", CaseMatching::Smart, Normalization::Smart);
- assert!(pat.negative);
- assert_eq!(pat.kind, AtomKind::Exact);
- assert_eq!(pat.needle.to_string(), "foo");
-}
-
-#[test]
-fn pattern_kinds() {
- let pat = Atom::parse("foo", CaseMatching::Smart, Normalization::Smart);
- assert!(!pat.negative);
- assert_eq!(pat.kind, AtomKind::Fuzzy);
- assert_eq!(pat.needle.to_string(), "foo");
- let pat = Atom::parse("'foo", CaseMatching::Smart, Normalization::Smart);
- assert!(!pat.negative);
- assert_eq!(pat.kind, AtomKind::Substring);
- assert_eq!(pat.needle.to_string(), "foo");
- let pat = Atom::parse("^foo", CaseMatching::Smart, Normalization::Smart);
- assert!(!pat.negative);
- assert_eq!(pat.kind, AtomKind::Prefix);
- assert_eq!(pat.needle.to_string(), "foo");
- let pat = Atom::parse("foo$", CaseMatching::Smart, Normalization::Smart);
- assert!(!pat.negative);
- assert_eq!(pat.kind, AtomKind::Postfix);
- assert_eq!(pat.needle.to_string(), "foo");
- let pat = Atom::parse("^foo$", CaseMatching::Smart, Normalization::Smart);
- assert!(!pat.negative);
- assert_eq!(pat.kind, AtomKind::Exact);
- assert_eq!(pat.needle.to_string(), "foo");
-}
-
-#[test]
-fn case_matching() {
- let pat = Atom::parse("foo", CaseMatching::Smart, Normalization::Smart);
- assert!(pat.ignore_case);
- assert_eq!(pat.needle.to_string(), "foo");
- let pat = Atom::parse("Foo", CaseMatching::Smart, Normalization::Smart);
- assert!(!pat.ignore_case);
- assert_eq!(pat.needle.to_string(), "Foo");
- let pat = Atom::parse("Foo", CaseMatching::Ignore, Normalization::Smart);
- assert!(pat.ignore_case);
- assert_eq!(pat.needle.to_string(), "foo");
- let pat = Atom::parse("Foo", CaseMatching::Respect, Normalization::Smart);
- assert!(!pat.ignore_case);
- assert_eq!(pat.needle.to_string(), "Foo");
- let pat = Atom::parse("Foo", CaseMatching::Respect, Normalization::Smart);
- assert!(!pat.ignore_case);
- assert_eq!(pat.needle.to_string(), "Foo");
- let pat = Atom::parse("Äxx", CaseMatching::Ignore, Normalization::Smart);
- assert!(pat.ignore_case);
- assert_eq!(pat.needle.to_string(), "äxx");
- let pat = Atom::parse("Äxx", CaseMatching::Respect, Normalization::Smart);
- assert!(!pat.ignore_case);
- let pat = Atom::parse("Axx", CaseMatching::Smart, Normalization::Smart);
- assert!(!pat.ignore_case);
- assert_eq!(pat.needle.to_string(), "Axx");
- let pat = Atom::parse("你xx", CaseMatching::Smart, Normalization::Smart);
- assert!(pat.ignore_case);
- assert_eq!(pat.needle.to_string(), "你xx");
- let pat = Atom::parse("你xx", CaseMatching::Ignore, Normalization::Smart);
- assert!(pat.ignore_case);
- assert_eq!(pat.needle.to_string(), "你xx");
- let pat = Atom::parse("Ⲽxx", CaseMatching::Smart, Normalization::Smart);
- assert!(!pat.ignore_case);
- assert_eq!(pat.needle.to_string(), "Ⲽxx");
- let pat = Atom::parse("Ⲽxx", CaseMatching::Ignore, Normalization::Smart);
- assert!(pat.ignore_case);
- assert_eq!(pat.needle.to_string(), "ⲽxx");
-}
-
-#[test]
-fn escape() {
- let pat = Atom::parse("foo\\ bar", CaseMatching::Smart, Normalization::Smart);
- assert_eq!(pat.needle.to_string(), "foo bar");
- let pat = Atom::parse("\\!foo", CaseMatching::Smart, Normalization::Smart);
- assert_eq!(pat.needle.to_string(), "!foo");
- assert_eq!(pat.kind, AtomKind::Fuzzy);
- let pat = Atom::parse("\\'foo", CaseMatching::Smart, Normalization::Smart);
- assert_eq!(pat.needle.to_string(), "'foo");
- assert_eq!(pat.kind, AtomKind::Fuzzy);
- let pat = Atom::parse("\\^foo", CaseMatching::Smart, Normalization::Smart);
- assert_eq!(pat.needle.to_string(), "^foo");
- assert_eq!(pat.kind, AtomKind::Fuzzy);
- let pat = Atom::parse("foo\\$", CaseMatching::Smart, Normalization::Smart);
- assert_eq!(pat.needle.to_string(), "foo$");
- assert_eq!(pat.kind, AtomKind::Fuzzy);
- let pat = Atom::parse("^foo\\$", CaseMatching::Smart, Normalization::Smart);
- assert_eq!(pat.needle.to_string(), "foo$");
- assert_eq!(pat.kind, AtomKind::Prefix);
- let pat = Atom::parse("\\^foo\\$", CaseMatching::Smart, Normalization::Smart);
- assert_eq!(pat.needle.to_string(), "^foo$");
- assert_eq!(pat.kind, AtomKind::Fuzzy);
- let pat = Atom::parse("\\!^foo\\$", CaseMatching::Smart, Normalization::Smart);
- assert_eq!(pat.needle.to_string(), "!^foo$");
- assert_eq!(pat.kind, AtomKind::Fuzzy);
- let pat = Atom::parse("!\\^foo\\$", CaseMatching::Smart, Normalization::Smart);
- assert_eq!(pat.needle.to_string(), "^foo$");
- assert_eq!(pat.kind, AtomKind::Substring);
-}
-
-#[test]
-fn pattern_atoms() {
- assert_eq!(
- Pattern::parse("a b", CaseMatching::Ignore, Normalization::Smart).atoms,
- vec![
- Atom::parse("a", CaseMatching::Ignore, Normalization::Smart),
- Atom::parse("b", CaseMatching::Ignore, Normalization::Smart),
- ]
- );
-
- assert_eq!(
- Pattern::parse("a\n b", CaseMatching::Ignore, Normalization::Smart).atoms,
- vec![
- Atom::parse("a", CaseMatching::Ignore, Normalization::Smart),
- Atom::parse("b", CaseMatching::Ignore, Normalization::Smart),
- ]
- );
-
- assert_eq!(
- Pattern::parse(" a b\r\n", CaseMatching::Ignore, Normalization::Smart).atoms,
- vec![
- Atom::parse("a", CaseMatching::Ignore, Normalization::Smart),
- Atom::parse("b", CaseMatching::Ignore, Normalization::Smart),
- ]
- );
-
- assert_eq!(
- Pattern::parse("ほ げ", CaseMatching::Smart, Normalization::Smart).atoms,
- vec![
- Atom::parse("ほ", CaseMatching::Smart, Normalization::Smart),
- Atom::parse("げ", CaseMatching::Smart, Normalization::Smart),
- ],
- )
-}
diff --git a/crates/atuin-nucleo/matcher/src/prefilter.rs b/crates/atuin-nucleo/matcher/src/prefilter.rs
deleted file mode 100644
index 1e79cc2c..00000000
--- a/crates/atuin-nucleo/matcher/src/prefilter.rs
+++ /dev/null
@@ -1,98 +0,0 @@
-use ::memchr::{memchr, memchr2, memrchr, memrchr2};
-
-use crate::chars::Char;
-use crate::utf32_str::Utf32Str;
-use crate::Matcher;
-
-#[inline(always)]
-fn find_ascii_ignore_case(c: u8, haystack: &[u8]) -> Option<usize> {
- if c >= b'a' && c <= b'z' {
- memchr2(c, c - 32, haystack)
- } else {
- memchr(c, haystack)
- }
-}
-
-#[inline(always)]
-fn find_ascii_ignore_case_rev(c: u8, haystack: &[u8]) -> Option<usize> {
- if c >= b'a' && c <= b'z' {
- memrchr2(c, c - 32, haystack)
- } else {
- memrchr(c, haystack)
- }
-}
-
-impl Matcher {
- pub(crate) fn prefilter_ascii(
- &self,
- mut haystack: &[u8],
- needle: &[u8],
- only_greedy: bool,
- ) -> Option<(usize, usize, usize)> {
- if self.config.ignore_case {
- let start =
- find_ascii_ignore_case(needle[0], &haystack[..haystack.len() - needle.len() + 1])?;
- let mut greedy_end = start + 1;
- haystack = &haystack[greedy_end..];
- for &c in &needle[1..] {
- let idx = find_ascii_ignore_case(c, haystack)? + 1;
- greedy_end += idx;
- haystack = &haystack[idx..];
- }
- if only_greedy {
- Some((start, greedy_end, greedy_end))
- } else {
- let end = greedy_end
- + find_ascii_ignore_case_rev(*needle.last().unwrap(), haystack)
- .map_or(0, |i| i + 1);
- Some((start, greedy_end, end))
- }
- } else {
- let start = memchr(needle[0], &haystack[..haystack.len() - needle.len() + 1])?;
- let mut greedy_end = start + 1;
- haystack = &haystack[greedy_end..];
- for &c in &needle[1..] {
- let idx = memchr(c, haystack)? + 1;
- greedy_end += idx;
- haystack = &haystack[idx..];
- }
- if only_greedy {
- Some((start, greedy_end, greedy_end))
- } else {
- let end =
- greedy_end + memrchr(*needle.last().unwrap(), haystack).map_or(0, |i| i + 1);
- Some((start, greedy_end, end))
- }
- }
- }
-
- pub(crate) fn prefilter_non_ascii(
- &self,
- haystack: &[char],
- needle: Utf32Str<'_>,
- only_greedy: bool,
- ) -> Option<(usize, usize)> {
- let needle_char = needle.get(0);
- let start = haystack[..haystack.len() - needle.len() + 1]
- .iter()
- .position(|c| c.normalize(&self.config) == needle_char)?;
- let needle_char = needle.last();
- if only_greedy {
- if haystack.len() - start < needle.len() {
- return None;
- }
- Some((start, start + 1))
- } else {
- let end = haystack.len()
- - haystack[start + 1..]
- .iter()
- .rev()
- .position(|c| c.normalize(&self.config) == needle_char)?;
- if end - start < needle.len() {
- return None;
- }
-
- Some((start, end))
- }
- }
-}
diff --git a/crates/atuin-nucleo/matcher/src/score.rs b/crates/atuin-nucleo/matcher/src/score.rs
deleted file mode 100644
index c934a8ef..00000000
--- a/crates/atuin-nucleo/matcher/src/score.rs
+++ /dev/null
@@ -1,158 +0,0 @@
-use std::cmp::max;
-
-use crate::chars::{Char, CharClass};
-use crate::{Config, Matcher};
-
-pub(crate) const SCORE_MATCH: u16 = 16;
-pub(crate) const PENALTY_GAP_START: u16 = 3;
-pub(crate) const PENALTY_GAP_EXTENSION: u16 = 1;
-/// If the prefer_prefix option is enabled we want to penalize
-/// the initial gap. The prefix should not be too much
-pub(crate) const PREFIX_BONUS_SCALE: u16 = 2;
-pub(crate) const MAX_PREFIX_BONUS: u16 = BONUS_BOUNDARY;
-
-// We prefer matches at the beginning of a word, but the bonus should not be
-// too great to prevent the longer acronym matches from always winning over
-// shorter fuzzy matches. The bonus point here was specifically chosen that
-// the bonus is cancelled when the gap between the acronyms grows over
-// 8 characters, which is approximately the average length of the words found
-// in web2 dictionary and my file system.
-pub(crate) const BONUS_BOUNDARY: u16 = SCORE_MATCH / 2;
-
-// Edge-triggered bonus for matches in camelCase words.
-// Their value should be BONUS_BOUNDARY - PENALTY_GAP_EXTENSION = 7.
-// However, this priporitzes camel case over non-camel case.
-// In fzf/skim this is not a problem since they score off the max
-// consecutive bonus. However, we don't do that (because its incorrect)
-// so to avoids prioritizing camel we use a lower bonus. I think that's fine
-// usually camel case is wekaer boundary than actual wourd boundaries anyway
-// This also has the nice sideeffect of perfectly balancing out
-// camel case, snake case and the consecutive version of the word
-pub(crate) const BONUS_CAMEL123: u16 = BONUS_BOUNDARY - PENALTY_GAP_START;
-
-/// Although bonus point for non-word characters is non-contextual, we need it
-/// for computing bonus points for consecutive chunks starting with a non-word
-/// character.
-pub(crate) const BONUS_NON_WORD: u16 = BONUS_BOUNDARY;
-
-// Minimum bonus point given to characters in consecutive chunks.
-// Note that bonus points for consecutive matches shouldn't have needed if we
-// used fixed match score as in the original algorithm.
-pub(crate) const BONUS_CONSECUTIVE: u16 = PENALTY_GAP_START + PENALTY_GAP_EXTENSION;
-
-// The first character in the typed pattern usually has more significance
-// than the rest so it's important that it appears at special positions where
-// bonus points are given, e.g. "to-go" vs. "ongoing" on "og" or on "ogo".
-// The amount of the extra bonus should be limited so that the gap penalty is
-// still respected.
-pub(crate) const BONUS_FIRST_CHAR_MULTIPLIER: u16 = 2;
-
-impl Config {
- #[inline]
- pub(crate) fn bonus_for(&self, prev_class: CharClass, class: CharClass) -> u16 {
- if class > CharClass::Delimiter {
- // transition from non word to word
- match prev_class {
- CharClass::Whitespace => return self.bonus_boundary_white,
- CharClass::Delimiter => return self.bonus_boundary_delimiter,
- CharClass::NonWord => return BONUS_BOUNDARY,
- _ => (),
- }
- }
- if prev_class == CharClass::Lower && class == CharClass::Upper
- || prev_class != CharClass::Number && class == CharClass::Number
- {
- // camelCase letter123
- BONUS_CAMEL123
- } else if class == CharClass::Whitespace {
- self.bonus_boundary_white
- } else if class == CharClass::NonWord {
- return BONUS_NON_WORD;
- } else {
- 0
- }
- }
-}
-impl Matcher {
- #[inline(always)]
- pub(crate) fn bonus_for(&self, prev_class: CharClass, class: CharClass) -> u16 {
- self.config.bonus_for(prev_class, class)
- }
-
- pub(crate) fn calculate_score<const INDICES: bool, H: Char + PartialEq<N>, N: Char>(
- &mut self,
- haystack: &[H],
- needle: &[N],
- start: usize,
- end: usize,
- indices: &mut Vec<u32>,
- ) -> u16 {
- if INDICES {
- indices.reserve(needle.len());
- }
-
- let mut prev_class = start
- .checked_sub(1)
- .map(|i| haystack[i].char_class(&self.config))
- .unwrap_or(self.config.initial_char_class);
- let mut needle_iter = needle.iter();
- let mut needle_char = *needle_iter.next().unwrap();
-
- let mut in_gap = false;
- let mut consecutive = 1;
-
- // unrolled the first iteration to make applying the first char multiplier less awkward
- if INDICES {
- indices.push(start as u32)
- }
- let class = haystack[start].char_class(&self.config);
- let mut first_bonus = self.bonus_for(prev_class, class);
- let mut score = SCORE_MATCH + first_bonus * BONUS_FIRST_CHAR_MULTIPLIER;
- prev_class = class;
- needle_char = *needle_iter.next().unwrap_or(&needle_char);
-
- for (i, c) in haystack[start + 1..end].iter().enumerate() {
- let (c, class) = c.char_class_and_normalize(&self.config);
- if c == needle_char {
- if INDICES {
- indices.push(i as u32 + start as u32 + 1)
- }
- let mut bonus = self.bonus_for(prev_class, class);
- if consecutive != 0 {
- if bonus >= BONUS_BOUNDARY && bonus > first_bonus {
- first_bonus = bonus
- }
- bonus = max(max(bonus, first_bonus), BONUS_CONSECUTIVE);
- } else {
- first_bonus = bonus;
- }
- score += SCORE_MATCH + bonus;
- in_gap = false;
- consecutive += 1;
- if let Some(&next) = needle_iter.next() {
- needle_char = next;
- }
- } else {
- let penalty = if in_gap {
- PENALTY_GAP_EXTENSION
- } else {
- PENALTY_GAP_START
- };
- score = score.saturating_sub(penalty);
- in_gap = true;
- consecutive = 0;
- }
- prev_class = class;
- }
- if self.config.prefer_prefix {
- if start != 0 {
- let penalty = PENALTY_GAP_START
- + PENALTY_GAP_START * (start - 1).min(u16::MAX as usize) as u16;
- score += MAX_PREFIX_BONUS.saturating_sub(penalty / PREFIX_BONUS_SCALE);
- } else {
- score += MAX_PREFIX_BONUS;
- }
- }
- score
- }
-}
diff --git a/crates/atuin-nucleo/matcher/src/tests.rs b/crates/atuin-nucleo/matcher/src/tests.rs
deleted file mode 100644
index 6cd48bc4..00000000
--- a/crates/atuin-nucleo/matcher/src/tests.rs
+++ /dev/null
@@ -1,771 +0,0 @@
-use crate::chars::Char;
-use crate::pattern::{CaseMatching, Normalization, Pattern};
-use crate::score::{
- BONUS_BOUNDARY, BONUS_CAMEL123, BONUS_CONSECUTIVE, BONUS_FIRST_CHAR_MULTIPLIER, BONUS_NON_WORD,
- MAX_PREFIX_BONUS, PENALTY_GAP_EXTENSION, PENALTY_GAP_START, SCORE_MATCH,
-};
-use crate::utf32_str::Utf32Str;
-use crate::{Config, Matcher};
-
-use Algorithm::{Exact, FuzzyGreedy, FuzzyOptimal, Postfix, Prefix, Substring};
-
-#[derive(Debug)]
-enum Algorithm {
- FuzzyOptimal,
- FuzzyGreedy,
- Substring,
- Prefix,
- Postfix,
- Exact,
-}
-
-fn assert_matches(
- algorithm: &[Algorithm],
- normalize: bool,
- case_sensitive: bool,
- path: bool,
- prefer_prefix: bool,
- cases: &[(&str, &str, &[u32], u16)],
-) {
- let mut config = Config {
- normalize,
- ignore_case: !case_sensitive,
- prefer_prefix,
- ..Config::DEFAULT
- };
- if path {
- config.set_match_paths();
- }
- let mut matcher = Matcher::new(config);
- let mut matched_indices = Vec::new();
- let mut needle_buf = Vec::new();
- let mut haystack_buf = Vec::new();
- for &(haystack, needle, indices, mut score) in cases {
- let needle = if !case_sensitive {
- needle.to_lowercase()
- } else {
- needle.to_owned()
- };
- let needle = Utf32Str::new(&needle, &mut needle_buf);
- let haystack = Utf32Str::new(haystack, &mut haystack_buf);
- score += needle.len() as u16 * SCORE_MATCH;
- for algo in algorithm {
- println!("xx {matched_indices:?} {algo:?}");
- matched_indices.clear();
- let res = match algo {
- FuzzyOptimal => matcher.fuzzy_indices(haystack, needle, &mut matched_indices),
- FuzzyGreedy => matcher.fuzzy_indices_greedy(haystack, needle, &mut matched_indices),
- Substring => matcher.substring_indices(haystack, needle, &mut matched_indices),
- Prefix => matcher.prefix_indices(haystack, needle, &mut matched_indices),
- Postfix => matcher.postfix_indices(haystack, needle, &mut matched_indices),
- Exact => matcher.exact_indices(haystack, needle, &mut matched_indices),
- };
- println!("{matched_indices:?}");
- let match_chars: Vec<_> = matched_indices
- .iter()
- .map(|&i| haystack.get(i).normalize(&matcher.config))
- .collect();
- let needle_chars: Vec<_> = needle.chars().collect();
-
- assert_eq!(
- res,
- Some(score),
- "{needle:?} did not match {haystack:?}: matched {match_chars:?} {matched_indices:?} {algo:?}"
- );
- assert_eq!(
- matched_indices, indices,
- "{needle:?} match {haystack:?} {algo:?}"
- );
- assert_eq!(
- match_chars, needle_chars,
- "{needle:?} match {haystack:?} indices are incorrect {matched_indices:?} {algo:?}"
- );
- }
- }
-}
-
-fn assert_not_matches_with(
- normalize: bool,
- case_sensitive: bool,
- algorithm: &[Algorithm],
- cases: &[(&str, &str)],
-) {
- let config = Config {
- normalize,
- ignore_case: !case_sensitive,
- ..Config::DEFAULT
- };
- let mut matcher = Matcher::new(config);
- let mut needle_buf = Vec::new();
- let mut haystack_buf = Vec::new();
- for &(haystack, needle) in cases {
- let needle = if !case_sensitive {
- needle.to_lowercase()
- } else {
- needle.to_owned()
- };
- let needle = Utf32Str::new(&needle, &mut needle_buf);
- let haystack = Utf32Str::new(haystack, &mut haystack_buf);
-
- for algo in algorithm {
- let res = match algo {
- FuzzyOptimal => matcher.fuzzy_match(haystack, needle),
- FuzzyGreedy => matcher.fuzzy_match_greedy(haystack, needle),
- Substring => matcher.substring_match(haystack, needle),
- Prefix => matcher.prefix_match(haystack, needle),
- Postfix => matcher.postfix_match(haystack, needle),
- Exact => matcher.exact_match(haystack, needle),
- };
- assert_eq!(
- res, None,
- "{needle:?} should not match {haystack:?} {algo:?}"
- );
- }
- }
-}
-
-pub fn assert_not_matches(normalize: bool, case_sensitive: bool, cases: &[(&str, &str)]) {
- assert_not_matches_with(
- normalize,
- case_sensitive,
- &[FuzzyOptimal, FuzzyGreedy, Substring, Prefix, Postfix, Exact],
- cases,
- )
-}
-
-const BONUS_BOUNDARY_WHITE: u16 = Config::DEFAULT.bonus_boundary_white;
-const BONUS_BOUNDARY_DELIMITER: u16 = Config::DEFAULT.bonus_boundary_delimiter;
-
-#[test]
-fn test_fuzzy() {
- assert_matches(
- &[FuzzyGreedy, FuzzyOptimal],
- false,
- false,
- false,
- false,
- &[
- (
- "fooBarbaz1",
- "obr",
- &[2, 3, 5],
- BONUS_CAMEL123 - PENALTY_GAP_START,
- ),
- (
- "/usr/share/doc/at/ChangeLog",
- "changelog",
- &[18, 19, 20, 21, 22, 23, 24, 25, 26],
- (BONUS_FIRST_CHAR_MULTIPLIER + 8) * BONUS_BOUNDARY_DELIMITER,
- ),
- (
- "fooBarbaz1",
- "br",
- &[3, 5],
- BONUS_CAMEL123 * BONUS_FIRST_CHAR_MULTIPLIER - PENALTY_GAP_START,
- ),
- (
- "foo bar baz",
- "fbb",
- &[0, 4, 8],
- BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_WHITE * 2
- - 2 * PENALTY_GAP_START
- - 4 * PENALTY_GAP_EXTENSION,
- ),
- (
- "/AutomatorDocument.icns",
- "rdoc",
- &[9, 10, 11, 12],
- BONUS_CAMEL123 + 2 * BONUS_CONSECUTIVE,
- ),
- (
- "/man1/zshcompctl.1",
- "zshc",
- &[6, 7, 8, 9],
- BONUS_BOUNDARY_DELIMITER * (BONUS_FIRST_CHAR_MULTIPLIER + 3),
- ),
- (
- "/.oh-my-zsh/cache",
- "zshc",
- &[8, 9, 10, 12],
- BONUS_BOUNDARY * (BONUS_FIRST_CHAR_MULTIPLIER + 2) - PENALTY_GAP_START
- + BONUS_BOUNDARY_DELIMITER,
- ),
- (
- "ab0123 456",
- "12356",
- &[3, 4, 5, 8, 9],
- BONUS_CONSECUTIVE * 3 - PENALTY_GAP_START - PENALTY_GAP_EXTENSION,
- ),
- (
- "abc123 456",
- "12356",
- &[3, 4, 5, 8, 9],
- BONUS_CAMEL123 * (BONUS_FIRST_CHAR_MULTIPLIER + 2)
- - PENALTY_GAP_START
- - PENALTY_GAP_EXTENSION
- + BONUS_CONSECUTIVE,
- ),
- (
- "foo/bar/baz",
- "fbb",
- &[0, 4, 8],
- BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_DELIMITER * 2
- - 2 * PENALTY_GAP_START
- - 4 * PENALTY_GAP_EXTENSION,
- ),
- (
- "fooBarBaz",
- "fbb",
- &[0, 3, 6],
- BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_CAMEL123 * 2
- - 2 * PENALTY_GAP_START
- - 2 * PENALTY_GAP_EXTENSION,
- ),
- (
- "foo barbaz",
- "fbb",
- &[0, 4, 7],
- BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_WHITE
- - PENALTY_GAP_START * 2
- - PENALTY_GAP_EXTENSION * 3,
- ),
- (
- "fooBar Baz",
- "foob",
- &[0, 1, 2, 3],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 3),
- ),
- (
- "xFoo-Bar Baz",
- "foo-b",
- &[1, 2, 3, 4, 5],
- BONUS_CAMEL123 * (BONUS_FIRST_CHAR_MULTIPLIER + 2) + 2 * BONUS_NON_WORD,
- ),
- ],
- );
-}
-
-#[test]
-fn empty_needle() {
- assert_matches(
- &[Substring, Prefix, Postfix, FuzzyGreedy, FuzzyOptimal, Exact],
- false,
- false,
- false,
- false,
- &[("foo bar baz", "", &[], 0)],
- );
-}
-
-#[test]
-fn test_substring() {
- assert_matches(
- &[Substring, Prefix],
- false,
- false,
- false,
- false,
- &[
- (
- "foo bar baz",
- "foo",
- &[0, 1, 2],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),
- ),
- (
- " foo bar baz",
- "FOO",
- &[1, 2, 3],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),
- ),
- (
- " foo bar baz",
- " FOO",
- &[0, 1, 2, 3],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 3),
- ),
- ],
- );
- assert_matches(
- &[Substring, Postfix],
- false,
- false,
- false,
- false,
- &[
- (
- "foo bar baz",
- "baz",
- &[8, 9, 10],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),
- ),
- (
- "foo bar baz ",
- "baz",
- &[8, 9, 10],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),
- ),
- (
- "foo bar baz ",
- "baz ",
- &[8, 9, 10, 11],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 3),
- ),
- ],
- );
- assert_matches(
- &[Substring, Prefix, Postfix, Exact, FuzzyGreedy, FuzzyOptimal],
- false,
- false,
- false,
- false,
- &[
- (
- "foo",
- "foo",
- &[0, 1, 2],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),
- ),
- (
- " foo",
- "foo",
- &[1, 2, 3],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),
- ),
- (
- " foo",
- " foo",
- &[0, 1, 2, 3],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 3),
- ),
- ],
- );
- assert_matches(
- &[Substring],
- false,
- false,
- false,
- false,
- &[
- (
- "fooBarbaz1",
- "oba",
- &[2, 3, 4],
- BONUS_CAMEL123 + BONUS_CONSECUTIVE,
- ),
- (
- "/AutomatorDocument.icns",
- "rdoc",
- &[9, 10, 11, 12],
- BONUS_CAMEL123 + 2 * BONUS_CONSECUTIVE,
- ),
- (
- "/man1/zshcompctl.1",
- "zshc",
- &[6, 7, 8, 9],
- BONUS_BOUNDARY_DELIMITER * (BONUS_FIRST_CHAR_MULTIPLIER + 3),
- ),
- (
- "/.oh-my-zsh/cache",
- "zsh/c",
- &[8, 9, 10, 11, 12],
- BONUS_BOUNDARY * (BONUS_FIRST_CHAR_MULTIPLIER + 2)
- + BONUS_NON_WORD
- + BONUS_BOUNDARY_DELIMITER,
- ),
- ],
- );
- assert_not_matches_with(
- true,
- false,
- &[Prefix, Substring, Postfix, Exact],
- &[(
- "At the Road’s End - Seeming - SOL: A Self-Banishment Ritual",
- "adi",
- )],
- )
-}
-
-#[test]
-fn test_substring_case_sensitive() {
- assert_matches(
- &[Substring, Prefix],
- false,
- true,
- false,
- false,
- &[
- (
- "Foo bar baz",
- "Foo",
- &[0, 1, 2],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),
- ),
- (
- "Fȫô bar baz",
- "Fȫô",
- &[0, 1, 2],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),
- ),
- (
- "Foo ฿ar baz",
- "Foo",
- &[0, 1, 2],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),
- ),
- ],
- );
- assert_not_matches_with(false, true, &[Substring, Prefix], &[("foo bar baz", "Foo")]);
-}
-
-#[test]
-fn test_fuzzy_case_sensitive() {
- assert_matches(
- &[FuzzyGreedy, FuzzyOptimal],
- false,
- true,
- false,
- false,
- &[
- (
- "fooBarbaz1",
- "oBr",
- &[2, 3, 5],
- BONUS_CAMEL123 - PENALTY_GAP_START,
- ),
- (
- "Foo/Bar/Baz",
- "FBB",
- &[0, 4, 8],
- BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_DELIMITER * 2
- - 2 * PENALTY_GAP_START
- - 4 * PENALTY_GAP_EXTENSION,
- ),
- (
- "FooBarBaz",
- "FBB",
- &[0, 3, 6],
- BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_CAMEL123 * 2
- - 2 * PENALTY_GAP_START
- - 2 * PENALTY_GAP_EXTENSION,
- ),
- (
- "FooBar Baz",
- "FooB",
- &[0, 1, 2, 3],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 3),
- ),
- ("foo-bar", "o-ba", &[2, 3, 4, 5], BONUS_NON_WORD * 3),
- ],
- );
-}
-
-#[test]
-fn test_normalize() {
- assert_matches(
- &[FuzzyGreedy, FuzzyOptimal],
- true,
- false,
- false,
- false,
- &[
- (
- "Só Danço Samba",
- "So",
- &[0, 1],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 1),
- ),
- (
- "Só Danço Samba",
- "sodc",
- &[0, 1, 3, 6],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 1) - PENALTY_GAP_START
- + BONUS_BOUNDARY_WHITE
- - PENALTY_GAP_START
- - PENALTY_GAP_EXTENSION,
- ),
- (
- "Danço",
- "danco",
- &[0, 1, 2, 3, 4],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 4),
- ),
- (
- "DanÇo",
- "danco",
- &[0, 1, 2, 3, 4],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 4),
- ),
- (
- "xÇando",
- "cando",
- &[1, 2, 3, 4, 5],
- BONUS_CAMEL123 * (BONUS_FIRST_CHAR_MULTIPLIER + 4),
- ),
- ("ۂ(GCGɴCG", "n", &[5], 0),
- ],
- )
-}
-
-#[test]
-fn test_unicode() {
- assert_matches(
- &[FuzzyGreedy, FuzzyOptimal, Substring],
- true,
- false,
- false,
- false,
- &[
- (
- "你好世界",
- "你好",
- &[0, 1],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 1),
- ),
- (
- " 你好世界",
- "你好",
- &[1, 2],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 1),
- ),
- ],
- );
- assert_matches(
- &[FuzzyGreedy, FuzzyOptimal],
- true,
- false,
- false,
- false,
- &[(
- "你好世界",
- "你世",
- &[0, 2],
- BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER - PENALTY_GAP_START,
- )],
- );
- assert_not_matches(
- false,
- false,
- &[("Flibbertigibbet / イタズラっ子たち", "lying")],
- );
-}
-
-#[test]
-fn test_long_str() {
- assert_matches(
- &[FuzzyGreedy, FuzzyOptimal],
- false,
- false,
- false,
- false,
- &[(
- &"x".repeat(u16::MAX as usize + 1),
- "xx",
- &[0, 1],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 1),
- )],
- );
-}
-
-#[test]
-fn test_casing() {
- assert_matches(
- &[FuzzyGreedy, FuzzyOptimal],
- false,
- false,
- false,
- false,
- &[
- // these two have the same score
- (
- "fooBar",
- "foobar",
- &[0, 1, 2, 3, 4, 5],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 5),
- ),
- (
- "foobar",
- "foobar",
- &[0, 1, 2, 3, 4, 5],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 5),
- ),
- // these two have the same score (slightly lower than the other two: 60 instead of 70)
- (
- "foo-bar",
- "foobar",
- &[0, 1, 2, 4, 5, 6],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2) - PENALTY_GAP_START
- + BONUS_BOUNDARY * 3,
- ),
- (
- "foo_bar",
- "foobar",
- &[0, 1, 2, 4, 5, 6],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2) - PENALTY_GAP_START
- + BONUS_BOUNDARY * 3,
- ),
- ],
- )
-}
-
-#[test]
-fn test_optimal() {
- assert_matches(
- &[FuzzyOptimal],
- false,
- false,
- false,
- false,
- &[
- (
- "axxx xx ",
- "xx",
- &[5, 6],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 1),
- ),
- (
- "SS!H",
- "S!",
- &[0, 2],
- BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER - PENALTY_GAP_START
- + BONUS_NON_WORD,
- ),
- // this case is a cool example of why our algorithm is more than fzf
- // we handle this correctly detect that it's better to match
- // the second f instead of the third yielding a higher score
- // (despite using the same scoring function!)
- (
- "xf.foo",
- "xfoo",
- &[0, 3, 4, 5],
- BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER
- - PENALTY_GAP_START
- - PENALTY_GAP_EXTENSION
- + BONUS_BOUNDARY * 3,
- ),
- (
- "xf fo",
- "xfo",
- &[0, 3, 4],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2)
- - PENALTY_GAP_START
- - PENALTY_GAP_EXTENSION,
- ),
- ],
- );
-}
-
-#[test]
-fn test_reject() {
- assert_not_matches(
- true,
- false,
- &[
- ("你好界", "abc"),
- ("你好界", "a"),
- ("你好世界", "富"),
- ("Só Danço Samba", "sox"),
- ("fooBarbaz", "fooBarbazz"),
- ("fooBarbaz", "c"),
- ],
- );
- assert_not_matches(
- true,
- true,
- &[
- ("你好界", "abc"),
- ("abc", "你"),
- ("abc", "A"),
- ("abc", "d"),
- ("你好世界", "富"),
- ("Só Danço Samba", "sox"),
- ("fooBarbaz", "oBZ"),
- ("Foo Bar Baz", "fbb"),
- ("fooBarbaz", "fooBarbazz"),
- ],
- );
- assert_not_matches(
- false,
- true,
- &[
- ("Só Danço Samba", "sod"),
- ("Só Danço Samba", "soc"),
- ("Só Danç", "So"),
- ],
- );
- assert_not_matches(false, false, &[("ۂۂfoۂۂ", "foo")]);
-}
-
-#[test]
-fn test_prefer_prefix() {
- assert_matches(
- &[FuzzyOptimal, FuzzyGreedy],
- false,
- false,
- false,
- true,
- &[
- (
- "Moby Dick",
- "md",
- &[0, 5],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 1) + MAX_PREFIX_BONUS
- - PENALTY_GAP_START
- - 3 * PENALTY_GAP_EXTENSION,
- ),
- (
- "Though I cannot tell why it was exactly that those stage managers, the Fates, put me down for this shabby part of a whaling voyage",
- "md",
- &[82, 85],
- BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 1)
- - PENALTY_GAP_START
- - PENALTY_GAP_EXTENSION,
- ),
- ],
- );
-}
-
-#[test]
-fn test_single_char_needle() {
- assert_matches(
- &[FuzzyOptimal],
- false,
- false,
- false,
- false,
- &[(
- "foO",
- "o",
- &[2],
- BONUS_FIRST_CHAR_MULTIPLIER * BONUS_CAMEL123,
- )],
- );
- assert_matches(
- &[FuzzyOptimal],
- false,
- false,
- false,
- false,
- &[(
- "föÖ",
- "ö",
- &[2],
- BONUS_FIRST_CHAR_MULTIPLIER * BONUS_CAMEL123,
- )],
- );
-}
-
-#[test]
-fn umlaut() {
- let paths = ["be", "bë"];
- let mut matcher = Matcher::new(Config::DEFAULT);
- let matches = Pattern::parse("ë", CaseMatching::Ignore, Normalization::Smart)
- .match_list(paths, &mut matcher);
- assert_eq!(matches.len(), 1);
- let matches = Pattern::parse("e", CaseMatching::Ignore, Normalization::Never)
- .match_list(paths, &mut matcher);
- assert_eq!(matches.len(), 1);
- let matches = Pattern::parse("e", CaseMatching::Ignore, Normalization::Smart)
- .match_list(paths, &mut matcher);
- assert_eq!(matches.len(), 2);
-}
diff --git a/crates/atuin-nucleo/matcher/src/utf32_str.rs b/crates/atuin-nucleo/matcher/src/utf32_str.rs
deleted file mode 100644
index 77bd9d51..00000000
--- a/crates/atuin-nucleo/matcher/src/utf32_str.rs
+++ /dev/null
@@ -1,428 +0,0 @@
-#[cfg(test)]
-mod tests;
-
-use std::borrow::Cow;
-use std::ops::{Bound, RangeBounds};
-use std::{fmt, slice};
-
-use memchr::memmem;
-
-use crate::chars;
-
-/// Check if a given string can be represented internally as the `Ascii` variant in a
-/// [`Utf32String`] or a [`Utf32Str`].
-///
-/// This returns true if the string is ASCII and does not contain a windows-style newline
-/// `'\r'`.
-/// The additional carriage return check is required since even for strings consisting only
-/// of ASCII, the windows-style newline `\r\n` is treated as a single grapheme.
-#[inline]
-fn has_ascii_graphemes(string: &str) -> bool {
- string.is_ascii() && memmem::find(string.as_bytes(), b"\r\n").is_none()
-}
-
-/// A UTF-32 encoded (char array) string that is used as an input to (fuzzy) matching.
-///
-/// This is mostly intended as an internal string type, but some methods are exposed for
-/// convenience. We make the following API guarantees for `Utf32Str(ing)`s produced from a string
-/// using one of its `From<T>` constructors for string types `T` or from the
-/// [`Utf32Str::new`] method.
-///
-/// 1. The `Ascii` variant contains a byte buffer which is guaranteed to be a valid string
-/// slice.
-/// 2. It is guaranteed that the string slice internal to the `Ascii` variant is identical
-/// to the original string.
-/// 3. The length of a `Utf32Str(ing)` is exactly the number of graphemes in the original string.
-///
-/// Since `Utf32Str(ing)`s variants may be constructed directly, you **must not** make these
-/// assumptions when handling `Utf32Str(ing)`s of unknown origin.
-///
-/// ## Caveats
-/// Despite the name, this type is quite far from being a true string type. Here are some
-/// examples demonstrating this.
-///
-/// ### String conversions are not round-trip
-/// In the presence of a multi-codepoint grapheme (e.g. `"u\u{0308}"` which is `u +
-/// COMBINING_DIAERESIS`), the trailing codepoints are truncated.
-/// ```
-/// # use atuin_nucleo_matcher::Utf32String;
-/// assert_eq!(Utf32String::from("u\u{0308}").to_string(), "u");
-/// ```
-///
-/// ### Indexing is done by grapheme
-/// Indexing into a string is done by grapheme rather than by codepoint.
-/// ```
-/// # use atuin_nucleo_matcher::Utf32String;
-/// assert!(Utf32String::from("au\u{0308}").len() == 2);
-/// ```
-///
-/// ### A `Unicode` variant may be produced by all-ASCII characters.
-/// Since the windows-style newline `\r\n` is ASCII only but considered to be a single grapheme,
-/// strings containing `\r\n` will still result in a `Unicode` variant.
-/// ```
-/// # use atuin_nucleo_matcher::Utf32String;
-/// let s = Utf32String::from("\r\n");
-/// assert!(!s.slice(..).is_ascii());
-/// assert!(s.len() == 1);
-/// assert!(s.slice(..).get(0) == '\n');
-/// ```
-///
-/// ## Design rationale
-/// Usually Rust's UTF-8 encoded strings are great. However, since fuzzy matching
-/// operates on codepoints (ideally, it should operate on graphemes but that's too
-/// much hassle to deal with), we want to quickly iterate over codepoints (up to 5
-/// times) during matching.
-///
-/// Doing codepoint segmentation on the fly not only blows through the cache
-/// (lookup tables and I-cache) but also has nontrivial runtime compared to the
-/// matching itself. Furthermore there are many extra optimizations available
-/// for ASCII only text, but checking each match has too much overhead.
-///
-/// Of course, this comes at extra memory cost as we usually still need the UTF-8
-/// encoded variant for rendering. In the (dominant) case of ASCII-only text
-/// we don't require a copy. Furthermore fuzzy matching usually is applied while
-/// the user is typing on the fly so the same item is potentially matched many
-/// times (making the the up-front cost more worth it). That means that its
-/// basically always worth it to pre-segment the string.
-///
-/// For usecases that only match (a lot of) strings once its possible to keep
-/// char buffer around that is filled with the presegmented chars.
-///
-/// Another advantage of this approach is that the matcher will naturally
-/// produce grapheme indices (instead of utf8 offsets) anyway. With a
-/// codepoint basic representation like this the indices can be used
-/// directly
-#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash)]
-pub enum Utf32Str<'a> {
- /// A string represented as ASCII encoded bytes.
- /// Correctness invariant: must only contain valid ASCII (`<= 127`)
- Ascii(&'a [u8]),
- /// A string represented as an array of unicode codepoints (basically UTF-32).
- Unicode(&'a [char]),
-}
-
-impl<'a> Utf32Str<'a> {
- /// Convenience method to construct a `Utf32Str` from a normal UTF-8 str
- pub fn new(str: &'a str, buf: &'a mut Vec<char>) -> Self {
- if has_ascii_graphemes(str) {
- Utf32Str::Ascii(str.as_bytes())
- } else {
- buf.clear();
- buf.extend(crate::chars::graphemes(str));
- Utf32Str::Unicode(buf)
- }
- }
-
- /// Returns the number of characters in this string.
- #[inline]
- pub fn len(self) -> usize {
- match self {
- Utf32Str::Unicode(codepoints) => codepoints.len(),
- Utf32Str::Ascii(ascii_bytes) => ascii_bytes.len(),
- }
- }
-
- /// Returns whether this string is empty.
- #[inline]
- pub fn is_empty(self) -> bool {
- match self {
- Utf32Str::Unicode(codepoints) => codepoints.is_empty(),
- Utf32Str::Ascii(ascii_bytes) => ascii_bytes.is_empty(),
- }
- }
-
- /// Creates a slice with a string that contains the characters in
- /// the specified **character range**.
- #[inline]
- pub fn slice(self, range: impl RangeBounds<usize>) -> Utf32Str<'a> {
- let start = match range.start_bound() {
- Bound::Included(&start) => start,
- Bound::Excluded(&start) => start + 1,
- Bound::Unbounded => 0,
- };
- let end = match range.end_bound() {
- Bound::Included(&end) => end + 1,
- Bound::Excluded(&end) => end,
- Bound::Unbounded => self.len(),
- };
- match self {
- Utf32Str::Ascii(bytes) => Utf32Str::Ascii(&bytes[start..end]),
- Utf32Str::Unicode(codepoints) => Utf32Str::Unicode(&codepoints[start..end]),
- }
- }
-
- /// Returns the number of leading whitespaces in this string
- #[inline]
- pub(crate) fn leading_white_space(self) -> usize {
- match self {
- Utf32Str::Ascii(bytes) => bytes
- .iter()
- .position(|b| !b.is_ascii_whitespace())
- .unwrap_or(0),
- Utf32Str::Unicode(codepoints) => codepoints
- .iter()
- .position(|c| !c.is_whitespace())
- .unwrap_or(0),
- }
- }
-
- /// Returns the number of trailing whitespaces in this string
- #[inline]
- pub(crate) fn trailing_white_space(self) -> usize {
- match self {
- Utf32Str::Ascii(bytes) => bytes
- .iter()
- .rev()
- .position(|b| !b.is_ascii_whitespace())
- .unwrap_or(0),
- Utf32Str::Unicode(codepoints) => codepoints
- .iter()
- .rev()
- .position(|c| !c.is_whitespace())
- .unwrap_or(0),
- }
- }
-
- /// Same as `slice` but accepts a u32 range for convenience since
- /// those are the indices returned by the matcher.
- #[inline]
- pub fn slice_u32(self, range: impl RangeBounds<u32>) -> Utf32Str<'a> {
- let start = match range.start_bound() {
- Bound::Included(&start) => start as usize,
- Bound::Excluded(&start) => start as usize + 1,
- Bound::Unbounded => 0,
- };
- let end = match range.end_bound() {
- Bound::Included(&end) => end as usize + 1,
- Bound::Excluded(&end) => end as usize,
- Bound::Unbounded => self.len(),
- };
- match self {
- Utf32Str::Ascii(bytes) => Utf32Str::Ascii(&bytes[start..end]),
- Utf32Str::Unicode(codepoints) => Utf32Str::Unicode(&codepoints[start..end]),
- }
- }
-
- /// Returns whether this string only contains graphemes which are single ASCII chars.
- ///
- /// This is almost equivalent to the string being ASCII, except with the additional requirement
- /// that the string cannot contain a windows-style newline `\r\n` which is treated as a single
- /// grapheme.
- pub fn is_ascii(self) -> bool {
- matches!(self, Utf32Str::Ascii(_))
- }
-
- /// Returns the `n`th character in this string, zero-indexed
- pub fn get(self, n: u32) -> char {
- match self {
- Utf32Str::Ascii(bytes) => bytes[n as usize] as char,
- Utf32Str::Unicode(codepoints) => codepoints[n as usize],
- }
- }
-
- /// Returns the last character in this string.
- ///
- /// Panics if the string is empty.
- pub(crate) fn last(self) -> char {
- match self {
- Utf32Str::Ascii(bytes) => bytes[bytes.len() - 1] as char,
- Utf32Str::Unicode(codepoints) => codepoints[codepoints.len() - 1],
- }
- }
-
- /// Returns the first character in this string.
- ///
- /// Panics if the string is empty.
- pub(crate) fn first(self) -> char {
- match self {
- Utf32Str::Ascii(bytes) => bytes[0] as char,
- Utf32Str::Unicode(codepoints) => codepoints[0],
- }
- }
-
- /// Returns an iterator over the characters in this string
- pub fn chars(self) -> Chars<'a> {
- match self {
- Utf32Str::Ascii(bytes) => Chars::Ascii(bytes.iter()),
- Utf32Str::Unicode(codepoints) => Chars::Unicode(codepoints.iter()),
- }
- }
-}
-
-impl fmt::Debug for Utf32Str<'_> {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "\"")?;
- for c in self.chars() {
- for c in c.escape_debug() {
- write!(f, "{c}")?
- }
- }
- write!(f, "\"")
- }
-}
-
-impl fmt::Display for Utf32Str<'_> {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- for c in self.chars() {
- write!(f, "{c}")?
- }
- Ok(())
- }
-}
-
-pub enum Chars<'a> {
- Ascii(slice::Iter<'a, u8>),
- Unicode(slice::Iter<'a, char>),
-}
-
-impl Iterator for Chars<'_> {
- type Item = char;
-
- fn next(&mut self) -> Option<Self::Item> {
- match self {
- Chars::Ascii(iter) => iter.next().map(|&c| c as char),
- Chars::Unicode(iter) => iter.next().copied(),
- }
- }
-}
-
-impl DoubleEndedIterator for Chars<'_> {
- fn next_back(&mut self) -> Option<Self::Item> {
- match self {
- Chars::Ascii(iter) => iter.next_back().map(|&c| c as char),
- Chars::Unicode(iter) => iter.next_back().copied(),
- }
- }
-}
-
-#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Hash)]
-/// An owned version of [`Utf32Str`].
-///
-/// See the API documentation for [`Utf32Str`] for more detail.
-pub enum Utf32String {
- /// A string represented as ASCII encoded bytes.
- /// Correctness invariant: must only contain valid ASCII (<=127)
- Ascii(Box<str>),
- /// A string represented as an array of unicode codepoints (basically UTF-32).
- Unicode(Box<[char]>),
-}
-
-impl Default for Utf32String {
- fn default() -> Self {
- Self::Ascii(String::new().into_boxed_str())
- }
-}
-
-impl Utf32String {
- /// Returns the number of characters in this string.
- #[inline]
- pub fn len(&self) -> usize {
- match self {
- Utf32String::Unicode(codepoints) => codepoints.len(),
- Utf32String::Ascii(ascii_bytes) => ascii_bytes.len(),
- }
- }
-
- /// Returns whether this string is empty.
- #[inline]
- pub fn is_empty(&self) -> bool {
- match self {
- Utf32String::Unicode(codepoints) => codepoints.is_empty(),
- Utf32String::Ascii(ascii_bytes) => ascii_bytes.is_empty(),
- }
- }
-
- /// Creates a slice with a string that contains the characters in
- /// the specified **character range**.
- #[inline]
- pub fn slice(&self, range: impl RangeBounds<usize>) -> Utf32Str {
- let start = match range.start_bound() {
- Bound::Included(&start) => start,
- Bound::Excluded(&start) => start + 1,
- Bound::Unbounded => 0,
- };
- let end = match range.end_bound() {
- Bound::Included(&end) => end + 1,
- Bound::Excluded(&end) => end,
- Bound::Unbounded => self.len(),
- };
- match self {
- Utf32String::Ascii(bytes) => Utf32Str::Ascii(&bytes.as_bytes()[start..end]),
- Utf32String::Unicode(codepoints) => Utf32Str::Unicode(&codepoints[start..end]),
- }
- }
-
- /// Same as `slice` but accepts a u32 range for convenience since
- /// those are the indices returned by the matcher.
- #[inline]
- pub fn slice_u32(&self, range: impl RangeBounds<u32>) -> Utf32Str {
- let start = match range.start_bound() {
- Bound::Included(&start) => start,
- Bound::Excluded(&start) => start + 1,
- Bound::Unbounded => 0,
- };
- let end = match range.end_bound() {
- Bound::Included(&end) => end + 1,
- Bound::Excluded(&end) => end,
- Bound::Unbounded => self.len() as u32,
- };
- match self {
- Utf32String::Ascii(bytes) => {
- Utf32Str::Ascii(&bytes.as_bytes()[start as usize..end as usize])
- }
- Utf32String::Unicode(codepoints) => {
- Utf32Str::Unicode(&codepoints[start as usize..end as usize])
- }
- }
- }
-}
-
-impl From<&str> for Utf32String {
- #[inline]
- fn from(value: &str) -> Self {
- if has_ascii_graphemes(value) {
- Self::Ascii(value.to_owned().into_boxed_str())
- } else {
- Self::Unicode(chars::graphemes(value).collect())
- }
- }
-}
-
-impl From<Box<str>> for Utf32String {
- fn from(value: Box<str>) -> Self {
- if has_ascii_graphemes(&value) {
- Self::Ascii(value)
- } else {
- Self::Unicode(chars::graphemes(&value).collect())
- }
- }
-}
-
-impl From<String> for Utf32String {
- #[inline]
- fn from(value: String) -> Self {
- value.into_boxed_str().into()
- }
-}
-
-impl<'a> From<Cow<'a, str>> for Utf32String {
- #[inline]
- fn from(value: Cow<'a, str>) -> Self {
- match value {
- Cow::Borrowed(value) => value.into(),
- Cow::Owned(value) => value.into(),
- }
- }
-}
-
-impl fmt::Debug for Utf32String {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{:?}", self.slice(..))
- }
-}
-
-impl fmt::Display for Utf32String {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.slice(..))
- }
-}
diff --git a/crates/atuin-nucleo/matcher/src/utf32_str/tests.rs b/crates/atuin-nucleo/matcher/src/utf32_str/tests.rs
deleted file mode 100644
index a38c8875..00000000
--- a/crates/atuin-nucleo/matcher/src/utf32_str/tests.rs
+++ /dev/null
@@ -1,44 +0,0 @@
-use crate::{Utf32Str, Utf32String};
-
-#[test]
-fn test_utf32str_ascii() {
- /// Helper function for testing
- fn expect_ascii(src: &str, is_ascii: bool) {
- let mut buffer = Vec::new();
- assert!(Utf32Str::new(src, &mut buffer).is_ascii() == is_ascii);
- assert!(Utf32String::from(src).slice(..).is_ascii() == is_ascii);
- assert!(Utf32String::from(src.to_owned()).slice(..).is_ascii() == is_ascii);
- }
-
- // ascii
- expect_ascii("", true);
- expect_ascii("a", true);
- expect_ascii("a\nb", true);
- expect_ascii("\n\r", true);
-
- // not ascii
- expect_ascii("aü", false);
- expect_ascii("au\u{0308}", false);
-
- // windows-style newline
- expect_ascii("a\r\nb", false);
- expect_ascii("ü\r\n", false);
- expect_ascii("\r\n", false);
-}
-
-#[test]
-fn test_grapheme_truncation() {
- // ascii is preserved
- let s = Utf32String::from("ab");
- assert_eq!(s.slice(..).get(0), 'a');
- assert_eq!(s.slice(..).get(1), 'b');
-
- // windows-style newline is truncated to '\n'
- let s = Utf32String::from("\r\n");
- assert_eq!(s.slice(..).get(0), '\n');
-
- // normal graphemes are truncated to the first character
- let s = Utf32String::from("u\u{0308}\r\n");
- assert_eq!(s.slice(..).get(0), 'u');
- assert_eq!(s.slice(..).get(1), '\n');
-}
diff --git a/crates/atuin-nucleo/src/boxcar.rs b/crates/atuin-nucleo/src/boxcar.rs
deleted file mode 100644
index e54afc05..00000000
--- a/crates/atuin-nucleo/src/boxcar.rs
+++ /dev/null
@@ -1,786 +0,0 @@
-//! Adapted from the `boxcar` crate at <https://github.com/ibraheemdev/boxcar/blob/master/src/raw.rs>
-//! under MIT licenes:
-//!
-//! Copyright (c) 2022 Ibraheem Ahmed
-//!
-//! Permission is hereby granted, free of charge, to any person obtaining a copy
-//! of this software and associated documentation files (the "Software"), to deal
-//! in the Software without restriction, including without limitation the rights
-//! to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-//! copies of the Software, and to permit persons to whom the Software is
-//! furnished to do so, subject to the following conditions:
-//!
-//! The above copyright notice and this permission notice shall be included in all
-//! copies or substantial portions of the Software.
-//!
-//! THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-//! IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-//! FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-//! AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-//! LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-//! OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-//! SOFTWARE.
-
-use std::alloc::Layout;
-use std::cell::UnsafeCell;
-use std::fmt::Debug;
-use std::mem::MaybeUninit;
-use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU64, Ordering};
-use std::{ptr, slice};
-
-use crate::{Item, Utf32String};
-
-const BUCKETS: u32 = u32::BITS - SKIP_BUCKET;
-const MAX_ENTRIES: u32 = u32::MAX - SKIP;
-
-/// A lock-free, append-only vector.
-pub(crate) struct Vec<T> {
- /// a counter used to retrieve a unique index to push to.
- ///
- /// this value may be more than the true length as it will
- /// be incremented before values are actually stored.
- inflight: AtomicU64,
- /// buckets of length 32, 64 .. 2^31
- buckets: [Bucket<T>; BUCKETS as usize],
- /// the number of matcher columns in this vector, its absolutely critical that
- /// this remains constant and after initilaziaton (safety invariant) since
- /// it is used to calculate the Entry layout
- columns: u32,
-}
-
-impl<T> Vec<T> {
- /// Constructs a new, empty `Vec<T>` with the specified capacity and matcher columns.
- pub fn with_capacity(capacity: u32, columns: u32) -> Vec<T> {
- assert_ne!(columns, 0, "there must be at least one matcher column");
- let init = match capacity {
- 0 => 0,
- // initialize enough buckets for `capacity` elements
- n => Location::of(n).bucket,
- };
-
- let mut buckets = [ptr::null_mut(); BUCKETS as usize];
-
- for (i, bucket) in buckets[..=init as usize].iter_mut().enumerate() {
- let len = Location::bucket_len(i as u32);
- *bucket = unsafe { Bucket::alloc(len, columns) };
- }
-
- Vec {
- buckets: buckets.map(Bucket::new),
- inflight: AtomicU64::new(0),
- columns,
- }
- }
- pub fn columns(&self) -> u32 {
- self.columns
- }
-
- /// Returns the number of elements in the vector.
- #[inline]
- pub fn count(&self) -> u32 {
- self.inflight
- .load(Ordering::Acquire)
- .min(MAX_ENTRIES as u64) as u32
- }
-
- // Returns a reference to the element at the given index.
- //
- // # Safety
- //
- // Entry at `index` must be initialized.
- #[inline]
- pub unsafe fn get_unchecked(&self, index: u32) -> Item<'_, T> {
- let location = Location::of(index);
-
- unsafe {
- let entries = self
- .buckets
- .get_unchecked(location.bucket as usize)
- .entries
- .load(Ordering::Relaxed);
- debug_assert!(!entries.is_null());
- let entry = Bucket::<T>::get(entries, location.entry, self.columns);
- // this looks odd but is necessary to ensure cross
- // thread synchronization (essentially acting as a memory barrier)
- // since the caller must only guarantee that he has observed active on any thread
- // but the current thread might still have an old value cached (although unlikely)
- let _ = (*entry).active.load(Ordering::Acquire);
- Entry::read(entry, self.columns)
- }
- }
-
- /// Returns a reference to the element at the given index.
- pub fn get(&self, index: u32) -> Option<Item<'_, T>> {
- let location = Location::of(index);
-
- unsafe {
- // safety: `location.bucket` is always in bounds
- let entries = self
- .buckets
- .get_unchecked(location.bucket as usize)
- .entries
- .load(Ordering::Relaxed);
-
- // bucket is uninitialized
- if entries.is_null() {
- return None;
- }
-
- // safety: `location.entry` is always in bounds for it's bucket
- let entry = Bucket::<T>::get(entries, location.entry, self.columns);
-
- // safety: the entry is active
- (*entry)
- .active
- .load(Ordering::Acquire)
- .then(|| Entry::read(entry, self.columns))
- }
- }
-
- /// Appends an element to the back of the vector.
- pub fn push(&self, value: T, fill_columns: impl FnOnce(&T, &mut [Utf32String])) -> u32 {
- let index = self.inflight.fetch_add(1, Ordering::Release);
- // the inflight counter is a `u64` to catch overflows of the vector'scapacity
- let index: u32 = index.try_into().expect("overflowed maximum capacity");
- let location = Location::of(index);
-
- // eagerly allocate the next bucket if we are close to the end of this one
- if index == (location.bucket_len - (location.bucket_len >> 3)) {
- if let Some(next_bucket) = self.buckets.get(location.bucket as usize + 1) {
- Vec::get_or_alloc(next_bucket, location.bucket_len << 1, self.columns);
- }
- }
-
- // safety: `location.bucket` is always in bounds
- let bucket = unsafe { self.buckets.get_unchecked(location.bucket as usize) };
- let mut entries = bucket.entries.load(Ordering::Acquire);
-
- // the bucket has not been allocated yet
- if entries.is_null() {
- entries = Vec::get_or_alloc(bucket, location.bucket_len, self.columns);
- }
-
- unsafe {
- // safety: `location.entry` is always in bounds for it's bucket
- let entry = Bucket::get(entries, location.entry, self.columns);
-
- // safety: we have unique access to this entry.
- //
- // 1. it is impossible for another thread to attempt a `push`
- // to this location as we retrieved it from `inflight.fetch_add`
- //
- // 2. any thread trying to `get` this entry will see `active == false`,
- // and will not try to access it
- for col in Entry::matcher_cols_raw(entry, self.columns) {
- col.get().write(MaybeUninit::new(Utf32String::default()))
- }
- fill_columns(&value, Entry::matcher_cols_mut(entry, self.columns));
- (*entry).slot.get().write(MaybeUninit::new(value));
- // let other threads know that this entry is active
- (*entry).active.store(true, Ordering::Release);
- }
-
- index
- }
-
- /// Extends the vector by appending multiple elements at once.
- pub fn extend<I>(&self, values: I, fill_columns: impl Fn(&T, &mut [Utf32String]))
- where
- I: IntoIterator<Item = T> + ExactSizeIterator,
- {
- let count: u32 = values
- .len()
- .try_into()
- .expect("overflowed maximum capacity");
- if count == 0 {
- assert!(
- values.into_iter().next().is_none(),
- "The `values` variable reported incorrect length."
- );
- return;
- }
-
- // Reserve all indices at once
- let start_index: u32 = self
- .inflight
- .fetch_add(u64::from(count), Ordering::Release)
- .try_into()
- .expect("overflowed maximum capacity");
-
- // Compute first and last locations
- let start_location = Location::of(start_index);
- let end_location = Location::of(start_index + count);
-
- // Eagerly allocate the next bucket if the last entry is close to the end of its next bucket
- let alloc_entry = end_location.alloc_next_bucket_entry();
- if end_location.entry >= alloc_entry
- && (start_location.bucket != end_location.bucket || start_location.entry <= alloc_entry)
- {
- // This might be the last bucket, hence the check
- if let Some(next_bucket) = self.buckets.get(end_location.bucket as usize + 1) {
- Vec::get_or_alloc(next_bucket, end_location.bucket_len << 1, self.columns);
- }
- }
-
- let mut bucket = unsafe { self.buckets.get_unchecked(start_location.bucket as usize) };
- let mut entries = bucket.entries.load(Ordering::Acquire);
- if entries.is_null() {
- entries = Vec::get_or_alloc(
- bucket,
- Location::bucket_len(start_location.bucket),
- self.columns,
- );
- }
- // Route each value to its corresponding bucket
- let mut location;
- let count = count as usize;
- for (i, v) in values.into_iter().enumerate() {
- // ExactSizeIterator is a safe trait that can have bugs/lie about it's size.
- // Unsafe code cannot rely on the reported length being correct.
- assert!(i < count);
-
- location =
- Location::of(start_index + u32::try_from(i).expect("overflowed maximum capacity"));
-
- // if we're starting to insert into a different bucket, allocate it beforehand
- if location.entry == 0 && i != 0 {
- // safety: `location.bucket` is always in bounds
- bucket = unsafe { self.buckets.get_unchecked(location.bucket as usize) };
- entries = bucket.entries.load(Ordering::Acquire);
-
- if entries.is_null() {
- entries = Vec::get_or_alloc(
- bucket,
- Location::bucket_len(location.bucket),
- self.columns,
- );
- }
- }
-
- unsafe {
- let entry = Bucket::get(entries, location.entry, self.columns);
-
- // Initialize matcher columns
- for col in Entry::matcher_cols_raw(entry, self.columns) {
- col.get().write(MaybeUninit::new(Utf32String::default()));
- }
- fill_columns(&v, Entry::matcher_cols_mut(entry, self.columns));
- (*entry).slot.get().write(MaybeUninit::new(v));
- (*entry).active.store(true, Ordering::Release);
- }
- }
- }
-
- /// race to initialize a bucket
- fn get_or_alloc(bucket: &Bucket<T>, len: u32, cols: u32) -> *mut Entry<T> {
- let entries = unsafe { Bucket::alloc(len, cols) };
- match bucket.entries.compare_exchange(
- ptr::null_mut(),
- entries,
- Ordering::Release,
- Ordering::Acquire,
- ) {
- Ok(_) => entries,
- Err(found) => unsafe {
- Bucket::dealloc(entries, len, cols);
- found
- },
- }
- }
-
- /// Returns an iterator over the vector starting at `start`
- /// the iterator is deterministically sized and will not grow
- /// as more elements are pushed
- pub unsafe fn snapshot(&self, start: u32) -> Iter<'_, T> {
- let end = self
- .inflight
- .load(Ordering::Acquire)
- .min(MAX_ENTRIES as u64) as u32;
- assert!(start <= end, "index {start} is out of bounds!");
- Iter {
- location: Location::of(start),
- vec: self,
- idx: start,
- end,
- }
- }
-
- /// Returns an iterator over the vector starting at `start`
- /// the iterator is deterministically sized and will not grow
- /// as more elements are pushed
- pub unsafe fn par_snapshot(&self, start: u32) -> ParIter<'_, T> {
- let end = self
- .inflight
- .load(Ordering::Acquire)
- .min(MAX_ENTRIES as u64) as u32;
- assert!(start <= end, "index {start} is out of bounds!");
-
- ParIter {
- start,
- end,
- vec: self,
- }
- }
-}
-
-impl<T> Drop for Vec<T> {
- fn drop(&mut self) {
- for (i, bucket) in self.buckets.iter_mut().enumerate() {
- let entries = *bucket.entries.get_mut();
-
- if entries.is_null() {
- break;
- }
-
- let len = Location::bucket_len(i as u32);
- // safety: in drop
- unsafe { Bucket::dealloc(entries, len, self.columns) }
- }
- }
-}
-type SnapshotItem<'v, T> = (u32, Option<Item<'v, T>>);
-
-pub struct Iter<'v, T> {
- location: Location,
- idx: u32,
- end: u32,
- vec: &'v Vec<T>,
-}
-impl<T> Iter<'_, T> {
- pub fn end(&self) -> u32 {
- self.end
- }
-}
-
-impl<'v, T> Iterator for Iter<'v, T> {
- type Item = SnapshotItem<'v, T>;
- fn size_hint(&self) -> (usize, Option<usize>) {
- (
- (self.end - self.idx) as usize,
- Some((self.end - self.idx) as usize),
- )
- }
-
- fn next(&mut self) -> Option<SnapshotItem<'v, T>> {
- if self.end == self.idx {
- return None;
- }
- debug_assert!(self.idx < self.end, "huh {} {}", self.idx, self.end);
- debug_assert!(self.end as u64 <= self.vec.inflight.load(Ordering::Relaxed));
-
- loop {
- let entries = unsafe {
- self.vec
- .buckets
- .get_unchecked(self.location.bucket as usize)
- .entries
- .load(Ordering::Relaxed)
- };
- debug_assert!(self.location.bucket < BUCKETS);
-
- if self.location.entry < self.location.bucket_len {
- if entries.is_null() {
- // we still want to yield these
- let index = self.idx;
- self.location.entry += 1;
- self.idx += 1;
- return Some((index, None));
- }
- // safety: bounds and null checked above
- let entry = unsafe { Bucket::get(entries, self.location.entry, self.vec.columns) };
- let index = self.idx;
- self.location.entry += 1;
- self.idx += 1;
-
- let entry = unsafe {
- (*entry)
- .active
- .load(Ordering::Acquire)
- .then(|| Entry::read(entry, self.vec.columns))
- };
- return Some((index, entry));
- }
-
- self.location.entry = 0;
- self.location.bucket += 1;
-
- if self.location.bucket < BUCKETS {
- self.location.bucket_len = Location::bucket_len(self.location.bucket);
- }
- }
- }
-}
-impl<T> ExactSizeIterator for Iter<'_, T> {}
-impl<T> DoubleEndedIterator for Iter<'_, T> {
- fn next_back(&mut self) -> Option<Self::Item> {
- unimplemented!()
- }
-}
-
-pub struct ParIter<'v, T> {
- end: u32,
- start: u32,
- vec: &'v Vec<T>,
-}
-impl<T> ParIter<'_, T> {
- pub fn end(&self) -> u32 {
- self.end
- }
-}
-
-impl<'v, T: Send + Sync> rayon::iter::ParallelIterator for ParIter<'v, T> {
- type Item = SnapshotItem<'v, T>;
-
- fn drive_unindexed<C>(self, consumer: C) -> C::Result
- where
- C: rayon::iter::plumbing::UnindexedConsumer<Self::Item>,
- {
- rayon::iter::plumbing::bridge(self, consumer)
- }
-
- fn opt_len(&self) -> Option<usize> {
- Some((self.end - self.start) as usize)
- }
-}
-
-impl<T: Send + Sync> rayon::iter::IndexedParallelIterator for ParIter<'_, T> {
- fn len(&self) -> usize {
- (self.end - self.start) as usize
- }
-
- fn drive<C: rayon::iter::plumbing::Consumer<Self::Item>>(self, consumer: C) -> C::Result {
- rayon::iter::plumbing::bridge(self, consumer)
- }
-
- fn with_producer<CB>(self, callback: CB) -> CB::Output
- where
- CB: rayon::iter::plumbing::ProducerCallback<Self::Item>,
- {
- callback.callback(ParIterProducer {
- start: self.start,
- end: self.end,
- vec: self.vec,
- })
- }
-}
-
-struct ParIterProducer<'v, T: Send> {
- start: u32,
- end: u32,
- vec: &'v Vec<T>,
-}
-
-impl<'v, T: 'v + Send + Sync> rayon::iter::plumbing::Producer for ParIterProducer<'v, T> {
- type Item = SnapshotItem<'v, T>;
- type IntoIter = Iter<'v, T>;
-
- fn into_iter(self) -> Self::IntoIter {
- debug_assert!(self.start <= self.end);
- Iter {
- location: Location::of(self.start),
- idx: self.start,
- end: self.end,
- vec: self.vec,
- }
- }
-
- fn split_at(self, index: usize) -> (Self, Self) {
- assert!(index <= (self.end - self.start) as usize);
- let index = index as u32;
- (
- ParIterProducer {
- start: self.start,
- end: self.start + index,
- vec: self.vec,
- },
- ParIterProducer {
- start: self.start + index,
- end: self.end,
- vec: self.vec,
- },
- )
- }
-}
-
-struct Bucket<T> {
- entries: AtomicPtr<Entry<T>>,
-}
-
-impl<T> Bucket<T> {
- fn layout(len: u32, layout: Layout) -> Layout {
- Layout::from_size_align(layout.size() * len as usize, layout.align())
- .expect("exceeded maximum allocation size")
- }
-
- unsafe fn alloc(len: u32, cols: u32) -> *mut Entry<T> {
- let layout = Entry::<T>::layout(cols);
- let arr_layout = Self::layout(len, layout);
- let entries = std::alloc::alloc(arr_layout);
- if entries.is_null() {
- std::alloc::handle_alloc_error(arr_layout)
- }
-
- for i in 0..len {
- let active = entries.add(i as usize * layout.size()) as *mut AtomicBool;
- active.write(AtomicBool::new(false))
- }
- entries as *mut Entry<T>
- }
-
- unsafe fn dealloc(entries: *mut Entry<T>, len: u32, cols: u32) {
- let layout = Entry::<T>::layout(cols);
- let arr_layout = Self::layout(len, layout);
- for i in 0..len {
- let entry = Bucket::get(entries, i, cols);
- if *(*entry).active.get_mut() {
- ptr::drop_in_place((*(*entry).slot.get()).as_mut_ptr());
- for matcher_col in Entry::matcher_cols_raw(entry, cols) {
- ptr::drop_in_place((*matcher_col.get()).as_mut_ptr());
- }
- }
- }
- std::alloc::dealloc(entries as *mut u8, arr_layout)
- }
-
- unsafe fn get(entries: *mut Entry<T>, idx: u32, cols: u32) -> *mut Entry<T> {
- let layout = Entry::<T>::layout(cols);
- let ptr = entries as *mut u8;
- ptr.add(layout.size() * idx as usize) as *mut Entry<T>
- }
-
- fn new(entries: *mut Entry<T>) -> Bucket<T> {
- Bucket {
- entries: AtomicPtr::new(entries),
- }
- }
-}
-
-#[repr(C)]
-struct Entry<T> {
- active: AtomicBool,
- slot: UnsafeCell<MaybeUninit<T>>,
- tail: [UnsafeCell<MaybeUninit<Utf32String>>; 0],
-}
-
-impl<T> Entry<T> {
- fn layout(cols: u32) -> Layout {
- let head = Layout::new::<Self>();
- let tail = Layout::array::<Utf32String>(cols as usize).expect("invalid memory layout");
- head.extend(tail)
- .expect("invalid memory layout")
- .0
- .pad_to_align()
- }
-
- unsafe fn matcher_cols_raw<'a>(
- ptr: *mut Entry<T>,
- cols: u32,
- ) -> &'a [UnsafeCell<MaybeUninit<Utf32String>>] {
- // this whole thing looks weird. The reason we do this is that
- // we must make sure the pointer retains its provenance which may (or may not?)
- // be lost if we used tail.as_ptr()
- let tail = std::ptr::addr_of!((*ptr).tail) as *const u8;
- let offset = tail.offset_from(ptr as *mut u8) as usize;
- let ptr = (ptr as *mut u8).add(offset) as *mut _;
- slice::from_raw_parts(ptr, cols as usize)
- }
-
- unsafe fn matcher_cols_mut<'a>(ptr: *mut Entry<T>, cols: u32) -> &'a mut [Utf32String] {
- // this whole thing looks weird. The reason we do this is that
- // we must make sure the pointer retains its provenance which may (or may not?)
- // be lost if we used tail.as_ptr()
- let tail = std::ptr::addr_of!((*ptr).tail) as *const u8;
- let offset = tail.offset_from(ptr as *mut u8) as usize;
- let ptr = (ptr as *mut u8).add(offset) as *mut _;
- slice::from_raw_parts_mut(ptr, cols as usize)
- }
- // # Safety
- //
- // Value must be initialized.
- unsafe fn read<'a>(ptr: *mut Entry<T>, cols: u32) -> Item<'a, T> {
- // this whole thing looks weird. The reason we do this is that
- // we must make sure the pointer retains its provenance which may (or may not?)
- // be lost if we used tail.as_ptr()
- let data = (*(*ptr).slot.get()).assume_init_ref();
- let tail = std::ptr::addr_of!((*ptr).tail) as *const u8;
- let offset = tail.offset_from(ptr as *mut u8) as usize;
- let ptr = (ptr as *mut u8).add(offset) as *mut _;
- let matcher_columns = slice::from_raw_parts(ptr, cols as usize);
- Item {
- data,
- matcher_columns,
- }
- }
-}
-
-#[derive(Debug)]
-struct Location {
- // the index of the bucket
- bucket: u32,
- // the length of `bucket`
- bucket_len: u32,
- // the index of the entry in `bucket`
- entry: u32,
-}
-
-// skip the shorter buckets to avoid unnecessary allocations.
-// this also reduces the maximum capacity of a vector.
-const SKIP: u32 = 32;
-const SKIP_BUCKET: u32 = (u32::BITS - SKIP.leading_zeros()) - 1;
-
-impl Location {
- fn of(index: u32) -> Location {
- let skipped = index.checked_add(SKIP).expect("exceeded maximum length");
- let bucket = u32::BITS - skipped.leading_zeros();
- let bucket = bucket - (SKIP_BUCKET + 1);
- let bucket_len = Location::bucket_len(bucket);
- let entry = skipped ^ bucket_len;
-
- Location {
- bucket,
- bucket_len,
- entry,
- }
- }
-
- fn bucket_len(bucket: u32) -> u32 {
- 1 << (bucket + SKIP_BUCKET)
- }
-
- /// The entry index at which the next bucket should be pre-allocated.
- fn alloc_next_bucket_entry(&self) -> u32 {
- self.bucket_len - (self.bucket_len >> 3)
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::{Location, Vec, BUCKETS, MAX_ENTRIES};
-
- #[test]
- fn location() {
- assert_eq!(Location::bucket_len(0), 32);
- for i in 0..32 {
- let loc = Location::of(i);
- assert_eq!(loc.bucket_len, 32);
- assert_eq!(loc.bucket, 0);
- assert_eq!(loc.entry, i);
- }
-
- assert_eq!(Location::bucket_len(1), 64);
- for i in 33..96 {
- let loc = Location::of(i);
- assert_eq!(loc.bucket_len, 64);
- assert_eq!(loc.bucket, 1);
- assert_eq!(loc.entry, i - 32);
- }
-
- assert_eq!(Location::bucket_len(2), 128);
- for i in 96..224 {
- let loc = Location::of(i);
- assert_eq!(loc.bucket_len, 128);
- assert_eq!(loc.bucket, 2);
- assert_eq!(loc.entry, i - 96);
- }
-
- let max = Location::of(MAX_ENTRIES);
- assert_eq!(max.bucket, BUCKETS - 1);
- assert_eq!(max.bucket_len, 1 << 31);
- assert_eq!(max.entry, (1 << 31) - 1);
- }
-
- #[test]
- fn extend_unique_bucket() {
- let vec = Vec::<u32>::with_capacity(1, 1);
- vec.extend(0..10, |_, _| {});
- assert_eq!(vec.count(), 10);
- for i in 0..10 {
- assert_eq!(*vec.get(i).unwrap().data, i);
- }
- assert!(vec.get(10).is_none());
- }
-
- #[test]
- fn extend_over_two_buckets() {
- let vec = Vec::<u32>::with_capacity(1, 1);
- vec.extend(0..100, |_, _| {});
- assert_eq!(vec.count(), 100);
- for i in 0..100 {
- assert_eq!(*vec.get(i).unwrap().data, i);
- }
- assert!(vec.get(100).is_none());
- }
-
- #[test]
- fn extend_over_more_than_two_buckets() {
- let vec = Vec::<u32>::with_capacity(1, 1);
- vec.extend(0..1000, |_, _| {});
- assert_eq!(vec.count(), 1000);
- for i in 0..1000 {
- assert_eq!(*vec.get(i).unwrap().data, i);
- }
- assert!(vec.get(1000).is_none());
- }
-
- #[test]
- /// test that ExactSizeIterator returning incorrect length is caught (0 AND more than reported)
- fn extend_with_incorrect_reported_len_is_caught() {
- struct IncorrectLenIter {
- len: usize,
- iter: std::ops::Range<u32>,
- }
-
- impl Iterator for IncorrectLenIter {
- type Item = u32;
-
- fn next(&mut self) -> Option<Self::Item> {
- self.iter.next()
- }
- }
-
- impl ExactSizeIterator for IncorrectLenIter {
- fn len(&self) -> usize {
- self.len
- }
- }
-
- let vec = Vec::<u32>::with_capacity(1, 1);
- let iter = IncorrectLenIter {
- len: 10,
- iter: (0..12),
- };
- // this should panic
- assert!(std::panic::catch_unwind(|| vec.extend(iter, |_, _| {})).is_err());
-
- let vec = Vec::<u32>::with_capacity(1, 1);
- let iter = IncorrectLenIter {
- len: 12,
- iter: (0..10),
- };
- // this shouldn't panic and should just ignore the extra elements
- assert!(std::panic::catch_unwind(|| vec.extend(iter, |_, _| {})).is_ok());
- // we should reserve 12 elements but only 10 should be present
- assert_eq!(vec.count(), 12);
- for i in 0..10 {
- assert_eq!(*vec.get(i).unwrap().data, i);
- }
- assert!(vec.get(10).is_none());
-
- let vec = Vec::<u32>::with_capacity(1, 1);
- let iter = IncorrectLenIter {
- len: 0,
- iter: (0..2),
- };
- // this should panic
- assert!(std::panic::catch_unwind(|| vec.extend(iter, |_, _| {})).is_err());
- }
-
- // test |values| does not fit in the boxcar
- #[test]
- fn extend_over_max_capacity() {
- let vec = Vec::<u32>::with_capacity(1, 1);
- let count = MAX_ENTRIES as usize + 2;
- let iter = std::iter::repeat_n(0, count);
- assert!(std::panic::catch_unwind(|| vec.extend(iter, |_, _| {})).is_err());
- }
-}
diff --git a/crates/atuin-nucleo/src/lib.rs b/crates/atuin-nucleo/src/lib.rs
deleted file mode 100644
index efc7628c..00000000
--- a/crates/atuin-nucleo/src/lib.rs
+++ /dev/null
@@ -1,522 +0,0 @@
-/*!
-`nucleo` is a high level crate that provides a high level matcher API that
-provides a highly effective (parallel) matcher worker. It's designed to allow
-quickly plugging a fully featured (and faster) fzf/skim like fuzzy matcher into
-your TUI application.
-
-It's designed to run matching on a background threadpool while providing a
-snapshot of the last complete match. That means the matcher can update the
-results live while the user is typing while never blocking the main UI thread
-(beyond a user provided timeout). Nucleo also supports fully concurrent lock-free
-(and wait-free) streaming of input items.
-
-The [`Nucleo`] struct serves as the main API entrypoint for this crate.
-
-# Status
-
-Nucleo is used in the helix-editor and therefore has a large user base with lots
-or real world testing. The core matcher implementation is considered complete
-and is unlikely to see major changes. The `atuin-nucleo-matcher` crate is finished and
-ready for widespread use, breaking changes should be very rare (a 1.0 release
-should not be far away).
-
-While the high level `nucleo` crate also works well (and is also used in helix),
-there are still additional features that will be added in the future. The high
-level crate also need better documentation and will likely see a few minor API
-changes in the future.
-
-*/
-use std::ops::{Bound, RangeBounds};
-use std::sync::atomic::{self, AtomicBool, Ordering};
-use std::sync::Arc;
-use std::time::Duration;
-
-/// A filter predicate that determines whether an item should be included in matching.
-/// Return `true` to include the item, `false` to skip it.
-pub type Filter<T> = Arc<dyn Fn(&T) -> bool + Send + Sync>;
-
-/// A scorer callback that computes the final ranking score for an item.
-/// Receives a reference to the item and its fuzzy match score.
-/// Returns the combined/external score used for sorting results.
-pub type Scorer<T> = Arc<dyn Fn(&T, u32) -> u32 + Send + Sync>;
-
-use parking_lot::Mutex;
-use rayon::ThreadPool;
-
-use crate::pattern::MultiPattern;
-use crate::worker::Worker;
-pub use atuin_nucleo_matcher::{chars, Config, Matcher, Utf32Str, Utf32String};
-
-mod boxcar;
-mod par_sort;
-pub mod pattern;
-mod worker;
-
-#[cfg(test)]
-mod tests;
-
-/// A match candidate stored in a [`Nucleo`] worker.
-pub struct Item<'a, T> {
- pub data: &'a T,
- pub matcher_columns: &'a [Utf32String],
-}
-
-/// A handle that allows adding new items to a [`Nucleo`] worker.
-///
-/// It's internally reference counted and can be cheaply cloned
-/// and sent across threads.
-pub struct Injector<T> {
- items: Arc<boxcar::Vec<T>>,
- notify: Arc<dyn Fn() + Sync + Send>,
-}
-
-impl<T> Clone for Injector<T> {
- fn clone(&self) -> Self {
- Injector {
- items: self.items.clone(),
- notify: self.notify.clone(),
- }
- }
-}
-
-impl<T> Injector<T> {
- /// Appends an element to the list of matched items.
- /// This function is lock-free and wait-free.
- pub fn push(&self, value: T, fill_columns: impl FnOnce(&T, &mut [Utf32String])) -> u32 {
- let idx = self.items.push(value, fill_columns);
- (self.notify)();
- idx
- }
-
- /// Appends multiple elements to the list of matched items.
- /// This function is lock-free and wait-free.
- ///
- /// You should favor this function over `push` if at least one of the following is true:
- /// - the number of items you're adding can be computed beforehand and is typically larger
- /// than 1k
- /// - you're able to batch incoming items
- /// - you're adding items from multiple threads concurrently (this function results in less
- /// contention)
- pub fn extend<I>(&self, values: I, fill_columns: impl Fn(&T, &mut [Utf32String]))
- where
- I: IntoIterator<Item = T> + ExactSizeIterator,
- {
- self.items.extend(values, fill_columns);
- (self.notify)();
- }
-
- /// Returns the total number of items injected in the matcher. This might
- /// not match the number of items in the match snapshot (if the matcher
- /// is still running)
- pub fn injected_items(&self) -> u32 {
- self.items.count()
- }
-
- /// Returns a reference to the item at the given index.
- ///
- /// # Safety
- ///
- /// Item at `index` must be initialized. That means you must have observed
- /// `push` returning this value or `get` returning `Some` for this value.
- /// Just because a later index is initialized doesn't mean that this index
- /// is initialized
- pub unsafe fn get_unchecked(&self, index: u32) -> Item<'_, T> {
- self.items.get_unchecked(index)
- }
-
- /// Returns a reference to the element at the given index.
- pub fn get(&self, index: u32) -> Option<Item<'_, T>> {
- self.items.get(index)
- }
-}
-
-/// An [item](crate::Item) that was successfully matched by a [`Nucleo`] worker.
-#[derive(PartialEq, Eq, Debug, Clone, Copy)]
-pub struct Match {
- /// The raw fuzzy match score from the matcher.
- pub score: u32,
- /// The external/combined score used for sorting.
- /// If no scorer callback is set, this equals `score`.
- /// If a scorer callback is set, this is the value returned by the callback.
- pub external_score: u32,
- /// The index of the matched item in the item list.
- pub idx: u32,
-}
-
-/// That status of a [`Nucleo`] worker after a match.
-#[derive(PartialEq, Eq, Debug, Clone, Copy)]
-pub struct Status {
- /// Whether the current snapshot has changed.
- pub changed: bool,
- /// Whether the matcher is still processing in the background.
- pub running: bool,
-}
-
-/// A snapshot represent the results of a [`Nucleo`] worker after
-/// finishing a [`tick`](Nucleo::tick).
-pub struct Snapshot<T: Sync + Send + 'static> {
- item_count: u32,
- matches: Vec<Match>,
- pattern: MultiPattern,
- items: Arc<boxcar::Vec<T>>,
-}
-
-impl<T: Sync + Send + 'static> Snapshot<T> {
- fn clear(&mut self, new_items: Arc<boxcar::Vec<T>>) {
- self.item_count = 0;
- self.matches.clear();
- self.items = new_items
- }
-
- fn update(&mut self, worker: &Worker<T>) {
- self.item_count = worker.item_count();
- self.pattern.clone_from(&worker.pattern);
- self.matches.clone_from(&worker.matches);
- if !Arc::ptr_eq(&worker.items, &self.items) {
- self.items = worker.items.clone()
- }
- }
-
- /// Returns that total number of items
- pub fn item_count(&self) -> u32 {
- self.item_count
- }
-
- /// Returns the pattern which items were matched against
- pub fn pattern(&self) -> &MultiPattern {
- &self.pattern
- }
-
- /// Returns that number of items that matched the pattern
- pub fn matched_item_count(&self) -> u32 {
- self.matches.len() as u32
- }
-
- /// Returns an iterator over the items that correspond to a subrange of
- /// all the matches in this snapshot.
- ///
- /// # Panics
- /// Panics if `range` has a range bound that is larger than
- /// the matched item count
- pub fn matched_items(
- &self,
- range: impl RangeBounds<u32>,
- ) -> impl ExactSizeIterator<Item = Item<'_, T>> + DoubleEndedIterator + '_ {
- // TODO: use TAIT
- let start = match range.start_bound() {
- Bound::Included(&start) => start as usize,
- Bound::Excluded(&start) => start as usize + 1,
- Bound::Unbounded => 0,
- };
- let end = match range.end_bound() {
- Bound::Included(&end) => end as usize + 1,
- Bound::Excluded(&end) => end as usize,
- Bound::Unbounded => self.matches.len(),
- };
- self.matches[start..end]
- .iter()
- .map(|&m| unsafe { self.items.get_unchecked(m.idx) })
- }
-
- /// Returns a reference to the item at the given index.
- ///
- /// # Safety
- ///
- /// Item at `index` must be initialized. That means you must have observed a
- /// match with the corresponding index in this exact snapshot. Observing
- /// a higher index is not enough as item indices can be non-contigously
- /// initialized
- #[inline]
- pub unsafe fn get_item_unchecked(&self, index: u32) -> Item<'_, T> {
- self.items.get_unchecked(index)
- }
-
- /// Returns a reference to the item at the given index.
- ///
- /// Returns `None` if the given `index` is not initialized. This function
- /// is only guarteed to return `Some` for item indices that can be found in
- /// the `matches` of this struct. Both smaller and larger indices may return
- /// `None`.
- #[inline]
- pub fn get_item(&self, index: u32) -> Option<Item<'_, T>> {
- self.items.get(index)
- }
-
- /// Return the matches corresponding to this snapshot.
- #[inline]
- pub fn matches(&self) -> &[Match] {
- &self.matches
- }
-
- /// A convenience function to return the [`Item`] corresponding to the
- /// `n`th match.
- ///
- /// Returns `None` if `n` is greater than or equal to the match count.
- #[inline]
- pub fn get_matched_item(&self, n: u32) -> Option<Item<'_, T>> {
- // SAFETY: A match index is guaranteed to corresponding to a valid global index in this
- // snapshot.
- unsafe { Some(self.get_item_unchecked(self.matches.get(n as usize)?.idx)) }
- }
-}
-
-#[repr(u8)]
-#[derive(Clone, Copy, PartialEq, Eq)]
-enum State {
- Init,
- /// items have been cleared but snapshot and items are still outdated
- Cleared,
- /// items are fresh
- Fresh,
-}
-
-impl State {
- fn matcher_item_refs(self) -> usize {
- match self {
- State::Cleared => 1,
- State::Init | State::Fresh => 2,
- }
- }
-
- fn canceled(self) -> bool {
- self != State::Fresh
- }
-
- fn cleared(self) -> bool {
- self != State::Fresh
- }
-}
-
-/// A high level matcher worker that quickly computes matches in a background
-/// threadpool.
-pub struct Nucleo<T: Sync + Send + 'static> {
- // the way the API is build we totally don't actually need these to be Arcs
- // but this lets us avoid some unsafe
- canceled: Arc<AtomicBool>,
- should_notify: Arc<AtomicBool>,
- worker: Arc<Mutex<Worker<T>>>,
- pool: ThreadPool,
- state: State,
- items: Arc<boxcar::Vec<T>>,
- notify: Arc<dyn Fn() + Sync + Send>,
- snapshot: Snapshot<T>,
- /// The pattern matched by this matcher. To update the match pattern
- /// [`MultiPattern::reparse`](`pattern::MultiPattern::reparse`) should be used.
- /// Note that the matcher worker will only become aware of the new pattern
- /// after a call to [`tick`](Nucleo::tick).
- pub pattern: MultiPattern,
- /// Optional filter predicate. Items where filter returns false are skipped.
- filter: Option<Filter<T>>,
- /// Optional scorer callback. Returns combined score used for sorting.
- scorer: Option<Scorer<T>>,
- /// Flag indicating filter or scorer has changed and rescore is needed.
- filter_scorer_changed: bool,
-}
-
-impl<T: Sync + Send + 'static> Nucleo<T> {
- /// Constructs a new `nucleo` worker threadpool with the provided `config`.
- ///
- /// `notify` is called every time new information is available and
- /// [`tick`](Nucleo::tick) should be called. Note that `notify` is not
- /// debounced, that should be handled by the downstream crate (for example
- /// debouncing to only redraw at most every 1/60 seconds).
- ///
- /// If `None` is passed for the number of worker threads, nucleo will use
- /// one thread per hardware thread.
- ///
- /// Nucleo can match items with multiple orthogonal properties. `columns`
- /// indicates how many matching columns each item (and the pattern) has. The
- /// number of columns cannot be changed after construction.
- pub fn new(
- config: Config,
- notify: Arc<dyn Fn() + Sync + Send>,
- num_threads: Option<usize>,
- columns: u32,
- ) -> Self {
- let (pool, worker) = Worker::new(num_threads, config, notify.clone(), columns);
- Self {
- canceled: worker.canceled.clone(),
- should_notify: worker.should_notify.clone(),
- items: worker.items.clone(),
- pool,
- pattern: MultiPattern::new(columns as usize),
- snapshot: Snapshot {
- matches: Vec::with_capacity(2 * 1024),
- pattern: MultiPattern::new(columns as usize),
- item_count: 0,
- items: worker.items.clone(),
- },
- worker: Arc::new(Mutex::new(worker)),
- state: State::Init,
- notify,
- filter: None,
- scorer: None,
- filter_scorer_changed: false,
- }
- }
-
- /// Returns the total number of active injectors
- pub fn active_injectors(&self) -> usize {
- Arc::strong_count(&self.items)
- - self.state.matcher_item_refs()
- - (Arc::ptr_eq(&self.snapshot.items, &self.items)) as usize
- }
-
- /// Returns a snapshot of the current matcher state.
- pub fn snapshot(&self) -> &Snapshot<T> {
- &self.snapshot
- }
-
- /// Returns an injector that can be used for adding candidates to the matcher.
- pub fn injector(&self) -> Injector<T> {
- Injector {
- items: self.items.clone(),
- notify: self.notify.clone(),
- }
- }
-
- /// Restart the the item stream. Removes all items and disconnects all
- /// previously created injectors from this instance. If `clear_snapshot`
- /// is `true` then all items and matched are removed from the [`Snapshot`]
- /// immediately. Otherwise the snapshot will keep the current matches until
- /// the matcher has run again.
- ///
- /// # Note
- ///
- /// The injectors will continue to function but they will not affect this
- /// instance anymore. The old items will only be dropped when all injectors
- /// were dropped.
- pub fn restart(&mut self, clear_snapshot: bool) {
- self.canceled.store(true, Ordering::Relaxed);
- self.items = Arc::new(boxcar::Vec::with_capacity(1024, self.items.columns()));
- self.state = State::Cleared;
- if clear_snapshot {
- self.snapshot.clear(self.items.clone());
- }
- }
-
- /// Update the internal configuration.
- pub fn update_config(&mut self, config: Config) {
- self.worker.lock().update_config(config)
- }
-
- // Set whether the matcher should sort search results by score after
- // matching. Defaults to true.
- pub fn sort_results(&mut self, sort_results: bool) {
- self.worker.lock().sort_results(sort_results)
- }
-
- // Set whether the matcher should reverse the order of the input.
- // Defaults to false.
- pub fn reverse_items(&mut self, reverse_items: bool) {
- self.worker.lock().reverse_items(reverse_items)
- }
-
- /// Set a filter predicate. Items where the filter returns `false` are
- /// skipped during matching. This is applied before fuzzy matching, so
- /// filtered items don't incur the cost of fuzzy matching.
- ///
- /// Setting a new filter triggers a rescore on the next [`tick`](Nucleo::tick).
- ///
- /// Pass `None` to remove the filter.
- pub fn set_filter(&mut self, filter: Option<Filter<T>>) {
- self.filter = filter;
- self.filter_scorer_changed = true;
- }
-
- /// Set a scorer callback. The callback receives a reference to the item
- /// and its fuzzy match score, and returns the combined score used for
- /// sorting results.
- ///
- /// If no scorer is set, results are sorted by fuzzy match score.
- ///
- /// Setting a new scorer triggers a rescore on the next [`tick`](Nucleo::tick).
- ///
- /// Pass `None` to remove the scorer and use default fuzzy score sorting.
- pub fn set_scorer(&mut self, scorer: Option<Scorer<T>>) {
- self.scorer = scorer;
- self.filter_scorer_changed = true;
- }
-
- /// The main way to interact with the matcher, this should be called
- /// regularly (for example each time a frame is rendered). To avoid
- /// excessive redraws this method will wait `timeout` milliseconds for the
- /// worker thread to finish. It is recommend to set the timeout to 10ms.
- pub fn tick(&mut self, timeout: u64) -> Status {
- self.should_notify.store(false, atomic::Ordering::Relaxed);
- let mut status = self.pattern.status();
- // If filter or scorer changed, treat as rescore
- if self.filter_scorer_changed {
- if status == pattern::Status::Unchanged {
- status = pattern::Status::Rescore;
- }
- self.filter_scorer_changed = false;
- }
- let canceled = status != pattern::Status::Unchanged || self.state.canceled();
- let mut res = self.tick_inner(timeout, canceled, status);
- if !canceled {
- return res;
- }
- self.state = State::Fresh;
- let status2 = self.tick_inner(timeout, false, pattern::Status::Unchanged);
- res.changed |= status2.changed;
- res.running = status2.running;
- res
- }
-
- fn tick_inner(&mut self, timeout: u64, canceled: bool, status: pattern::Status) -> Status {
- let mut inner = if canceled {
- self.pattern.reset_status();
- self.canceled.store(true, atomic::Ordering::Relaxed);
- self.worker.lock_arc()
- } else {
- let Some(worker) = self.worker.try_lock_arc_for(Duration::from_millis(timeout)) else {
- self.should_notify.store(true, Ordering::Release);
- return Status {
- changed: false,
- running: true,
- };
- };
- worker
- };
-
- let changed = inner.running;
-
- let running = canceled || self.items.count() > inner.item_count();
- if inner.running {
- inner.running = false;
- if !inner.was_canceled && !self.state.canceled() {
- self.snapshot.update(&inner)
- }
- }
- if running {
- inner.pattern.clone_from(&self.pattern);
- // Update filter and scorer in worker
- inner.set_filter(self.filter.clone());
- inner.set_scorer(self.scorer.clone());
- self.canceled.store(false, atomic::Ordering::Relaxed);
- if !canceled {
- self.should_notify.store(true, atomic::Ordering::Release);
- }
- let cleared = self.state.cleared();
- if cleared {
- inner.items = self.items.clone();
- }
- self.pool
- .spawn(move || unsafe { inner.run(status, cleared) })
- }
- Status { changed, running }
- }
-}
-
-impl<T: Sync + Send> Drop for Nucleo<T> {
- fn drop(&mut self) {
- // we ensure the worker quits before dropping items to ensure that
- // the worker can always assume the items outlive it
- self.canceled.store(true, atomic::Ordering::Relaxed);
- let lock = self.worker.try_lock_for(Duration::from_secs(1));
- if lock.is_none() {
- unreachable!("thread pool failed to shutdown properly")
- }
- }
-}
diff --git a/crates/atuin-nucleo/src/par_sort.rs b/crates/atuin-nucleo/src/par_sort.rs
deleted file mode 100644
index b7ca3f9e..00000000
--- a/crates/atuin-nucleo/src/par_sort.rs
+++ /dev/null
@@ -1,895 +0,0 @@
-//! Parallel quicksort.
-//!
-//! This implementation is copied verbatim from `std::slice::sort_unstable` and then parallelized.
-//! The only difference from the original is that calls to `recurse` are executed in parallel using
-//! `rayon_core::join`.
-//! Further modified for nucleo to allow canceling the sort
-
-// Copyright (c) 2010 The Rust Project Developers
-//
-// Permission is hereby granted, free of charge, to any
-// person obtaining a copy of this software and associated
-// documentation files (the "Software"), to deal in the
-// Software without restriction, including without
-// limitation the rights to use, copy, modify, merge,
-// publish, distribute, sublicense, and/or sell copies of
-// the Software, and to permit persons to whom the Software
-// is furnished to do so, subject to the following
-// conditions:
-//
-// The above copyright notice and this permission notice
-// shall be included in all copies or substantial portions
-// of the Software.
-//
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
-// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
-// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
-// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
-// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
-// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
-// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-// DEALINGS IN THE SOFTWARE.
-
-use std::cmp;
-use std::mem::{self, MaybeUninit};
-use std::ptr;
-use std::sync::atomic::{self, AtomicBool};
-
-/// When dropped, copies from `src` into `dest`.
-struct CopyOnDrop<T> {
- src: *const T,
- dest: *mut T,
-}
-
-impl<T> Drop for CopyOnDrop<T> {
- fn drop(&mut self) {
- // SAFETY: This is a helper class.
- // Please refer to its usage for correctness.
- // Namely, one must be sure that `src` and `dst` does not overlap as required by `ptr::copy_nonoverlapping`.
- unsafe {
- ptr::copy_nonoverlapping(self.src, self.dest, 1);
- }
- }
-}
-
-/// Shifts the first element to the right until it encounters a greater or equal element.
-fn shift_head<T, F>(v: &mut [T], is_less: &F)
-where
- F: Fn(&T, &T) -> bool,
-{
- let len = v.len();
- // SAFETY: The unsafe operations below involves indexing without a bounds check (by offsetting a
- // pointer) and copying memory (`ptr::copy_nonoverlapping`).
- //
- // a. Indexing:
- // 1. We checked the size of the array to >=2.
- // 2. All the indexing that we will do is always between {0 <= index < len} at most.
- //
- // b. Memory copying
- // 1. We are obtaining pointers to references which are guaranteed to be valid.
- // 2. They cannot overlap because we obtain pointers to difference indices of the slice.
- // Namely, `i` and `i-1`.
- // 3. If the slice is properly aligned, the elements are properly aligned.
- // It is the caller's responsibility to make sure the slice is properly aligned.
- //
- // See comments below for further detail.
- unsafe {
- // If the first two elements are out-of-order...
- if len >= 2 && is_less(v.get_unchecked(1), v.get_unchecked(0)) {
- // Read the first element into a stack-allocated variable. If a following comparison
- // operation panics, `hole` will get dropped and automatically write the element back
- // into the slice.
- let tmp = mem::ManuallyDrop::new(ptr::read(v.get_unchecked(0)));
- let v = v.as_mut_ptr();
- let mut hole = CopyOnDrop {
- src: &*tmp,
- dest: v.add(1),
- };
- ptr::copy_nonoverlapping(v.add(1), v.add(0), 1);
-
- for i in 2..len {
- if !is_less(&*v.add(i), &*tmp) {
- break;
- }
-
- // Move `i`-th element one place to the left, thus shifting the hole to the right.
- ptr::copy_nonoverlapping(v.add(i), v.add(i - 1), 1);
- hole.dest = v.add(i);
- }
- // `hole` gets dropped and thus copies `tmp` into the remaining hole in `v`.
- }
- }
-}
-
-/// Shifts the last element to the left until it encounters a smaller or equal element.
-fn shift_tail<T, F>(v: &mut [T], is_less: &F)
-where
- F: Fn(&T, &T) -> bool,
-{
- let len = v.len();
- // SAFETY: The unsafe operations below involves indexing without a bound check (by offsetting a
- // pointer) and copying memory (`ptr::copy_nonoverlapping`).
- //
- // a. Indexing:
- // 1. We checked the size of the array to >= 2.
- // 2. All the indexing that we will do is always between `0 <= index < len-1` at most.
- //
- // b. Memory copying
- // 1. We are obtaining pointers to references which are guaranteed to be valid.
- // 2. They cannot overlap because we obtain pointers to difference indices of the slice.
- // Namely, `i` and `i+1`.
- // 3. If the slice is properly aligned, the elements are properly aligned.
- // It is the caller's responsibility to make sure the slice is properly aligned.
- //
- // See comments below for further detail.
- unsafe {
- // If the last two elements are out-of-order...
- if len >= 2 && is_less(v.get_unchecked(len - 1), v.get_unchecked(len - 2)) {
- // Read the last element into a stack-allocated variable. If a following comparison
- // operation panics, `hole` will get dropped and automatically write the element back
- // into the slice.
- let tmp = mem::ManuallyDrop::new(ptr::read(v.get_unchecked(len - 1)));
- let v = v.as_mut_ptr();
- let mut hole = CopyOnDrop {
- src: &*tmp,
- dest: v.add(len - 2),
- };
- ptr::copy_nonoverlapping(v.add(len - 2), v.add(len - 1), 1);
-
- for i in (0..len - 2).rev() {
- if !is_less(&*tmp, &*v.add(i)) {
- break;
- }
-
- // Move `i`-th element one place to the right, thus shifting the hole to the left.
- ptr::copy_nonoverlapping(v.add(i), v.add(i + 1), 1);
- hole.dest = v.add(i);
- }
- // `hole` gets dropped and thus copies `tmp` into the remaining hole in `v`.
- }
- }
-}
-
-/// Partially sorts a slice by shifting several out-of-order elements around.
-///
-/// Returns `true` if the slice is sorted at the end. This function is *O*(*n*) worst-case.
-#[cold]
-fn partial_insertion_sort<T, F>(v: &mut [T], is_less: &F) -> bool
-where
- F: Fn(&T, &T) -> bool,
-{
- // Maximum number of adjacent out-of-order pairs that will get shifted.
- const MAX_STEPS: usize = 5;
- // If the slice is shorter than this, don't shift any elements.
- const SHORTEST_SHIFTING: usize = 50;
-
- let len = v.len();
- let mut i = 1;
-
- for _ in 0..MAX_STEPS {
- // SAFETY: We already explicitly did the bound checking with `i < len`.
- // All our subsequent indexing is only in the range `0 <= index < len`
- unsafe {
- // Find the next pair of adjacent out-of-order elements.
- while i < len && !is_less(v.get_unchecked(i), v.get_unchecked(i - 1)) {
- i += 1;
- }
- }
-
- // Are we done?
- if i == len {
- return true;
- }
-
- // Don't shift elements on short arrays, that has a performance cost.
- if len < SHORTEST_SHIFTING {
- return false;
- }
-
- // Swap the found pair of elements. This puts them in correct order.
- v.swap(i - 1, i);
-
- // Shift the smaller element to the left.
- shift_tail(&mut v[..i], is_less);
- // Shift the greater element to the right.
- shift_head(&mut v[i..], is_less);
- }
-
- // Didn't manage to sort the slice in the limited number of steps.
- false
-}
-
-/// Sorts a slice using insertion sort, which is *O*(*n*^2) worst-case.
-fn insertion_sort<T, F>(v: &mut [T], is_less: &F)
-where
- F: Fn(&T, &T) -> bool,
-{
- for i in 1..v.len() {
- shift_tail(&mut v[..i + 1], is_less);
- }
-}
-
-/// Sorts `v` using heapsort, which guarantees *O*(*n* \* log(*n*)) worst-case.
-#[cold]
-fn heapsort<T, F>(v: &mut [T], is_less: &F)
-where
- F: Fn(&T, &T) -> bool,
-{
- // This binary heap respects the invariant `parent >= child`.
- let sift_down = |v: &mut [T], mut node| {
- loop {
- // Children of `node`.
- let mut child = 2 * node + 1;
- if child >= v.len() {
- break;
- }
-
- // Choose the greater child.
- if child + 1 < v.len() && is_less(&v[child], &v[child + 1]) {
- child += 1;
- }
-
- // Stop if the invariant holds at `node`.
- if !is_less(&v[node], &v[child]) {
- break;
- }
-
- // Swap `node` with the greater child, move one step down, and continue sifting.
- v.swap(node, child);
- node = child;
- }
- };
-
- // Build the heap in linear time.
- for i in (0..v.len() / 2).rev() {
- sift_down(v, i);
- }
-
- // Pop maximal elements from the heap.
- for i in (1..v.len()).rev() {
- v.swap(0, i);
- sift_down(&mut v[..i], 0);
- }
-}
-
-/// Partitions `v` into elements smaller than `pivot`, followed by elements greater than or equal
-/// to `pivot`.
-///
-/// Returns the number of elements smaller than `pivot`.
-///
-/// Partitioning is performed block-by-block in order to minimize the cost of branching operations.
-/// This idea is presented in the [BlockQuicksort][pdf] paper.
-///
-/// [pdf]: https://drops.dagstuhl.de/opus/volltexte/2016/6389/pdf/LIPIcs-ESA-2016-38.pdf
-fn partition_in_blocks<T, F>(v: &mut [T], pivot: &T, is_less: &F) -> usize
-where
- F: Fn(&T, &T) -> bool,
-{
- // Number of elements in a typical block.
- const BLOCK: usize = 128;
-
- // The partitioning algorithm repeats the following steps until completion:
- //
- // 1. Trace a block from the left side to identify elements greater than or equal to the pivot.
- // 2. Trace a block from the right side to identify elements smaller than the pivot.
- // 3. Exchange the identified elements between the left and right side.
- //
- // We keep the following variables for a block of elements:
- //
- // 1. `block` - Number of elements in the block.
- // 2. `start` - Start pointer into the `offsets` array.
- // 3. `end` - End pointer into the `offsets` array.
- // 4. `offsets - Indices of out-of-order elements within the block.
-
- // The current block on the left side (from `l` to `l.add(block_l)`).
- let mut l = v.as_mut_ptr();
- let mut block_l = BLOCK;
- let mut start_l = ptr::null_mut();
- let mut end_l = ptr::null_mut();
- let mut offsets_l = [MaybeUninit::<u8>::uninit(); BLOCK];
-
- // The current block on the right side (from `r.sub(block_r)` to `r`).
- // SAFETY: The documentation for .add() specifically mention that `vec.as_ptr().add(vec.len())` is always safe`
- let mut r = unsafe { l.add(v.len()) };
- let mut block_r = BLOCK;
- let mut start_r = ptr::null_mut();
- let mut end_r = ptr::null_mut();
- let mut offsets_r = [MaybeUninit::<u8>::uninit(); BLOCK];
-
- // FIXME: When we get VLAs, try creating one array of length `min(v.len(), 2 * BLOCK)` rather
- // than two fixed-size arrays of length `BLOCK`. VLAs might be more cache-efficient.
-
- // Returns the number of elements between pointers `l` (inclusive) and `r` (exclusive).
- fn width<T>(l: *mut T, r: *mut T) -> usize {
- assert!(mem::size_of::<T>() > 0);
- // FIXME: this should *likely* use `offset_from`, but more
- // investigation is needed (including running tests in miri).
- // TODO unstable: (r.addr() - l.addr()) / mem::size_of::<T>()
- (r as usize - l as usize) / mem::size_of::<T>()
- }
-
- loop {
- // We are done with partitioning block-by-block when `l` and `r` get very close. Then we do
- // some patch-up work in order to partition the remaining elements in between.
- let is_done = width(l, r) <= 2 * BLOCK;
-
- if is_done {
- // Number of remaining elements (still not compared to the pivot).
- let mut rem = width(l, r);
- if start_l < end_l || start_r < end_r {
- rem -= BLOCK;
- }
-
- // Adjust block sizes so that the left and right block don't overlap, but get perfectly
- // aligned to cover the whole remaining gap.
- if start_l < end_l {
- block_r = rem;
- } else if start_r < end_r {
- block_l = rem;
- } else {
- // There were the same number of elements to switch on both blocks during the last
- // iteration, so there are no remaining elements on either block. Cover the remaining
- // items with roughly equally-sized blocks.
- block_l = rem / 2;
- block_r = rem - block_l;
- }
- debug_assert!(block_l <= BLOCK && block_r <= BLOCK);
- debug_assert!(width(l, r) == block_l + block_r);
- }
-
- if start_l == end_l {
- // Trace `block_l` elements from the left side.
- // TODO unstable: start_l = MaybeUninit::slice_as_mut_ptr(&mut offsets_l);
- start_l = offsets_l.as_mut_ptr() as *mut u8;
- end_l = start_l;
- let mut elem = l;
-
- for i in 0..block_l {
- // SAFETY: The unsafety operations below involve the usage of the `offset`.
- // According to the conditions required by the function, we satisfy them because:
- // 1. `offsets_l` is stack-allocated, and thus considered separate allocated object.
- // 2. The function `is_less` returns a `bool`.
- // Casting a `bool` will never overflow `isize`.
- // 3. We have guaranteed that `block_l` will be `<= BLOCK`.
- // Plus, `end_l` was initially set to the begin pointer of `offsets_` which was declared on the stack.
- // Thus, we know that even in the worst case (all invocations of `is_less` returns false) we will only be at most 1 byte pass the end.
- // Another unsafety operation here is dereferencing `elem`.
- // However, `elem` was initially the begin pointer to the slice which is always valid.
- unsafe {
- // Branchless comparison.
- *end_l = i as u8;
- end_l = end_l.offset(!is_less(&*elem, pivot) as isize);
- elem = elem.offset(1);
- }
- }
- }
-
- if start_r == end_r {
- // Trace `block_r` elements from the right side.
- // TODO unstable: start_r = MaybeUninit::slice_as_mut_ptr(&mut offsets_r);
- start_r = offsets_r.as_mut_ptr() as *mut u8;
- end_r = start_r;
- let mut elem = r;
-
- for i in 0..block_r {
- // SAFETY: The unsafety operations below involve the usage of the `offset`.
- // According to the conditions required by the function, we satisfy them because:
- // 1. `offsets_r` is stack-allocated, and thus considered separate allocated object.
- // 2. The function `is_less` returns a `bool`.
- // Casting a `bool` will never overflow `isize`.
- // 3. We have guaranteed that `block_r` will be `<= BLOCK`.
- // Plus, `end_r` was initially set to the begin pointer of `offsets_` which was declared on the stack.
- // Thus, we know that even in the worst case (all invocations of `is_less` returns true) we will only be at most 1 byte pass the end.
- // Another unsafety operation here is dereferencing `elem`.
- // However, `elem` was initially `1 * sizeof(T)` past the end and we decrement it by `1 * sizeof(T)` before accessing it.
- // Plus, `block_r` was asserted to be less than `BLOCK` and `elem` will therefore at most be pointing to the beginning of the slice.
- unsafe {
- // Branchless comparison.
- elem = elem.offset(-1);
- *end_r = i as u8;
- end_r = end_r.offset(is_less(&*elem, pivot) as isize);
- }
- }
- }
-
- // Number of out-of-order elements to swap between the left and right side.
- let count = cmp::min(width(start_l, end_l), width(start_r, end_r));
-
- if count > 0 {
- macro_rules! left {
- () => {
- l.offset(*start_l as isize)
- };
- }
- macro_rules! right {
- () => {
- r.offset(-(*start_r as isize) - 1)
- };
- }
-
- // Instead of swapping one pair at the time, it is more efficient to perform a cyclic
- // permutation. This is not strictly equivalent to swapping, but produces a similar
- // result using fewer memory operations.
-
- // SAFETY: The use of `ptr::read` is valid because there is at least one element in
- // both `offsets_l` and `offsets_r`, so `left!` is a valid pointer to read from.
- //
- // The uses of `left!` involve calls to `offset` on `l`, which points to the
- // beginning of `v`. All the offsets pointed-to by `start_l` are at most `block_l`, so
- // these `offset` calls are safe as all reads are within the block. The same argument
- // applies for the uses of `right!`.
- //
- // The calls to `start_l.offset` are valid because there are at most `count-1` of them,
- // plus the final one at the end of the unsafe block, where `count` is the minimum number
- // of collected offsets in `offsets_l` and `offsets_r`, so there is no risk of there not
- // being enough elements. The same reasoning applies to the calls to `start_r.offset`.
- //
- // The calls to `copy_nonoverlapping` are safe because `left!` and `right!` are guaranteed
- // not to overlap, and are valid because of the reasoning above.
- unsafe {
- let tmp = ptr::read(left!());
- ptr::copy_nonoverlapping(right!(), left!(), 1);
-
- for _ in 1..count {
- start_l = start_l.offset(1);
- ptr::copy_nonoverlapping(left!(), right!(), 1);
- start_r = start_r.offset(1);
- ptr::copy_nonoverlapping(right!(), left!(), 1);
- }
-
- ptr::copy_nonoverlapping(&tmp, right!(), 1);
- mem::forget(tmp);
- start_l = start_l.offset(1);
- start_r = start_r.offset(1);
- }
- }
-
- if start_l == end_l {
- // All out-of-order elements in the left block were moved. Move to the next block.
-
- // block-width-guarantee
- // SAFETY: if `!is_done` then the slice width is guaranteed to be at least `2*BLOCK` wide. There
- // are at most `BLOCK` elements in `offsets_l` because of its size, so the `offset` operation is
- // safe. Otherwise, the debug assertions in the `is_done` case guarantee that
- // `width(l, r) == block_l + block_r`, namely, that the block sizes have been adjusted to account
- // for the smaller number of remaining elements.
- l = unsafe { l.add(block_l) };
- }
-
- if start_r == end_r {
- // All out-of-order elements in the right block were moved. Move to the previous block.
-
- // SAFETY: Same argument as [block-width-guarantee]. Either this is a full block `2*BLOCK`-wide,
- // or `block_r` has been adjusted for the last handful of elements.
- r = unsafe { r.offset(-(block_r as isize)) };
- }
-
- if is_done {
- break;
- }
- }
-
- // All that remains now is at most one block (either the left or the right) with out-of-order
- // elements that need to be moved. Such remaining elements can be simply shifted to the end
- // within their block.
-
- if start_l < end_l {
- // The left block remains.
- // Move its remaining out-of-order elements to the far right.
- debug_assert_eq!(width(l, r), block_l);
- while start_l < end_l {
- // remaining-elements-safety
- // SAFETY: while the loop condition holds there are still elements in `offsets_l`, so it
- // is safe to point `end_l` to the previous element.
- //
- // The `ptr::swap` is safe if both its arguments are valid for reads and writes:
- // - Per the debug assert above, the distance between `l` and `r` is `block_l`
- // elements, so there can be at most `block_l` remaining offsets between `start_l`
- // and `end_l`. This means `r` will be moved at most `block_l` steps back, which
- // makes the `r.offset` calls valid (at that point `l == r`).
- // - `offsets_l` contains valid offsets into `v` collected during the partitioning of
- // the last block, so the `l.offset` calls are valid.
- unsafe {
- end_l = end_l.offset(-1);
- ptr::swap(l.offset(*end_l as isize), r.offset(-1));
- r = r.offset(-1);
- }
- }
- width(v.as_mut_ptr(), r)
- } else if start_r < end_r {
- // The right block remains.
- // Move its remaining out-of-order elements to the far left.
- debug_assert_eq!(width(l, r), block_r);
- while start_r < end_r {
- // SAFETY: See the reasoning in [remaining-elements-safety].
- unsafe {
- end_r = end_r.offset(-1);
- ptr::swap(l, r.offset(-(*end_r as isize) - 1));
- l = l.offset(1);
- }
- }
- width(v.as_mut_ptr(), l)
- } else {
- // Nothing else to do, we're done.
- width(v.as_mut_ptr(), l)
- }
-}
-
-/// Partitions `v` into elements smaller than `v[pivot]`, followed by elements greater than or
-/// equal to `v[pivot]`.
-///
-/// Returns a tuple of:
-///
-/// 1. Number of elements smaller than `v[pivot]`.
-/// 2. True if `v` was already partitioned.
-fn partition<T, F>(v: &mut [T], pivot: usize, is_less: &F) -> (usize, bool)
-where
- F: Fn(&T, &T) -> bool,
-{
- let (mid, was_partitioned) = {
- // Place the pivot at the beginning of slice.
- v.swap(0, pivot);
- let (pivot, v) = v.split_at_mut(1);
- let pivot = &mut pivot[0];
-
- // Read the pivot into a stack-allocated variable for efficiency. If a following comparison
- // operation panics, the pivot will be automatically written back into the slice.
-
- // SAFETY: `pivot` is a reference to the first element of `v`, so `ptr::read` is safe.
- let tmp = mem::ManuallyDrop::new(unsafe { ptr::read(pivot) });
- let _pivot_guard = CopyOnDrop {
- src: &*tmp,
- dest: pivot,
- };
- let pivot = &*tmp;
-
- // Find the first pair of out-of-order elements.
- let mut l = 0;
- let mut r = v.len();
-
- // SAFETY: The unsafety below involves indexing an array.
- // For the first one: We already do the bounds checking here with `l < r`.
- // For the second one: We initially have `l == 0` and `r == v.len()` and we checked that `l < r` at every indexing operation.
- // From here we know that `r` must be at least `r == l` which was shown to be valid from the first one.
- unsafe {
- // Find the first element greater than or equal to the pivot.
- while l < r && is_less(v.get_unchecked(l), pivot) {
- l += 1;
- }
-
- // Find the last element smaller that the pivot.
- while l < r && !is_less(v.get_unchecked(r - 1), pivot) {
- r -= 1;
- }
- }
-
- (
- l + partition_in_blocks(&mut v[l..r], pivot, is_less),
- l >= r,
- )
-
- // `_pivot_guard` goes out of scope and writes the pivot (which is a stack-allocated
- // variable) back into the slice where it originally was. This step is critical in ensuring
- // safety!
- };
-
- // Place the pivot between the two partitions.
- v.swap(0, mid);
-
- (mid, was_partitioned)
-}
-
-/// Partitions `v` into elements equal to `v[pivot]` followed by elements greater than `v[pivot]`.
-///
-/// Returns the number of elements equal to the pivot. It is assumed that `v` does not contain
-/// elements smaller than the pivot.
-fn partition_equal<T, F>(v: &mut [T], pivot: usize, is_less: &F) -> usize
-where
- F: Fn(&T, &T) -> bool,
-{
- // Place the pivot at the beginning of slice.
- v.swap(0, pivot);
- let (pivot, v) = v.split_at_mut(1);
- let pivot = &mut pivot[0];
-
- // Read the pivot into a stack-allocated variable for efficiency. If a following comparison
- // operation panics, the pivot will be automatically written back into the slice.
- // SAFETY: The pointer here is valid because it is obtained from a reference to a slice.
- let tmp = mem::ManuallyDrop::new(unsafe { ptr::read(pivot) });
- let _pivot_guard = CopyOnDrop {
- src: &*tmp,
- dest: pivot,
- };
- let pivot = &*tmp;
-
- // Now partition the slice.
- let mut l = 0;
- let mut r = v.len();
- loop {
- // SAFETY: The unsafety below involves indexing an array.
- // For the first one: We already do the bounds checking here with `l < r`.
- // For the second one: We initially have `l == 0` and `r == v.len()` and we checked that `l < r` at every indexing operation.
- // From here we know that `r` must be at least `r == l` which was shown to be valid from the first one.
- unsafe {
- // Find the first element greater than the pivot.
- while l < r && !is_less(pivot, v.get_unchecked(l)) {
- l += 1;
- }
-
- // Find the last element equal to the pivot.
- while l < r && is_less(pivot, v.get_unchecked(r - 1)) {
- r -= 1;
- }
-
- // Are we done?
- if l >= r {
- break;
- }
-
- // Swap the found pair of out-of-order elements.
- r -= 1;
- let ptr = v.as_mut_ptr();
- ptr::swap(ptr.add(l), ptr.add(r));
- l += 1;
- }
- }
-
- // We found `l` elements equal to the pivot. Add 1 to account for the pivot itself.
- l + 1
-
- // `_pivot_guard` goes out of scope and writes the pivot (which is a stack-allocated variable)
- // back into the slice where it originally was. This step is critical in ensuring safety!
-}
-
-/// Scatters some elements around in an attempt to break patterns that might cause imbalanced
-/// partitions in quicksort.
-#[cold]
-fn break_patterns<T>(v: &mut [T]) {
- let len = v.len();
- if len >= 8 {
- // Pseudorandom number generator from the "Xorshift RNGs" paper by George Marsaglia.
- let mut random = len as u32;
- let mut gen_u32 = || {
- random ^= random << 13;
- random ^= random >> 17;
- random ^= random << 5;
- random
- };
- let mut gen_usize = || {
- if usize::BITS <= 32 {
- gen_u32() as usize
- } else {
- (((gen_u32() as u64) << 32) | (gen_u32() as u64)) as usize
- }
- };
-
- // Take random numbers modulo this number.
- // The number fits into `usize` because `len` is not greater than `isize::MAX`.
- let modulus = len.next_power_of_two();
-
- // Some pivot candidates will be in the nearby of this index. Let's randomize them.
- let pos = len / 4 * 2;
-
- for i in 0..3 {
- // Generate a random number modulo `len`. However, in order to avoid costly operations
- // we first take it modulo a power of two, and then decrease by `len` until it fits
- // into the range `[0, len - 1]`.
- let mut other = gen_usize() & (modulus - 1);
-
- // `other` is guaranteed to be less than `2 * len`.
- if other >= len {
- other -= len;
- }
-
- v.swap(pos - 1 + i, other);
- }
- }
-}
-
-/// Chooses a pivot in `v` and returns the index and `true` if the slice is likely already sorted.
-///
-/// Elements in `v` might be reordered in the process.
-fn choose_pivot<T, F>(v: &mut [T], is_less: &F) -> (usize, bool)
-where
- F: Fn(&T, &T) -> bool,
-{
- // Minimum length to choose the median-of-medians method.
- // Shorter slices use the simple median-of-three method.
- const SHORTEST_MEDIAN_OF_MEDIANS: usize = 50;
- // Maximum number of swaps that can be performed in this function.
- const MAX_SWAPS: usize = 4 * 3;
-
- let len = v.len();
-
- // Three indices near which we are going to choose a pivot.
- #[expect(clippy::identity_op)]
- let mut a = len / 4 * 1;
- let mut b = len / 4 * 2;
- let mut c = len / 4 * 3;
-
- // Counts the total number of swaps we are about to perform while sorting indices.
- let mut swaps = 0;
-
- if len >= 8 {
- // Swaps indices so that `v[a] <= v[b]`.
- // SAFETY: `len >= 8` so there are at least two elements in the neighborhoods of
- // `a`, `b` and `c`. This means the three calls to `sort_adjacent` result in
- // corresponding calls to `sort3` with valid 3-item neighborhoods around each
- // pointer, which in turn means the calls to `sort2` are done with valid
- // references. Thus the `v.get_unchecked` calls are safe, as is the `ptr::swap`
- // call.
- let mut sort2 = |a: &mut usize, b: &mut usize| unsafe {
- if is_less(v.get_unchecked(*b), v.get_unchecked(*a)) {
- ptr::swap(a, b);
- swaps += 1;
- }
- };
-
- // Swaps indices so that `v[a] <= v[b] <= v[c]`.
- let mut sort3 = |a: &mut usize, b: &mut usize, c: &mut usize| {
- sort2(a, b);
- sort2(b, c);
- sort2(a, b);
- };
-
- if len >= SHORTEST_MEDIAN_OF_MEDIANS {
- // Finds the median of `v[a - 1], v[a], v[a + 1]` and stores the index into `a`.
- let mut sort_adjacent = |a: &mut usize| {
- let tmp = *a;
- sort3(&mut (tmp - 1), a, &mut (tmp + 1));
- };
-
- // Find medians in the neighborhoods of `a`, `b`, and `c`.
- sort_adjacent(&mut a);
- sort_adjacent(&mut b);
- sort_adjacent(&mut c);
- }
-
- // Find the median among `a`, `b`, and `c`.
- sort3(&mut a, &mut b, &mut c);
- }
-
- if swaps < MAX_SWAPS {
- (b, swaps == 0)
- } else {
- // The maximum number of swaps was performed. Chances are the slice is descending or mostly
- // descending, so reversing will probably help sort it faster.
- v.reverse();
- (len - 1 - b, true)
- }
-}
-
-/// Sorts `v` recursively.
-///
-/// If the slice had a predecessor in the original array, it is specified as `pred`.
-///
-/// `limit` is the number of allowed imbalanced partitions before switching to `heapsort`. If zero,
-/// this function will immediately switch to heapsort.
-fn recurse<'a, T, F>(
- mut v: &'a mut [T],
- is_less: &F,
- mut pred: Option<&'a mut T>,
- mut limit: u32,
- canceled: &AtomicBool,
-) -> bool
-where
- T: Send,
- F: Fn(&T, &T) -> bool + Sync,
-{
- // Slices of up to this length get sorted using insertion sort.
- const MAX_INSERTION: usize = 20;
- // If both partitions are up to this length, we continue sequentially. This number is as small
- // as possible but so that the overhead of Rayon's task scheduling is still negligible.
- const MAX_SEQUENTIAL: usize = 2000;
-
- // True if the last partitioning was reasonably balanced.
- let mut was_balanced = true;
- // True if the last partitioning didn't shuffle elements (the slice was already partitioned).
- let mut was_partitioned = true;
-
- loop {
- let len = v.len();
-
- // Very short slices get sorted using insertion sort.
- if len <= MAX_INSERTION {
- insertion_sort(v, is_less);
- return false;
- }
-
- // If too many bad pivot choices were made, simply fall back to heapsort in order to
- // guarantee `O(n * log(n))` worst-case.
- if limit == 0 {
- heapsort(v, is_less);
- return false;
- }
-
- // If the last partitioning was imbalanced, try breaking patterns in the slice by shuffling
- // some elements around. Hopefully we'll choose a better pivot this time.
- if !was_balanced {
- break_patterns(v);
- limit -= 1;
- }
-
- // Choose a pivot and try guessing whether the slice is already sorted.
- let (pivot, likely_sorted) = choose_pivot(v, is_less);
-
- // If the last partitioning was decently balanced and didn't shuffle elements, and if pivot
- // selection predicts the slice is likely already sorted...
- if was_balanced && was_partitioned && likely_sorted {
- // Try identifying several out-of-order elements and shifting them to correct
- // positions. If the slice ends up being completely sorted, we're done.
- if partial_insertion_sort(v, is_less) {
- return false;
- }
- }
-
- // If the chosen pivot is equal to the predecessor, then it's the smallest element in the
- // slice. Partition the slice into elements equal to and elements greater than the pivot.
- // This case is usually hit when the slice contains many duplicate elements.
- if let Some(ref p) = pred {
- if !is_less(p, &v[pivot]) {
- let mid = partition_equal(v, pivot, is_less);
-
- // Continue sorting elements greater than the pivot.
- v = &mut v[mid..];
- continue;
- }
- }
-
- // Partition the slice.
- let (mid, was_p) = partition(v, pivot, is_less);
- was_balanced = cmp::min(mid, len - mid) >= len / 8;
- was_partitioned = was_p;
-
- // Split the slice into `left`, `pivot`, and `right`.
- let (left, right) = v.split_at_mut(mid);
- let (pivot, right) = right.split_at_mut(1);
- let pivot = &mut pivot[0];
-
- if cmp::max(left.len(), right.len()) <= MAX_SEQUENTIAL {
- // Recurse into the shorter side only in order to minimize the total number of recursive
- // calls and consume less stack space. Then just continue with the longer side (this is
- // akin to tail recursion).
- if left.len() < right.len() {
- recurse(left, is_less, pred, limit, canceled);
- v = right;
- pred = Some(pivot);
- } else {
- recurse(right, is_less, Some(pivot), limit, canceled);
- v = left;
- }
- } else if canceled.load(atomic::Ordering::Relaxed) {
- break true;
- } else {
- // Sort the left and right half in parallel.
- let (canceled1, canceled2) = rayon::join(
- || recurse(left, is_less, pred, limit, canceled),
- || recurse(right, is_less, Some(pivot), limit, canceled),
- );
- break canceled1 | canceled2;
- }
- }
-}
-
-/// Sorts `v` using pattern-defeating quicksort in parallel.
-///
-/// The algorithm is unstable, in-place, and *O*(*n* \* log(*n*)) worst-case.
-pub(crate) fn par_quicksort<T, F>(v: &mut [T], is_less: F, canceled: &AtomicBool) -> bool
-where
- T: Send,
- F: Fn(&T, &T) -> bool + Sync,
-{
- // Sorting has no meaningful behavior on zero-sized types.
- if mem::size_of::<T>() == 0 {
- return false;
- }
- if canceled.load(atomic::Ordering::Relaxed) {
- return true;
- }
-
- // Limit the number of imbalanced partitions to `floor(log2(len)) + 1`.
- let limit = usize::BITS - v.len().leading_zeros();
-
- recurse(v, &is_less, None, limit, canceled)
-}
diff --git a/crates/atuin-nucleo/src/pattern.rs b/crates/atuin-nucleo/src/pattern.rs
deleted file mode 100644
index a9663274..00000000
--- a/crates/atuin-nucleo/src/pattern.rs
+++ /dev/null
@@ -1,100 +0,0 @@
-pub use atuin_nucleo_matcher::pattern::{Atom, AtomKind, CaseMatching, Normalization, Pattern};
-use atuin_nucleo_matcher::{Matcher, Utf32String};
-
-#[cfg(test)]
-mod tests;
-
-#[derive(Debug, PartialEq, Eq, Clone, Copy, PartialOrd, Ord, Default)]
-pub(crate) enum Status {
- #[default]
- Unchanged,
- Update,
- Rescore,
-}
-
-#[derive(Debug)]
-pub struct MultiPattern {
- cols: Vec<(Pattern, Status)>,
-}
-
-impl Clone for MultiPattern {
- fn clone(&self) -> Self {
- Self {
- cols: self.cols.clone(),
- }
- }
-
- fn clone_from(&mut self, source: &Self) {
- self.cols.clone_from(&source.cols)
- }
-}
-
-impl MultiPattern {
- /// Creates a multi pattern with `columns` empty column patterns.
- pub fn new(columns: usize) -> Self {
- Self {
- cols: vec![Default::default(); columns],
- }
- }
-
- /// Reparses a column. By specifying `append` the caller promises that text passed
- /// to the previous `reparse` invocation is a prefix of `new_text`. This enables
- /// additional optimizations but can lead to missing matches if an incorrect value
- /// is passed.
- pub fn reparse(
- &mut self,
- column: usize,
- new_text: &str,
- case_matching: CaseMatching,
- normalization: Normalization,
- append: bool,
- ) {
- let old_status = self.cols[column].1;
- if append
- && old_status != Status::Rescore
- && self.cols[column]
- .0
- .atoms
- .last()
- .is_none_or(|last| !last.negative)
- {
- self.cols[column].1 = Status::Update;
- } else {
- self.cols[column].1 = Status::Rescore;
- }
- self.cols[column]
- .0
- .reparse(new_text, case_matching, normalization);
- }
-
- pub fn column_pattern(&self, column: usize) -> &Pattern {
- &self.cols[column].0
- }
-
- pub(crate) fn status(&self) -> Status {
- self.cols
- .iter()
- .map(|&(_, status)| status)
- .max()
- .unwrap_or(Status::Unchanged)
- }
-
- pub(crate) fn reset_status(&mut self) {
- for (_, status) in &mut self.cols {
- *status = Status::Unchanged
- }
- }
-
- pub fn score(&self, haystack: &[Utf32String], matcher: &mut Matcher) -> Option<u32> {
- // TODO: weight columns?
- let mut score = 0;
- for ((pattern, _), haystack) in self.cols.iter().zip(haystack) {
- score += pattern.score(haystack.slice(..), matcher)?
- }
- Some(score)
- }
-
- pub fn is_empty(&self) -> bool {
- self.cols.iter().all(|(pat, _)| pat.atoms.is_empty())
- }
-}
diff --git a/crates/atuin-nucleo/src/pattern/tests.rs b/crates/atuin-nucleo/src/pattern/tests.rs
deleted file mode 100644
index 59ed13f0..00000000
--- a/crates/atuin-nucleo/src/pattern/tests.rs
+++ /dev/null
@@ -1,14 +0,0 @@
-use atuin_nucleo_matcher::pattern::{CaseMatching, Normalization};
-
-use crate::pattern::{MultiPattern, Status};
-
-#[test]
-fn append() {
- let mut pat = MultiPattern::new(1);
- pat.reparse(0, "!", CaseMatching::Smart, Normalization::Smart, true);
- assert_eq!(pat.status(), Status::Update);
- pat.reparse(0, "!f", CaseMatching::Smart, Normalization::Smart, true);
- assert_eq!(pat.status(), Status::Update);
- pat.reparse(0, "!fo", CaseMatching::Smart, Normalization::Smart, true);
- assert_eq!(pat.status(), Status::Rescore);
-}
diff --git a/crates/atuin-nucleo/src/tests.rs b/crates/atuin-nucleo/src/tests.rs
deleted file mode 100644
index 1052264a..00000000
--- a/crates/atuin-nucleo/src/tests.rs
+++ /dev/null
@@ -1,267 +0,0 @@
-use std::sync::Arc;
-
-use atuin_nucleo_matcher::Config;
-
-use crate::{pattern, Nucleo};
-
-#[test]
-fn active_injector_count() {
- let mut nucleo: Nucleo<()> = Nucleo::new(Config::DEFAULT, Arc::new(|| ()), Some(1), 1);
- assert_eq!(nucleo.active_injectors(), 0);
- let injector = nucleo.injector();
- assert_eq!(nucleo.active_injectors(), 1);
- let injector2 = nucleo.injector();
- assert_eq!(nucleo.active_injectors(), 2);
- drop(injector2);
- assert_eq!(nucleo.active_injectors(), 1);
- nucleo.restart(false);
- assert_eq!(nucleo.active_injectors(), 0);
- let injector3 = nucleo.injector();
- assert_eq!(nucleo.active_injectors(), 1);
- nucleo.tick(0);
- assert_eq!(nucleo.active_injectors(), 1);
- drop(injector);
- assert_eq!(nucleo.active_injectors(), 1);
- drop(injector3);
- assert_eq!(nucleo.active_injectors(), 0);
-}
-
-#[derive(Clone, Debug)]
-struct TestItem {
- text: String,
- category: u32,
- priority: u32,
-}
-
-#[test]
-fn filter_excludes_items() {
- let mut nucleo: Nucleo<TestItem> = Nucleo::new(Config::DEFAULT, Arc::new(|| ()), Some(1), 1);
- let injector = nucleo.injector();
-
- // Add items with different categories
- injector.push(
- TestItem {
- text: "apple".into(),
- category: 1,
- priority: 10,
- },
- |item, cols| cols[0] = item.text.clone().into(),
- );
- injector.push(
- TestItem {
- text: "apricot".into(),
- category: 2,
- priority: 20,
- },
- |item, cols| cols[0] = item.text.clone().into(),
- );
- injector.push(
- TestItem {
- text: "avocado".into(),
- category: 1,
- priority: 30,
- },
- |item, cols| cols[0] = item.text.clone().into(),
- );
-
- // Search without filter - should get all 3
- nucleo.pattern.reparse(
- 0,
- "a",
- pattern::CaseMatching::Ignore,
- pattern::Normalization::Smart,
- false,
- );
- while nucleo.tick(10).running {}
- assert_eq!(nucleo.snapshot().matched_item_count(), 3);
-
- // Set filter to only include category 1
- nucleo.set_filter(Some(Arc::new(|item: &TestItem| item.category == 1)));
-
- // Search again - should only get 2 items (apple, avocado)
- while nucleo.tick(10).running {}
- assert_eq!(nucleo.snapshot().matched_item_count(), 2);
-
- // Verify the items are correct
- let items: Vec<_> = nucleo
- .snapshot()
- .matched_items(..)
- .map(|i| i.data.text.clone())
- .collect();
- assert!(items.contains(&"apple".to_string()));
- assert!(items.contains(&"avocado".to_string()));
- assert!(!items.contains(&"apricot".to_string()));
-
- // Remove filter - should get all 3 again
- nucleo.set_filter(None);
- while nucleo.tick(10).running {}
- assert_eq!(nucleo.snapshot().matched_item_count(), 3);
-}
-
-#[test]
-fn scorer_affects_sort_order() {
- let mut nucleo: Nucleo<TestItem> = Nucleo::new(Config::DEFAULT, Arc::new(|| ()), Some(1), 1);
- let injector = nucleo.injector();
-
- // Add items with different priorities
- injector.push(
- TestItem {
- text: "banana".into(),
- category: 1,
- priority: 10,
- },
- |item, cols| cols[0] = item.text.clone().into(),
- );
- injector.push(
- TestItem {
- text: "blueberry".into(),
- category: 1,
- priority: 100,
- },
- |item, cols| cols[0] = item.text.clone().into(),
- );
- injector.push(
- TestItem {
- text: "blackberry".into(),
- category: 1,
- priority: 50,
- },
- |item, cols| cols[0] = item.text.clone().into(),
- );
-
- // Search without scorer - results sorted by fuzzy score
- nucleo.pattern.reparse(
- 0,
- "b",
- pattern::CaseMatching::Ignore,
- pattern::Normalization::Smart,
- false,
- );
- while nucleo.tick(10).running {}
- assert_eq!(nucleo.snapshot().matched_item_count(), 3);
-
- // Set scorer that uses priority as the score (ignoring fuzzy score)
- nucleo.set_scorer(Some(Arc::new(|item: &TestItem, _fuzzy_score| {
- item.priority
- })));
-
- // Search again - should be sorted by priority (high to low)
- while nucleo.tick(10).running {}
- let items: Vec<_> = nucleo
- .snapshot()
- .matched_items(..)
- .map(|i| i.data.clone())
- .collect();
- assert_eq!(items.len(), 3);
- assert_eq!(items[0].text, "blueberry"); // priority 100
- assert_eq!(items[1].text, "blackberry"); // priority 50
- assert_eq!(items[2].text, "banana"); // priority 10
-
- // Verify external_score is set correctly
- let matches = nucleo.snapshot().matches();
- assert_eq!(matches[0].external_score, 100);
- assert_eq!(matches[1].external_score, 50);
- assert_eq!(matches[2].external_score, 10);
-}
-
-#[test]
-fn filter_and_scorer_combined() {
- let mut nucleo: Nucleo<TestItem> = Nucleo::new(Config::DEFAULT, Arc::new(|| ()), Some(1), 1);
- let injector = nucleo.injector();
-
- injector.push(
- TestItem {
- text: "cherry".into(),
- category: 1,
- priority: 10,
- },
- |item, cols| cols[0] = item.text.clone().into(),
- );
- injector.push(
- TestItem {
- text: "cranberry".into(),
- category: 2,
- priority: 100,
- },
- |item, cols| cols[0] = item.text.clone().into(),
- );
- injector.push(
- TestItem {
- text: "coconut".into(),
- category: 1,
- priority: 50,
- },
- |item, cols| cols[0] = item.text.clone().into(),
- );
-
- // Set both filter (category 1) and scorer (priority)
- nucleo.set_filter(Some(Arc::new(|item: &TestItem| item.category == 1)));
- nucleo.set_scorer(Some(Arc::new(|item: &TestItem, _| item.priority)));
-
- nucleo.pattern.reparse(
- 0,
- "c",
- pattern::CaseMatching::Ignore,
- pattern::Normalization::Smart,
- false,
- );
- while nucleo.tick(10).running {}
-
- // Should have 2 items (cherry, coconut) sorted by priority
- let items: Vec<_> = nucleo
- .snapshot()
- .matched_items(..)
- .map(|i| i.data.clone())
- .collect();
- assert_eq!(items.len(), 2);
- assert_eq!(items[0].text, "coconut"); // priority 50
- assert_eq!(items[1].text, "cherry"); // priority 10
-}
-
-#[test]
-fn scorer_combines_with_fuzzy_score() {
- let mut nucleo: Nucleo<TestItem> = Nucleo::new(Config::DEFAULT, Arc::new(|| ()), Some(1), 1);
- let injector = nucleo.injector();
-
- injector.push(
- TestItem {
- text: "date".into(),
- category: 1,
- priority: 100,
- },
- |item, cols| cols[0] = item.text.clone().into(),
- );
- injector.push(
- TestItem {
- text: "dragon fruit".into(),
- category: 1,
- priority: 10,
- },
- |item, cols| cols[0] = item.text.clone().into(),
- );
-
- // Set scorer that combines fuzzy score with priority
- nucleo.set_scorer(Some(Arc::new(|item: &TestItem, fuzzy_score| {
- fuzzy_score + item.priority
- })));
-
- nucleo.pattern.reparse(
- 0,
- "d",
- pattern::CaseMatching::Ignore,
- pattern::Normalization::Smart,
- false,
- );
- while nucleo.tick(10).running {}
-
- // Both items match, verify that external_score includes priority boost
- let matches = nucleo.snapshot().matches();
- assert_eq!(matches.len(), 2);
-
- // The raw fuzzy scores should be in Match.score
- // The combined scores should be in Match.external_score
- for m in matches {
- let item = nucleo.snapshot().get_item(m.idx).unwrap();
- assert_eq!(m.external_score, m.score + item.data.priority);
- }
-}
diff --git a/crates/atuin-nucleo/src/worker.rs b/crates/atuin-nucleo/src/worker.rs
deleted file mode 100644
index bebfa308..00000000
--- a/crates/atuin-nucleo/src/worker.rs
+++ /dev/null
@@ -1,408 +0,0 @@
-use std::cell::UnsafeCell;
-use std::mem::take;
-use std::sync::atomic::{self, AtomicBool, AtomicU32};
-use std::sync::Arc;
-
-use atuin_nucleo_matcher::Config;
-use parking_lot::Mutex;
-use rayon::{
- prelude::{IntoParallelRefMutIterator, ParallelExtend, ParallelIterator},
- ThreadPool,
-};
-
-use crate::par_sort::par_quicksort;
-use crate::pattern::{self, MultiPattern};
-use crate::{boxcar, Filter, Match, Scorer};
-
-struct Matchers(Box<[UnsafeCell<atuin_nucleo_matcher::Matcher>]>);
-
-impl Matchers {
- // this is not a true mut from ref, we use a cell here
- #[expect(clippy::mut_from_ref)]
- unsafe fn get(&self) -> &mut atuin_nucleo_matcher::Matcher {
- &mut *self.0[rayon::current_thread_index().unwrap()].get()
- }
-}
-
-unsafe impl Sync for Matchers {}
-unsafe impl Send for Matchers {}
-
-pub(crate) struct Worker<T: Sync + Send + 'static> {
- pub(crate) running: bool,
- matchers: Matchers,
- pub(crate) matches: Vec<Match>,
- pub(crate) pattern: MultiPattern,
- pub(crate) sort_results: bool,
- pub(crate) reverse_items: bool,
- pub(crate) canceled: Arc<AtomicBool>,
- pub(crate) should_notify: Arc<AtomicBool>,
- pub(crate) was_canceled: bool,
- pub(crate) last_snapshot: u32,
- notify: Arc<dyn Fn() + Sync + Send>,
- pub(crate) items: Arc<boxcar::Vec<T>>,
- in_flight: Vec<u32>,
- pub(crate) filter: Option<Filter<T>>,
- pub(crate) scorer: Option<Scorer<T>>,
-}
-
-impl<T: Sync + Send + 'static> Worker<T> {
- pub(crate) fn item_count(&self) -> u32 {
- self.last_snapshot - self.in_flight.len() as u32
- }
- pub(crate) fn update_config(&mut self, config: Config) {
- for matcher in self.matchers.0.iter_mut() {
- matcher.get_mut().config = config.clone();
- }
- }
- pub(crate) fn sort_results(&mut self, sort_results: bool) {
- self.sort_results = sort_results;
- }
- pub(crate) fn reverse_items(&mut self, reverse_items: bool) {
- self.reverse_items = reverse_items;
- }
-
- pub(crate) fn set_filter(&mut self, filter: Option<Filter<T>>) {
- self.filter = filter;
- }
-
- pub(crate) fn set_scorer(&mut self, scorer: Option<Scorer<T>>) {
- self.scorer = scorer;
- }
-
- pub(crate) fn new(
- worker_threads: Option<usize>,
- config: Config,
- notify: Arc<dyn Fn() + Sync + Send>,
- cols: u32,
- ) -> (ThreadPool, Self) {
- let worker_threads = worker_threads
- .unwrap_or_else(|| std::thread::available_parallelism().map_or(4, |it| it.get()));
- let pool = rayon::ThreadPoolBuilder::new()
- .thread_name(|i| format!("nucleo worker {i}"))
- .num_threads(worker_threads)
- .build()
- .expect("creating threadpool failed");
- let matchers = (0..worker_threads)
- .map(|_| UnsafeCell::new(atuin_nucleo_matcher::Matcher::new(config.clone())))
- .collect();
- let worker = Worker {
- running: false,
- matchers: Matchers(matchers),
- last_snapshot: 0,
- matches: Vec::new(),
- // just a placeholder
- pattern: MultiPattern::new(cols as usize),
- sort_results: true,
- reverse_items: false,
- canceled: Arc::new(AtomicBool::new(false)),
- should_notify: Arc::new(AtomicBool::new(false)),
- was_canceled: false,
- notify,
- items: Arc::new(boxcar::Vec::with_capacity(2 * 1024, cols)),
- in_flight: Vec::with_capacity(64),
- filter: None,
- scorer: None,
- };
- (pool, worker)
- }
-
- unsafe fn process_new_items(&mut self, unmatched: &AtomicU32) {
- let matchers = &self.matchers;
- let pattern = &self.pattern;
- self.matches.reserve(self.in_flight.len());
- self.in_flight.retain(|&idx| {
- let Some(item) = self.items.get(idx) else {
- return true;
- };
- // Apply filter if set
- if let Some(ref filter) = self.filter {
- if !filter(item.data) {
- return false; // Item is ready but filtered out
- }
- }
- if let Some(score) = pattern.score(item.matcher_columns, matchers.get()) {
- let external_score = match &self.scorer {
- Some(scorer) => scorer(item.data, score),
- None => score,
- };
- self.matches.push(Match {
- score,
- external_score,
- idx,
- });
- };
- false
- });
- let new_snapshot = self.items.par_snapshot(self.last_snapshot);
- if new_snapshot.end() != self.last_snapshot {
- let end = new_snapshot.end();
- let in_flight = Mutex::new(&mut self.in_flight);
- let items = new_snapshot.map(|(idx, item)| {
- let Some(item) = item else {
- in_flight.lock().push(idx);
- unmatched.fetch_add(1, atomic::Ordering::Relaxed);
- return Match {
- score: 0,
- external_score: 0,
- idx: u32::MAX,
- };
- };
- if self.canceled.load(atomic::Ordering::Relaxed) {
- return Match {
- score: 0,
- external_score: 0,
- idx,
- };
- }
- // Apply filter if set
- if let Some(ref filter) = self.filter {
- if !filter(item.data) {
- unmatched.fetch_add(1, atomic::Ordering::Relaxed);
- return Match {
- score: 0,
- external_score: 0,
- idx: u32::MAX,
- };
- }
- }
- let Some(score) = pattern.score(item.matcher_columns, matchers.get()) else {
- unmatched.fetch_add(1, atomic::Ordering::Relaxed);
- return Match {
- score: 0,
- external_score: 0,
- idx: u32::MAX,
- };
- };
- let external_score = match &self.scorer {
- Some(scorer) => scorer(item.data, score),
- None => score,
- };
- Match {
- score,
- external_score,
- idx,
- }
- });
- self.matches.par_extend(items);
- self.last_snapshot = end;
- }
- }
-
- fn remove_in_flight_matches(&mut self) {
- let mut off = 0;
- self.in_flight.retain(|&i| {
- let is_in_flight = self.items.get(i).is_none();
- if is_in_flight {
- self.matches.remove((i - off) as usize);
- off += 1;
- }
- is_in_flight
- });
- }
-
- unsafe fn process_new_items_trivial(&mut self) {
- let new_snapshot = self.items.snapshot(self.last_snapshot);
- if new_snapshot.end() != self.last_snapshot {
- let end = new_snapshot.end();
- let items = new_snapshot.filter_map(|(idx, item)| {
- let item = item?;
- // Apply filter if set
- if let Some(ref filter) = self.filter {
- if !filter(item.data) {
- return None;
- }
- }
- // For empty pattern, apply scorer with score=0 if set
- let external_score = match &self.scorer {
- Some(scorer) => scorer(item.data, 0),
- None => 0,
- };
- Some(Match {
- score: 0,
- external_score,
- idx,
- })
- });
- self.matches.extend(items);
- self.last_snapshot = end;
- }
- }
-
- pub(crate) unsafe fn run(&mut self, pattern_status: pattern::Status, cleared: bool) {
- self.running = true;
- self.was_canceled = false;
-
- if cleared {
- self.last_snapshot = 0;
- self.in_flight.clear();
- self.matches.clear();
- }
-
- // TODO: be smarter around reusing past results for rescoring
- if self.pattern.is_empty() {
- self.reset_matches();
- self.process_new_items_trivial();
- let canceled = self.sort_matches();
- if canceled {
- self.was_canceled = true;
- } else if self.should_notify.load(atomic::Ordering::Relaxed) {
- (self.notify)();
- }
- return;
- }
-
- if pattern_status == pattern::Status::Rescore {
- self.reset_matches();
- }
-
- let mut unmatched = AtomicU32::new(0);
- if pattern_status != pattern::Status::Unchanged && !self.matches.is_empty() {
- self.process_new_items_trivial();
- let matchers = &self.matchers;
- let pattern = &self.pattern;
- self.matches
- .par_iter_mut()
- .take_any_while(|_| !self.canceled.load(atomic::Ordering::Relaxed))
- .for_each(|match_| {
- if match_.idx == u32::MAX {
- debug_assert_eq!(match_.score, 0);
- unmatched.fetch_add(1, atomic::Ordering::Relaxed);
- return;
- }
- // safety: in-flight items are never added to the matches
- let item = self.items.get_unchecked(match_.idx);
- // Apply filter if set
- if let Some(ref filter) = self.filter {
- if !filter(item.data) {
- unmatched.fetch_add(1, atomic::Ordering::Relaxed);
- match_.score = 0;
- match_.external_score = 0;
- match_.idx = u32::MAX;
- return;
- }
- }
- if let Some(score) = pattern.score(item.matcher_columns, matchers.get()) {
- match_.score = score;
- match_.external_score = match &self.scorer {
- Some(scorer) => scorer(item.data, score),
- None => score,
- };
- } else {
- unmatched.fetch_add(1, atomic::Ordering::Relaxed);
- match_.score = 0;
- match_.external_score = 0;
- match_.idx = u32::MAX;
- }
- });
- } else {
- self.process_new_items(&unmatched);
- }
-
- let canceled = self.sort_matches();
- if canceled {
- self.was_canceled = true;
- } else {
- self.matches
- .truncate(self.matches.len() - take(unmatched.get_mut()) as usize);
- if self.should_notify.load(atomic::Ordering::Relaxed) {
- (self.notify)();
- }
- }
- }
-
- unsafe fn sort_matches(&mut self) -> bool {
- if self.sort_results {
- par_quicksort(
- &mut self.matches,
- |match1, match2| {
- // Primary sort: external_score (used for frecency/custom ranking)
- if match1.external_score != match2.external_score {
- return match1.external_score > match2.external_score;
- }
- if match1.idx == u32::MAX {
- return false;
- }
- if match2.idx == u32::MAX {
- return true;
- }
- // the tie breaker is comparatively rarely needed so we keep it
- // in a branch especially because we need to access the items
- // array here which involves some pointer chasing
- let item1 = self.items.get_unchecked(match1.idx);
- let item2 = &self.items.get_unchecked(match2.idx);
- let len1: u32 = item1
- .matcher_columns
- .iter()
- .map(|haystack| haystack.len() as u32)
- .sum();
- let len2 = item2
- .matcher_columns
- .iter()
- .map(|haystack| haystack.len() as u32)
- .sum();
- if len1 == len2 {
- if self.reverse_items {
- match2.idx < match1.idx
- } else {
- match1.idx < match2.idx
- }
- } else {
- len1 < len2
- }
- },
- &self.canceled,
- )
- } else {
- par_quicksort(
- &mut self.matches,
- |match1, match2| {
- if match1.idx == u32::MAX {
- return false;
- }
- if match2.idx == u32::MAX {
- return true;
- }
- if self.reverse_items {
- match2.idx < match1.idx
- } else {
- match1.idx < match2.idx
- }
- },
- &self.canceled,
- )
- }
- }
-
- fn reset_matches(&mut self) {
- self.matches.clear();
- // When resetting, apply filter if set
- if let Some(ref filter) = self.filter {
- for idx in 0..self.last_snapshot {
- // Items up to last_snapshot should be initialized
- if let Some(item) = self.items.get(idx) {
- if filter(item.data) {
- let external_score = match &self.scorer {
- Some(scorer) => scorer(item.data, 0),
- None => 0,
- };
- self.matches.push(Match {
- score: 0,
- external_score,
- idx,
- });
- }
- }
- }
- } else {
- // No filter - add all items
- self.matches
- .extend((0..self.last_snapshot).map(|idx| Match {
- score: 0,
- external_score: 0,
- idx,
- }));
- }
- // there are usually only very few in flight items (one for each writer)
- self.remove_in_flight_matches();
- }
-}
diff --git a/crates/atuin-nucleo/tarpaulin.toml b/crates/atuin-nucleo/tarpaulin.toml
deleted file mode 100644
index 4869f256..00000000
--- a/crates/atuin-nucleo/tarpaulin.toml
+++ /dev/null
@@ -1 +0,0 @@
-exclude = ["matcher/src/tests.rs", "matcher/src/debug.rs", "matcher/src/chars/normalize.rs"]
diff --git a/crates/atuin-nucleo/typos.toml b/crates/atuin-nucleo/typos.toml
deleted file mode 100644
index 14fc504d..00000000
--- a/crates/atuin-nucleo/typos.toml
+++ /dev/null
@@ -1,3 +0,0 @@
-default.extend-ignore-re = ["\\\\u\\{[0-9A-Za-z]*\\}"]
-[files]
-extend-exclude = ["matcher/src/tests.rs","src/pattern/tests.rs", "*.html"]
diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml
index 26ee6771..6d26a282 100644
--- a/crates/client/Cargo.toml
+++ b/crates/client/Cargo.toml
@@ -13,8 +13,6 @@ repository = { workspace = true }
[dependencies]
async-trait = "0.1.58"
-atuin-nucleo-matcher = { workspace = true }
-atuin-nucleo = { workspace = true }
axum = "0.8"
base64 = "0.22"
clap = { version = "4.5.7", features = ["derive"] }
diff --git a/crates/client/src/atuin_client/mod.rs b/crates/client/src/atuin_client/mod.rs
index 851dfbdb..0678d8b7 100644
--- a/crates/client/src/atuin_client/mod.rs
+++ b/crates/client/src/atuin_client/mod.rs
@@ -1,12 +1,2 @@
-pub(crate) mod api_client;
-pub(crate) mod database;
-pub(crate) mod encryption;
-pub(crate) mod history;
-pub(crate) mod meta;
-pub(crate) mod ordering;
-pub(crate) mod record;
-pub(crate) mod secrets;
pub(crate) mod settings;
pub(crate) mod theme;
-
-mod utils;
diff --git a/crates/client/src/atuin_client/settings/meta.rs b/crates/client/src/atuin_client/settings/meta.rs
index cc5afcf7..7993ef6d 100644
--- a/crates/client/src/atuin_client/settings/meta.rs
+++ b/crates/client/src/atuin_client/settings/meta.rs
@@ -7,7 +7,7 @@ pub(crate) struct Settings {
impl Default for Settings {
fn default() -> Self {
- let dir = crate::atuin_common::utils::data_dir();
+ let dir = turtle_common::utils::data_dir();
let path = dir.join("meta.db");
Self {
diff --git a/crates/client/src/atuin_client/settings.rs b/crates/client/src/atuin_client/settings/mod.rs
index 9e14c4c8..0bddc09c 100644
--- a/crates/client/src/atuin_client/settings.rs
+++ b/crates/client/src/atuin_client/settings/mod.rs
@@ -6,8 +6,7 @@ use tokio::sync::OnceCell;
use tracing::info;
use uuid::Uuid;
-use crate::atuin_common::utils;
-use crate::{atuin_client::encryption::decode_key, atuin_common::record::HostId};
+use crate::aclient::encryption::decode_key;
use clap::ValueEnum;
use config::{
Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState,
@@ -18,10 +17,12 @@ use regex::RegexSet;
use serde::{Deserialize, Serialize};
use serde_with::DeserializeFromStr;
use time::{OffsetDateTime, UtcOffset, format_description::FormatItem, macros::format_description};
+use turtle_common::record::HostId;
+use turtle_common::utils;
static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
static META_CONFIG: OnceLock<(String, f64)> = OnceLock::new();
-static META_STORE: OnceCell<crate::atuin_client::meta::MetaStore> = OnceCell::const_new();
+static META_STORE: OnceCell<crate::aclient::meta::MetaStore> = OnceCell::const_new();
pub(crate) mod meta;
pub(crate) mod watcher;
@@ -852,7 +853,7 @@ impl Sync {
#[derive(Clone, Debug, Deserialize, Serialize)]
#[expect(clippy::struct_excessive_bools)]
-pub(crate) struct Settings {
+pub struct Settings {
pub(crate) data_dir: Option<String>,
pub(crate) dialect: Dialect,
pub(crate) timezone: Timezone,
@@ -938,13 +939,13 @@ pub(crate) struct Settings {
impl Settings {
// -- Meta store: lazily initialized on first access --
- pub(crate) async fn meta_store() -> Result<&'static crate::atuin_client::meta::MetaStore> {
+ pub(crate) async fn meta_store() -> Result<&'static crate::aclient::meta::MetaStore> {
META_STORE
.get_or_try_init(|| async {
let (db_path, timeout) = META_CONFIG.get().ok_or_else(|| {
eyre!("meta store config not set — Settings::new() has not been called")
})?;
- crate::atuin_client::meta::MetaStore::new(db_path, *timeout).await
+ crate::aclient::meta::MetaStore::new(db_path, *timeout).await
})
.await
}
@@ -1464,7 +1465,7 @@ mod tests {
assert_eq!(meta_db_path, custom_dir.join("meta.db").to_str().unwrap());
assert_eq!(
daemon_socket_path,
- crate::atuin_common::utils::runtime_dir()
+ turtle_common::utils::runtime_dir()
.join("atuin.sock")
.to_str()
.unwrap()
diff --git a/crates/client/src/atuin_client/settings/watcher.rs b/crates/client/src/atuin_client/settings/watcher.rs
index 5eec3692..01d20855 100644
--- a/crates/client/src/atuin_client/settings/watcher.rs
+++ b/crates/client/src/atuin_client/settings/watcher.rs
@@ -6,7 +6,7 @@
//! # Example
//!
//! ```no_run
-//! use crate::atuin_client::settings::watcher::global_settings_watcher;
+//! use crate::aclient::settings::watcher::global_settings_watcher;
//!
//! async fn example() -> eyre::Result<()> {
//! let watcher = global_settings_watcher()?;
@@ -96,7 +96,7 @@ impl SettingsWatcher {
/// Get the config file path.
fn config_path() -> PathBuf {
let config_dir = std::env::var("ATUIN_CONFIG_DIR")
- .map_or_else(|_| crate::atuin_common::utils::config_dir(), PathBuf::from);
+ .map_or_else(|_| turtle_common::utils::config_dir(), PathBuf::from);
config_dir.join("config.toml")
}
diff --git a/crates/client/src/command/client.rs b/crates/client/src/command/client.rs
index 6c0e70e8..42e72e21 100644
--- a/crates/client/src/command/client.rs
+++ b/crates/client/src/command/client.rs
@@ -1,19 +1,12 @@
-use std::fs::{self, OpenOptions};
+use std::fs::{self};
use std::path::{Path, PathBuf};
use clap::Subcommand;
use eyre::{Result, WrapErr};
-use tracing_subscriber::util::SubscriberInitExt;
-use tracing_appender::rolling::{RollingFileAppender, Rotation};
-use tracing_subscriber::{
- Layer, filter::EnvFilter, filter::LevelFilter, fmt, fmt::format::FmtSpan,
- prelude::__tracing_subscriber_SubscriberExt,
-};
+use tracing_subscriber::filter::EnvFilter;
-use crate::atuin_client::{
- database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings,
-};
+use crate::atuin_client::settings::Settings;
fn cleanup_old_logs(log_dir: &Path, prefix: &str, retention_days: u64) {
let cutoff = std::time::SystemTime::now()
@@ -44,13 +37,9 @@ fn cleanup_old_logs(log_dir: &Path, prefix: &str, retention_days: u64) {
}
mod config;
-mod daemon;
mod default_config;
mod history;
mod info;
-mod init;
-mod search;
-mod server;
mod stats;
mod store;
mod sync;
@@ -63,25 +52,14 @@ pub(crate) enum Cmd {
#[command(subcommand)]
History(history::Cmd),
- /// Interactive history search
- Search(search::Cmd),
-
#[command(subcommand)]
/// Request a sync or view sync status
Sync(sync::Cmd),
- /// Manage the atuin server
- #[command(subcommand)]
- Server(server::Cmd),
-
/// Manage the atuin data store
#[command(subcommand)]
Store(store::Cmd),
- /// Print Atuin's shell init script
- #[command()]
- Init(init::Cmd),
-
/// Information about dotfiles locations and ENV vars
#[command()]
Info,
@@ -93,10 +71,6 @@ pub(crate) enum Cmd {
/// Display a recap of your last year's history
Wrapped { year: Option<i32> },
- /// *Experimental* Manage the background daemon
- #[command()]
- Daemon(daemon::Cmd),
-
/// Print the default atuin configuration (config.toml)
#[command()]
DefaultConfig,
@@ -108,28 +82,11 @@ pub(crate) enum Cmd {
impl Cmd {
pub(crate) fn run(self) -> Result<()> {
- // Daemonize before creating the async runtime – fork() inside a live
- // tokio runtime corrupts its internal state.
- #[cfg(unix)]
- if let Self::Daemon(ref cmd) = self
- && cmd.should_daemonize()
- {
- daemon::daemonize_current_process()?;
- }
-
let mut runtime = tokio::runtime::Builder::new_current_thread();
let runtime = runtime.enable_all().build().unwrap();
- // Start the server before descending into the client-specific setup code.
- // We simply cannot setup settings or a theme on the server, because the client-specific
- // stuff will error out.
- let res = if let Self::Server(server) = self {
- runtime.block_on(server.run())
- } else {
- // For non-history commands, we want to initialize logging and the theme manager before
- // doing anything else. History commands are performance-sensitive and run before and after
- // every shell command, so we want to skip any unnecessary initialization for them.
+ let res = {
let settings = Settings::new().wrap_err("could not load client settings")?;
runtime.block_on(self.run_inner(settings))
@@ -140,7 +97,6 @@ impl Cmd {
res
}
- #[expect(clippy::too_many_lines)]
async fn run_inner(self, mut settings: Settings) -> Result<()> {
// ATUIN_LOG env var overrides config file level settings
let env_log_set = std::env::var("ATUIN_LOG").is_ok();
@@ -149,137 +105,6 @@ impl Cmd {
let base_filter =
EnvFilter::from_env("ATUIN_LOG").add_directive("sqlx_sqlite::regexp=off".parse()?);
- let is_interactive_search = matches!(&self, Self::Search(cmd) if cmd.is_interactive());
- // Use file-based logging for interactive search (TUI mode)
- let use_search_logging = is_interactive_search && settings.logs.search_enabled();
-
- // Use file-based logging for daemon
- let use_daemon_logging = matches!(&self, Self::Daemon(_)) && settings.logs.daemon_enabled();
-
- // Check if daemon should also log to console
- let daemon_show_logs = matches!(&self, Self::Daemon(cmd) if cmd.show_logs());
-
- // Set up span timing JSON logs if ATUIN_SPAN is set
- let span_path = std::env::var("ATUIN_SPAN").ok().map(|p| {
- if p.is_empty() {
- "atuin-spans.json".to_string()
- } else {
- p
- }
- });
-
- // Helper to create span timing layer
- macro_rules! make_span_layer {
- ($path:expr) => {{
- let span_file = OpenOptions::new()
- .create(true)
- .truncate(true)
- .write(true)
- .open($path)?;
- Some(
- fmt::layer()
- .json()
- .with_writer(span_file)
- .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE)
- .with_filter(LevelFilter::TRACE),
- )
- }};
- }
-
- // Build the subscriber with all configured layers
- if use_search_logging {
- let search_filename = settings.logs.search.file.clone();
- let log_dir = PathBuf::from(&settings.logs.dir);
- fs::create_dir_all(&log_dir)?;
-
- // Clean up old log files
- cleanup_old_logs(&log_dir, &search_filename, settings.logs.search_retention());
-
- let file_appender =
- RollingFileAppender::new(Rotation::DAILY, &log_dir, &search_filename);
-
- // Use config level unless ATUIN_LOG is set
- let filter = if env_log_set {
- base_filter
- } else {
- EnvFilter::default()
- .add_directive(settings.logs.search_level().as_directive().parse()?)
- .add_directive("sqlx_sqlite::regexp=off".parse()?)
- };
-
- let base = tracing_subscriber::registry().with(
- fmt::layer()
- .with_writer(file_appender)
- .with_ansi(false)
- .with_filter(filter),
- );
-
- match &span_path {
- Some(sp) => {
- base.with(make_span_layer!(sp)).init();
- }
- None => {
- base.init();
- }
- }
- } else if use_daemon_logging {
- let daemon_filename = settings.logs.daemon.file.clone();
- let log_dir = PathBuf::from(&settings.logs.dir);
- fs::create_dir_all(&log_dir)?;
-
- // Clean up old log files
- cleanup_old_logs(&log_dir, &daemon_filename, settings.logs.daemon_retention());
-
- let file_appender =
- RollingFileAppender::new(Rotation::DAILY, &log_dir, &daemon_filename);
-
- // Use config level unless ATUIN_LOG is set
- let file_filter = if env_log_set {
- base_filter
- } else {
- EnvFilter::default()
- .add_directive(settings.logs.daemon_level().as_directive().parse()?)
- .add_directive("sqlx_sqlite::regexp=off".parse()?)
- };
-
- let file_layer = fmt::layer()
- .with_writer(file_appender)
- .with_ansi(false)
- .with_filter(file_filter);
-
- // Optionally add console layer for --show-logs
- if daemon_show_logs {
- let console_filter = EnvFilter::from_env("ATUIN_LOG")
- .add_directive("sqlx_sqlite::regexp=off".parse()?);
-
- let console_layer = fmt::layer().with_filter(console_filter);
-
- let base = tracing_subscriber::registry()
- .with(file_layer)
- .with(console_layer);
-
- match &span_path {
- Some(sp) => {
- base.with(make_span_layer!(sp)).init();
- }
- None => {
- base.init();
- }
- }
- } else {
- let base = tracing_subscriber::registry().with(file_layer);
-
- match &span_path {
- Some(sp) => {
- base.with(make_span_layer!(sp)).init();
- }
- None => {
- base.init();
- }
- }
- }
- }
-
tracing::trace!(command = ?self, "client command");
// Skip initializing any databases for history
@@ -287,23 +112,12 @@ impl Cmd {
// runs
match self {
Self::History(history) => return history.run(&settings).await,
- Self::Init(init) => {
- init.run(&settings);
- return Ok(());
- }
Self::Config(config) => return config.run(&settings).await,
_ => {}
}
- let db_path = PathBuf::from(settings.db_path.as_str());
- let record_store_path = PathBuf::from(settings.record_store_path.as_str());
-
- let db = ClientSqlite::new(db_path, settings.local_timeout).await?;
- let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?;
-
match self {
Self::Stats(stats) => stats.run(&db, &settings).await,
- Self::Search(search) => search.run(db, &mut settings, sqlite_store).await,
Self::Wrapped { year } => wrapped::run(year, &db, &settings).await,
Self::Sync(sync) => sync.run(settings, &db, sqlite_store).await,
@@ -317,9 +131,7 @@ impl Cmd {
Ok(())
}
- Self::Daemon(cmd) => cmd.run(settings, sqlite_store, db).await,
-
- Self::History(_) | Self::Init(_) | Self::Config(_) | Self::Server(_) => {
+ Self::History(_) | Self::Config(_) => {
unreachable!()
}
}
diff --git a/crates/client/src/command/client/init.rs b/crates/client/src/command/client/init.rs
deleted file mode 100644
index 0db9b143..00000000
--- a/crates/client/src/command/client/init.rs
+++ /dev/null
@@ -1,129 +0,0 @@
-use crate::atuin_client::settings::Settings;
-use clap::{Parser, ValueEnum};
-
-mod bash;
-mod fish;
-mod powershell;
-mod xonsh;
-mod zsh;
-
-#[derive(Parser, Debug)]
-pub(crate) struct Cmd {
- shell: Shell,
-
- /// Disable the binding of CTRL-R to atuin
- #[clap(long)]
- disable_ctrl_r: bool,
-
- /// Disable the binding of the Up Arrow key to atuin
- #[clap(long)]
- disable_up_arrow: bool,
-
- /// Disable the binding of ? to Atuin AI
- #[clap(long)]
- disable_ai: bool,
-}
-
-#[derive(Clone, Copy, ValueEnum, Debug)]
-#[value(rename_all = "lower")]
-#[expect(clippy::enum_variant_names)]
-pub(crate) enum Shell {
- /// Zsh setup
- Zsh,
-
- /// Bash setup
- Bash,
-
- /// Fish setup
- Fish,
-
- /// Nu setup
- Nu,
-
- /// Xonsh setup
- Xonsh,
-
- /// PowerShell setup
- PowerShell,
-}
-
-impl Cmd {
- fn init_nu(&self) {
- let full = include_str!("../../shell/atuin.nu");
-
- println!("{full}");
-
- if std::env::var("ATUIN_NOBIND").is_err() {
- const BIND_CTRL_R: &str = r"$env.config = (
- $env.config | upsert keybindings (
- $env.config.keybindings
- | append {
- name: atuin
- modifier: control
- keycode: char_r
- mode: [emacs, vi_normal, vi_insert]
- event: { send: executehostcommand cmd: (_atuin_search_cmd) }
- }
- )
-)";
- const BIND_UP_ARROW: &str = r"
-$env.config = (
- $env.config | upsert keybindings (
- $env.config.keybindings
- | append {
- name: atuin
- modifier: none
- keycode: up
- mode: [emacs, vi_normal, vi_insert]
- event: {
- until: [
- {send: menuup}
- {send: executehostcommand cmd: (_atuin_search_cmd '--shell-up-key-binding') }
- ]
- }
- }
- )
-)
-";
- if !self.disable_ctrl_r {
- println!("{BIND_CTRL_R}");
- }
- if !self.disable_up_arrow {
- println!("{BIND_UP_ARROW}");
- }
- }
- }
-
- fn static_init(&self) {
- match self.shell {
- Shell::Zsh => {
- zsh::init_static(self.disable_up_arrow, self.disable_ctrl_r);
- }
- Shell::Bash => {
- bash::init_static(self.disable_up_arrow, self.disable_ctrl_r);
- }
- Shell::Fish => {
- fish::init_static(self.disable_up_arrow, self.disable_ctrl_r);
- }
- Shell::Nu => {
- self.init_nu();
- }
- Shell::Xonsh => {
- xonsh::init_static(self.disable_up_arrow, self.disable_ctrl_r);
- }
- Shell::PowerShell => {
- powershell::init_static(self.disable_up_arrow, self.disable_ctrl_r);
- }
- }
- }
-
- pub(crate) fn run(self, settings: &Settings) {
- if !settings.paths_ok() {
- eprintln!(
- "Atuin settings paths are broken. Disabling atuin shell hooks. Run `atuin doctor` to diagnose."
- );
- }
-
- self.static_init();
- }
-}
diff --git a/crates/client/src/command/client/init/bash.rs b/crates/client/src/command/client/init/bash.rs
deleted file mode 100644
index c16663e2..00000000
--- a/crates/client/src/command/client/init/bash.rs
+++ /dev/null
@@ -1,13 +0,0 @@
-pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
- let base = include_str!("../../../shell/atuin.bash");
-
- let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() {
- (false, false)
- } else {
- (!disable_ctrl_r, !disable_up_arrow)
- };
-
- println!("__atuin_bind_ctrl_r={bind_ctrl_r}");
- println!("__atuin_bind_up_arrow={bind_up_arrow}");
- println!("{base}");
-}
diff --git a/crates/client/src/command/client/init/fish.rs b/crates/client/src/command/client/init/fish.rs
deleted file mode 100644
index 0a992b9c..00000000
--- a/crates/client/src/command/client/init/fish.rs
+++ /dev/null
@@ -1,74 +0,0 @@
-fn print_bindings(
- indent: &str,
- disable_up_arrow: bool,
- disable_ctrl_r: bool,
- bind_ctrl_r: &str,
- bind_up_arrow: &str,
- bind_ctrl_r_ins: &str,
- bind_up_arrow_ins: &str,
-) {
- if !disable_ctrl_r {
- println!("{indent}{bind_ctrl_r}");
- }
- if !disable_up_arrow {
- println!("{indent}{bind_up_arrow}");
- }
-
- println!("{indent}if bind -M insert >/dev/null 2>&1");
- if !disable_ctrl_r {
- println!("{indent}{indent}{bind_ctrl_r_ins}");
- }
- if !disable_up_arrow {
- println!("{indent}{indent}{bind_up_arrow_ins}");
- }
- println!("{indent}end");
-}
-
-pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
- let indent = " ".repeat(4);
-
- let base = include_str!("../../../shell/atuin.fish");
-
- println!("{base}");
-
- if std::env::var("ATUIN_NOBIND").is_err() {
- println!("if string match -q '4.*' $version");
-
- // In fish 4.0 and above the option bind -k doesn't exist anymore,
- // instead we can use key names and modifiers directly.
- print_bindings(
- &indent,
- disable_up_arrow,
- disable_ctrl_r,
- "bind ctrl-r _atuin_search",
- "bind up _atuin_bind_up",
- "bind -M insert ctrl-r _atuin_search",
- "bind -M insert up _atuin_bind_up",
- );
-
- println!("else");
-
- // We keep these for compatibility with fish 3.x
- print_bindings(
- &indent,
- disable_up_arrow,
- disable_ctrl_r,
- r"bind \cr _atuin_search",
- &[
- r"bind -k up _atuin_bind_up",
- r"bind \eOA _atuin_bind_up",
- r"bind \e\[A _atuin_bind_up",
- ]
- .join("; "),
- r"bind -M insert \cr _atuin_search",
- &[
- r"bind -M insert -k up _atuin_bind_up",
- r"bind -M insert \eOA _atuin_bind_up",
- r"bind -M insert \e\[A _atuin_bind_up",
- ]
- .join("; "),
- );
-
- println!("end");
- }
-}
diff --git a/crates/client/src/command/client/init/powershell.rs b/crates/client/src/command/client/init/powershell.rs
deleted file mode 100644
index 94d89c67..00000000
--- a/crates/client/src/command/client/init/powershell.rs
+++ /dev/null
@@ -1,20 +0,0 @@
-pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
- let base = include_str!("../../../shell/atuin.ps1");
-
- let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() {
- (false, false)
- } else {
- (!disable_ctrl_r, !disable_up_arrow)
- };
-
- println!("{base}");
- println!(
- "Enable-AtuinSearchKeys -CtrlR {} -UpArrow {}",
- ps_bool(bind_ctrl_r),
- ps_bool(bind_up_arrow)
- );
-}
-
-fn ps_bool(value: bool) -> &'static str {
- if value { "$true" } else { "$false" }
-}
diff --git a/crates/client/src/command/client/init/xonsh.rs b/crates/client/src/command/client/init/xonsh.rs
deleted file mode 100644
index 25f867f7..00000000
--- a/crates/client/src/command/client/init/xonsh.rs
+++ /dev/null
@@ -1,19 +0,0 @@
-pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
- let base = include_str!("../../../shell/atuin.xsh");
-
- let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() {
- (false, false)
- } else {
- (!disable_ctrl_r, !disable_up_arrow)
- };
-
- println!(
- "_ATUIN_BIND_CTRL_R={}",
- if bind_ctrl_r { "True" } else { "False" }
- );
- println!(
- "_ATUIN_BIND_UP_ARROW={}",
- if bind_up_arrow { "True" } else { "False" }
- );
- println!("{base}");
-}
diff --git a/crates/client/src/command/client/init/zsh.rs b/crates/client/src/command/client/init/zsh.rs
deleted file mode 100644
index 96a817d0..00000000
--- a/crates/client/src/command/client/init/zsh.rs
+++ /dev/null
@@ -1,26 +0,0 @@
-pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
- let base = include_str!("../../../shell/atuin.zsh");
-
- println!("{base}");
-
- if std::env::var("ATUIN_NOBIND").is_err() {
- const BIND_CTRL_R: &str = r"bindkey -M emacs '^r' atuin-search
-bindkey -M viins '^r' atuin-search-viins
-bindkey -M vicmd '/' atuin-search";
-
- const BIND_UP_ARROW: &str = r"bindkey -M emacs '^[[A' atuin-up-search
-bindkey -M vicmd '^[[A' atuin-up-search-vicmd
-bindkey -M viins '^[[A' atuin-up-search-viins
-bindkey -M emacs '^[OA' atuin-up-search
-bindkey -M vicmd '^[OA' atuin-up-search-vicmd
-bindkey -M viins '^[OA' atuin-up-search-viins
-bindkey -M vicmd 'k' atuin-up-search-vicmd";
-
- if !disable_ctrl_r {
- println!("{BIND_CTRL_R}");
- }
- if !disable_up_arrow {
- println!("{BIND_UP_ARROW}");
- }
- }
-}
diff --git a/crates/client/src/command/client/search.rs b/crates/client/src/command/client/search.rs
deleted file mode 100644
index 359864cb..00000000
--- a/crates/client/src/command/client/search.rs
+++ /dev/null
@@ -1,366 +0,0 @@
-use std::fs::File;
-use std::io::{IsTerminal as _, Write, stderr, stdout};
-
-use crate::atuin_client::database::ClientSqlite;
-use crate::atuin_common::utils::{self, Escapable as _};
-use clap::Parser;
-use eyre::Result;
-
-use crate::atuin_client::{
- database::{OptFilters, current_context},
- encryption,
- history::{History, store::HistoryStore},
- record::sqlite_store::SqliteStore,
- settings::{FilterMode, KeymapMode, SearchMode, Settings, Timezone},
-};
-
-use super::history::ListMode;
-
-mod cursor;
-mod duration;
-mod engines;
-mod history_list;
-mod inspector;
-mod interactive;
-pub(crate) mod keybindings;
-
-pub(crate) use duration::format_duration_into;
-
-#[expect(clippy::struct_excessive_bools, clippy::struct_field_names)]
-#[derive(Parser, Debug)]
-pub(crate) struct Cmd {
- /// Filter search result by directory
- #[arg(long, short)]
- cwd: Option<String>,
-
- /// Exclude directory from results
- #[arg(long = "exclude-cwd")]
- exclude_cwd: Option<String>,
-
- /// Filter search result by exit code
- #[arg(long, short)]
- exit: Option<i64>,
-
- /// Exclude results with this exit code
- #[arg(long = "exclude-exit")]
- exclude_exit: Option<i64>,
-
- /// Only include results added before this date
- #[arg(long, short)]
- before: Option<String>,
-
- /// Only include results after this date
- #[arg(long)]
- after: Option<String>,
-
- /// How many entries to return at most
- #[arg(long)]
- limit: Option<i64>,
-
- /// Offset from the start of the results
- #[arg(long)]
- offset: Option<i64>,
-
- /// Open interactive search UI
- #[arg(long, short)]
- interactive: bool,
-
- /// Allow overriding filter mode over config
- #[arg(long = "filter-mode")]
- filter_mode: Option<FilterMode>,
-
- /// Allow overriding search mode over config
- #[arg(long = "search-mode")]
- search_mode: Option<SearchMode>,
-
- /// Marker argument used to inform atuin that it was invoked from a shell up-key binding (hidden from help to avoid confusion)
- #[arg(long = "shell-up-key-binding", hide = true)]
- shell_up_key_binding: bool,
-
- /// Notify the keymap at the shell's side
- #[arg(long = "keymap-mode", default_value = "auto")]
- keymap_mode: KeymapMode,
-
- /// Use human-readable formatting for time
- #[arg(long)]
- human: bool,
-
- #[arg(allow_hyphen_values = true)]
- query: Option<Vec<String>>,
-
- /// Show only the text of the command
- #[arg(long)]
- cmd_only: bool,
-
- /// Terminate the output with a null, for better multiline handling
- #[arg(long)]
- print0: bool,
-
- /// Delete anything matching this query. Will not print out the match
- #[arg(long)]
- delete: bool,
-
- /// Delete EVERYTHING!
- #[arg(long)]
- delete_it_all: bool,
-
- /// Reverse the order of results, oldest first
- #[arg(long, short)]
- reverse: bool,
-
- /// Display the command time in another timezone other than the configured default.
- ///
- /// This option takes one of the following kinds of values:
- /// - the special value "local" (or "l") which refers to the system time zone
- /// - an offset from UTC (e.g. "+9", "-2:30")
- #[arg(long, visible_alias = "tz")]
- #[arg(allow_hyphen_values = true)]
- // Clippy warns about `Option<Option<T>>`, but we suppress it because we need
- // this distinction for proper argument handling.
- #[expect(clippy::option_option)]
- timezone: Option<Option<Timezone>>,
-
- /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {time}, {exit} and
- /// {relativetime}.
- /// Example: --format "{time} - [{duration}] - {directory}$\t{command}"
- #[arg(long, short)]
- format: Option<String>,
-
- /// Set the maximum number of lines Atuin's interface should take up.
- #[arg(long = "inline-height")]
- inline_height: Option<u16>,
-
- /// Filter by author. Supports $all-user (non-agents), $all-agent, or literal names.
- /// Can be specified multiple times.
- #[arg(long)]
- author: Option<Vec<String>>,
-
- /// Include duplicate commands in the output (non-interactive only)
- #[arg(long)]
- include_duplicates: bool,
-
- /// File name to write the result to (hidden from help as this is meant to be used from a script)
- #[arg(long = "result-file", hide = true)]
- result_file: Option<String>,
-}
-
-impl Cmd {
- /// Returns true if this search command will run in interactive (TUI) mode
- pub(crate) fn is_interactive(&self) -> bool {
- self.interactive
- }
-
- // clippy: please write this instead
- // clippy: now it has too many lines
- // me: I'll do it later OKAY
- #[expect(clippy::too_many_lines)]
- pub(crate) async fn run(
- self,
- db: ClientSqlite,
- settings: &mut Settings,
- store: SqliteStore,
- ) -> Result<()> {
- let query = self.query.unwrap_or_else(|| {
- std::env::var("ATUIN_QUERY").map_or_else(
- |_| vec![],
- |query| query.split(' ').map(ToString::to_string).collect(),
- )
- });
-
- if (self.delete_it_all || self.delete) && self.limit.is_some() {
- // Because of how deletion is implemented, it will always delete all matches
- // and disregard the limit option. It is also not clear what deletion with a
- // limit would even mean. Deleting the LIMIT most recent entries that match
- // the search query would make sense, but that wouldn't match what's displayed
- // when running the equivalent search, but deleting those entries that are
- // displayed with the search would leave any duplicates of those lines which may
- // or may not have been intended to be deleted.
- eprintln!("\"--limit\" is not compatible with deletion.");
- return Ok(());
- }
-
- if self.delete && query.is_empty() {
- eprintln!(
- "Please specify a query to match the items you wish to delete. If you wish to delete all history, pass --delete-it-all"
- );
- return Ok(());
- }
-
- if self.delete_it_all && !query.is_empty() {
- eprintln!(
- "--delete-it-all will delete ALL of your history! It does not require a query."
- );
- return Ok(());
- }
-
- if let Some(search_mode) = self.search_mode {
- settings.search_mode = search_mode;
- }
- if let Some(filter_mode) = self.filter_mode {
- settings.filter_mode = Some(filter_mode);
- }
- if let Some(inline_height) = self.inline_height {
- settings.inline_height = inline_height;
- }
-
- settings.shell_up_key_binding = self.shell_up_key_binding;
-
- // `keymap_mode` specified in config.toml overrides the `--keymap-mode`
- // option specified in the keybindings.
- settings.keymap_mode = match settings.keymap_mode {
- KeymapMode::Auto => self.keymap_mode,
- value => value,
- };
- settings.keymap_mode_shell = self.keymap_mode;
-
- let encryption_key: [u8; 32] = encryption::load_key(settings)?.into();
-
- let host_id = Settings::host_id().await?;
- let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
-
- if self.interactive {
- let item = interactive::history(&query, settings, db, &history_store).await?;
-
- if let Some(result_file) = self.result_file {
- let mut file = File::create(result_file)?;
- write!(file, "{item}")?;
- } else if !stdout().is_terminal() {
- // stdout is not a terminal - likely command substitution like VAR=$(atuin search -i)
- // Write to stdout so it gets captured. This requires some care on Windows, as the current
- // console code page or `[Console]::OutputEncoding` on PowerShell may be different from UTF-8.
- println!("{item}");
- } else if stderr().is_terminal() {
- eprintln!("{}", item.escape_control());
- } else {
- eprintln!("{item}");
- }
- } else {
- let opt_filter = OptFilters {
- exit: self.exit,
- exclude_exit: self.exclude_exit,
- cwd: self.cwd,
- exclude_cwd: self.exclude_cwd,
- before: self.before,
- after: self.after,
- limit: self.limit,
- offset: self.offset,
- reverse: self.reverse,
- include_duplicates: self.include_duplicates,
- };
-
- let mut entries =
- run_non_interactive(settings, opt_filter.clone(), &query, &db).await?;
-
- if entries.is_empty() {
- std::process::exit(1)
- }
-
- // if we aren't deleting, print it all
- if self.delete || self.delete_it_all {
- // delete it
- // it only took me _years_ to add this
- // sorry
- while !entries.is_empty() {
- for entry in &entries {
- eprintln!("deleting {}", entry.id);
- }
-
- let ids = history_store.delete_entries(entries).await?;
- history_store.incremental_build(&db, &ids).await?;
-
- entries =
- run_non_interactive(settings, opt_filter.clone(), &query, &db).await?;
- }
- } else {
- let format = match self.format {
- None => Some(settings.history_format.as_str()),
- _ => self.format.as_deref(),
- };
- let tz = match self.timezone {
- Some(Some(tz)) => tz, // User provided a value
- Some(None) | None => settings.timezone, // No value was provided
- };
-
- super::history::print_list(
- &entries,
- ListMode::from_flags(self.human, self.cmd_only),
- format,
- self.print0,
- true,
- tz,
- );
- }
- }
- Ok(())
- }
-}
-
-// This is supposed to more-or-less mirror the command line version, so ofc
-// it is going to have a lot of args
-async fn run_non_interactive(
- settings: &Settings,
- filter_options: OptFilters,
- query: &[String],
- db: &ClientSqlite,
-) -> Result<Vec<History>> {
- let dir = if filter_options.cwd.as_deref() == Some(".") {
- Some(utils::get_current_dir())
- } else {
- filter_options.cwd
- };
-
- let context = current_context().await?;
-
- let opt_filter = OptFilters {
- cwd: dir.clone(),
- ..filter_options
- };
-
- let filter_mode = settings.default_filter_mode(context.git_root.is_some());
-
- let results = db
- .search(
- settings.search_mode,
- filter_mode,
- &context,
- query.join(" ").as_str(),
- opt_filter,
- )
- .await?;
-
- Ok(results)
-}
-
-#[cfg(test)]
-mod tests {
- use super::Cmd;
- use clap::Parser;
-
- #[test]
- fn search_for_triple_dash() {
- // Issue #3028: searching for `---` should not be treated as a CLI flag
- let cmd = Cmd::try_parse_from(["search", "---"]);
- assert!(cmd.is_ok(), "Failed to parse '---' as a query: {cmd:?}");
- let cmd = cmd.unwrap();
- assert_eq!(cmd.query, Some(vec!["---".to_string()]));
- }
-
- #[test]
- fn search_for_double_dash_value() {
- // Searching for strings starting with -- should also work
- let cmd = Cmd::try_parse_from(["search", "--", "--foo"]);
- assert!(cmd.is_ok());
- let cmd = cmd.unwrap();
- assert_eq!(cmd.query, Some(vec!["--foo".to_string()]));
- }
-
- #[test]
- fn search_author_cli_flag() {
- let cmd =
- Cmd::try_parse_from(["search", "--author", "codex", "--author", "ellie"]).unwrap();
- assert_eq!(
- cmd.author,
- Some(vec!["codex".to_string(), "ellie".to_string()])
- );
- }
-}
diff --git a/crates/client/src/command/client/search/cursor.rs b/crates/client/src/command/client/search/cursor.rs
deleted file mode 100644
index e13e52b3..00000000
--- a/crates/client/src/command/client/search/cursor.rs
+++ /dev/null
@@ -1,404 +0,0 @@
-use crate::atuin_client::settings::WordJumpMode;
-
-pub(crate) struct Cursor {
- source: String,
- index: usize,
-}
-
-impl From<String> for Cursor {
- fn from(source: String) -> Self {
- Self { source, index: 0 }
- }
-}
-
-pub(crate) struct WordJumper<'a> {
- word_chars: &'a str,
- word_jump_mode: WordJumpMode,
-}
-
-impl WordJumper<'_> {
- fn is_word_boundary(&self, c: char, next_c: char) -> bool {
- (c.is_whitespace() && !next_c.is_whitespace())
- || (!c.is_whitespace() && next_c.is_whitespace())
- || (self.word_chars.contains(c) && !self.word_chars.contains(next_c))
- || (!self.word_chars.contains(c) && self.word_chars.contains(next_c))
- }
-
- fn emacs_get_next_word_pos(&self, source: &str, index: usize) -> usize {
- let index = (index + 1..source.len().saturating_sub(1))
- .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap()))
- .unwrap_or(source.len());
- (index + 1..source.len().saturating_sub(1))
- .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap()))
- .unwrap_or(source.len())
- }
-
- fn emacs_get_prev_word_pos(&self, source: &str, index: usize) -> usize {
- let index = (1..index)
- .rev()
- .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap()))
- .unwrap_or(0);
- (1..index)
- .rev()
- .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap()))
- .map_or(0, |i| i + 1)
- }
-
- fn subl_get_next_word_pos(&self, source: &str, index: usize) -> usize {
- let index = (index..source.len().saturating_sub(1)).find(|&i| {
- self.is_word_boundary(
- source.chars().nth(i).unwrap(),
- source.chars().nth(i + 1).unwrap(),
- )
- });
- if index.is_none() {
- return source.len();
- }
- (index.unwrap() + 1..source.len())
- .find(|&i| !source.chars().nth(i).unwrap().is_whitespace())
- .unwrap_or(source.len())
- }
-
- fn subl_get_prev_word_pos(&self, source: &str, index: usize) -> usize {
- let index = (1..index)
- .rev()
- .find(|&i| !source.chars().nth(i).unwrap().is_whitespace());
- if index.is_none() {
- return 0;
- }
- (1..index.unwrap())
- .rev()
- .find(|&i| {
- self.is_word_boundary(
- source.chars().nth(i - 1).unwrap(),
- source.chars().nth(i).unwrap(),
- )
- })
- .unwrap_or(0)
- }
-
- fn get_next_word_pos(&self, source: &str, index: usize) -> usize {
- match self.word_jump_mode {
- WordJumpMode::Emacs => self.emacs_get_next_word_pos(source, index),
- WordJumpMode::Subl => self.subl_get_next_word_pos(source, index),
- }
- }
-
- fn get_prev_word_pos(&self, source: &str, index: usize) -> usize {
- match self.word_jump_mode {
- WordJumpMode::Emacs => self.emacs_get_prev_word_pos(source, index),
- WordJumpMode::Subl => self.subl_get_prev_word_pos(source, index),
- }
- }
-}
-
-impl Cursor {
- pub(crate) fn as_str(&self) -> &str {
- self.source.as_str()
- }
-
- pub(crate) fn into_inner(self) -> String {
- self.source
- }
-
- /// Returns the string before the cursor
- pub(crate) fn substring(&self) -> &str {
- &self.source[..self.index]
- }
-
- /// Returns the currently selected [`char`]
- pub(crate) fn char(&self) -> Option<char> {
- self.source[self.index..].chars().next()
- }
-
- pub(crate) fn right(&mut self) {
- if self.index < self.source.len() {
- loop {
- self.index += 1;
- if self.source.is_char_boundary(self.index) {
- break;
- }
- }
- }
- }
-
- pub(crate) fn left(&mut self) -> bool {
- if self.index > 0 {
- loop {
- self.index -= 1;
- if self.source.is_char_boundary(self.index) {
- break true;
- }
- }
- } else {
- false
- }
- }
-
- pub(crate) fn next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {
- let word_jumper = WordJumper {
- word_chars,
- word_jump_mode,
- };
- self.index = word_jumper.get_next_word_pos(&self.source, self.index);
- }
-
- pub(crate) fn prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {
- let word_jumper = WordJumper {
- word_chars,
- word_jump_mode,
- };
- self.index = word_jumper.get_prev_word_pos(&self.source, self.index);
- }
-
- /// Move cursor to the end of the current/next word (vim `e` motion).
- ///
- /// If cursor is in the middle of a word, moves to the end of that word.
- /// If cursor is at the end of a word (or on whitespace), moves to the
- /// end of the next word.
- pub(crate) fn word_end(&mut self, word_chars: &str) {
- let len = self.source.len();
- if self.index >= len {
- return;
- }
-
- let chars: Vec<char> = self.source.chars().collect();
- let mut char_idx = self.source[..self.index].chars().count();
-
- if char_idx >= chars.len() {
- return;
- }
-
- let current = chars[char_idx];
-
- // Check if we're at a word boundary (end of current word or on whitespace)
- let at_word_boundary = current.is_whitespace() || char_idx + 1 >= chars.len() || {
- let next = chars[char_idx + 1];
- next.is_whitespace() || (word_chars.contains(current) != word_chars.contains(next))
- };
-
- // If at word boundary, advance past it and skip whitespace to find next word
- if at_word_boundary {
- char_idx += 1;
- while char_idx < chars.len() && chars[char_idx].is_whitespace() {
- char_idx += 1;
- }
- }
-
- // If we've gone past end, go to end of string
- if char_idx >= chars.len() {
- self.index = len;
- return;
- }
-
- // Find end of word: advance until next char is whitespace or different word type
- let in_word_chars = word_chars.contains(chars[char_idx]);
- while char_idx < chars.len() {
- let next_idx = char_idx + 1;
- if next_idx >= chars.len() {
- // At last char, move past it
- char_idx = next_idx;
- break;
- }
- let next_c = chars[next_idx];
- if next_c.is_whitespace() || (word_chars.contains(next_c) != in_word_chars) {
- // Next char is start of new word/whitespace, so current char is end
- char_idx = next_idx;
- break;
- }
- char_idx += 1;
- }
-
- // Convert char index back to byte index
- self.index = chars.iter().take(char_idx).map(|c| c.len_utf8()).sum();
- }
-
- pub(crate) fn insert(&mut self, c: char) {
- self.source.insert(self.index, c);
- self.index += c.len_utf8();
- }
-
- pub(crate) fn remove(&mut self) -> Option<char> {
- if self.index < self.source.len() {
- Some(self.source.remove(self.index))
- } else {
- None
- }
- }
-
- pub(crate) fn remove_next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {
- let word_jumper = WordJumper {
- word_chars,
- word_jump_mode,
- };
- let next_index = word_jumper.get_next_word_pos(&self.source, self.index);
- self.source.replace_range(self.index..next_index, "");
- }
-
- pub(crate) fn remove_prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {
- let word_jumper = WordJumper {
- word_chars,
- word_jump_mode,
- };
- let next_index = word_jumper.get_prev_word_pos(&self.source, self.index);
- self.source.replace_range(next_index..self.index, "");
- self.index = next_index;
- }
-
- pub(crate) fn back(&mut self) -> Option<char> {
- if self.left() { self.remove() } else { None }
- }
-
- pub(crate) fn clear(&mut self) {
- self.source.clear();
- self.index = 0;
- }
-
- pub(crate) fn clear_to_start(&mut self) {
- self.source.replace_range(..self.index, "");
- self.index = 0;
- }
-
- pub(crate) fn clear_to_end(&mut self) {
- self.source.replace_range(self.index.., "");
- self.index = self.source.len();
- }
-
- pub(crate) fn end(&mut self) {
- self.index = self.source.len();
- }
-
- pub(crate) fn start(&mut self) {
- self.index = 0;
- }
-
- pub(crate) fn position(&self) -> usize {
- self.index
- }
-}
-
-#[cfg(test)]
-mod cursor_tests {
- use super::{Cursor, WordJumpMode, WordJumper};
-
- static EMACS_WORD_JUMPER: WordJumper<'_> = WordJumper {
- word_chars: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
- word_jump_mode: WordJumpMode::Emacs,
- };
-
- static SUBL_WORD_JUMPER: WordJumper<'_> = WordJumper {
- word_chars: "./\\()\"'-:,.;<>~!@#$%^&*|+=[]{}`~?",
- word_jump_mode: WordJumpMode::Subl,
- };
-
- #[test]
- fn right() {
- // ö is 2 bytes
- let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
- let indices = [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 20, 20, 20];
- for i in indices {
- assert_eq!(c.index, i);
- c.right();
- }
- }
-
- #[test]
- fn left() {
- // ö is 2 bytes
- let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
- c.end();
- let indices = [20, 18, 17, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 0, 0, 0, 0];
- for i in indices {
- assert_eq!(c.index, i);
- c.left();
- }
- }
-
- #[test]
- fn test_emacs_get_next_word_pos() {
- let s = String::from(" aaa ((()))bbb ((())) ");
- let indices = [(0, 6), (3, 6), (7, 18), (19, 30)];
- for (i_src, i_dest) in indices {
- assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest);
- }
- assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos("", 0), 0);
- }
-
- #[test]
- fn test_emacs_get_prev_word_pos() {
- let s = String::from(" aaa ((()))bbb ((())) ");
- let indices = [(30, 15), (29, 15), (15, 3), (3, 0)];
- for (i_src, i_dest) in indices {
- assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest);
- }
- assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos("", 0), 0);
- }
-
- #[test]
- fn test_subl_get_next_word_pos() {
- let s = String::from(" aaa ((()))bbb ((())) ");
- let indices = [(0, 3), (1, 3), (3, 9), (9, 15), (15, 21), (21, 30)];
- for (i_src, i_dest) in indices {
- assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest);
- }
- assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos("", 0), 0);
- }
-
- #[test]
- fn test_subl_get_prev_word_pos() {
- let s = String::from(" aaa ((()))bbb ((())) ");
- let indices = [(30, 21), (21, 15), (15, 9), (9, 3), (3, 0)];
- for (i_src, i_dest) in indices {
- assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest);
- }
- assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos("", 0), 0);
- }
-
- #[test]
- fn pop() {
- let mut s = String::from("öaöböcödöeöfö");
- let mut c = Cursor::from(s.clone());
- c.end();
- while !s.is_empty() {
- let c1 = s.pop();
- let c2 = c.back();
- assert_eq!(c1, c2);
- assert_eq!(s.as_str(), c.substring());
- }
- let c1 = s.pop();
- let c2 = c.back();
- assert_eq!(c1, c2);
- }
-
- #[test]
- fn back() {
- let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
- // move to ^
- for _ in 0..4 {
- c.right();
- }
- assert_eq!(c.substring(), "öaöb");
- assert_eq!(c.back(), Some('b'));
- assert_eq!(c.back(), Some('ö'));
- assert_eq!(c.back(), Some('a'));
- assert_eq!(c.back(), Some('ö'));
- assert_eq!(c.back(), None);
- assert_eq!(c.as_str(), "öcödöeöfö");
- }
-
- #[test]
- fn insert() {
- let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
- // move to ^
- for _ in 0..4 {
- c.right();
- }
- assert_eq!(c.substring(), "öaöb");
- c.insert('ö');
- c.insert('g');
- c.insert('ö');
- c.insert('h');
- assert_eq!(c.substring(), "öaöbögöh");
- assert_eq!(c.as_str(), "öaöbögöhöcödöeöfö");
- }
-}
diff --git a/crates/client/src/command/client/search/duration.rs b/crates/client/src/command/client/search/duration.rs
deleted file mode 100644
index 0d70353a..00000000
--- a/crates/client/src/command/client/search/duration.rs
+++ /dev/null
@@ -1,63 +0,0 @@
-use core::fmt;
-use std::{ops::ControlFlow, time::Duration};
-
-pub(crate) fn format_duration_into(dur: Duration, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- fn item(unit: &'static str, value: u64) -> ControlFlow<(&'static str, u64)> {
- if value > 0 {
- ControlFlow::Break((unit, value))
- } else {
- ControlFlow::Continue(())
- }
- }
-
- // impl taken and modified from
- // https://github.com/tailhook/humantime/blob/master/src/duration.rs#L295-L331
- // Copyright (c) 2016 The humantime Developers
- fn fmt(f: Duration) -> ControlFlow<(&'static str, u64), ()> {
- let secs = f.as_secs();
- let nanos = f.subsec_nanos();
-
- let years = secs / 31_557_600; // 365.25d
- let year_days = secs % 31_557_600;
- let months = year_days / 2_630_016; // 30.44d
- let month_days = year_days % 2_630_016;
- let days = month_days / 86400;
- let day_secs = month_days % 86400;
- let hours = day_secs / 3600;
- let minutes = day_secs % 3600 / 60;
- let seconds = day_secs % 60;
-
- let millis = nanos / 1_000_000;
- let micros = nanos / 1_000;
-
- // a difference from our impl than the original is that
- // we only care about the most-significant segment of the duration.
- // If the item call returns `Break`, then the `?` will early-return.
- // This allows for a very consise impl
- item("y", years)?;
- item("mo", months)?;
- item("d", days)?;
- item("h", hours)?;
- item("m", minutes)?;
- item("s", seconds)?;
- item("ms", u64::from(millis))?;
- item("us", u64::from(micros))?;
- item("ns", u64::from(nanos))?;
- ControlFlow::Continue(())
- }
-
- match fmt(dur) {
- ControlFlow::Break((unit, value)) => write!(f, "{value}{unit}"),
- ControlFlow::Continue(()) => write!(f, "0s"),
- }
-}
-
-pub(crate) fn format_duration(f: Duration) -> String {
- struct F(Duration);
- impl fmt::Display for F {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- format_duration_into(self.0, f)
- }
- }
- F(f).to_string()
-}
diff --git a/crates/client/src/command/client/search/engines.rs b/crates/client/src/command/client/search/engines.rs
deleted file mode 100644
index 9fbff278..00000000
--- a/crates/client/src/command/client/search/engines.rs
+++ /dev/null
@@ -1,86 +0,0 @@
-use crate::atuin_client::{
- database::{ClientSqlite, Context, OptFilters},
- history::{History, HistoryId},
- settings::{FilterMode, SearchMode, Settings},
-};
-use async_trait::async_trait;
-use eyre::Result;
-
-use super::cursor::Cursor;
-
-pub(crate) mod daemon;
-pub(crate) mod db;
-pub(crate) mod skim;
-
-pub(crate) fn engine(search_mode: SearchMode, settings: &Settings) -> Box<dyn SearchEngine> {
- match search_mode {
- SearchMode::Skim => Box::new(skim::Search::new()),
- SearchMode::DaemonFuzzy => Box::new(daemon::Search::new(settings)),
- mode => Box::new(db::Search(mode)),
- }
-}
-
-pub(crate) struct SearchState {
- pub(crate) input: Cursor,
- pub(crate) filter_mode: FilterMode,
- pub(crate) context: Context,
- pub(crate) custom_context: Option<HistoryId>,
-}
-
-impl SearchState {
- pub(crate) fn rotate_filter_mode(&mut self, settings: &Settings, offset: isize) {
- let mut i = settings
- .search
- .filters
- .iter()
- .position(|&m| m == self.filter_mode)
- .unwrap_or_default();
- for _ in 0..settings.search.filters.len() {
- i = (i.wrapping_add_signed(offset)) % settings.search.filters.len();
- let mode = settings.search.filters[i];
- if self.filter_mode_available(mode, settings) {
- self.filter_mode = mode;
- break;
- }
- }
- }
-
- fn filter_mode_available(&self, mode: FilterMode, settings: &Settings) -> bool {
- match mode {
- FilterMode::Global | FilterMode::SessionPreload => self.custom_context.is_none(),
- FilterMode::Workspace => settings.workspaces && self.context.git_root.is_some(),
- _ => true,
- }
- }
-}
-
-#[async_trait]
-pub(crate) trait SearchEngine: Send + Sync + 'static {
- async fn full_query(
- &mut self,
- state: &SearchState,
- db: &mut ClientSqlite,
- ) -> Result<Vec<History>>;
-
- async fn query(&mut self, state: &SearchState, db: &mut ClientSqlite) -> Result<Vec<History>> {
- if state.input.as_str().is_empty() {
- Ok(db
- .search(
- SearchMode::FullText,
- state.filter_mode,
- &state.context,
- "",
- OptFilters {
- limit: Some(200),
- ..Default::default()
- },
- )
- .await?
- .into_iter()
- .collect::<Vec<_>>())
- } else {
- self.full_query(state, db).await
- }
- }
- fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize>;
-}
diff --git a/crates/client/src/command/client/search/engines/daemon.rs b/crates/client/src/command/client/search/engines/daemon.rs
deleted file mode 100644
index ee92ebaf..00000000
--- a/crates/client/src/command/client/search/engines/daemon.rs
+++ /dev/null
@@ -1,213 +0,0 @@
-use crate::atuin_client::{
- database::{ClientSqlite, OptFilters},
- history::History,
- settings::{SearchMode, Settings},
-};
-use crate::atuin_daemon::client::SearchClient;
-use async_trait::async_trait;
-use atuin_nucleo_matcher::{
- Config, Matcher, Utf32Str,
- pattern::{CaseMatching, Normalization, Pattern},
-};
-use eyre::Result;
-use tracing::{Level, debug, instrument, span};
-use uuid::Uuid;
-
-use super::{SearchEngine, SearchState};
-
-pub(crate) struct Search {
- client: Option<SearchClient>,
- query_id: u64,
- #[cfg(unix)]
- socket_path: String,
-}
-
-impl Search {
- pub(crate) fn new(settings: &Settings) -> Self {
- Self {
- client: None,
- query_id: 0,
- #[cfg(unix)]
- socket_path: settings.daemon.socket_path.clone(),
- }
- }
-
- #[instrument(skip_all, level = Level::TRACE, name = "get_daemon_client")]
- async fn get_client(&mut self) -> Result<&mut SearchClient> {
- if self.client.is_none() {
- self.connect().await?;
- }
- Ok(self.client.as_mut().unwrap())
- }
-
- async fn connect(&mut self) -> Result<()> {
- #[cfg(unix)]
- let client = SearchClient::new(self.socket_path.clone()).await?;
-
- self.client = Some(client);
- Ok(())
- }
-
- fn next_query_id(&mut self) -> u64 {
- self.query_id += 1;
- self.query_id
- }
-
- /// Check if query contains regex pattern (r/.../)
- /// Nucleo doesn't support regex, so we fall back to database search
- fn contains_regex_pattern(query: &str) -> bool {
- query.starts_with("r/") || query.contains(" r/")
- }
-
- #[instrument(skip_all, level = Level::TRACE, name = "daemon_db_fallback")]
- async fn fallback_to_db_search(
- &self,
- state: &SearchState,
- db: &ClientSqlite,
- ) -> Result<Vec<History>> {
- let results = db
- .search(
- SearchMode::FullText,
- state.filter_mode,
- &state.context,
- state.input.as_str(),
- OptFilters {
- limit: Some(200),
- ..Default::default()
- },
- )
- .await
- .map_or(Vec::new(), |r| r.into_iter().collect());
- Ok(results)
- }
-
- #[instrument(skip_all, level = Level::TRACE, name = "hydrate_from_db", fields(count = ids.len()))]
- async fn hydrate_from_db(&self, db: &ClientSqlite, ids: &[String]) -> Result<Vec<History>> {
- let placeholders: Vec<String> = ids.iter().map(|id| format!("'{id}'")).collect();
- let sql_query = format!(
- "SELECT * FROM history WHERE id IN ({}) ORDER BY timestamp DESC",
- placeholders.join(",")
- );
- Ok(db.query_history(&sql_query).await?)
- }
-}
-
-#[async_trait]
-impl SearchEngine for Search {
- #[instrument(skip_all, level = Level::TRACE, name = "daemon_search", fields(query = %state.input.as_str()))]
- async fn full_query(
- &mut self,
- state: &SearchState,
- db: &mut ClientSqlite,
- ) -> Result<Vec<History>> {
- let query = state.input.as_str().to_string();
-
- // Fall back to database for regex queries (Nucleo doesn't support regex)
- if Self::contains_regex_pattern(&query) {
- debug!(query = %query, "[daemon-client] regex detected, falling back to db");
- return self.fallback_to_db_search(state, db).await;
- }
-
- let query_id = self.next_query_id();
-
- let span =
- span!(Level::TRACE, "daemon_search.req_resp", query = %query, query_id = query_id);
-
- // Try to connect and search; if it fails with a retriable error,
- // auto-start the daemon and retry once.
- let first_attempt = async {
- let client = self.get_client().await?;
- client
- .search(
- query.clone(),
- query_id,
- state.filter_mode,
- Some(state.context.clone()),
- )
- .await
- }
- .await;
-
- let mut stream = match first_attempt {
- Ok(stream) => stream,
- Err(err) => return Err(err),
- };
-
- let mut ids = Vec::with_capacity(200);
- span!(Level::TRACE, "daemon_search.resp")
- .in_scope(async || {
- while let Ok(Some(response)) = stream.message().await {
- let span2 = span!(
- Level::TRACE,
- "daemon_search.resp.item",
- query_id = response.query_id
- );
- let _span2 = span2.enter();
- // Only process if the query_id matches (prevents stale responses)
- if response.query_id == query_id {
- let uuids = response
- .ids
- .iter()
- .map(|id| {
- let bytes: [u8; 16] =
- id.as_slice().try_into().expect("id should be 16 bytes");
- Uuid::from_bytes(bytes).as_simple().to_string()
- })
- .collect::<Vec<_>>();
- ids.extend(uuids);
- }
- drop(_span2);
- drop(span2);
- }
- })
- .await;
- drop(span);
-
- if ids.is_empty() {
- debug!(query = %query, results = 0, "[daemon-client] empty results");
- return Ok(Vec::new());
- }
-
- // // Hydrate from local database
- let results = self.hydrate_from_db(db, &ids).await?;
-
- // // Reorder results to match the order from the daemon (which is ranked by relevance)
- let ordered_results = span!(Level::TRACE, "reorder_results").in_scope(|| {
- let mut ordered_results = Vec::with_capacity(results.len());
- for id in &ids {
- if let Some(history) = results.iter().find(|h| h.id.0 == *id) {
- ordered_results.push(history.clone());
- }
- }
- ordered_results
- });
-
- debug!(
- query = %query,
- results = results.len(),
- "[daemon-client]"
- );
-
- Ok(ordered_results)
- }
-
- #[instrument(skip_all, level = Level::TRACE, name = "daemon_highlight")]
- fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> {
- // Use fulltext highlighting for regex queries
- if Self::contains_regex_pattern(search_input) {
- return super::db::get_highlight_indices_fulltext(command, search_input);
- }
-
- let mut matcher = Matcher::new(Config::DEFAULT);
- let pattern = Pattern::parse(search_input, CaseMatching::Smart, Normalization::Smart);
-
- let mut indices: Vec<u32> = Vec::new();
- let mut haystack_buf = Vec::new();
-
- let haystack = Utf32Str::new(command, &mut haystack_buf);
- pattern.indices(haystack, &mut matcher, &mut indices);
-
- // Convert u32 indices to usize
- indices.into_iter().map(|i| i as usize).collect()
- }
-}
diff --git a/crates/client/src/command/client/search/engines/db.rs b/crates/client/src/command/client/search/engines/db.rs
deleted file mode 100644
index 0eb86878..00000000
--- a/crates/client/src/command/client/search/engines/db.rs
+++ /dev/null
@@ -1,107 +0,0 @@
-use super::{SearchEngine, SearchState};
-use crate::atuin_client::{
- database::{ClientSqlite, OptFilters, QueryToken, QueryTokenizer},
- history::History,
- settings::SearchMode,
-};
-use async_trait::async_trait;
-use eyre::Result;
-use norm::Metric;
-use norm::fzf::{FzfParser, FzfV2};
-use std::ops::Range;
-use tracing::{Level, instrument};
-
-pub(crate) struct Search(pub(crate) SearchMode);
-
-#[async_trait]
-impl SearchEngine for Search {
- #[instrument(skip_all, level = Level::TRACE, name = "db_search", fields(mode = ?self.0, query = %state.input.as_str()))]
- async fn full_query(
- &mut self,
- state: &SearchState,
- db: &mut ClientSqlite,
- ) -> Result<Vec<History>> {
- let results = db
- .search(
- self.0,
- state.filter_mode,
- &state.context,
- state.input.as_str(),
- OptFilters {
- limit: Some(200),
- ..Default::default()
- },
- )
- .await
- // ignore errors as it may be caused by incomplete regex
- .map_or(Vec::new(), |r| r.into_iter().collect());
- Ok(results)
- }
-
- #[instrument(skip_all, level = Level::TRACE, name = "db_highlight")]
- fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> {
- if self.0 == SearchMode::Prefix {
- return vec![];
- } else if self.0 == SearchMode::FullText {
- return get_highlight_indices_fulltext(command, search_input);
- }
- let mut fzf = FzfV2::new();
- let mut parser = FzfParser::new();
- let query = parser.parse(search_input);
- let mut ranges: Vec<Range<usize>> = Vec::new();
- fzf.distance_and_ranges(query, command, &mut ranges);
-
- // convert ranges to all indices
- ranges.into_iter().flatten().collect()
- }
-}
-
-#[instrument(skip_all, level = Level::TRACE, name = "db_highlight_fulltext")]
-pub(crate) fn get_highlight_indices_fulltext(command: &str, search_input: &str) -> Vec<usize> {
- let mut ranges = vec![];
- let lower_command = command.to_ascii_lowercase();
-
- for token in QueryTokenizer::new(search_input) {
- let matchee = if token.has_uppercase() {
- command
- } else {
- &lower_command
- };
-
- if token.is_inverse() {
- continue;
- }
-
- match token {
- QueryToken::Or => {}
- QueryToken::Regex(r) => {
- if let Ok(re) = regex::Regex::new(r) {
- for m in re.find_iter(command) {
- ranges.push(m.range());
- }
- }
- }
- QueryToken::MatchStart(term, _) => {
- if matchee.starts_with(term) {
- ranges.push(0..term.len());
- }
- }
- QueryToken::MatchEnd(term, _) => {
- if matchee.ends_with(term) {
- let l = matchee.len();
- ranges.push((l - term.len())..l);
- }
- }
- QueryToken::Match(term, _) | QueryToken::MatchFull(term, _) => {
- for (idx, m) in matchee.match_indices(term) {
- ranges.push(idx..(idx + m.len()));
- }
- }
- }
- }
-
- let mut ret: Vec<_> = ranges.into_iter().flatten().collect();
- ret.sort_unstable();
- ret.dedup();
- ret
-}
diff --git a/crates/client/src/command/client/search/engines/skim.rs b/crates/client/src/command/client/search/engines/skim.rs
deleted file mode 100644
index e090e40d..00000000
--- a/crates/client/src/command/client/search/engines/skim.rs
+++ /dev/null
@@ -1,222 +0,0 @@
-use std::path::Path;
-
-use crate::atuin_client::{database::ClientSqlite, history::History, settings::FilterMode};
-use async_trait::async_trait;
-use eyre::Result;
-use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
-use itertools::Itertools;
-use time::OffsetDateTime;
-use tokio::task::yield_now;
-use tracing::{Level, instrument, warn};
-
-use super::{SearchEngine, SearchState};
-
-pub(crate) struct Search {
- all_history: Vec<(History, i32)>,
- engine: SkimMatcherV2,
-}
-
-impl Search {
- pub(crate) fn new() -> Self {
- Self {
- all_history: vec![],
- engine: SkimMatcherV2::default(),
- }
- }
-}
-
-#[async_trait]
-impl SearchEngine for Search {
- #[instrument(skip_all, level = Level::TRACE, name = "skim_search", fields(query = %state.input.as_str()))]
- async fn full_query(
- &mut self,
- state: &SearchState,
- db: &mut ClientSqlite,
- ) -> Result<Vec<History>> {
- if self.all_history.is_empty() {
- self.all_history = load_all_history(db).await;
- }
-
- Ok(fuzzy_search(&self.engine, state, &self.all_history).await)
- }
-
- #[instrument(skip_all, level = Level::TRACE, name = "skim_highlight")]
- fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> {
- let (_, indices) = self
- .engine
- .fuzzy_indices(command, search_input)
- .unwrap_or_default();
- indices
- }
-}
-
-#[instrument(skip_all, level = Level::TRACE, name = "load_all_history")]
-async fn load_all_history(db: &ClientSqlite) -> Vec<(History, i32)> {
- db.all_with_count().await.unwrap()
-}
-
-#[expect(clippy::too_many_lines)]
-#[instrument(skip_all, level = Level::TRACE, name = "fuzzy_match", fields(history_count = all_history.len()))]
-async fn fuzzy_search(
- engine: &SkimMatcherV2,
- state: &SearchState,
- all_history: &[(History, i32)],
-) -> Vec<History> {
- let mut set = Vec::with_capacity(200);
- let mut ranks = Vec::with_capacity(200);
- let query = state.input.as_str();
- let now = OffsetDateTime::now_utc();
-
- for (i, (history, count)) in all_history.iter().enumerate() {
- if i % 256 == 0 {
- yield_now().await;
- }
-
- let context = &state.context;
- let git_root = context
- .git_root
- .as_ref()
- .and_then(|git_root| git_root.to_str())
- .unwrap_or(&context.cwd);
- match state.filter_mode {
- FilterMode::Global => {}
- // we aggregate host by ',' separating them
- FilterMode::Host
- if history
- .hostname
- .split(',')
- .contains(&context.hostname.as_str()) => {}
- // we aggregate session by concattenating them.
- // sessions are 32 byte simple uuid formats
- FilterMode::Session
- if history
- .session
- .as_bytes()
- .chunks(32)
- .contains(&context.session.as_bytes()) => {}
- // SessionPreload: include current session + global history from before session start
- FilterMode::SessionPreload => {
- let is_current_session = {
- history
- .session
- .as_bytes()
- .chunks(32)
- .any(|chunk| chunk == context.session.as_bytes())
- };
-
- if !is_current_session {
- let Ok(uuid) = uuid::Uuid::parse_str(&context.session) else {
- warn!("failed to parse session id '{}'", context.session);
- continue;
- };
- let Some(timestamp) = uuid.get_timestamp() else {
- warn!(
- "failed to get timestamp from uuid '{}'",
- uuid.as_hyphenated()
- );
- continue;
- };
- let (seconds, nanos) = timestamp.to_unix();
- let Ok(session_start) = OffsetDateTime::from_unix_timestamp_nanos(
- i128::from(seconds) * 1_000_000_000 + i128::from(nanos),
- ) else {
- warn!(
- "failed to create OffsetDateTime from second: {seconds}, nanosecond: {nanos}"
- );
- continue;
- };
-
- if history.timestamp >= session_start {
- continue;
- }
- }
- }
- // we aggregate directory by ':' separating them
- FilterMode::Directory if history.cwd.split(':').contains(&context.cwd.as_str()) => {}
- FilterMode::Workspace if history.cwd.split(':').contains(&git_root) => {}
- _ => continue,
- }
- #[expect(clippy::cast_lossless, clippy::cast_precision_loss)]
- if let Some((score, indices)) = engine.fuzzy_indices(&history.command, query) {
- let begin = indices.first().copied().unwrap_or_default();
-
- let mut duration = (now - history.timestamp).as_seconds_f64().log2();
- if !duration.is_finite() || duration <= 1.0 {
- duration = 1.0;
- }
- // these + X.0 just make the log result a bit smoother.
- // log is very spiky towards 1-4, but I want a gradual decay.
- // eg:
- // log2(4) = 2, log2(5) = 2.3 (16% increase)
- // log2(8) = 3, log2(9) = 3.16 (5% increase)
- // log2(16) = 4, log2(17) = 4.08 (2% increase)
- let count = (*count as f64 + 8.0).log2();
- let begin = (begin as f64 + 16.0).log2();
- let path = path_dist(history.cwd.as_ref(), state.context.cwd.as_ref());
- let path = (path as f64 + 8.0).log2();
-
- // reduce longer durations, raise higher counts, raise matches close to the start
- let score = (-score as f64) * count / path / duration / begin;
-
- 'insert: {
- // algorithm:
- // 1. find either the position that this command ranks
- // 2. find the same command positioned better than our rank.
- for i in 0..set.len() {
- // do we out score the current position?
- if ranks[i] > score {
- ranks.insert(i, score);
- set.insert(i, history.clone());
- let mut j = i + 1;
- while j < set.len() {
- // remove duplicates that have a worse score
- if set[j].command == history.command {
- ranks.remove(j);
- set.remove(j);
-
- // break this while loop because there won't be any other
- // duplicates.
- break;
- }
- j += 1;
- }
-
- // keep it limited
- if ranks.len() > 200 {
- ranks.pop();
- set.pop();
- }
-
- break 'insert;
- }
- // don't continue if this command has a better score already
- if set[i].command == history.command {
- break 'insert;
- }
- }
-
- if set.len() < 200 {
- ranks.push(score);
- set.push(history.clone());
- }
- }
- }
- }
-
- set
-}
-
-fn path_dist(a: &Path, b: &Path) -> usize {
- let mut a: Vec<_> = a.components().collect();
- let b: Vec<_> = b.components().collect();
-
- let mut dist = 0;
-
- // pop a until there's a common ancestor
- while !b.starts_with(&a) {
- dist += 1;
- a.pop();
- }
-
- b.len() - a.len() + dist
-}
diff --git a/crates/client/src/command/client/search/history_list.rs b/crates/client/src/command/client/search/history_list.rs
deleted file mode 100644
index e46f37b7..00000000
--- a/crates/client/src/command/client/search/history_list.rs
+++ /dev/null
@@ -1,431 +0,0 @@
-use std::time::Duration;
-
-use super::duration::format_duration;
-use super::engines::SearchEngine;
-use crate::atuin_client::{
- history::History,
- settings::{UiColumn, UiColumnType},
- theme::{
- style_alerterror, style_alertinfo, style_alertwarn, style_annotation, style_base,
- style_guidance,
- },
-};
-use crate::atuin_common::utils::Escapable as _;
-use itertools::Itertools;
-use ratatui::{
- backend::FromCrossterm,
- buffer::Buffer,
- crossterm::style,
- layout::Rect,
- style::{Modifier, Style},
- widgets::{Block, StatefulWidget, Widget},
-};
-use time::OffsetDateTime;
-
-pub(crate) struct HistoryHighlighter<'a> {
- pub(crate) engine: &'a dyn SearchEngine,
- pub(crate) search_input: &'a str,
-}
-
-impl HistoryHighlighter<'_> {
- pub(crate) fn get_highlight_indices(&self, command: &str) -> Vec<usize> {
- self.engine
- .get_highlight_indices(command, self.search_input)
- }
-}
-
-pub(crate) struct HistoryList<'a> {
- history: &'a [History],
- block: Option<Block<'a>>,
- inverted: bool,
- /// Apply an alternative highlighting to the selected row
- alternate_highlight: bool,
- now: &'a dyn Fn() -> OffsetDateTime,
- indicator: &'a str,
-
- history_highlighter: HistoryHighlighter<'a>,
- show_numeric_shortcuts: bool,
- /// Columns to display (in order, after the indicator)
- columns: &'a [UiColumn],
-}
-
-#[derive(Default)]
-pub(crate) struct ListState {
- offset: usize,
- selected: usize,
- max_entries: usize,
-}
-
-impl ListState {
- pub(crate) fn selected(&self) -> usize {
- self.selected
- }
-
- pub(crate) fn max_entries(&self) -> usize {
- self.max_entries
- }
-
- pub(crate) fn offset(&self) -> usize {
- self.offset
- }
-
- pub(crate) fn select(&mut self, index: usize) {
- self.selected = index;
- }
-}
-
-impl StatefulWidget for HistoryList<'_> {
- type State = ListState;
-
- fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
- let list_area = self.block.take().map_or(area, |b| {
- let inner_area = b.inner(area);
- b.render(area, buf);
- inner_area
- });
-
- if list_area.width < 1 || list_area.height < 1 || self.history.is_empty() {
- return;
- }
- let list_height = list_area.height as usize;
-
- let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height);
- state.offset = start;
- state.max_entries = end - start;
-
- let mut s = DrawState {
- buf,
- list_area,
- x: 0,
- y: 0,
- state,
- inverted: self.inverted,
- alternate_highlight: self.alternate_highlight,
- now: &self.now,
- indicator: self.indicator,
-
- history_highlighter: self.history_highlighter,
- show_numeric_shortcuts: self.show_numeric_shortcuts,
- columns: self.columns,
- };
-
- for item in self.history.iter().skip(state.offset).take(end - start) {
- s.render_row(item);
-
- // reset line
- s.y += 1;
- s.x = 0;
- }
- }
-}
-
-impl<'a> HistoryList<'a> {
- #[expect(clippy::too_many_arguments)]
- pub(crate) fn new(
- history: &'a [History],
- inverted: bool,
- alternate_highlight: bool,
- now: &'a dyn Fn() -> OffsetDateTime,
- indicator: &'a str,
-
- history_highlighter: HistoryHighlighter<'a>,
- show_numeric_shortcuts: bool,
- columns: &'a [UiColumn],
- ) -> Self {
- Self {
- history,
- block: None,
- inverted,
- alternate_highlight,
- now,
- indicator,
- history_highlighter,
- show_numeric_shortcuts,
- columns,
- }
- }
-
- pub(crate) fn block(mut self, block: Block<'a>) -> Self {
- self.block = Some(block);
- self
- }
-
- fn get_items_bounds(&self, selected: usize, offset: usize, height: usize) -> (usize, usize) {
- let offset = offset.min(self.history.len().saturating_sub(1));
-
- let max_scroll_space = height.min(10).min(self.history.len() - selected);
- if offset + height < selected + max_scroll_space {
- let end = selected + max_scroll_space;
- (end - height, end)
- } else if selected < offset {
- (selected, selected + height)
- } else {
- (offset, offset + height)
- }
- }
-}
-
-struct DrawState<'a> {
- buf: &'a mut Buffer,
- list_area: Rect,
- x: u16,
- y: u16,
- state: &'a ListState,
- inverted: bool,
- alternate_highlight: bool,
- now: &'a dyn Fn() -> OffsetDateTime,
- indicator: &'a str,
-
- history_highlighter: HistoryHighlighter<'a>,
- show_numeric_shortcuts: bool,
- columns: &'a [UiColumn],
-}
-
-// these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form.
-// Yes, this is a hack, but it makes me feel happy
-static SLICES: &str = " > 1 2 3 4 5 6 7 8 9 ";
-
-impl DrawState<'_> {
- /// Render a complete row for a history item based on configured columns.
- fn render_row(&mut self, h: &History) {
- // Always render the indicator first (width 3)
- self.index();
-
- // Calculate the width for the expanding column
- // Fixed columns use their configured width + 1 (trailing space)
- let indicator_width: u16 = 3;
- let fixed_width: u16 = self
- .columns
- .iter()
- .filter(|c| !c.expand)
- .map(|c| c.width + 1)
- .sum();
- let expand_width = self
- .list_area
- .width
- .saturating_sub(indicator_width + fixed_width);
-
- let style = style_base();
- // Render each configured column
- for (idx, column) in self.columns.iter().enumerate() {
- if idx != 0 {
- self.draw(" ", Style::from_crossterm(style));
- }
- let width = if column.expand {
- expand_width
- } else {
- column.width
- };
- match column.column_type {
- UiColumnType::Duration => self.duration(h, width),
- UiColumnType::Time => self.time(h, width),
- UiColumnType::Datetime => self.datetime(h, width),
- UiColumnType::Directory => self.directory(h, width),
- UiColumnType::Host => self.host(h, width),
- UiColumnType::User => self.user(h, width),
- UiColumnType::Exit => self.exit_code(h, width),
- UiColumnType::Command => self.command(h),
- }
- }
- }
-
- fn index(&mut self) {
- if !self.show_numeric_shortcuts {
- let i = self.y as usize + self.state.offset;
- let is_selected = i == self.state.selected();
- let prompt: &str = if is_selected { self.indicator } else { " " };
- self.draw(prompt, Style::default());
- return;
- }
-
- // these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form.
- // Yes, this is a hack, but it makes me feel happy
-
- let i = self.y as usize + self.state.offset;
- let i = i.checked_sub(self.state.selected);
- let i = i.unwrap_or(10).min(10) * 2;
- let prompt: &str = if i == 0 {
- self.indicator
- } else {
- &SLICES[i..i + 3]
- };
- self.draw(prompt, Style::default());
- }
-
- fn duration(&mut self, h: &History, width: u16) {
- let style = if h.success() {
- style_alertinfo()
- } else {
- style_alerterror()
- };
- let duration = Duration::from_nanos(u64::try_from(h.duration).unwrap_or(0));
- let formatted = format_duration(duration);
- let w = width as usize;
- // Right-align duration within its column width, plus trailing space
- let display = format!("{formatted:>w$}");
- self.draw(&display, Style::from_crossterm(style));
- }
-
- fn time(&mut self, h: &History, width: u16) {
- let style = style_guidance();
-
- // Account for the chance that h.timestamp is "in the future"
- // This would mean that "since" is negative, and the unwrap here
- // would fail.
- // If the timestamp would otherwise be in the future, display
- // the time since as 0.
- let since = (self.now)() - h.timestamp;
- let time = format_duration(since.try_into().unwrap_or_default());
-
- // Format as "Xs ago" right-aligned within column width
- let w = width as usize;
- let time_str = format!("{time} ago");
-
- let display = format!("{time_str:>w$}");
- self.draw(&display, Style::from_crossterm(style));
- }
-
- fn command(&mut self, h: &History) {
- let mut style = style_base();
- let mut row_highlighted = false;
- if !self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected)
- {
- row_highlighted = true;
- // if not applying alternative highlighting to the whole row, color the command
- style = style_alerterror();
- style.attributes.set(style::Attribute::Bold);
- }
-
- let highlight_indices = self.history_highlighter.get_highlight_indices(
- h.command
- .escape_control()
- .split_ascii_whitespace()
- .join(" ")
- .as_str(),
- );
-
- let mut pos = 0;
- for section in h.command.escape_control().split_ascii_whitespace() {
- if pos != 0 {
- self.draw(" ", Style::from_crossterm(style));
- }
- for ch in section.chars() {
- if self.x > self.list_area.width {
- // Avoid attempting to draw a command section beyond the width
- // of the list
- return;
- }
- let mut style = style;
- if highlight_indices.contains(&pos) {
- if row_highlighted {
- // if the row is highlighted bold is not enough as the whole row is bold
- // change the color too
- style = style_alertwarn();
- }
- style.attributes.set(style::Attribute::Bold);
- }
- let s = ch.to_string();
- self.draw(&s, Style::from_crossterm(style));
- pos += s.len();
- }
- pos += 1;
- }
- }
-
- /// Render the absolute datetime column (e.g., "2025-01-22 14:35")
- fn datetime(&mut self, h: &History, width: u16) {
- let style = style_annotation();
- // Format: YYYY-MM-DD HH:MM
- let formatted = h
- .timestamp
- .format(
- &time::format_description::parse("[year]-[month]-[day] [hour]:[minute]")
- .expect("valid format"),
- )
- .unwrap_or_else(|_| "????-??-?? ??:??".to_string());
- let w = width as usize;
- let display = format!("{formatted:w$}");
- self.draw(&display, Style::from_crossterm(style));
- }
-
- /// Render the directory column (working directory, truncated)
- fn directory(&mut self, h: &History, width: u16) {
- let style = style_annotation();
- let w = width as usize;
- let cwd = &h.cwd;
- let char_count = cwd.chars().count();
- // Truncate from the left with "..." if too long, plus trailing space
- // Use character count for comparison and skip for UTF-8 safety
- let display = if char_count > w && w >= 4 {
- let truncated: String = cwd.chars().skip(char_count - (w - 3)).collect();
- format!("...{truncated}")
- } else {
- format!("{cwd:w$}")
- };
- self.draw(&display, Style::from_crossterm(style));
- }
-
- /// Render the host column (just the hostname)
- fn host(&mut self, h: &History, width: u16) {
- let style = style_annotation();
- let w = width as usize;
- // Database stores hostname as "hostname:username"
- let host = h.hostname.split(':').next().unwrap_or(&h.hostname);
- let char_count = host.chars().count();
- // Use character count for comparison and take for UTF-8 safety
- let display = if char_count > w && w >= 4 {
- let truncated: String = host.chars().take(w.saturating_sub(4)).collect();
- format!("{truncated}...")
- } else {
- format!("{host:w$}")
- };
- self.draw(&display, Style::from_crossterm(style));
- }
-
- /// Render the user column
- fn user(&mut self, h: &History, width: u16) {
- let style = style_annotation();
- let w = width as usize;
- // Database stores hostname as "hostname:username"
- let user = h.hostname.split(':').nth(1).unwrap_or("");
- let char_count = user.chars().count();
- // Use character count for comparison and take for UTF-8 safety
- let display = if char_count > w && w >= 4 {
- let truncated: String = user.chars().take(w.saturating_sub(4)).collect();
- format!("{truncated}...")
- } else {
- format!("{user:w$}")
- };
- self.draw(&display, Style::from_crossterm(style));
- }
-
- /// Render the exit code column
- fn exit_code(&mut self, h: &History, width: u16) {
- let style = if h.success() {
- style_alertinfo()
- } else {
- style_alerterror()
- };
- let w = width as usize;
- let display = format!("{:>w$}", h.exit);
- self.draw(&display, Style::from_crossterm(style));
- }
-
- fn draw(&mut self, s: &str, mut style: Style) {
- let cx = self.list_area.left() + self.x;
-
- let cy = if self.inverted {
- self.list_area.top() + self.y
- } else {
- self.list_area.bottom() - self.y - 1
- };
-
- if self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected)
- {
- style = style.add_modifier(Modifier::REVERSED);
- }
-
- let w = (self.list_area.width - self.x) as usize;
- self.x += self.buf.set_stringn(cx, cy, s, w, style).0 - cx;
- }
-}
diff --git a/crates/client/src/command/client/search/inspector.rs b/crates/client/src/command/client/search/inspector.rs
deleted file mode 100644
index 186dcd3a..00000000
--- a/crates/client/src/command/client/search/inspector.rs
+++ /dev/null
@@ -1,414 +0,0 @@
-use std::time::Duration;
-use time::macros::format_description;
-
-use crate::atuin_client::{
- history::{History, HistoryStats},
- settings::{Settings, Timezone},
- theme::{style_annotation, style_base, style_important},
-};
-use ratatui::{
- Frame,
- backend::FromCrossterm,
- layout::Rect,
- prelude::{Constraint, Direction, Layout},
- style::Style,
- text::{Span, Text},
- widgets::{Bar, BarChart, BarGroup, Block, Borders, Padding, Paragraph, Row, Table},
-};
-
-use super::duration::format_duration;
-
-use super::interactive::{Compactness, to_compactness};
-
-#[expect(clippy::cast_sign_loss)]
-fn u64_or_zero(num: i64) -> u64 {
- if num < 0 { 0 } else { num as u64 }
-}
-
-pub(crate) fn draw_commands(
- f: &mut Frame<'_>,
- parent: Rect,
- history: &History,
- stats: &HistoryStats,
- compact: bool,
-) {
- let commands = Layout::default()
- .direction(if compact {
- Direction::Vertical
- } else {
- Direction::Horizontal
- })
- .constraints(if compact {
- [
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Min(0),
- ]
- } else {
- [
- Constraint::Ratio(1, 4),
- Constraint::Ratio(1, 2),
- Constraint::Ratio(1, 4),
- ]
- })
- .split(parent);
-
- let command = Paragraph::new(Text::from(Span::styled(
- history.command.clone(),
- Style::from_crossterm(style_important()),
- )))
- .block(if compact {
- Block::new()
- .borders(Borders::NONE)
- .style(Style::from_crossterm(style_base()))
- } else {
- Block::new()
- .borders(Borders::ALL)
- .style(Style::from_crossterm(style_base()))
- .title("Command")
- .padding(Padding::horizontal(1))
- });
-
- let previous = Paragraph::new(
- stats
- .previous
- .clone()
- .map_or_else(|| "[No previous command]".to_string(), |prev| prev.command),
- )
- .block(if compact {
- Block::new()
- .borders(Borders::NONE)
- .style(Style::from_crossterm(style_annotation()))
- } else {
- Block::new()
- .borders(Borders::ALL)
- .style(Style::from_crossterm(style_annotation()))
- .title("Previous command")
- .padding(Padding::horizontal(1))
- });
-
- // Add [] around blank text, as when this is shown in a list
- // compacted, it makes it more obviously control text.
- let next = Paragraph::new(
- stats
- .next
- .clone()
- .map_or_else(|| "[No next command]".to_string(), |next| next.command),
- )
- .block(if compact {
- Block::new()
- .borders(Borders::NONE)
- .style(Style::from_crossterm(style_annotation()))
- } else {
- Block::new()
- .borders(Borders::ALL)
- .title("Next command")
- .padding(Padding::horizontal(1))
- .style(Style::from_crossterm(style_annotation()))
- });
-
- f.render_widget(previous, commands[0]);
- f.render_widget(command, commands[1]);
- f.render_widget(next, commands[2]);
-}
-
-pub(crate) fn draw_stats_table(
- f: &mut Frame<'_>,
- parent: Rect,
- history: &History,
- tz: Timezone,
- stats: &HistoryStats,
-) {
- let duration = Duration::from_nanos(u64_or_zero(history.duration));
- let avg_duration = Duration::from_nanos(stats.average_duration);
- let (host, user) = history.hostname.split_once(':').unwrap_or(("", ""));
-
- let rows = [
- Row::new(vec!["Host".to_string(), host.to_string()]),
- Row::new(vec!["User".to_string(), user.to_string()]),
- Row::new(vec![
- "Time".to_string(),
- history.timestamp.to_offset(tz.0).to_string(),
- ]),
- Row::new(vec!["Duration".to_string(), format_duration(duration)]),
- Row::new(vec![
- "Avg duration".to_string(),
- format_duration(avg_duration),
- ]),
- Row::new(vec!["Exit".to_string(), history.exit.to_string()]),
- Row::new(vec!["Directory".to_string(), history.cwd.clone()]),
- Row::new(vec!["Session".to_string(), history.session.clone()]),
- Row::new(vec!["Total runs".to_string(), stats.total.to_string()]),
- ];
-
- let widths = [Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)];
-
- let table = Table::new(rows, widths).column_spacing(1).block(
- Block::default()
- .title("Command stats")
- .borders(Borders::ALL)
- .style(Style::from_crossterm(style_base()))
- .padding(Padding::vertical(1)),
- );
-
- f.render_widget(table, parent);
-}
-
-fn num_to_day(num: &str) -> String {
- match num {
- "0" => "Sunday".to_string(),
- "1" => "Monday".to_string(),
- "2" => "Tuesday".to_string(),
- "3" => "Wednesday".to_string(),
- "4" => "Thursday".to_string(),
- "5" => "Friday".to_string(),
- "6" => "Saturday".to_string(),
- _ => "Invalid day".to_string(),
- }
-}
-
-fn sort_duration_over_time(durations: &[(String, i64)]) -> Vec<(String, i64)> {
- let format = format_description!("[day]-[month]-[year]");
- let output = format_description!("[month]/[year repr:last_two]");
-
- let mut durations: Vec<(time::Date, i64)> = durations
- .iter()
- .map(|d| {
- (
- time::Date::parse(d.0.as_str(), &format).expect("invalid date string from sqlite"),
- d.1,
- )
- })
- .collect();
-
- durations.sort_by_key(|a| a.0);
-
- durations
- .iter()
- .map(|(date, duration)| {
- (
- date.format(output).expect("failed to format sqlite date"),
- *duration,
- )
- })
- .collect()
-}
-
-fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats) {
- let exits: Vec<Bar<'_>> = stats
- .exits
- .iter()
- .map(|(exit, count)| {
- Bar::default()
- .label(exit.to_string())
- .value(u64_or_zero(*count))
- })
- .collect();
-
- let exits = BarChart::default()
- .block(
- Block::default()
- .title("Exit code distribution")
- .style(Style::from_crossterm(style_base()))
- .borders(Borders::ALL),
- )
- .bar_width(3)
- .bar_gap(1)
- .bar_style(Style::default())
- .value_style(Style::default())
- .label_style(Style::default())
- .data(BarGroup::default().bars(&exits));
-
- let day_of_week: Vec<Bar<'_>> = stats
- .day_of_week
- .iter()
- .map(|(day, count)| {
- Bar::default()
- .label(num_to_day(day.as_str()))
- .value(u64_or_zero(*count))
- })
- .collect();
-
- let day_of_week = BarChart::default()
- .block(
- Block::default()
- .title("Runs per day")
- .style(Style::from_crossterm(style_base()))
- .borders(Borders::ALL),
- )
- .bar_width(3)
- .bar_gap(1)
- .bar_style(Style::default())
- .value_style(Style::default())
- .label_style(Style::default())
- .data(BarGroup::default().bars(&day_of_week));
-
- let duration_over_time = sort_duration_over_time(&stats.duration_over_time);
- let duration_over_time: Vec<Bar<'_>> = duration_over_time
- .iter()
- .map(|(date, duration)| {
- let d = Duration::from_nanos(u64_or_zero(*duration));
- Bar::default()
- .label(date.clone())
- .value(u64_or_zero(*duration))
- .text_value(format_duration(d))
- })
- .collect();
-
- let duration_over_time = BarChart::default()
- .block(
- Block::default()
- .title("Duration over time")
- .style(Style::from_crossterm(style_base()))
- .borders(Borders::ALL),
- )
- .bar_width(5)
- .bar_gap(1)
- .bar_style(Style::default())
- .value_style(Style::default())
- .label_style(Style::default())
- .data(BarGroup::default().bars(&duration_over_time));
-
- let layout = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Ratio(1, 3),
- Constraint::Ratio(1, 3),
- Constraint::Ratio(1, 3),
- ])
- .split(parent);
-
- f.render_widget(exits, layout[0]);
- f.render_widget(day_of_week, layout[1]);
- f.render_widget(duration_over_time, layout[2]);
-}
-
-pub(crate) fn draw(
- f: &mut Frame<'_>,
- chunk: Rect,
- history: &History,
- stats: &HistoryStats,
- settings: &Settings,
- tz: Timezone,
-) {
- let compactness = to_compactness(f, settings);
-
- match compactness {
- Compactness::Ultracompact => draw_ultracompact(f, chunk, history, stats),
- _ => draw_full(f, chunk, history, stats, tz),
- }
-}
-
-pub(crate) fn draw_ultracompact(
- f: &mut Frame<'_>,
- chunk: Rect,
- history: &History,
- stats: &HistoryStats,
-) {
- draw_commands(f, chunk, history, stats, true);
-}
-
-pub(crate) fn draw_full(
- f: &mut Frame<'_>,
- chunk: Rect,
- history: &History,
- stats: &HistoryStats,
- tz: Timezone,
-) {
- let vert_layout = Layout::default()
- .direction(Direction::Vertical)
- .constraints([Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)])
- .split(chunk);
-
- let stats_layout = Layout::default()
- .direction(Direction::Horizontal)
- .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])
- .split(vert_layout[1]);
-
- draw_commands(f, vert_layout[0], history, stats, false);
- draw_stats_table(f, stats_layout[0], history, tz, stats);
- draw_stats_charts(f, stats_layout[1], stats);
-}
-
-#[cfg(test)]
-mod tests {
- use super::draw_ultracompact;
- use crate::atuin_client::history::{History, HistoryId, HistoryStats};
- use ratatui::{
- backend::TestBackend,
- prelude::{Line, Rect, Terminal},
- };
- use time::OffsetDateTime;
-
- fn mock_history_stats() -> (History, HistoryStats) {
- let history = History {
- id: HistoryId::from("test1".to_string()),
- timestamp: OffsetDateTime::now_utc(),
- duration: 3,
- exit: 0,
- command: "/bin/cmd".to_string(),
- cwd: "/toot".to_string(),
- session: "sesh1".to_string(),
- hostname: "hostn".to_string(),
- author: "hostn".to_string(),
- intent: None,
- deleted_at: None,
- };
- let next = History {
- id: HistoryId::from("test2".to_string()),
- timestamp: OffsetDateTime::now_utc(),
- duration: 2,
- exit: 0,
- command: "/bin/cmd -os".to_string(),
- cwd: "/toot".to_string(),
- session: "sesh1".to_string(),
- hostname: "hostn".to_string(),
- author: "hostn".to_string(),
- intent: None,
- deleted_at: None,
- };
- let prev = History {
- id: HistoryId::from("test3".to_string()),
- timestamp: OffsetDateTime::now_utc(),
- duration: 1,
- exit: 0,
- command: "/bin/cmd -a".to_string(),
- cwd: "/toot".to_string(),
- session: "sesh1".to_string(),
- hostname: "hostn".to_string(),
- author: "hostn".to_string(),
- intent: None,
- deleted_at: None,
- };
- let stats = HistoryStats {
- next: Some(next.clone()),
- previous: Some(prev.clone()),
- total: 2,
- average_duration: 3,
- exits: Vec::new(),
- day_of_week: Vec::new(),
- duration_over_time: Vec::new(),
- };
- (history, stats)
- }
-
- #[test]
- fn test_output_looks_correct_for_ultracompact() {
- let backend = TestBackend::new(22, 5);
- let mut terminal = Terminal::new(backend).expect("Could not create terminal");
- let chunk = Rect::new(0, 0, 22, 5);
- let (history, stats) = mock_history_stats();
- let prev = stats.previous.clone().unwrap();
- let next = stats.next.clone().unwrap();
-
- drop(terminal.draw(|f| draw_ultracompact(f, chunk, &history, &stats)));
- let mut lines = [" "; 5].map(|l| Line::from(l));
- for (n, entry) in [prev, history, next].iter().enumerate() {
- let mut l = lines[n].to_string();
- l.replace_range(0..entry.command.len(), &entry.command);
- lines[n] = Line::from(l);
- }
-
- terminal.backend().assert_buffer_lines(lines);
- }
-}
diff --git a/crates/client/src/command/client/search/interactive.rs b/crates/client/src/command/client/search/interactive.rs
deleted file mode 100644
index b8cf53db..00000000
--- a/crates/client/src/command/client/search/interactive.rs
+++ /dev/null
@@ -1,3024 +0,0 @@
-use std::{
- io::{IsTerminal, Write, stdout},
- time::Duration,
-};
-
-#[cfg(unix)]
-use std::io::Read as _;
-
-use crate::{
- atuin_client::{
- database::ClientSqlite,
- theme::{style_annotation, style_base, style_important},
- },
- atuin_common::{shell::Shell, utils::Escapable as _},
-};
-use eyre::Result;
-use time::OffsetDateTime;
-use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
-
-use super::{
- cursor::Cursor,
- engines::{SearchEngine, SearchState},
- history_list::{HistoryList, ListState},
-};
-use crate::atuin_client::{
- database::{Context, current_context},
- history::{History, HistoryId, HistoryStats, store::HistoryStore},
- settings::{
- CursorStyle, ExitMode, FilterMode, KeymapMode, PreviewStrategy, SearchMode, Settings,
- UiColumn,
- },
-};
-
-use crate::command::client::search::history_list::HistoryHighlighter;
-use crate::command::client::search::keybindings::KeymapSet;
-use crate::{VERSION, command::client::search::engines};
-
-use ratatui::{
- Frame, Terminal, TerminalOptions, Viewport,
- backend::{CrosstermBackend, FromCrossterm},
- crossterm::{
- cursor::SetCursorStyle,
- event::{self, Event, KeyEvent, MouseEvent},
- execute, queue, terminal,
- },
- layout::{Alignment, Constraint, Direction, Layout},
- prelude::Rect,
- style::{Modifier, Style},
- text::{Line, Span, Text},
- widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Tabs},
-};
-
-#[cfg(not(target_os = "windows"))]
-use ratatui::crossterm::event::{
- KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
-};
-
-const TAB_TITLES: [&str; 2] = ["Search", "Inspect"];
-
-pub(crate) enum InputAction {
- Accept(usize),
- AcceptInspecting,
- Copy(usize),
- Delete(usize),
- DeleteAllMatching(usize),
- ReturnOriginal,
- ReturnQuery,
- Continue,
- Redraw,
- SwitchContext(Option<usize>),
-}
-
-#[derive(Clone)]
-pub(crate) struct InspectingState {
- current: Option<HistoryId>,
- next: Option<HistoryId>,
- previous: Option<HistoryId>,
-}
-
-impl InspectingState {
- pub(crate) fn move_to_previous(&mut self) {
- let previous = self.previous.clone();
- self.reset();
- self.current = previous;
- }
-
- pub(crate) fn move_to_next(&mut self) {
- let next = self.next.clone();
- self.reset();
- self.current = next;
- }
-
- pub(crate) fn reset(&mut self) {
- self.current = None;
- self.next = None;
- self.previous = None;
- }
-}
-
-pub(crate) fn to_compactness(f: &Frame<'_>, settings: &Settings) -> Compactness {
- if match settings.style {
- crate::atuin_client::settings::Style::Auto => f.area().height < 14,
- crate::atuin_client::settings::Style::Compact => true,
- crate::atuin_client::settings::Style::Full => false,
- } {
- if settings.auto_hide_height != 0 && f.area().height <= settings.auto_hide_height {
- Compactness::Ultracompact
- } else {
- Compactness::Compact
- }
- } else {
- Compactness::Full
- }
-}
-
-#[expect(clippy::struct_field_names)]
-#[expect(clippy::struct_excessive_bools)]
-pub(crate) struct State {
- history_count: i64,
- results_state: ListState,
- switched_search_mode: bool,
- search_mode: SearchMode,
- results_len: usize,
- accept: bool,
- keymap_mode: KeymapMode,
- prefix: bool,
- current_cursor: Option<CursorStyle>,
- tab_index: usize,
- pending_vim_key: Option<char>,
- original_input_empty: bool,
-
- pub(crate) inspecting_state: InspectingState,
-
- keymaps: KeymapSet,
- search: SearchState,
- engine: Box<dyn SearchEngine>,
- now: Box<dyn Fn() -> OffsetDateTime + Send>,
-}
-
-#[derive(Clone, Copy)]
-pub(crate) enum Compactness {
- Ultracompact,
- Compact,
- Full,
-}
-
-#[derive(Clone, Copy)]
-struct StyleState {
- compactness: Compactness,
- invert: bool,
- inner_width: usize,
-}
-
-impl State {
- async fn query_results(
- &mut self,
- db: &mut ClientSqlite,
- smart_sort: bool,
- ) -> Result<Vec<History>> {
- let results = self.engine.query(&self.search, db).await?;
-
- self.inspecting_state = InspectingState {
- current: None,
- next: None,
- previous: None,
- };
- self.results_state.select(0);
- self.results_len = results.len();
-
- if smart_sort {
- Ok(crate::atuin_history::sort::sort(
- self.search.input.as_str(),
- results,
- ))
- } else {
- Ok(results)
- }
- }
-
- fn handle_input(&mut self, settings: &Settings, input: &Event) -> InputAction {
- match input {
- Event::Key(k) => self.handle_key_input(settings, k),
- Event::Mouse(m) => self.handle_mouse_input(*m, settings.invert),
- Event::Paste(d) => self.handle_paste_input(d),
- _ => InputAction::Continue,
- }
- }
-
- fn handle_mouse_input(&mut self, input: MouseEvent, inverted: bool) -> InputAction {
- match (input.kind, inverted) {
- (event::MouseEventKind::ScrollDown, false)
- | (event::MouseEventKind::ScrollUp, true) => {
- self.scroll_down(1);
- }
- (event::MouseEventKind::ScrollDown, true)
- | (event::MouseEventKind::ScrollUp, false) => {
- self.scroll_up(1);
- }
- _ => {}
- }
- InputAction::Continue
- }
-
- fn handle_paste_input(&mut self, input: &str) -> InputAction {
- for i in input.chars() {
- self.search.input.insert(i);
- }
- InputAction::Continue
- }
-
- fn cast_cursor_style(style: CursorStyle) -> SetCursorStyle {
- match style {
- CursorStyle::DefaultUserShape => SetCursorStyle::DefaultUserShape,
- CursorStyle::BlinkingBlock => SetCursorStyle::BlinkingBlock,
- CursorStyle::SteadyBlock => SetCursorStyle::SteadyBlock,
- CursorStyle::BlinkingUnderScore => SetCursorStyle::BlinkingUnderScore,
- CursorStyle::SteadyUnderScore => SetCursorStyle::SteadyUnderScore,
- CursorStyle::BlinkingBar => SetCursorStyle::BlinkingBar,
- CursorStyle::SteadyBar => SetCursorStyle::SteadyBar,
- }
- }
-
- fn set_keymap_cursor(&mut self, settings: &Settings, keymap_name: &str) {
- let cursor_style = if keymap_name == "__clear__" {
- None
- } else {
- settings.keymap_cursor.get(keymap_name).copied()
- }
- .or_else(|| self.current_cursor.map(|_| CursorStyle::DefaultUserShape));
-
- if cursor_style != self.current_cursor
- && let Some(style) = cursor_style
- {
- self.current_cursor = cursor_style;
- drop(execute!(stdout(), Self::cast_cursor_style(style)));
- }
- }
-
- pub(crate) fn initialize_keymap_cursor(&mut self, settings: &Settings) {
- match self.keymap_mode {
- KeymapMode::Emacs => self.set_keymap_cursor(settings, "emacs"),
- KeymapMode::VimNormal => self.set_keymap_cursor(settings, "vim_normal"),
- KeymapMode::VimInsert => self.set_keymap_cursor(settings, "vim_insert"),
- KeymapMode::Auto => {}
- }
- }
-
- pub(crate) fn finalize_keymap_cursor(&mut self, settings: &Settings) {
- match settings.keymap_mode_shell {
- KeymapMode::Emacs => self.set_keymap_cursor(settings, "emacs"),
- KeymapMode::VimNormal => self.set_keymap_cursor(settings, "vim_normal"),
- KeymapMode::VimInsert => self.set_keymap_cursor(settings, "vim_insert"),
- KeymapMode::Auto => self.set_keymap_cursor(settings, "__clear__"),
- }
- }
-
- fn handle_key_exit(settings: &Settings) -> InputAction {
- match settings.exit_mode {
- ExitMode::ReturnOriginal => InputAction::ReturnOriginal,
- ExitMode::ReturnQuery => InputAction::ReturnQuery,
- }
- }
-
- /// Select the keymap for the current mode (ignoring prefix).
- fn mode_keymap(&self) -> &super::keybindings::Keymap {
- if self.tab_index == 1 {
- &self.keymaps.inspector
- } else {
- match self.keymap_mode {
- KeymapMode::Emacs | KeymapMode::Auto => &self.keymaps.emacs,
- KeymapMode::VimNormal => &self.keymaps.vim_normal,
- KeymapMode::VimInsert => &self.keymaps.vim_insert,
- }
- }
- }
-
- /// Whether the current mode supports character insertion on unmatched keys.
- fn is_insert_mode(&self) -> bool {
- matches!(
- self.keymap_mode,
- KeymapMode::Emacs | KeymapMode::Auto | KeymapMode::VimInsert
- )
- }
-
- fn handle_key_input(&mut self, settings: &Settings, input: &KeyEvent) -> InputAction {
- use super::keybindings::Action;
- use super::keybindings::EvalContext;
- use super::keybindings::key::{KeyCodeValue, KeyInput, SingleKey};
-
- // Skip release events
- if input.kind == event::KeyEventKind::Release {
- return InputAction::Continue;
- }
-
- // Reset switched_search_mode at start of each key event
- self.switched_search_mode = false;
-
- // Build evaluation context from current state
- let ctx = EvalContext {
- cursor_position: self.search.input.position(),
- input_width: UnicodeWidthStr::width(self.search.input.as_str()),
- input_byte_len: self.search.input.as_str().len(),
- selected_index: self.results_state.selected(),
- results_len: self.results_len,
- original_input_empty: self.original_input_empty,
- has_context: self.search.custom_context.is_some(),
- };
-
- // Convert KeyEvent to SingleKey
- let Some(single) = SingleKey::from_event(input) else {
- return InputAction::Continue;
- };
-
- // --- Phase 1: Resolve (take pending key first, then immutable borrows) ---
-
- // Take pending key before any immutable borrows of self
- let pending = self.pending_vim_key.take();
-
- // If in prefix mode, try prefix keymap first (single keys only)
- let prefix_action = if self.prefix {
- let ki = KeyInput::Single(single.clone());
- self.keymaps.prefix.resolve(&ki, &ctx)
- } else {
- None
- };
-
- // The if-let/else-if chain here is clearer than map_or_else with nested closures.
- #[expect(clippy::option_if_let_else)]
- let (action, new_pending) = if prefix_action.is_some() {
- (prefix_action, None)
- } else {
- // Use mode keymap (handles both single and multi-key sequences)
- let keymap = self.mode_keymap();
-
- if let Some(pending_char) = pending {
- // We have a pending key from a previous press (e.g., first 'g' of 'gg')
- let pending_single = SingleKey {
- code: KeyCodeValue::Char(pending_char),
- ctrl: false,
- alt: false,
- shift: false,
- super_key: false,
- };
- let seq = KeyInput::Sequence(vec![pending_single, single.clone()]);
- let action = keymap
- .resolve(&seq, &ctx)
- .or_else(|| keymap.resolve(&KeyInput::Single(single.clone()), &ctx));
- (action, None)
- } else if keymap.has_sequence_starting_with(&single)
- && matches!(single.code, KeyCodeValue::Char(_))
- && !single.ctrl
- && !single.alt
- {
- // This key starts a multi-key sequence; wait for next key
- let KeyCodeValue::Char(c) = single.code else {
- unreachable!()
- };
- (Some(Action::Noop), Some(c))
- } else {
- (
- keymap.resolve(&KeyInput::Single(single.clone()), &ctx),
- None,
- )
- }
- };
-
- // --- Phase 2: Apply mutations ---
- self.pending_vim_key = new_pending;
-
- // Reset prefix (before execute, so EnterPrefixMode can re-set it)
- self.prefix = false;
-
- if let Some(action) = action {
- self.execute_action(&action, settings)
- } else {
- // No action matched. In insert-capable modes, insert the character.
- if self.is_insert_mode() && !single.ctrl && !single.alt {
- match single.code {
- KeyCodeValue::Char(c) => {
- self.search.input.insert(c);
- }
- KeyCodeValue::Space => {
- self.search.input.insert(' ');
- }
- _ => {}
- }
- }
- InputAction::Continue
- }
- }
-
- fn scroll_down(&mut self, scroll_len: usize) {
- let i = self.results_state.selected().saturating_sub(scroll_len);
- self.inspecting_state.reset();
- self.results_state.select(i);
- }
-
- fn scroll_up(&mut self, scroll_len: usize) {
- let i = self.results_state.selected() + scroll_len;
- self.results_state
- .select(i.min(self.results_len.saturating_sub(1)));
- self.inspecting_state.reset();
- }
-
- /// Execute a resolved action, performing all side effects and returning the
- /// appropriate `InputAction` for the event loop.
- ///
- /// This is the "do it" half of the resolve+execute pipeline. The resolver
- /// decides *what* to do (which `Action`), and this function carries it out.
- ///
- /// Invert handling: scroll actions (`SelectNext`, `ScrollPageDown`, etc.) account
- /// for `settings.invert` so that keybindings are always in "visual" terms —
- /// users never need to think about invert in their keybinding config.
- #[expect(clippy::too_many_lines)]
- pub(crate) fn execute_action(
- &mut self,
- action: &super::keybindings::Action,
- settings: &Settings,
- ) -> InputAction {
- use crate::command::client::search::keybindings::Action;
-
- match action {
- // -- Cursor movement --
- Action::CursorLeft => {
- self.search.input.left();
- InputAction::Continue
- }
- Action::CursorRight => {
- self.search.input.right();
- InputAction::Continue
- }
- Action::CursorWordLeft => {
- self.search
- .input
- .prev_word(&settings.word_chars, settings.word_jump_mode);
- InputAction::Continue
- }
- Action::CursorWordRight => {
- self.search
- .input
- .next_word(&settings.word_chars, settings.word_jump_mode);
- InputAction::Continue
- }
- Action::CursorWordEnd => {
- self.search.input.word_end(&settings.word_chars);
- InputAction::Continue
- }
- Action::CursorStart => {
- self.search.input.start();
- InputAction::Continue
- }
- Action::CursorEnd => {
- self.search.input.end();
- InputAction::Continue
- }
-
- // -- Editing --
- Action::DeleteCharBefore => {
- self.search.input.back();
- InputAction::Continue
- }
- Action::DeleteCharAfter => {
- self.search.input.remove();
- InputAction::Continue
- }
- Action::DeleteWordBefore => {
- self.search
- .input
- .remove_prev_word(&settings.word_chars, settings.word_jump_mode);
- InputAction::Continue
- }
- Action::DeleteWordAfter => {
- self.search
- .input
- .remove_next_word(&settings.word_chars, settings.word_jump_mode);
- InputAction::Continue
- }
- Action::DeleteToWordBoundary => {
- // ctrl-w: remove trailing whitespace, then delete to word boundary
- while matches!(self.search.input.back(), Some(c) if c.is_whitespace()) {}
- while self.search.input.left() {
- if self.search.input.char().unwrap().is_whitespace() {
- self.search.input.right();
- break;
- }
- self.search.input.remove();
- }
- InputAction::Continue
- }
- Action::ClearLine => {
- self.search.input.clear();
- InputAction::Continue
- }
- Action::ClearToStart => {
- self.search.input.clear_to_start();
- InputAction::Continue
- }
- Action::ClearToEnd => {
- self.search.input.clear_to_end();
- InputAction::Continue
- }
-
- // -- List navigation (invert-aware) --
- Action::SelectNext => {
- if settings.invert {
- self.scroll_up(1);
- } else {
- self.scroll_down(1);
- }
- InputAction::Continue
- }
- Action::SelectPrevious => {
- if settings.invert {
- self.scroll_down(1);
- } else {
- self.scroll_up(1);
- }
- InputAction::Continue
- }
- // -- Page/half-page scroll (invert-aware) --
- Action::ScrollHalfPageUp => {
- let scroll_len = self
- .results_state
- .max_entries()
- .saturating_sub(settings.scroll_context_lines)
- / 2;
- if settings.invert {
- self.scroll_down(scroll_len);
- } else {
- self.scroll_up(scroll_len);
- }
- InputAction::Continue
- }
- Action::ScrollHalfPageDown => {
- let scroll_len = self
- .results_state
- .max_entries()
- .saturating_sub(settings.scroll_context_lines)
- / 2;
- if settings.invert {
- self.scroll_up(scroll_len);
- } else {
- self.scroll_down(scroll_len);
- }
- InputAction::Continue
- }
- Action::ScrollPageUp => {
- let scroll_len = self
- .results_state
- .max_entries()
- .saturating_sub(settings.scroll_context_lines);
- if settings.invert {
- self.scroll_down(scroll_len);
- } else {
- self.scroll_up(scroll_len);
- }
- InputAction::Continue
- }
- Action::ScrollPageDown => {
- let scroll_len = self
- .results_state
- .max_entries()
- .saturating_sub(settings.scroll_context_lines);
- if settings.invert {
- self.scroll_up(scroll_len);
- } else {
- self.scroll_down(scroll_len);
- }
- InputAction::Continue
- }
-
- // -- Absolute jumps (invert-aware) --
- Action::ScrollToTop => {
- // Visual top of history
- if settings.invert {
- self.results_state.select(0);
- } else {
- let last_idx = self.results_len.saturating_sub(1);
- self.results_state.select(last_idx);
- }
- self.inspecting_state.reset();
- InputAction::Continue
- }
- Action::ScrollToBottom => {
- // Visual bottom of history
- if settings.invert {
- let last_idx = self.results_len.saturating_sub(1);
- self.results_state.select(last_idx);
- } else {
- self.results_state.select(0);
- }
- self.inspecting_state.reset();
- InputAction::Continue
- }
- Action::ScrollToScreenTop => {
- // H — jump to top of visible screen
- let top = self.results_state.offset();
- let visible = self.results_state.max_entries().min(self.results_len);
- let bottom = top + visible.saturating_sub(1);
- self.results_state
- .select(bottom.min(self.results_len.saturating_sub(1)));
- self.inspecting_state.reset();
- InputAction::Continue
- }
- Action::ScrollToScreenMiddle => {
- // M — jump to middle of visible screen
- let top = self.results_state.offset();
- let visible = self.results_state.max_entries().min(self.results_len);
- let middle = top + visible / 2;
- self.results_state
- .select(middle.min(self.results_len.saturating_sub(1)));
- self.inspecting_state.reset();
- InputAction::Continue
- }
- Action::ScrollToScreenBottom => {
- // L — jump to bottom of visible screen
- let top_visible = self.results_state.offset();
- self.results_state.select(top_visible);
- self.inspecting_state.reset();
- InputAction::Continue
- }
-
- // -- Commands --
- Action::Accept => {
- if self.tab_index == 1 {
- return InputAction::AcceptInspecting;
- }
- self.accept = true;
- InputAction::Accept(self.results_state.selected())
- }
- Action::AcceptNth(n) => {
- self.accept = true;
- InputAction::Accept(self.results_state.selected() + *n as usize)
- }
- Action::ReturnSelection => {
- if self.tab_index == 1 {
- return InputAction::AcceptInspecting;
- }
- InputAction::Accept(self.results_state.selected())
- }
- Action::ReturnSelectionNth(n) => {
- InputAction::Accept(self.results_state.selected() + *n as usize)
- }
- Action::Copy => InputAction::Copy(self.results_state.selected()),
- Action::Delete => InputAction::Delete(self.results_state.selected()),
- Action::DeleteAll => InputAction::DeleteAllMatching(self.results_state.selected()),
- Action::ReturnOriginal => InputAction::ReturnOriginal,
- Action::ReturnQuery => InputAction::ReturnQuery,
- Action::Exit => Self::handle_key_exit(settings),
- Action::Redraw => InputAction::Redraw,
- Action::CycleFilterMode => {
- self.search.rotate_filter_mode(settings, 1);
- InputAction::Continue
- }
- Action::CycleSearchMode => {
- self.switched_search_mode = true;
- self.search_mode = self.search_mode.next(settings);
- self.engine = engines::engine(self.search_mode, settings);
- InputAction::Continue
- }
- Action::SwitchContext => {
- InputAction::SwitchContext(Some(self.results_state.selected()))
- }
- Action::ClearContext => InputAction::SwitchContext(None),
- Action::ToggleTab => {
- self.tab_index = (self.tab_index + 1) % TAB_TITLES.len();
- InputAction::Continue
- }
-
- // -- Mode changes --
- Action::VimEnterNormal => {
- self.set_keymap_cursor(settings, "vim_normal");
- self.keymap_mode = KeymapMode::VimNormal;
- InputAction::Continue
- }
- Action::VimEnterInsert => {
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- InputAction::Continue
- }
- Action::VimEnterInsertAfter => {
- self.search.input.right();
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- InputAction::Continue
- }
- Action::VimEnterInsertAtStart => {
- self.search.input.start();
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- InputAction::Continue
- }
- Action::VimEnterInsertAtEnd => {
- self.search.input.end();
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- InputAction::Continue
- }
- Action::VimSearchInsert => {
- self.search.input.clear();
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- InputAction::Continue
- }
- Action::VimChangeToEnd => {
- self.search.input.clear_to_end();
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- InputAction::Continue
- }
- Action::EnterPrefixMode => {
- self.prefix = true;
- InputAction::Continue
- }
-
- // -- Inspector --
- Action::InspectPrevious => {
- self.inspecting_state.move_to_previous();
- InputAction::Redraw
- }
- Action::InspectNext => {
- self.inspecting_state.move_to_next();
- InputAction::Redraw
- }
-
- // -- Special --
- Action::Noop => InputAction::Continue,
- }
- }
-
- #[expect(clippy::cast_possible_truncation)]
- #[expect(clippy::bool_to_int_with_if)]
- fn calc_preview_height(
- settings: &Settings,
- results: &[History],
- selected: usize,
- tab_index: usize,
- compactness: Compactness,
- border_size: u16,
- preview_width: u16,
- ) -> u16 {
- if settings.show_preview
- && settings.preview.strategy == PreviewStrategy::Auto
- && tab_index == 0
- && !results.is_empty()
- {
- let length_current_cmd = results[selected].command.len() as u16;
- // calculate the number of newlines in the command
- let num_newlines = results[selected]
- .command
- .chars()
- .filter(|&c| c == '\n')
- .count() as u16;
- if num_newlines > 0 {
- std::cmp::min(
- settings.max_preview_height,
- results[selected]
- .command
- .split('\n')
- .map(|line| {
- (line.len() as u16 + preview_width - 1 - border_size)
- / (preview_width - border_size)
- })
- .sum(),
- ) + border_size * 2
- }
- // The '- 19' takes the characters before the command (duration and time) into account
- else if length_current_cmd > preview_width - 19 {
- std::cmp::min(
- settings.max_preview_height,
- (length_current_cmd + preview_width - 1 - border_size)
- / (preview_width - border_size),
- ) + border_size * 2
- } else {
- 1
- }
- } else if settings.show_preview
- && settings.preview.strategy == PreviewStrategy::Static
- && tab_index == 0
- {
- let longest_command = results
- .iter()
- .max_by(|h1, h2| h1.command.len().cmp(&h2.command.len()));
- longest_command.map_or(0, |v| {
- std::cmp::min(
- settings.max_preview_height,
- v.command
- .split('\n')
- .map(|line| {
- (line.len() as u16 + preview_width - 1 - border_size)
- / (preview_width - border_size)
- })
- .sum(),
- )
- }) + border_size * 2
- } else if settings.show_preview && settings.preview.strategy == PreviewStrategy::Fixed {
- settings.max_preview_height + border_size * 2
- } else if !matches!(compactness, Compactness::Full) || tab_index == 1 {
- 0
- } else {
- 1
- }
- }
-
- fn draw(
- &mut self,
- f: &mut Frame<'_>,
- results: &[History],
- stats: Option<HistoryStats>,
- inspecting: Option<&History>,
- settings: &Settings,
-
- popup_mode: bool,
- ) {
- let area = f.area();
- if popup_mode {
- f.render_widget(Clear, area);
- }
- self.draw_inner(f, area, results, stats, inspecting, settings);
- }
-
- #[expect(clippy::too_many_lines)]
- #[expect(clippy::bool_to_int_with_if)]
- fn draw_inner(
- &mut self,
- f: &mut Frame<'_>,
- area: Rect,
- results: &[History],
- stats: Option<HistoryStats>,
- inspecting: Option<&History>,
- settings: &Settings,
- ) {
- let compactness = to_compactness(f, settings);
- let invert = settings.invert;
- let border_size = match compactness {
- Compactness::Full => 1,
- _ => 0,
- };
- let preview_width = area.width.saturating_sub(2);
- let preview_height = Self::calc_preview_height(
- settings,
- results,
- self.results_state.selected(),
- self.tab_index,
- compactness,
- border_size,
- preview_width,
- );
- let show_help =
- settings.show_help && (matches!(compactness, Compactness::Full) || area.height > 1);
- // This is an OR, as it seems more likely for someone to wish to override
- // tabs unexpectedly being missed, than unexpectedly present.
- let show_tabs = settings.show_tabs && !matches!(compactness, Compactness::Ultracompact);
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .margin(0)
- .horizontal_margin(1)
- .constraints::<&[Constraint]>(
- if invert {
- [
- Constraint::Length(1 + border_size), // input
- Constraint::Min(1), // results list
- Constraint::Length(preview_height), // preview
- Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs
- Constraint::Length(if show_help { 1 } else { 0 }), // header (sic)
- ]
- } else {
- match compactness {
- Compactness::Ultracompact => [
- Constraint::Length(if show_help { 1 } else { 0 }), // header
- Constraint::Length(0), // tabs
- Constraint::Min(1), // results list
- Constraint::Length(0),
- Constraint::Length(0),
- ],
- _ => [
- Constraint::Length(if show_help { 1 } else { 0 }), // header
- Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs
- Constraint::Min(1), // results list
- Constraint::Length(1 + border_size), // input
- Constraint::Length(preview_height), // preview
- ],
- }
- }
- .as_ref(),
- )
- .split(area);
-
- let input_chunk = if invert { chunks[0] } else { chunks[3] };
- let results_list_chunk = if invert { chunks[1] } else { chunks[2] };
- let preview_chunk = if invert { chunks[2] } else { chunks[4] };
- let tabs_chunk = if invert { chunks[3] } else { chunks[1] };
- let header_chunk = if invert { chunks[4] } else { chunks[0] };
-
- // TODO: this should be split so that we have one interactive search container that is
- // EITHER a search box or an inspector. But I'm not doing that now, way too much atm.
- // also allocate less 🙈
- let titles: Vec<_> = TAB_TITLES.iter().copied().map(Line::from).collect();
-
- if show_tabs {
- let tabs = Tabs::new(titles)
- .block(Block::default().borders(Borders::NONE))
- .select(self.tab_index)
- .style(Style::default())
- .highlight_style(Style::from_crossterm(style_important()));
-
- f.render_widget(tabs, tabs_chunk);
- }
-
- let style = StyleState {
- compactness,
- invert,
- inner_width: input_chunk.width.into(),
- };
-
- let header_chunks = Layout::default()
- .direction(Direction::Horizontal)
- .constraints::<&[Constraint]>(
- [
- Constraint::Ratio(1, 5),
- Constraint::Ratio(3, 5),
- Constraint::Ratio(1, 5),
- ]
- .as_ref(),
- )
- .split(header_chunk);
-
- let title = Self::build_title();
- f.render_widget(title, header_chunks[0]);
-
- let help = self.build_help(settings);
- f.render_widget(help, header_chunks[1]);
-
- let stats_tab = self.build_stats();
- f.render_widget(stats_tab, header_chunks[2]);
-
- let indicator: String = match compactness {
- Compactness::Ultracompact => {
- if self.switched_search_mode {
- format!("S{}>", self.search_mode.as_str().chars().next().unwrap())
- } else if self.search.custom_context.is_some() {
- format!(
- "C{}>",
- self.search.filter_mode.as_str().chars().next().unwrap()
- )
- } else {
- format!(
- "{}> ",
- self.search.filter_mode.as_str().chars().next().unwrap()
- )
- }
- }
- _ => " > ".to_string(),
- };
-
- match self.tab_index {
- 0 => {
- let history_highlighter = HistoryHighlighter {
- engine: self.engine.as_ref(),
- search_input: self.search.input.as_str(),
- };
- let results_list = Self::build_results_list(
- style,
- results,
- self.keymap_mode,
- &self.now,
- indicator.as_str(),
- history_highlighter,
- settings.show_numeric_shortcuts,
- &settings.ui.columns,
- );
- f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state);
- }
-
- 1 => {
- if results.is_empty() {
- let message = Paragraph::new("Nothing to inspect")
- .block(
- Block::new()
- .title(Line::from(" Info ".to_string()))
- .title_alignment(Alignment::Center)
- .borders(Borders::ALL)
- .padding(Padding::vertical(2)),
- )
- .alignment(Alignment::Center);
- f.render_widget(message, results_list_chunk);
- } else {
- let inspecting = match inspecting {
- Some(inspecting) => inspecting,
- None => &results[self.results_state.selected()],
- };
- super::inspector::draw(
- f,
- results_list_chunk,
- inspecting,
- &stats.expect("Drawing inspector, but no stats"),
- settings,
- settings.timezone,
- );
- }
-
- // HACK: I'm following up with abstracting this into the UI container, with a
- // sub-widget for search + for inspector
- let feedback = Paragraph::new(
- "The inspector is new - please give feedback (good, or bad) at https://forum.atuin.sh",
- );
- f.render_widget(feedback, input_chunk);
-
- return;
- }
-
- _ => {
- panic!("invalid tab index");
- }
- }
-
- if !matches!(compactness, Compactness::Ultracompact) {
- let preview_width = match compactness {
- Compactness::Full => preview_width - 2,
- _ => preview_width,
- };
- let preview = self.build_preview(
- results,
- compactness,
- preview_width,
- preview_chunk.width.into(),
- );
- #[expect(clippy::cast_possible_truncation)]
- let prefix_width = settings
- .ui
- .columns
- .iter()
- .take_while(|col| !col.expand)
- .map(|col| col.width + 1)
- .sum::<u16>()
- + " > ".len() as u16;
- #[expect(clippy::cast_possible_truncation)]
- let min_prefix_width = "[ SRCH: FULLTXT ] ".len() as u16;
- self.draw_preview(
- f,
- style,
- input_chunk,
- compactness,
- preview_chunk,
- preview,
- std::cmp::max(prefix_width, min_prefix_width),
- );
- }
- }
-
- #[expect(clippy::cast_possible_truncation, clippy::too_many_arguments)]
- fn draw_preview(
- &self,
- f: &mut Frame<'_>,
- style: StyleState,
- input_chunk: Rect,
- compactness: Compactness,
- preview_chunk: Rect,
- preview: Paragraph<'_>,
- prefix_width: u16,
- ) {
- let input = self.build_input(style, prefix_width);
- f.render_widget(input, input_chunk);
-
- f.render_widget(preview, preview_chunk);
-
- let extra_width = UnicodeWidthStr::width(self.search.input.substring());
-
- let cursor_offset = match compactness {
- Compactness::Full => 1,
- _ => 0,
- };
- f.set_cursor_position((
- // Put cursor past the end of the input text
- input_chunk.x + extra_width as u16 + prefix_width + cursor_offset,
- input_chunk.y + cursor_offset,
- ));
- }
-
- fn build_title<'a>() -> Paragraph<'a> {
- let title = {
- let style: Style = Style::from_crossterm(style_base());
- Paragraph::new(Text::from(Span::styled(
- format!("Atuin v{VERSION}"),
- style.add_modifier(Modifier::BOLD),
- )))
- };
- title.alignment(Alignment::Left)
- }
-
- fn build_help(&self, settings: &Settings) -> Paragraph<'_> {
- match self.tab_index {
- // search
- 0 => Paragraph::new(Text::from(Line::from(vec![
- Span::styled("<esc>", Style::default().add_modifier(Modifier::BOLD)),
- Span::raw(": exit"),
- Span::raw(", "),
- Span::styled("<tab>", Style::default().add_modifier(Modifier::BOLD)),
- Span::raw(": edit"),
- Span::raw(", "),
- Span::styled("<enter>", Style::default().add_modifier(Modifier::BOLD)),
- Span::raw(if settings.enter_accept {
- ": run"
- } else {
- ": edit"
- }),
- Span::raw(", "),
- Span::styled("<ctrl-o>", Style::default().add_modifier(Modifier::BOLD)),
- Span::raw(": inspect"),
- ]))),
-
- 1 => Paragraph::new(Text::from(Line::from(vec![
- Span::styled("<esc>", Style::default().add_modifier(Modifier::BOLD)),
- Span::raw(": exit"),
- Span::raw(", "),
- Span::styled("<ctrl-o>", Style::default().add_modifier(Modifier::BOLD)),
- Span::raw(": search"),
- Span::raw(", "),
- Span::styled("<ctrl-d>", Style::default().add_modifier(Modifier::BOLD)),
- Span::raw(": delete"),
- ]))),
-
- _ => unreachable!("invalid tab index"),
- }
- .style(Style::from_crossterm(style_annotation()))
- .alignment(Alignment::Center)
- }
-
- fn build_stats(&self) -> Paragraph<'_> {
- Paragraph::new(Text::from(Span::raw(format!(
- "history count: {}",
- self.history_count,
- ))))
- .style(Style::from_crossterm(style_annotation()))
- .alignment(Alignment::Right)
- }
-
- #[expect(clippy::too_many_arguments)]
- fn build_results_list<'a>(
- style: StyleState,
- results: &'a [History],
- keymap_mode: KeymapMode,
- now: &'a dyn Fn() -> OffsetDateTime,
- indicator: &'a str,
-
- history_highlighter: HistoryHighlighter<'a>,
- show_numeric_shortcuts: bool,
- columns: &'a [UiColumn],
- ) -> HistoryList<'a> {
- let results_list = HistoryList::new(
- results,
- style.invert,
- keymap_mode == KeymapMode::VimNormal,
- now,
- indicator,
- history_highlighter,
- show_numeric_shortcuts,
- columns,
- );
-
- match style.compactness {
- Compactness::Full => {
- if style.invert {
- results_list.block(
- Block::default()
- .borders(Borders::LEFT | Borders::RIGHT)
- .border_type(BorderType::Rounded)
- .title(format!("{:─>width$}", "", width = style.inner_width - 2)),
- )
- } else {
- results_list.block(
- Block::default()
- .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
- .border_type(BorderType::Rounded),
- )
- }
- }
- _ => results_list,
- }
- }
-
- fn build_input(&self, style: StyleState, prefix_width: u16) -> Paragraph<'_> {
- let (pref, mode) = if self.switched_search_mode {
- (" SRCH:", self.search_mode.as_str())
- } else if self.search.custom_context.is_some() {
- (" CTX:", self.search.filter_mode.as_str())
- } else {
- ("", self.search.filter_mode.as_str())
- };
- // 3: surrounding "[" "] "
- let mode_width = usize::from(prefix_width) - pref.len() - 3;
- // sanity check to ensure we don't exceed the layout limits
- debug_assert!(mode_width >= mode.len(), "mode name '{mode}' is too long!");
- let input = format!("[{pref}{mode:^mode_width$}] {}", self.search.input.as_str());
- let input = Paragraph::new(input);
- match style.compactness {
- Compactness::Full => {
- if style.invert {
- input.block(
- Block::default()
- .borders(Borders::LEFT | Borders::RIGHT | Borders::TOP)
- .border_type(BorderType::Rounded),
- )
- } else {
- input.block(
- Block::default()
- .borders(Borders::LEFT | Borders::RIGHT)
- .border_type(BorderType::Rounded)
- .title(format!("{:─>width$}", "", width = style.inner_width - 2)),
- )
- }
- }
- _ => input,
- }
- }
-
- fn build_preview(
- &self,
- results: &[History],
- compactness: Compactness,
- preview_width: u16,
- chunk_width: usize,
- ) -> Paragraph<'_> {
- let selected = self.results_state.selected();
- let command = if results.is_empty() {
- String::new()
- } else {
- let s = &results[selected].command;
- let mut lines = Vec::new();
- for line in s.split('\n') {
- let line = line.escape_control();
- let mut width = 0;
- let mut start = 0;
- for (idx, ch) in line.char_indices() {
- let w = ch.width().unwrap_or(0); // None for control chars which should not happen
- if width + w > preview_width.into() {
- lines.push(line[start..idx].to_owned());
- start = idx;
- width = w;
- } else {
- width += w;
- }
- }
- if width != 0 {
- lines.push(line[start..].to_owned());
- }
- }
- lines.join("\n")
- };
-
- match compactness {
- Compactness::Full => Paragraph::new(command).block(
- Block::default()
- .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
- .border_type(BorderType::Rounded)
- .title(format!("{:─>width$}", "", width = chunk_width - 2)),
- ),
- _ => Paragraph::new(command).style(Style::from_crossterm(style_annotation())),
- }
- }
-}
-
-/// The writer used for terminal output - either stdout or /dev/tty
-enum TerminalWriter {
- Stdout(std::io::Stdout),
- #[cfg(unix)]
- Tty(std::fs::File),
-}
-
-impl TerminalWriter {
- fn new() -> std::io::Result<Self> {
- let stdout = stdout();
- if stdout.is_terminal() {
- return Ok(Self::Stdout(stdout));
- }
-
- // If stdout is not a terminal (e.g., captured by command substitution),
- // fall back to /dev/tty so the TUI can still render.
- // This allows usage like: VAR=$(atuin search -i)
- #[cfg(unix)]
- {
- Ok(Self::Tty(
- std::fs::File::options()
- .read(true)
- .write(true)
- .open("/dev/tty")?,
- ))
- }
- }
-}
-
-impl Write for TerminalWriter {
- fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
- match self {
- Self::Stdout(stdout) => stdout.write(buf),
- #[cfg(unix)]
- Self::Tty(file) => file.write(buf),
- }
- }
-
- fn flush(&mut self) -> std::io::Result<()> {
- match self {
- Self::Stdout(stdout) => stdout.flush(),
- #[cfg(unix)]
- Self::Tty(file) => file.flush(),
- }
- }
-}
-
-/// Screen state captured from atuin pty-proxy's screen server.
-#[cfg(unix)]
-struct SavedScreen {
- rows: u16,
- cols: u16,
- cursor_row: u16,
- cursor_col: u16,
- /// Pre-formatted ANSI bytes for each screen row, ready to write to stdout.
- rows_data: Vec<Vec<u8>>,
-}
-
-/// Connect to atuin pty-proxy's Unix socket and fetch the current screen state.
-///
-/// The wire format is:
-/// ```text
-/// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE]
-/// [row_0_len: u32 BE][row_0_bytes...]
-/// [row_1_len: u32 BE][row_1_bytes...]
-/// ...
-/// ```
-#[cfg(unix)]
-fn fetch_screen_state(socket_path: &str) -> Option<SavedScreen> {
- use std::os::unix::net::UnixStream;
-
- let mut stream = UnixStream::connect(socket_path).ok()?;
- stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?;
-
- let mut data = Vec::new();
- stream.read_to_end(&mut data).ok()?;
-
- if data.len() < 8 {
- return None;
- }
-
- let rows = u16::from_be_bytes([data[0], data[1]]);
- let cols = u16::from_be_bytes([data[2], data[3]]);
- let cursor_row = u16::from_be_bytes([data[4], data[5]]);
- let cursor_col = u16::from_be_bytes([data[6], data[7]]);
-
- // Parse length-prefixed rows
- let mut rows_data = Vec::with_capacity(rows as usize);
- let mut offset = 8;
- while offset + 4 <= data.len() {
- let row_len = u32::from_be_bytes([
- data[offset],
- data[offset + 1],
- data[offset + 2],
- data[offset + 3],
- ]) as usize;
- offset += 4;
- if offset + row_len > data.len() {
- break;
- }
- rows_data.push(data[offset..offset + row_len].to_vec());
- offset += row_len;
- }
-
- Some(SavedScreen {
- rows,
- cols,
- cursor_row,
- cursor_col,
- rows_data,
- })
-}
-
-/// Restore the screen area that was covered by the popup.
-///
-/// Writes the pre-formatted per-row ANSI bytes received from atuin pty-proxy
-/// directly to stdout, which correctly handles wide characters, colors, and
-/// all text attributes without needing a client-side vt100 parser.
-#[cfg(unix)]
-fn restore_popup_area(saved: &SavedScreen, popup_rect: Rect, scroll_offset: u16) {
- use ratatui::crossterm::cursor::MoveTo;
-
- let mut stdout = stdout();
-
- for dy in 0..popup_rect.height {
- let target_row = popup_rect.y + dy;
- let source_row = (target_row + scroll_offset) as usize;
-
- // Clear only the popup region. The server-side rows_formatted() skips
- // default cells (spaces with default attributes) using cursor jumps, so
- // any popup content at those positions would remain if not cleared
- // beforehand. We write `popup_rect.width` spaces instead of
- // ClearType::CurrentLine so that only the popup area is cleared, not
- // the entire terminal line.
- drop(execute!(
- stdout,
- MoveTo(popup_rect.x, target_row),
- crossterm::style::SetAttribute(crossterm::style::Attribute::Reset),
- ));
- drop(write!(
- stdout,
- "{:width$}",
- "",
- width = popup_rect.width as usize
- ));
- drop(execute!(stdout, MoveTo(popup_rect.x, target_row)));
-
- if let Some(row_bytes) = saved.rows_data.get(source_row) {
- drop(stdout.write_all(row_bytes));
- }
- }
-
- drop(execute!(
- stdout,
- MoveTo(
- saved.cursor_col,
- saved.cursor_row.saturating_sub(scroll_offset)
- )
- ));
- drop(stdout.flush());
-}
-
-struct Stdout {
- writer: TerminalWriter,
- inline_mode: bool,
- no_mouse: bool,
-}
-
-impl Stdout {
- pub(crate) fn new(inline_mode: bool, no_mouse: bool) -> std::io::Result<Self> {
- terminal::enable_raw_mode()?;
-
- let mut writer = TerminalWriter::new()?;
-
- if !inline_mode {
- execute!(writer, terminal::EnterAlternateScreen)?;
- }
-
- if !no_mouse {
- execute!(writer, event::EnableMouseCapture)?;
- }
-
- execute!(writer, event::EnableBracketedPaste)?;
-
- #[cfg(not(target_os = "windows"))]
- execute!(
- writer,
- PushKeyboardEnhancementFlags(
- KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
- | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
- | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
- ),
- )?;
-
- Ok(Self {
- writer,
- inline_mode,
- no_mouse,
- })
- }
-}
-
-impl Drop for Stdout {
- fn drop(&mut self) {
- #[cfg(not(target_os = "windows"))]
- if let Err(e) = execute!(self.writer, PopKeyboardEnhancementFlags) {
- tracing::error!(?e, "Failed to pop keyboard enhancement flags");
- }
-
- if !self.inline_mode
- && let Err(e) = execute!(self.writer, terminal::LeaveAlternateScreen)
- {
- tracing::error!(?e, "Failed to leave alt screen mode");
- }
-
- if !self.no_mouse
- && let Err(e) = execute!(self.writer, event::DisableMouseCapture)
- {
- tracing::error!(?e, "Failed to disable mouse capture");
- }
-
- if let Err(e) = execute!(self.writer, event::DisableBracketedPaste) {
- tracing::error!(?e, "Failed to disable bracketed paste");
- }
-
- if let Err(e) = terminal::disable_raw_mode() {
- tracing::error!(?e, "Failed to disable raw mode");
- }
- }
-}
-
-impl Write for Stdout {
- fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
- self.writer.write(buf)
- }
-
- fn flush(&mut self) -> std::io::Result<()> {
- self.writer.flush()
- }
-}
-
-// this is a big blob of horrible! clean it up!
-/// Compute the popup position and any scroll offset needed to make room.
-///
-/// Given the cursor row, terminal dimensions, and desired popup height,
-/// returns `(popup_rect, scroll_offset)` where `scroll_offset` is the number
-/// of lines the caller should scroll the terminal up before rendering.
-///
-/// This function performs no I/O — it is a pure computation.
-#[cfg(unix)]
-fn compute_popup_placement(
- cursor_row: u16,
- term_rows: u16,
- term_cols: u16,
- inline_height: u16,
-) -> (Rect, u16) {
- let popup_w = term_cols;
- let popup_h = inline_height.min(term_rows);
- let space_below = term_rows.saturating_sub(cursor_row);
-
- let (popup_y, scroll) = if popup_h <= space_below {
- // Fits below cursor
- (cursor_row, 0u16)
- } else if cursor_row >= term_rows / 2 {
- // Bottom half — render above cursor (overlay on existing text)
- (cursor_row.saturating_sub(popup_h), 0u16)
- } else {
- // Top half, not enough space — scroll terminal to make room
- let scroll = popup_h.saturating_sub(space_below);
- let popup_y = cursor_row.saturating_sub(scroll);
- (popup_y, scroll)
- };
-
- (Rect::new(0, popup_y, popup_w, popup_h), scroll)
-}
-
-// for now, it works. But it'd be great if it were more easily readable, and
-// modular. I'd like to add some more stats and stuff at some point
-#[expect(clippy::too_many_lines, clippy::cognitive_complexity)]
-pub(crate) async fn history(
- query: &[String],
- settings: &Settings,
- mut db: ClientSqlite,
- history_store: &HistoryStore,
-) -> Result<String> {
- let inline_height = if settings.shell_up_key_binding {
- settings
- .inline_height_shell_up_key_binding
- .unwrap_or(settings.inline_height)
- } else {
- settings.inline_height
- };
-
- // Use fullscreen mode if the inline height doesn't fit in the terminal,
- // this will preserve the scroll position upon exit.
- // Also force fullscreen when stdout isn't a terminal (e.g., command substitution
- // like VAR=$(atuin search -i)). In that case, we need to use /dev/tty for the TUI and force
- // fullscreen mode (inline mode won't work as it requires cursor position queries
- // that don't work when stdout is captured).
- let inline_height = if !stdout().is_terminal() {
- 0
- } else if let Ok(size) = terminal::size()
- && inline_height >= size.1
- {
- 0
- } else {
- inline_height
- };
-
- // Popup mode: if running under atuin pty-proxy and inline mode is requested,
- // fetch the screen state and render as a centered overlay.
- #[cfg(unix)]
- let (saved_screen, popup_rect, popup_scroll_offset) = {
- let socket_path = std::env::var("ATUIN_PTY_PROXY_SOCKET")
- .or_else(|_| std::env::var("ATUIN_HEX_SOCKET"))
- .ok();
- if let Some(ref path) = socket_path
- && inline_height > 0
- {
- let saved = fetch_screen_state(path);
- if let Some(ref s) = saved {
- let (term_cols, term_rows) = terminal::size().unwrap_or((s.cols, s.rows));
- let (popup_rect, scroll) =
- compute_popup_placement(s.cursor_row, term_rows, term_cols, inline_height);
-
- // Scroll terminal content up to make room if needed
- if scroll > 0 {
- use ratatui::crossterm::cursor::MoveTo;
- let mut stdout = stdout();
- drop(execute!(stdout, MoveTo(0, term_rows - 1)));
- for _ in 0..scroll {
- drop(writeln!(stdout));
- }
- drop(stdout.flush());
- }
-
- (saved, popup_rect, scroll)
- } else {
- (None, Rect::default(), 0u16)
- }
- } else {
- (None, Rect::default(), 0u16)
- }
- };
-
- let popup_mode = saved_screen.is_some();
-
- let stdout = Stdout::new(inline_height > 0, settings.no_mouse)?;
-
- // In popup mode, clear the popup region on the physical terminal before
- // ratatui takes over. Ratatui's diff-based rendering compares against an
- // initially-empty buffer, so cells that remain "empty" (spaces with default
- // style) won't be written — leaving underlying terminal text visible.
- // By pre-clearing with spaces, those cells are already correct on screen.
- if popup_mode {
- use ratatui::crossterm::cursor::MoveTo;
- let mut raw_stdout = std::io::stdout();
- // Queue all commands without flushing so the terminal receives them
- // as a single write — no intermediate cursor positions are visible.
- drop(queue!(
- raw_stdout,
- crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
- ));
- for row in popup_rect.y..popup_rect.y.saturating_add(popup_rect.height) {
- drop(queue!(raw_stdout, MoveTo(popup_rect.x, row)));
- drop(write!(
- raw_stdout,
- "{:width$}",
- "",
- width = popup_rect.width as usize
- ));
- }
- drop(raw_stdout.flush());
- }
-
- let backend = CrosstermBackend::new(stdout);
- let mut terminal = Terminal::with_options(
- backend,
- TerminalOptions {
- viewport: if popup_mode {
- Viewport::Fixed(popup_rect)
- } else if inline_height > 0 {
- Viewport::Inline(inline_height)
- } else {
- Viewport::Fullscreen
- },
- },
- )?;
-
- let original_query = query.join(" ");
-
- // Check if this is a command chaining scenario
- let is_command_chaining = if settings.command_chaining {
- let trimmed = original_query.trim_end();
- trimmed.ends_with("&&") || trimmed.ends_with('|')
- } else {
- false
- };
-
- // For command chaining, start with empty input to allow searching for new commands
- let search_input = if is_command_chaining {
- String::new()
- } else {
- original_query.clone()
- };
-
- let mut input = Cursor::from(search_input);
- // Put the cursor at the end of the query by default
- input.end();
-
- let initial_context = current_context().await?;
-
- let history_count = db.history_count(false).await?;
- let search_mode = if settings.shell_up_key_binding {
- settings
- .search_mode_shell_up_key_binding
- .unwrap_or(settings.search_mode)
- } else {
- settings.search_mode
- };
- let default_filter_mode = settings
- .filter_mode_shell_up_key_binding
- .filter(|_| settings.shell_up_key_binding)
- .unwrap_or_else(|| settings.default_filter_mode(initial_context.git_root.is_some()));
- let mut app = State {
- history_count,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode,
- tab_index: 0,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::from_settings(settings),
- search: SearchState {
- input,
- filter_mode: default_filter_mode,
- context: initial_context.clone(),
- custom_context: None,
- },
- engine: engines::engine(search_mode, settings),
- results_len: 0,
- accept: false,
- keymap_mode: match settings.keymap_mode {
- KeymapMode::Auto => KeymapMode::Emacs,
- value => value,
- },
- current_cursor: None,
- now: if settings.prefers_reduced_motion {
- let now = OffsetDateTime::now_utc();
- Box::new(move || now)
- } else {
- Box::new(OffsetDateTime::now_utc)
- },
- prefix: false,
- pending_vim_key: None,
- original_input_empty: original_query.is_empty(),
- };
-
- app.initialize_keymap_cursor(settings);
-
- let mut results = app.query_results(&mut db, settings.smart_sort).await?;
-
- if inline_height > 0 && !popup_mode {
- terminal.clear()?;
- }
-
- let mut stats: Option<HistoryStats> = None;
- let mut inspecting: Option<History> = None;
- let accept;
- let result = 'render: loop {
- terminal.draw(|f| {
- app.draw(
- f,
- &results,
- stats.clone(),
- inspecting.as_ref(),
- settings,
- popup_mode,
- );
- })?;
-
- let initial_input = app.search.input.as_str().to_owned();
- let initial_filter_mode = app.search.filter_mode;
- let initial_search_mode = app.search_mode;
- let initial_custom_context = app.search.custom_context.clone();
-
- let event_ready = tokio::task::spawn_blocking(|| event::poll(Duration::from_millis(250)));
-
- tokio::select! {
- event_ready = event_ready => {
- if event_ready?? {
- loop {
- match app.handle_input(settings, &event::read()?) {
- InputAction::Continue => {},
- InputAction::Delete(index) => {
- if results.is_empty() {
- break;
- }
- app.results_len -= 1;
- let selected = app.results_state.selected();
- if selected == app.results_len {
- app.inspecting_state.reset();
- app.results_state.select(selected - 1);
- }
-
- let entry = results.remove(index);
-
- let ids = history_store.delete_entries([entry]).await?;
- history_store.incremental_build(&db, &ids).await?;
-
- app.tab_index = 0;
- },
- InputAction::DeleteAllMatching(index) => {
- if results.is_empty() {
- break;
- }
-
- let command = results[index].command.clone();
-
- // Remove matching entries from the visible results
- results.retain(|e| e.command != command);
-
- // Query the DB for ALL entries with this command and delete them
- let all_matching = db.query_history(
- &format!(
- "select * from history where command = '{}' and deleted_at is null",
- command.replace('\'', "''")
- )
- ).await?;
-
- let ids = history_store.delete_entries(all_matching).await?;
- history_store.incremental_build(&db, &ids).await?;
-
- app.results_len = results.len();
- app.results_state = ListState::default();
- app.inspecting_state.reset();
- app.tab_index = 0;
- },
- InputAction::SwitchContext(index) => {
- if let Some(index) = index && let Some(entry) = results.get(index) {
- app.search.custom_context = Some(entry.id.clone());
- app.search.context = Context::from_history(entry);
- app.search.filter_mode = FilterMode::Session;
- app.search.input = Cursor::from(String::new());
- app.results_state = ListState::default();
- } else {
- app.search.custom_context = None;
- app.search.context = initial_context.clone();
- app.search.filter_mode = default_filter_mode;
- }
- },
- InputAction::Redraw => {
- if !popup_mode {
- terminal.clear()?;
- }
- terminal.draw(|f| {
- app.draw(f, &results, stats.clone(), inspecting.as_ref(), settings, popup_mode);
- })?;
- },
- r => {
- accept = app.accept;
- break 'render r;
- },
- }
- if !event::poll(Duration::ZERO)? {
- break;
- }
- }
- }
- }
- }
-
- if initial_input != app.search.input.as_str()
- || initial_filter_mode != app.search.filter_mode
- || initial_search_mode != app.search_mode
- || initial_custom_context != app.search.custom_context
- {
- results = app.query_results(&mut db, settings.smart_sort).await?;
- }
-
- // In custom context mode, when no filter is applied, highlight the entry which was used
- // to enter the context when changing modes. This helps to find your way around.
- if app.search.custom_context.is_some()
- && app.search.input.as_str().is_empty()
- && (initial_custom_context != app.search.custom_context
- || initial_filter_mode != app.search.filter_mode)
- && let Some(history_id) = app.search.custom_context.clone()
- && let Some(pos) = results.iter().position(|entry| entry.id == history_id)
- {
- app.results_state.select(pos);
- }
-
- let inspecting_id = app.inspecting_state.clone().current;
- // If inspecting ID is not the current inspecting History, update it.
- match inspecting_id {
- Some(inspecting_id) => {
- if inspecting.is_none() || inspecting_id != inspecting.clone().unwrap().id {
- inspecting = db.load(inspecting_id.0.as_str()).await?;
- }
- }
- _ => {
- inspecting = None;
- }
- }
-
- stats = if app.tab_index == 0 {
- None
- } else if !results.is_empty() {
- // If we have stats, then we can indicate next available IDs. This avoids passing
- // around a database object, or a full stats object.
- let selected = match inspecting.clone() {
- Some(insp) => insp,
- None => results[app.results_state.selected()].clone(),
- };
- let stats = db.stats(&selected).await?;
- app.inspecting_state.current = Some(selected.id);
- app.inspecting_state.previous = match stats.previous.clone() {
- Some(p) => Some(p.id),
- _ => None,
- };
- app.inspecting_state.next = match stats.next.clone() {
- Some(p) => Some(p.id),
- _ => None,
- };
- Some(stats)
- } else {
- None
- };
- };
-
- app.finalize_keymap_cursor(settings);
-
- if popup_mode {
- // In popup mode, restore the screen area that was covered by the popup.
- // This must happen before Stdout is dropped (which disables raw mode).
- #[cfg(unix)]
- if let Some(ref saved) = saved_screen {
- restore_popup_area(saved, popup_rect, popup_scroll_offset);
- }
- } else if inline_height > 0 {
- terminal.clear()?;
- }
-
- let accept = accept
- && matches!(
- Shell::from_env(),
- Shell::Zsh | Shell::Fish | Shell::Bash | Shell::Xonsh | Shell::Nu | Shell::Powershell
- );
-
- let accept_prefix = "__atuin_accept__:";
-
- match result {
- InputAction::AcceptInspecting => {
- match inspecting {
- Some(result) => {
- let mut command = result.command;
-
- if accept {
- command = String::from(accept_prefix) + &command;
- }
-
- // index is in bounds so we return that entry
- Ok(command)
- }
- None => Ok(String::new()),
- }
- }
- InputAction::Accept(index) if index < results.len() => {
- let mut command = results.swap_remove(index).command;
-
- if is_command_chaining {
- command = format!("{} {}", original_query.trim_end(), command);
- } else if accept {
- command = String::from(accept_prefix) + &command;
- }
-
- // index is in bounds so we return that entry
- Ok(command)
- }
- InputAction::ReturnOriginal => Ok(String::new()),
- InputAction::Copy(index) => {
- let cmd = results.swap_remove(index).command;
- set_clipboard(cmd);
- Ok(String::new())
- }
- InputAction::ReturnQuery | InputAction::Accept(_) => {
- // Either:
- // * index == RETURN_QUERY, in which case we should return the input
- // * out of bounds -> usually implies no selected entry so we return the input
- Ok(app.search.input.into_inner())
- }
- InputAction::Continue
- | InputAction::Redraw
- | InputAction::Delete(_)
- | InputAction::DeleteAllMatching(_)
- | InputAction::SwitchContext(_) => {
- unreachable!("should have been handled!")
- }
- }
-}
-
-// cli-clipboard only works on Windows, Mac, and Linux.
-
-#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
-fn set_clipboard(s: String) {
- let mut ctx = arboard::Clipboard::new().unwrap();
- ctx.set_text(s).unwrap();
- // Use the clipboard context to make sure it is saved
- ctx.get_text().unwrap();
-}
-
-#[cfg(test)]
-mod tests {
- use crate::atuin_client::database::Context;
- use crate::atuin_client::history::History;
- use crate::atuin_client::settings::{
- FilterMode, KeymapMode, Preview, PreviewStrategy, SearchMode, Settings,
- };
- use time::OffsetDateTime;
-
- use crate::command::client::search::engines::{self, SearchState};
- use crate::command::client::search::history_list::ListState;
-
- use super::{Compactness, InspectingState, KeymapSet, State};
-
- #[test]
- #[expect(clippy::too_many_lines)]
- fn calc_preview_height_test() {
- let settings_preview_auto = Settings {
- preview: Preview {
- strategy: PreviewStrategy::Auto,
- },
- show_preview: true,
- ..Settings::new().unwrap()
- };
-
- let settings_preview_auto_h2 = Settings {
- preview: Preview {
- strategy: PreviewStrategy::Auto,
- },
- show_preview: true,
- max_preview_height: 2,
- ..Settings::new().unwrap()
- };
-
- let settings_preview_h4 = Settings {
- preview: Preview {
- strategy: PreviewStrategy::Static,
- },
- show_preview: true,
- max_preview_height: 4,
- ..Settings::new().unwrap()
- };
-
- let settings_preview_fixed = Settings {
- preview: Preview {
- strategy: PreviewStrategy::Fixed,
- },
- show_preview: true,
- max_preview_height: 15,
- ..Settings::new().unwrap()
- };
-
- let cmd_60: History = History::capture()
- .timestamp(OffsetDateTime::now_utc())
- .command("for i in $(seq -w 10); do echo \"item number $i - abcd\"; done")
- .cwd("/")
- .build()
- .into();
-
- let cmd_124: History = History::capture()
- .timestamp(OffsetDateTime::now_utc())
- .command("echo 'Aurea prima sata est aetas, quae vindice nullo, sponte sua, sine lege fidem rectumque colebat. Poena metusque aberant'")
- .cwd("/")
- .build()
- .into();
-
- let cmd_200: History = History::capture()
- .timestamp(OffsetDateTime::now_utc())
- .command("CREATE USER atuin WITH ENCRYPTED PASSWORD 'supersecretpassword'; CREATE DATABASE atuin WITH OWNER = atuin; \\c atuin; REVOKE ALL PRIVILEGES ON SCHEMA public FROM PUBLIC; echo 'All done. 200 characters'")
- .cwd("/")
- .build()
- .into();
-
- let results: Vec<History> = vec![cmd_60, cmd_124, cmd_200];
-
- // the selected command does not require a preview
- let no_preview = State::calc_preview_height(
- &settings_preview_auto,
- &results,
- 0_usize,
- 0_usize,
- Compactness::Full,
- 1,
- 80,
- );
- // the selected command requires 2 lines
- let preview_h2 = State::calc_preview_height(
- &settings_preview_auto,
- &results,
- 1_usize,
- 0_usize,
- Compactness::Full,
- 1,
- 80,
- );
- // the selected command requires 3 lines
- let preview_h3 = State::calc_preview_height(
- &settings_preview_auto,
- &results,
- 2_usize,
- 0_usize,
- Compactness::Full,
- 1,
- 80,
- );
- // the selected command requires a preview of 1 line (happens when the command is between preview_width-19 and preview_width)
- let preview_one_line = State::calc_preview_height(
- &settings_preview_auto,
- &results,
- 0_usize,
- 0_usize,
- Compactness::Full,
- 1,
- 66,
- );
- // the selected command requires 3 lines, but we have a max preview height limit of 2
- let preview_limit_at_2 = State::calc_preview_height(
- &settings_preview_auto_h2,
- &results,
- 2_usize,
- 0_usize,
- Compactness::Full,
- 1,
- 80,
- );
- // the longest command requires 3 lines
- let preview_static_h3 = State::calc_preview_height(
- &settings_preview_h4,
- &results,
- 1_usize,
- 0_usize,
- Compactness::Full,
- 1,
- 80,
- );
- // the longest command requires 10 lines, but we have a max preview height limit of 4
- let preview_static_limit_at_4 = State::calc_preview_height(
- &settings_preview_h4,
- &results,
- 1_usize,
- 0_usize,
- Compactness::Full,
- 1,
- 20,
- );
- // the longest command requires 10 lines, but we have a max preview height of 15 and a fixed preview strategy
- let settings_preview_fixed = State::calc_preview_height(
- &settings_preview_fixed,
- &results,
- 1_usize,
- 0_usize,
- Compactness::Full,
- 1,
- 20,
- );
-
- assert_eq!(no_preview, 1);
- // 1 * 2 is the space for the border
- let border_space = 2;
- assert_eq!(preview_h2, 2 + border_space);
- assert_eq!(preview_h3, 3 + border_space);
- assert_eq!(preview_one_line, 1 + border_space);
- assert_eq!(preview_limit_at_2, 2 + border_space);
- assert_eq!(preview_static_h3, 3 + border_space);
- assert_eq!(preview_static_limit_at_4, 4 + border_space);
- assert_eq!(settings_preview_fixed, 15 + border_space);
- }
-
- // Test when there's no results, scrolling up or down doesn't underflow
- #[test]
- fn state_scroll_up_underflow() {
- let settings = Settings::new().unwrap();
- let mut state = State {
- history_count: 0,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len: 0,
- accept: false,
- keymap_mode: KeymapMode::Auto,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::defaults(&settings),
- search: SearchState {
- input: String::new().into(),
- filter_mode: FilterMode::Directory,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
-
- state.scroll_up(1);
- state.scroll_down(1);
- }
-
- #[test]
- fn test_accept_keybindings() {
- use crate::atuin_client::settings::Keys;
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
- let mut settings = Settings::new().unwrap();
- settings.keys = Keys {
- scroll_exits: true,
- exit_past_line_start: false,
- accept_past_line_end: true,
- accept_past_line_start: false,
- accept_with_backspace: false,
- prefix: "a".to_string(),
- };
-
- let mut state = State {
- history_count: 1,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len: 1,
- accept: false,
- keymap_mode: KeymapMode::Emacs,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::defaults(&settings),
- search: SearchState {
- input: String::new().into(),
- filter_mode: FilterMode::Global,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
-
- let tab_event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &tab_event);
- assert!(
- matches!(result, super::InputAction::Accept(_)),
- "Tab should always accept"
- );
-
- // Test left arrow with accept_past_line_start disabled (should continue)
- let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &left_event);
- assert!(
- matches!(result, super::InputAction::Continue),
- "Left arrow should continue when disabled"
- );
-
- // Test left arrow with accept_past_line_start enabled (should accept at start of line)
- settings.keys.accept_past_line_start = true;
- state.keymaps = KeymapSet::defaults(&settings);
- let result = state.handle_key_input(&settings, &left_event);
- assert!(
- matches!(result, super::InputAction::Accept(_)),
- "Left arrow should accept at start of line when enabled"
- );
- settings.keys.accept_past_line_start = false;
- state.keymaps = KeymapSet::defaults(&settings);
-
- let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &backspace_event);
- assert!(
- matches!(result, super::InputAction::Continue),
- "Backspace should continue when disabled"
- );
-
- settings.keys.accept_with_backspace = true;
- state.keymaps = KeymapSet::defaults(&settings);
- let result = state.handle_key_input(&settings, &backspace_event);
- assert!(
- matches!(result, super::InputAction::Accept(_)),
- "Backspace should accept at start of line when enabled"
- );
-
- state.search.input.insert('t');
- state.search.input.insert('e');
- state.search.input.insert('s');
- state.search.input.insert('t');
- state.search.input.end();
-
- let right_event = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &right_event);
- assert!(
- matches!(result, super::InputAction::Accept(_)),
- "Right arrow should accept at end of line when enabled"
- );
-
- settings.keys.accept_past_line_start = true;
- state.keymaps = KeymapSet::defaults(&settings);
- let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &left_event);
- assert!(
- matches!(result, super::InputAction::Continue),
- "Left arrow should continue and end of line, even when enabled"
- );
- settings.keys.accept_past_line_start = false;
- state.keymaps = KeymapSet::defaults(&settings);
-
- settings.keys.accept_with_backspace = true;
- state.keymaps = KeymapSet::defaults(&settings);
- let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &backspace_event);
- assert!(
- matches!(result, super::InputAction::Continue),
- "Backspace should continue at end of line, even when enabled"
- );
- settings.keys.accept_with_backspace = false;
- state.keymaps = KeymapSet::defaults(&settings);
- }
-
- #[test]
- fn test_vim_gg_multikey_sequence() {
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
- let settings = Settings::new().unwrap();
-
- let mut state = State {
- history_count: 100,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len: 100,
- accept: false,
- keymap_mode: KeymapMode::VimNormal,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::defaults(&settings),
- search: SearchState {
- input: String::new().into(),
- filter_mode: FilterMode::Global,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
-
- // Start in the middle of the list
- state.results_state.select(50);
-
- // First 'g' should set pending state
- let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &g_event);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.pending_vim_key, Some('g'));
- assert_eq!(state.results_state.selected(), 50); // Position unchanged
-
- // Second 'g' should jump to end (visual top in non-inverted mode)
- let result = state.handle_key_input(&settings, &g_event);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.pending_vim_key, None);
- assert_eq!(state.results_state.selected(), 99); // Jumped to last index (visual top)
- }
-
- #[test]
- fn test_vim_g_key_clears_on_other_input() {
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
- let settings = Settings::new().unwrap();
-
- let mut state = State {
- history_count: 100,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len: 100,
- accept: false,
- keymap_mode: KeymapMode::VimNormal,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::defaults(&settings),
- search: SearchState {
- input: String::new().into(),
- filter_mode: FilterMode::Global,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
-
- state.results_state.select(50);
-
- // Press 'g' to set pending state
- let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
- state.handle_key_input(&settings, &g_event);
- assert_eq!(state.pending_vim_key, Some('g'));
-
- // Press 'j' - should clear pending state
- let j_event = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
- state.handle_key_input(&settings, &j_event);
- assert_eq!(state.pending_vim_key, None);
- }
-
- #[test]
- fn test_vim_big_g_jump_to_bottom() {
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
- let settings = Settings::new().unwrap();
-
- let mut state = State {
- history_count: 100,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len: 100,
- accept: false,
- keymap_mode: KeymapMode::VimNormal,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::defaults(&settings),
- search: SearchState {
- input: String::new().into(),
- filter_mode: FilterMode::Global,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
-
- state.results_state.select(50);
-
- // 'G' should jump to visual bottom (index 0 in non-inverted mode)
- let big_g_event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &big_g_event);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.results_state.selected(), 0);
- }
-
- #[test]
- #[expect(clippy::similar_names)]
- fn test_vim_ctrl_u_d_half_page_scroll() {
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
- let settings = Settings::new().unwrap();
-
- let mut state = State {
- history_count: 100,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len: 100,
- accept: false,
- keymap_mode: KeymapMode::VimNormal,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::defaults(&settings),
- search: SearchState {
- input: String::new().into(),
- filter_mode: FilterMode::Global,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
-
- state.results_state.select(50);
-
- // Ctrl+d should return Continue and clear pending key
- // (scroll amount depends on max_entries which is 0 in tests)
- state.pending_vim_key = Some('g');
- let ctrl_d_event = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
- let result = state.handle_key_input(&settings, &ctrl_d_event);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.pending_vim_key, None);
-
- // Ctrl+u should return Continue and clear pending key
- state.pending_vim_key = Some('g');
- let ctrl_u_event = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
- let result = state.handle_key_input(&settings, &ctrl_u_event);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.pending_vim_key, None);
- }
-
- #[test]
- #[expect(clippy::similar_names)]
- fn test_vim_ctrl_f_b_full_page_scroll() {
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
- let settings = Settings::new().unwrap();
-
- let mut state = State {
- history_count: 100,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len: 100,
- accept: false,
- keymap_mode: KeymapMode::VimNormal,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::defaults(&settings),
- search: SearchState {
- input: String::new().into(),
- filter_mode: FilterMode::Global,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
-
- state.results_state.select(50);
-
- // Ctrl+f should return Continue and clear pending key
- // (scroll amount depends on max_entries which is 0 in tests)
- state.pending_vim_key = Some('g');
- let ctrl_f_event = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL);
- let result = state.handle_key_input(&settings, &ctrl_f_event);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.pending_vim_key, None);
-
- // Ctrl+b should return Continue and clear pending key
- state.pending_vim_key = Some('g');
- let ctrl_b_event = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL);
- let result = state.handle_key_input(&settings, &ctrl_b_event);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.pending_vim_key, None);
- }
-
- // -----------------------------------------------------------------------
- // Executor tests (execute_action)
- // -----------------------------------------------------------------------
-
- /// Helper to build a State for executor tests.
- fn make_executor_state(results_len: usize, selected: usize) -> State {
- let settings = Settings::new().unwrap();
- let mut state = State {
- history_count: results_len as i64,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len,
- accept: false,
- keymap_mode: KeymapMode::Emacs,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::defaults(&settings),
- search: SearchState {
- input: String::new().into(),
- filter_mode: FilterMode::Global,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
- state.results_state.select(selected);
- state
- }
-
- #[test]
- fn execute_select_next_no_invert() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 50);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::SelectNext, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- // Non-inverted: SelectNext = scroll_down = selected - 1
- assert_eq!(state.results_state.selected(), 49);
- }
-
- #[test]
- fn execute_select_next_with_invert() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 50);
- let mut settings = Settings::new().unwrap();
- settings.invert = true;
- let result = state.execute_action(&Action::SelectNext, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- // Inverted: SelectNext = scroll_up = selected + 1
- assert_eq!(state.results_state.selected(), 51);
- }
-
- #[test]
- fn execute_select_previous_no_invert() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 50);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::SelectPrevious, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- // Non-inverted: SelectPrevious = scroll_up = selected + 1
- assert_eq!(state.results_state.selected(), 51);
- }
-
- #[test]
- fn execute_vim_enter_normal() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::VimEnterNormal, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.keymap_mode, KeymapMode::VimNormal);
- }
-
- #[test]
- fn execute_vim_enter_insert() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- state.keymap_mode = KeymapMode::VimNormal;
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::VimEnterInsert, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.keymap_mode, KeymapMode::VimInsert);
- }
-
- #[test]
- fn execute_accept_sets_accept_flag() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 5);
- let mut settings = Settings::new().unwrap();
- settings.enter_accept = true;
- let result = state.execute_action(&Action::Accept, &settings);
- assert!(matches!(result, super::InputAction::Accept(5)));
- assert!(state.accept);
- }
-
- #[test]
- fn execute_return_selection_does_not_set_accept() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 5);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::ReturnSelection, &settings);
- assert!(matches!(result, super::InputAction::Accept(5)));
- assert!(!state.accept);
- }
-
- #[test]
- fn execute_accept_nth() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 5);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::AcceptNth(3), &settings);
- assert!(matches!(result, super::InputAction::Accept(8)));
- }
-
- #[test]
- fn execute_scroll_to_top_no_invert() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 50);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::ScrollToTop, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- // Non-inverted: visual top = highest index
- assert_eq!(state.results_state.selected(), 99);
- }
-
- #[test]
- fn execute_scroll_to_top_with_invert() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 50);
- let mut settings = Settings::new().unwrap();
- settings.invert = true;
- let result = state.execute_action(&Action::ScrollToTop, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- // Inverted: visual top = index 0
- assert_eq!(state.results_state.selected(), 0);
- }
-
- #[test]
- fn execute_scroll_to_bottom_no_invert() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 50);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::ScrollToBottom, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- // Non-inverted: visual bottom = index 0
- assert_eq!(state.results_state.selected(), 0);
- }
-
- #[test]
- fn execute_toggle_tab() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- let settings = Settings::new().unwrap();
- assert_eq!(state.tab_index, 0);
- state.execute_action(&Action::ToggleTab, &settings);
- assert_eq!(state.tab_index, 1);
- state.execute_action(&Action::ToggleTab, &settings);
- assert_eq!(state.tab_index, 0);
- }
-
- #[test]
- fn execute_enter_prefix_mode() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- let settings = Settings::new().unwrap();
- assert!(!state.prefix);
- state.execute_action(&Action::EnterPrefixMode, &settings);
- assert!(state.prefix);
- }
-
- #[test]
- fn execute_exit_returns_based_on_exit_mode() {
- use crate::atuin_client::settings::ExitMode;
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- let mut settings = Settings::new().unwrap();
-
- settings.exit_mode = ExitMode::ReturnOriginal;
- let result = state.execute_action(&Action::Exit, &settings);
- assert!(matches!(result, super::InputAction::ReturnOriginal));
-
- settings.exit_mode = ExitMode::ReturnQuery;
- let result = state.execute_action(&Action::Exit, &settings);
- assert!(matches!(result, super::InputAction::ReturnQuery));
- }
-
- #[test]
- fn execute_return_original() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::ReturnOriginal, &settings);
- assert!(matches!(result, super::InputAction::ReturnOriginal));
- }
-
- #[test]
- fn execute_copy() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 7);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::Copy, &settings);
- assert!(matches!(result, super::InputAction::Copy(7)));
- }
-
- #[test]
- fn execute_delete() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 7);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::Delete, &settings);
- assert!(matches!(result, super::InputAction::Delete(7)));
- }
-
- #[test]
- fn execute_switch_context() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 7);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::SwitchContext, &settings);
- assert!(matches!(result, super::InputAction::SwitchContext(Some(7))));
- }
-
- #[test]
- fn execute_clear_context() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 7);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::ClearContext, &settings);
- assert!(matches!(result, super::InputAction::SwitchContext(None)));
- }
-
- #[test]
- fn execute_noop() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 50);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::Noop, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.results_state.selected(), 50);
- }
-
- #[test]
- fn execute_accept_in_inspector_tab() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 5);
- state.tab_index = 1;
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::Accept, &settings);
- assert!(matches!(result, super::InputAction::AcceptInspecting));
- }
-
- #[test]
- fn execute_cycle_search_mode() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- let settings = Settings::new().unwrap();
- let original_mode = state.search_mode;
- let result = state.execute_action(&Action::CycleSearchMode, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- assert!(state.switched_search_mode);
- assert_ne!(state.search_mode, original_mode);
- }
-
- #[test]
- fn execute_vim_search_insert() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- state.search.input.insert('h');
- state.search.input.insert('i');
- state.keymap_mode = KeymapMode::VimNormal;
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::VimSearchInsert, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- // Should clear input and switch to insert mode
- assert_eq!(state.search.input.as_str(), "");
- assert_eq!(state.keymap_mode, KeymapMode::VimInsert);
- }
-
- #[test]
- fn execute_cursor_movement() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- let settings = Settings::new().unwrap();
-
- // Insert some text
- state.search.input.insert('h');
- state.search.input.insert('e');
- state.search.input.insert('l');
- state.search.input.insert('l');
- state.search.input.insert('o');
- // cursor is at end (position 5)
-
- // CursorLeft
- state.execute_action(&Action::CursorLeft, &settings);
- assert_eq!(state.search.input.position(), 4);
-
- // CursorStart
- state.execute_action(&Action::CursorStart, &settings);
- assert_eq!(state.search.input.position(), 0);
-
- // CursorEnd
- state.execute_action(&Action::CursorEnd, &settings);
- assert_eq!(state.search.input.position(), 5);
-
- // CursorRight at end does nothing
- state.execute_action(&Action::CursorRight, &settings);
- assert_eq!(state.search.input.position(), 5);
- }
-
- #[test]
- fn execute_editing() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- let settings = Settings::new().unwrap();
-
- // Insert "hello"
- state.search.input.insert('h');
- state.search.input.insert('e');
- state.search.input.insert('l');
- state.search.input.insert('l');
- state.search.input.insert('o');
-
- // DeleteCharBefore (backspace)
- state.execute_action(&Action::DeleteCharBefore, &settings);
- assert_eq!(state.search.input.as_str(), "hell");
-
- // ClearLine
- state.execute_action(&Action::ClearLine, &settings);
- assert_eq!(state.search.input.as_str(), "");
- }
-
- #[test]
- fn keymap_config_return_query() {
- use crate::atuin_client::settings::KeyBindingConfig;
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
- use std::collections::HashMap;
-
- let mut settings = Settings::new().unwrap();
- // Configure tab to return-query
- settings.keymap.emacs = HashMap::from([(
- "tab".to_string(),
- KeyBindingConfig::Simple("return-query".to_string()),
- )]);
-
- let mut state = State {
- history_count: 100,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len: 100,
- accept: false,
- keymap_mode: KeymapMode::Emacs,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::from_settings(&settings),
- search: SearchState {
- input: "test query".to_string().into(),
- filter_mode: FilterMode::Global,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
-
- let tab_event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &tab_event);
- assert!(
- matches!(result, super::InputAction::ReturnQuery),
- "Tab configured as return-query should return InputAction::ReturnQuery"
- );
- }
-}
diff --git a/crates/client/src/command/client/search/keybindings/actions.rs b/crates/client/src/command/client/search/keybindings/actions.rs
deleted file mode 100644
index 2842d618..00000000
--- a/crates/client/src/command/client/search/keybindings/actions.rs
+++ /dev/null
@@ -1,322 +0,0 @@
-use std::fmt;
-
-use serde::{Deserialize, Deserializer, Serialize, Serializer};
-
-/// All possible actions that can be triggered by a keybinding.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) enum Action {
- // Cursor movement
- CursorLeft,
- CursorRight,
- CursorWordLeft,
- CursorWordRight,
- CursorWordEnd,
- CursorStart,
- CursorEnd,
-
- // Editing
- DeleteCharBefore,
- DeleteCharAfter,
- DeleteWordBefore,
- DeleteWordAfter,
- DeleteToWordBoundary,
- ClearLine,
- ClearToStart,
- ClearToEnd,
-
- // List navigation
- SelectNext,
- SelectPrevious,
- ScrollHalfPageUp,
- ScrollHalfPageDown,
- ScrollPageUp,
- ScrollPageDown,
- ScrollToTop,
- ScrollToBottom,
- ScrollToScreenTop,
- ScrollToScreenMiddle,
- ScrollToScreenBottom,
-
- // Commands — accept selection and execute immediately
- Accept,
- AcceptNth(u8),
- // Commands — return selection to command line without executing
- ReturnSelection,
- ReturnSelectionNth(u8),
- // Commands — other
- Copy,
- Delete,
- DeleteAll,
- ReturnOriginal,
- ReturnQuery,
- Exit,
- Redraw,
- CycleFilterMode,
- CycleSearchMode,
- SwitchContext,
- ClearContext,
- ToggleTab,
-
- // Mode changes
- VimEnterNormal,
- VimEnterInsert,
- VimEnterInsertAfter,
- VimEnterInsertAtStart,
- VimEnterInsertAtEnd,
- VimSearchInsert,
- VimChangeToEnd,
- EnterPrefixMode,
-
- // Inspector
- InspectPrevious,
- InspectNext,
-
- // Special
- Noop,
-}
-
-impl Action {
- /// Convert from a kebab-case string.
- pub(crate) fn from_str(s: &str) -> Result<Self, String> {
- // Handle accept-N and return-selection-N patterns
- if let Some(rest) = s.strip_prefix("accept-")
- && let Ok(n) = rest.parse::<u8>()
- && (1..=9).contains(&n)
- {
- return Ok(Self::AcceptNth(n));
- }
- if let Some(rest) = s.strip_prefix("return-selection-")
- && let Ok(n) = rest.parse::<u8>()
- && (1..=9).contains(&n)
- {
- return Ok(Self::ReturnSelectionNth(n));
- }
-
- match s {
- "cursor-left" => Ok(Self::CursorLeft),
- "cursor-right" => Ok(Self::CursorRight),
- "cursor-word-left" => Ok(Self::CursorWordLeft),
- "cursor-word-right" => Ok(Self::CursorWordRight),
- "cursor-word-end" => Ok(Self::CursorWordEnd),
- "cursor-start" => Ok(Self::CursorStart),
- "cursor-end" => Ok(Self::CursorEnd),
-
- "delete-char-before" => Ok(Self::DeleteCharBefore),
- "delete-char-after" => Ok(Self::DeleteCharAfter),
- "delete-word-before" => Ok(Self::DeleteWordBefore),
- "delete-word-after" => Ok(Self::DeleteWordAfter),
- "delete-to-word-boundary" => Ok(Self::DeleteToWordBoundary),
- "clear-line" => Ok(Self::ClearLine),
- "clear-to-start" => Ok(Self::ClearToStart),
- "clear-to-end" => Ok(Self::ClearToEnd),
-
- "select-next" => Ok(Self::SelectNext),
- "select-previous" => Ok(Self::SelectPrevious),
- "scroll-half-page-up" => Ok(Self::ScrollHalfPageUp),
- "scroll-half-page-down" => Ok(Self::ScrollHalfPageDown),
- "scroll-page-up" => Ok(Self::ScrollPageUp),
- "scroll-page-down" => Ok(Self::ScrollPageDown),
- "scroll-to-top" => Ok(Self::ScrollToTop),
- "scroll-to-bottom" => Ok(Self::ScrollToBottom),
- "scroll-to-screen-top" => Ok(Self::ScrollToScreenTop),
- "scroll-to-screen-middle" => Ok(Self::ScrollToScreenMiddle),
- "scroll-to-screen-bottom" => Ok(Self::ScrollToScreenBottom),
-
- "accept" => Ok(Self::Accept),
- "return-selection" => Ok(Self::ReturnSelection),
- "copy" => Ok(Self::Copy),
- "delete" => Ok(Self::Delete),
- "delete-all" => Ok(Self::DeleteAll),
- "return-original" => Ok(Self::ReturnOriginal),
- "return-query" => Ok(Self::ReturnQuery),
- "exit" => Ok(Self::Exit),
- "redraw" => Ok(Self::Redraw),
- "cycle-filter-mode" => Ok(Self::CycleFilterMode),
- "cycle-search-mode" => Ok(Self::CycleSearchMode),
- "switch-context" => Ok(Self::SwitchContext),
- "clear-context" => Ok(Self::ClearContext),
- "toggle-tab" => Ok(Self::ToggleTab),
-
- "vim-enter-normal" => Ok(Self::VimEnterNormal),
- "vim-enter-insert" => Ok(Self::VimEnterInsert),
- "vim-enter-insert-after" => Ok(Self::VimEnterInsertAfter),
- "vim-enter-insert-at-start" => Ok(Self::VimEnterInsertAtStart),
- "vim-enter-insert-at-end" => Ok(Self::VimEnterInsertAtEnd),
- "vim-search-insert" => Ok(Self::VimSearchInsert),
- "vim-change-to-end" => Ok(Self::VimChangeToEnd),
- "enter-prefix-mode" => Ok(Self::EnterPrefixMode),
-
- "inspect-previous" => Ok(Self::InspectPrevious),
- "inspect-next" => Ok(Self::InspectNext),
-
- "noop" => Ok(Self::Noop),
-
- _ => Err(format!("unknown action: {s}")),
- }
- }
-
- /// Convert to a kebab-case string.
- pub(crate) fn as_str(&self) -> String {
- match self {
- Self::CursorLeft => "cursor-left".to_string(),
- Self::CursorRight => "cursor-right".to_string(),
- Self::CursorWordLeft => "cursor-word-left".to_string(),
- Self::CursorWordRight => "cursor-word-right".to_string(),
- Self::CursorWordEnd => "cursor-word-end".to_string(),
- Self::CursorStart => "cursor-start".to_string(),
- Self::CursorEnd => "cursor-end".to_string(),
-
- Self::DeleteCharBefore => "delete-char-before".to_string(),
- Self::DeleteCharAfter => "delete-char-after".to_string(),
- Self::DeleteWordBefore => "delete-word-before".to_string(),
- Self::DeleteWordAfter => "delete-word-after".to_string(),
- Self::DeleteToWordBoundary => "delete-to-word-boundary".to_string(),
- Self::ClearLine => "clear-line".to_string(),
- Self::ClearToStart => "clear-to-start".to_string(),
- Self::ClearToEnd => "clear-to-end".to_string(),
-
- Self::SelectNext => "select-next".to_string(),
- Self::SelectPrevious => "select-previous".to_string(),
- Self::ScrollHalfPageUp => "scroll-half-page-up".to_string(),
- Self::ScrollHalfPageDown => "scroll-half-page-down".to_string(),
- Self::ScrollPageUp => "scroll-page-up".to_string(),
- Self::ScrollPageDown => "scroll-page-down".to_string(),
- Self::ScrollToTop => "scroll-to-top".to_string(),
- Self::ScrollToBottom => "scroll-to-bottom".to_string(),
- Self::ScrollToScreenTop => "scroll-to-screen-top".to_string(),
- Self::ScrollToScreenMiddle => "scroll-to-screen-middle".to_string(),
- Self::ScrollToScreenBottom => "scroll-to-screen-bottom".to_string(),
-
- Self::Accept => "accept".to_string(),
- Self::AcceptNth(n) => format!("accept-{n}"),
- Self::ReturnSelection => "return-selection".to_string(),
- Self::ReturnSelectionNth(n) => format!("return-selection-{n}"),
- Self::Copy => "copy".to_string(),
- Self::Delete => "delete".to_string(),
- Self::DeleteAll => "delete-all".to_string(),
- Self::ReturnOriginal => "return-original".to_string(),
- Self::ReturnQuery => "return-query".to_string(),
- Self::Exit => "exit".to_string(),
- Self::Redraw => "redraw".to_string(),
- Self::CycleFilterMode => "cycle-filter-mode".to_string(),
- Self::CycleSearchMode => "cycle-search-mode".to_string(),
- Self::SwitchContext => "switch-context".to_string(),
- Self::ClearContext => "clear-context".to_string(),
- Self::ToggleTab => "toggle-tab".to_string(),
-
- Self::VimEnterNormal => "vim-enter-normal".to_string(),
- Self::VimEnterInsert => "vim-enter-insert".to_string(),
- Self::VimEnterInsertAfter => "vim-enter-insert-after".to_string(),
- Self::VimEnterInsertAtStart => "vim-enter-insert-at-start".to_string(),
- Self::VimEnterInsertAtEnd => "vim-enter-insert-at-end".to_string(),
- Self::VimSearchInsert => "vim-search-insert".to_string(),
- Self::VimChangeToEnd => "vim-change-to-end".to_string(),
- Self::EnterPrefixMode => "enter-prefix-mode".to_string(),
-
- Self::InspectPrevious => "inspect-previous".to_string(),
- Self::InspectNext => "inspect-next".to_string(),
-
- Self::Noop => "noop".to_string(),
- }
- }
-}
-
-impl fmt::Display for Action {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.as_str())
- }
-}
-
-impl Serialize for Action {
- fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
- serializer.serialize_str(&self.as_str())
- }
-}
-
-impl<'de> Deserialize<'de> for Action {
- fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
- let s = String::deserialize(deserializer)?;
- Self::from_str(&s).map_err(serde::de::Error::custom)
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::Action;
-
- #[test]
- fn parse_basic_actions() {
- assert_eq!(Action::from_str("cursor-left").unwrap(), Action::CursorLeft);
- assert_eq!(Action::from_str("accept").unwrap(), Action::Accept);
- assert_eq!(Action::from_str("exit").unwrap(), Action::Exit);
- assert_eq!(Action::from_str("noop").unwrap(), Action::Noop);
- assert_eq!(
- Action::from_str("vim-enter-normal").unwrap(),
- Action::VimEnterNormal
- );
- }
-
- #[test]
- fn parse_accept_nth() {
- assert_eq!(Action::from_str("accept-1").unwrap(), Action::AcceptNth(1));
- assert_eq!(Action::from_str("accept-9").unwrap(), Action::AcceptNth(9));
- }
-
- #[test]
- fn parse_return_selection() {
- assert_eq!(
- Action::from_str("return-selection").unwrap(),
- Action::ReturnSelection
- );
- assert_eq!(
- Action::from_str("return-selection-1").unwrap(),
- Action::ReturnSelectionNth(1)
- );
- assert_eq!(
- Action::from_str("return-selection-9").unwrap(),
- Action::ReturnSelectionNth(9)
- );
- }
-
- #[test]
- fn parse_unknown_action() {
- assert!(Action::from_str("unknown-action").is_err());
- assert!(Action::from_str("accept-0").is_err());
- assert!(Action::from_str("accept-10").is_err());
- assert!(Action::from_str("return-selection-0").is_err());
- assert!(Action::from_str("return-selection-10").is_err());
- }
-
- #[test]
- fn round_trip() {
- let actions = vec![
- Action::CursorLeft,
- Action::Accept,
- Action::AcceptNth(5),
- Action::ReturnSelection,
- Action::ReturnSelectionNth(3),
- Action::VimSearchInsert,
- Action::ScrollToScreenMiddle,
- ];
- for action in actions {
- let s = action.as_str();
- let parsed = Action::from_str(&s).unwrap();
- assert_eq!(action, parsed);
- }
- }
-
- #[test]
- fn serde_round_trip() {
- let action = Action::CursorLeft;
- let json = serde_json::to_string(&action).unwrap();
- assert_eq!(json, "\"cursor-left\"");
- let parsed: Action = serde_json::from_str(&json).unwrap();
- assert_eq!(parsed, Action::CursorLeft);
-
- let action = Action::AcceptNth(3);
- let json = serde_json::to_string(&action).unwrap();
- assert_eq!(json, "\"accept-3\"");
- let parsed: Action = serde_json::from_str(&json).unwrap();
- assert_eq!(parsed, Action::AcceptNth(3));
- }
-}
diff --git a/crates/client/src/command/client/search/keybindings/conditions.rs b/crates/client/src/command/client/search/keybindings/conditions.rs
deleted file mode 100644
index fd993f2b..00000000
--- a/crates/client/src/command/client/search/keybindings/conditions.rs
+++ /dev/null
@@ -1,801 +0,0 @@
-use std::fmt;
-
-use serde::{Deserialize, Deserializer, Serialize, Serializer};
-
-/// Atomic (leaf) conditions that can be evaluated against state.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) enum ConditionAtom {
- CursorAtStart,
- CursorAtEnd,
- InputEmpty,
- OriginalInputEmpty,
- ListAtEnd,
- ListAtStart,
- NoResults,
- HasResults,
- HasContext,
-}
-
-/// Boolean expression tree over condition atoms.
-///
-/// Supports negation, conjunction, and disjunction with standard precedence:
-/// `!` binds tightest, then `&&`, then `||`.
-///
-/// Examples of valid expression strings:
-/// - `"cursor-at-start"` (bare atom)
-/// - `"!no-results"` (negation)
-/// - `"cursor-at-start && input-empty"` (conjunction)
-/// - `"list-at-start || no-results"` (disjunction)
-/// - `"(cursor-at-start && !input-empty) || no-results"` (grouping)
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) enum ConditionExpr {
- Atom(ConditionAtom),
- Not(Box<Self>),
- And(Box<Self>, Box<Self>),
- Or(Box<Self>, Box<Self>),
-}
-
-/// Context needed to evaluate conditions. This is a pure snapshot of state —
-/// no references to mutable data.
-pub(crate) struct EvalContext {
- /// Current cursor position (unicode width units).
- pub(crate) cursor_position: usize,
- /// Width of the input string in unicode width units.
- pub(crate) input_width: usize,
- /// Byte length of the input string.
- pub(crate) input_byte_len: usize,
- /// Currently selected index in the results list.
- pub(crate) selected_index: usize,
- /// Total number of results.
- pub(crate) results_len: usize,
- /// Whether the original input (query passed to the TUI) was empty.
- pub(crate) original_input_empty: bool,
- /// Whether we use a search context of a command from the history.
- pub(crate) has_context: bool,
-}
-
-// ---------------------------------------------------------------------------
-// ConditionAtom
-// ---------------------------------------------------------------------------
-
-impl ConditionAtom {
- /// Evaluate this atom against the given context.
- pub(crate) fn evaluate(&self, ctx: &EvalContext) -> bool {
- match self {
- Self::CursorAtStart => ctx.cursor_position == 0,
- Self::CursorAtEnd => ctx.cursor_position == ctx.input_width,
- Self::InputEmpty => ctx.input_byte_len == 0,
- Self::OriginalInputEmpty => ctx.original_input_empty,
- Self::ListAtEnd => {
- ctx.results_len == 0 || ctx.selected_index >= ctx.results_len.saturating_sub(1)
- }
- Self::ListAtStart => ctx.results_len == 0 || ctx.selected_index == 0,
- Self::NoResults => ctx.results_len == 0,
- Self::HasResults => ctx.results_len > 0,
- Self::HasContext => ctx.has_context,
- }
- }
-
- /// Parse from a kebab-case string.
- pub(crate) fn from_str(s: &str) -> Result<Self, String> {
- match s {
- "cursor-at-start" => Ok(Self::CursorAtStart),
- "cursor-at-end" => Ok(Self::CursorAtEnd),
- "input-empty" => Ok(Self::InputEmpty),
- "original-input-empty" => Ok(Self::OriginalInputEmpty),
- "list-at-end" => Ok(Self::ListAtEnd),
- "list-at-start" => Ok(Self::ListAtStart),
- "no-results" => Ok(Self::NoResults),
- "has-results" => Ok(Self::HasResults),
- "has-context" => Ok(Self::HasContext),
- _ => Err(format!("unknown condition: {s}")),
- }
- }
-
- /// Convert to a kebab-case string.
- pub(crate) fn as_str(&self) -> &'static str {
- match self {
- Self::CursorAtStart => "cursor-at-start",
- Self::CursorAtEnd => "cursor-at-end",
- Self::InputEmpty => "input-empty",
- Self::OriginalInputEmpty => "original-input-empty",
- Self::ListAtEnd => "list-at-end",
- Self::ListAtStart => "list-at-start",
- Self::NoResults => "no-results",
- Self::HasResults => "has-results",
- Self::HasContext => "has-context",
- }
- }
-}
-
-impl fmt::Display for ConditionAtom {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.as_str())
- }
-}
-
-// ---------------------------------------------------------------------------
-// ConditionExpr — evaluation
-// ---------------------------------------------------------------------------
-
-impl ConditionExpr {
- /// Evaluate this expression against the given context.
- pub(crate) fn evaluate(&self, ctx: &EvalContext) -> bool {
- match self {
- Self::Atom(atom) => atom.evaluate(ctx),
- Self::Not(inner) => !inner.evaluate(ctx),
- Self::And(lhs, rhs) => lhs.evaluate(ctx) && rhs.evaluate(ctx),
- Self::Or(lhs, rhs) => lhs.evaluate(ctx) || rhs.evaluate(ctx),
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-// ConditionExpr — ergonomic builders
-// ---------------------------------------------------------------------------
-
-impl From<ConditionAtom> for ConditionExpr {
- fn from(atom: ConditionAtom) -> Self {
- Self::Atom(atom)
- }
-}
-
-#[expect(dead_code)]
-impl ConditionExpr {
- /// Negate this expression: `!self`.
- pub(crate) fn not(self) -> Self {
- Self::Not(Box::new(self))
- }
-
- /// Conjoin with another expression: `self && other`.
- pub(crate) fn and(self, other: Self) -> Self {
- Self::And(Box::new(self), Box::new(other))
- }
-
- /// Disjoin with another expression: `self || other`.
- pub(crate) fn or(self, other: Self) -> Self {
- Self::Or(Box::new(self), Box::new(other))
- }
-}
-
-// ---------------------------------------------------------------------------
-// ConditionExpr — parser
-// ---------------------------------------------------------------------------
-
-/// Recursive descent parser for boolean condition expressions.
-///
-/// Grammar (standard boolean precedence):
-/// ```text
-/// expr = or_expr
-/// or_expr = and_expr ("||" and_expr)*
-/// and_expr = unary ("&&" unary)*
-/// unary = "!" unary | primary
-/// primary = atom | "(" expr ")"
-/// atom = [a-z][a-z0-9-]*
-/// ```
-struct ExprParser<'a> {
- input: &'a str,
- pos: usize,
-}
-
-impl<'a> ExprParser<'a> {
- fn new(input: &'a str) -> Self {
- Self { input, pos: 0 }
- }
-
- fn skip_whitespace(&mut self) {
- while self.pos < self.input.len() && self.input.as_bytes()[self.pos].is_ascii_whitespace() {
- self.pos += 1;
- }
- }
-
- fn starts_with(&mut self, s: &str) -> bool {
- self.skip_whitespace();
- self.input[self.pos..].starts_with(s)
- }
-
- fn consume(&mut self, s: &str) -> bool {
- self.skip_whitespace();
- if self.input[self.pos..].starts_with(s) {
- self.pos += s.len();
- true
- } else {
- false
- }
- }
-
- /// Parse a full expression, expecting to consume all input.
- fn parse(mut self) -> Result<ConditionExpr, String> {
- let expr = self.parse_or()?;
- self.skip_whitespace();
- if self.pos < self.input.len() {
- return Err(format!(
- "unexpected input at position {}: {:?}",
- self.pos,
- &self.input[self.pos..]
- ));
- }
- Ok(expr)
- }
-
- /// `or_expr` = `and_expr` ("||" `and_expr`)*
- fn parse_or(&mut self) -> Result<ConditionExpr, String> {
- let mut left = self.parse_and()?;
- while self.starts_with("||") {
- self.consume("||");
- let right = self.parse_and()?;
- left = ConditionExpr::Or(Box::new(left), Box::new(right));
- }
- Ok(left)
- }
-
- /// `and_expr` = unary ("&&" unary)*
- fn parse_and(&mut self) -> Result<ConditionExpr, String> {
- let mut left = self.parse_unary()?;
- while self.starts_with("&&") {
- self.consume("&&");
- let right = self.parse_unary()?;
- left = ConditionExpr::And(Box::new(left), Box::new(right));
- }
- Ok(left)
- }
-
- /// unary = "!" unary | primary
- fn parse_unary(&mut self) -> Result<ConditionExpr, String> {
- if self.consume("!") {
- let inner = self.parse_unary()?;
- Ok(ConditionExpr::Not(Box::new(inner)))
- } else {
- self.parse_primary()
- }
- }
-
- /// primary = "(" expr ")" | atom
- fn parse_primary(&mut self) -> Result<ConditionExpr, String> {
- if self.consume("(") {
- let expr = self.parse_or()?;
- if !self.consume(")") {
- return Err(format!("expected ')' at position {}", self.pos));
- }
- Ok(expr)
- } else {
- self.parse_atom()
- }
- }
-
- /// atom = [a-z][a-z0-9-]*
- fn parse_atom(&mut self) -> Result<ConditionExpr, String> {
- self.skip_whitespace();
- let start = self.pos;
- while self.pos < self.input.len() {
- let b = self.input.as_bytes()[self.pos];
- if b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' {
- self.pos += 1;
- } else {
- break;
- }
- }
- if self.pos == start {
- return Err(format!("expected condition name at position {}", self.pos));
- }
- let name = &self.input[start..self.pos];
- let atom = ConditionAtom::from_str(name)?;
- Ok(ConditionExpr::Atom(atom))
- }
-}
-
-impl ConditionExpr {
- /// Parse a condition expression from a string.
- pub(crate) fn parse(s: &str) -> Result<Self, String> {
- let parser = ExprParser::new(s);
- parser.parse()
- }
-}
-
-// ---------------------------------------------------------------------------
-// ConditionExpr — Display
-// ---------------------------------------------------------------------------
-
-/// Precedence levels for minimal-parentheses display.
-#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
-enum Prec {
- Or = 0,
- And = 1,
- Not = 2,
- Atom = 3,
-}
-
-impl ConditionExpr {
- fn prec(&self) -> Prec {
- match self {
- Self::Or(..) => Prec::Or,
- Self::And(..) => Prec::And,
- Self::Not(..) => Prec::Not,
- Self::Atom(..) => Prec::Atom,
- }
- }
-
- fn fmt_with_prec(&self, f: &mut fmt::Formatter<'_>, parent_prec: Prec) -> fmt::Result {
- let needs_parens = self.prec() < parent_prec;
- if needs_parens {
- write!(f, "(")?;
- }
- match self {
- Self::Atom(atom) => write!(f, "{atom}")?,
- Self::Not(inner) => {
- write!(f, "!")?;
- inner.fmt_with_prec(f, Prec::Not)?;
- }
- Self::And(lhs, rhs) => {
- lhs.fmt_with_prec(f, Prec::And)?;
- write!(f, " && ")?;
- rhs.fmt_with_prec(f, Prec::And)?;
- }
- Self::Or(lhs, rhs) => {
- lhs.fmt_with_prec(f, Prec::Or)?;
- write!(f, " || ")?;
- rhs.fmt_with_prec(f, Prec::Or)?;
- }
- }
- if needs_parens {
- write!(f, ")")?;
- }
- Ok(())
- }
-}
-
-impl fmt::Display for ConditionExpr {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- self.fmt_with_prec(f, Prec::Or)
- }
-}
-
-// ---------------------------------------------------------------------------
-// Serde
-// ---------------------------------------------------------------------------
-
-impl Serialize for ConditionExpr {
- fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
- serializer.serialize_str(&self.to_string())
- }
-}
-
-impl<'de> Deserialize<'de> for ConditionExpr {
- fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
- let s = String::deserialize(deserializer)?;
- Self::parse(&s).map_err(serde::de::Error::custom)
- }
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-#[cfg(test)]
-mod tests {
- use super::{ConditionAtom, ConditionExpr, EvalContext};
-
- fn ctx(
- cursor: usize,
- width: usize,
- byte_len: usize,
- selected: usize,
- len: usize,
- ) -> EvalContext {
- ctx_with_original(cursor, width, byte_len, selected, len, false)
- }
-
- fn ctx_with_original(
- cursor: usize,
- width: usize,
- byte_len: usize,
- selected: usize,
- len: usize,
- original_input_empty: bool,
- ) -> EvalContext {
- EvalContext {
- cursor_position: cursor,
- input_width: width,
- input_byte_len: byte_len,
- selected_index: selected,
- results_len: len,
- original_input_empty,
- has_context: false,
- }
- }
-
- // -- Atom evaluation (carried over from Phase 0) --
-
- #[test]
- fn atom_cursor_at_start() {
- assert!(ConditionAtom::CursorAtStart.evaluate(&ctx(0, 5, 5, 0, 10)));
- assert!(!ConditionAtom::CursorAtStart.evaluate(&ctx(3, 5, 5, 0, 10)));
- }
-
- #[test]
- fn atom_cursor_at_end() {
- assert!(ConditionAtom::CursorAtEnd.evaluate(&ctx(5, 5, 5, 0, 10)));
- assert!(!ConditionAtom::CursorAtEnd.evaluate(&ctx(3, 5, 5, 0, 10)));
- assert!(ConditionAtom::CursorAtEnd.evaluate(&ctx(0, 0, 0, 0, 10)));
- }
-
- #[test]
- fn atom_input_empty() {
- assert!(ConditionAtom::InputEmpty.evaluate(&ctx(0, 0, 0, 0, 10)));
- assert!(!ConditionAtom::InputEmpty.evaluate(&ctx(0, 5, 5, 0, 10)));
- }
-
- #[test]
- fn atom_original_input_empty() {
- // original_input_empty = true
- assert!(
- ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 0, 0, 0, 10, true))
- );
- // original_input_empty = false
- assert!(
- !ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 0, 0, 0, 10, false))
- );
- // original_input_empty is independent of current input state
- assert!(
- ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 5, 5, 0, 10, true))
- );
- }
-
- #[test]
- fn atom_list_at_end() {
- assert!(ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 99, 100)));
- assert!(!ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 50, 100)));
- assert!(ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 0, 0)));
- }
-
- #[test]
- fn atom_list_at_start() {
- assert!(ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 0, 100)));
- assert!(!ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 50, 100)));
- assert!(ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 0, 0)));
- }
-
- #[test]
- fn atom_no_results_and_has_results() {
- assert!(ConditionAtom::NoResults.evaluate(&ctx(0, 0, 0, 0, 0)));
- assert!(!ConditionAtom::NoResults.evaluate(&ctx(0, 0, 0, 0, 5)));
- assert!(ConditionAtom::HasResults.evaluate(&ctx(0, 0, 0, 0, 5)));
- assert!(!ConditionAtom::HasResults.evaluate(&ctx(0, 0, 0, 0, 0)));
- }
-
- #[test]
- fn atom_has_context() {
- let mut context = ctx(0, 0, 0, 0, 0);
- assert!(!ConditionAtom::HasContext.evaluate(&context));
- context.has_context = true;
- assert!(ConditionAtom::HasContext.evaluate(&context));
- }
-
- #[test]
- fn atom_parse_round_trip() {
- let conditions = [
- "cursor-at-start",
- "cursor-at-end",
- "input-empty",
- "original-input-empty",
- "list-at-end",
- "list-at-start",
- "no-results",
- "has-results",
- ];
- for s in conditions {
- let c = ConditionAtom::from_str(s).unwrap();
- assert_eq!(c.as_str(), s);
- }
- }
-
- #[test]
- fn atom_parse_unknown() {
- assert!(ConditionAtom::from_str("unknown-condition").is_err());
- }
-
- // -- Parser tests --
-
- #[test]
- fn parse_bare_atom() {
- let expr = ConditionExpr::parse("cursor-at-start").unwrap();
- assert_eq!(expr, ConditionExpr::Atom(ConditionAtom::CursorAtStart));
- }
-
- #[test]
- fn parse_negation() {
- let expr = ConditionExpr::parse("!no-results").unwrap();
- assert_eq!(
- expr,
- ConditionExpr::Not(Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)))
- );
- }
-
- #[test]
- fn parse_double_negation() {
- let expr = ConditionExpr::parse("!!no-results").unwrap();
- assert_eq!(
- expr,
- ConditionExpr::Not(Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(
- ConditionAtom::NoResults
- )))))
- );
- }
-
- #[test]
- fn parse_and() {
- let expr = ConditionExpr::parse("cursor-at-start && input-empty").unwrap();
- assert_eq!(
- expr,
- ConditionExpr::And(
- Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
- Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),
- )
- );
- }
-
- #[test]
- fn parse_or() {
- let expr = ConditionExpr::parse("list-at-start || no-results").unwrap();
- assert_eq!(
- expr,
- ConditionExpr::Or(
- Box::new(ConditionExpr::Atom(ConditionAtom::ListAtStart)),
- Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
- )
- );
- }
-
- #[test]
- fn parse_precedence_and_binds_tighter_than_or() {
- // "a || b && c" should parse as "a || (b && c)"
- let expr = ConditionExpr::parse("cursor-at-start || input-empty && no-results").unwrap();
- assert_eq!(
- expr,
- ConditionExpr::Or(
- Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
- Box::new(ConditionExpr::And(
- Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),
- Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
- )),
- )
- );
- }
-
- #[test]
- fn parse_parens_override_precedence() {
- // "(a || b) && c"
- let expr = ConditionExpr::parse("(cursor-at-start || input-empty) && no-results").unwrap();
- assert_eq!(
- expr,
- ConditionExpr::And(
- Box::new(ConditionExpr::Or(
- Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
- Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),
- )),
- Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
- )
- );
- }
-
- #[test]
- fn parse_complex_nested() {
- // "(a && !b) || c"
- let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap();
- assert_eq!(
- expr,
- ConditionExpr::Or(
- Box::new(ConditionExpr::And(
- Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
- Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(
- ConditionAtom::InputEmpty
- )))),
- )),
- Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
- )
- );
- }
-
- #[test]
- fn parse_whitespace_tolerance() {
- let a = ConditionExpr::parse("cursor-at-start||input-empty").unwrap();
- let b = ConditionExpr::parse("cursor-at-start || input-empty").unwrap();
- let c = ConditionExpr::parse(" cursor-at-start || input-empty ").unwrap();
- assert_eq!(a, b);
- assert_eq!(b, c);
- }
-
- #[test]
- fn parse_error_unknown_atom() {
- assert!(ConditionExpr::parse("unknown-thing").is_err());
- }
-
- #[test]
- fn parse_error_trailing_input() {
- assert!(ConditionExpr::parse("cursor-at-start blah").is_err());
- }
-
- #[test]
- fn parse_error_unmatched_paren() {
- assert!(ConditionExpr::parse("(cursor-at-start").is_err());
- }
-
- #[test]
- fn parse_error_empty() {
- assert!(ConditionExpr::parse("").is_err());
- }
-
- // -- Expression evaluation --
-
- #[test]
- fn eval_not() {
- let expr = ConditionExpr::parse("!no-results").unwrap();
- // Has results → !no-results is true
- assert!(expr.evaluate(&ctx(0, 0, 0, 0, 5)));
- // No results → !no-results is false
- assert!(!expr.evaluate(&ctx(0, 0, 0, 0, 0)));
- }
-
- #[test]
- fn eval_and() {
- let expr = ConditionExpr::parse("cursor-at-start && input-empty").unwrap();
- // Both true
- assert!(expr.evaluate(&ctx(0, 0, 0, 0, 10)));
- // First true, second false (non-empty input)
- assert!(!expr.evaluate(&ctx(0, 5, 5, 0, 10)));
- // First false (cursor not at start)
- assert!(!expr.evaluate(&ctx(3, 5, 5, 0, 10)));
- }
-
- #[test]
- fn eval_or() {
- let expr = ConditionExpr::parse("list-at-start || no-results").unwrap();
- // list at bottom (selected=0)
- assert!(expr.evaluate(&ctx(0, 0, 0, 0, 10)));
- // no results
- assert!(expr.evaluate(&ctx(0, 0, 0, 0, 0)));
- // neither
- assert!(!expr.evaluate(&ctx(0, 0, 0, 5, 10)));
- }
-
- #[test]
- fn eval_complex_nested() {
- // (cursor-at-start && !input-empty) || no-results
- let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap();
-
- // cursor at start, input not empty → true (left branch)
- assert!(expr.evaluate(&ctx(0, 5, 5, 0, 10)));
- // no results → true (right branch)
- assert!(expr.evaluate(&ctx(3, 5, 5, 0, 0)));
- // cursor not at start, has results → false
- assert!(!expr.evaluate(&ctx(3, 5, 5, 0, 10)));
- // cursor at start, input empty → false (left: && fails; right: has results)
- assert!(!expr.evaluate(&ctx(0, 0, 0, 0, 10)));
- }
-
- // -- Display --
-
- #[test]
- fn display_atom() {
- let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart);
- assert_eq!(expr.to_string(), "cursor-at-start");
- }
-
- #[test]
- fn display_not() {
- let expr = ConditionExpr::Atom(ConditionAtom::NoResults).not();
- assert_eq!(expr.to_string(), "!no-results");
- }
-
- #[test]
- fn display_and() {
- let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart)
- .and(ConditionExpr::Atom(ConditionAtom::InputEmpty));
- assert_eq!(expr.to_string(), "cursor-at-start && input-empty");
- }
-
- #[test]
- fn display_or() {
- let expr = ConditionExpr::Atom(ConditionAtom::ListAtStart)
- .or(ConditionExpr::Atom(ConditionAtom::NoResults));
- assert_eq!(expr.to_string(), "list-at-start || no-results");
- }
-
- #[test]
- fn display_parens_when_needed() {
- // (a || b) && c — the Or inside And needs parens
- let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart)
- .or(ConditionExpr::Atom(ConditionAtom::InputEmpty))
- .and(ConditionExpr::Atom(ConditionAtom::NoResults));
- assert_eq!(
- expr.to_string(),
- "(cursor-at-start || input-empty) && no-results"
- );
- }
-
- #[test]
- fn display_no_parens_when_not_needed() {
- // a || b && c — no parens needed (and binds tighter)
- let inner_and = ConditionExpr::Atom(ConditionAtom::InputEmpty)
- .and(ConditionExpr::Atom(ConditionAtom::NoResults));
- let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart).or(inner_and);
- assert_eq!(
- expr.to_string(),
- "cursor-at-start || input-empty && no-results"
- );
- }
-
- // -- Display round-trip --
-
- #[test]
- fn display_round_trip() {
- let cases = [
- "cursor-at-start",
- "!no-results",
- "cursor-at-start && input-empty",
- "list-at-start || no-results",
- "(cursor-at-start || input-empty) && no-results",
- "(cursor-at-start && !input-empty) || no-results",
- ];
- for s in cases {
- let expr = ConditionExpr::parse(s).unwrap();
- let displayed = expr.to_string();
- let reparsed = ConditionExpr::parse(&displayed).unwrap();
- assert_eq!(expr, reparsed, "round-trip failed for: {s}");
- }
- }
-
- // -- Serde --
-
- #[test]
- fn serde_simple_atom() {
- let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart);
- let json = serde_json::to_string(&expr).unwrap();
- assert_eq!(json, "\"cursor-at-start\"");
- let parsed: ConditionExpr = serde_json::from_str(&json).unwrap();
- assert_eq!(parsed, expr);
- }
-
- #[test]
- fn serde_compound_expression() {
- let json = "\"cursor-at-start && !input-empty\"";
- let parsed: ConditionExpr = serde_json::from_str(json).unwrap();
- let expected = ConditionExpr::And(
- Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
- Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(
- ConditionAtom::InputEmpty,
- )))),
- );
- assert_eq!(parsed, expected);
- }
-
- #[test]
- fn serde_round_trip() {
- let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap();
- let json = serde_json::to_string(&expr).unwrap();
- let parsed: ConditionExpr = serde_json::from_str(&json).unwrap();
- assert_eq!(expr, parsed);
- }
-
- // -- From<ConditionAtom> --
-
- #[test]
- fn from_atom_into_expr() {
- let expr: ConditionExpr = ConditionAtom::CursorAtStart.into();
- assert_eq!(expr, ConditionExpr::Atom(ConditionAtom::CursorAtStart));
- }
-
- // -- Builder helpers --
-
- #[test]
- fn builder_chain() {
- let expr = ConditionExpr::from(ConditionAtom::CursorAtStart)
- .and(ConditionExpr::from(ConditionAtom::InputEmpty).not())
- .or(ConditionExpr::from(ConditionAtom::NoResults));
- // And binds tighter than Or, so no parens needed around the And
- assert_eq!(
- expr.to_string(),
- "cursor-at-start && !input-empty || no-results"
- );
- }
-}
diff --git a/crates/client/src/command/client/search/keybindings/defaults.rs b/crates/client/src/command/client/search/keybindings/defaults.rs
deleted file mode 100644
index 4d00b475..00000000
--- a/crates/client/src/command/client/search/keybindings/defaults.rs
+++ /dev/null
@@ -1,1274 +0,0 @@
-use std::collections::HashMap;
-
-use crate::atuin_client::settings::{KeyBindingConfig, Settings};
-use tracing::warn;
-
-use super::actions::Action;
-use super::conditions::{ConditionAtom, ConditionExpr};
-use super::key::KeyInput;
-use super::keymap::{KeyBinding, KeyRule, Keymap};
-
-/// Helper to bind a scroll key with optional exit behavior.
-///
-/// When `scroll_exits` is true AND the key scrolls toward index 0 (the newest
-/// entry), we add a conditional rule: at `ListAtStart` → `Exit`, otherwise →
-/// the scroll action.
-///
-/// Whether a key scrolls toward index 0 depends on the `invert` setting:
-/// - Non-inverted: "down" / "j" move toward index 0, "up" / "k" move away
-/// - Inverted: "up" / "k" move toward index 0, "down" / "j" move away
-///
-/// If `toward_index_zero` is false, or `scroll_exits` is false, we just bind
-/// the key to the plain scroll action (no exit).
-fn bind_scroll_key(
- km: &mut Keymap,
- key_str: &str,
- action: Action,
- toward_index_zero: bool,
- scroll_exits: bool,
-) {
- let k = key(key_str);
- if scroll_exits && toward_index_zero {
- km.bind_conditional(
- k,
- vec![
- KeyRule::when(ConditionAtom::ListAtStart, Action::Exit),
- KeyRule::always(action),
- ],
- );
- } else {
- km.bind(k, action);
- }
-}
-
-/// Helper to parse a key string, panicking on invalid keys (these are all
-/// compile-time-known strings).
-fn key(s: &str) -> KeyInput {
- KeyInput::parse(s).unwrap_or_else(|e| panic!("invalid default key {s:?}: {e}"))
-}
-
-/// All five keymaps bundled together.
-#[derive(Debug, Clone)]
-pub(crate) struct KeymapSet {
- pub(crate) emacs: Keymap,
- pub(crate) vim_normal: Keymap,
- pub(crate) vim_insert: Keymap,
- pub(crate) inspector: Keymap,
- pub(crate) prefix: Keymap,
-}
-
-// ---------------------------------------------------------------------------
-// Common bindings shared across search-tab keymaps
-// ---------------------------------------------------------------------------
-
-/// Add the bindings that are common to all search-tab keymaps:
-/// ctrl-c, ctrl-g, ctrl-o, and tab.
-///
-/// Note: `esc`/`ctrl-[` are NOT included here because their behavior differs
-/// between emacs (exit), vim-normal (exit), and vim-insert (enter normal mode).
-fn add_common_bindings(km: &mut Keymap) {
- km.bind(key("ctrl-c"), Action::ReturnOriginal);
- km.bind(key("ctrl-g"), Action::ReturnOriginal);
- km.bind(key("ctrl-o"), Action::ToggleTab);
-
- // Tab: always returns selection without executing (unlike Enter which respects enter_accept)
- km.bind(key("tab"), Action::ReturnSelection);
-}
-
-/// Returns `Accept` or `ReturnSelection` based on the `enter_accept` setting.
-fn accept_action(settings: &Settings) -> Action {
- if settings.enter_accept {
- Action::Accept
- } else {
- Action::ReturnSelection
- }
-}
-
-// ---------------------------------------------------------------------------
-// Emacs keymap (also base for vim-insert)
-// ---------------------------------------------------------------------------
-
-/// Build the default emacs keymap. This encodes the behavior from
-/// `handle_key_input` common section + `handle_search_input` shared section.
-///
-/// The `settings` parameter is used for:
-/// - `keys.prefix` — which ctrl-key enters prefix mode
-/// - `keys.scroll_exits`, `invert` — scroll-at-boundary exit behavior
-/// - `keys.accept_past_line_end` — right arrow at end of line accepts
-/// - `keys.exit_past_line_start` — left arrow at start of line exits
-/// - `keys.accept_past_line_start` — left arrow at start accepts (overrides exit)
-/// - `keys.accept_with_backspace` — backspace at start of line accepts
-/// - `ctrl_n_shortcuts` — whether alt or ctrl is used for numeric shortcuts
-// Keymap builder that enumerates every default binding; not worth splitting.
-#[expect(clippy::too_many_lines)]
-pub(crate) fn default_emacs_keymap(settings: &Settings) -> Keymap {
- let mut km = Keymap::new();
- add_common_bindings(&mut km);
-
- let accept = accept_action(settings);
-
- // esc / ctrl-[ → exit
- km.bind(key("esc"), Action::Exit);
- km.bind(key("ctrl-["), Action::Exit);
-
- // Prefix key: ctrl-<prefix_char> → enter prefix mode
- let prefix_char = settings.keys.prefix.chars().next().unwrap_or('a');
- km.bind(key(&format!("ctrl-{prefix_char}")), Action::EnterPrefixMode);
-
- // --- Accept / navigation edge behaviors (from [keys] settings) ---
-
- // right: behavior at end of line
- if settings.keys.accept_past_line_end {
- km.bind_conditional(
- key("right"),
- vec![
- KeyRule::when(ConditionAtom::CursorAtEnd, Action::ReturnSelection),
- KeyRule::always(Action::CursorRight),
- ],
- );
- } else {
- km.bind(key("right"), Action::CursorRight);
- }
-
- // left: behavior at start of line
- // accept_past_line_start takes precedence over exit_past_line_start
- if settings.keys.accept_past_line_start {
- km.bind_conditional(
- key("left"),
- vec![
- KeyRule::when(ConditionAtom::CursorAtStart, Action::ReturnSelection),
- KeyRule::always(Action::CursorLeft),
- ],
- );
- } else if settings.keys.exit_past_line_start {
- km.bind_conditional(
- key("left"),
- vec![
- KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit),
- KeyRule::always(Action::CursorLeft),
- ],
- );
- } else {
- km.bind(key("left"), Action::CursorLeft);
- }
-
- // down/up: scroll with optional exit at boundary.
- // Non-inverted: down moves toward index 0 (can exit); up moves away (no exit).
- // Inverted: up moves toward index 0 (can exit); down moves away (no exit).
- let scroll_exits = settings.keys.scroll_exits;
- let invert = settings.invert;
- bind_scroll_key(&mut km, "down", Action::SelectNext, !invert, scroll_exits);
- bind_scroll_key(&mut km, "up", Action::SelectPrevious, invert, scroll_exits);
-
- // backspace: behavior at start of line
- if settings.keys.accept_with_backspace {
- km.bind_conditional(
- key("backspace"),
- vec![
- KeyRule::when(ConditionAtom::CursorAtStart, Action::ReturnSelection),
- KeyRule::always(Action::DeleteCharBefore),
- ],
- );
- } else {
- km.bind(key("backspace"), Action::DeleteCharBefore);
- }
-
- // --- Accept ---
- km.bind(key("enter"), accept.clone());
- km.bind(key("ctrl-m"), accept);
-
- // --- Copy ---
- km.bind(key("ctrl-y"), Action::Copy);
-
- // --- Numeric shortcuts (alt-1..9 by default, ctrl-1..9 if ctrl_n_shortcuts) ---
- // These return the selection without executing, regardless of enter_accept.
- let num_mod = if settings.ctrl_n_shortcuts {
- "ctrl"
- } else {
- "alt"
- };
- for n in 1..=9u8 {
- km.bind(
- key(&format!("{num_mod}-{n}")),
- Action::ReturnSelectionNth(n),
- );
- }
-
- // --- Cursor movement ---
- km.bind(key("ctrl-left"), Action::CursorWordLeft);
- km.bind(key("alt-b"), Action::CursorWordLeft);
- km.bind(key("ctrl-b"), Action::CursorLeft);
- km.bind(key("ctrl-right"), Action::CursorWordRight);
- km.bind(key("alt-f"), Action::CursorWordRight);
- km.bind(key("ctrl-f"), Action::CursorRight);
- km.bind(key("home"), Action::CursorStart);
- // ctrl-a → CursorStart only if prefix char is NOT 'a'
- // (otherwise ctrl-a is already bound to EnterPrefixMode above)
- if prefix_char != 'a' {
- km.bind(key("ctrl-a"), Action::CursorStart);
- }
- km.bind(key("ctrl-e"), Action::CursorEnd);
- km.bind(key("end"), Action::CursorEnd);
-
- // --- Editing ---
- km.bind(key("ctrl-backspace"), Action::DeleteWordBefore);
- km.bind(key("ctrl-h"), Action::DeleteCharBefore);
- km.bind(key("ctrl-?"), Action::DeleteCharBefore);
- km.bind(key("ctrl-delete"), Action::DeleteWordAfter);
- km.bind(key("delete"), Action::DeleteCharAfter);
- // ctrl-d: if input empty → return original, otherwise delete char
- km.bind_conditional(
- key("ctrl-d"),
- vec![
- KeyRule::when(ConditionAtom::InputEmpty, Action::ReturnOriginal),
- KeyRule::always(Action::DeleteCharAfter),
- ],
- );
- km.bind(key("ctrl-w"), Action::DeleteToWordBoundary);
- km.bind(key("ctrl-u"), Action::ClearLine);
-
- // --- Search mode ---
- km.bind(key("ctrl-r"), Action::CycleFilterMode);
- km.bind(key("ctrl-s"), Action::CycleSearchMode);
-
- // --- Scroll (no exit) ---
- km.bind(key("ctrl-n"), Action::SelectNext);
- km.bind(key("ctrl-j"), Action::SelectNext);
- km.bind(key("ctrl-p"), Action::SelectPrevious);
- km.bind(key("ctrl-k"), Action::SelectPrevious);
-
- // --- Redraw ---
- km.bind(key("ctrl-l"), Action::Redraw);
-
- // --- Page scroll ---
- km.bind(key("pagedown"), Action::ScrollPageDown);
- km.bind(key("pageup"), Action::ScrollPageUp);
-
- km
-}
-
-// ---------------------------------------------------------------------------
-// Vim Normal keymap
-// ---------------------------------------------------------------------------
-
-/// Build the default vim-normal keymap.
-pub(crate) fn default_vim_normal_keymap(settings: &Settings) -> Keymap {
- let mut km = Keymap::new();
- add_common_bindings(&mut km);
-
- // esc / ctrl-[ → exit (vim-normal exits, unlike vim-insert)
- km.bind(key("esc"), Action::Exit);
- km.bind(key("ctrl-["), Action::Exit);
-
- // Prefix key
- let prefix_char = settings.keys.prefix.chars().next().unwrap_or('a');
- km.bind(key(&format!("ctrl-{prefix_char}")), Action::EnterPrefixMode);
-
- // --- Vim navigation ---
- // j/k: scroll with optional exit at boundary.
- let scroll_exits = settings.keys.scroll_exits;
- let invert = settings.invert;
- bind_scroll_key(&mut km, "j", Action::SelectNext, !invert, scroll_exits);
- bind_scroll_key(&mut km, "k", Action::SelectPrevious, invert, scroll_exits);
- km.bind(key("h"), Action::CursorLeft);
- km.bind(key("l"), Action::CursorRight);
-
- // --- Vim cursor movement ---
- km.bind(key("0"), Action::CursorStart);
- km.bind(key("$"), Action::CursorEnd);
- km.bind(key("w"), Action::CursorWordRight);
- km.bind(key("b"), Action::CursorWordLeft);
- km.bind(key("e"), Action::CursorWordEnd);
-
- // --- Vim editing ---
- km.bind(key("x"), Action::DeleteCharAfter);
- km.bind(key("d d"), Action::ClearLine);
- km.bind(key("D"), Action::ClearToEnd);
- km.bind(key("C"), Action::VimChangeToEnd);
-
- // --- Mode switching ---
- km.bind(key("?"), Action::VimSearchInsert);
- km.bind(key("/"), Action::VimSearchInsert);
- km.bind(key("a"), Action::VimEnterInsertAfter);
- km.bind(key("A"), Action::VimEnterInsertAtEnd);
- km.bind(key("i"), Action::VimEnterInsert);
- km.bind(key("I"), Action::VimEnterInsertAtStart);
-
- // --- Numeric shortcuts (return selection without executing) ---
- for n in 1..=9u8 {
- km.bind(key(&n.to_string()), Action::ReturnSelectionNth(n));
- }
-
- // --- Half/full page scroll ---
- km.bind(key("ctrl-u"), Action::ScrollHalfPageUp);
- km.bind(key("ctrl-d"), Action::ScrollHalfPageDown);
- km.bind(key("ctrl-b"), Action::ScrollPageUp);
- km.bind(key("ctrl-f"), Action::ScrollPageDown);
-
- // --- Jump ---
- km.bind(key("G"), Action::ScrollToBottom);
- km.bind(key("g g"), Action::ScrollToTop);
- km.bind(key("H"), Action::ScrollToScreenTop);
- km.bind(key("M"), Action::ScrollToScreenMiddle);
- km.bind(key("L"), Action::ScrollToScreenBottom);
-
- // --- Arrow keys (same as emacs for convenience) ---
- bind_scroll_key(&mut km, "down", Action::SelectNext, !invert, scroll_exits);
- bind_scroll_key(&mut km, "up", Action::SelectPrevious, invert, scroll_exits);
-
- // --- Page scroll ---
- km.bind(key("pagedown"), Action::ScrollPageDown);
- km.bind(key("pageup"), Action::ScrollPageUp);
-
- // --- Accept ---
- let accept = accept_action(settings);
- km.bind(key("enter"), accept);
-
- km
-}
-
-// ---------------------------------------------------------------------------
-// Vim Insert keymap
-// ---------------------------------------------------------------------------
-
-/// Build the default vim-insert keymap. This clones the emacs keymap and
-/// overlays vim-insert-specific bindings (esc → enter normal mode).
-pub(crate) fn default_vim_insert_keymap(settings: &Settings) -> Keymap {
- let mut km = default_emacs_keymap(settings);
-
- // Override esc and ctrl-[ to enter normal mode instead of exiting
- km.bind(key("esc"), Action::VimEnterNormal);
- km.bind(key("ctrl-["), Action::VimEnterNormal);
-
- km
-}
-
-// ---------------------------------------------------------------------------
-// Inspector keymap
-// ---------------------------------------------------------------------------
-
-/// Build the default inspector keymap (tab index 1).
-///
-/// The inspector shows details about the selected history item and has no
-/// text input, so we build a minimal keymap with only inspector-relevant
-/// bindings. We respect the user's `keymap_mode` to provide vim-style j/k
-/// navigation for vim users.
-pub(crate) fn default_inspector_keymap(settings: &Settings) -> Keymap {
- use crate::atuin_client::settings::KeymapMode;
-
- let mut km = Keymap::new();
-
- // Common bindings (same as search tab)
- km.bind(key("ctrl-c"), Action::ReturnOriginal);
- km.bind(key("ctrl-g"), Action::ReturnOriginal);
- km.bind(key("esc"), Action::Exit);
- km.bind(key("ctrl-["), Action::Exit);
- km.bind(key("tab"), Action::ReturnSelection);
- km.bind(key("ctrl-o"), Action::ToggleTab);
-
- // Accept behavior respects enter_accept setting
- let accept = if settings.enter_accept {
- Action::Accept
- } else {
- Action::ReturnSelection
- };
- km.bind(key("enter"), accept);
-
- // Inspector-specific: delete history entry
- km.bind(key("ctrl-d"), Action::Delete);
-
- // Inspector navigation
- km.bind(key("up"), Action::InspectPrevious);
- km.bind(key("down"), Action::InspectNext);
- km.bind(key("pageup"), Action::InspectPrevious);
- km.bind(key("pagedown"), Action::InspectNext);
-
- // For vim users, add j/k navigation
- if matches!(
- settings.keymap_mode,
- KeymapMode::VimNormal | KeymapMode::VimInsert
- ) {
- km.bind(key("j"), Action::InspectNext);
- km.bind(key("k"), Action::InspectPrevious);
- }
-
- km
-}
-
-// ---------------------------------------------------------------------------
-// Prefix keymap
-// ---------------------------------------------------------------------------
-
-/// Build the default prefix keymap (active after ctrl-a prefix).
-pub(crate) fn default_prefix_keymap() -> Keymap {
- let mut km = Keymap::new();
-
- km.bind(key("d"), Action::Delete);
- km.bind(key("D"), Action::DeleteAll);
- km.bind(key("a"), Action::CursorStart);
- km.bind_conditional(
- key("c"),
- vec![
- KeyRule::when(ConditionAtom::HasContext, Action::ClearContext),
- KeyRule::always(Action::SwitchContext),
- ],
- );
-
- km
-}
-
-// ---------------------------------------------------------------------------
-// KeymapSet construction
-// ---------------------------------------------------------------------------
-
-// ---------------------------------------------------------------------------
-// Config → Keymap conversion
-// ---------------------------------------------------------------------------
-
-/// Convert a `KeyBindingConfig` (from TOML) into a `KeyBinding`.
-/// Returns `Err` if an action name or condition expression is invalid.
-fn parse_binding_config(config: &KeyBindingConfig) -> Result<KeyBinding, String> {
- match config {
- KeyBindingConfig::Simple(action_str) => {
- let action = Action::from_str(action_str)?;
- Ok(KeyBinding::simple(action))
- }
- KeyBindingConfig::Rules(rules) => {
- let mut parsed_rules = Vec::with_capacity(rules.len());
- for rule_cfg in rules {
- let action = Action::from_str(&rule_cfg.action)?;
- let rule = match &rule_cfg.when {
- None => KeyRule::always(action),
- Some(cond_str) => {
- let cond = ConditionExpr::parse(cond_str)?;
- KeyRule::when(cond, action)
- }
- };
- parsed_rules.push(rule);
- }
- Ok(KeyBinding::conditional(parsed_rules))
- }
- }
-}
-
-/// Apply a map of key-string → binding-config overrides to a keymap.
-/// Per-key override replaces the entire rule list for that key.
-/// Invalid keys or action names are logged and skipped.
-fn apply_config_to_keymap(keymap: &mut Keymap, overrides: &HashMap<String, KeyBindingConfig>) {
- for (key_str, binding_cfg) in overrides {
- let key = match KeyInput::parse(key_str) {
- Ok(k) => k,
- Err(e) => {
- warn!("invalid key in keymap config: {key_str:?}: {e}");
- continue;
- }
- };
- match parse_binding_config(binding_cfg) {
- Ok(binding) => {
- keymap.bindings.insert(key, binding);
- }
- Err(e) => {
- warn!("invalid binding for {key_str:?} in keymap config: {e}");
- }
- }
- }
-}
-
-impl KeymapSet {
- /// Build the complete set of default keymaps from settings.
- pub(crate) fn defaults(settings: &Settings) -> Self {
- Self {
- emacs: default_emacs_keymap(settings),
- vim_normal: default_vim_normal_keymap(settings),
- vim_insert: default_vim_insert_keymap(settings),
- inspector: default_inspector_keymap(settings),
- prefix: default_prefix_keymap(),
- }
- }
-
- /// Build keymaps from settings, applying any user `[keymap]` overrides.
- ///
- /// Precedence rules:
- /// - If `[keymap]` has any entries, `[keys]` is **ignored entirely**.
- /// Defaults are built with standard `[keys]` values, then `[keymap]`
- /// overrides are applied per-key.
- /// - If `[keymap]` is empty/absent, `[keys]` customizes the defaults
- /// (current behavior for backward compatibility).
- pub(crate) fn from_settings(settings: &Settings) -> Self {
- use crate::atuin_client::settings::Keys;
-
- if settings.keymap.is_empty() {
- // No [keymap] section → use [keys] to customize defaults
- Self::defaults(settings)
- } else {
- // [keymap] present → ignore [keys], use standard defaults as base
- let mut base_settings = settings.clone();
- base_settings.keys = Keys::standard_defaults();
- let mut set = Self::defaults(&base_settings);
- set.apply_config(settings);
- set
- }
- }
-
- /// Apply user keymap config overrides to all modes.
- fn apply_config(&mut self, settings: &Settings) {
- let config = &settings.keymap;
- apply_config_to_keymap(&mut self.emacs, &config.emacs);
- apply_config_to_keymap(&mut self.vim_normal, &config.vim_normal);
- apply_config_to_keymap(&mut self.vim_insert, &config.vim_insert);
- apply_config_to_keymap(&mut self.inspector, &config.inspector);
- apply_config_to_keymap(&mut self.prefix, &config.prefix);
- }
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-#[cfg(test)]
-mod tests {
- use super::{
- Action, HashMap, KeymapSet, Settings, default_emacs_keymap, default_inspector_keymap,
- default_prefix_keymap, default_vim_insert_keymap, default_vim_normal_keymap, key,
- parse_binding_config,
- };
- use crate::command::client::search::keybindings::conditions::EvalContext;
-
- fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext {
- EvalContext {
- cursor_position: cursor,
- input_width: width,
- input_byte_len: width,
- selected_index: selected,
- results_len: len,
- original_input_empty: false,
- has_context: false,
- }
- }
-
- fn default_settings() -> Settings {
- Settings::new().unwrap()
- }
-
- // -- Emacs keymap tests --
-
- #[test]
- fn emacs_ctrl_c_returns_original() {
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- km.resolve(&key("ctrl-c"), &ctx),
- Some(Action::ReturnOriginal)
- );
- }
-
- #[test]
- fn emacs_esc_exits() {
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::Exit));
- }
-
- #[test]
- fn emacs_tab_returns_selection() {
- // enter_accept=false in test defaults → ReturnSelection
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection));
- }
-
- #[test]
- fn emacs_enter_returns_selection() {
- // enter_accept=false in test defaults → ReturnSelection
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- km.resolve(&key("enter"), &ctx),
- Some(Action::ReturnSelection)
- );
- }
-
- #[test]
- fn emacs_enter_accept_true_uses_accept() {
- let mut settings = default_settings();
- settings.enter_accept = true;
- let km = default_emacs_keymap(&settings);
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("enter"), &ctx), Some(Action::Accept));
- assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection));
- }
-
- #[test]
- fn emacs_right_at_end_returns_selection() {
- let km = default_emacs_keymap(&default_settings());
- // cursor at end of "hello" (width 5)
- let ctx = make_ctx(5, 5, 0, 10);
- assert_eq!(
- km.resolve(&key("right"), &ctx),
- Some(Action::ReturnSelection)
- );
- }
-
- #[test]
- fn emacs_right_not_at_end_moves() {
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(2, 5, 0, 10);
- assert_eq!(km.resolve(&key("right"), &ctx), Some(Action::CursorRight));
- }
-
- #[test]
- fn emacs_left_at_start_exits() {
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(0, 5, 0, 10);
- assert_eq!(km.resolve(&key("left"), &ctx), Some(Action::Exit));
- }
-
- #[test]
- fn emacs_left_not_at_start_moves() {
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(3, 5, 0, 10);
- assert_eq!(km.resolve(&key("left"), &ctx), Some(Action::CursorLeft));
- }
-
- #[test]
- fn emacs_down_at_start_exits() {
- let km = default_emacs_keymap(&default_settings());
- // selected=0 → ListAtStart → Exit
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::Exit));
- }
-
- #[test]
- fn emacs_down_not_at_start_selects_next() {
- let km = default_emacs_keymap(&default_settings());
- // selected=5 → not at start → SelectNext
- let ctx = make_ctx(0, 0, 5, 10);
- assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::SelectNext));
- }
-
- #[test]
- fn emacs_up_selects_previous() {
- let km = default_emacs_keymap(&default_settings());
- // Non-inverted: up never exits (moves away from index 0)
- let ctx = make_ctx(0, 0, 5, 10);
- assert_eq!(km.resolve(&key("up"), &ctx), Some(Action::SelectPrevious));
- }
-
- #[test]
- fn emacs_ctrl_d_empty_returns_original() {
- let km = default_emacs_keymap(&default_settings());
- // input empty (byte_len = 0)
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- km.resolve(&key("ctrl-d"), &ctx),
- Some(Action::ReturnOriginal)
- );
- }
-
- #[test]
- fn emacs_ctrl_d_nonempty_deletes() {
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(2, 5, 0, 10);
- assert_eq!(
- km.resolve(&key("ctrl-d"), &ctx),
- Some(Action::DeleteCharAfter)
- );
- }
-
- #[test]
- fn emacs_ctrl_n_selects_next_no_exit_condition() {
- let km = default_emacs_keymap(&default_settings());
- // at start, but ctrl-n should NOT exit (no exit condition bound)
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("ctrl-n"), &ctx), Some(Action::SelectNext));
- }
-
- #[test]
- fn emacs_prefix_key_enters_prefix() {
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- km.resolve(&key("ctrl-a"), &ctx),
- Some(Action::EnterPrefixMode)
- );
- }
-
- #[test]
- fn emacs_home_cursor_start() {
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(5, 10, 0, 10);
- assert_eq!(km.resolve(&key("home"), &ctx), Some(Action::CursorStart));
- }
-
- // -- Vim Normal keymap tests --
-
- #[test]
- fn vim_normal_j_at_start_exits() {
- let km = default_vim_normal_keymap(&default_settings());
- // selected=0 → ListAtStart → Exit (non-inverted: j moves toward index 0)
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("j"), &ctx), Some(Action::Exit));
- }
-
- #[test]
- fn vim_normal_j_not_at_start_selects_next() {
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 5, 10);
- assert_eq!(km.resolve(&key("j"), &ctx), Some(Action::SelectNext));
- }
-
- #[test]
- fn vim_normal_k_selects_previous() {
- let km = default_vim_normal_keymap(&default_settings());
- // Non-inverted: k never exits (moves away from index 0)
- let ctx = make_ctx(0, 0, 5, 10);
- assert_eq!(km.resolve(&key("k"), &ctx), Some(Action::SelectPrevious));
- }
-
- #[test]
- fn vim_normal_i_enters_insert() {
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("i"), &ctx), Some(Action::VimEnterInsert));
- }
-
- #[test]
- fn vim_normal_slash_search_insert() {
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("/"), &ctx), Some(Action::VimSearchInsert));
- }
-
- #[test]
- fn vim_normal_gg_scroll_to_top() {
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 50, 100);
- assert_eq!(km.resolve(&key("g g"), &ctx), Some(Action::ScrollToTop));
- }
-
- #[test]
- fn vim_normal_big_g_scroll_to_bottom() {
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 50, 100);
- assert_eq!(km.resolve(&key("G"), &ctx), Some(Action::ScrollToBottom));
- }
-
- #[test]
- fn vim_normal_numeric_returns_selection() {
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- km.resolve(&key("3"), &ctx),
- Some(Action::ReturnSelectionNth(3))
- );
- }
-
- #[test]
- fn vim_normal_ctrl_u_half_page_up() {
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 50, 100);
- assert_eq!(
- km.resolve(&key("ctrl-u"), &ctx),
- Some(Action::ScrollHalfPageUp)
- );
- }
-
- #[test]
- fn vim_normal_screen_jumps() {
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 50, 100);
- assert_eq!(km.resolve(&key("H"), &ctx), Some(Action::ScrollToScreenTop));
- assert_eq!(
- km.resolve(&key("M"), &ctx),
- Some(Action::ScrollToScreenMiddle)
- );
- assert_eq!(
- km.resolve(&key("L"), &ctx),
- Some(Action::ScrollToScreenBottom)
- );
- }
-
- #[test]
- fn vim_normal_enter_returns_selection() {
- // enter_accept=false in test defaults → ReturnSelection
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- km.resolve(&key("enter"), &ctx),
- Some(Action::ReturnSelection)
- );
- }
-
- #[test]
- fn vim_normal_enter_accept_true_uses_accept() {
- let mut settings = default_settings();
- settings.enter_accept = true;
- let km = default_vim_normal_keymap(&settings);
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("enter"), &ctx), Some(Action::Accept));
- }
-
- // -- Vim Insert keymap tests --
-
- #[test]
- fn vim_insert_inherits_emacs_enter() {
- let km = default_vim_insert_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- // enter_accept=false → ReturnSelection
- assert_eq!(
- km.resolve(&key("enter"), &ctx),
- Some(Action::ReturnSelection)
- );
- }
-
- #[test]
- fn vim_insert_esc_enters_normal() {
- let km = default_vim_insert_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::VimEnterNormal));
- }
-
- #[test]
- fn vim_insert_ctrl_bracket_enters_normal() {
- let km = default_vim_insert_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- km.resolve(&key("ctrl-["), &ctx),
- Some(Action::VimEnterNormal)
- );
- }
-
- #[test]
- fn vim_insert_inherits_emacs_ctrl_d() {
- let km = default_vim_insert_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- // input empty → return original
- assert_eq!(
- km.resolve(&key("ctrl-d"), &ctx),
- Some(Action::ReturnOriginal)
- );
- }
-
- // -- Inspector keymap tests --
-
- #[test]
- fn inspector_ctrl_d_deletes() {
- let km = default_inspector_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("ctrl-d"), &ctx), Some(Action::Delete));
- }
-
- #[test]
- fn inspector_up_inspects_previous() {
- let km = default_inspector_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("up"), &ctx), Some(Action::InspectPrevious));
- }
-
- #[test]
- fn inspector_down_inspects_next() {
- let km = default_inspector_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::InspectNext));
- }
-
- #[test]
- fn inspector_esc_exits() {
- let km = default_inspector_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::Exit));
- }
-
- #[test]
- fn inspector_tab_returns_selection() {
- // enter_accept=false → ReturnSelection
- let km = default_inspector_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection));
- }
-
- // -- Prefix keymap tests --
-
- #[test]
- fn prefix_d_deletes() {
- let km = default_prefix_keymap();
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("d"), &ctx), Some(Action::Delete));
- }
-
- #[test]
- fn prefix_a_cursor_start() {
- let km = default_prefix_keymap();
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("a"), &ctx), Some(Action::CursorStart));
- }
-
- #[test]
- fn prefix_unknown_key_returns_none() {
- let km = default_prefix_keymap();
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("x"), &ctx), None);
- }
-
- // -- KeymapSet tests --
-
- #[test]
- fn keymap_set_defaults_builds() {
- let settings = default_settings();
- let set = KeymapSet::defaults(&settings);
- let ctx = make_ctx(0, 0, 0, 10);
-
- // Sanity check each keymap has bindings
- assert!(set.emacs.resolve(&key("ctrl-c"), &ctx).is_some());
- assert!(set.vim_normal.resolve(&key("ctrl-c"), &ctx).is_some());
- assert!(set.vim_insert.resolve(&key("ctrl-c"), &ctx).is_some());
- assert!(set.inspector.resolve(&key("ctrl-c"), &ctx).is_some());
- assert!(set.prefix.resolve(&key("d"), &ctx).is_some());
- }
-
- // -- Settings-dependent behavior --
-
- #[test]
- fn custom_prefix_char() {
- let mut settings = default_settings();
- settings.keys.prefix = "x".to_string();
- let km = default_emacs_keymap(&settings);
- let ctx = make_ctx(0, 0, 0, 10);
-
- // ctrl-x should be prefix mode
- assert_eq!(
- km.resolve(&key("ctrl-x"), &ctx),
- Some(Action::EnterPrefixMode)
- );
- // ctrl-a should now be CursorStart (not prefix)
- assert_eq!(km.resolve(&key("ctrl-a"), &ctx), Some(Action::CursorStart));
- }
-
- #[test]
- fn ctrl_n_shortcuts_changes_numeric_modifier() {
- let mut settings = default_settings();
- settings.ctrl_n_shortcuts = true;
- let km = default_emacs_keymap(&settings);
- let ctx = make_ctx(0, 0, 0, 10);
-
- // ctrl-1 should work
- assert_eq!(
- km.resolve(&key("ctrl-1"), &ctx),
- Some(Action::ReturnSelectionNth(1))
- );
- // alt-1 should NOT be bound
- assert_eq!(km.resolve(&key("alt-1"), &ctx), None);
- }
-
- #[test]
- fn default_alt_numeric_shortcuts() {
- let settings = default_settings();
- let km = default_emacs_keymap(&settings);
- let ctx = make_ctx(0, 0, 0, 10);
-
- // alt-1 should work by default
- assert_eq!(
- km.resolve(&key("alt-1"), &ctx),
- Some(Action::ReturnSelectionNth(1))
- );
- }
-
- // -----------------------------------------------------------------------
- // Config parsing and merging tests
- // -----------------------------------------------------------------------
-
- #[test]
- fn parse_simple_binding_config() {
- use crate::atuin_client::settings::KeyBindingConfig;
- let cfg = KeyBindingConfig::Simple("accept".to_string());
- let binding = parse_binding_config(&cfg).unwrap();
- assert_eq!(binding.rules.len(), 1);
- assert!(binding.rules[0].condition.is_none());
- assert_eq!(binding.rules[0].action, Action::Accept);
- }
-
- #[test]
- fn parse_conditional_binding_config() {
- use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
- let cfg = KeyBindingConfig::Rules(vec![
- KeyRuleConfig {
- when: Some("cursor-at-start".to_string()),
- action: "exit".to_string(),
- },
- KeyRuleConfig {
- when: None,
- action: "cursor-left".to_string(),
- },
- ]);
- let binding = parse_binding_config(&cfg).unwrap();
- assert_eq!(binding.rules.len(), 2);
- assert!(binding.rules[0].condition.is_some());
- assert_eq!(binding.rules[0].action, Action::Exit);
- assert!(binding.rules[1].condition.is_none());
- assert_eq!(binding.rules[1].action, Action::CursorLeft);
- }
-
- #[test]
- fn parse_binding_config_invalid_action() {
- use crate::atuin_client::settings::KeyBindingConfig;
- let cfg = KeyBindingConfig::Simple("not-a-real-action".to_string());
- assert!(parse_binding_config(&cfg).is_err());
- }
-
- #[test]
- fn parse_binding_config_invalid_condition() {
- use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
- let cfg = KeyBindingConfig::Rules(vec![KeyRuleConfig {
- when: Some("not-a-real-condition".to_string()),
- action: "exit".to_string(),
- }]);
- assert!(parse_binding_config(&cfg).is_err());
- }
-
- #[test]
- fn config_override_replaces_key() {
- use crate::atuin_client::settings::KeyBindingConfig;
- use std::collections::HashMap;
-
- let mut settings = default_settings();
- let set = KeymapSet::defaults(&settings);
-
- // Default: ctrl-c → ReturnOriginal
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- set.emacs.resolve(&key("ctrl-c"), &ctx),
- Some(Action::ReturnOriginal)
- );
-
- // Override ctrl-c → Exit via config
- settings.keymap.emacs = HashMap::from([(
- "ctrl-c".to_string(),
- KeyBindingConfig::Simple("exit".to_string()),
- )]);
-
- let set = KeymapSet::from_settings(&settings);
- assert_eq!(set.emacs.resolve(&key("ctrl-c"), &ctx), Some(Action::Exit));
- }
-
- #[test]
- fn config_override_preserves_unoverridden_keys() {
- use crate::atuin_client::settings::KeyBindingConfig;
- use std::collections::HashMap;
-
- let mut settings = default_settings();
- // Override only ctrl-c; enter should keep its default
- settings.keymap.emacs = HashMap::from([(
- "ctrl-c".to_string(),
- KeyBindingConfig::Simple("exit".to_string()),
- )]);
-
- let set = KeymapSet::from_settings(&settings);
- let ctx = make_ctx(0, 0, 0, 10);
-
- // ctrl-c overridden
- assert_eq!(set.emacs.resolve(&key("ctrl-c"), &ctx), Some(Action::Exit));
- // enter still has default (enter_accept=false → ReturnSelection)
- assert_eq!(
- set.emacs.resolve(&key("enter"), &ctx),
- Some(Action::ReturnSelection)
- );
- }
-
- #[test]
- fn config_conditional_override() {
- use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
- use std::collections::HashMap;
-
- let mut settings = default_settings();
- // Override "up" with a custom conditional
- settings.keymap.emacs = HashMap::from([(
- "up".to_string(),
- KeyBindingConfig::Rules(vec![
- KeyRuleConfig {
- when: Some("no-results".to_string()),
- action: "exit".to_string(),
- },
- KeyRuleConfig {
- when: None,
- action: "select-previous".to_string(),
- },
- ]),
- )]);
-
- let set = KeymapSet::from_settings(&settings);
-
- // With no results → exit
- let ctx = make_ctx(0, 0, 0, 0);
- assert_eq!(set.emacs.resolve(&key("up"), &ctx), Some(Action::Exit));
-
- // With results → select-previous
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- set.emacs.resolve(&key("up"), &ctx),
- Some(Action::SelectPrevious)
- );
- }
-
- #[test]
- fn from_settings_with_empty_config_equals_defaults() {
- let settings = default_settings();
- let defaults = KeymapSet::defaults(&settings);
- let from_settings = KeymapSet::from_settings(&settings);
-
- // Verify a sample of keys produce the same results
- let ctx = make_ctx(0, 0, 0, 10);
- let test_keys = [
- "ctrl-c", "enter", "esc", "tab", "up", "down", "left", "right",
- ];
- for k in &test_keys {
- assert_eq!(
- defaults.emacs.resolve(&key(k), &ctx),
- from_settings.emacs.resolve(&key(k), &ctx),
- "mismatch for emacs key {k}"
- );
- }
- }
-
- // -----------------------------------------------------------------------
- // Phase 5: [keys] vs [keymap] backward compatibility
- // -----------------------------------------------------------------------
-
- #[test]
- fn keymap_overrides_ignore_keys_section() {
- use crate::atuin_client::settings::KeyBindingConfig;
-
- // Set up: [keys] disables scroll_exits, but [keymap] is present
- let mut settings = default_settings();
- settings.keys.scroll_exits = false;
-
- // Without [keymap], scroll_exits=false means no exit condition on down
- let set_legacy = KeymapSet::defaults(&settings);
- // At list-at-start (selected=0), down should still be SelectNext (no exit)
- let ctx_at_boundary = make_ctx(0, 0, 0, 10);
- assert_eq!(
- set_legacy.emacs.resolve(&key("down"), &ctx_at_boundary),
- Some(Action::SelectNext),
- "legacy: down at boundary should be SelectNext with scroll_exits=false"
- );
-
- // With [keymap] present (even just one override), [keys] is ignored
- // so the standard defaults (scroll_exits=true) apply
- settings.keymap.emacs = HashMap::from([(
- "ctrl-c".to_string(),
- KeyBindingConfig::Simple("exit".to_string()),
- )]);
- let set_keymap = KeymapSet::from_settings(&settings);
-
- // Not at boundary (selected=5): should SelectNext normally
- let ctx_not_at_boundary = make_ctx(0, 0, 5, 10);
- assert_eq!(
- set_keymap.emacs.resolve(&key("down"), &ctx_not_at_boundary),
- Some(Action::SelectNext),
- "keymap: down not at boundary should SelectNext"
- );
- // At list-at-start (selected=0): should Exit (standard scroll_exits=true)
- assert_eq!(
- set_keymap.emacs.resolve(&key("down"), &ctx_at_boundary),
- Some(Action::Exit),
- "keymap: down at boundary should Exit (standard defaults restored)"
- );
- }
-
- #[test]
- fn keymap_present_resets_to_standard_keys_defaults() {
- use crate::atuin_client::settings::KeyBindingConfig;
-
- let mut settings = default_settings();
- // Disable all [keys] behaviors
- settings.keys.exit_past_line_start = false;
- settings.keys.accept_past_line_end = false;
-
- // Without [keymap], left should be plain CursorLeft
- let set_legacy = KeymapSet::defaults(&settings);
- let ctx_at_start = make_ctx(0, 5, 0, 10);
- assert_eq!(
- set_legacy.emacs.resolve(&key("left"), &ctx_at_start),
- Some(Action::CursorLeft),
- "legacy: left should be plain CursorLeft without exit_past_line_start"
- );
-
- // Add a [keymap] entry (for a different key)
- settings.keymap.emacs = HashMap::from([(
- "ctrl-c".to_string(),
- KeyBindingConfig::Simple("exit".to_string()),
- )]);
- let set_keymap = KeymapSet::from_settings(&settings);
-
- // Now left should use standard defaults (exit_past_line_start=true)
- // At cursor start → Exit
- assert_eq!(
- set_keymap.emacs.resolve(&key("left"), &ctx_at_start),
- Some(Action::Exit),
- "keymap: left at cursor start should exit (standard defaults)"
- );
-
- // Right at cursor end should return selection (standard defaults: accept_past_line_end=true, enter_accept=false)
- let ctx_at_end = make_ctx(5, 5, 0, 10);
- assert_eq!(
- set_keymap.emacs.resolve(&key("right"), &ctx_at_end),
- Some(Action::ReturnSelection),
- "keymap: right at cursor end should return selection (standard defaults)"
- );
- }
-
- #[test]
- fn original_input_empty_condition_in_config() {
- use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
- use std::collections::HashMap;
-
- let mut settings = default_settings();
- // Configure esc to: if original-input-empty -> return-query, else return-original
- settings.keymap.emacs = HashMap::from([(
- "esc".to_string(),
- KeyBindingConfig::Rules(vec![
- KeyRuleConfig {
- when: Some("original-input-empty".to_string()),
- action: "return-query".to_string(),
- },
- KeyRuleConfig {
- when: None,
- action: "return-original".to_string(),
- },
- ]),
- )]);
-
- let set = KeymapSet::from_settings(&settings);
-
- // When original input was empty, should return-query
- let ctx_original_empty = EvalContext {
- cursor_position: 0,
- input_width: 5,
- input_byte_len: 5,
- selected_index: 0,
- results_len: 10,
- original_input_empty: true,
- has_context: false,
- };
- assert_eq!(
- set.emacs.resolve(&key("esc"), &ctx_original_empty),
- Some(Action::ReturnQuery),
- "esc with original_input_empty=true should return-query"
- );
-
- // When original input was not empty, should return-original
- let ctx_original_not_empty = EvalContext {
- cursor_position: 0,
- input_width: 5,
- input_byte_len: 5,
- selected_index: 0,
- results_len: 10,
- original_input_empty: false,
- has_context: false,
- };
- assert_eq!(
- set.emacs.resolve(&key("esc"), &ctx_original_not_empty),
- Some(Action::ReturnOriginal),
- "esc with original_input_empty=false should return-original"
- );
- }
-}
diff --git a/crates/client/src/command/client/search/keybindings/key.rs b/crates/client/src/command/client/search/keybindings/key.rs
deleted file mode 100644
index 5e772238..00000000
--- a/crates/client/src/command/client/search/keybindings/key.rs
+++ /dev/null
@@ -1,633 +0,0 @@
-use std::fmt;
-
-use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MediaKeyCode};
-use serde::{Deserialize, Deserializer, Serialize, Serializer};
-
-/// A single key press with modifiers (e.g. `ctrl-c`, `alt-f`, `enter`).
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
-#[expect(clippy::struct_excessive_bools)]
-pub(crate) struct SingleKey {
- pub(crate) code: KeyCodeValue,
- pub(crate) ctrl: bool,
- pub(crate) alt: bool,
- pub(crate) shift: bool,
- pub(crate) super_key: bool,
-}
-
-/// The key code portion of a key press.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
-#[expect(
- variant_size_differences,
- reason = "It's not that much. So should be ok?"
-)]
-pub(crate) enum KeyCodeValue {
- Char(char),
- Enter,
- Esc,
- Tab,
- Backspace,
- Delete,
- Insert,
- Up,
- Down,
- Left,
- Right,
- Home,
- End,
- PageUp,
- PageDown,
- Space,
- F(u8),
- Media(MediaKeyCode),
-}
-
-/// A key input that may be a single key or a multi-key sequence (e.g. `g g`).
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
-pub(crate) enum KeyInput {
- Single(SingleKey),
- Sequence(Vec<SingleKey>),
-}
-
-impl SingleKey {
- /// Convert a crossterm `KeyEvent` into a `SingleKey`.
- pub(crate) fn from_event(event: &KeyEvent) -> Option<Self> {
- let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
- let alt = event.modifiers.contains(KeyModifiers::ALT);
- let shift = event.modifiers.contains(KeyModifiers::SHIFT);
- let super_key = event.modifiers.contains(KeyModifiers::SUPER);
-
- let code = match event.code {
- KeyCode::Char(' ') => KeyCodeValue::Space,
- KeyCode::Char(c) => {
- // If shift is the only modifier and it's an uppercase letter,
- // we store the uppercase char directly and clear the shift flag
- // since the case already encodes it.
- if shift && !ctrl && !alt && !super_key && c.is_ascii_uppercase() {
- return Some(Self {
- code: KeyCodeValue::Char(c),
- ctrl: false,
- alt: false,
- shift: false,
- super_key: false,
- });
- }
- KeyCodeValue::Char(c)
- }
- KeyCode::Enter => KeyCodeValue::Enter,
- KeyCode::Esc => KeyCodeValue::Esc,
- KeyCode::Tab => KeyCodeValue::Tab,
- // BackTab is sent by many terminals for Shift+Tab
- KeyCode::BackTab => {
- return Some(Self {
- code: KeyCodeValue::Tab,
- ctrl,
- alt,
- shift: true,
- super_key,
- });
- }
- KeyCode::Backspace => KeyCodeValue::Backspace,
- KeyCode::Delete => KeyCodeValue::Delete,
- KeyCode::Insert => KeyCodeValue::Insert,
- KeyCode::Up => KeyCodeValue::Up,
- KeyCode::Down => KeyCodeValue::Down,
- KeyCode::Left => KeyCodeValue::Left,
- KeyCode::Right => KeyCodeValue::Right,
- KeyCode::Home => KeyCodeValue::Home,
- KeyCode::End => KeyCodeValue::End,
- KeyCode::PageUp => KeyCodeValue::PageUp,
- KeyCode::PageDown => KeyCodeValue::PageDown,
- KeyCode::F(n) => KeyCodeValue::F(n),
- KeyCode::Media(m) => KeyCodeValue::Media(m),
- _ => return None,
- };
-
- Some(Self {
- code,
- ctrl,
- alt,
- shift: if matches!(code, KeyCodeValue::Char(_)) {
- false
- } else {
- shift
- },
- super_key,
- })
- }
-
- /// Parse a key string like `"ctrl-c"`, `"alt-f"`, `"enter"`, `"G"`.
- pub(crate) fn parse(s: &str) -> Result<Self, String> {
- let s = s.trim();
- let parts: Vec<&str> = s.split('-').collect();
-
- let mut ctrl = false;
- let mut alt = false;
- let mut shift = false;
- let mut super_key = false;
-
- // All parts except the last are modifiers
- for &part in &parts[..parts.len() - 1] {
- match part.to_lowercase().as_str() {
- "ctrl" => ctrl = true,
- "alt" => alt = true,
- "shift" => shift = true,
- "super" | "cmd" | "win" => super_key = true,
- _ => return Err(format!("unknown modifier: {part}")),
- }
- }
-
- let key_part = parts[parts.len() - 1];
- let code = match key_part.to_lowercase().as_str() {
- "enter" | "return" => KeyCodeValue::Enter,
- "esc" | "escape" => KeyCodeValue::Esc,
- "tab" => KeyCodeValue::Tab,
- "backspace" => KeyCodeValue::Backspace,
- "delete" | "del" => KeyCodeValue::Delete,
- "insert" | "ins" => KeyCodeValue::Insert,
- "up" => KeyCodeValue::Up,
- "down" => KeyCodeValue::Down,
- "left" => KeyCodeValue::Left,
- "right" => KeyCodeValue::Right,
- "home" => KeyCodeValue::Home,
- "end" => KeyCodeValue::End,
- "pageup" => KeyCodeValue::PageUp,
- "pagedown" => KeyCodeValue::PageDown,
- "space" => KeyCodeValue::Space,
- s if s.starts_with('f') && s.len() > 1 => {
- // Parse function keys like "f1", "f12"
- if let Ok(n) = s[1..].parse::<u8>() {
- if (1..=24).contains(&n) {
- KeyCodeValue::F(n)
- } else {
- return Err(format!("function key out of range: {key_part}"));
- }
- } else {
- return Err(format!("unknown key: {key_part}"));
- }
- }
- "[" => KeyCodeValue::Char('['),
- "]" => KeyCodeValue::Char(']'),
- "?" => KeyCodeValue::Char('?'),
- "/" => KeyCodeValue::Char('/'),
- "$" => KeyCodeValue::Char('$'),
- // Media keys (no dashes - the parser splits on dash for modifiers)
- "play" => KeyCodeValue::Media(MediaKeyCode::Play),
- "pause" => KeyCodeValue::Media(MediaKeyCode::Pause),
- "playpause" => KeyCodeValue::Media(MediaKeyCode::PlayPause),
- "stop" => KeyCodeValue::Media(MediaKeyCode::Stop),
- "fastforward" => KeyCodeValue::Media(MediaKeyCode::FastForward),
- "rewind" => KeyCodeValue::Media(MediaKeyCode::Rewind),
- "tracknext" => KeyCodeValue::Media(MediaKeyCode::TrackNext),
- "trackprevious" => KeyCodeValue::Media(MediaKeyCode::TrackPrevious),
- "record" => KeyCodeValue::Media(MediaKeyCode::Record),
- "lowervolume" => KeyCodeValue::Media(MediaKeyCode::LowerVolume),
- "raisevolume" => KeyCodeValue::Media(MediaKeyCode::RaiseVolume),
- "mutevolume" | "mute" => KeyCodeValue::Media(MediaKeyCode::MuteVolume),
- _ => {
- let chars: Vec<char> = key_part.chars().collect();
- if chars.len() == 1 {
- let c = chars[0];
- // An uppercase letter implies shift (unless shift already specified)
- if c.is_ascii_uppercase() && !ctrl && !alt && !super_key {
- return Ok(Self {
- code: KeyCodeValue::Char(c),
- ctrl: false,
- alt: false,
- shift: false,
- super_key: false,
- });
- }
- KeyCodeValue::Char(c)
- } else {
- return Err(format!("unknown key: {key_part}"));
- }
- }
- };
-
- Ok(Self {
- code,
- ctrl,
- alt,
- shift,
- super_key,
- })
- }
-}
-
-impl fmt::Display for SingleKey {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- if self.super_key {
- write!(f, "super-")?;
- }
- if self.ctrl {
- write!(f, "ctrl-")?;
- }
- if self.alt {
- write!(f, "alt-")?;
- }
- if self.shift {
- write!(f, "shift-")?;
- }
- match &self.code {
- KeyCodeValue::Char(c) => write!(f, "{c}"),
- KeyCodeValue::Enter => write!(f, "enter"),
- KeyCodeValue::Esc => write!(f, "esc"),
- KeyCodeValue::Tab => write!(f, "tab"),
- KeyCodeValue::Backspace => write!(f, "backspace"),
- KeyCodeValue::Delete => write!(f, "delete"),
- KeyCodeValue::Insert => write!(f, "insert"),
- KeyCodeValue::Up => write!(f, "up"),
- KeyCodeValue::Down => write!(f, "down"),
- KeyCodeValue::Left => write!(f, "left"),
- KeyCodeValue::Right => write!(f, "right"),
- KeyCodeValue::Home => write!(f, "home"),
- KeyCodeValue::End => write!(f, "end"),
- KeyCodeValue::PageUp => write!(f, "pageup"),
- KeyCodeValue::PageDown => write!(f, "pagedown"),
- KeyCodeValue::Space => write!(f, "space"),
- KeyCodeValue::F(n) => write!(f, "f{n}"),
- KeyCodeValue::Media(m) => match m {
- MediaKeyCode::Play => write!(f, "play"),
- MediaKeyCode::Pause => write!(f, "media-pause"),
- MediaKeyCode::PlayPause => write!(f, "playpause"),
- MediaKeyCode::Stop => write!(f, "stop"),
- MediaKeyCode::FastForward => write!(f, "fastforward"),
- MediaKeyCode::Rewind => write!(f, "rewind"),
- MediaKeyCode::TrackNext => write!(f, "tracknext"),
- MediaKeyCode::TrackPrevious => write!(f, "trackprevious"),
- MediaKeyCode::Record => write!(f, "record"),
- MediaKeyCode::LowerVolume => write!(f, "lowervolume"),
- MediaKeyCode::RaiseVolume => write!(f, "raisevolume"),
- MediaKeyCode::MuteVolume => write!(f, "mutevolume"),
- MediaKeyCode::Reverse => write!(f, "reverse"),
- },
- }
- }
-}
-
-impl KeyInput {
- /// Parse a key input string. Supports multi-key sequences separated by spaces
- /// (e.g. `"g g"`).
- pub(crate) fn parse(s: &str) -> Result<Self, String> {
- let s = s.trim();
- // Check for space-separated multi-key sequences
- // But don't split "space" or modifier combos like "ctrl-a"
- let parts: Vec<&str> = s.split_whitespace().collect();
- if parts.len() > 1 {
- let keys: Result<Vec<SingleKey>, String> =
- parts.iter().map(|p| SingleKey::parse(p)).collect();
- Ok(Self::Sequence(keys?))
- } else {
- Ok(Self::Single(SingleKey::parse(s)?))
- }
- }
-}
-
-impl fmt::Display for KeyInput {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::Single(k) => write!(f, "{k}"),
- Self::Sequence(keys) => {
- for (i, k) in keys.iter().enumerate() {
- if i > 0 {
- write!(f, " ")?;
- }
- write!(f, "{k}")?;
- }
- Ok(())
- }
- }
- }
-}
-
-impl Serialize for KeyInput {
- fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
- serializer.serialize_str(&self.to_string())
- }
-}
-
-impl<'de> Deserialize<'de> for KeyInput {
- fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
- let s = String::deserialize(deserializer)?;
- Self::parse(&s).map_err(serde::de::Error::custom)
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::{KeyCodeValue, KeyInput, SingleKey};
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
- #[test]
- fn parse_simple_keys() {
- let k = SingleKey::parse("a").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('a'));
- assert!(!k.ctrl && !k.alt && !k.shift);
-
- let k = SingleKey::parse("enter").unwrap();
- assert_eq!(k.code, KeyCodeValue::Enter);
-
- let k = SingleKey::parse("esc").unwrap();
- assert_eq!(k.code, KeyCodeValue::Esc);
-
- let k = SingleKey::parse("tab").unwrap();
- assert_eq!(k.code, KeyCodeValue::Tab);
-
- let k = SingleKey::parse("space").unwrap();
- assert_eq!(k.code, KeyCodeValue::Space);
- }
-
- #[test]
- fn parse_modifiers() {
- let k = SingleKey::parse("ctrl-c").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('c'));
- assert!(k.ctrl);
- assert!(!k.alt);
-
- let k = SingleKey::parse("alt-f").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('f'));
- assert!(k.alt);
- assert!(!k.ctrl);
-
- let k = SingleKey::parse("ctrl-alt-x").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('x'));
- assert!(k.ctrl && k.alt);
- }
-
- #[test]
- fn parse_uppercase_implies_no_shift_flag() {
- let k = SingleKey::parse("G").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('G'));
- assert!(!k.shift);
- assert!(!k.ctrl);
- }
-
- #[test]
- fn parse_special_chars() {
- let k = SingleKey::parse("ctrl-[").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('['));
- assert!(k.ctrl);
-
- let k = SingleKey::parse("?").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('?'));
-
- let k = SingleKey::parse("/").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('/'));
- }
-
- #[test]
- fn parse_multi_key_sequence() {
- let ki = KeyInput::parse("g g").unwrap();
- match ki {
- KeyInput::Sequence(keys) => {
- assert_eq!(keys.len(), 2);
- assert_eq!(keys[0].code, KeyCodeValue::Char('g'));
- assert_eq!(keys[1].code, KeyCodeValue::Char('g'));
- }
- _ => panic!("expected sequence"),
- }
- }
-
- #[test]
- fn display_round_trip() {
- let cases = ["ctrl-c", "alt-f", "enter", "G", "tab", "pageup"];
- for s in cases {
- let k = KeyInput::parse(s).unwrap();
- let display = k.to_string();
- let k2 = KeyInput::parse(&display).unwrap();
- assert_eq!(k, k2, "round-trip failed for {s}");
- }
-
- let ki = KeyInput::parse("g g").unwrap();
- assert_eq!(ki.to_string(), "g g");
- }
-
- #[test]
- fn from_event_basic() {
- let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('c'));
- assert!(k.ctrl);
- assert!(!k.alt);
-
- let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::Enter);
- }
-
- #[test]
- fn from_event_uppercase() {
- // Crossterm sends uppercase chars with SHIFT modifier
- let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('G'));
- // shift flag should be cleared since the case encodes it
- assert!(!k.shift);
- }
-
- #[test]
- fn from_event_matches_parsed() {
- // Verify that from_event and parse produce the same SingleKey
- let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
- let from_event = SingleKey::from_event(&event).unwrap();
- let parsed = SingleKey::parse("ctrl-c").unwrap();
- assert_eq!(from_event, parsed);
-
- let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT);
- let from_event = SingleKey::from_event(&event).unwrap();
- let parsed = SingleKey::parse("G").unwrap();
- assert_eq!(from_event, parsed);
- }
-
- #[test]
- fn parse_super_modifier() {
- let k = SingleKey::parse("super-a").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('a'));
- assert!(k.super_key);
- assert!(!k.ctrl && !k.alt && !k.shift);
-
- // "cmd" is an alias for "super"
- let k2 = SingleKey::parse("cmd-a").unwrap();
- assert_eq!(k, k2);
-
- // "win" is an alias for "super"
- let k3 = SingleKey::parse("win-a").unwrap();
- assert_eq!(k, k3);
- }
-
- #[test]
- fn parse_super_with_other_modifiers() {
- let k = SingleKey::parse("super-ctrl-c").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('c'));
- assert!(k.super_key && k.ctrl);
- assert!(!k.alt && !k.shift);
- }
-
- #[test]
- fn display_super_modifier() {
- let k = SingleKey::parse("super-a").unwrap();
- assert_eq!(k.to_string(), "super-a");
-
- let k = SingleKey::parse("super-ctrl-x").unwrap();
- assert_eq!(k.to_string(), "super-ctrl-x");
- }
-
- #[test]
- fn display_round_trip_super() {
- let k = KeyInput::parse("super-a").unwrap();
- let display = k.to_string();
- let k2 = KeyInput::parse(&display).unwrap();
- assert_eq!(k, k2, "round-trip failed for super-a");
- }
-
- #[test]
- fn from_event_super() {
- let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('a'));
- assert!(k.super_key);
- assert!(!k.ctrl && !k.alt && !k.shift);
- }
-
- #[test]
- fn from_event_super_matches_parsed() {
- let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER);
- let from_event = SingleKey::from_event(&event).unwrap();
- let parsed = SingleKey::parse("super-a").unwrap();
- assert_eq!(from_event, parsed);
- }
-
- #[test]
- fn super_uppercase_preserves_super() {
- // super-G should keep the super flag (unlike bare "G" which clears shift)
- let k = SingleKey::parse("super-G").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('G'));
- assert!(k.super_key);
- }
-
- #[test]
- fn parse_errors() {
- assert!(SingleKey::parse("ctrl-alt-shift-xxx").is_err());
- assert!(SingleKey::parse("foobar-a").is_err());
- }
-
- #[test]
- fn parse_function_keys() {
- let k = SingleKey::parse("f1").unwrap();
- assert_eq!(k.code, KeyCodeValue::F(1));
- assert!(!k.ctrl && !k.alt && !k.shift);
-
- let k = SingleKey::parse("F12").unwrap();
- assert_eq!(k.code, KeyCodeValue::F(12));
-
- let k = SingleKey::parse("ctrl-f5").unwrap();
- assert_eq!(k.code, KeyCodeValue::F(5));
- assert!(k.ctrl);
-
- // F24 is valid (some keyboards have extended function keys)
- let k = SingleKey::parse("f24").unwrap();
- assert_eq!(k.code, KeyCodeValue::F(24));
-
- // F0 and F25+ are invalid
- assert!(SingleKey::parse("f0").is_err());
- assert!(SingleKey::parse("f25").is_err());
- }
-
- #[test]
- fn from_event_function_keys() {
- let event = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::F(1));
-
- let event = KeyEvent::new(KeyCode::F(12), KeyModifiers::CONTROL);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::F(12));
- assert!(k.ctrl);
- }
-
- #[test]
- fn display_function_keys() {
- let k = SingleKey::parse("f1").unwrap();
- assert_eq!(k.to_string(), "f1");
-
- let k = SingleKey::parse("ctrl-f12").unwrap();
- assert_eq!(k.to_string(), "ctrl-f12");
- }
-
- #[test]
- fn function_key_round_trip() {
- let cases = ["f1", "f12", "ctrl-f5", "alt-f10"];
- for s in cases {
- let k = KeyInput::parse(s).unwrap();
- let display = k.to_string();
- let k2 = KeyInput::parse(&display).unwrap();
- assert_eq!(k, k2, "round-trip failed for {s}");
- }
- }
-
- #[test]
- fn from_event_function_key_matches_parsed() {
- let event = KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE);
- let from_event = SingleKey::from_event(&event).unwrap();
- let parsed = SingleKey::parse("f12").unwrap();
- assert_eq!(from_event, parsed);
- }
-
- #[test]
- fn from_event_backtab_becomes_shift_tab() {
- // Many terminals send BackTab for Shift+Tab
- let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::Tab);
- assert!(k.shift);
- assert!(!k.ctrl && !k.alt);
- }
-
- #[test]
- fn from_event_backtab_matches_parsed_shift_tab() {
- let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);
- let from_event = SingleKey::from_event(&event).unwrap();
- let parsed = SingleKey::parse("shift-tab").unwrap();
- assert_eq!(from_event, parsed);
- }
-
- #[test]
- fn from_event_backtab_with_ctrl() {
- // BackTab with ctrl modifier
- let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::CONTROL);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::Tab);
- assert!(k.shift);
- assert!(k.ctrl);
- }
-
- #[test]
- fn parse_insert_key() {
- let k = SingleKey::parse("insert").unwrap();
- assert_eq!(k.code, KeyCodeValue::Insert);
- assert!(!k.ctrl && !k.alt && !k.shift);
-
- let k = SingleKey::parse("ins").unwrap();
- assert_eq!(k.code, KeyCodeValue::Insert);
-
- let k = SingleKey::parse("ctrl-insert").unwrap();
- assert_eq!(k.code, KeyCodeValue::Insert);
- assert!(k.ctrl);
- }
-
- #[test]
- fn from_event_insert_key() {
- let event = KeyEvent::new(KeyCode::Insert, KeyModifiers::NONE);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::Insert);
- }
-
- #[test]
- fn insert_key_round_trip() {
- let k = KeyInput::parse("insert").unwrap();
- let display = k.to_string();
- assert_eq!(display, "insert");
- let k2 = KeyInput::parse(&display).unwrap();
- assert_eq!(k, k2);
- }
-}
diff --git a/crates/client/src/command/client/search/keybindings/keymap.rs b/crates/client/src/command/client/search/keybindings/keymap.rs
deleted file mode 100644
index 067d9403..00000000
--- a/crates/client/src/command/client/search/keybindings/keymap.rs
+++ /dev/null
@@ -1,233 +0,0 @@
-use std::collections::HashMap;
-
-use super::actions::Action;
-use super::conditions::{ConditionExpr, EvalContext};
-use super::key::{KeyInput, SingleKey};
-
-/// A single rule within a keybinding: an optional condition and an action.
-/// If the condition is `None`, the rule always matches.
-#[derive(Debug, Clone)]
-pub(crate) struct KeyRule {
- pub(crate) condition: Option<ConditionExpr>,
- pub(crate) action: Action,
-}
-
-/// A keybinding is an ordered list of rules. The first rule whose condition
-/// matches (or has no condition) wins.
-#[derive(Debug, Clone)]
-pub(crate) struct KeyBinding {
- pub(crate) rules: Vec<KeyRule>,
-}
-
-/// A keymap is a collection of keybindings indexed by key input.
-#[derive(Debug, Clone)]
-pub(crate) struct Keymap {
- pub(crate) bindings: HashMap<KeyInput, KeyBinding>,
-}
-
-impl KeyRule {
- /// Create an unconditional rule.
- pub(crate) fn always(action: Action) -> Self {
- Self {
- condition: None,
- action,
- }
- }
-
- /// Create a conditional rule. Accepts any type convertible to `ConditionExpr`,
- /// including bare `ConditionAtom` values.
- pub(crate) fn when(condition: impl Into<ConditionExpr>, action: Action) -> Self {
- Self {
- condition: Some(condition.into()),
- action,
- }
- }
-}
-
-impl KeyBinding {
- /// Create a simple (unconditional) binding.
- pub(crate) fn simple(action: Action) -> Self {
- Self {
- rules: vec![KeyRule::always(action)],
- }
- }
-
- /// Create a conditional binding from a list of rules.
- pub(crate) fn conditional(rules: Vec<KeyRule>) -> Self {
- Self { rules }
- }
-}
-
-impl Keymap {
- /// Create an empty keymap.
- pub(crate) fn new() -> Self {
- Self {
- bindings: HashMap::new(),
- }
- }
-
- /// Bind a key input to a simple (unconditional) action.
- pub(crate) fn bind(&mut self, key: KeyInput, action: Action) {
- self.bindings.insert(key, KeyBinding::simple(action));
- }
-
- /// Bind a key input to a conditional set of rules.
- pub(crate) fn bind_conditional(&mut self, key: KeyInput, rules: Vec<KeyRule>) {
- self.bindings.insert(key, KeyBinding::conditional(rules));
- }
-
- /// Resolve a key input to an action given the current evaluation context.
- /// Returns `None` if the key has no binding or no rule's condition matches.
- pub(crate) fn resolve(&self, key: &KeyInput, ctx: &EvalContext) -> Option<Action> {
- let binding = self.bindings.get(key)?;
- for rule in &binding.rules {
- match &rule.condition {
- None => return Some(rule.action.clone()),
- Some(cond) if cond.evaluate(ctx) => return Some(rule.action.clone()),
- Some(_) => {}
- }
- }
- None
- }
-
- /// Check if any binding starts with the given single key as the first key
- /// of a multi-key sequence. Used to detect pending multi-key sequences.
- pub(crate) fn has_sequence_starting_with(&self, prefix: &SingleKey) -> bool {
- self.bindings.keys().any(|ki| match ki {
- KeyInput::Sequence(keys) => keys.first() == Some(prefix),
- KeyInput::Single(_) => false,
- })
- }
-
- /// Merge another keymap into this one. Keys from `other` override keys in `self`.
- #[expect(dead_code)]
- pub(crate) fn merge(&mut self, other: &Self) {
- for (key, binding) in &other.bindings {
- self.bindings.insert(key.clone(), binding.clone());
- }
- }
-}
-
-impl Default for Keymap {
- fn default() -> Self {
- Self::new()
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::super::conditions::ConditionAtom;
- use super::{Action, EvalContext, KeyInput, KeyRule, Keymap, SingleKey};
-
- fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext {
- EvalContext {
- cursor_position: cursor,
- input_width: width,
- input_byte_len: width,
- selected_index: selected,
- results_len: len,
- original_input_empty: false,
- has_context: false,
- }
- }
-
- #[test]
- fn simple_binding_resolves() {
- let mut keymap = Keymap::new();
- let key = KeyInput::parse("ctrl-c").unwrap();
- keymap.bind(key.clone(), Action::ReturnOriginal);
-
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(keymap.resolve(&key, &ctx), Some(Action::ReturnOriginal));
- }
-
- #[test]
- fn conditional_first_match_wins() {
- let mut keymap = Keymap::new();
- let key = KeyInput::parse("left").unwrap();
- keymap.bind_conditional(
- key.clone(),
- vec![
- KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit),
- KeyRule::always(Action::CursorLeft),
- ],
- );
-
- // Cursor at start → Exit
- let ctx = make_ctx(0, 5, 0, 10);
- assert_eq!(keymap.resolve(&key, &ctx), Some(Action::Exit));
-
- // Cursor not at start → CursorLeft
- let ctx = make_ctx(3, 5, 0, 10);
- assert_eq!(keymap.resolve(&key, &ctx), Some(Action::CursorLeft));
- }
-
- #[test]
- fn no_match_returns_none() {
- let keymap = Keymap::new();
- let key = KeyInput::parse("ctrl-c").unwrap();
- let ctx = make_ctx(0, 0, 0, 0);
- assert_eq!(keymap.resolve(&key, &ctx), None);
- }
-
- #[test]
- fn conditional_no_condition_matches_returns_none() {
- let mut keymap = Keymap::new();
- let key = KeyInput::parse("left").unwrap();
- // Only one rule with a condition that won't match
- keymap.bind_conditional(
- key.clone(),
- vec![KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit)],
- );
-
- // Cursor not at start → no match
- let ctx = make_ctx(3, 5, 0, 10);
- assert_eq!(keymap.resolve(&key, &ctx), None);
- }
-
- #[test]
- fn has_sequence_starting_with() {
- let mut keymap = Keymap::new();
- let seq = KeyInput::parse("g g").unwrap();
- keymap.bind(seq, Action::ScrollToTop);
-
- let g = SingleKey::parse("g").unwrap();
- assert!(keymap.has_sequence_starting_with(&g));
-
- let h = SingleKey::parse("h").unwrap();
- assert!(!keymap.has_sequence_starting_with(&h));
- }
-
- #[test]
- fn merge_overrides() {
- let mut base = Keymap::new();
- let key = KeyInput::parse("ctrl-c").unwrap();
- base.bind(key.clone(), Action::ReturnOriginal);
-
- let mut overlay = Keymap::new();
- overlay.bind(key.clone(), Action::Exit);
-
- base.merge(&overlay);
-
- let ctx = make_ctx(0, 0, 0, 0);
- assert_eq!(base.resolve(&key, &ctx), Some(Action::Exit));
- }
-
- #[test]
- fn merge_preserves_unoverridden() {
- let mut base = Keymap::new();
- let key1 = KeyInput::parse("ctrl-c").unwrap();
- let key2 = KeyInput::parse("ctrl-d").unwrap();
- base.bind(key1.clone(), Action::ReturnOriginal);
- base.bind(key2.clone(), Action::DeleteCharAfter);
-
- let mut overlay = Keymap::new();
- overlay.bind(key1.clone(), Action::Exit);
-
- base.merge(&overlay);
-
- let ctx = make_ctx(0, 0, 0, 0);
- assert_eq!(base.resolve(&key1, &ctx), Some(Action::Exit));
- assert_eq!(base.resolve(&key2, &ctx), Some(Action::DeleteCharAfter));
- }
-}
diff --git a/crates/client/src/command/client/search/keybindings/mod.rs b/crates/client/src/command/client/search/keybindings/mod.rs
deleted file mode 100644
index cdca0406..00000000
--- a/crates/client/src/command/client/search/keybindings/mod.rs
+++ /dev/null
@@ -1,14 +0,0 @@
-pub(crate) mod actions;
-pub(crate) mod conditions;
-pub(crate) mod defaults;
-pub(crate) mod key;
-pub(crate) mod keymap;
-
-pub(crate) use actions::Action;
-#[expect(unused_imports)]
-pub(crate) use conditions::{ConditionAtom, ConditionExpr, EvalContext};
-pub(crate) use defaults::KeymapSet;
-#[expect(unused_imports)]
-pub(crate) use key::{KeyCodeValue, KeyInput, SingleKey};
-#[expect(unused_imports)]
-pub(crate) use keymap::{KeyBinding, KeyRule, Keymap};
diff --git a/crates/client/src/command/client/stats.rs b/crates/client/src/command/client/stats.rs
index 9ea5e283..9b8ebdff 100644
--- a/crates/client/src/command/client/stats.rs
+++ b/crates/client/src/command/client/stats.rs
@@ -3,8 +3,7 @@ use eyre::Result;
use interim::parse_date_string;
use time::{Duration, OffsetDateTime, Time};
-use crate::atuin_client::database::ClientSqlite;
-use crate::atuin_client::{database::current_context, settings::Settings};
+use crate::atuin_client::settings::Settings;
use crate::atuin_history::stats::{compute, pretty_print};
@@ -36,7 +35,7 @@ pub(crate) struct Cmd {
}
impl Cmd {
- pub(crate) async fn run(&self, db: &ClientSqlite, settings: &Settings) -> Result<()> {
+ pub(crate) async fn run(&self, settings: &Settings) -> Result<()> {
let context = current_context().await?;
let words = if self.period.is_empty() {
String::from("all")
diff --git a/crates/client/src/shell/.gitattributes b/crates/client/src/shell/.gitattributes
deleted file mode 100644
index fae8897c..00000000
--- a/crates/client/src/shell/.gitattributes
+++ /dev/null
@@ -1 +0,0 @@
-* eol=lf
diff --git a/crates/client/src/shell/atuin.bash b/crates/client/src/shell/atuin.bash
deleted file mode 100644
index 703e8fe2..00000000
--- a/crates/client/src/shell/atuin.bash
+++ /dev/null
@@ -1,672 +0,0 @@
-# Include guard
-if [[ ${__atuin_initialized-} == true ]]; then
- false
-elif [[ $- != *i* ]]; then
- # Enable only in interactive shells
- false
-elif ((BASH_VERSINFO[0] < 3 || BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 1)); then
- # Require bash >= 3.1
- [[ -t 2 ]] && printf 'atuin: requires bash >= 3.1 for the integration.\n' >&2
- false
-else # (include guard) beginning of main content
- #------------------------------------------------------------------------------
- __atuin_initialized=true
-
- if [[ -z "${ATUIN_SESSION:-}" || "${ATUIN_SHLVL:-}" != "$SHLVL" ]]; then
- ATUIN_SESSION=$(atuin uuid)
- export ATUIN_SESSION
- export ATUIN_SHLVL=$SHLVL
- fi
- ATUIN_STTY=$(stty -g)
- ATUIN_HISTORY_ID=""
-
- __atuin_osc133_command_executed() {
- [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || return
- [[ -n "${ATUIN_HISTORY_ID:-}" && "$ATUIN_HISTORY_ID" != "__bash_preexec_failure__" ]] || return
-
- printf '\033]133;C\a'
- }
-
- __atuin_osc133_command_finished() {
- [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || return
- [[ -n "${ATUIN_HISTORY_ID:-}" && "$ATUIN_HISTORY_ID" != "__bash_preexec_failure__" ]] || return
-
- printf '\033]133;D;%s;history_id=%s;session_id=%s\a' "$1" "$ATUIN_HISTORY_ID" "${ATUIN_SESSION:-}"
- }
-
- __atuin_osc133_prompt_start=$'\001\033]133;A;cl=line\a\002'
- __atuin_osc133_prompt_end=$'\001\033]133;B\a\002'
-
- __atuin_osc133_wrap_prompt() {
- local __atuin_prompt="${PS1-}"
- __atuin_prompt="${__atuin_prompt//$__atuin_osc133_prompt_start/}"
- __atuin_prompt="${__atuin_prompt//$__atuin_osc133_prompt_end/}"
-
- if [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]]; then
- PS1="${__atuin_osc133_prompt_start}${__atuin_prompt}${__atuin_osc133_prompt_end}"
- else
- PS1="$__atuin_prompt"
- fi
- }
-
- export ATUIN_PREEXEC_BACKEND=$SHLVL:none
- __atuin_update_preexec_backend() {
- if [[ ${BLE_ATTACHED-} ]]; then
- ATUIN_PREEXEC_BACKEND=$SHLVL:blesh-${BLE_VERSION-}
- elif [[ ${bash_preexec_imported-} ]]; then
- ATUIN_PREEXEC_BACKEND=$SHLVL:bash-preexec
- elif [[ ${__bp_imported-} ]]; then
- ATUIN_PREEXEC_BACKEND="$SHLVL:bash-preexec (old)"
- else
- ATUIN_PREEXEC_BACKEND=$SHLVL:unknown
- fi
- }
-
- __atuin_preexec() {
- # Workaround for old versions of bash-preexec
- if [[ ! ${BLE_ATTACHED-} ]]; then
- # In older versions of bash-preexec, the preexec hook may be called
- # even for the commands run by keybindings. There is no general and
- # robust way to detect the command for keybindings, but at least we
- # want to exclude Atuin's keybindings. When the preexec hook is called
- # for a keybinding, the preexec hook for the user command will not
- # fire, so we instead set a fake ATUIN_HISTORY_ID here to notify
- # __atuin_precmd of this failure.
- if [[ $BASH_COMMAND != "$1" ]]; then
- case $BASH_COMMAND in
- '__atuin_history'* | '__atuin_widget_run'* | '__atuin_bash42_dispatch'*)
- ATUIN_HISTORY_ID=__bash_preexec_failure__
- return 0
- ;;
- esac
- fi
- fi
-
- # Note: We update ATUIN_PREEXEC_BACKEND on every preexec because blesh's
- # attaching state can dynamically change.
- __atuin_update_preexec_backend
-
- local id
- id=$(atuin history start -- "$1" 2>/dev/null)
- export ATUIN_HISTORY_ID=$id
- [[ -n ${__atuin_skip_osc133:-} ]] || __atuin_osc133_command_executed
- __atuin_preexec_time=${EPOCHREALTIME-}
- }
-
- __atuin_precmd() {
- local EXIT=$? __atuin_precmd_time=${EPOCHREALTIME-}
-
- __atuin_osc133_wrap_prompt
-
- [[ ! $ATUIN_HISTORY_ID ]] && return
-
- # If the previous preexec hook failed, we manually call __atuin_preexec
- local __atuin_skip_osc133=""
- if [[ $ATUIN_HISTORY_ID == __bash_preexec_failure__ ]]; then
- # This is the command extraction code taken from bash-preexec
- local previous_command
- previous_command=$(
- export LC_ALL=C HISTTIMEFORMAT=''
- builtin history 1 | sed '1 s/^ *[0-9][0-9]*[* ] //'
- )
- __atuin_skip_osc133=1
- __atuin_preexec "$previous_command"
- fi
-
- local duration=""
- # shellcheck disable=SC2154,SC2309
- if [[ ${BLE_ATTACHED-} && ${_ble_exec_time_ata-} ]]; then
- # With ble.sh, we utilize the shell variable `_ble_exec_time_ata`
- # recorded by ble.sh. It is more accurate than the measurements by
- # Atuin, which includes the spawn cost of Atuin. ble.sh uses the
- # special shell variable `EPOCHREALTIME` in bash >= 5.0 with the
- # microsecond resolution, or the builtin `time` in bash < 5.0 with the
- # millisecond resolution.
- duration=${_ble_exec_time_ata}000
- elif ((BASH_VERSINFO[0] >= 5)); then
- # We calculate the high-resolution duration based on EPOCHREALTIME
- # (bash >= 5.0) recorded by precmd/preexec, though it might not be as
- # accurate as `_ble_exec_time_ata` provided by ble.sh because it
- # includes the extra time of the precmd/preexec handling. Since Bash
- # does not offer floating-point arithmetic, we remove the non-digit
- # characters and perform the integral arithmetic. The fraction part of
- # EPOCHREALTIME is fixed to have 6 digits in Bash. We remove all the
- # non-digit characters because the decimal point is not necessarily a
- # period depending on the locale.
- duration=$((${__atuin_precmd_time//[!0-9]/} - ${__atuin_preexec_time//[!0-9]/}))
- if ((duration >= 0)); then
- duration=${duration}000
- else
- duration="" # clear the result on overflow
- fi
- fi
-
- [[ -n ${__atuin_skip_osc133:-} ]] || __atuin_osc133_command_finished "$EXIT"
- (ATUIN_LOG=error atuin history end --exit "$EXIT" ${duration:+"--duration=$duration"} -- "$ATUIN_HISTORY_ID" &) >/dev/null 2>&1
- export ATUIN_HISTORY_ID=""
- }
-
- __atuin_set_ret_value() {
- return ${1:+"$1"}
- }
-
- #------------------------------------------------------------------------------
- # section: __atuin_accept_line
- #
- # The function "__atuin_accept_line" is kept for backward compatibility of the
- # direct use of __atuin_history in keybindings by users.
-
- # The shell function `__atuin_evaluate_prompt` evaluates prompt sequences in
- # $PS1. We switch the implementation of the shell function
- # `__atuin_evaluate_prompt` based on the Bash version because the expansion
- # ${PS1@P} is only available in bash >= 4.4.
- if ((BASH_VERSINFO[0] >= 5 || BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4)); then
- __atuin_evaluate_prompt() {
- __atuin_set_ret_value "${__bp_last_ret_value-}" "${__bp_last_argument_prev_command-}"
- __atuin_prompt=${PS1@P}
-
- # Note: Strip the control characters ^A (\001) and ^B (\002), which
- # Bash internally uses to enclose the escape sequences. They are
- # produced by '\[' and '\]', respectively, in $PS1 and used to tell
- # Bash that the strings inbetween do not contribute to the prompt
- # width. After the prompt width calculation, Bash strips those control
- # characters before outputting it to the terminal. We here strip these
- # characters following Bash's behavior.
- __atuin_prompt=${__atuin_prompt//[$'\001\002']/}
-
- # Count the number of newlines contained in $__atuin_prompt
- __atuin_prompt_offset=${__atuin_prompt//[!$'\n']/}
- __atuin_prompt_offset=${#__atuin_prompt_offset}
- }
- else
- __atuin_evaluate_prompt() {
- __atuin_prompt='$ '
- __atuin_prompt_offset=0
- }
- fi
-
- # The shell function `__atuin_clear_prompt N` outputs terminal control
- # sequences to clear the contents of the current and N previous lines. After
- # clearing, the cursor is placed at the beginning of the N-th previous line.
- __atuin_clear_prompt_cache=()
- __atuin_clear_prompt() {
- local offset=$1
- if [[ ! ${__atuin_clear_prompt_cache[offset]+set} ]]; then
- if [[ ! ${__atuin_clear_prompt_cache[0]+set} ]]; then
- __atuin_clear_prompt_cache[0]=$'\r'$(tput el 2>/dev/null || tput ce 2>/dev/null)
- fi
- if ((offset > 0)); then
- __atuin_clear_prompt_cache[offset]=${__atuin_clear_prompt_cache[0]}$(
- tput cuu "$offset" 2>/dev/null || tput UP "$offset" 2>/dev/null
- tput dl "$offset" 2>/dev/null || tput DL "$offset" 2>/dev/null
- tput il "$offset" 2>/dev/null || tput AL "$offset" 2>/dev/null
- )
- fi
- fi
- printf '%s' "${__atuin_clear_prompt_cache[offset]}"
- }
-
- __atuin_accept_line() {
- local __atuin_command=$1
-
- # Reprint the prompt, accounting for multiple lines
- local __atuin_prompt __atuin_prompt_offset
- __atuin_evaluate_prompt
- __atuin_clear_prompt "$__atuin_prompt_offset"
- printf '%s\n' "$__atuin_prompt$__atuin_command"
-
- # Add it to the bash history
- history -s "$__atuin_command"
-
- # Assuming bash-preexec
- # Invoke every function in the preexec array
- local __atuin_preexec_function
- local __atuin_preexec_function_ret_value
- local __atuin_preexec_ret_value=0
- for __atuin_preexec_function in "${preexec_functions[@]:-}"; do
- if type -t "$__atuin_preexec_function" 1>/dev/null; then
- __atuin_set_ret_value "${__bp_last_ret_value:-}"
- "$__atuin_preexec_function" "$__atuin_command"
- __atuin_preexec_function_ret_value=$?
- if [[ $__atuin_preexec_function_ret_value != 0 ]]; then
- __atuin_preexec_ret_value=$__atuin_preexec_function_ret_value
- fi
- fi
- done
-
- # If extdebug is turned on and any preexec function returns non-zero
- # exit status, we do not run the user command.
- if ! { shopt -q extdebug && ((__atuin_preexec_ret_value)); }; then
- # Note: When a child Bash session is started by enter_accept, if the
- # environment variable READLINE_POINT is present, bash-preexec in the
- # child session does not fire preexec at all because it considers we
- # are inside Atuin's keybinding of the current session. To avoid
- # propagating the environment variable to the child session, we remove
- # the export attribute of READLINE_LINE and READLINE_POINT.
- export -n READLINE_LINE READLINE_POINT
-
- # Juggle the terminal settings so that the command can be interacted
- # with
- local __atuin_stty_backup
- __atuin_stty_backup=$(stty -g)
- stty "$ATUIN_STTY"
-
- # Execute the command. Note: We need to record $? and $_ after the
- # user command within the same call of "eval" because $_ is otherwise
- # overwritten by the last argument of "eval".
- __atuin_set_ret_value "${__bp_last_ret_value-}" "${__bp_last_argument_prev_command-}"
- eval -- "$__atuin_command"$'\n__bp_last_ret_value=$? __bp_last_argument_prev_command=$_'
-
- stty "$__atuin_stty_backup"
- fi
-
- # Execute preprompt commands
- local __atuin_prompt_command
- for __atuin_prompt_command in "${PROMPT_COMMAND[@]}"; do
- __atuin_set_ret_value "${__bp_last_ret_value-}" "${__bp_last_argument_prev_command-}"
- eval -- "$__atuin_prompt_command"
- done
- # Bash will redraw only the line with the prompt after we finish,
- # so to work for a multiline prompt we need to print it ourselves,
- # then go to the beginning of the last line.
- __atuin_evaluate_prompt
- printf '%s' "$__atuin_prompt"
- __atuin_clear_prompt 0
- }
-
- #------------------------------------------------------------------------------
-
- __atuin_search_cmd() {
- local -a search_args=("$@")
-
- ATUIN_SHELL=bash ATUIN_LOG=error ATUIN_QUERY=$READLINE_LINE atuin search "${search_args[@]}" -i 3>&1 1>&2 2>&3 3>&-
- }
-
- __atuin_history() {
- # Default action of the up key: When this function is called with the first
- # argument `--shell-up-key-binding`, we perform Atuin's history search only
- # when the up key is supposed to cause the history movement in the original
- # binding. We do this only for ble.sh because the up key always invokes
- # the history movement in the plain Bash.
- if [[ ${BLE_ATTACHED-} && ${1-} == --shell-up-key-binding ]]; then
- # When the current cursor position is not in the first line, the up key
- # should move the cursor to the previous line. While the selection is
- # performed, the up key should not start the history search.
- # shellcheck disable=SC2154 # Note: these variables are set by ble.sh
- if [[ ${_ble_edit_str::_ble_edit_ind} == *$'\n'* || $_ble_edit_mark_active ]]; then
- ble/widget/@nomarked backward-line
- local status=$?
- READLINE_LINE=$_ble_edit_str
- READLINE_POINT=$_ble_edit_ind
- READLINE_MARK=$_ble_edit_mark
- return "$status"
- fi
- fi
-
- # READLINE_LINE and READLINE_POINT are only supported by bash >= 4.0 or
- # ble.sh. When it is not supported, we clear them to suppress strange
- # behaviors.
- [[ ${BLE_ATTACHED-} ]] || ((BASH_VERSINFO[0] >= 4)) ||
- READLINE_LINE="" READLINE_POINT=0
-
- local __atuin_output
- if ! __atuin_output=$(__atuin_search_cmd "$@"); then
- [[ $__atuin_output ]] && printf '%s\n' "$__atuin_output" >&2
- return 1
- fi
-
- # We do nothing when the search is canceled.
- [[ $__atuin_output ]] || return 0
-
- if [[ $__atuin_output == __atuin_accept__:* ]]; then
- __atuin_output=${__atuin_output#__atuin_accept__:}
-
- if [[ ${BLE_ATTACHED-} ]]; then
- ble-edit/content/reset-and-check-dirty "$__atuin_output"
- ble/widget/accept-line
- READLINE_LINE=""
- elif [[ ${__atuin_macro_chain_keymap-} ]]; then
- READLINE_LINE=$__atuin_output
- bind -m "$__atuin_macro_chain_keymap" '"'"$__atuin_macro_chain"'": '"$__atuin_macro_accept_line"
- else
- __atuin_accept_line "$__atuin_output"
- READLINE_LINE=""
- fi
-
- READLINE_POINT=${#READLINE_LINE}
- else
- READLINE_LINE=$__atuin_output
- READLINE_POINT=${#READLINE_LINE}
- if [[ ! ${BLE_ATTACHED-} ]] && ((BASH_VERSINFO[0] < 4)) && [[ ${__atuin_macro_chain_keymap-} ]]; then
- bind -m "$__atuin_macro_chain_keymap" '"'"$__atuin_macro_chain"'": '"$__atuin_macro_insert_line"
- fi
- fi
- }
-
- __atuin_initialize_blesh() {
- # shellcheck disable=SC2154
- [[ ${BLE_VERSION-} ]] && ((_ble_version >= 400)) || return 0
-
- ble-import contrib/integration/bash-preexec
-
- # Define and register an autosuggestion source for ble.sh's auto-complete.
- # If you'd like to overwrite this, define the same name of shell function
- # after the $(atuin init bash) line in your .bashrc. If you do not need
- # the auto-complete source by Atuin, please add the following code to
- # remove the entry after the $(atuin init bash) line in your .bashrc:
- #
- # ble/util/import/eval-after-load core-complete '
- # ble/array#remove _ble_complete_auto_source atuin-history'
- #
- function ble/complete/auto-complete/source:atuin-history {
- local suggestion
- suggestion=$(ATUIN_QUERY="$_ble_edit_str" atuin search --cmd-only --limit 1 --search-mode prefix 2>/dev/null)
- [[ $suggestion == "$_ble_edit_str"?* ]] || return 1
- ble/complete/auto-complete/enter h 0 "${suggestion:${#_ble_edit_str}}" '' "$suggestion"
- }
- ble/util/import/eval-after-load core-complete '
- ble/array#unshift _ble_complete_auto_source atuin-history'
-
- # @env BLE_SESSION_ID: `atuin doctor` references the environment variable
- # BLE_SESSION_ID. We explicitly export the variable because it was not
- # exported in older versions of ble.sh.
- [[ ${BLE_SESSION_ID-} ]] && export BLE_SESSION_ID
- }
- __atuin_initialize_blesh
- BLE_ONLOAD+=(__atuin_initialize_blesh)
- precmd_functions+=(__atuin_precmd)
- preexec_functions+=(__atuin_preexec)
-
- #------------------------------------------------------------------------------
- # section: atuin-bind
-
- __atuin_widget=()
-
- __atuin_widget_save() {
- local data=$1
- for REPLY in "${!__atuin_widget[@]}"; do
- if [[ ${__atuin_widget[REPLY]} == "$data" ]]; then
- return 0
- fi
- done
- # shellcheck disable=SC2154
- REPLY=${#__atuin_widget[*]}
- __atuin_widget[REPLY]=$data
- }
-
- __atuin_widget_run() {
- local data=${__atuin_widget[$1]}
- local keymap=${data%%:*} widget=${data#*:}
- local __atuin_macro_chain_keymap=$keymap
- bind -m "$keymap" '"'"$__atuin_macro_chain"'": ""'
- builtin eval -- "$widget"
- }
-
- # To realize the enter_accept feature in a robust way, we need to call the
- # readline bindable function `accept-line'. However, there is no way to call
- # `accept-line' from the shell script. To call the bindable function
- # `accept-line', we may utilize string macros of readline. When we bind KEYSEQ
- # to a WIDGET that wants to conditionally call `accept-line' at the end, we
- # perform two-step dispatching:
- #
- # 1. [KEYSEQ -> IKEYSEQ1 IKEYSEQ2]---We first translate KEYSEQ to two
- # intermediate key sequences IKEYSEQ1 and IKEYSEQ2 using string macros. For
- # example, when we bind `__atuin_history` to \C-r, this step can be set up by
- # `bind '"\C-r": "IKEYSEQ1IKEYSEQ2"'`.
- #
- # 2. [IKEYSEQ1 -> WIDGET]---Then, IKEYSEQ1 is bound to the WIDGET, and the
- # binding of IKEYSEQ2 is dynamically determined by WIDGET. For example, when
- # we bind `__atuin_history` to \C-r, this step can be set up by `bind -x
- # '"IKEYSEQ1": WIDGET'`.
- #
- # 3. [IKEYSEQ2 -> accept-line] or [IKEYSEQ2 -> ""]---To request the execution
- # of `accept-line', WIDGET can change the binding of IKEYSEQ2 by running
- # `bind '"IKEYSEQ2": accept-line''. Otherwise, WIDGET can change the binding
- # of IKEYSEQ2 to no-op by running `bind '"IKEYSEQ2": ""'`.
- #
- # For the choice of the intermediate key sequences, we want to choose key
- # sequences that are unlikely to conflict with others. In addition, we want to
- # avoid a key sequence containing \e because keymap "vi-insert" stops
- # processing key sequences containing \e in older versions of Bash. We have
- # used \e[0;<m>A (a variant of the [up] key with modifier <m>) in Atuin 3.10.0
- # for intermediate key sequences, but this contains \e and caused a problem.
- # Instead, we use \C-x\C-_A<n>\a, which starts with \C-x\C-_ (an unlikely
- # two-byte combination) and A (represents the initial letter of Atuin),
- # followed by the payload <n> and the terminator \a (BEL, \C-g).
-
- __atuin_macro_chain='\C-x\C-_A0\a'
- for __atuin_keymap in emacs vi-insert vi-command; do
- bind -m "$__atuin_keymap" "\"$__atuin_macro_chain\": \"\""
- done
- unset -v __atuin_keymap
-
- if ((BASH_VERSINFO[0] >= 5 || BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 3)); then
- # In Bash >= 4.3
-
- __atuin_macro_accept_line=accept-line
-
- __atuin_bind_impl() {
- local keymap=$1 keyseq=$2 command=$3
-
- # Note: In Bash <= 5.0, the table for `bind -x` from the keyseq to the
- # command is shared by all the keymaps (emacs, vi-insert, and
- # vi-command), so one cannot safely bind different command strings to
- # the same keyseq in different keymaps. Therefore, the command string
- # and the keyseq need to be globally in one-to-one correspondence in
- # all the keymaps.
- local REPLY
- __atuin_widget_save "$keymap:$command"
- local widget=$REPLY
- local ikeyseq1='\C-x\C-_A'$((1 + widget))'\a'
- local ikeyseq2=$__atuin_macro_chain
-
- if ((BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] == 1)); then
- # Workaround for Bash 5.1: Bash 5.1 has a bug that overwriting an
- # existing "bind -x" keybinding breaks other existing "bind -x"
- # keybindings [1,2]. To work around the problem, we explicitly
- # unbind an existing keybinding before overwriting it.
- #
- # [1] https://lists.gnu.org/archive/html/bug-bash/2021-04/msg00135.html
- # [2] https://github.com/atuinsh/atuin/issues/962#issuecomment-3451132291
- bind -m "$keymap" -r "$keyseq"
- fi
-
- bind -m "$keymap" "\"$keyseq\": \"$ikeyseq1$ikeyseq2\""
- bind -m "$keymap" -x "\"$ikeyseq1\": __atuin_widget_run $widget"
- }
-
- __atuin_bind_blesh_onload() {
- # In ble.sh, we need to enable unrecognized CSI sequences like \e[0;0A,
- # which are discarded by ble.sh by default. Note: In Bash <= 4.2, we
- # do not need to unset "decode_error_cseq_discard" because \e[0;<m>A is
- # used only for the macro chaining (which is unused by ble.sh) in Bash
- # <= 4.2.
- bleopt decode_error_cseq_discard=
- }
- if [[ ${BLE_VERSION-} ]]; then
- __atuin_bind_blesh_onload
- fi
- BLE_ONLOAD+=(__atuin_bind_blesh_onload)
- else
- # In Bash <= 4.2, "bind -x" cannot bind a shell command to a keyseq having
- # more than two bytes, so we need to work with only two-byte sequences.
- #
- # However, the number of available combinations of two-byte sequences is
- # limited. To minimize the number of key sequences used by Atuin, instead
- # of specifying a widget by its own intermediate sequence, we specify a
- # widget by a fixed-length sequence of multiple two-byte sequences. More
- # specifically, instead of IKEYSEQ1, we use IKS1 IKS2 IKS3 [IKS4 IKS5]
- # IKSX, where IKS1..IKS5 just stores its information to a global variable,
- # and IKSX collects all the information and determine and call the actual
- # widget based on the stored information. Each of IKn (n=1..5) is one of
- # the two reserved sequences, $__atuin_bash42_code0 and
- # $__atuin_bash42_code1. IKSX is fixed to be $__atuin_bash42_code2.
- #
- # For the choices of the special key sequences, we consider \C-xQ, \C-xR,
- # and \C-xS. In the emacs editing mode of Bash, \C-x is used as a prefix
- # key, i.e., it is used for the beginning key of the keybindings with
- # multiple keys, so \C-x is unlikely to be used for a single-key binding by
- # the user. Also, \C-x is not used in the vi editing mode by default. The
- # combinations \C-xQ..\C-xS are also unlikely be used because we need to
- # switch the modifier keys from Control to Shift to input these sequences,
- # and these are not easy to input.
- __atuin_bash42_code0='\C-xQ'
- __atuin_bash42_code1='\C-xR'
- __atuin_bash42_code2='\C-xS'
-
- __atuin_bash42_encode() {
- REPLY=
- local n=$1 min_width=${2-}
- while
- if ((n % 2 == 0)); then
- REPLY=$__atuin_bash42_code0$REPLY
- else
- REPLY=$__atuin_bash42_code1$REPLY
- fi
- (((n /= 2) || ${#REPLY} / ${#__atuin_bash42_code0} < min_width))
- do :; done
- }
-
- __atuin_bash42_bind() {
- local __atuin_keymap
- for __atuin_keymap in emacs vi-insert vi-command; do
- bind -m "$__atuin_keymap" -x '"'"$__atuin_bash42_code0"'": __atuin_bash42_dispatch_selector+=0'
- bind -m "$__atuin_keymap" -x '"'"$__atuin_bash42_code1"'": __atuin_bash42_dispatch_selector+=1'
- bind -m "$__atuin_keymap" -x '"'"$__atuin_bash42_code2"'": __atuin_bash42_dispatch'
- done
- }
- __atuin_bash42_bind
- # In Bash <= 4.2, there is no way to read users' "bind -x" settings, so we
- # need to explicitly perform "bind -x" when ble.sh is loaded.
- BLE_ONLOAD+=(__atuin_bash42_bind)
-
- if ((BASH_VERSINFO[0] >= 4)); then
- __atuin_macro_accept_line=accept-line
- else
- # Note: We rewrite the command line and invoke `accept-line'. In
- # bash <= 3.2, there is no way to rewrite the command line from the
- # shell script, so we rewrite it using a macro and
- # `shell-expand-line'.
- #
- # Note: Concerning the key sequences to invoke bindable functions
- # such as "\C-x\C-_A1\a", another option is to use
- # "\exbegginning-of-line\r", etc. to make it consistent with bash
- # >= 5.3. However, an older Bash configuration can still conflict
- # on [M-x]. The conflict is more likely than \C-x\C-_A1\a.
- for __atuin_keymap in emacs vi-insert vi-command; do
- bind -m "$__atuin_keymap" '"\C-x\C-_A1\a": beginning-of-line'
- bind -m "$__atuin_keymap" '"\C-x\C-_A2\a": kill-line'
- # shellcheck disable=SC2016
- bind -m "$__atuin_keymap" '"\C-x\C-_A3\a": "$READLINE_LINE"'
- bind -m "$__atuin_keymap" '"\C-x\C-_A4\a": shell-expand-line'
- bind -m "$__atuin_keymap" '"\C-x\C-_A5\a": accept-line'
- bind -m "$__atuin_keymap" '"\C-x\C-_A6\a": end-of-line'
- done
- unset -v __atuin_keymap
-
- bind -m vi-command '"\C-x\C-_A7\a": vi-insertion-mode'
- bind -m vi-insert '"\C-x\C-_A7\a": vi-movement-mode'
-
- # "\C-x\C-_A10\a": Replace the command line with READLINE_LINE. When we are
- # in the vi-command keymap, we go to vi-insert, input
- # "$READLINE_LINE", and come back to vi-command.
- bind -m emacs '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A3\a\C-x\C-_A4\a"'
- bind -m vi-insert '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A3\a\C-x\C-_A4\a"'
- bind -m vi-command '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A7\a\C-x\C-_A3\a\C-x\C-_A7\a\C-x\C-_A4\a"'
-
- __atuin_macro_accept_line='"\C-x\C-_A10\a\C-x\C-_A5\a"'
- __atuin_macro_insert_line='"\C-x\C-_A10\a\C-x\C-_A6\a"'
- fi
-
- __atuin_bash42_dispatch_selector=
-
- __atuin_bash42_dispatch() {
- local s=$__atuin_bash42_dispatch_selector
- __atuin_bash42_dispatch_selector=
- __atuin_widget_run "$((2#0$s))"
- }
-
- __atuin_bind_impl() {
- local keymap=$1 keyseq=$2 command=$3
-
- __atuin_widget_save "$keymap:$command"
- __atuin_bash42_encode "$REPLY"
- local macro=$REPLY$__atuin_bash42_code2$__atuin_macro_chain
-
- bind -m "$keymap" "\"$keyseq\": \"$macro\""
- }
- fi
-
- atuin-bind() {
- local keymap=
- local OPTIND=1 OPTARG="" OPTERR=0 flag
- while getopts ':m:' flag "$@"; do
- case $flag in
- m) keymap=$OPTARG ;;
- *)
- printf '%s\n' "atuin-bind: unrecognized option '-$flag'" >&2
- return 2
- ;;
- esac
- done
- shift "$((OPTIND - 1))"
-
- if (($# != 2)); then
- printf '%s\n' 'usage: atuin-bind [-m keymap] keyseq widget' >&2
- return 2
- fi
-
- local keyseq=$1
- [[ $keymap ]] || keymap=$(bind -v | awk '$2 == "keymap" { print $3 }')
- case $keymap in
- emacs-meta) keymap=emacs keyseq='\e'$keyseq ;;
- emacs-ctlx) keymap=emacs keyseq='\C-x'$keyseq ;;
- emacs*) keymap=emacs ;;
- vi-insert) ;;
- vi*) keymap=vi-command ;;
- *)
- printf '%s\n' "atuin-bind: unknown keymap '$keymap'" >&2
- return 2
- ;;
- esac
-
- local command=$2 widget=${2%%[[:blank:]]*}
- case $widget in
- atuin-search) command=${2/#"$widget"/__atuin_history} ;;
- atuin-search-emacs) command=${2/#"$widget"/__atuin_history --keymap-mode=emacs} ;;
- atuin-search-viins) command=${2/#"$widget"/__atuin_history --keymap-mode=vim-insert} ;;
- atuin-search-vicmd) command=${2/#"$widget"/__atuin_history --keymap-mode=vim-normal} ;;
- atuin-up-search) command=${2/#"$widget"/__atuin_history --shell-up-key-binding} ;;
- atuin-up-search-emacs) command=${2/#"$widget"/__atuin_history --shell-up-key-binding --keymap-mode=emacs} ;;
- atuin-up-search-viins) command=${2/#"$widget"/__atuin_history --shell-up-key-binding --keymap-mode=vim-insert} ;;
- atuin-up-search-vicmd) command=${2/#"$widget"/__atuin_history --shell-up-key-binding --keymap-mode=vim-normal} ;;
- esac
-
- __atuin_bind_impl "$keymap" "$keyseq" "$command"
- }
-
- #------------------------------------------------------------------------------
-
- # shellcheck disable=SC2154
- if [[ $__atuin_bind_ctrl_r == true ]]; then
- # Note: We do not overwrite [C-r] in the vi-command keymap because we do
- # not want to overwrite "redo", which is already bound to [C-r] in the
- # vi_nmap keymap in ble.sh.
- atuin-bind -m emacs '\C-r' atuin-search-emacs
- atuin-bind -m vi-insert '\C-r' atuin-search-viins
- atuin-bind -m vi-command '/' atuin-search-emacs
- fi
-
- # shellcheck disable=SC2154
- if [[ $__atuin_bind_up_arrow == true ]]; then
- atuin-bind -m emacs '\e[A' atuin-up-search-emacs
- atuin-bind -m emacs '\eOA' atuin-up-search-emacs
- atuin-bind -m vi-insert '\e[A' atuin-up-search-viins
- atuin-bind -m vi-insert '\eOA' atuin-up-search-viins
- atuin-bind -m vi-command '\e[A' atuin-up-search-vicmd
- atuin-bind -m vi-command '\eOA' atuin-up-search-vicmd
- atuin-bind -m vi-command 'k' atuin-up-search-vicmd
- fi
-
-#------------------------------------------------------------------------------
-fi # (include guard) end of main content
diff --git a/crates/client/src/shell/atuin.fish b/crates/client/src/shell/atuin.fish
deleted file mode 100644
index 2b469383..00000000
--- a/crates/client/src/shell/atuin.fish
+++ /dev/null
@@ -1,102 +0,0 @@
-if not set -q ATUIN_SESSION; or test "$ATUIN_SHLVL" != "$SHLVL"
- set -gx ATUIN_SESSION (atuin uuid)
- set -gx ATUIN_SHLVL $SHLVL
-end
-set --erase ATUIN_HISTORY_ID
-
-function _atuin_osc133_command_executed
- set -q ATUIN_PTY_PROXY_ACTIVE; or return
- test -n "$ATUIN_HISTORY_ID"; or return
-
- printf '\033]133;C\a'
-end
-
-function _atuin_osc133_command_finished --argument-names exit_code
- set -q ATUIN_PTY_PROXY_ACTIVE; or return
- test -n "$ATUIN_HISTORY_ID"; or return
-
- printf '\033]133;D;%s;history_id=%s;session_id=%s\a' "$exit_code" "$ATUIN_HISTORY_ID" "$ATUIN_SESSION"
-end
-
-function _atuin_preexec --on-event fish_preexec
- if not test -n "$fish_private_mode"
- set -g ATUIN_HISTORY_ID (atuin history start -- "$argv[1]" 2>/dev/null)
- _atuin_osc133_command_executed
- end
-end
-
-function _atuin_postexec --on-event fish_postexec
- set -l s $status
-
- if test -n "$ATUIN_HISTORY_ID"
- _atuin_osc133_command_finished $s
- ATUIN_LOG=error atuin history end --exit $s -- $ATUIN_HISTORY_ID &>/dev/null &
- disown
- end
-
- set --erase ATUIN_HISTORY_ID
-end
-
-function _atuin_search
- set -l keymap_mode
- switch $fish_key_bindings
- case fish_vi_key_bindings fish_hybrid_key_bindings
- switch $fish_bind_mode
- case default
- set keymap_mode vim-normal
- case insert
- set keymap_mode vim-insert
- end
- case '*'
- set keymap_mode emacs
- end
-
- set -l ATUIN_H
- set -l ATUIN_STATUS 0
-
- # In fish 3.4 and above we can use `"$(some command)"` to keep multiple lines separate;
- # but to support fish 3.3 we need to use `(some command | string collect)`.
- # https://fishshell.com/docs/current/relnotes.html#id24 (fish 3.4 "Notable improvements and fixes")
- set ATUIN_H (ATUIN_SHELL=fish ATUIN_LOG=error ATUIN_QUERY=(commandline -b) atuin search --keymap-mode=$keymap_mode $argv -i 3>&1 1>&2 2>&3 3>&- | string collect)
- set ATUIN_STATUS $pipestatus[1]
-
- if test "$ATUIN_STATUS" -ne 0
- test -n "$ATUIN_H"; and printf '%s\n' "$ATUIN_H" >&2
- commandline -f repaint
- return "$ATUIN_STATUS"
- end
-
- set ATUIN_H (string trim -- $ATUIN_H | string collect) # trim whitespace
-
- if test -n "$ATUIN_H"
- if string match --quiet '__atuin_accept__:*' "$ATUIN_H"
- set -l ATUIN_HIST (string replace "__atuin_accept__:" "" -- "$ATUIN_H" | string collect)
- commandline -r "$ATUIN_HIST"
- commandline -f repaint
- commandline -f execute
- return
- else
- commandline -r "$ATUIN_H"
- end
- end
-
- commandline -f repaint
-end
-
-function _atuin_bind_up
- # Fallback to fish's builtin up-or-search if we're in search or paging mode
- if commandline --search-mode; or commandline --paging-mode
- up-or-search
- return
- end
-
- # Only invoke atuin if we're on the top line of the command
- set -l lineno (commandline --line)
-
- switch $lineno
- case 1
- _atuin_search --shell-up-key-binding
- case '*'
- up-or-search
- end
-end
diff --git a/crates/client/src/shell/atuin.nu b/crates/client/src/shell/atuin.nu
deleted file mode 100644
index d37457e4..00000000
--- a/crates/client/src/shell/atuin.nu
+++ /dev/null
@@ -1,121 +0,0 @@
-# Source this in your ~/.config/nushell/config.nu
-# minimum supported version = 0.93.0
-module compat {
- export def --wrapped "random uuid -v 7" [...rest] { atuin uuid }
-}
-use (if not (
- (version).major > 0 or
- (version).minor >= 103
-) { "compat" }) *
-
-if 'ATUIN_SESSION' not-in $env or ('ATUIN_SHLVL' not-in $env) or ($env.ATUIN_SHLVL != ($env.SHLVL? | default "")) {
- $env.ATUIN_SESSION = (random uuid -v 7 | str replace -a "-" "")
- $env.ATUIN_SHLVL = ($env.SHLVL? | default "")
-}
-hide-env -i ATUIN_HISTORY_ID
-
-def _atuin_osc133_command_executed [] {
- if 'ATUIN_PTY_PROXY_ACTIVE' not-in $env {
- return
- }
- if 'ATUIN_HISTORY_ID' not-in $env or ($env.ATUIN_HISTORY_ID | is-empty) {
- return
- }
-
- print -n $"(char esc)]133;C(char bel)"
-}
-
-def _atuin_osc133_command_finished [exit_code: int] {
- if 'ATUIN_PTY_PROXY_ACTIVE' not-in $env {
- return
- }
- if 'ATUIN_HISTORY_ID' not-in $env or ($env.ATUIN_HISTORY_ID | is-empty) {
- return
- }
-
- print -n $"(char esc)]133;D;($exit_code);history_id=($env.ATUIN_HISTORY_ID);session_id=($env.ATUIN_SESSION)(char bel)"
-}
-
-# Magic token to make sure we don't record commands run by keybindings
-let ATUIN_KEYBINDING_TOKEN = $"# (random uuid)"
-
-let _atuin_pre_execution = {||
- if ($nu | get history-enabled?) == false {
- return
- }
- let cmd = (commandline)
- if ($cmd | is-empty) {
- return
- }
- if not ($cmd | str starts-with $ATUIN_KEYBINDING_TOKEN) {
- $env.ATUIN_HISTORY_ID = (atuin history start -- $cmd | complete | get stdout | str trim)
- _atuin_osc133_command_executed
- }
-}
-
-let _atuin_pre_prompt = {||
- let last_exit = $env.LAST_EXIT_CODE
- if 'ATUIN_HISTORY_ID' not-in $env {
- return
- }
- _atuin_osc133_command_finished $last_exit
- with-env { ATUIN_LOG: error } {
- if (version).minor >= 104 or (version).major > 0 {
- job spawn {
- ^atuin history end $'--exit=($env.LAST_EXIT_CODE)' -- $env.ATUIN_HISTORY_ID | complete
- } | ignore
- } else {
- do { atuin history end $'--exit=($last_exit)' -- $env.ATUIN_HISTORY_ID } | complete
- }
-
- }
- hide-env ATUIN_HISTORY_ID
-}
-
-def _atuin_search_cmd [...flags: string] {
- if (version).minor >= 106 or (version).major > 0 {
- [
- $ATUIN_KEYBINDING_TOKEN,
- ([
- `with-env { ATUIN_LOG: error, ATUIN_QUERY: (commandline), ATUIN_SHELL: nu } {`,
- ([
- 'let output = (run-external atuin search',
- ($flags | append [--interactive] | each {|e| $'"($e)"'}),
- 'e>| str trim)',
- ] | flatten | str join ' '),
- 'if ($output | str starts-with "__atuin_accept__:") {',
- 'commandline edit --accept ($output | str replace "__atuin_accept__:" "")',
- '} else {',
- 'commandline edit $output',
- '}',
- `}`,
- ] | flatten | str join "\n"),
- ]
- } else {
- [
- $ATUIN_KEYBINDING_TOKEN,
- ([
- `with-env { ATUIN_LOG: error, ATUIN_QUERY: (commandline) } {`,
- 'commandline edit',
- '(run-external atuin search',
- ($flags | append [--interactive] | each {|e| $'"($e)"'}),
- ' e>| str trim)',
- `}`,
- ] | flatten | str join ' '),
- ]
- } | str join "\n"
-}
-
-$env.config = ($env | default {} config).config
-$env.config = ($env.config | default {} hooks)
-$env.config = (
- $env.config | upsert hooks (
- $env.config.hooks
- | upsert pre_execution (
- $env.config.hooks | get pre_execution? | default [] | append $_atuin_pre_execution)
- | upsert pre_prompt (
- $env.config.hooks | get pre_prompt? | default [] | append $_atuin_pre_prompt)
- )
-)
-
-$env.config = ($env.config | default [] keybindings)
diff --git a/crates/client/src/shell/atuin.ps1 b/crates/client/src/shell/atuin.ps1
deleted file mode 100644
index 431ee2c3..00000000
--- a/crates/client/src/shell/atuin.ps1
+++ /dev/null
@@ -1,240 +0,0 @@
-# Atuin PowerShell module
-#
-# This should support PowerShell 5.1 (which is shipped with Windows) and later versions, on Windows and Linux.
-#
-# Usage: atuin init powershell | Out-String | Invoke-Expression
-#
-# Settings:
-# - $env:ATUIN_POWERSHELL_PROMPT_OFFSET - Number of lines to offset the prompt position after exiting search.
-# This is useful when using a multi-line prompt: e.g. set this to -1 when using a 2-line prompt.
-# It is initialized from the current prompt line count if not set when the first Atuin search is performed.
-
-if (Get-Module Atuin -ErrorAction Ignore) {
- if ($PSVersionTable.PSVersion.Major -ge 7) {
- Write-Warning "The Atuin module is already loaded, replacing it."
- Remove-Module Atuin
- } else {
- Write-Warning "The Atuin module is already loaded, skipping."
- return
- }
-}
-
-if (!(Get-Command atuin -ErrorAction Ignore)) {
- Write-Error "The 'atuin' executable needs to be available in the PATH."
- return
-}
-
-if (!(Get-Module PSReadLine -ErrorAction Ignore)) {
- Write-Error "Atuin requires the PSReadLine module to be installed."
- return
-}
-
-New-Module -Name Atuin -ScriptBlock {
- if (-not $env:ATUIN_SESSION -or $env:ATUIN_PID -ne $PID) {
- $env:ATUIN_SESSION = atuin uuid
- $env:ATUIN_PID = $PID
- }
-
- $script:atuinHistoryId = $null
- $script:previousPSConsoleHostReadLine = $Function:PSConsoleHostReadLine
-
- # The ReadLine overloads changed with breaking changes over time, make sure the one we expect is available.
- $script:hasExpectedReadLineOverload = ([Microsoft.PowerShell.PSConsoleReadLine]::ReadLine).OverloadDefinitions.Contains("static string ReadLine(runspace runspace, System.Management.Automation.EngineIntrinsics engineIntrinsics, System.Threading.CancellationToken cancellationToken, System.Nullable[bool] lastRunStatus)")
-
- function Get-CommandLine {
- $commandLine = ""
- [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$commandLine, [ref]$null)
- return $commandLine
- }
-
- function Set-CommandLine {
- param([string]$Text)
-
- $commandLine = Get-CommandLine
- [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $commandLine.Length, $Text)
- }
-
- # This function name is called by PSReadLine to read the next command line to execute.
- # We replace it with a custom implementation which adds Atuin support.
- function PSConsoleHostReadLine {
- ## 1. Collect the exit code of the previous command.
-
- # This needs to be done as the first thing because any script run will flush $?.
- $lastRunStatus = $?
-
- # Exit statuses are maintained separately for native and PowerShell commands, this needs to be taken into account.
- $lastNativeExitCode = $global:LASTEXITCODE
- $exitCode = if ($lastRunStatus) { 0 } elseif ($lastNativeExitCode) { $lastNativeExitCode } else { 1 }
-
- ## 2. Report the status of the previous command to Atuin (atuin history end).
-
- if ($script:atuinHistoryId) {
- try {
- # The duration is not recorded in old PowerShell versions, let Atuin handle it. $null arguments are ignored.
- $duration = (Get-History -Count 1).Duration.Ticks * 100
- $durationArg = if ($duration) { "--duration=$duration" } else { $null }
-
- # Fire and forget the atuin history end command to avoid blocking the shell during a potential sync.
- $process = New-Object System.Diagnostics.Process
- $process.StartInfo.FileName = "atuin"
- $process.StartInfo.Arguments = "history end --exit=$exitCode $durationArg -- $script:atuinHistoryId"
- $process.StartInfo.UseShellExecute = $false
- $process.StartInfo.CreateNoWindow = $true
- $process.StartInfo.RedirectStandardInput = $true
- $process.StartInfo.RedirectStandardOutput = $true
- $process.StartInfo.RedirectStandardError = $true
- $process.Start() | Out-Null
- $process.StandardInput.Close()
- $process.BeginOutputReadLine()
- $process.BeginErrorReadLine()
- }
- catch {
- # Ignore errors to avoid breaking the shell.
- # An error would occur if the user removes atuin from the PATH, for instance.
- }
- finally {
- $script:atuinHistoryId = $null
- }
- }
-
- ## 3. Read the next command line to execute.
-
- # PSConsoleHostReadLine implementation from PSReadLine, adjusted to support old versions.
- Microsoft.PowerShell.Core\Set-StrictMode -Off
-
- $line = if ($script:hasExpectedReadLineOverload) {
- # When the overload we expect is available, we can pass $lastRunStatus to it.
- [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($Host.Runspace, $ExecutionContext, [System.Threading.CancellationToken]::None, $lastRunStatus)
- } else {
- # Either PSReadLine is older than v2.2.0-beta3, or maybe newer than we expect, so use the function from PSReadLine as-is.
- & $script:previousPSConsoleHostReadLine
- }
-
- ## 4. Report the next command line to Atuin (atuin history start).
-
- # PowerShell doesn't handle double quotes in native command line arguments the same way depending on its version,
- # and the value of $PSNativeCommandArgumentPassing - see the about_Parsing help page which explains the breaking changes.
- # This makes it unreliable, so we go through an environment variable, which should always be consistent across versions.
- try {
- $env:ATUIN_COMMAND_LINE = $line
- $script:atuinHistoryId = atuin history start --command-from-env
- }
- catch {
- # Ignore errors to avoid breaking the shell, see above.
- }
- finally {
- $env:ATUIN_COMMAND_LINE = $null
- }
-
- $global:LASTEXITCODE = $lastNativeExitCode
- return $line
- }
-
- function Invoke-AtuinSearch {
- param([string]$ExtraArgs = "")
-
- $previousOutputEncoding = [System.Console]::OutputEncoding
- $resultFile = New-TemporaryFile
- $suggestion = ""
- $errorOutput = ""
-
- try {
- [System.Console]::OutputEncoding = [System.Text.Encoding]::UTF8
-
- # Start-Process does some crazy stuff, just use the Process class directly to have more control.
- $process = New-Object System.Diagnostics.Process
- $process.StartInfo.FileName = "atuin"
- $process.StartInfo.Arguments = "search -i --result-file ""$($resultFile.FullName)"" $ExtraArgs"
- $process.StartInfo.UseShellExecute = $false
- $process.StartInfo.RedirectStandardError = $true
- $process.StartInfo.StandardErrorEncoding = [System.Text.Encoding]::UTF8
- $process.StartInfo.EnvironmentVariables["ATUIN_SHELL"] = "powershell"
- $process.StartInfo.EnvironmentVariables["ATUIN_QUERY"] = Get-CommandLine
- # PowerShell's Set-Location (cd) doesn't update the process-level working directory, set it explicitly
- $process.StartInfo.WorkingDirectory = (Get-Location -PSProvider FileSystem).ProviderPath
-
- try {
- $process.Start() | Out-Null
-
- # A single stream is redirected, so we can read it synchronously, but we have to start reading it
- # before waiting for the process to exit, otherwise the buffer could fill up and cause a deadlock.
- $errorOutput = $process.StandardError.ReadToEnd().Trim()
- $process.WaitForExit()
-
- $suggestion = (Get-Content -LiteralPath $resultFile.FullName -Raw -Encoding UTF8 | Out-String).Trim()
- }
- catch {
- $errorOutput = $_
- }
-
- if ($errorOutput) {
- Write-Host -ForegroundColor Red "Atuin error:"
- Write-Host -ForegroundColor DarkRed $errorOutput
- }
-
- # If no shell prompt offset is set, initialize it from the current prompt line count.
- if ($null -eq $env:ATUIN_POWERSHELL_PROMPT_OFFSET) {
- try {
- $promptLines = (& $Function:prompt | Out-String | Measure-Object -Line).Lines
- $env:ATUIN_POWERSHELL_PROMPT_OFFSET = -1 * ($promptLines - 1)
- }
- catch {
- $env:ATUIN_POWERSHELL_PROMPT_OFFSET = 0
- }
- }
-
- # PSReadLine maintains its own cursor position, which will no longer be valid if Atuin scrolls the display in inline mode.
- # Fortunately, InvokePrompt can receive a new Y position and reset the internal state.
- $y = $Host.UI.RawUI.CursorPosition.Y + [int]$env:ATUIN_POWERSHELL_PROMPT_OFFSET
- $y = [System.Math]::Max([System.Math]::Min($y, [System.Console]::BufferHeight - 1), 0)
- [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt($null, $y)
-
- if ($suggestion -eq "") {
- # The previous input was already rendered by InvokePrompt
- return
- }
-
- $acceptPrefix = "__atuin_accept__:"
-
- if ( $suggestion.StartsWith($acceptPrefix)) {
- Set-CommandLine $suggestion.Substring($acceptPrefix.Length)
- [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()
- } else {
- Set-CommandLine $suggestion
- }
- }
- finally {
- [System.Console]::OutputEncoding = $previousOutputEncoding
- $resultFile.Delete()
- }
- }
-
- function Enable-AtuinSearchKeys {
- param([bool]$CtrlR = $true, [bool]$UpArrow = $true)
-
- if ($CtrlR) {
- Set-PSReadLineKeyHandler -Chord "Ctrl+r" -BriefDescription "Runs Atuin search" -ScriptBlock {
- Invoke-AtuinSearch
- }
- }
-
- if ($UpArrow) {
- Set-PSReadLineKeyHandler -Chord "UpArrow" -BriefDescription "Runs Atuin search" -ScriptBlock {
- $line = Get-CommandLine
-
- if (!$line.Contains("`n")) {
- Invoke-AtuinSearch -ExtraArgs "--shell-up-key-binding"
- } else {
- [Microsoft.PowerShell.PSConsoleReadLine]::PreviousLine()
- }
- }
- }
- }
-
- $ExecutionContext.SessionState.Module.OnRemove += {
- $env:ATUIN_SESSION = $null
- $Function:PSConsoleHostReadLine = $script:previousPSConsoleHostReadLine
- }
-
- Export-ModuleMember -Function @("Enable-AtuinSearchKeys", "PSConsoleHostReadLine")
-} | Import-Module -Global
diff --git a/crates/client/src/shell/atuin.xsh b/crates/client/src/shell/atuin.xsh
deleted file mode 100644
index a0283402..00000000
--- a/crates/client/src/shell/atuin.xsh
+++ /dev/null
@@ -1,86 +0,0 @@
-import os
-import subprocess
-
-from prompt_toolkit.application.current import get_app
-from prompt_toolkit.filters import Condition
-from prompt_toolkit.keys import Keys
-
-
-if "ATUIN_SESSION" not in ${...} or ${...}.get("ATUIN_SHLVL", "") != ${...}.get("SHLVL", ""):
- $ATUIN_SESSION=$(atuin uuid).rstrip('\n')
- $ATUIN_SHLVL = ${...}.get("SHLVL", "")
-
-@events.on_precommand
-def _atuin_precommand(cmd: str):
- cmd = cmd.rstrip("\n")
- try:
- $ATUIN_HISTORY_ID = $(atuin history start -- @(cmd) 2>@(os.devnull)).rstrip("\n")
- except:
- $ATUIN_HISTORY_ID = ""
-
-
-@events.on_postcommand
-def _atuin_postcommand(cmd: str, rtn: int, out, ts):
- if "ATUIN_HISTORY_ID" not in ${...}:
- return
-
- duration = ts[1] - ts[0]
- # Duration is float representing seconds, but atuin expects integer of nanoseconds
- nanos = round(duration * 10 ** 9)
- with ${...}.swap(ATUIN_LOG="error"):
- # This causes the entire .xonshrc to be re-executed, which is incredibly slow
- # This happens when using a subshell and using output redirection at the same time
- # For more details, see https://github.com/xonsh/xonsh/issues/5224
- # (atuin history end --exit @(rtn) -- $ATUIN_HISTORY_ID &) > /dev/null 2>&1
- atuin history end --exit @(rtn) --duration @(nanos) -- $ATUIN_HISTORY_ID > @(os.devnull) 2>&1
- del $ATUIN_HISTORY_ID
-
-
-def _search(event, extra_args: list[str]):
- buffer = event.current_buffer
- cmd = ["atuin", "search", "--interactive", *extra_args]
- # We need to explicitly pass in xonsh env, in case user has set XDG_HOME or something else that matters
- env = ${...}.detype()
- env["ATUIN_SHELL"] = "xonsh"
- env["ATUIN_QUERY"] = buffer.text
-
- p = subprocess.run(cmd, stderr=subprocess.PIPE, encoding="utf-8", env=env)
- result = p.stderr.rstrip("\n")
- # redraw prompt - necessary if atuin is configured to run inline, rather than fullscreen
- event.cli.renderer.erase()
-
- if not result:
- return
-
- buffer.reset()
- if result.startswith("__atuin_accept__:"):
- buffer.insert_text(result[17:])
- buffer.validate_and_handle()
- else:
- buffer.insert_text(result)
-
-
-@events.on_ptk_create
-def _custom_keybindings(bindings, **kw):
- if _ATUIN_BIND_CTRL_R:
- @bindings.add(Keys.ControlR)
- def r_search(event):
- _search(event, extra_args=[])
-
- if _ATUIN_BIND_UP_ARROW:
- @Condition
- def should_search():
- buffer = get_app().current_buffer
- # disable keybind when there is an active completion, so
- # that up arrow can be used to navigate completion menu
- if buffer.complete_state is not None:
- return False
- # similarly, disable when buffer text contains multiple lines
- if '\n' in buffer.text:
- return False
-
- return True
-
- @bindings.add(Keys.Up, filter=should_search)
- def up_search(event):
- _search(event, extra_args=["--shell-up-key-binding"])
diff --git a/crates/client/src/shell/atuin.zsh b/crates/client/src/shell/atuin.zsh
deleted file mode 100644
index 7e7fef27..00000000
--- a/crates/client/src/shell/atuin.zsh
+++ /dev/null
@@ -1,167 +0,0 @@
-# shellcheck disable=SC2034,SC2153,SC2086,SC2155
-
-# Above line is because shellcheck doesn't support zsh, per
-# https://github.com/koalaman/shellcheck/wiki/SC1071, and the ignore: param in
-# ludeeus/action-shellcheck only supports _directories_, not _files_. So
-# instead, we manually add any error the shellcheck step finds in the file to
-# the above line ...
-
-# Source this in your ~/.zshrc
-autoload -U add-zsh-hook
-
-zmodload zsh/datetime 2>/dev/null
-
-# If zsh-autosuggestions is installed, configure it to use Atuin's search. If
-# you'd like to override this, then add your config after the $(atuin init zsh)
-# in your .zshrc
-_zsh_autosuggest_strategy_atuin() {
- # silence errors, since we don't want to spam the terminal prompt while typing.
- suggestion=$(ATUIN_QUERY="$1" atuin search --cmd-only --limit 1 --search-mode prefix 2>/dev/null)
-}
-
-if [ -n "${ZSH_AUTOSUGGEST_STRATEGY:-}" ]; then
- ZSH_AUTOSUGGEST_STRATEGY=("atuin" "${ZSH_AUTOSUGGEST_STRATEGY[@]}")
-else
- ZSH_AUTOSUGGEST_STRATEGY=("atuin")
-fi
-
-if [[ -z "${ATUIN_SESSION:-}" || "${ATUIN_SHLVL:-}" != "$SHLVL" ]]; then
- export ATUIN_SESSION=$(atuin uuid)
- export ATUIN_SHLVL=$SHLVL
-fi
-ATUIN_HISTORY_ID=""
-
-__atuin_osc133_command_executed() {
- [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || return
- [[ -n "${ATUIN_HISTORY_ID:-}" ]] || return
-
- printf '\033]133;C\a'
-}
-
-__atuin_osc133_command_finished() {
- [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || return
- [[ -n "${ATUIN_HISTORY_ID:-}" ]] || return
-
- printf '\033]133;D;%s;history_id=%s;session_id=%s\a' "$1" "$ATUIN_HISTORY_ID" "${ATUIN_SESSION:-}"
-}
-
-__atuin_osc133_prompt_start=$'%{\033]133;A;cl=line\a%}'
-__atuin_osc133_prompt_end=$'%{\033]133;B\a%}'
-
-__atuin_osc133_wrap_prompt() {
- local __atuin_prompt="${PROMPT-}"
- local __atuin_rprompt="${RPROMPT-}"
-
- __atuin_prompt="${__atuin_prompt//$__atuin_osc133_prompt_start/}"
- __atuin_prompt="${__atuin_prompt//$__atuin_osc133_prompt_end/}"
- __atuin_rprompt="${__atuin_rprompt//$__atuin_osc133_prompt_start/}"
- __atuin_rprompt="${__atuin_rprompt//$__atuin_osc133_prompt_end/}"
-
- if [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]]; then
- PROMPT="${__atuin_osc133_prompt_start}${__atuin_prompt}"
- RPROMPT="${__atuin_rprompt}${__atuin_osc133_prompt_end}"
- else
- PROMPT="$__atuin_prompt"
- RPROMPT="$__atuin_rprompt"
- fi
-}
-
-_atuin_preexec() {
- local id
- id=$(atuin history start -- "$1" 2>/dev/null)
- export ATUIN_HISTORY_ID="$id"
- __atuin_osc133_command_executed
- __atuin_preexec_time=${EPOCHREALTIME-}
-}
-
-_atuin_precmd() {
- local EXIT="$?" __atuin_precmd_time=${EPOCHREALTIME-}
-
- __atuin_osc133_wrap_prompt
-
- [[ -z "${ATUIN_HISTORY_ID:-}" ]] && return
-
- local duration=""
- if [[ -n $__atuin_preexec_time && -n $__atuin_precmd_time ]]; then
- printf -v duration %.0f $(((__atuin_precmd_time - __atuin_preexec_time) * 1000000000))
- fi
-
- __atuin_osc133_command_finished "$EXIT"
- (ATUIN_LOG=error atuin history end --exit $EXIT ${duration:+--duration=$duration} -- $ATUIN_HISTORY_ID &) >/dev/null 2>&1
- export ATUIN_HISTORY_ID=""
-}
-
-__atuin_search_cmd() {
- local -a search_args=("$@")
-
-
- ATUIN_SHELL=zsh ATUIN_LOG=error ATUIN_QUERY=$BUFFER atuin search "${search_args[@]}" -i 3>&1 1>&2 2>&3 3>&-
-}
-
-_atuin_search() {
- emulate -L zsh
- zle -I
-
- # swap stderr and stdout, so that the tui stuff works
- # TODO: not this
- local output __atuin_status
- # shellcheck disable=SC2048
- output=$(__atuin_search_cmd $*)
- __atuin_status=$?
-
- zle reset-prompt
- # re-enable bracketed paste
- # shellcheck disable=SC2154
- echo -n ${zle_bracketed_paste[1]} >/dev/tty
-
- if (( __atuin_status != 0 )); then
- [[ -n $output ]] && print -r -- "$output" >/dev/tty
- return $__atuin_status
- fi
-
- if [[ -n $output ]]; then
- RBUFFER=""
- LBUFFER=$output
-
- if [[ $LBUFFER == __atuin_accept__:* ]]
- then
- LBUFFER=${LBUFFER#__atuin_accept__:}
- zle accept-line
- fi
- fi
-}
-_atuin_search_vicmd() {
- _atuin_search --keymap-mode=vim-normal
-}
-_atuin_search_viins() {
- _atuin_search --keymap-mode=vim-insert
-}
-
-_atuin_up_search() {
- # Only trigger if the buffer is a single line
- if [[ ! $BUFFER == *$'\n'* ]]; then
- _atuin_search --shell-up-key-binding "$@"
- else
- zle up-line
- fi
-}
-_atuin_up_search_vicmd() {
- _atuin_up_search --keymap-mode=vim-normal
-}
-_atuin_up_search_viins() {
- _atuin_up_search --keymap-mode=vim-insert
-}
-
-add-zsh-hook preexec _atuin_preexec
-add-zsh-hook precmd _atuin_precmd
-
-zle -N atuin-search _atuin_search
-zle -N atuin-search-vicmd _atuin_search_vicmd
-zle -N atuin-search-viins _atuin_search_viins
-zle -N atuin-up-search _atuin_up_search
-zle -N atuin-up-search-vicmd _atuin_up_search_vicmd
-zle -N atuin-up-search-viins _atuin_up_search_viins
-
-# These are compatibility widget names for "atuin <= 17.2.1" users.
-zle -N _atuin_search_widget _atuin_search
-zle -N _atuin_up_search_widget _atuin_up_search
diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml
index 0fd7f065..3d36429c 100644
--- a/crates/common/Cargo.toml
+++ b/crates/common/Cargo.toml
@@ -13,8 +13,6 @@ repository = { workspace = true }
[dependencies]
async-trait = "0.1.58"
-atuin-nucleo-matcher = { workspace = true }
-atuin-nucleo = { workspace = true }
axum = "0.8"
base64 = "0.22"
clap = { version = "4.5.7", features = ["derive"] }
diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml
index 6d5de681..07fd84d7 100644
--- a/crates/daemon/Cargo.toml
+++ b/crates/daemon/Cargo.toml
@@ -14,8 +14,6 @@ repository = { workspace = true }
[dependencies]
turtle-common = { workspace = true }
async-trait = "0.1.58"
-atuin-nucleo-matcher = { workspace = true }
-atuin-nucleo = { workspace = true }
axum = "0.8"
base64 = "0.22"
clap = { version = "4.5.7", features = ["derive"] }
diff --git a/crates/daemon/build.rs b/crates/daemon/build.rs
index 646b8588..5abf6b2a 100644
--- a/crates/daemon/build.rs
+++ b/crates/daemon/build.rs
@@ -6,7 +6,6 @@ use protox::prost::Message;
fn main() -> Result<(), std::io::Error> {
let proto_paths = [
"proto/history.proto",
- "proto/search.proto",
"proto/control.proto",
"proto/semantic.proto",
];
diff --git a/crates/daemon/proto/search.proto b/crates/daemon/proto/search.proto
deleted file mode 100644
index 6b84acbd..00000000
--- a/crates/daemon/proto/search.proto
+++ /dev/null
@@ -1,35 +0,0 @@
-syntax = "proto3";
-package search;
-
-enum FilterMode {
- GLOBAL = 0;
- HOST = 1;
- SESSION = 2;
- DIRECTORY = 3;
- WORKSPACE = 4;
- SESSION_PRELOAD = 5;
-}
-
-message SearchContext {
- string session_id = 1;
- string cwd = 2;
- string hostname = 3;
- string host_id = 4;
- optional string git_root = 5;
-}
-
-message SearchRequest {
- string query = 1;
- uint64 query_id = 2; // Incrementing ID to match responses to queries
- FilterMode filter_mode = 3;
- SearchContext context = 4;
-}
-
-message SearchResponse {
- uint64 query_id = 1; // Echo back the query ID
- repeated bytes ids = 2;
-}
-
-service Search {
- rpc Search(stream SearchRequest) returns (stream SearchResponse);
-}
diff --git a/crates/client/src/atuin_client/api_client.rs b/crates/daemon/src/aclient/api_client.rs
index bd5bf59e..666e6dce 100644
--- a/crates/client/src/atuin_client/api_client.rs
+++ b/crates/daemon/src/aclient/api_client.rs
@@ -6,8 +6,8 @@ use reqwest::{Response, StatusCode, Url, header::HeaderMap};
use tracing::debug;
use uuid::Uuid;
-use crate::atuin_common::{api::ErrorResponse, record::RecordStatus};
-use crate::atuin_common::{
+use turtle_common::{api::ErrorResponse, record::RecordStatus};
+use turtle_common::{
api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ATUIN_VERSION},
record::{EncryptedData, HostId, Record, RecordIdx},
tls::ensure_crypto_provider,
diff --git a/crates/client/src/atuin_client/database.rs b/crates/daemon/src/aclient/database/mod.rs
index a9eb2058..36049f80 100644
--- a/crates/client/src/atuin_client/database.rs
+++ b/crates/daemon/src/aclient/database/mod.rs
@@ -4,7 +4,7 @@ use std::{
str::FromStr,
};
-use crate::{atuin_client::utils::setup_db, atuin_common::utils};
+use crate::aclient::utils::setup_db;
use fs_err::{self as fs};
use itertools::Itertools;
use sql_builder::{SqlBuilder, SqlName, bind::Bind, esc, quote};
@@ -14,9 +14,10 @@ use sqlx::{
};
use time::OffsetDateTime;
use tracing::debug;
+use turtle_common::utils;
use uuid::Uuid;
-use crate::atuin_client::{
+use crate::aclient::{
history::{HistoryId, HistoryStats},
utils::get_host_user,
};
@@ -93,12 +94,12 @@ fn get_session_start_time(session_id: &str) -> Option<i64> {
// Intended for use on a developer machine and not a sync server.
// TODO: implement IntoIterator
#[derive(Debug, Clone)]
-pub(crate) struct ClientSqlite {
+pub struct ClientSqlite {
pub(crate) pool: SqlitePool,
}
impl ClientSqlite {
- pub(crate) async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {
+ pub async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {
fn mk_opts(path: &str) -> Result<SqliteConnectOptions> {
let opts = SqliteConnectOptions::from_str(path)?
.journal_mode(SqliteJournalMode::Wal)
@@ -800,7 +801,7 @@ impl SqlBuilderExt for SqlBuilder {
#[cfg(test)]
mod test {
- use crate::atuin_client::settings::test_local_timeout;
+ use crate::aclient::settings::test_local_timeout;
use super::{
ClientSqlite, Context, FilterMode, History, OffsetDateTime, OptFilters, Result, SearchMode,
diff --git a/crates/client/src/atuin_client/encryption.rs b/crates/daemon/src/aclient/encryption.rs
index f1c921cb..220ac74e 100644
--- a/crates/client/src/atuin_client/encryption.rs
+++ b/crates/daemon/src/aclient/encryption.rs
@@ -17,7 +17,7 @@ use eyre::{Context, Result, bail, ensure, eyre};
use fs_err as fs;
use rmp::Marker;
-use crate::atuin_client::settings::Settings;
+use crate::aclient::settings::Settings;
pub(crate) fn generate_encoded_key() -> Result<(Key, String)> {
let key = XSalsa20Poly1305::generate_key(&mut OsRng);
diff --git a/crates/client/src/atuin_client/history/builder.rs b/crates/daemon/src/aclient/history/builder.rs
index daa4ef49..daa4ef49 100644
--- a/crates/client/src/atuin_client/history/builder.rs
+++ b/crates/daemon/src/aclient/history/builder.rs
diff --git a/crates/client/src/atuin_client/history.rs b/crates/daemon/src/aclient/history/mod.rs
index c38d8ccc..09d24169 100644
--- a/crates/client/src/atuin_client/history.rs
+++ b/crates/daemon/src/aclient/history/mod.rs
@@ -5,14 +5,14 @@ use rmp::{Marker, decode::Bytes};
use std::env;
use std::fmt::Display;
-use crate::atuin_common::record::DecryptedData;
-use crate::atuin_common::utils::uuid_v7;
+use turtle_common::record::DecryptedData;
+use turtle_common::utils::uuid_v7;
use eyre::{Result, bail, eyre};
-use crate::atuin_client::secrets::SECRET_PATTERNS_RE;
-use crate::atuin_client::settings::Settings;
-use crate::atuin_client::utils::get_host_user;
+use crate::aclient::secrets::SECRET_PATTERNS_RE;
+use crate::aclient::settings::Settings;
+use crate::aclient::utils::get_host_user;
use time::OffsetDateTime;
mod builder;
@@ -28,7 +28,7 @@ const HISTORY_AUTHOR_ENV: &str = "ATUIN_HISTORY_AUTHOR";
const HISTORY_INTENT_ENV: &str = "ATUIN_HISTORY_INTENT";
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
-pub(crate) struct HistoryId(pub(crate) String);
+pub struct HistoryId(pub(crate) String);
impl Display for HistoryId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
@@ -56,7 +56,7 @@ impl From<String> for HistoryId {
// New fields must be added to `History::{serialize,deserialize}` in a backwards
// compatible way (sensible defaults and careful `nfields` handling).
#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
-pub(crate) struct History {
+pub struct History {
/// A client-generated ID, used to identify the entry when syncing.
///
/// Stored as `client_id` in the database.
@@ -375,7 +375,7 @@ impl History {
///
/// ## Examples
/// ```rust
- /// use crate::atuin_client::history::History;
+ /// use crate::aclient::history::History;
///
/// let history: History = History::capture()
/// .timestamp(time::OffsetDateTime::now_utc())
@@ -388,7 +388,7 @@ impl History {
/// Command without any required info cannot be captured, which is forced at compile time:
///
/// ```compile_fail
- /// use crate::atuin_client::history::History;
+ /// use crate::aclient::history::History;
///
/// // this will not compile because `cwd` is missing
/// let history: History = History::capture()
@@ -413,7 +413,7 @@ impl History {
///
/// ## Examples
/// ```rust
- /// use crate::atuin_client::history::History;
+ /// use crate::aclient::history::History;
///
/// let history: History = History::daemon()
/// .timestamp(time::OffsetDateTime::now_utc())
@@ -428,7 +428,7 @@ impl History {
/// Command without any required info cannot be captured, which is forced at compile time:
///
/// ```compile_fail
- /// use crate::atuin_client::history::History;
+ /// use crate::aclient::history::History;
///
/// // this will not compile because `hostname` is missing
/// let history: History = History::daemon()
@@ -448,7 +448,7 @@ impl History {
/// All fields are required, as they are all present in the database.
///
/// ```compile_fail
- /// use crate::atuin_client::history::History;
+ /// use crate::aclient::history::History;
///
/// // this will not compile because `id` field is missing
/// let history: History = History::from_db()
@@ -486,7 +486,7 @@ mod tests {
use regex::RegexSet;
use time::macros::datetime;
- use crate::atuin_client::{history::HISTORY_VERSION, settings::Settings};
+ use crate::aclient::{history::HISTORY_VERSION, settings::Settings};
use super::History;
diff --git a/crates/client/src/atuin_client/history/store.rs b/crates/daemon/src/aclient/history/store.rs
index 9c7771cc..db692590 100644
--- a/crates/client/src/atuin_client/history/store.rs
+++ b/crates/daemon/src/aclient/history/store.rs
@@ -5,11 +5,11 @@ use indicatif::{ProgressBar, ProgressState, ProgressStyle};
use rmp::decode::Bytes;
use tracing::debug;
-use crate::atuin_client::{
+use crate::aclient::{
database::{ClientSqlite, current_context},
record::{encryption::PASETO_V4, sqlite_store::SqliteStore},
};
-use crate::atuin_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx};
+use turtle_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx};
use super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0, History, HistoryId};
@@ -364,10 +364,10 @@ impl HistoryStore {
#[cfg(test)]
mod tests {
- use crate::atuin_common::record::DecryptedData;
+ use turtle_common::record::DecryptedData;
use time::macros::datetime;
- use crate::atuin_client::history::{HISTORY_VERSION, store::HistoryRecord};
+ use crate::aclient::history::{HISTORY_VERSION, store::HistoryRecord};
use super::History;
diff --git a/crates/client/src/atuin_client/meta.rs b/crates/daemon/src/aclient/meta.rs
index 079c9926..00dabcce 100644
--- a/crates/client/src/atuin_client/meta.rs
+++ b/crates/daemon/src/aclient/meta.rs
@@ -2,7 +2,7 @@ use std::path::Path;
use std::str::FromStr;
use std::time::Duration;
-use crate::atuin_common::record::HostId;
+use turtle_common::record::HostId;
use eyre::{Result, eyre};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
@@ -108,7 +108,7 @@ impl MetaStore {
return Ok(HostId(parsed));
}
- let uuid = crate::atuin_common::utils::uuid_v7();
+ let uuid = turtle_common::utils::uuid_v7();
self.set(KEY_HOST_ID, uuid.as_simple().to_string().as_ref())
.await?;
diff --git a/crates/daemon/src/aclient/mod.rs b/crates/daemon/src/aclient/mod.rs
new file mode 100644
index 00000000..3f3709a9
--- /dev/null
+++ b/crates/daemon/src/aclient/mod.rs
@@ -0,0 +1,11 @@
+pub mod database;
+pub mod history;
+pub mod record;
+pub mod settings;
+
+pub(crate) mod encryption;
+pub(crate) mod meta;
+pub(crate) mod utils;
+pub(crate) mod ordering;
+pub(crate) mod secrets;
+pub(crate) mod api_client;
diff --git a/crates/client/src/atuin_client/ordering.rs b/crates/daemon/src/aclient/ordering.rs
index 84001f52..84001f52 100644
--- a/crates/client/src/atuin_client/ordering.rs
+++ b/crates/daemon/src/aclient/ordering.rs
diff --git a/crates/client/src/atuin_client/record/encryption.rs b/crates/daemon/src/aclient/record/encryption.rs
index d8587cf6..6851a99f 100644
--- a/crates/client/src/atuin_client/record/encryption.rs
+++ b/crates/daemon/src/aclient/record/encryption.rs
@@ -1,4 +1,4 @@
-use crate::atuin_common::record::{
+use turtle_common::record::{
AdditionalData, DecryptedData, EncryptedData, Encryption, HostId, RecordId, RecordIdx,
};
use base64::{Engine, engine::general_purpose};
@@ -201,7 +201,7 @@ impl Assertions<'_> {
#[cfg(test)]
mod tests {
- use crate::atuin_common::{
+ use turtle_common::{
record::{Host, Record},
utils::uuid_v7,
};
diff --git a/crates/client/src/atuin_client/record/mod.rs b/crates/daemon/src/aclient/record/mod.rs
index 4e5774ea..2ace26f5 100644
--- a/crates/client/src/atuin_client/record/mod.rs
+++ b/crates/daemon/src/aclient/record/mod.rs
@@ -1,3 +1,4 @@
pub(crate) mod encryption;
-pub(crate) mod sqlite_store;
pub(crate) mod sync;
+
+pub mod sqlite_store;
diff --git a/crates/client/src/atuin_client/record/sqlite_store.rs b/crates/daemon/src/aclient/record/sqlite_store.rs
index 18f5c869..f2fc9d84 100644
--- a/crates/client/src/atuin_client/record/sqlite_store.rs
+++ b/crates/daemon/src/aclient/record/sqlite_store.rs
@@ -14,22 +14,22 @@ use sqlx::{
};
use tracing::debug;
-use crate::atuin_client::utils::setup_db;
-use crate::atuin_common::record::{
+use crate::aclient::utils::setup_db;
+use turtle_common::record::{
EncryptedData, Host, HostId, Record, RecordId, RecordIdx, RecordStatus,
};
-use crate::atuin_common::utils;
+use turtle_common::utils;
use uuid::Uuid;
use super::encryption::PASETO_V4;
#[derive(Debug, Clone)]
-pub(crate) struct SqliteStore {
+pub struct SqliteStore {
pool: SqlitePool,
}
impl SqliteStore {
- pub(crate) async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {
+ pub async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {
fn mk_opts(path: &str) -> sqlx::Result<SqliteConnectOptions> {
let opts = SqliteConnectOptions::from_str(path)?
.journal_mode(SqliteJournalMode::Wal)
diff --git a/crates/client/src/atuin_client/record/sync.rs b/crates/daemon/src/aclient/record/sync.rs
index da05533c..94764f67 100644
--- a/crates/client/src/atuin_client/record/sync.rs
+++ b/crates/daemon/src/aclient/record/sync.rs
@@ -6,10 +6,10 @@ use thiserror::Error;
use tracing::error;
use super::encryption::PASETO_V4;
-use crate::atuin_client::record::sqlite_store::SqliteStore;
-use crate::atuin_client::{api_client::Client, settings::Settings};
+use crate::aclient::record::sqlite_store::SqliteStore;
+use crate::aclient::{api_client::Client, settings::Settings};
-use crate::atuin_common::record::{Diff, HostId, RecordId, RecordIdx, RecordStatus};
+use turtle_common::record::{Diff, HostId, RecordId, RecordIdx, RecordStatus};
use indicatif::{ProgressBar, ProgressState, ProgressStyle};
#[derive(Error, Debug)]
@@ -374,10 +374,10 @@ pub(crate) async fn sync(
#[cfg(test)]
mod tests {
- use crate::atuin_client::record::sync::Operation;
- use crate::atuin_common::record::{Diff, EncryptedData, HostId, Record};
+ use crate::aclient::record::sync::Operation;
+ use turtle_common::record::{Diff, EncryptedData, HostId, Record};
- use crate::atuin_client::{
+ use crate::aclient::{
record::{
sqlite_store::SqliteStore,
sync::{self},
@@ -387,11 +387,11 @@ mod tests {
fn test_record() -> Record<EncryptedData> {
Record::builder()
- .host(crate::atuin_common::record::Host::new(HostId(
- crate::atuin_common::utils::uuid_v7(),
+ .host(turtle_common::record::Host::new(HostId(
+ turtle_common::utils::uuid_v7(),
)))
.version("v1".into())
- .tag(crate::atuin_common::utils::uuid_v7().simple().to_string())
+ .tag(turtle_common::utils::uuid_v7().simple().to_string())
.data(EncryptedData {
data: String::new(),
content_encryption_key: String::new(),
diff --git a/crates/client/src/atuin_client/secrets.rs b/crates/daemon/src/aclient/secrets.rs
index 74d47ea6..08d24339 100644
--- a/crates/client/src/atuin_client/secrets.rs
+++ b/crates/daemon/src/aclient/secrets.rs
@@ -173,7 +173,7 @@ pub(crate) static SECRET_PATTERNS_RE: LazyLock<RegexSet> = LazyLock::new(|| {
mod tests {
use regex::Regex;
- use crate::atuin_client::secrets::{SECRET_PATTERNS, TestValue};
+ use crate::aclient::secrets::{SECRET_PATTERNS, TestValue};
#[test]
fn test_secrets() {
diff --git a/crates/daemon/src/aclient/settings/meta.rs b/crates/daemon/src/aclient/settings/meta.rs
new file mode 100644
index 00000000..7993ef6d
--- /dev/null
+++ b/crates/daemon/src/aclient/settings/meta.rs
@@ -0,0 +1,17 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub(crate) struct Settings {
+ pub(crate) db_path: String,
+}
+
+impl Default for Settings {
+ fn default() -> Self {
+ let dir = turtle_common::utils::data_dir();
+ let path = dir.join("meta.db");
+
+ Self {
+ db_path: path.to_string_lossy().to_string(),
+ }
+ }
+}
diff --git a/crates/daemon/src/aclient/settings/mod.rs b/crates/daemon/src/aclient/settings/mod.rs
new file mode 100644
index 00000000..58afd17c
--- /dev/null
+++ b/crates/daemon/src/aclient/settings/mod.rs
@@ -0,0 +1,1544 @@
+use crypto_secretbox::Key;
+use std::{
+ collections::HashMap, fmt, fs::read_to_string, path::PathBuf, str::FromStr, sync::OnceLock,
+};
+use tokio::sync::OnceCell;
+use tracing::info;
+use uuid::Uuid;
+
+use crate::aclient::encryption::decode_key;
+use clap::ValueEnum;
+use config::{
+ Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState,
+};
+use eyre::{Context, Error, Result, bail, eyre};
+use fs_err::create_dir_all;
+use regex::RegexSet;
+use serde::{Deserialize, Serialize};
+use serde_with::DeserializeFromStr;
+use time::{OffsetDateTime, UtcOffset, format_description::FormatItem, macros::format_description};
+use turtle_common::record::HostId;
+use turtle_common::utils;
+
+static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
+static META_CONFIG: OnceLock<(String, f64)> = OnceLock::new();
+static META_STORE: OnceCell<crate::aclient::meta::MetaStore> = OnceCell::const_new();
+
+pub(crate) mod meta;
+pub(crate) mod watcher;
+
+#[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq, Serialize)]
+pub(crate) enum SearchMode {
+ #[serde(rename = "prefix")]
+ Prefix,
+
+ #[serde(rename = "fulltext")]
+ #[clap(aliases = &["fulltext"])]
+ FullText,
+
+ #[serde(rename = "fuzzy")]
+ Fuzzy,
+
+ #[serde(rename = "skim")]
+ Skim,
+
+ #[serde(rename = "daemon-fuzzy")]
+ #[clap(aliases = &["daemon-fuzzy"])]
+ DaemonFuzzy,
+}
+
+impl SearchMode {
+ pub(crate) fn as_str(self) -> &'static str {
+ match self {
+ Self::Prefix => "PREFIX",
+ Self::FullText => "FULLTXT",
+ Self::Fuzzy => "FUZZY",
+ Self::Skim => "SKIM",
+ Self::DaemonFuzzy => "DAEMON",
+ }
+ }
+ pub(crate) fn next(self, settings: &Settings) -> Self {
+ match self {
+ Self::Prefix => Self::FullText,
+ // if the user is using skim, we go to skim
+ Self::FullText if settings.search_mode == Self::Skim => Self::Skim,
+ // if the user is using daemon-fuzzy, we go to daemon-fuzzy
+ Self::FullText if settings.search_mode == Self::DaemonFuzzy => Self::DaemonFuzzy,
+ // otherwise fuzzy.
+ Self::FullText => Self::Fuzzy,
+ Self::Fuzzy | Self::Skim | Self::DaemonFuzzy => Self::Prefix,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
+pub(crate) enum FilterMode {
+ #[serde(rename = "global")]
+ Global = 0,
+
+ #[serde(rename = "host")]
+ Host = 1,
+
+ #[serde(rename = "session")]
+ Session = 2,
+
+ #[serde(rename = "directory")]
+ Directory = 3,
+
+ #[serde(rename = "workspace")]
+ Workspace = 4,
+
+ #[serde(rename = "session-preload")]
+ SessionPreload = 5,
+}
+
+impl FilterMode {
+ pub(crate) fn as_str(self) -> &'static str {
+ match self {
+ Self::Global => "GLOBAL",
+ Self::Host => "HOST",
+ Self::Session => "SESSION",
+ Self::Directory => "DIRECTORY",
+ Self::Workspace => "WORKSPACE",
+ Self::SessionPreload => "SESSION+",
+ }
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
+pub(crate) enum ExitMode {
+ #[serde(rename = "return-original")]
+ ReturnOriginal,
+
+ #[serde(rename = "return-query")]
+ ReturnQuery,
+}
+
+// FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged
+// FIXME: Above PR was merged, but dependency was changed to interim (fork of chrono-english) in the ... interim
+#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
+pub(crate) enum Dialect {
+ #[serde(rename = "us")]
+ Us,
+
+ #[serde(rename = "uk")]
+ Uk,
+}
+
+impl From<Dialect> for interim::Dialect {
+ fn from(d: Dialect) -> Self {
+ match d {
+ Dialect::Uk => Self::Uk,
+ Dialect::Us => Self::Us,
+ }
+ }
+}
+
+/// Type wrapper around `time::UtcOffset` to support a wider variety of timezone formats.
+///
+/// Note that the parsing of this struct needs to be done before starting any
+/// multithreaded runtime, otherwise it will fail on most Unix systems.
+///
+/// See: <https://github.com/atuinsh/atuin/pull/1517#discussion_r1447516426>
+#[derive(Clone, Copy, Debug, Eq, PartialEq, DeserializeFromStr, Serialize)]
+pub(crate) struct Timezone(pub(crate) UtcOffset);
+impl fmt::Display for Timezone {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.0.fmt(f)
+ }
+}
+/// format: <+|-><hour>[:<minute>[:<second>]]
+static OFFSET_FMT: &[FormatItem<'_>] = format_description!(
+ "[offset_hour sign:mandatory padding:none][optional [:[offset_minute padding:none][optional [:[offset_second padding:none]]]]]"
+);
+impl FromStr for Timezone {
+ type Err = Error;
+
+ fn from_str(s: &str) -> Result<Self> {
+ // local timezone
+ if matches!(s.to_lowercase().as_str(), "l" | "local") {
+ // There have been some timezone issues, related to errors fetching it on some
+ // platforms
+ // Rather than fail to start, fallback to UTC. The user should still be able to specify
+ // their timezone manually in the config file.
+ let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
+ return Ok(Self(offset));
+ }
+
+ if matches!(s.to_lowercase().as_str(), "0" | "utc") {
+ let offset = UtcOffset::UTC;
+ return Ok(Self(offset));
+ }
+
+ // offset from UTC
+ if let Ok(offset) = UtcOffset::parse(s, OFFSET_FMT) {
+ return Ok(Self(offset));
+ }
+
+ // IDEA: Currently named timezones are not supported, because the well-known crate
+ // for this is `chrono_tz`, which is not really interoperable with the datetime crate
+ // that we currently use - `time`. If ever we migrate to using `chrono`, this would
+ // be a good feature to add.
+
+ bail!(r#""{s}" is not a valid timezone spec"#)
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
+pub(crate) enum Style {
+ #[serde(rename = "auto")]
+ Auto,
+
+ #[serde(rename = "full")]
+ Full,
+
+ #[serde(rename = "compact")]
+ Compact,
+}
+
+#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
+pub(crate) enum WordJumpMode {
+ #[serde(rename = "emacs")]
+ Emacs,
+
+ #[serde(rename = "subl")]
+ Subl,
+}
+
+#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
+pub(crate) enum KeymapMode {
+ #[serde(rename = "emacs")]
+ Emacs,
+
+ #[serde(rename = "vim-normal")]
+ VimNormal,
+
+ #[serde(rename = "vim-insert")]
+ VimInsert,
+
+ #[serde(rename = "auto")]
+ Auto,
+}
+
+// We want to translate the config to crossterm::cursor::SetCursorStyle, but
+// the original type does not implement trait serde::Deserialize unfortunately.
+// It seems impossible to implement Deserialize for external types when it is
+// used in HashMap (https://stackoverflow.com/questions/67142663). We instead
+// define an adapter type.
+#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
+pub(crate) enum CursorStyle {
+ #[serde(rename = "default")]
+ DefaultUserShape,
+
+ #[serde(rename = "blink-block")]
+ BlinkingBlock,
+
+ #[serde(rename = "steady-block")]
+ SteadyBlock,
+
+ #[serde(rename = "blink-underline")]
+ BlinkingUnderScore,
+
+ #[serde(rename = "steady-underline")]
+ SteadyUnderScore,
+
+ #[serde(rename = "blink-bar")]
+ BlinkingBar,
+
+ #[serde(rename = "steady-bar")]
+ SteadyBar,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub(crate) struct Stats {
+ #[serde(default = "Stats::common_prefix_default")]
+ pub(crate) common_prefix: Vec<String>, // sudo, etc. commands we want to strip off
+ #[serde(default = "Stats::common_subcommands_default")]
+ pub(crate) common_subcommands: Vec<String>, // kubectl, commands we should consider subcommands for
+ #[serde(default = "Stats::ignored_commands_default")]
+ pub(crate) ignored_commands: Vec<String>, // cd, ls, etc. commands we want to completely hide from stats
+}
+
+impl Stats {
+ fn common_prefix_default() -> Vec<String> {
+ vec!["sudo", "doas"].into_iter().map(String::from).collect()
+ }
+
+ fn common_subcommands_default() -> Vec<String> {
+ vec![
+ "apt",
+ "cargo",
+ "composer",
+ "dnf",
+ "docker",
+ "dotnet",
+ "git",
+ "go",
+ "ip",
+ "jj",
+ "kubectl",
+ "nix",
+ "nmcli",
+ "npm",
+ "pecl",
+ "pnpm",
+ "podman",
+ "port",
+ "systemctl",
+ "tmux",
+ "yarn",
+ ]
+ .into_iter()
+ .map(String::from)
+ .collect()
+ }
+
+ fn ignored_commands_default() -> Vec<String> {
+ vec![]
+ }
+}
+
+impl Default for Stats {
+ fn default() -> Self {
+ Self {
+ common_prefix: Self::common_prefix_default(),
+ common_subcommands: Self::common_subcommands_default(),
+ ignored_commands: Self::ignored_commands_default(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Default, Serialize)]
+#[expect(clippy::struct_excessive_bools)]
+pub(crate) struct Keys {
+ pub(crate) scroll_exits: bool,
+ pub(crate) exit_past_line_start: bool,
+ pub(crate) accept_past_line_end: bool,
+ pub(crate) accept_past_line_start: bool,
+ pub(crate) accept_with_backspace: bool,
+ pub(crate) prefix: String,
+}
+
+impl Keys {
+ /// The standard default values for all `[keys]` options.
+ /// These match the config defaults set in `builder_with_data_dir()`.
+ pub(crate) fn standard_defaults() -> Self {
+ Self {
+ scroll_exits: true,
+ exit_past_line_start: true,
+ accept_past_line_end: true,
+ accept_past_line_start: false,
+ accept_with_backspace: false,
+ prefix: "a".to_string(),
+ }
+ }
+}
+
+/// A single rule within a conditional keybinding config.
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub(crate) struct KeyRuleConfig {
+ /// Optional condition expression (e.g. "cursor-at-start", "input-empty && no-results").
+ /// If absent, the rule always matches.
+ #[serde(default)]
+ pub(crate) when: Option<String>,
+ /// The action to perform (e.g. "exit", "cursor-left", "accept").
+ pub(crate) action: String,
+}
+
+/// A keybinding config value: either a simple action string or an ordered list of conditional rules.
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(untagged)]
+pub(crate) enum KeyBindingConfig {
+ /// Simple unconditional binding: `"ctrl-c" = "return-original"`
+ Simple(String),
+ /// Conditional binding: `"left" = [{ when = "cursor-at-start", action = "exit" }, { action = "cursor-left" }]`
+ Rules(Vec<KeyRuleConfig>),
+}
+
+/// User-facing keymap configuration. Each mode maps key strings to bindings.
+/// Keys present here override the defaults for that key; unmentioned keys keep defaults.
+#[derive(Clone, Debug, Deserialize, Serialize, Default)]
+pub(crate) struct KeymapConfig {
+ #[serde(default)]
+ pub(crate) emacs: HashMap<String, KeyBindingConfig>,
+ #[serde(default, rename = "vim-normal")]
+ pub(crate) vim_normal: HashMap<String, KeyBindingConfig>,
+ #[serde(default, rename = "vim-insert")]
+ pub(crate) vim_insert: HashMap<String, KeyBindingConfig>,
+ #[serde(default)]
+ pub(crate) inspector: HashMap<String, KeyBindingConfig>,
+ #[serde(default)]
+ pub(crate) prefix: HashMap<String, KeyBindingConfig>,
+}
+
+impl KeymapConfig {
+ /// Returns true if no keybinding overrides are configured in any mode.
+ pub(crate) fn is_empty(&self) -> bool {
+ self.emacs.is_empty()
+ && self.vim_normal.is_empty()
+ && self.vim_insert.is_empty()
+ && self.inspector.is_empty()
+ && self.prefix.is_empty()
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub(crate) struct Preview {
+ pub(crate) strategy: PreviewStrategy,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct Daemon {
+ /// The daemon will handle sync on an interval. How often to sync, in seconds.
+ pub sync_frequency: u64,
+
+ /// The path to the unix socket used by the daemon
+ pub socket_path: String,
+
+ /// Path to the daemon pidfile used for process coordination.
+ pub pidfile_path: String,
+
+ /// Use a socket passed via systemd's socket activation protocol, instead of the path
+ pub systemd_socket: bool,
+
+ /// The port that should be used for TCP on non unix systems
+ pub tcp_port: u64,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub(crate) struct Search {
+ /// The list of enabled filter modes, in order of priority.
+ pub(crate) filters: Vec<FilterMode>,
+
+ /// The recency score multiplier for the search index (default: 1.0).
+ /// Values < 1.0 reduce weight, > 1.0 increase weight, 0.0 disables.
+ pub(crate) recency_score_multiplier: f64,
+
+ /// The frequency score multiplier for the search index (default: 1.0).
+ /// Values < 1.0 reduce weight, > 1.0 increase weight, 0.0 disables.
+ pub(crate) frequency_score_multiplier: f64,
+
+ /// The overall frecency score multiplier for the search index (default: 1.0).
+ /// Applied after combining recency and frequency scores.
+ pub(crate) frecency_score_multiplier: f64,
+}
+
+/// Log level for file logging. Maps to tracing's [`LevelFilter`].
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub(crate) enum LogLevel {
+ Trace,
+ Debug,
+ #[default]
+ Info,
+ Warn,
+ Error,
+}
+
+impl LogLevel {
+ /// Convert to a tracing directive string for use with [`EnvFilter`].
+ pub(crate) fn as_directive(self) -> &'static str {
+ match self {
+ Self::Trace => "trace",
+ Self::Debug => "debug",
+ Self::Info => "info",
+ Self::Warn => "warn",
+ Self::Error => "error",
+ }
+ }
+}
+
+/// Configuration for a specific log type (search or daemon).
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+pub(crate) struct LogConfig {
+ /// Log file name (relative to dir) or absolute path.
+ pub(crate) file: String,
+
+ /// Override global enabled setting for this log type.
+ pub(crate) enabled: Option<bool>,
+
+ /// Override global level setting for this log type.
+ pub(crate) level: Option<LogLevel>,
+
+ /// Override global retention days setting for this log type.
+ pub(crate) retention: Option<u64>,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub(crate) struct Logs {
+ /// Enable file logging globally. Defaults to true.
+ #[serde(default = "Logs::default_enabled")]
+ pub(crate) enabled: bool,
+
+ /// Directory for log files. Defaults to ~/.atuin/logs
+ pub(crate) dir: String,
+
+ /// Default log level for file logging. Defaults to "info".
+ /// Note: [`ATUIN_LOG`] environment variable overrides this.
+ #[serde(default)]
+ pub(crate) level: LogLevel,
+
+ /// Default retention days for log files. Defaults to 4.
+ #[serde(default = "Logs::default_retention")]
+ pub(crate) retention: u64,
+
+ /// Search log settings
+ #[serde(default)]
+ pub(crate) search: LogConfig,
+
+ /// Daemon log settings
+ #[serde(default)]
+ pub(crate) daemon: LogConfig,
+}
+
+impl Default for Preview {
+ fn default() -> Self {
+ Self {
+ strategy: PreviewStrategy::Auto,
+ }
+ }
+}
+
+impl Default for Daemon {
+ fn default() -> Self {
+ Self {
+ sync_frequency: 300,
+ socket_path: String::new(),
+ pidfile_path: String::new(),
+ systemd_socket: false,
+ tcp_port: 8889,
+ }
+ }
+}
+
+impl Default for Logs {
+ fn default() -> Self {
+ Self {
+ enabled: true,
+ dir: String::new(),
+ level: LogLevel::default(),
+ retention: Self::default_retention(),
+ search: LogConfig {
+ file: "search.log".to_string(),
+ ..Default::default()
+ },
+ daemon: LogConfig {
+ file: "daemon.log".to_string(),
+ ..Default::default()
+ },
+ }
+ }
+}
+
+impl Logs {
+ fn default_enabled() -> bool {
+ true
+ }
+
+ fn default_retention() -> u64 {
+ 4
+ }
+
+ /// Returns whether search logging is enabled.
+ /// Uses search-specific setting if set, otherwise falls back to global.
+ pub(crate) fn search_enabled(&self) -> bool {
+ self.search.enabled.unwrap_or(self.enabled)
+ }
+
+ /// Returns whether daemon logging is enabled.
+ /// Uses daemon-specific setting if set, otherwise falls back to global.
+ pub(crate) fn daemon_enabled(&self) -> bool {
+ self.daemon.enabled.unwrap_or(self.enabled)
+ }
+
+ /// Returns the log level for search logging.
+ /// Uses search-specific setting if set, otherwise falls back to global.
+ pub(crate) fn search_level(&self) -> LogLevel {
+ self.search.level.unwrap_or(self.level)
+ }
+
+ /// Returns the log level for daemon logging.
+ /// Uses daemon-specific setting if set, otherwise falls back to global.
+ pub(crate) fn daemon_level(&self) -> LogLevel {
+ self.daemon.level.unwrap_or(self.level)
+ }
+
+ /// Returns the retention days for search logging.
+ /// Uses search-specific setting if set, otherwise falls back to global.
+ pub(crate) fn search_retention(&self) -> u64 {
+ self.search.retention.unwrap_or(self.retention)
+ }
+
+ /// Returns the retention days for daemon logging.
+ /// Uses daemon-specific setting if set, otherwise falls back to global.
+ pub(crate) fn daemon_retention(&self) -> u64 {
+ self.daemon.retention.unwrap_or(self.retention)
+ }
+}
+
+impl Default for Search {
+ fn default() -> Self {
+ Self {
+ filters: vec![
+ FilterMode::Global,
+ FilterMode::Host,
+ FilterMode::Session,
+ FilterMode::SessionPreload,
+ FilterMode::Workspace,
+ FilterMode::Directory,
+ ],
+
+ recency_score_multiplier: 1.0,
+ frequency_score_multiplier: 1.0,
+ frecency_score_multiplier: 1.0,
+ }
+ }
+}
+
+// The preview height strategy also takes max_preview_height into account.
+#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
+pub(crate) enum PreviewStrategy {
+ // Preview height is calculated for the length of the selected command.
+ #[serde(rename = "auto")]
+ Auto,
+
+ // Preview height is calculated for the length of the longest command stored in the history.
+ #[serde(rename = "static")]
+ Static,
+
+ // max_preview_height is used as fixed height.
+ #[serde(rename = "fixed")]
+ Fixed,
+}
+
+/// Column types available for the interactive search UI.
+#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub(crate) enum UiColumnType {
+ /// Command execution duration (e.g., "123ms")
+ Duration,
+ /// Relative time since execution (e.g., "59s ago")
+ Time,
+ /// Absolute timestamp (e.g., "2025-01-22 14:35")
+ Datetime,
+ /// Working directory
+ Directory,
+ /// Hostname
+ Host,
+ /// Username
+ User,
+ /// Exit code
+ Exit,
+ /// The command itself (should be last, expands to fill)
+ Command,
+}
+
+impl UiColumnType {
+ /// Returns the default width for this column type (in characters).
+ /// The Command column returns 0 as it expands to fill remaining space.
+ pub(crate) fn default_width(self) -> u16 {
+ match self {
+ Self::Duration => 5, // "814ms"
+ Self::Time => 9, // "459ms ago"
+ Self::Datetime => 16, // "2025-01-22 14:35"
+ Self::Directory => 20,
+ Self::Host => 15,
+ Self::User => 10,
+ Self::Exit => {
+ if cfg!(windows) {
+ 11 // 32-bit integer on Windows: "-1978335212"
+ } else {
+ 3 // Usually a byte on Unix
+ }
+ }
+ Self::Command => 0, // Expands to fill
+ }
+ }
+}
+
+/// A column configuration with type and optional custom width.
+/// Can be specified as just a string (uses default width) or as an object with type and width.
+#[derive(Clone, Debug, Serialize)]
+pub(crate) struct UiColumn {
+ pub(crate) column_type: UiColumnType,
+ pub(crate) width: u16,
+ /// If true, this column expands to fill remaining space. Only one column should expand.
+ pub(crate) expand: bool,
+}
+
+impl UiColumn {
+ pub(crate) fn new(column_type: UiColumnType) -> Self {
+ Self {
+ width: column_type.default_width(),
+ expand: column_type == UiColumnType::Command,
+ column_type,
+ }
+ }
+}
+
+// Custom deserialize to handle both string and object formats:
+// "duration" or { type = "duration", width = 8, expand = true }
+impl<'de> Deserialize<'de> for UiColumn {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ use serde::de::{self, MapAccess, Visitor};
+
+ struct UiColumnVisitor;
+
+ impl<'de> Visitor<'de> for UiColumnVisitor {
+ type Value = UiColumn;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(
+ "a column type string or an object with 'type' and optional 'width'/'expand'",
+ )
+ }
+
+ fn visit_str<E>(self, value: &str) -> Result<UiColumn, E>
+ where
+ E: de::Error,
+ {
+ let column_type: UiColumnType =
+ Deserialize::deserialize(de::value::StrDeserializer::new(value))?;
+ Ok(UiColumn::new(column_type))
+ }
+
+ fn visit_map<M>(self, mut map: M) -> Result<UiColumn, M::Error>
+ where
+ M: MapAccess<'de>,
+ {
+ let mut column_type: Option<UiColumnType> = None;
+ let mut width: Option<u16> = None;
+ let mut expand: Option<bool> = None;
+
+ while let Some(key) = map.next_key::<String>()? {
+ match key.as_str() {
+ "type" => {
+ column_type = Some(map.next_value()?);
+ }
+ "width" => {
+ width = Some(map.next_value()?);
+ }
+ "expand" => {
+ expand = Some(map.next_value()?);
+ }
+ _ => {
+ let _: de::IgnoredAny = map.next_value()?;
+ }
+ }
+ }
+
+ let column_type = column_type.ok_or_else(|| de::Error::missing_field("type"))?;
+ let width = width.unwrap_or_else(|| column_type.default_width());
+ let expand = expand.unwrap_or(column_type == UiColumnType::Command);
+ Ok(UiColumn {
+ column_type,
+ width,
+ expand,
+ })
+ }
+ }
+
+ deserializer.deserialize_any(UiColumnVisitor)
+ }
+}
+
+/// UI-specific settings for the interactive search.
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub(crate) struct Ui {
+ /// Columns to display in interactive search, from left to right.
+ /// The indicator column (" > ") is always shown first implicitly.
+ /// The "command" column should be last as it expands to fill remaining space.
+ /// Can be simple strings or objects with type and width.
+ #[serde(default = "Ui::default_columns")]
+ pub(crate) columns: Vec<UiColumn>,
+}
+
+impl Ui {
+ fn default_columns() -> Vec<UiColumn> {
+ vec![
+ UiColumn::new(UiColumnType::Duration),
+ UiColumn::new(UiColumnType::Time),
+ UiColumn::new(UiColumnType::Command),
+ ]
+ }
+
+ /// Validate the UI configuration.
+ /// Returns an error if more than one column has expand = true.
+ pub(crate) fn validate(&self) -> Result<()> {
+ let expand_count = self.columns.iter().filter(|c| c.expand).count();
+ if expand_count > 1 {
+ bail!(
+ "Only one column can have expand = true, but {} columns are set to expand",
+ expand_count
+ );
+ }
+ Ok(())
+ }
+}
+
+impl Default for Ui {
+ fn default() -> Self {
+ Self {
+ columns: Self::default_columns(),
+ }
+ }
+}
+
+/// Sync-specific settings.
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+pub(crate) struct Sync {
+ /// The sync address for atuin.
+ pub(crate) address: String,
+
+ #[serde(default)]
+ pub(crate) frequency: String,
+
+ #[serde(default)]
+ pub(crate) auto: bool,
+
+ #[serde(default)]
+ pub(crate) user_id_path: Option<PathBuf>,
+
+ #[serde(default)]
+ pub(crate) encryption_key_path: Option<PathBuf>,
+}
+
+impl Sync {
+ fn try_read_file(file: Option<&PathBuf>) -> Result<Option<String>> {
+ if let Some(path) = file {
+ if path.try_exists()? {
+ let user = read_to_string(path)?;
+
+ if user.is_empty() {
+ Ok(None)
+ } else {
+ Ok(Some(user))
+ }
+ } else {
+ // It's okay that the file doesn't exist.
+ // The important part is to error out if we can't access it (e.g. Because of missing
+ // permissions).
+ Ok(None)
+ }
+ } else {
+ Ok(None)
+ }
+ }
+
+ pub(crate) fn have_sync_user(&self) -> Result<bool> {
+ let sa = self.user_id()?;
+ Ok(sa.is_some())
+ }
+
+ pub(crate) fn user_id(&self) -> Result<Option<Uuid>> {
+ Self::try_read_file(self.user_id_path.as_ref())?
+ .map(|file| {
+ Uuid::parse_str(file.trim()).context(
+ "Failed to decode user id as UUID, while trying to decode sync user_id",
+ )
+ })
+ .transpose()
+ }
+ pub(crate) fn encryption_key(&self) -> Result<Option<Key>> {
+ Self::try_read_file(self.encryption_key_path.as_ref())?
+ .as_deref()
+ .map(str::trim)
+ .map(decode_key)
+ .transpose()
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[expect(clippy::struct_excessive_bools)]
+pub struct Settings {
+ pub(crate) data_dir: Option<String>,
+ pub(crate) dialect: Dialect,
+ pub(crate) timezone: Timezone,
+ pub(crate) style: Style,
+
+ pub db_path: String,
+ pub record_store_path: String,
+ pub(crate) search_mode: SearchMode,
+ pub(crate) filter_mode: Option<FilterMode>,
+ pub(crate) filter_mode_shell_up_key_binding: Option<FilterMode>,
+ pub(crate) search_mode_shell_up_key_binding: Option<SearchMode>,
+ pub(crate) shell_up_key_binding: bool,
+ pub(crate) inline_height: u16,
+ pub(crate) inline_height_shell_up_key_binding: Option<u16>,
+ pub(crate) invert: bool,
+ pub(crate) show_preview: bool,
+ pub(crate) max_preview_height: u16,
+ pub(crate) show_help: bool,
+ pub(crate) show_tabs: bool,
+ pub(crate) show_numeric_shortcuts: bool,
+ pub(crate) auto_hide_height: u16,
+ pub(crate) exit_mode: ExitMode,
+ pub(crate) keymap_mode: KeymapMode,
+ pub(crate) keymap_mode_shell: KeymapMode,
+ pub(crate) keymap_cursor: HashMap<String, CursorStyle>,
+ pub(crate) word_jump_mode: WordJumpMode,
+ pub(crate) word_chars: String,
+ pub(crate) scroll_context_lines: usize,
+ pub(crate) history_format: String,
+ pub(crate) strip_trailing_whitespace: bool,
+ pub(crate) prefers_reduced_motion: bool,
+ pub(crate) store_failed: bool,
+ pub(crate) no_mouse: bool,
+
+ #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)]
+ pub(crate) history_filter: RegexSet,
+
+ #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)]
+ pub(crate) cwd_filter: RegexSet,
+
+ pub(crate) secrets_filter: bool,
+ pub(crate) workspaces: bool,
+ pub(crate) ctrl_n_shortcuts: bool,
+
+ pub(crate) network_connect_timeout: u64,
+ pub(crate) network_timeout: u64,
+ pub local_timeout: f64,
+ pub(crate) enter_accept: bool,
+ pub(crate) smart_sort: bool,
+ pub(crate) command_chaining: bool,
+
+ #[serde(default)]
+ pub(crate) sync: Sync,
+
+ #[serde(default)]
+ pub(crate) stats: Stats,
+
+ #[serde(default)]
+ pub(crate) keys: Keys,
+
+ #[serde(default)]
+ pub(crate) keymap: KeymapConfig,
+
+ #[serde(default)]
+ pub(crate) preview: Preview,
+
+ #[serde(default)]
+ pub daemon: Daemon,
+
+ #[serde(default)]
+ pub(crate) search: Search,
+
+ #[serde(default)]
+ pub(crate) ui: Ui,
+
+ #[serde(default)]
+ pub(crate) logs: Logs,
+
+ #[serde(default)]
+ pub(crate) meta: meta::Settings,
+}
+
+impl Settings {
+ // -- Meta store: lazily initialized on first access --
+
+ pub(crate) async fn meta_store() -> Result<&'static crate::aclient::meta::MetaStore> {
+ META_STORE
+ .get_or_try_init(|| async {
+ let (db_path, timeout) = META_CONFIG.get().ok_or_else(|| {
+ eyre!("meta store config not set — Settings::new() has not been called")
+ })?;
+ crate::aclient::meta::MetaStore::new(db_path, *timeout).await
+ })
+ .await
+ }
+
+ pub(crate) async fn host_id() -> Result<HostId> {
+ Self::meta_store().await?.host_id().await
+ }
+
+ pub(crate) async fn last_sync() -> Result<OffsetDateTime> {
+ Self::meta_store().await?.last_sync().await
+ }
+
+ pub(crate) async fn save_sync_time() -> Result<()> {
+ Self::meta_store().await?.save_sync_time().await
+ }
+
+ pub(crate) fn default_filter_mode(&self, git_root: bool) -> FilterMode {
+ self.filter_mode
+ .filter(|x| self.search.filters.contains(x))
+ .or_else(|| {
+ self.search
+ .filters
+ .iter()
+ .find(|x| match (x, git_root, self.workspaces) {
+ (FilterMode::Workspace, true, true) => true,
+ (FilterMode::Workspace, _, _) => false,
+ (_, _, _) => true,
+ })
+ .copied()
+ })
+ .unwrap_or(FilterMode::Global)
+ }
+
+ pub(crate) fn builder() -> Result<ConfigBuilder<DefaultState>> {
+ Self::builder_with_data_dir(&utils::data_dir())
+ }
+
+ #[expect(clippy::too_many_lines)]
+ fn builder_with_data_dir(data_dir: &std::path::Path) -> Result<ConfigBuilder<DefaultState>> {
+ let db_path = data_dir.join("history.db");
+ let record_store_path = data_dir.join("records.db");
+ let kv_path = data_dir.join("kv.db");
+ let scripts_path = data_dir.join("scripts.db");
+ let ai_sessions_path = data_dir.join("ai_sessions.db");
+ let socket_path = utils::runtime_dir().join("atuin.sock");
+ let pidfile_path = data_dir.join("atuin-daemon.pid");
+ let logs_dir = utils::logs_dir();
+
+ let key_path = data_dir.join("key");
+ let meta_path = data_dir.join("meta.db");
+
+ Ok(Config::builder()
+ .set_default("history_format", "{time}\t{command}\t{duration}")?
+ .set_default("db_path", db_path.to_str())?
+ .set_default("record_store_path", record_store_path.to_str())?
+ .set_default("key_path", key_path.to_str())?
+ .set_default("dialect", "us")?
+ .set_default("timezone", "local")?
+ .set_default("auto_sync", true)?
+ .set_default("sync.address", "https://api.atuin.sh")?
+ .set_default("sync_frequency", "5m")?
+ .set_default("search_mode", "fuzzy")?
+ .set_default("filter_mode", None::<String>)?
+ .set_default("style", "compact")?
+ .set_default("inline_height", 40)?
+ .set_default("show_preview", true)?
+ .set_default("preview.strategy", "auto")?
+ .set_default("max_preview_height", 4)?
+ .set_default("show_help", true)?
+ .set_default("show_tabs", true)?
+ .set_default("show_numeric_shortcuts", true)?
+ .set_default("auto_hide_height", 8)?
+ .set_default("invert", false)?
+ .set_default("exit_mode", "return-original")?
+ .set_default("word_jump_mode", "emacs")?
+ .set_default(
+ "word_chars",
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
+ )?
+ .set_default("scroll_context_lines", 1)?
+ .set_default("shell_up_key_binding", false)?
+ .set_default("workspaces", false)?
+ .set_default("ctrl_n_shortcuts", false)?
+ .set_default("secrets_filter", true)?
+ .set_default("strip_trailing_whitespace", true)?
+ .set_default("network_connect_timeout", 5)?
+ .set_default("network_timeout", 30)?
+ .set_default("local_timeout", 2.0)?
+ // enter_accept defaults to false here, but true in the default config file. The dissonance is
+ // intentional!
+ // Existing users will get the default "False", so we don't mess with any potential
+ // muscle memory.
+ // New users will get the new default, that is more similar to what they are used to.
+ .set_default("enter_accept", false)?
+ .set_default("keys.scroll_exits", true)?
+ .set_default("keys.accept_past_line_end", true)?
+ .set_default("keys.exit_past_line_start", true)?
+ .set_default("keys.accept_past_line_start", false)?
+ .set_default("keys.accept_with_backspace", false)?
+ .set_default("keys.prefix", "a")?
+ .set_default("keymap_mode", "emacs")?
+ .set_default("keymap_mode_shell", "auto")?
+ .set_default("keymap_cursor", HashMap::<String, String>::new())?
+ .set_default("smart_sort", false)?
+ .set_default("command_chaining", false)?
+ .set_default("store_failed", true)?
+ .set_default("daemon.sync_frequency", 300)?
+ .set_default("daemon.socket_path", socket_path.to_str())?
+ .set_default("daemon.pidfile_path", pidfile_path.to_str())?
+ .set_default("daemon.systemd_socket", false)?
+ .set_default("daemon.tcp_port", 8889)?
+ .set_default("logs.enabled", true)?
+ .set_default("logs.dir", logs_dir.to_str())?
+ .set_default("logs.level", "info")?
+ .set_default("logs.search.file", "search.log")?
+ .set_default("logs.daemon.file", "daemon.log")?
+ .set_default("logs.ai.file", "ai.log")?
+ .set_default("kv.db_path", kv_path.to_str())?
+ .set_default("scripts.db_path", scripts_path.to_str())?
+ .set_default("search.recency_score_multiplier", 1.0)?
+ .set_default("search.frequency_score_multiplier", 1.0)?
+ .set_default("search.frecency_score_multiplier", 1.0)?
+ .set_default("meta.db_path", meta_path.to_str())?
+ .set_default("ai.db_path", ai_sessions_path.to_str())?
+ .set_default("ai.session_continue_minutes", 60)?
+ .set_default("ai.send_cwd", false)?
+ .set_default("ai.opening.send_cwd", false)?
+ .set_default("ai.opening.send_last_command", false)?
+ .set_default(
+ "search.filters",
+ vec![
+ "global",
+ "host",
+ "session",
+ "workspace",
+ "directory",
+ "session-preload",
+ ],
+ )?
+ .set_default("theme.name", "default")?
+ .set_default("theme.debug", None::<bool>)?
+ .set_default("tmux.enabled", false)?
+ .set_default("tmux.width", "80%")?
+ .set_default("tmux.height", "60%")?
+ .set_default(
+ "prefers_reduced_motion",
+ std::env::var("NO_MOTION").ok().map_or_else(
+ || config::Value::new(None, config::ValueKind::Boolean(false)),
+ |_| config::Value::new(None, config::ValueKind::Boolean(true)),
+ ),
+ )?
+ .set_default("no_mouse", false)?
+ .add_source(
+ Environment::with_prefix("atuin")
+ .prefix_separator("_")
+ .separator("__"),
+ ))
+ }
+
+ pub(crate) fn get_config_path() -> Result<PathBuf> {
+ let config_dir = utils::config_dir();
+
+ create_dir_all(&config_dir)
+ .wrap_err_with(|| format!("could not create dir {}", config_dir.display()))?;
+
+ let mut config_file = std::env::var("ATUIN_CONFIG_DIR").map_or_else(
+ |_| {
+ let mut config_file = PathBuf::new();
+ config_file.push(config_dir);
+ config_file
+ },
+ PathBuf::from,
+ );
+
+ config_file.push("config.toml");
+
+ Ok(config_file)
+ }
+
+ /// Build a merged `Config` from defaults, config file, and environment.
+ ///
+ /// This resolves `data_dir`, initializes the data directory on disk,
+ /// and layers defaults → config file → env overrides. Both `new()` and
+ /// `get_config_value()` use this so the resolution logic lives in one place.
+ fn build_config() -> Result<Config> {
+ let config_file = Self::get_config_path()?;
+
+ // extract data_dir first so we can use it as the base for other path defaults
+ let effective_data_dir = if config_file.exists() {
+ #[derive(Deserialize, Default)]
+ struct DataDirOnly {
+ data_dir: Option<String>,
+ }
+
+ let config_file_str = config_file
+ .to_str()
+ .ok_or_else(|| eyre!("config file path is not valid UTF-8"))?;
+
+ let partial_config = Config::builder()
+ .add_source(ConfigFile::new(config_file_str, FileFormat::Toml))
+ .add_source(
+ Environment::with_prefix("atuin")
+ .prefix_separator("_")
+ .separator("__"),
+ )
+ .build()
+ .ok();
+
+ let custom_data_dir = partial_config
+ .and_then(|c| c.try_deserialize::<DataDirOnly>().ok())
+ .and_then(|d| d.data_dir);
+
+ match custom_data_dir {
+ Some(dir) => {
+ let expanded = shellexpand::full(&dir)
+ .map_err(|e| eyre!("failed to expand data_dir path: {}", e))?;
+ PathBuf::from(expanded.as_ref())
+ }
+ None => utils::data_dir(),
+ }
+ } else {
+ utils::data_dir()
+ };
+
+ DATA_DIR.set(effective_data_dir.clone()).ok();
+
+ create_dir_all(&effective_data_dir)
+ .wrap_err_with(|| format!("could not create dir {}", effective_data_dir.display()))?;
+
+ let mut config_builder = Self::builder_with_data_dir(&effective_data_dir)?;
+
+ config_builder = if config_file.exists() {
+ let config_file_str = config_file
+ .to_str()
+ .ok_or_else(|| eyre!("config file path is not valid UTF-8"))?;
+ config_builder.add_source(ConfigFile::new(config_file_str, FileFormat::Toml))
+ } else {
+ // TODO(@bpeetz): Rework the config handling, so that we can actually auto-write a
+ // file with defaults. <2026-06-13>
+ create_dir_all(config_file.parent().unwrap())?;
+
+ info!(
+ "No config file at: `{}`. Not adding one.",
+ config_file.display()
+ );
+
+ config_builder
+ };
+
+ // all paths should be expanded
+ let built = config_builder.build_cloned()?;
+ config_builder = [
+ "db_path",
+ "record_store_path",
+ "key_path",
+ "daemon.socket_path",
+ "daemon.pidfile_path",
+ "logs.dir",
+ "logs.search.file",
+ "logs.daemon.file",
+ ]
+ .iter()
+ .map(|key| (key, built.get_string(key).unwrap_or_default()))
+ .filter_map(|(key, value)| match Self::expand_path(&value) {
+ Ok(expanded) => Some((key, expanded)),
+ Err(e) => {
+ log::warn!("failed to expand path for {key}: {e}");
+ None
+ }
+ })
+ .fold(config_builder, |builder, (key, value)| {
+ builder
+ .set_override(key, value)
+ .unwrap_or_else(|_| panic!("failed to set absolute path override for {key}"))
+ });
+
+ config_builder.build().map_err(Into::into)
+ }
+
+ /// Look up a single config value by dotted key (e.g. `"daemon.sync_frequency"`).
+ ///
+ /// Returns the effective value after merging defaults, config file, and
+ /// environment — without the side-effects of full `Settings` construction
+ /// (meta store init, path expansion, etc.).
+ pub(crate) fn get_config_value(key: &str) -> Result<String> {
+ let config = Self::build_config()?;
+ let value: config::Value = config
+ .get(key)
+ .map_err(|e| eyre!("failed to get config value '{}': {}", key, e))?;
+ Ok(Self::format_resolved_value(&value, key))
+ }
+
+ fn format_resolved_value(value: &config::Value, prefix: &str) -> String {
+ use config::ValueKind;
+
+ match &value.kind {
+ ValueKind::Nil => String::new(),
+ ValueKind::Boolean(b) => b.to_string(),
+ ValueKind::I64(i) => i.to_string(),
+ ValueKind::I128(i) => i.to_string(),
+ ValueKind::U64(u) => u.to_string(),
+ ValueKind::U128(u) => u.to_string(),
+ ValueKind::Float(f) => f.to_string(),
+ ValueKind::String(s) => s.clone(),
+ ValueKind::Array(arr) => {
+ let items: Vec<String> = arr
+ .iter()
+ .map(|v| Self::format_resolved_value(v, ""))
+ .collect();
+ format!("[{}]", items.join(", "))
+ }
+ ValueKind::Table(map) => {
+ let mut lines = Vec::new();
+ let mut keys: Vec<_> = map.keys().collect();
+ keys.sort();
+
+ for k in keys {
+ let v = &map[k];
+ let full_key = if prefix.is_empty() {
+ k.clone()
+ } else {
+ format!("{prefix}.{k}")
+ };
+
+ match &v.kind {
+ ValueKind::Table(_) => {
+ lines.push(Self::format_resolved_value(v, &full_key));
+ }
+ _ => {
+ lines.push(format!(
+ "{} = {}",
+ full_key,
+ Self::format_resolved_value(v, "")
+ ));
+ }
+ }
+ }
+
+ lines.join("\n")
+ }
+ }
+ }
+
+ pub fn new() -> Result<Self> {
+ let config = Self::build_config()?;
+ let settings: Self = config
+ .try_deserialize()
+ .map_err(|e| eyre!("failed to deserialize: {}", e))?;
+
+ // Validate UI settings
+ settings.ui.validate()?;
+
+ // Register meta store config for lazy initialization on first access
+ META_CONFIG
+ .set((settings.meta.db_path.clone(), settings.local_timeout))
+ .ok();
+
+ Ok(settings)
+ }
+
+ fn expand_path(path: &str) -> Result<String> {
+ shellexpand::full(&path)
+ .map(|p| p.to_string())
+ .map_err(|e| eyre!("failed to expand path: {}", e))
+ }
+
+ pub(crate) fn paths_ok(&self) -> bool {
+ // TODO(@bpeetz): Add the `sync.*` paths <2026-06-11>
+ let paths = [&self.db_path, &self.record_store_path, &self.meta.db_path];
+ paths.iter().all(|p| !utils::broken_symlink(p))
+ }
+}
+
+impl Default for Settings {
+ fn default() -> Self {
+ // if this panics something is very wrong, as the default config
+ // does not build or deserialize into the settings struct
+ Self::builder()
+ .expect("Could not build default")
+ .build()
+ .expect("Could not build config")
+ .try_deserialize()
+ .expect("Could not deserialize config")
+ }
+}
+
+#[cfg(test)]
+pub(crate) fn test_local_timeout() -> f64 {
+ std::env::var("ATUIN_TEST_LOCAL_TIMEOUT")
+ .ok()
+ .and_then(|x| x.parse().ok())
+ // this hardcoded value should be replaced by a simple way to get the
+ // default local_timeout of Settings if possible
+ .unwrap_or(2.0)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::str::FromStr;
+
+ use eyre::Result;
+
+ use super::Timezone;
+
+ #[test]
+ fn can_parse_offset_timezone_spec() -> Result<()> {
+ assert_eq!(Timezone::from_str("+02")?.0.as_hms(), (2, 0, 0));
+ assert_eq!(Timezone::from_str("-04")?.0.as_hms(), (-4, 0, 0));
+ assert_eq!(Timezone::from_str("+05:30")?.0.as_hms(), (5, 30, 0));
+ assert_eq!(Timezone::from_str("-09:30")?.0.as_hms(), (-9, -30, 0));
+
+ // single digit hours are allowed
+ assert_eq!(Timezone::from_str("+2")?.0.as_hms(), (2, 0, 0));
+ assert_eq!(Timezone::from_str("-4")?.0.as_hms(), (-4, 0, 0));
+ assert_eq!(Timezone::from_str("+5:30")?.0.as_hms(), (5, 30, 0));
+ assert_eq!(Timezone::from_str("-9:30")?.0.as_hms(), (-9, -30, 0));
+
+ // fully qualified form
+ assert_eq!(Timezone::from_str("+09:30:00")?.0.as_hms(), (9, 30, 0));
+ assert_eq!(Timezone::from_str("-09:30:00")?.0.as_hms(), (-9, -30, 0));
+
+ // these offsets don't really exist but are supported anyway
+ assert_eq!(Timezone::from_str("+0:5")?.0.as_hms(), (0, 5, 0));
+ assert_eq!(Timezone::from_str("-0:5")?.0.as_hms(), (0, -5, 0));
+ assert_eq!(Timezone::from_str("+01:23:45")?.0.as_hms(), (1, 23, 45));
+ assert_eq!(Timezone::from_str("-01:23:45")?.0.as_hms(), (-1, -23, -45));
+
+ // require a leading sign for clarity
+ assert!(Timezone::from_str("5").is_err());
+ assert!(Timezone::from_str("10:30").is_err());
+
+ Ok(())
+ }
+
+ #[test]
+ fn can_choose_workspace_filters_when_in_git_context() -> Result<()> {
+ let mut settings = super::Settings::default();
+ settings.search.filters = vec![
+ super::FilterMode::Workspace,
+ super::FilterMode::Host,
+ super::FilterMode::Directory,
+ super::FilterMode::Session,
+ super::FilterMode::Global,
+ ];
+ settings.workspaces = true;
+
+ assert_eq!(
+ settings.default_filter_mode(true),
+ super::FilterMode::Workspace,
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn wont_choose_workspace_filters_when_not_in_git_context() -> Result<()> {
+ let mut settings = super::Settings::default();
+ settings.search.filters = vec![
+ super::FilterMode::Workspace,
+ super::FilterMode::Host,
+ super::FilterMode::Directory,
+ super::FilterMode::Session,
+ super::FilterMode::Global,
+ ];
+ settings.workspaces = true;
+
+ assert_eq!(settings.default_filter_mode(false), super::FilterMode::Host,);
+
+ Ok(())
+ }
+
+ #[test]
+ fn wont_choose_workspace_filters_when_workspaces_disabled() -> Result<()> {
+ let mut settings = super::Settings::default();
+ settings.search.filters = vec![
+ super::FilterMode::Workspace,
+ super::FilterMode::Host,
+ super::FilterMode::Directory,
+ super::FilterMode::Session,
+ super::FilterMode::Global,
+ ];
+ settings.workspaces = false;
+
+ assert_eq!(settings.default_filter_mode(true), super::FilterMode::Host,);
+
+ Ok(())
+ }
+
+ #[test]
+ fn builder_with_data_dir_uses_custom_paths() -> Result<()> {
+ use std::path::PathBuf;
+
+ let custom_dir = PathBuf::from("/custom/data/dir");
+ let builder = super::Settings::builder_with_data_dir(&custom_dir)?;
+ let config = builder.build()?;
+
+ let db_path: String = config.get("db_path")?;
+ let key_path: String = config.get("key_path")?;
+ let record_store_path: String = config.get("record_store_path")?;
+ let kv_db_path: String = config.get("kv.db_path")?;
+ let scripts_db_path: String = config.get("scripts.db_path")?;
+ let meta_db_path: String = config.get("meta.db_path")?;
+ let daemon_socket_path: String = config.get("daemon.socket_path")?;
+ let daemon_pidfile_path: String = config.get("daemon.pidfile_path")?;
+
+ assert_eq!(db_path, custom_dir.join("history.db").to_str().unwrap());
+ assert_eq!(key_path, custom_dir.join("key").to_str().unwrap());
+ assert_eq!(
+ record_store_path,
+ custom_dir.join("records.db").to_str().unwrap()
+ );
+ assert_eq!(kv_db_path, custom_dir.join("kv.db").to_str().unwrap());
+ assert_eq!(
+ scripts_db_path,
+ custom_dir.join("scripts.db").to_str().unwrap()
+ );
+ assert_eq!(meta_db_path, custom_dir.join("meta.db").to_str().unwrap());
+ assert_eq!(
+ daemon_socket_path,
+ turtle_common::utils::runtime_dir()
+ .join("atuin.sock")
+ .to_str()
+ .unwrap()
+ );
+ assert_eq!(
+ daemon_pidfile_path,
+ custom_dir.join("atuin-daemon.pid").to_str().unwrap()
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn keymap_config_deserializes_simple_binding() {
+ let json = r#"{"emacs": {"ctrl-c": "exit"}}"#;
+ let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
+ assert_eq!(config.emacs.len(), 1);
+ match &config.emacs["ctrl-c"] {
+ super::KeyBindingConfig::Simple(s) => assert_eq!(s, "exit"),
+ _ => panic!("expected Simple variant"),
+ }
+ }
+
+ #[test]
+ fn keymap_config_deserializes_conditional_binding() {
+ let json = r#"{
+ "emacs": {
+ "left": [
+ {"when": "cursor-at-start", "action": "exit"},
+ {"action": "cursor-left"}
+ ]
+ }
+ }"#;
+ let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
+ match &config.emacs["left"] {
+ super::KeyBindingConfig::Rules(rules) => {
+ assert_eq!(rules.len(), 2);
+ assert_eq!(rules[0].when.as_deref(), Some("cursor-at-start"));
+ assert_eq!(rules[0].action, "exit");
+ assert!(rules[1].when.is_none());
+ assert_eq!(rules[1].action, "cursor-left");
+ }
+ _ => panic!("expected Rules variant"),
+ }
+ }
+
+ #[test]
+ fn keymap_config_deserializes_vim_normal() {
+ let json = r#"{"vim-normal": {"j": "select-next", "k": "select-previous"}}"#;
+ let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
+ assert_eq!(config.vim_normal.len(), 2);
+ assert!(config.emacs.is_empty());
+ }
+
+ #[test]
+ fn keymap_config_is_empty_when_default() {
+ let config = super::KeymapConfig::default();
+ assert!(config.is_empty());
+ }
+
+ #[test]
+ fn keymap_config_mixed_modes() {
+ let json = r#"{
+ "emacs": {"ctrl-c": "exit"},
+ "vim-normal": {"q": "exit"},
+ "inspector": {"d": "delete"}
+ }"#;
+ let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
+ assert!(!config.is_empty());
+ assert_eq!(config.emacs.len(), 1);
+ assert_eq!(config.vim_normal.len(), 1);
+ assert_eq!(config.inspector.len(), 1);
+ assert!(config.vim_insert.is_empty());
+ assert!(config.prefix.is_empty());
+ }
+}
diff --git a/crates/daemon/src/aclient/settings/watcher.rs b/crates/daemon/src/aclient/settings/watcher.rs
new file mode 100644
index 00000000..01d20855
--- /dev/null
+++ b/crates/daemon/src/aclient/settings/watcher.rs
@@ -0,0 +1,260 @@
+//! Config file watching for automatic settings reload.
+//!
+//! This module provides a `SettingsWatcher` that monitors the config file
+//! for changes and broadcasts updated settings via a `tokio::sync::watch` channel.
+//!
+//! # Example
+//!
+//! ```no_run
+//! use crate::aclient::settings::watcher::global_settings_watcher;
+//!
+//! async fn example() -> eyre::Result<()> {
+//! let watcher = global_settings_watcher()?;
+//! let mut rx = watcher.subscribe();
+//!
+//! // React to settings changes
+//! while rx.changed().await.is_ok() {
+//! let settings = rx.borrow();
+//! println!("Settings updated!");
+//! }
+//! Ok(())
+//! }
+//! ```
+
+use std::{
+ path::{Path, PathBuf},
+ sync::{Arc, OnceLock},
+ time::Duration,
+};
+
+use eyre::{Result, WrapErr};
+use log::{debug, error, info, warn};
+use notify::{
+ Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher,
+ event::{EventKind, ModifyKind},
+};
+use tokio::sync::watch;
+
+use super::Settings;
+
+/// Global singleton for the settings watcher.
+static SETTINGS_WATCHER: OnceLock<Result<SettingsWatcher, String>> = OnceLock::new();
+
+/// Get the global settings watcher singleton.
+///
+/// Initializes the watcher on first call. Subsequent calls return the same instance.
+/// The watcher monitors the config file for changes and broadcasts updates.
+pub(crate) fn global_settings_watcher() -> Result<&'static SettingsWatcher> {
+ let result = SETTINGS_WATCHER.get_or_init(|| SettingsWatcher::new().map_err(|e| e.to_string()));
+
+ match result {
+ Ok(watcher) => Ok(watcher),
+ Err(e) => Err(eyre::eyre!("{}", e)),
+ }
+}
+
+/// Watches the config file for changes and broadcasts updated settings.
+///
+/// Uses `notify` for cross-platform file watching and `tokio::sync::watch`
+/// for efficient broadcast to multiple subscribers.
+pub(crate) struct SettingsWatcher {
+ /// Receiver for settings updates. Clone this to subscribe.
+ rx: watch::Receiver<Arc<Settings>>,
+ /// Keeps the file watcher alive for the lifetime of this struct.
+ _watcher: RecommendedWatcher,
+}
+
+impl SettingsWatcher {
+ /// Create a new settings watcher.
+ ///
+ /// Loads initial settings and starts watching the config file for changes.
+ /// Changes are debounced (500ms) to avoid multiple reloads during saves.
+ pub(crate) fn new() -> Result<Self> {
+ let initial_settings = Arc::new(Settings::new()?);
+ let (tx, rx) = watch::channel(initial_settings);
+
+ let config_path = Self::config_path();
+ info!("starting config file watcher: {}", config_path.display());
+
+ let watcher = Self::create_watcher(tx, &config_path)?;
+
+ Ok(Self {
+ rx,
+ _watcher: watcher,
+ })
+ }
+
+ /// Subscribe to settings updates.
+ ///
+ /// Returns a receiver that will be notified when settings change.
+ /// Use `changed().await` to wait for the next update, then `borrow()`
+ /// to access the current settings.
+ pub(crate) fn subscribe(&self) -> watch::Receiver<Arc<Settings>> {
+ self.rx.clone()
+ }
+
+ /// Get the config file path.
+ fn config_path() -> PathBuf {
+ let config_dir = std::env::var("ATUIN_CONFIG_DIR")
+ .map_or_else(|_| turtle_common::utils::config_dir(), PathBuf::from);
+ config_dir.join("config.toml")
+ }
+
+ /// Create the file watcher with debouncing.
+ fn create_watcher(
+ tx: watch::Sender<Arc<Settings>>,
+ config_path: &Path,
+ ) -> Result<RecommendedWatcher> {
+ // Channel for debouncing file events
+ let (debounce_tx, debounce_rx) = std::sync::mpsc::channel::<()>();
+
+ // Spawn debounce thread
+ let config_path_clone = config_path.to_owned();
+ std::thread::spawn(move || {
+ Self::debounce_loop(&debounce_rx, &tx, &config_path_clone);
+ });
+
+ // Clone config_path for use in the watcher callback
+ let config_path_for_watcher = config_path.to_owned();
+
+ // Canonicalize config path for reliable comparison on macOS
+ // (handles symlinks like /var -> /private/var)
+ let canonical_config_path = config_path_for_watcher
+ .canonicalize()
+ .unwrap_or_else(|_| config_path_for_watcher.clone());
+
+ // Create file watcher
+ let mut watcher = RecommendedWatcher::new(
+ move |res: Result<notify::Event, notify::Error>| {
+ match res {
+ Ok(event) => {
+ // Defensive: if paths is empty, we can't filter, so assume
+ // it might be our config file and trigger a reload to be safe
+ if event.paths.is_empty() {
+ warn!(
+ "config watcher: event has no paths, triggering reload to be safe"
+ );
+ debounce_tx.send(()).expect("should still be active");
+ return;
+ }
+
+ // Only react to events for our specific config file
+ // (filter out editor temp files, backups, etc.)
+ let is_config_file = event.paths.iter().any(|path| {
+ // Canonicalize for reliable comparison (handles macOS symlinks)
+ let canonical_event_path =
+ path.canonicalize().unwrap_or_else(|_| path.clone());
+
+ // Check if this event is for our config file
+ // (either exact match or the file was renamed to our config)
+ canonical_event_path == canonical_config_path
+ || path.file_name() == config_path_for_watcher.file_name()
+ });
+
+ if !is_config_file {
+ return;
+ }
+
+ // Only react to modify events (content changes) or creates
+ if matches!(
+ event.kind,
+ EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Any)
+ | EventKind::Create(_)
+ ) {
+ debug!("config file event detected: {event:?}");
+ // Send to debounce channel (ignore send errors - receiver might be gone)
+ debounce_tx.send(()).ok();
+ }
+ }
+ Err(e) => {
+ error!("file watcher error: {e}");
+ }
+ }
+ },
+ NotifyConfig::default(),
+ )
+ .wrap_err("failed to create file watcher")?;
+
+ // Watch the config file's parent directory (some editors create new files)
+ let watch_path = config_path.parent().unwrap_or(config_path);
+
+ // Defensive: ensure watch path exists before trying to watch
+ if !watch_path.exists() {
+ warn!(
+ "config directory does not exist, creating it: {}",
+ watch_path.display()
+ );
+ std::fs::create_dir_all(watch_path).wrap_err_with(|| {
+ format!(
+ "failed to create config directory: {}",
+ watch_path.display()
+ )
+ })?;
+ }
+
+ watcher
+ .watch(watch_path, RecursiveMode::NonRecursive)
+ .wrap_err_with(|| {
+ format!("failed to watch config directory: {}", watch_path.display())
+ })?;
+
+ info!(
+ "config file watcher initialized for: {}",
+ watch_path.display()
+ );
+ Ok(watcher)
+ }
+
+ /// Debounce loop that batches file events and reloads settings.
+ fn debounce_loop(
+ rx: &std::sync::mpsc::Receiver<()>,
+ tx: &watch::Sender<Arc<Settings>>,
+ config_path: &Path,
+ ) {
+ const DEBOUNCE_DURATION: Duration = Duration::from_millis(500);
+
+ loop {
+ // Wait for first event
+ if rx.recv().is_err() {
+ // Channel closed, watcher was dropped
+ debug!("config watcher debounce loop exiting");
+ return;
+ }
+
+ // Drain any additional events within debounce window
+ while rx.recv_timeout(DEBOUNCE_DURATION).is_ok() {
+ // Keep draining
+ }
+
+ // Defensive: check if config file exists before reloading
+ // (handles case where file was deleted - we'll get notified when it's recreated)
+ if !config_path.exists() {
+ debug!(
+ "config file does not exist, skipping reload: {}",
+ config_path.display()
+ );
+ continue;
+ }
+
+ // Now reload settings
+ info!(
+ "config file changed, reloading settings: {}",
+ config_path.display()
+ );
+ match Settings::new() {
+ Ok(settings) => {
+ if tx.send(Arc::new(settings)).is_err() {
+ // All receivers dropped
+ debug!("all settings subscribers dropped, exiting");
+ return;
+ }
+ info!("settings reloaded successfully");
+ }
+ Err(e) => {
+ warn!("failed to reload settings: {e}");
+ // Keep the old settings, don't broadcast the error
+ }
+ }
+ }
+ }
+}
diff --git a/crates/client/src/atuin_client/utils.rs b/crates/daemon/src/aclient/utils.rs
index 989f9fc1..cf515183 100644
--- a/crates/client/src/atuin_client/utils.rs
+++ b/crates/daemon/src/aclient/utils.rs
@@ -28,7 +28,7 @@ macro_rules! setup_db {
Ok(())
}
- crate::atuin_client::utils::setup_db_inner($db_path, $a_timeout, $opts, migrate)
+ crate::aclient::utils::setup_db_inner($db_path, $a_timeout, $opts, migrate)
}};
}
pub(crate) use setup_db;
diff --git a/crates/daemon/src/client.rs b/crates/daemon/src/client.rs
index 5cccb5ff..5f8ea0f2 100644
--- a/crates/daemon/src/client.rs
+++ b/crates/daemon/src/client.rs
@@ -7,36 +7,25 @@ use hyper_util::rt::TokioIo;
#[cfg(unix)]
use tokio::net::UnixStream;
-use tracing::{Level, instrument, span};
+use crate::aclient::{history::History, settings::Settings};
use crate::generated;
use crate::{
- atuin_client::{
- database::Context,
- history::History,
- settings::{FilterMode, Settings},
- },
- atuin_daemon::{
- events::DaemonEvent,
- generated::{
- control::{
- ForceSyncEvent, HistoryDeletedEvent, HistoryPrunedEvent, HistoryRebuiltEvent,
- SendEventRequest, SettingsReloadedEvent, ShutdownEvent,
- control_client::ControlClient as ControlServiceClient,
- },
- history::{
- EndHistoryReply, EndHistoryRequest, ShutdownRequest, StartHistoryReply,
- StartHistoryRequest, StatusReply, StatusRequest, TailHistoryReply,
- TailHistoryRequest, history_client::HistoryClient as HistoryServiceClient,
- },
- search::{
- FilterMode as RpcFilterMode, SearchContext as RpcSearchContext, SearchRequest,
- SearchResponse, search_client::SearchClient as SearchServiceClient,
- },
- semantic::{
- CommandCapture, RecordCommandsReply,
- semantic_client::SemanticClient as SemanticServiceClient,
- },
+ events::DaemonEvent,
+ generated::{
+ control::{
+ ForceSyncEvent, HistoryDeletedEvent, HistoryPrunedEvent, HistoryRebuiltEvent,
+ SendEventRequest, SettingsReloadedEvent, ShutdownEvent,
+ control_client::ControlClient as ControlServiceClient,
+ },
+ history::{
+ EndHistoryReply, EndHistoryRequest, ShutdownRequest, StartHistoryReply,
+ StartHistoryRequest, StatusReply, StatusRequest, TailHistoryReply, TailHistoryRequest,
+ history_client::HistoryClient as HistoryServiceClient,
+ },
+ semantic::{
+ CommandCapture, RecordCommandsReply,
+ semantic_client::SemanticClient as SemanticServiceClient,
},
},
};
@@ -129,7 +118,7 @@ impl HistoryClient {
Ok(self.client.status(StatusRequest {}).await?.into_inner())
}
- pub async fn tail_history(&mut self) -> Result<tonic::Streaming<TailHistoryReply>> {
+ pub(crate) async fn tail_history(&mut self) -> Result<tonic::Streaming<TailHistoryReply>> {
Ok(self
.client
.tail_history(TailHistoryRequest {})
@@ -143,92 +132,13 @@ impl HistoryClient {
}
}
-pub struct SearchClient {
- client: SearchServiceClient<Channel>,
-}
-
-impl SearchClient {
- #[cfg(unix)]
- pub async fn new(path: String) -> Result<Self> {
- let log_path = path.clone();
- let channel = Endpoint::try_from("http://atuin_local_daemon:0")?
- .connect_with_connector(service_fn(move |_: Uri| {
- let path = path.clone();
-
- async move {
- Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(path.clone()).await?))
- }
- }))
- .await
- .wrap_err_with(|| {
- format!(
- "failed to connect to local atuin daemon at {}. Is it running?",
- &log_path
- )
- })?;
-
- let client = SearchServiceClient::new(channel);
-
- Ok(Self { client })
- }
-
- #[instrument(skip_all, level = Level::TRACE, name = "daemon_client_search", fields(query = %query, query_id = query_id))]
- pub async fn search(
- &mut self,
- query: String,
- query_id: u64,
- filter_mode: FilterMode,
- context: Option<Context>,
- ) -> Result<tonic::Streaming<SearchResponse>> {
- let request = SearchRequest {
- query,
- query_id,
- filter_mode: RpcFilterMode::from(filter_mode).into(),
- context: context.map(RpcSearchContext::from),
- };
- let request_stream = tokio_stream::once(request);
- let response = span!(Level::TRACE, "daemon_client_search.request")
- .in_scope(async || self.client.search(request_stream).await)
- .await?;
-
- Ok(response.into_inner())
- }
-}
-
-impl From<FilterMode> for RpcFilterMode {
- fn from(filter_mode: FilterMode) -> Self {
- match filter_mode {
- FilterMode::Global => Self::Global,
- FilterMode::Host => Self::Host,
- FilterMode::Session => Self::Session,
- FilterMode::Directory => Self::Directory,
- FilterMode::Workspace => Self::Workspace,
- FilterMode::SessionPreload => Self::SessionPreload,
- }
- }
-}
-
-impl From<Context> for RpcSearchContext {
- fn from(context: Context) -> Self {
- Self {
- session_id: context.session,
- cwd: context.cwd,
- hostname: context.hostname,
- host_id: context.host_id,
- git_root: context
- .git_root
- .map(|path| path.to_string_lossy().to_string()),
- }
- }
-}
-
-pub struct SemanticClient {
+pub(crate) struct SemanticClient {
client: SemanticServiceClient<Channel>,
}
impl SemanticClient {
#[cfg(unix)]
- pub async fn new(path: String) -> Result<Self> {
+ pub(crate) async fn new(path: String) -> Result<Self> {
let log_path = path.clone();
let channel = Endpoint::try_from("http://atuin_local_daemon:0")?
.connect_with_connector(service_fn(move |_: Uri| {
@@ -252,11 +162,11 @@ impl SemanticClient {
}
#[cfg(unix)]
- pub async fn from_settings(settings: &Settings) -> Result<Self> {
+ pub(crate) async fn from_settings(settings: &Settings) -> Result<Self> {
Self::new(settings.daemon.socket_path.clone()).await
}
- pub async fn record_commands(
+ pub(crate) async fn record_commands(
&mut self,
captures: Vec<CommandCapture>,
) -> Result<RecordCommandsReply> {
@@ -279,7 +189,7 @@ pub struct ControlClient {
impl ControlClient {
/// Connect to the daemon's control service.
#[cfg(unix)]
- pub async fn new(path: String) -> Result<Self> {
+ pub(crate) async fn new(path: String) -> Result<Self> {
let log_path = path.clone();
let channel = Endpoint::try_from("http://atuin_local_daemon:0")?
.connect_with_connector(service_fn(move |_: Uri| {
diff --git a/crates/daemon/src/components/history.rs b/crates/daemon/src/components/history.rs
index a75ff774..7a0882a3 100644
--- a/crates/daemon/src/components/history.rs
+++ b/crates/daemon/src/components/history.rs
@@ -4,7 +4,7 @@
use std::{pin::Pin, sync::Arc};
-use crate::atuin_client::{
+use crate::aclient::{
history::{History, HistoryId, store::HistoryStore},
settings::Settings,
};
@@ -35,7 +35,7 @@ const DAEMON_PROTOCOL_VERSION: u32 = 1;
/// - Saves completed commands to the database and record store
/// - Emits history events for other components (e.g., search indexing)
/// - Provides the History gRPC service
-pub struct HistoryComponent {
+pub(crate) struct HistoryComponent {
inner: Arc<HistoryComponentInner>,
}
@@ -52,7 +52,7 @@ struct HistoryComponentInner {
impl HistoryComponent {
/// Create a new history component.
- pub fn new() -> Self {
+ pub(crate) fn new() -> Self {
Self {
inner: Arc::new(HistoryComponentInner {
running: DashMap::new(),
@@ -65,7 +65,7 @@ impl HistoryComponent {
/// Get the gRPC service for this component.
///
/// This returns a tonic service that can be added to a gRPC server.
- pub fn grpc_service(&self) -> HistoryServer<HistoryGrpcService> {
+ pub(crate) fn grpc_service(&self) -> HistoryServer<HistoryGrpcService> {
HistoryServer::new(HistoryGrpcService {
inner: self.inner.clone(),
})
@@ -111,7 +111,7 @@ impl Component for HistoryComponent {
/// The gRPC service implementation.
///
/// This is a thin wrapper that delegates to the component's shared state.
-pub struct HistoryGrpcService {
+pub(crate) struct HistoryGrpcService {
inner: Arc<HistoryComponentInner>,
}
diff --git a/crates/daemon/src/components/mod.rs b/crates/daemon/src/components/mod.rs
index 447e31df..7c478efb 100644
--- a/crates/daemon/src/components/mod.rs
+++ b/crates/daemon/src/components/mod.rs
@@ -10,16 +10,13 @@
//! Available components:
//!
//! - [`history::HistoryComponent`]: Command history lifecycle management
-//! - [`search::SearchComponent`]: Fuzzy search over history
//! - [`semantic::SemanticComponent`]: In-memory semantic command captures
//! - [`sync::SyncComponent`]: Cloud sync
-pub mod history;
-pub mod search;
-pub mod semantic;
-pub mod sync;
+pub(crate) mod history;
+pub(crate) mod semantic;
+pub(crate) mod sync;
-pub use history::HistoryComponent;
-pub use search::SearchComponent;
-pub use semantic::SemanticComponent;
-pub use sync::SyncComponent;
+pub(crate) use history::HistoryComponent;
+pub(crate) use semantic::SemanticComponent;
+pub(crate) use sync::SyncComponent;
diff --git a/crates/daemon/src/components/search.rs b/crates/daemon/src/components/search.rs
deleted file mode 100644
index 91f2db17..00000000
--- a/crates/daemon/src/components/search.rs
+++ /dev/null
@@ -1,407 +0,0 @@
-//! Search component.
-//!
-//! Provides fuzzy search over command history using the Nucleo search library
-//! with frecency-based ranking and dynamic filtering.
-
-use std::{pin::Pin, sync::Arc};
-
-use eyre::Result;
-use tokio::sync::RwLock;
-use tokio_stream::Stream;
-use tonic::{Request, Response, Status, Streaming};
-use tracing::{Level, debug, info, instrument, span, trace};
-use uuid::Uuid;
-
-use crate::{
- daemon::{Component, DaemonHandle},
- events::DaemonEvent,
- generated::search::{
- self, FilterMode, SearchRequest, SearchResponse,
- search_server::{Search as SearchSvc, SearchServer},
- },
- search::{IndexFilterMode, QueryContext, SearchIndex},
-};
-
-const PAGE_SIZE: usize = 5000;
-const RESULTS_LIMIT: u32 = 200;
-/// How often to rebuild the frecency map (in seconds).
-const FRECENCY_REFRESH_INTERVAL_SECS: u64 = 60;
-
-/// Search component - provides fuzzy search over command history.
-///
-/// This component:
-/// - Maintains a deduplicated search index with frecency ranking
-/// - Loads history from the database on startup
-/// - Updates the index when history events occur
-/// - Provides the Search gRPC service
-pub struct SearchComponent {
- index: Arc<RwLock<SearchIndex>>,
- handle: RwLock<Option<DaemonHandle>>,
- loader_handle: Option<tokio::task::JoinHandle<()>>,
- frecency_handle: Option<tokio::task::JoinHandle<()>>,
-}
-
-impl SearchComponent {
- /// Create a new search component.
- pub fn new() -> Self {
- Self {
- index: Arc::new(RwLock::new(SearchIndex::new())),
- handle: RwLock::new(None),
- loader_handle: None,
- frecency_handle: None,
- }
- }
-
- /// Get the gRPC service for this component.
- pub fn grpc_service(&self) -> SearchServer<SearchGrpcService> {
- SearchServer::new(SearchGrpcService {
- index: self.index.clone(),
- })
- }
-
- /// Rebuild the entire search index from the database.
- #[expect(clippy::significant_drop_tightening, reason = "false positive")]
- async fn rebuild_index(&self) -> Result<()> {
- let handle_guard = self.handle.read().await;
- let handle = handle_guard
- .as_ref()
- .ok_or_else(|| eyre::eyre!("component not initialized"))?;
-
- info!("Rebuilding search index from database");
-
- // Create a new index
- let new_index = SearchIndex::new();
-
- // Load all history into the new index
- let db = handle.history_db().clone();
- let mut pager = db.all_paged(PAGE_SIZE, false, true);
- loop {
- match pager.next().await {
- Ok(Some(histories)) => {
- info!(
- "Loading {} history entries into search index",
- histories.len()
- );
- new_index.add_histories(&histories);
- }
- Ok(None) => break,
- Err(e) => {
- tracing::error!("Failed to load history during rebuild: {}", e);
- break;
- }
- }
- }
-
- info!(
- "Search index rebuild complete; {} unique commands",
- new_index.command_count()
- );
-
- // Replace the old index with the new one
- *self.index.write().await = new_index;
- Ok(())
- }
-}
-
-impl Default for SearchComponent {
- fn default() -> Self {
- Self::new()
- }
-}
-
-#[tonic::async_trait]
-impl Component for SearchComponent {
- fn name(&self) -> &'static str {
- "search"
- }
-
- #[expect(clippy::significant_drop_tightening, reason = "false positive")]
- async fn start(&mut self, handle: DaemonHandle) -> Result<()> {
- *self.handle.write().await = Some(handle.clone());
-
- // Spawn background task to load history into index
- let index = self.index.clone();
- let db = handle.history_db().clone();
- let handle_for_loader = handle.clone();
-
- self.loader_handle = Some(tokio::spawn(async move {
- info!(
- "Loading history into search index; page size = {}",
- PAGE_SIZE
- );
- let mut pager = db.all_paged(PAGE_SIZE, false, true);
- loop {
- match pager.next().await {
- Ok(Some(histories)) => {
- info!(
- "Loading {} history entries into search index",
- histories.len()
- );
- index.read().await.add_histories(&histories);
- }
- Ok(None) => {
- info!(
- "Initial history load complete; {} unique commands indexed",
- index.read().await.command_count()
- );
- // Build initial frecency map with current settings
- let settings = handle_for_loader.settings().await;
- index.read().await.rebuild_frecency(&settings.search).await;
- info!("Initial frecency map built");
- break;
- }
- Err(e) => {
- tracing::error!("Failed to load history: {}", e);
- break;
- }
- }
- }
- }));
-
- // Spawn background task to periodically refresh frecency
- let index_for_frecency = self.index.clone();
- let handle_for_frecency = handle.clone();
- self.frecency_handle = Some(tokio::spawn(async move {
- let mut interval = tokio::time::interval(std::time::Duration::from_secs(
- FRECENCY_REFRESH_INTERVAL_SECS,
- ));
- loop {
- interval.tick().await;
- trace!("Refreshing frecency map");
- let settings = handle_for_frecency.settings().await;
- index_for_frecency
- .read()
- .await
- .rebuild_frecency(&settings.search)
- .await;
- }
- }));
-
- tracing::info!("search component started");
- Ok(())
- }
-
- #[expect(clippy::significant_drop_tightening, reason = "false positive")]
- async fn handle_event(&mut self, event: &DaemonEvent) -> Result<()> {
- match event {
- DaemonEvent::RecordsAdded(records) => {
- debug!(
- count = records.len(),
- "Processing added records for search index"
- );
-
- let handle_guard = self.handle.read().await;
- if let Some(handle) = handle_guard.as_ref() {
- let histories: Vec<_> = handle
- .history_db()
- .query_history(
- format!(
- "select * from history where id in ({})",
- records
- .iter()
- .map(|record| record.0.to_string())
- .collect::<Vec<_>>()
- .join(",")
- )
- .as_str(),
- )
- .await
- .unwrap_or_default();
-
- span!(Level::TRACE, "inject_records", count = histories.len())
- .in_scope(async || {
- self.index.read().await.add_histories(&histories);
- })
- .await;
- }
- }
- DaemonEvent::HistoryStarted(history) => {
- debug!(id = %history.id, command = %history.command, "History started (no index action)");
- }
- DaemonEvent::HistoryEnded(history) => {
- span!(Level::TRACE, "inject_history_ended")
- .in_scope(async || {
- self.index.read().await.add_history(history);
- })
- .await;
- }
- DaemonEvent::HistoryPruned | DaemonEvent::HistoryRebuilt => {
- info!("History store pruned or rebuilt, rebuilding search index");
- if let Err(e) = self.rebuild_index().await {
- tracing::error!("Failed to rebuild search index: {}", e);
- }
- }
- DaemonEvent::HistoryDeleted { ids } => {
- info!(
- count = ids.len(),
- "History deleted, rebuilding search index"
- );
- // For now, just rebuild the entire index. A more efficient implementation
- // would remove specific items from the index.
- if let Err(e) = self.rebuild_index().await {
- tracing::error!("Failed to rebuild search index: {}", e);
- }
- }
- DaemonEvent::SettingsReloaded => {
- info!("Settings reloaded, rebuilding frecency map with new multipliers");
- let handle_guard = self.handle.read().await;
- if let Some(handle) = handle_guard.as_ref() {
- let settings = handle.settings().await;
- self.index
- .read()
- .await
- .rebuild_frecency(&settings.search)
- .await;
- }
- }
- // Events we don't care about
- DaemonEvent::SyncCompleted { .. }
- | DaemonEvent::SyncFailed { .. }
- | DaemonEvent::ForceSync
- | DaemonEvent::ShutdownRequested => {}
- }
- Ok(())
- }
-
- async fn stop(&mut self) -> Result<()> {
- if let Some(handle) = self.loader_handle.take() {
- handle.abort();
- }
- if let Some(handle) = self.frecency_handle.take() {
- handle.abort();
- }
- tracing::info!("search component stopped");
- Ok(())
- }
-}
-
-/// The gRPC service implementation.
-pub struct SearchGrpcService {
- index: Arc<RwLock<SearchIndex>>,
-}
-
-#[tonic::async_trait]
-impl SearchSvc for SearchGrpcService {
- type SearchStream = Pin<Box<dyn Stream<Item = Result<SearchResponse, Status>> + Send>>;
-
- #[instrument(skip_all, level = Level::TRACE, name = "search_rpc")]
- async fn search(
- &self,
- request: Request<Streaming<SearchRequest>>,
- ) -> Result<Response<Self::SearchStream>, Status> {
- let mut in_stream = request.into_inner();
- let index = self.index.clone();
-
- // Create output channel
- let (tx, rx) = tokio::sync::mpsc::channel::<Result<SearchResponse, Status>>(128);
-
- // Spawn task to handle incoming requests and send responses
- tokio::spawn(async move {
- while let Some(req) = in_stream.message().await.transpose() {
- match req {
- Ok(search_req) => {
- let query = search_req.query;
- let query_id = search_req.query_id;
- let filter_mode: FilterMode = search_req
- .filter_mode
- .try_into()
- .unwrap_or(FilterMode::Global);
- let proto_context = search_req.context;
-
- debug!(
- "search request: query = {}, query_id = {}, filter_mode = {}, context = {:?}",
- query,
- query_id,
- filter_mode.as_str_name(),
- proto_context
- );
-
- // Convert proto FilterMode + context to IndexFilterMode
- let index_filter = convert_filter_mode(filter_mode, proto_context.as_ref());
-
- // Build QueryContext from proto context
- let query_context = proto_context
- .map(|ctx| QueryContext {
- cwd: Some(with_trailing_slash(&ctx.cwd)),
- git_root: ctx.git_root.map(|s| with_trailing_slash(&s)),
- hostname: Some(ctx.hostname),
- session_id: Some(ctx.session_id),
- })
- .unwrap_or_default();
-
- // Perform the search
- let history_ids =
- span!(Level::TRACE, "daemon_search_query", %query, query_id)
- .in_scope(|| async {
- let index = index.read().await;
- index
- .search(&query, index_filter, &query_context, RESULTS_LIMIT)
- .await
- })
- .await;
-
- // Convert history IDs to bytes
- let ids: Vec<Vec<u8>> = history_ids
- .iter()
- .filter_map(|id| {
- Uuid::parse_str(id)
- .ok()
- .map(|uuid| uuid.as_bytes().to_vec())
- })
- .collect();
-
- if tx.send(Ok(SearchResponse { query_id, ids })).await.is_err() {
- break; // Client disconnected
- }
- }
- Err(e) => {
- drop(tx.send(Err(e)).await);
- break;
- }
- }
- }
- });
-
- // Convert receiver to stream
- let out_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
- Ok(Response::new(Box::pin(out_stream)))
- }
-}
-
-/// Convert proto `FilterMode` and context to `IndexFilterMode`.
-fn convert_filter_mode(
- mode: FilterMode,
- context: Option<&search::SearchContext>,
-) -> IndexFilterMode {
- #[expect(
- clippy::match_same_arms,
- reason = "wildcard pattern used in second one"
- )]
- match (mode, context) {
- (FilterMode::Global, _) => IndexFilterMode::Global,
- (FilterMode::Directory, Some(ctx)) => {
- IndexFilterMode::Directory(with_trailing_slash(&ctx.cwd))
- }
- (FilterMode::Workspace, Some(ctx)) => ctx.git_root.as_ref().map_or_else(
- || IndexFilterMode::Directory(with_trailing_slash(&ctx.cwd)),
- |git_root| IndexFilterMode::Workspace(with_trailing_slash(git_root)),
- ),
- (FilterMode::Host, Some(ctx)) => IndexFilterMode::Host(ctx.hostname.clone()),
- (FilterMode::Session, Some(ctx)) => IndexFilterMode::Session(ctx.session_id.clone()),
- (FilterMode::SessionPreload, Some(ctx)) => {
- // SessionPreload is similar to Session - filter by session
- IndexFilterMode::Session(ctx.session_id.clone())
- }
- // If no context provided, fall back to global
- _ => IndexFilterMode::Global,
- }
-}
-
-#[cfg(not(windows))]
-pub fn with_trailing_slash(s: &str) -> String {
- if s.ends_with('/') {
- s.to_string()
- } else {
- format!("{s}/")
- }
-}
diff --git a/crates/daemon/src/components/semantic.rs b/crates/daemon/src/components/semantic.rs
index 02f5c3d1..aec26887 100644
--- a/crates/daemon/src/components/semantic.rs
+++ b/crates/daemon/src/components/semantic.rs
@@ -8,7 +8,7 @@ use std::collections::{HashMap, VecDeque};
use std::fmt::{Display, Formatter};
use std::sync::Arc;
-use crate::atuin_client::history::{History, HistoryId};
+use crate::aclient::history::{History, HistoryId};
use crate::generated::semantic;
use eyre::Result;
use tokio::sync::Mutex;
@@ -30,7 +30,7 @@ const MAX_BYTES_PER_SESSION: usize = 32 * 1024 * 1024;
const MAX_PENDING_HISTORIES: usize = 128;
/// Stores completed command captures and associates them with history events.
-pub struct SemanticComponent {
+pub(crate) struct SemanticComponent {
inner: Arc<SemanticComponentInner>,
}
@@ -84,7 +84,7 @@ struct SemanticCommandRecord {
}
impl SemanticComponent {
- pub fn new() -> Self {
+ pub(crate) fn new() -> Self {
Self {
inner: Arc::new(SemanticComponentInner {
state: Mutex::new(SemanticState::default()),
@@ -92,7 +92,7 @@ impl SemanticComponent {
}
}
- pub fn grpc_service(&self) -> SemanticServer<SemanticGrpcService> {
+ pub(crate) fn grpc_service(&self) -> SemanticServer<SemanticGrpcService> {
SemanticServer::new(SemanticGrpcService {
inner: self.inner.clone(),
})
@@ -453,7 +453,7 @@ impl Display for SessionId {
}
}
-pub struct SemanticGrpcService {
+pub(crate) struct SemanticGrpcService {
inner: Arc<SemanticComponentInner>,
}
diff --git a/crates/daemon/src/components/sync.rs b/crates/daemon/src/components/sync.rs
index e898e8bd..e200ad73 100644
--- a/crates/daemon/src/components/sync.rs
+++ b/crates/daemon/src/components/sync.rs
@@ -9,7 +9,7 @@ use rand::Rng;
use tokio::sync::mpsc;
use tokio::time::{self, MissedTickBehavior};
-use crate::atuin_client::{history::store::HistoryStore, record::sync, settings::Settings};
+use crate::aclient::{history::store::HistoryStore, record::sync, settings::Settings};
use crate::{
daemon::{Component, DaemonHandle},
@@ -41,14 +41,14 @@ enum SyncState {
/// - Implements exponential backoff on sync failures
/// - Responds to [`ForceSync`] events for immediate sync
/// - Emits SyncCompleted/SyncFailed events
-pub struct SyncComponent {
+pub(crate) struct SyncComponent {
task_handle: Option<tokio::task::JoinHandle<()>>,
command_tx: Option<mpsc::Sender<SyncCommand>>,
}
impl SyncComponent {
/// Create a new sync component.
- pub fn new() -> Self {
+ pub(crate) fn new() -> Self {
Self {
task_handle: None,
command_tx: None,
diff --git a/crates/daemon/src/control/mod.rs b/crates/daemon/src/control/mod.rs
index fcc2a0b8..f727a3a2 100644
--- a/crates/daemon/src/control/mod.rs
+++ b/crates/daemon/src/control/mod.rs
@@ -7,15 +7,13 @@ use tonic::{Request, Response, Status};
use tracing::{Level, info, instrument};
use crate::{
- atuin_client::history::HistoryId,
- atuin_daemon::{
- daemon::DaemonHandle,
- events::DaemonEvent,
- generated::control::{
- SendEventRequest, SendEventResponse,
- control_server::{Control, ControlServer},
- send_event_request::Event,
- },
+ aclient::history::HistoryId,
+ daemon::DaemonHandle,
+ events::DaemonEvent,
+ generated::control::{
+ SendEventRequest, SendEventResponse,
+ control_server::{Control, ControlServer},
+ send_event_request::Event,
},
};
@@ -23,18 +21,18 @@ use crate::{
///
/// This service is used by external processes to inject events into the daemon.
/// It's not a component - it's part of the daemon's core infrastructure.
-pub struct ControlService {
+pub(crate) struct ControlService {
handle: DaemonHandle,
}
impl ControlService {
/// Create a new control service with the given daemon handle.
- pub fn new(handle: DaemonHandle) -> Self {
+ pub(crate) fn new(handle: DaemonHandle) -> Self {
Self { handle }
}
/// Get a tonic server for this service.
- pub fn into_server(self) -> ControlServer<Self> {
+ pub(crate) fn into_server(self) -> ControlServer<Self> {
ControlServer::new(self)
}
}
diff --git a/crates/daemon/src/daemon.rs b/crates/daemon/src/daemon.rs
index 8f0a5957..4e691be2 100644
--- a/crates/daemon/src/daemon.rs
+++ b/crates/daemon/src/daemon.rs
@@ -10,7 +10,7 @@
use std::sync::Arc;
-use crate::atuin_client::{
+use crate::aclient::{
database::ClientSqlite as HistoryDatabase, encryption, record::sqlite_store::SqliteStore,
settings::Settings,
};
@@ -27,7 +27,7 @@ use crate::events::DaemonEvent;
///
/// This contains all the resources that components and services need access to.
/// The state is wrapped in an `Arc` and accessed via [`DaemonHandle`].
-pub struct DaemonState {
+pub(crate) struct DaemonState {
// Event bus
event_tx: broadcast::Sender<DaemonEvent>,
@@ -72,7 +72,7 @@ pub struct DaemonState {
/// let history = handle.history_db().load(id).await?;
/// ```
#[derive(Clone)]
-pub struct DaemonHandle {
+pub(crate) struct DaemonHandle {
state: Arc<DaemonState>,
}
@@ -83,7 +83,7 @@ impl DaemonHandle {
///
/// This is fire-and-forget - if no receivers are listening (which shouldn't
/// happen in normal operation), the event is dropped silently.
- pub fn emit(&self, event: DaemonEvent) {
+ pub(crate) fn emit(&self, event: DaemonEvent) {
if let Err(e) = self.state.event_tx.send(event) {
tracing::warn!("failed to emit event (no receivers?): {e}");
}
@@ -94,12 +94,12 @@ impl DaemonHandle {
/// Returns a receiver that will receive all events emitted after this call.
/// Useful for components that need to listen for events outside of the
/// normal `handle_event` callback flow.
- pub fn subscribe(&self) -> broadcast::Receiver<DaemonEvent> {
+ pub(crate) fn subscribe(&self) -> broadcast::Receiver<DaemonEvent> {
self.state.event_tx.subscribe()
}
/// Request graceful shutdown of the daemon.
- pub fn shutdown(&self) {
+ pub(crate) fn shutdown(&self) {
self.emit(DaemonEvent::ShutdownRequested);
}
@@ -109,7 +109,7 @@ impl DaemonHandle {
///
/// This acquires a read lock on the settings. For most use cases, clone
/// the settings if you need to hold onto them.
- pub async fn settings(&self) -> tokio::sync::RwLockReadGuard<'_, Settings> {
+ pub(crate) async fn settings(&self) -> tokio::sync::RwLockReadGuard<'_, Settings> {
self.state.settings.read().await
}
@@ -117,26 +117,26 @@ impl DaemonHandle {
///
/// Use this when settings have already been loaded (e.g., from a file watcher)
/// to avoid parsing the config file twice.
- pub async fn apply_settings(&self, settings: Settings) {
+ pub(crate) async fn apply_settings(&self, settings: Settings) {
*self.state.settings.write().await = settings;
self.emit(DaemonEvent::SettingsReloaded);
tracing::info!("settings applied");
}
/// Get the encryption key.
- pub fn encryption_key(&self) -> &[u8; 32] {
+ pub(crate) fn encryption_key(&self) -> &[u8; 32] {
&self.state.encryption_key
}
// ---- Database ----
/// Get a reference to the history database.
- pub fn history_db(&self) -> &HistoryDatabase {
+ pub(crate) fn history_db(&self) -> &HistoryDatabase {
&self.state.history_db
}
/// Get a reference to the record store.
- pub fn store(&self) -> &SqliteStore {
+ pub(crate) fn store(&self) -> &SqliteStore {
&self.state.store
}
}
@@ -171,7 +171,7 @@ impl std::fmt::Debug for DaemonHandle {
/// # Example
///
/// ```ignore
-/// pub struct MyComponent {
+/// pub(crate) struct MyComponent {
/// handle: Option<DaemonHandle>,
/// }
///
@@ -203,7 +203,7 @@ impl std::fmt::Debug for DaemonHandle {
/// }
/// ```
#[tonic::async_trait]
-pub trait Component: Send + Sync {
+pub(crate) trait Component: Send + Sync {
/// Human-readable name for logging and debugging.
fn name(&self) -> &'static str;
@@ -247,21 +247,21 @@ pub trait Component: Send + Sync {
///
/// Events emitted during handling are queued and processed in subsequent
/// iterations, ensuring the loop eventually drains.
-pub struct Daemon {
+pub(crate) struct Daemon {
components: Vec<Box<dyn Component>>,
handle: DaemonHandle,
}
impl Daemon {
/// Create a new daemon builder.
- pub fn builder(settings: Settings) -> DaemonBuilder {
+ pub(crate) fn builder(settings: Settings) -> DaemonBuilder {
DaemonBuilder::new(settings)
}
/// Get a clone of the daemon handle.
///
/// The handle can be used to emit events, access settings, etc.
- pub fn handle(&self) -> DaemonHandle {
+ pub(crate) fn handle(&self) -> DaemonHandle {
self.handle.clone()
}
@@ -269,7 +269,7 @@ impl Daemon {
///
/// This must be called before `run_event_loop()`. It initializes all
/// registered components with the daemon handle.
- pub async fn start_components(&mut self) -> Result<()> {
+ pub(crate) async fn start_components(&mut self) -> Result<()> {
for component in &mut self.components {
tracing::info!(component = component.name(), "starting component");
component
@@ -284,7 +284,7 @@ impl Daemon {
///
/// This processes events until a [`ShutdownRequested`] event is received.
/// Components must be started first via `start_components()`.
- pub async fn run_event_loop(&mut self) -> Result<()> {
+ pub(crate) async fn run_event_loop(&mut self) -> Result<()> {
let mut event_rx = self.handle.subscribe();
loop {
match event_rx.recv().await {
@@ -314,7 +314,7 @@ impl Daemon {
/// Stop all components.
///
/// This performs graceful shutdown of all components.
- pub async fn stop_components(&mut self) {
+ pub(crate) async fn stop_components(&mut self) {
for component in &mut self.components {
tracing::info!(component = component.name(), "stopping component");
if let Err(e) = component.stop().await {
@@ -361,7 +361,7 @@ impl Daemon {
///
/// daemon.run().await?;
/// ```
-pub struct DaemonBuilder {
+pub(crate) struct DaemonBuilder {
settings: Settings,
store: Option<SqliteStore>,
history_db: Option<HistoryDatabase>,
@@ -370,7 +370,7 @@ pub struct DaemonBuilder {
impl DaemonBuilder {
/// Create a new daemon builder with the given settings.
- pub fn new(settings: Settings) -> Self {
+ pub(crate) fn new(settings: Settings) -> Self {
Self {
settings,
store: None,
@@ -380,13 +380,13 @@ impl DaemonBuilder {
}
/// Set the record store.
- pub fn store(mut self, store: SqliteStore) -> Self {
+ pub(crate) fn store(mut self, store: SqliteStore) -> Self {
self.store = Some(store);
self
}
/// Set the history database.
- pub fn history_db(mut self, db: HistoryDatabase) -> Self {
+ pub(crate) fn history_db(mut self, db: HistoryDatabase) -> Self {
self.history_db = Some(db);
self
}
@@ -394,7 +394,7 @@ impl DaemonBuilder {
/// Register a component.
///
/// Components are started in registration order and stopped in reverse order.
- pub fn component(mut self, component: impl Component + 'static) -> Self {
+ pub(crate) fn component(mut self, component: impl Component + 'static) -> Self {
self.components.push(Box::new(component));
self
}
@@ -402,7 +402,7 @@ impl DaemonBuilder {
/// Build the daemon.
///
/// This loads the encryption key and creates the daemon state.
- pub fn build(self) -> Result<Daemon> {
+ pub(crate) fn build(self) -> Result<Daemon> {
let store = self.store.ok_or_else(|| eyre::eyre!("store is required"))?;
let history_db = self
.history_db
diff --git a/crates/daemon/src/events.rs b/crates/daemon/src/events.rs
index 32ed1ff1..092e5f32 100644
--- a/crates/daemon/src/events.rs
+++ b/crates/daemon/src/events.rs
@@ -7,7 +7,7 @@
//! External processes (like CLI commands) can also inject events via the
//! Control gRPC service.
-use crate::atuin_client::history::{History, HistoryId};
+use crate::aclient::history::{History, HistoryId};
use turtle_common::record::RecordId;
/// Events that flow through the daemon's event bus.
diff --git a/crates/daemon/src/generated.rs b/crates/daemon/src/generated.rs
index 6620e94c..9deb4e0c 100644
--- a/crates/daemon/src/generated.rs
+++ b/crates/daemon/src/generated.rs
@@ -11,22 +11,14 @@
)]
/// Semantic command capture gRPC service types.
-pub mod semantic {
+pub(crate) mod semantic {
tonic::include_proto!("semantic");
}
-/// Search module for the daemon gRPC search service.
-///
-/// This module provides fuzzy search over command history using Nucleo.
-pub mod search {
- // Include the generated proto code
- tonic::include_proto!("search");
-}
-
/// History module for the daemon gRPC history service.
///
/// This module contains the proto-generated types for the history gRPC service.
-pub mod history {
+pub(crate) mod history {
// Include the generated proto code
tonic::include_proto!("history");
}
@@ -35,10 +27,10 @@ pub mod history {
///
/// This module provides the gRPC service that allows external processes
/// (like CLI commands) to inject events into the daemon's event bus.
-pub mod control {
+pub(crate) mod control {
// Include the generated proto code
tonic::include_proto!("control");
// Re-export the service
- pub use crate::control::ControlService;
+ pub(crate) use crate::control::ControlService;
}
diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs
index 1abf0314..a5d233c6 100644
--- a/crates/daemon/src/lib.rs
+++ b/crates/daemon/src/lib.rs
@@ -1,27 +1,25 @@
-use crate::atuin_client::database::ClientSqlite as HistoryDatabase;
-use crate::atuin_client::record::sqlite_store::SqliteStore;
-use crate::atuin_client::settings::{Settings, watcher::global_settings_watcher};
+use crate::aclient::database::ClientSqlite as HistoryDatabase;
+use crate::aclient::record::sqlite_store::SqliteStore;
+use crate::aclient::settings::{Settings, watcher::global_settings_watcher};
use eyre::Result;
+pub mod aclient;
pub mod client;
-pub mod components;
-pub mod control;
-pub mod daemon;
-pub mod events;
-pub mod search;
-pub mod server;
-pub mod generated;
+pub(crate) mod components;
+pub(crate) mod control;
+pub(crate) mod daemon;
+pub(crate) mod events;
+pub(crate) mod server;
+
+pub(crate) mod generated;
// Re-export core daemon types for convenience
-pub use daemon::Daemon;
+pub(crate) use daemon::Daemon;
pub use events::DaemonEvent;
// Re-export components
-pub use components::{HistoryComponent, SearchComponent, SemanticComponent, SyncComponent};
-
-// Re-export client helpers
-pub use client::SemanticClient;
+pub(crate) use components::{HistoryComponent, SemanticComponent, SyncComponent};
/// Boot the daemon using the new component-based architecture.
///
@@ -34,14 +32,12 @@ pub async fn boot(
) -> Result<()> {
// Create the components
let history_component = HistoryComponent::new();
- let search_component = SearchComponent::new();
let semantic_component = SemanticComponent::new();
let sync_component = SyncComponent::new();
// Get the gRPC services before moving components into the daemon
// (The services share state with the components via Arc)
let history_service = history_component.grpc_service();
- let search_service = search_component.grpc_service();
let semantic_service = semantic_component.grpc_service();
// Build the daemon
@@ -49,7 +45,6 @@ pub async fn boot(
.store(store)
.history_db(history_db)
.component(history_component)
- .component(search_component)
.component(semantic_component)
.component(sync_component)
.build()?;
@@ -94,7 +89,6 @@ pub async fn boot(
server::run_grpc_server(
&settings,
history_service,
- search_service,
semantic_service,
control_service.into_server(),
handle,
diff --git a/crates/client/src/command/client/daemon.rs b/crates/daemon/src/main.rs
index 39aa1b1e..26a5cafd 100644
--- a/crates/client/src/command/client/daemon.rs
+++ b/crates/daemon/src/main.rs
@@ -1,3 +1,7 @@
+#[allow(unused_imports)]
+use clap::Parser;
+use eyre::{Result, WrapErr, bail, eyre};
+use fs4::fs_std::FileExt;
use std::fs::{self, File, OpenOptions};
use std::io::{ErrorKind, Write};
#[cfg(unix)]
@@ -5,30 +9,19 @@ use std::os::unix::net::UnixStream as StdUnixStream;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
-
-use crate::atuin_client::{
- database::ClientSqlite, history::History, record::sqlite_store::SqliteStore, settings::Settings,
-};
-use crate::atuin_daemon::DaemonEvent;
-use crate::atuin_daemon::client::{
- ControlClient, DaemonClientErrorKind, HistoryClient, classify_error,
-};
-use clap::Subcommand;
-#[cfg(unix)]
-use daemonize::Daemonize;
-use eyre::{Result, WrapErr, bail, eyre};
-use fs4::fs_std::FileExt;
use tokio::time::sleep;
+use turtle_daemon::{
+ DaemonEvent,
+ aclient::{
+ database::ClientSqlite, history::History, record::sqlite_store::SqliteStore,
+ settings::Settings,
+ },
+ client::{ControlClient, DaemonClientErrorKind, HistoryClient, classify_error},
+};
-#[derive(clap::Args, Debug)]
-pub(crate) struct Cmd {
- #[command(subcommand)]
- subcmd: SubCmd,
-}
-
-#[derive(Subcommand, Debug)]
+#[derive(Parser, Debug)]
#[command(infer_subcommands = true)]
-pub(crate) enum SubCmd {
+pub(crate) enum Cmd {
/// Start the daemon server
Start {
#[arg(long, hide = true)]
@@ -37,10 +30,6 @@ pub(crate) enum SubCmd {
/// Also write daemon logs to the console (useful for debugging)
#[arg(long)]
show_logs: bool,
-
- /// Force start: kill existing daemon process and reset the socket
- #[arg(long)]
- force: bool,
},
/// Show the daemon's current status
@@ -48,45 +37,35 @@ pub(crate) enum SubCmd {
/// Stop the daemon gracefully
Stop,
-
- /// Restart the daemon (stop, then start in background)
- Restart,
}
impl Cmd {
- /// Returns `true` when the process should daemonize before creating the
- /// async runtime or opening any database connections.
- #[cfg(unix)]
- pub(crate) fn should_daemonize(&self) -> bool {
- match &self.subcmd {
- SubCmd::Start { daemonize, .. } => *daemonize,
- _ => false,
- }
- }
-
- /// Returns `true` when logs should also be written to the console.
- pub(crate) fn show_logs(&self) -> bool {
- match &self.subcmd {
- SubCmd::Start { show_logs, .. } => *show_logs,
- _ => false,
- }
- }
-
pub(crate) async fn run(
self,
settings: Settings,
store: SqliteStore,
history_db: ClientSqlite,
) -> Result<()> {
- match self.subcmd {
- SubCmd::Start { force, .. } => run(settings, store, history_db, force).await,
- SubCmd::Status => status_cmd(&settings).await,
- SubCmd::Stop => stop_cmd(&settings).await,
- SubCmd::Restart => restart_cmd(&settings).await,
+ match self {
+ Cmd::Start { .. } => run(settings, store, history_db).await,
+ Cmd::Status => status_cmd(&settings).await,
+ Cmd::Stop => stop_cmd(&settings).await,
}
}
}
+#[tokio::main]
+async fn main() -> Result<()> {
+ let settings = Settings::new().wrap_err("could not load client settings")?;
+ let db_path = PathBuf::from(settings.db_path.as_str());
+ let record_store_path = PathBuf::from(settings.record_store_path.as_str());
+
+ let db = ClientSqlite::new(db_path, settings.local_timeout).await?;
+ let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?;
+
+ Cmd::parse().run(settings, sqlite_store, db).await
+}
+
const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
const DAEMON_PROTOCOL_VERSION: u32 = 1;
const STARTUP_POLL: Duration = Duration::from_millis(40);
@@ -221,87 +200,10 @@ async fn request_shutdown(settings: &Settings) {
}
}
-fn spawn_daemon_process() -> Result<()> {
- let exe = std::env::current_exe().wrap_err("could not locate atuin executable")?;
-
- let mut cmd = Command::new(exe);
- cmd.arg("daemon")
- .arg("start")
- .stdin(Stdio::null())
- .stdout(Stdio::null())
- .stderr(Stdio::null());
-
- #[cfg(unix)]
- cmd.arg("--daemonize");
-
- cmd.spawn().wrap_err("failed to spawn daemon process")?;
-
- Ok(())
-}
-
fn startup_timeout(settings: &Settings) -> Duration {
Duration::from_secs_f64(settings.local_timeout.max(0.5) + 2.0)
}
-#[cfg(unix)]
-fn remove_stale_socket_if_present(settings: &Settings) -> Result<()> {
- if settings.daemon.systemd_socket {
- return Ok(());
- }
-
- let socket_path = Path::new(&settings.daemon.socket_path);
- if !socket_path.exists() {
- return Ok(());
- }
-
- match StdUnixStream::connect(socket_path) {
- Ok(stream) => {
- drop(stream);
- Ok(())
- }
- Err(err) if err.kind() == ErrorKind::ConnectionRefused => {
- fs::remove_file(socket_path).wrap_err_with(|| {
- format!(
- "failed to remove stale daemon socket {}",
- socket_path.display()
- )
- })?;
- Ok(())
- }
- Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
- Err(_) => Ok(()),
- }
-}
-
-async fn wait_until_ready(settings: &Settings, timeout: Duration) -> Result<HistoryClient> {
- let start = Instant::now();
- let mut last_error = eyre!("daemon did not become ready");
-
- loop {
- match probe(settings).await {
- Probe::Ready(client) => return Ok(client),
- Probe::NeedsRestart(reason) => {
- last_error = eyre!(reason);
- }
- Probe::Unreachable(err) => {
- if is_legacy_daemon_error(&err) {
- return Err(err.wrap_err(LEGACY_DAEMON_RESTART_MESSAGE));
- }
- last_error = err;
- }
- }
-
- if start.elapsed() >= timeout {
- return Err(last_error.wrap_err(format!(
- "timed out waiting for daemon startup after {}ms",
- timeout.as_millis()
- )));
- }
-
- sleep(STARTUP_POLL).await;
- }
-}
-
pub(crate) async fn start_history(settings: &Settings, history: History) -> Result<String> {
match async {
connect_client(settings)
@@ -429,123 +331,15 @@ async fn stop_cmd(settings: &Settings) -> Result<()> {
}
}
-async fn restart_cmd(settings: &Settings) -> Result<()> {
- // Stop if running
- match probe(settings).await {
- Probe::Ready(_) | Probe::NeedsRestart(_) => {
- request_shutdown(settings).await;
- println!("Stopping daemon...");
-
- let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path);
- let timeout = Duration::from_secs(5);
- wait_for_pidfile_available(&pidfile_path, timeout)
- .await
- .wrap_err("Timed out waiting for old daemon to stop")?;
- }
- Probe::Unreachable(_) => {
- println!("No daemon running");
- }
- }
-
- #[cfg(unix)]
- remove_stale_socket_if_present(settings)?;
-
- spawn_daemon_process()?;
- println!("Starting daemon...");
-
- let timeout = startup_timeout(settings);
- let status = wait_until_ready(settings, timeout).await?.status().await?;
-
- println!("Daemon restarted");
- println!(" PID: {}", status.pid);
- println!(" Version: {}", status.version);
-
- Ok(())
-}
-
-/// Daemonize the current process. Must be called before creating the tokio
-/// runtime or opening database connections, since `fork()` inside an async
-/// runtime corrupts its internal state.
-#[cfg(unix)]
-pub(crate) fn daemonize_current_process() -> Result<()> {
- let cwd =
- std::env::current_dir().wrap_err("could not determine current directory for daemon")?;
-
- Daemonize::new()
- .working_directory(cwd)
- .start()
- .wrap_err("failed to daemonize process")?;
-
- Ok(())
-}
-
-async fn run(
- settings: Settings,
- store: SqliteStore,
- history_db: ClientSqlite,
- force: bool,
-) -> Result<()> {
- if force {
- force_cleanup(&settings);
- }
-
+async fn run(settings: Settings, store: SqliteStore, history_db: ClientSqlite) -> Result<()> {
let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path);
let _pidfile_guard = PidfileGuard::acquire(&pidfile_path)?;
- crate::atuin_daemon::boot(settings, store, history_db).await?;
+ turtle_daemon::boot(settings, store, history_db).await?;
Ok(())
}
-/// Force cleanup: kill existing daemon process and remove socket.
-fn force_cleanup(settings: &Settings) {
- let pidfile_path = Path::new(&settings.daemon.pidfile_path);
-
- // Read and kill the existing process if pidfile exists
- if pidfile_path.exists() {
- if let Ok(contents) = fs::read_to_string(pidfile_path)
- && let Some(pid_str) = contents.lines().next()
- && let Ok(pid) = pid_str.parse::<u32>()
- {
- kill_process(pid);
- // Give it a moment to release resources
- std::thread::sleep(Duration::from_millis(100));
- }
-
- // Remove the pidfile
- if let Err(e) = fs::remove_file(pidfile_path)
- && e.kind() != ErrorKind::NotFound
- {
- tracing::warn!("failed to remove pidfile: {e}");
- }
- }
-
- // Remove the socket file
- #[cfg(unix)]
- {
- let socket_path = Path::new(&settings.daemon.socket_path);
- if socket_path.exists()
- && let Err(e) = fs::remove_file(socket_path)
- && e.kind() != ErrorKind::NotFound
- {
- tracing::warn!("failed to remove socket: {e}");
- }
- }
-}
-
-/// Kill a process by PID.
-#[cfg(unix)]
-fn kill_process(pid: u32) {
- // Use kill command to send SIGTERM for graceful shutdown
- drop(
- Command::new("kill")
- .args(["-TERM", &pid.to_string()])
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status(),
- );
-}
-
#[cfg(test)]
mod tests {
use super::{
diff --git a/crates/daemon/src/search/mod.rs b/crates/daemon/src/search/mod.rs
deleted file mode 100644
index b4d03bcd..00000000
--- a/crates/daemon/src/search/mod.rs
+++ /dev/null
@@ -1,557 +0,0 @@
-//! Search index with frecency-based ranking.
-//!
-//! This module provides a deduplicated search index where each unique command
-//! is stored once, with metadata about all its invocations. This enables:
-//!
-//! - Efficient fuzzy matching (fewer items to match)
-//! - Frecency-based ranking (frequency + recency)
-//! - Dynamic filtering by directory, host, session, etc.
-
-use std::{
- collections::{HashMap, HashSet},
- sync::Arc,
-};
-
-use crate::atuin_client::settings::Search;
-use crate::{
- atuin_client::history::History, atuin_daemon::components::search::with_trailing_slash,
-};
-use atuin_nucleo::{Injector, Nucleo, pattern};
-use dashmap::DashMap;
-use lasso::{Spur, ThreadedRodeo};
-use time::OffsetDateTime;
-use tokio::sync::RwLock;
-use tracing::{Level, instrument};
-use uuid::Uuid;
-
-/// Parse a UUID string into a 16-byte array.
-/// Returns None if the string is not a valid UUID.
-fn parse_uuid_bytes(s: &str) -> Option<[u8; 16]> {
- Uuid::parse_str(s).ok().map(|u| *u.as_bytes())
-}
-
-/// Format a 16-byte array as a UUID string.
-fn format_uuid_bytes(bytes: &[u8; 16]) -> String {
- Uuid::from_bytes(*bytes).to_string()
-}
-
-/// Pre-computed frecency data for O(1) lookup.
-#[derive(Debug, Clone, Default)]
-pub struct FrecencyData {
- /// Total number of times this command was used.
- pub count: u32,
- /// Most recent usage timestamp (unix seconds).
- pub last_used: i64,
-}
-
-impl FrecencyData {
- /// Record a new usage of this command.
- pub fn record_use(&mut self, timestamp: i64) {
- self.count += 1;
- if timestamp > self.last_used {
- self.last_used = timestamp;
- }
- }
-
- /// Compute frecency score based on count and recency.
- ///
- /// Uses a decay function where more recent commands score higher.
- /// The formula balances frequency (how often) with recency (how recent).
- ///
- /// Multipliers allow tuning the relative weights:
- /// - `recency_mul`: Multiplier for recency score (default: 1.0)
- /// - `frequency_mul`: Multiplier for frequency score (default: 1.0)
- ///
- /// A multiplier of 0.0 disables that component, 1.0 is unchanged, 2.0 doubles weight.
- /// Values like 0.5 reduce weight by half, 1.5 increases by 50%, etc.
- #[instrument(level = Level::TRACE, name = "index_frecency_compute")]
- pub fn compute(&self, now: i64, recency_mul: f64, frequency_mul: f64) -> u32 {
- if self.count == 0 {
- return 0;
- }
-
- // Time-based decay: score decreases as time passes
- let age_seconds = (now - self.last_used).max(0) as u64;
- let age_hours = age_seconds / 3600;
-
- // Decay factor: recent commands get higher scores
- // - Last hour: multiplier ~1.0
- // - Last day: multiplier ~0.5
- // - Last week: multiplier ~0.1
- // - Older: multiplier approaches 0
- let recency_score: f64 = match age_hours {
- 0 => 100.0,
- 1..=6 => 90.0,
- 7..=24 => 70.0,
- 25..=72 => 50.0,
- 73..=168 => 30.0,
- 169..=720 => 15.0,
- _ => 5.0,
- };
-
- // Frequency boost: more uses = higher score (with diminishing returns)
- let frequency_score = (f64::from(self.count).ln() * 20.0).min(100.0);
-
- // Apply multipliers and combine scores, then round to u32
- recency_score
- .mul_add(recency_mul, frequency_score * frequency_mul)
- .round() as u32
- }
-}
-
-/// Data for a unique command.
-pub struct CommandData {
- /// History ID of the most recent invocation (16-byte UUID).
- most_recent_id: [u8; 16],
- /// Timestamp of the most recent invocation.
- most_recent_timestamp: i64,
- /// Pre-computed global frecency.
- pub global_frecency: FrecencyData,
-
- // Pre-computed indexes for O(1) filter lookups
- // Using HashSet instead of DashSet since CommandData lives inside DashMap (already synchronized)
- /// All directories where this command has been run (interned keys).
- directories: HashSet<Spur>,
- /// All hostnames where this command has been run (interned keys).
- hosts: HashSet<Spur>,
- /// All sessions where this command has been run (as 16-byte UUIDs).
- sessions: HashSet<[u8; 16]>,
-}
-
-impl CommandData {
- /// Create a new [`CommandData`] from a history entry.
- /// Returns None if the history entry has invalid UUIDs.
- pub fn new(history: &History, interner: &ThreadedRodeo) -> Option<Self> {
- let history_id = parse_uuid_bytes(&history.id.0)?;
- let session = parse_uuid_bytes(&history.session)?;
- let timestamp = history.timestamp.unix_timestamp();
-
- let dir_key = interner.get_or_intern(with_trailing_slash(&history.cwd));
- let host_key = interner.get_or_intern(&history.hostname);
-
- let mut directories = HashSet::new();
- directories.insert(dir_key);
-
- let mut hosts = HashSet::new();
- hosts.insert(host_key);
-
- let mut sessions = HashSet::new();
- sessions.insert(session);
-
- let mut global_frecency = FrecencyData::default();
- global_frecency.record_use(timestamp);
-
- Some(Self {
- most_recent_id: history_id,
- most_recent_timestamp: timestamp,
- global_frecency,
- directories,
- hosts,
- sessions,
- })
- }
-
- /// Add an invocation from a history entry.
- /// Returns false if the history entry has invalid UUIDs.
- pub fn add_invocation(&mut self, history: &History, interner: &ThreadedRodeo) -> bool {
- let Some(history_id) = parse_uuid_bytes(&history.id.0) else {
- return false;
- };
- let Some(session) = parse_uuid_bytes(&history.session) else {
- return false;
- };
-
- let timestamp = history.timestamp.unix_timestamp();
-
- // Update global frecency
- self.global_frecency.record_use(timestamp);
-
- // Update pre-computed indexes for O(1) filter lookups
- let dir_key = interner.get_or_intern(with_trailing_slash(&history.cwd));
- self.directories.insert(dir_key);
- self.hosts.insert(interner.get_or_intern(&history.hostname));
- self.sessions.insert(session);
-
- // Update most recent if this invocation is newer
- if timestamp > self.most_recent_timestamp {
- self.most_recent_id = history_id;
- self.most_recent_timestamp = timestamp;
- }
-
- true
- }
-
- /// Get the most recent history ID for this command.
- pub fn most_recent_id(&self) -> String {
- format_uuid_bytes(&self.most_recent_id)
- }
-
- /// Check if any invocation matches a directory filter (exact match).
- /// O(1) lookup using pre-computed index.
- pub fn has_invocation_in_dir(&self, dir: &str, interner: &ThreadedRodeo) -> bool {
- interner
- .get(dir)
- .is_some_and(|spur| self.directories.contains(&spur))
- }
-
- /// Check if any invocation matches a directory prefix (workspace/git root).
- /// O(n) where n = number of unique directories for this command.
- pub fn has_invocation_in_workspace(
- &self,
- prefix: &str,
- interner: &ThreadedRodeo,
- ) -> bool {
- self.directories
- .iter()
- .any(|&spur| interner.resolve(&spur).starts_with(prefix))
- }
-
- /// Check if any invocation matches a hostname.
- /// O(1) lookup using pre-computed index.
- pub fn has_invocation_on_host(&self, hostname: &str, interner: &ThreadedRodeo) -> bool {
- interner
- .get(hostname)
- .is_some_and(|spur| self.hosts.contains(&spur))
- }
-
- /// Check if any invocation matches a session.
- /// O(1) lookup using pre-computed index.
- pub fn has_invocation_in_session(&self, session: &str) -> bool {
- parse_uuid_bytes(session).is_some_and(|bytes| self.sessions.contains(&bytes))
- }
-}
-
-/// Filter mode for search queries.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum IndexFilterMode {
- /// No filtering - search all commands.
- Global,
- /// Filter to commands run in a specific directory.
- Directory(String),
- /// Filter to commands run in a workspace (directory prefix).
- Workspace(String),
- /// Filter to commands run on a specific host.
- Host(String),
- /// Filter to commands run in a specific session.
- Session(String),
-}
-
-/// Context for search queries.
-#[derive(Debug, Clone, Default)]
-pub struct QueryContext {
- #[expect(dead_code)]
- pub cwd: Option<String>,
- #[expect(dead_code)]
- pub git_root: Option<String>,
- #[expect(dead_code)]
- pub hostname: Option<String>,
- #[expect(dead_code)]
- pub session_id: Option<String>,
-}
-
-/// Shareable frecency map: command -> frecency score.
-/// Wrapped in Arc for zero-copy sharing with scorer callbacks.
-type FrecencyMap = Arc<HashMap<Arc<str>, u32>>;
-
-/// A deduplicated search index with frecency-based ranking.
-///
-/// Commands are stored by their text, with metadata about all invocations.
-/// Nucleo handles fuzzy matching, while frecency is computed via scorer callback.
-///
-/// Global frecency is precomputed by a background task and used for scoring.
-/// If frecency data is not available, search still works but without frecency ranking;
-/// although this should never happen due to precomputing the frecency map.
-pub struct SearchIndex {
- /// Map from command text to command data.
- /// Using `DashMap` for concurrent read/write access, wrapped in Arc for sharing with scorer.
- /// Keys are Arc<str> to enable zero-copy sharing with `frecency_map`.
- commands: Arc<DashMap<Arc<str>, CommandData>>,
- /// Nucleo fuzzy matcher - items are command strings.
- nucleo: RwLock<Nucleo<String>>,
- /// Injector for adding new commands to Nucleo.
- injector: Injector<String>,
- /// Precomputed global frecency map. Updated by background task.
- frecency_map: RwLock<Option<FrecencyMap>>,
- /// String interner for deduplicating cwd, hostname, and directory paths.
- interner: Arc<ThreadedRodeo>,
-}
-
-impl SearchIndex {
- /// Create a new empty search index.
- pub fn new() -> Self {
- let nucleo_config = atuin_nucleo::Config::DEFAULT;
- // Single column for command text
- let nucleo = Nucleo::<String>::new(nucleo_config, Arc::new(|| {}), None, 1);
- let injector = nucleo.injector();
-
- Self {
- commands: Arc::new(DashMap::new()),
- nucleo: RwLock::new(nucleo),
- injector,
- frecency_map: RwLock::new(None),
- interner: Arc::new(ThreadedRodeo::new()),
- }
- }
-
- /// Add a history entry to the index.
- ///
- /// If the command already exists, updates its invocation data.
- /// If it's a new command, adds it to both the map and Nucleo.
- pub fn add_history(&self, history: &History) {
- let command = history.command.as_str();
-
- // DashMap with Arc<str> keys can be looked up with &str via Borrow trait
- if let Some(mut entry) = self.commands.get_mut(command) {
- // Existing command - just update invocations
- entry.add_invocation(history, &self.interner);
- } else {
- // New command - create Arc<str> once and share it
- let Some(data) = CommandData::new(history, &self.interner) else {
- return; // Invalid UUIDs, skip this entry
- };
- let command_arc: Arc<str> = command.into();
- self.commands.insert(Arc::clone(&command_arc), data);
- // Nucleo still needs String (unavoidable copy for fuzzy matching)
- self.injector.push(command_arc.to_string(), |cmd, cols| {
- cols[0] = cmd.clone().into();
- });
- }
- // Note: frecency_map is rebuilt by background task, not invalidated here
- }
-
- /// Add multiple history entries to the index.
- pub fn add_histories(&self, histories: &[History]) {
- for history in histories {
- self.add_history(history);
- }
- }
-
- /// Get the number of unique commands in the index.
- pub fn command_count(&self) -> usize {
- self.commands.len()
- }
-
- /// Search for commands matching a query.
- ///
- /// Returns a list of history IDs (most recent invocation per command).
- /// Uses precomputed global frecency for scoring if available.
- #[instrument(skip_all, level = Level::TRACE, name = "index_search", fields(query = %query))]
- #[expect(
- clippy::significant_drop_tightening,
- reason = "The nucleo early drop is a false-positive"
- )]
- pub async fn search(
- &self,
- query: &str,
- filter_mode: IndexFilterMode,
- // TODO(@bpeetz): Use the query context here <2026-06-12>
- #[expect(unused)] context: &QueryContext,
- limit: u32,
- ) -> Vec<String> {
- let mut nucleo = self.nucleo.write().await;
-
- // Get precomputed frecency map (may be None if not yet computed)
- let frecency_map = self.frecency_map.read().await.clone();
-
- // Build filter based on mode
- let filter = self.build_filter(&filter_mode);
- nucleo.set_filter(filter);
-
- // Build scorer from precomputed frecency (or None if not available)
- let scorer = Self::build_scorer(frecency_map);
- nucleo.set_scorer(scorer);
-
- // Update pattern
- nucleo.pattern.reparse(
- 0,
- query,
- pattern::CaseMatching::Smart,
- pattern::Normalization::Smart,
- false,
- );
-
- tracing::span!(Level::TRACE, "index_search_tick").in_scope(|| {
- // Tick until complete
- while nucleo.tick(10).running {}
- });
-
- // Collect results
- let snapshot = nucleo.snapshot();
- let matched_count = snapshot.matched_item_count().min(limit);
-
- tracing::span!(Level::TRACE, "index_search_results").in_scope(|| {
- snapshot
- .matched_items(..matched_count)
- .filter_map(|item| {
- let cmd = item.data;
- // DashMap<Arc<str>, _>::get accepts &str via Borrow trait
- self.commands
- .get(cmd.as_str())
- .map(|data| data.most_recent_id())
- })
- .collect()
- })
- }
-
- /// Rebuild the global frecency map.
- ///
- /// This should be called by a background task periodically.
- /// The map is used for scoring search results.
- ///
- /// Uses multipliers from search settings:
- /// - `recency_score_multiplier`: Weight for recency component
- /// - `frequency_score_multiplier`: Weight for frequency component
- /// - `frecency_score_multiplier`: Overall multiplier for final score
- #[instrument(skip_all, level = Level::DEBUG, name = "rebuild_frecency")]
- pub async fn rebuild_frecency(&self, search_settings: &Search) {
- let now = OffsetDateTime::now_utc().unix_timestamp();
- let mut frecency_map: HashMap<Arc<str>, u32> = HashMap::new();
-
- // Clamp multipliers to non-negative values to prevent broken frecency ranking
- // (negative values would produce unexpected results when cast to u32)
- let recency_mul = search_settings.recency_score_multiplier.max(0.0);
- let frequency_mul = search_settings.frequency_score_multiplier.max(0.0);
- let frecency_mul = search_settings.frecency_score_multiplier.max(0.0);
-
- for entry in self.commands.iter() {
- let frecency = entry
- .global_frecency
- .compute(now, recency_mul, frequency_mul);
- // Apply overall frecency multiplier and round to u32
- let frecency = (f64::from(frecency) * frecency_mul).round() as u32;
- // Arc::clone is cheap - just increments reference count
- frecency_map.insert(Arc::clone(entry.key()), frecency);
- }
-
- *self.frecency_map.write().await = Some(Arc::new(frecency_map));
- }
-
- /// Build filter predicate for the given mode.
- fn build_filter(&self, mode: &IndexFilterMode) -> Option<atuin_nucleo::Filter<String>> {
- // For Global mode, no filter needed
- if matches!(mode, IndexFilterMode::Global) {
- return None;
- }
-
- // Pre-compute which commands pass the filter
- // Use HashSet<String> for the short-lived filter (simpler than Arc lookup)
- let passing_commands: Arc<HashSet<String>> = {
- let mut set = HashSet::new();
- for entry in self.commands.iter() {
- let passes = match mode {
- IndexFilterMode::Global => unreachable!(),
- IndexFilterMode::Directory(dir) => {
- entry.has_invocation_in_dir(dir, &self.interner)
- }
- IndexFilterMode::Workspace(prefix) => {
- entry.has_invocation_in_workspace(prefix, &self.interner)
- }
- IndexFilterMode::Host(hostname) => {
- entry.has_invocation_on_host(hostname, &self.interner)
- }
- IndexFilterMode::Session(session) => entry.has_invocation_in_session(session),
- };
- if passes {
- // Convert Arc<str> to String for filter lookup
- set.insert(entry.key().to_string());
- }
- }
- Arc::new(set)
- };
-
- Some(Arc::new(move |cmd: &String| passing_commands.contains(cmd)))
- }
-
- /// Build scorer from precomputed frecency map.
- ///
- /// Returns None if frecency map is not available (search still works, just without frecency ranking).
- fn build_scorer(frecency_map: Option<FrecencyMap>) -> Option<atuin_nucleo::Scorer<String>> {
- let map = frecency_map?;
- Some(Arc::new(move |cmd: &String, fuzzy_score: u32| {
- // HashMap<Arc<str>, _>::get accepts &str via Borrow trait
- let frecency = map.get(cmd.as_str()).copied().unwrap_or(0);
- fuzzy_score + frecency
- }))
- }
-}
-
-impl Default for SearchIndex {
- fn default() -> Self {
- Self::new()
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::FrecencyData;
-
- #[test]
- fn frecency_data_compute() {
- let now = 1_000_000i64;
-
- // Recent command (with default multipliers of 1.0)
- let recent = FrecencyData {
- count: 5,
- last_used: now - 60, // 1 minute ago
- };
- assert!(recent.compute(now, 1.0, 1.0) > 100); // High score
-
- // Old command
- let old = FrecencyData {
- count: 5,
- last_used: now - 86400 * 30, // 30 days ago
- };
- assert!(old.compute(now, 1.0, 1.0) < recent.compute(now, 1.0, 1.0));
-
- // Frequently used old command
- let frequent_old = FrecencyData {
- count: 100,
- last_used: now - 86400 * 7, // 1 week ago
- };
- // Should still have decent score due to frequency
- assert!(frequent_old.compute(now, 1.0, 1.0) > 50);
- }
-
- #[test]
- fn frecency_data_compute_with_multipliers() {
- let now = 1_000_000_i64;
-
- let data = FrecencyData {
- count: 5,
- last_used: now - 60, // 1 minute ago (recency_score = 100)
- };
-
- // Default multipliers (1.0, 1.0)
- let default_score = data.compute(now, 1.0, 1.0);
-
- // Double recency weight
- let double_recency = data.compute(now, 2.0, 1.0);
- assert!(double_recency > default_score);
-
- // Double frequency weight
- let double_frequency = data.compute(now, 1.0, 2.0);
- assert!(double_frequency > default_score);
-
- // Zero out recency (only frequency counts)
- let no_recency = data.compute(now, 0.0, 1.0);
- assert!(no_recency < default_score);
-
- // Zero out frequency (only recency counts)
- let no_frequency = data.compute(now, 1.0, 0.0);
- assert!(no_frequency < default_score);
-
- // Zero both (should be zero)
- let no_score = data.compute(now, 0.0, 0.0);
- assert_eq!(no_score, 0);
-
- // Fractional multipliers
- let half_recency = data.compute(now, 0.5, 1.0);
- assert!(half_recency < default_score);
- assert!(half_recency > no_recency);
-
- // 1.5x multiplier
- let boost_recency = data.compute(now, 1.5, 1.0);
- assert!(boost_recency > default_score);
- assert!(boost_recency < double_recency);
- }
-}
diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs
index 335f8260..97c4fe48 100644
--- a/crates/daemon/src/server.rs
+++ b/crates/daemon/src/server.rs
@@ -1,18 +1,13 @@
use eyre::Result;
use crate::{
- atuin_client::settings::Settings,
- atuin_daemon::{
- components::{
- history::HistoryGrpcService, search::SearchGrpcService, semantic::SemanticGrpcService,
- },
- daemon::DaemonHandle,
- generated::{
- control::{ControlService, control_server::ControlServer},
- history::history_server::HistoryServer,
- search::search_server::SearchServer,
- semantic::semantic_server::SemanticServer,
- },
+ aclient::settings::Settings,
+ components::{history::HistoryGrpcService, semantic::SemanticGrpcService},
+ daemon::DaemonHandle,
+ generated::{
+ control::{ControlService, control_server::ControlServer},
+ history::history_server::HistoryServer,
+ semantic::semantic_server::SemanticServer,
},
};
@@ -21,10 +16,9 @@ use crate::{
/// This starts the gRPC server in the background and returns immediately.
/// The server will shut down when a [`ShutdownRequested`] event is received.
#[cfg(unix)]
-pub fn run_grpc_server(
+pub(crate) fn run_grpc_server(
settings: &Settings,
history_service: HistoryServer<HistoryGrpcService>,
- search_service: SearchServer<SearchGrpcService>,
semantic_service: SemanticServer<SemanticGrpcService>,
control_service: ControlServer<ControlService>,
handle: DaemonHandle,
@@ -107,7 +101,6 @@ pub fn run_grpc_server(
if let Err(e) = Server::builder()
.add_service(history_service)
- .add_service(search_service)
.add_service(semantic_service)
.add_service(control_service)
.serve_with_incoming_shutdown(uds_stream, shutdown_signal)
diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml
index c9ae44da..3a01d4fd 100644
--- a/crates/server/Cargo.toml
+++ b/crates/server/Cargo.toml
@@ -14,8 +14,6 @@ repository = { workspace = true }
[dependencies]
turtle-common = { workspace = true }
async-trait = "0.1.58"
-atuin-nucleo-matcher = { workspace = true }
-atuin-nucleo = { workspace = true }
axum = "0.8"
base64 = "0.22"
clap = { version = "4.5.7", features = ["derive"] }