This commit is contained in:
Kovid Goyal
2026-07-04 12:41:38 +05:30
5 changed files with 226 additions and 5 deletions

View File

@@ -192,7 +192,12 @@ func main(_ *cli.Command, o *Options, args []string) (rc int, err error) {
text_style := fctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s bold", o.HintsTextColor, o.HintsTextBackgroundColor)) text_style := fctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s bold", o.HintsTextColor, o.HintsTextBackgroundColor))
highlight_mark := func(m *Mark, mark_text string) string { highlight_mark := func(m *Mark, mark_text string) string {
hint := encode_hint(m.Index, alphabet) var hint string
if o.PrefixFree {
hint = generate_prefix_free_hint(m.Index, alphabet)
} else {
hint = encode_hint(m.Index, alphabet)
}
if current_input != "" && !strings.HasPrefix(hint, current_input) { if current_input != "" && !strings.HasPrefix(hint, current_input) {
return faint(mark_text) return faint(mark_text)
} }
@@ -311,7 +316,13 @@ func main(_ *cli.Command, o *Options, args []string) (rc int, err error) {
if changed { if changed {
matches := []*Mark{} matches := []*Mark{}
for idx, m := range index_map { for idx, m := range index_map {
if eh := encode_hint(idx, alphabet); strings.HasPrefix(eh, current_input) { var eh string
if o.PrefixFree {
eh = generate_prefix_free_hint(idx, alphabet)
} else {
eh = encode_hint(idx, alphabet)
}
if strings.HasPrefix(eh, current_input) {
matches = append(matches, m) matches = append(matches, m)
} }
} }

View File

@@ -201,6 +201,16 @@ The offset (from zero) at which to start hint numbering. Note that only numbers
greater than or equal to zero are respected. greater than or equal to zero are respected.
--prefix-free
type=bool-set
Generate hints such that no hint is a prefix of another. For example, with
alphabet "abc" and 4 matches, hints are "b", "c", "aa", "ab", instead of "a",
"b", "c", "aa" (where "a" prefixes "aa"). This works by applying a dynamic
offset large enough so that the hints are prefix-free. When combined with a
:option:`--hints-offset` greater than 0, the effective offset is whichever is
larger.
--alphabet --alphabet
The list of characters to use for hints. The default is to use numbers and The list of characters to use for hints. The default is to use numbers and
lowercase English alphabets. Specify your preference as a string of characters. lowercase English alphabets. Specify your preference as a string of characters.

View File

@@ -706,13 +706,28 @@ process_answer:
} }
largest_index := ans[len(ans)-1].Index largest_index := ans[len(ans)-1].Index
offset := max(0, opts.HintsOffset) offset := max(0, opts.HintsOffset)
if opts.PrefixFree {
alphabetLength := len(opts.Alphabet)
if alphabetLength == 0 {
alphabetLength = len(DEFAULT_HINT_ALPHABET)
}
offset = max(offset, hints_to_skip(len(ans), alphabetLength))
}
index_map = make(map[int]*Mark, len(ans)) index_map = make(map[int]*Mark, len(ans))
for i := range ans { for i := range ans {
m := &ans[i] m := &ans[i]
if opts.Ascending { if opts.PrefixFree {
m.Index += offset if opts.Ascending {
m.Index = i + offset + 1
} else {
m.Index = (largest_index - m.Index) + offset + 1
}
} else { } else {
m.Index = largest_index - m.Index + offset if opts.Ascending {
m.Index += offset
} else {
m.Index = largest_index - m.Index + offset
}
} }
index_map[m.Index] = m index_map[m.Index] = m
} }

View File

@@ -0,0 +1,62 @@
// License: GPLv3 Copyright: 2026, Kovid Goyal, <kovid at kovidgoyal.net>
package hints
// hints_to_skip calculates how many hints to skip based on the given n and
// alphabet size so that the next n hints generated by expanding the prefix tree
// of an alphabet of size alphabetSize will be prefix-free.
//
// The result is 1-based, meaning that the first hint to be skipped is the first
// non-empty hint.
//
// For example, if alphabet was "abc" and n was 5, the first non-empty hints
// from that alphabet would be: "a", "b", "c", "aa", "ab", "ac", ...
// the result of hints_to_skip would be 1, and this would mean that if you skip
// "a" from the list above, the next 5 hints are prefix-free:
// "b", "c", "aa", "ab", "ac".
//
// See issue #10195 for an informal proof on why this function works.
func hints_to_skip(n int, alphabetSize int) int {
if n < 2 {
n = 2
}
return (n - 2) / (alphabetSize - 1)
}
// generate_prefix_free_hint generates the hint corresponding to the given index
// in the breadth-first traversal of the prefix tree generated by the given
// alphabet.
//
// For example, given the alphabet "abc", the hints would be generated in
// the following order:
// 1. "a"
// 2. "b"
// 3. "c"
// 4. "aa"
// 5. "ab"
// 6. "ac"
// 7. "ba"
// 8. "bb"
// 9. "bc"
// 10. "ca"
// 11. "cb"
// 12. "cc"
// 13. "aaa"
// ...
//
// Indices are 1-based on non-empty hints; index=0 returns an empty (invalid)
// hint.
func generate_prefix_free_hint(index int, alphabet string) string {
l := len(alphabet)
hint := ""
index -= 1
for index >= 0 {
mod := index % l
char := string(alphabet[mod])
index /= l
hint = char + hint
index -= 1
}
return hint
}

View File

@@ -0,0 +1,123 @@
// License: GPLv3 Copyright: 2026, Kovid Goyal, <kovid at kovidgoyal.net>
package hints
import (
"math"
"strings"
"testing"
"github.com/kovidgoyal/kitty"
)
func TestPrefixFreeHints(t *testing.T) {
// Test hints_to_skip
testsToSkip := []struct {
n, alphabetSize, expected int
}{
{5, 3, 1},
{6, 3, 2},
{4, 3, 1},
{3, 3, 0},
{2, 3, 0},
{1, 3, 0},
{0, 3, 0},
}
for _, tc := range testsToSkip {
actual := hints_to_skip(tc.n, tc.alphabetSize)
if actual != tc.expected {
t.Errorf("hints_to_skip(%d, %d) = %d, expected %d", tc.n, tc.alphabetSize, actual, tc.expected)
}
}
// Test prefix-free property verification
alphabets := []string{"abc", "0123456789"}
for _, alph := range alphabets {
l := len(alph)
for n := 1; n <= 10; n++ {
total_hints := int(math.Pow(float64(2), float64(n)))
skip := hints_to_skip(total_hints, l)
hints := make([]string, total_hints)
for i := 0; i < total_hints; i++ {
hints[i] = generate_prefix_free_hint(skip+1+i, alph)
}
// Verify that no hint is a prefix of another hint
for i := 0; i < total_hints; i++ {
for j := 0; j < total_hints; j++ {
if i == j {
continue
}
if strings.HasPrefix(hints[j], hints[i]) {
t.Errorf("For alphabet %q and n=%d: %q is a prefix of %q (skip=%d)", alph, total_hints, hints[i], hints[j], skip)
}
}
}
}
}
// HintsOffset + PrefixFree Test case 1: HintsOffset is smaller than dynamic
// offset (dynamic offset wins)
//
// With 4 matches, and alphabet size 3:
// hints_to_skip(4, 3) = 1.
// HintsOffset = 0.
// Effective offset = max(0, 1) = 1.
// Ascending = true.
// Match 0: 0 + 1 + 1 = 2 -> "b"
// Match 1: 1 + 1 + 1 = 3 -> "c"
// Match 2: 2 + 1 + 1 = 4 -> "aa"
// Match 3: 3 + 1 + 1 = 5 -> "ab"
opts1 := &Options{
Type: "url",
UrlPrefixes: "default",
Regex: kitty.HintsDefaultRegex,
PrefixFree: true,
Alphabet: "abc",
HintsOffset: 0,
Ascending: false, // default
}
_, marks1, _, err := find_marks("http://a.com http://b.com http://c.com http://d.com", opts1)
if err != nil {
t.Fatalf("find_marks failed: %v", err)
}
expectedCodes1 := []string{"ab", "aa", "c", "b"}
for i, m := range marks1 {
hint := generate_prefix_free_hint(m.Index, opts1.Alphabet)
if hint != expectedCodes1[i] {
t.Errorf("Case 1 - Match %d: got hint %q, expected %q", i, hint, expectedCodes1[i])
}
}
// HintsOffset + PrefixFree Test case 2: HintsOffset is larger than dynamic
// offset (HintsOffset wins)
//
// With 4 matches, and alphabet size 3:
// hints_to_skip(4, 3) = 1.
// HintsOffset = 3.
// Effective offset = max(3, 1) = 3.
// Ascending = true.
// Match 0: 0 + 3 + 1 = 4 -> "aa"
// Match 1: 1 + 3 + 1 = 5 -> "ab"
// Match 2: 2 + 3 + 1 = 6 -> "ac"
// Match 3: 3 + 3 + 1 = 7 -> "ba"
opts2 := &Options{
Type: "url",
UrlPrefixes: "default",
Regex: kitty.HintsDefaultRegex,
PrefixFree: true,
Alphabet: "abc",
HintsOffset: 3,
Ascending: true,
}
_, marks2, _, err := find_marks("http://a.com http://b.com http://c.com http://d.com", opts2)
if err != nil {
t.Fatalf("find_marks failed: %v", err)
}
expectedCodes2 := []string{"aa", "ab", "ac", "ba"}
for i, m := range marks2 {
hint := generate_prefix_free_hint(m.Index, opts2.Alphabet)
if hint != expectedCodes2[i] {
t.Errorf("Case 2 - Match %d: got hint %q, expected %q", i, hint, expectedCodes2[i])
}
}
}