From 5d1215b11df8219bbfc87ff06a00e490045da50d Mon Sep 17 00:00:00 2001 From: Hector Tellez Date: Wed, 1 Jul 2026 00:38:32 -0700 Subject: [PATCH] Implements option to generate prefix-free hints - Declares --prefix-free CLI flag in main.py. - Implements dynamic index offset shifting in marks.go to prevent generating hints that prefix other hints. - Integrates prefix-free encoding in main.go. - Includes tests. Implements #10195 --- kittens/hints/main.go | 15 ++- kittens/hints/main.py | 10 ++ kittens/hints/marks.go | 21 +++- kittens/hints/prefix_free_hints.go | 62 ++++++++++++ kittens/hints/prefix_free_hints_test.go | 123 ++++++++++++++++++++++++ 5 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 kittens/hints/prefix_free_hints.go create mode 100644 kittens/hints/prefix_free_hints_test.go diff --git a/kittens/hints/main.go b/kittens/hints/main.go index e8249c12c..116133b11 100644 --- a/kittens/hints/main.go +++ b/kittens/hints/main.go @@ -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)) 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) { return faint(mark_text) } @@ -311,7 +316,13 @@ func main(_ *cli.Command, o *Options, args []string) (rc int, err error) { if changed { matches := []*Mark{} 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) } } diff --git a/kittens/hints/main.py b/kittens/hints/main.py index da69fa898..c9befc906 100644 --- a/kittens/hints/main.py +++ b/kittens/hints/main.py @@ -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. +--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 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. diff --git a/kittens/hints/marks.go b/kittens/hints/marks.go index 41b111f30..55c9b2d19 100644 --- a/kittens/hints/marks.go +++ b/kittens/hints/marks.go @@ -706,13 +706,28 @@ process_answer: } largest_index := ans[len(ans)-1].Index 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)) for i := range ans { m := &ans[i] - if opts.Ascending { - m.Index += offset + if opts.PrefixFree { + if opts.Ascending { + m.Index = i + offset + 1 + } else { + m.Index = (largest_index - m.Index) + offset + 1 + } } 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 } diff --git a/kittens/hints/prefix_free_hints.go b/kittens/hints/prefix_free_hints.go new file mode 100644 index 000000000..e308b47aa --- /dev/null +++ b/kittens/hints/prefix_free_hints.go @@ -0,0 +1,62 @@ +// License: GPLv3 Copyright: 2026, Kovid Goyal, + +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 +} diff --git a/kittens/hints/prefix_free_hints_test.go b/kittens/hints/prefix_free_hints_test.go new file mode 100644 index 000000000..8741164a0 --- /dev/null +++ b/kittens/hints/prefix_free_hints_test.go @@ -0,0 +1,123 @@ +// License: GPLv3 Copyright: 2026, Kovid Goyal, + +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]) + } + } +}