Skip to content

Commit

Permalink
[#128] Custom tiebreaking function (#151)
Browse files Browse the repository at this point in the history
* [#128] Custom tiebreaking function

* Grant access to original search query
  • Loading branch information
raxod502 authored Sep 9, 2023
1 parent e0cca29 commit 37d356e
Show file tree
Hide file tree
Showing 4 changed files with 85 additions and 8 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog].

## Unreleased
### Features
* New user option `prescient-tiebreaker` which can be used to change
how matches with no recency information are sorted, instead of by
length ([#128]).

### Internal Changes
* `prescient-filter` now only propertizes the first returned candidate
for use with `prescient-sort-full-matches-first` ([#148]). Custom
Expand All @@ -29,10 +34,10 @@ The format is based on [Keep a Changelog].
couldn't be used as initialisms anyway, so there is no conflict
when matching greedily for these inputs.

[#128]: https://github.com/radian-software/prescient.el/pull/128
[#148]: https://github.com/radian-software/prescient.el/pull/148
[#149]: https://github.com/radian-software/prescient.el/pull/149


## 6.1 (released 2022-12-16)
### New features
* Add package `vertico-prescient`, which integrates prescient.el with
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ command `prescient-forget`.
* `prescient-sort-length-enable`: Whether to sort the candidates by
length in addition to recency and frequency.

* `prescient-tiebreaker`: Function to use for breaking ties in recency
instead of length.

* `prescient-use-char-folding`: Whether the `literal` and
`literal-prefix` filter methods use character folding.

Expand Down
51 changes: 44 additions & 7 deletions prescient.el
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,20 @@ usefully be sorted by length (presumably, the backend returns
these results in some already-sorted order)."
:type 'boolean)

(defcustom prescient-tiebreaker nil
"If non-nil, the method used to break ties instead of length.
The value will be called as a function with two candidates that
have the same recency and frequency information, and should
return a number to indicate their relative order (negative for
first < second, zero for first = second, positive for first >
second), where candidates are assumed to sort in ascending order.
You can also use the variable `prescient-query' to access the
original query from the user (but see that variable for
caveats)."
:type '(choice
(const :tag "Length" nil)
(function :tag "Custom function")))

(defcustom prescient-aggressive-file-save nil
"Whether to save the cache file aggressively.
If non-nil, then write the cache data to `prescient-save-file'
Expand Down Expand Up @@ -467,6 +481,8 @@ Currently recognized PROPERTIES are:
- `:prescient-ignore-case': Whether prescient ignored case.
- `:prescient-query': Original search query from user.
These properties are identified using keyword symbols.
This information is used by the function
Expand All @@ -480,13 +496,18 @@ This information is used by the function
;; they're not given. This makes testing easier
;; and should be helpful for others creating their
;; own sorting functions.
;;
;; Note all passed properties will still get set,
;; this just defaults the standard ones to nil in
;; case they are missing.
(cl-flet ((put-get (props sym)
(plist-put props sym
(plist-get props sym))))
(thread-first properties
(put-get :prescient-match-regexps)
(put-get :prescient-all-regexps)
(put-get :prescient-ignore-case))))
(put-get :prescient-ignore-case)
(put-get :prescient-query))))
(cdr candidates))))

(defun prescient--get-sort-info (candidates)
Expand Down Expand Up @@ -805,7 +826,8 @@ copy of the list."
:prescient-match-regexps completion-regexp-list
:prescient-all-regexps (maybe-add-prefix
(prescient-filter-regexps pattern nil t))
:prescient-ignore-case completion-ignore-case))))
:prescient-ignore-case completion-ignore-case
:prescient-query query))))

(defmacro prescient--sort-compare ()
"Hack used to cause the byte-compiler to produce faster code.
Expand All @@ -820,9 +842,12 @@ lexical scope."
(f2 (gethash c2 freq 0)))
(or (> f1 f2)
(and (eq f1 f2)
len-enable
(< (length c1)
(length c2))))))))))
(if tiebreaker
(< (funcall tiebreaker c1 c2) 0)
(and
len-enable
(< (length c1)
(length c2))))))))))))

(defun prescient-sort-compare (c1 c2)
"Compare candidates C1 and C2 by usage and length.
Expand All @@ -839,9 +864,18 @@ length."
(let ((hist prescient--history)
(len prescient-history-length)
(freq prescient--frequency)
(len-enable prescient-sort-length-enable))
(len-enable prescient-sort-length-enable)
(tiebreaker prescient-tiebreaker))
(prescient--sort-compare)))

(defvar prescient-query nil
"The original query from the user, if available.
You can use this in your implementation of `prescient-tiebreaker'
to sort candidates depending on the user's query. This might be
nil if `prescient-sort-compare' is invoked directly, or if
`prescient-sort' is invoked without `prescient-filter' having
been run first, so you should handle that case too.")

(defun prescient-sort (candidates)
"Sort CANDIDATES using frequency data.
Return the sorted list. The original is modified destructively.
Expand All @@ -859,7 +893,10 @@ See also the functions `prescient-sort-full-matches-first' and
(let ((hist prescient--history)
(len prescient-history-length)
(freq prescient--frequency)
(len-enable prescient-sort-length-enable))
(len-enable prescient-sort-length-enable)
(tiebreaker prescient-tiebreaker)
(prescient-query (plist-get (prescient--get-sort-info candidates)
:prescient-query)))
(sort
candidates
(lambda (c1 c2)
Expand Down
32 changes: 32 additions & 0 deletions test/prescient-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,35 @@ for BINDINGS."
'("test.el" ".elpaignore" "barracuda") '(prefix) t ".e" '(".elpaignore" "test.el")
'("test.el" ".elpaignore" "barracuda") '(prefix) nil ".e" '("test.el" ".elpaignore")
)

(defun prescient-test--tiebreak-length-increasing (c1 c2)
(- (length c1) (length c2)))

(defun prescient-test--tiebreak-length-decreasing (c1 c2)
(- (length c2) (length c1)))

(defun prescient-test--tiebreak-first-letter-index (c1 c2)
"Weird tiebreaking function.
It prioritizes candidates based on how close the first letter of
the search query is to the front of the candidate. The main
purpose is to test that tiebreak functions are able to access the
search query properly."
(if (or (null prescient-query) (string-empty-p prescient-query))
0
(- (or (string-match (substring prescient-query 0 1) c1) 999)
(or (string-match (substring prescient-query 0 1) c2) 999))))

(prescient-deftest prescient-tiebreaker ()
(let ((prescient-sort-length-enable ,len-enable)
(prescient-tiebreaker ,tiebreaker))
(prescient-test--stateless
(should (equal ,result (prescient-completion-sort
(prescient-filter ,query ,candidates))))))
(candidates len-enable query tiebreaker result)
'("first" "second" "third" "fourth" "fifth" "seventh") nil "" nil '("first" "second" "third" "fourth" "fifth" "seventh")
'("first" "second" "third" "fourth" "fifth" "seventh") t "" nil '("first" "third" "fifth" "second" "fourth" "seventh")
'("first" "second" "third" "fourth" "fifth" "seventh") nil "" #'prescient-test--tiebreak-length-increasing '("first" "third" "fifth" "second" "fourth" "seventh")
'("first" "second" "third" "fourth" "fifth" "seventh") nil "" #'prescient-test--tiebreak-length-decreasing '("seventh" "second" "fourth" "first" "third" "fifth")
'("first" "second" "third" "fourth" "fifth" "seventh") nil "h" nil '("third" "fourth" "fifth" "seventh")
'("first" "second" "third" "fourth" "fifth" "seventh") nil "h" #'prescient-test--tiebreak-first-letter-index '("third" "fifth" "fourth" "seventh")
)

0 comments on commit 37d356e

Please sign in to comment.