1212//! already order correctly.
1313//!
1414//! Matching is case-insensitive until the query itself contains an uppercase character
15- //! ("smart case"), the convention every interactive fuzzy finder uses: `readme` finds `README`,
16- //! but `README` no longer finds `readme.txt`. zf's own default is case-sensitive, which is the
17- //! wrong default for a filter box.
15+ //! ("smart case"), the convention every interactive fuzzy finder uses: `readme` finds `README`.
16+ //! zf's own default is case-sensitive, which is the wrong default for a filter box.
17+ //!
18+ //! Smart case here *prefers* an exact-case match rather than requiring one: a candidate that only
19+ //! matches case-insensitively still matches, it just ranks behind every exact-case hit (see
20+ //! `case_fallback_penalty`). Strict smart case makes a mixed-case query silently return nothing —
21+ //! `Cl` would not find `CLAUDE.md`, because the `l` is upper there — which reads as a broken
22+ //! filter box rather than a precision feature.
1823const std = @import ("std" );
1924const zf = @import ("zf" );
2025
@@ -67,15 +72,22 @@ pub const Options = struct {
6772 plain : bool = true ,
6873};
6974
75+ /// Added to the score of a candidate that only matched once case was ignored, so exact-case hits
76+ /// always sort ahead of them. Far larger than any rank zf produces for a realistic label or path
77+ /// (those are single or double digits), so the two form clean bands rather than interleaving.
78+ pub const case_fallback_penalty : f64 = 1000 ;
79+
7080/// Score `haystack` against `query`. Null means "no match" — the candidate should be dropped,
7181/// not merely sorted last. **Lower is better**; an empty query scores everything 0.
7282pub fn score (haystack : []const u8 , query : * const Query , opts : Options ) ? f64 {
7383 if (query .isEmpty ()) return 0 ;
7484 if (haystack .len == 0 ) return null ;
75- return zf .rank (haystack , query .tokens (), .{
76- .case_sensitive = query .case_sensitive ,
77- .plain = opts .plain ,
78- });
85+ if (query .case_sensitive ) {
86+ if (zf .rank (haystack , query .tokens (), .{ .case_sensitive = true , .plain = opts .plain })) | s | return s ;
87+ const relaxed = zf .rank (haystack , query .tokens (), .{ .case_sensitive = false , .plain = opts .plain }) orelse return null ;
88+ return relaxed + case_fallback_penalty ;
89+ }
90+ return zf .rank (haystack , query .tokens (), .{ .case_sensitive = false , .plain = opts .plain });
7991}
8092
8193/// Best (lowest) score across several fields of one candidate — a store row matching on any of
@@ -99,8 +111,12 @@ pub fn matches(haystack: []const u8, query: *const Query, opts: Options) bool {
99111/// `highlight_buf_len` unless you know better. Returns empty for an empty query.
100112pub fn highlight (haystack : []const u8 , query : * const Query , buf : []usize , opts : Options ) []const usize {
101113 if (query .isEmpty () or haystack .len == 0 ) return &.{};
114+ // Mirror `score`'s smart-case fallback, or a candidate that only matched with case ignored
115+ // would come back with no (or partial) highlights.
116+ const case_sensitive = query .case_sensitive and
117+ zf .rank (haystack , query .tokens (), .{ .case_sensitive = true , .plain = opts .plain }) != null ;
102118 return zf .highlight (haystack , query .tokens (), buf , .{
103- .case_sensitive = query . case_sensitive ,
119+ .case_sensitive = case_sensitive ,
104120 .plain = opts .plain ,
105121 });
106122}
@@ -158,7 +174,29 @@ test "smart case" {
158174 var upper = Query .init ("README" );
159175 try testing .expect (upper .case_sensitive );
160176 try testing .expect (matches ("README" , & upper , .{}));
161- try testing .expect (! matches ("readme.txt" , & upper , .{}));
177+ // Case still only ranks — a wrong-case candidate matches, behind every exact-case one.
178+ try testing .expect (matches ("readme.txt" , & upper , .{}));
179+ try testing .expect (score ("README" , & upper , .{}).? < score ("readme.txt" , & upper , .{}).? );
180+ }
181+
182+ test "a mixed-case query still finds an all-caps candidate" {
183+ var q = Query .init ("Cl" );
184+ try testing .expect (q .case_sensitive );
185+ try testing .expect (matches ("CLAUDE.md" , & q , .{ .plain = false }));
186+
187+ // ...and it is highlighted, not left blank by the case-sensitive pass.
188+ var buf : [highlight_buf_len ]usize = undefined ;
189+ const hits = highlight ("CLAUDE.md" , & q , & buf , .{ .plain = false });
190+ try testing .expectEqualSlices (usize , &.{ 0 , 1 }, hits );
191+ }
192+
193+ test "exact-case hits band ahead of case-folded ones" {
194+ var q = Query .init ("Cl" );
195+ const exact = score ("src/Client.zig" , & q , .{ .plain = false }).? ;
196+ const folded = score ("CLAUDE.md" , & q , .{ .plain = false }).? ;
197+ try testing .expect (exact < case_fallback_penalty );
198+ try testing .expect (folded >= case_fallback_penalty );
199+ try testing .expect (exact < folded );
162200}
163201
164202test "scattered characters match, missing ones do not" {
0 commit comments