patch 9.1.1214: matchfuzzy() can be improved for camel case matches

Problem:  When searching for "Cur", CamelCase matches like "lCursor" score
          higher than exact prefix matches like Cursor, which is
          counter-intuitive (Maxim Kim).
Solution: Add a 'camelcase' option to matchfuzzy() that lets users disable
          CamelCase bonuses when needed, making prefix matches rank higher.
          (glepnir)

fixes: #16504
closes: #16797

Signed-off-by: glepnir <glephunter@gmail.com>
Signed-off-by: Christian Brabandt <cb@256bit.org>
diff --git a/runtime/doc/builtin.txt b/runtime/doc/builtin.txt
index b7df7c5..3456093 100644
--- a/runtime/doc/builtin.txt
+++ b/runtime/doc/builtin.txt
@@ -1,4 +1,4 @@
-*builtin.txt*	For Vim version 9.1.  Last change: 2025 Mar 11
+*builtin.txt*	For Vim version 9.1.  Last change: 2025 Mar 16
 
 
 		  VIM REFERENCE MANUAL	  by Bram Moolenaar
@@ -7275,6 +7275,9 @@
 				given sequence.
 		    limit	Maximum number of matches in {list} to be
 				returned.  Zero means no limit.
+		    camelcase	Use enhanced camel case scoring making results
+				better suited for completion related to
+				programming languages. Default is v:true
 
 		If {list} is a list of dictionaries, then the optional {dict}
 		argument supports the following additional items:
diff --git a/src/proto/search.pro b/src/proto/search.pro
index e9be453..1927738 100644
--- a/src/proto/search.pro
+++ b/src/proto/search.pro
@@ -37,7 +37,7 @@
 spat_T *get_spat(int idx);
 int get_spat_last_idx(void);
 void f_searchcount(typval_T *argvars, typval_T *rettv);
-int fuzzy_match(char_u *str, char_u *pat_arg, int matchseq, int *outScore, int_u *matches, int maxMatches);
+int fuzzy_match(char_u *str, char_u *pat_arg, int matchseq, int *outScore, int_u *matches, int maxMatches, int camelcase);
 void f_matchfuzzy(typval_T *argvars, typval_T *rettv);
 void f_matchfuzzypos(typval_T *argvars, typval_T *rettv);
 int fuzzy_match_str(char_u *str, char_u *pat);
diff --git a/src/quickfix.c b/src/quickfix.c
index 1008dd5..e498d5e 100644
--- a/src/quickfix.c
+++ b/src/quickfix.c
@@ -6246,7 +6246,8 @@
 
 	    // Fuzzy string match
 	    CLEAR_FIELD(matches);
-	    while (fuzzy_match(str + col, spat, FALSE, &score, matches, sz) > 0)
+	    while (fuzzy_match(str + col, spat, FALSE, &score,
+			matches, sz, TRUE) > 0)
 	    {
 		// Pass the buffer number so that it gets used even for a
 		// dummy buffer, unless duplicate_name is set, then the
diff --git a/src/search.c b/src/search.c
index 064ed3e..c7ae4f8 100644
--- a/src/search.c
+++ b/src/search.c
@@ -42,11 +42,11 @@
 static int is_zero_width(char_u *pattern, size_t patternlen, int move, pos_T *cur, int direction);
 static void cmdline_search_stat(int dirc, pos_T *pos, pos_T *cursor_pos, int show_top_bot_msg, char_u *msgbuf, size_t msgbuflen, int recompute, int maxcount, long timeout);
 static void update_search_stat(int dirc, pos_T *pos, pos_T *cursor_pos, searchstat_T *stat, int recompute, int maxcount, long timeout);
-static int fuzzy_match_compute_score(char_u *fuzpat, char_u *str, int strSz, int_u *matches, int numMatches);
-static int fuzzy_match_recursive(char_u *fuzpat, char_u *str, int_u strIdx, int *outScore, char_u *strBegin, int strLen, int_u *srcMatches, int_u *matches, int maxMatches, int nextMatch, int *recursionCount);
+static int fuzzy_match_compute_score(char_u *fuzpat, char_u *str, int strSz, int_u *matches, int numMatches, int camelcase);
+static int fuzzy_match_recursive(char_u *fuzpat, char_u *str, int_u strIdx, int *outScore, char_u *strBegin, int strLen, int_u *srcMatches, int_u *matches, int maxMatches, int nextMatch, int *recursionCount, int camelcase);
 #if defined(FEAT_EVAL) || defined(FEAT_PROTO)
 static int fuzzy_match_item_compare(const void *s1, const void *s2);
-static void fuzzy_match_in_list(list_T *l, char_u *str, int matchseq, char_u *key, callback_T *item_cb, int retmatchpos, list_T *fmatchlist, long max_matches);
+static void fuzzy_match_in_list(list_T *l, char_u *str, int matchseq, char_u *key, callback_T *item_cb, int retmatchpos, list_T *fmatchlist, long max_matches, int camelcase);
 static void do_fuzzymatch(typval_T *argvars, typval_T *rettv, int retmatchpos);
 #endif
 static int fuzzy_match_str_compare(const void *s1, const void *s2);
@@ -4388,7 +4388,8 @@
 	char_u		*str,
 	int		strSz,
 	int_u		*matches,
-	int		numMatches)
+	int		numMatches,
+	int		camelcase)
 {
     int		score;
     int		penalty;
@@ -4461,7 +4462,7 @@
 	    }
 
 	    // Enhanced camel case scoring
-	    if (vim_islower(neighbor) && vim_isupper(curr))
+	    if (camelcase && vim_islower(neighbor) && vim_isupper(curr))
 	    {
 		score += CAMEL_BONUS * 2;  // Double the camel case bonus
 		is_camel = TRUE;
@@ -4544,7 +4545,8 @@
 	int_u		*matches,
 	int		maxMatches,
 	int		nextMatch,
-	int		*recursionCount)
+	int		*recursionCount,
+	int		camelcase)
 {
     // Recursion params
     int		recursiveMatch = FALSE;
@@ -4596,7 +4598,7 @@
 			&recursiveScore, strBegin, strLen, matches,
 			recursiveMatches,
 			ARRAY_LENGTH(recursiveMatches),
-			nextMatch, recursionCount))
+			nextMatch, recursionCount, camelcase))
 	    {
 		// Pick best recursive score
 		if (!recursiveMatch || recursiveScore > bestRecursiveScore)
@@ -4628,7 +4630,7 @@
     // Calculate score
     if (matched)
 	*outScore = fuzzy_match_compute_score(fuzpat, strBegin, strLen, matches,
-		nextMatch);
+		nextMatch, camelcase);
 
     // Return best result
     if (recursiveMatch && (!matched || bestRecursiveScore > *outScore))
@@ -4666,7 +4668,8 @@
 	int		matchseq,
 	int		*outScore,
 	int_u		*matches,
-	int		maxMatches)
+	int		maxMatches,
+	int		camelcase)
 {
     int		recursionCount = 0;
     int		len = MB_CHARLEN(str);
@@ -4714,7 +4717,7 @@
 	recursionCount = 0;
 	matchCount = fuzzy_match_recursive(pat, str, 0, &score, str, len, NULL,
 				matches + numMatches, maxMatches - numMatches,
-				0, &recursionCount);
+				0, &recursionCount, camelcase);
 	if (matchCount == 0)
 	{
 	    numMatches = 0;
@@ -4775,7 +4778,8 @@
 	callback_T	*item_cb,
 	int		retmatchpos,
 	list_T		*fmatchlist,
-	long		max_matches)
+	long		max_matches,
+	int		camelcase)
 {
     long	len;
     fuzzyItem_T	*items;
@@ -4836,7 +4840,7 @@
 
 	if (itemstr != NULL
 		&& fuzzy_match(itemstr, str, matchseq, &score, matches,
-							MAX_FUZZY_MATCHES))
+						MAX_FUZZY_MATCHES, camelcase))
 	{
 	    items[match_count].idx = match_count;
 	    items[match_count].item = li;
@@ -4955,6 +4959,7 @@
     int		ret;
     int		matchseq = FALSE;
     long	max_matches = 0;
+    int		camelcase = TRUE;
 
     if (in_vim9script()
 	    && (check_for_list_arg(argvars, 0) == FAIL
@@ -5020,6 +5025,16 @@
 	    max_matches = (long)tv_get_number_chk(&di->di_tv, NULL);
 	}
 
+	if ((di = dict_find(d, (char_u *)"camelcase", -1)) != NULL)
+        {
+	    if (di->di_tv.v_type != VAR_BOOL)
+	    {
+		semsg(_(e_invalid_argument_str), "camelcase");
+		return;
+	    }
+	    camelcase = tv_get_bool_chk(&di->di_tv, NULL);
+        }
+
 	if (dict_has_key(d, "matchseq"))
 	    matchseq = TRUE;
     }
@@ -5063,7 +5078,8 @@
     }
 
     fuzzy_match_in_list(argvars[0].vval.v_list, tv_get_string(&argvars[1]),
-	    matchseq, key, &cb, retmatchpos, rettv->vval.v_list, max_matches);
+	    matchseq, key, &cb, retmatchpos, rettv->vval.v_list, max_matches,
+	    camelcase);
 
 done:
     free_callback(&cb);
@@ -5166,7 +5182,7 @@
 	return 0;
 
     fuzzy_match(str, pat, TRUE, &score, matchpos,
-				sizeof(matchpos) / sizeof(matchpos[0]));
+				sizeof(matchpos) / sizeof(matchpos[0]), TRUE);
 
     return score;
 }
@@ -5193,7 +5209,7 @@
 	return NULL;
     ga_init2(match_positions, sizeof(int_u), 10);
 
-    if (!fuzzy_match(str, pat, FALSE, &score, matches, MAX_FUZZY_MATCHES)
+    if (!fuzzy_match(str, pat, FALSE, &score, matches, MAX_FUZZY_MATCHES, TRUE)
 	    || score == 0)
     {
 	ga_clear(match_positions);
diff --git a/src/testdir/test_matchfuzzy.vim b/src/testdir/test_matchfuzzy.vim
index cba0844..5280f2f 100644
--- a/src/testdir/test_matchfuzzy.vim
+++ b/src/testdir/test_matchfuzzy.vim
@@ -87,6 +87,10 @@
   let l = [{'id' : 5, 'name' : 'foo'}, {'id' : 6, 'name' : []}, {'id' : 7}]
   call assert_fails("let x = matchfuzzy(l, 'foo', {'key' : 'name'})", 'E730:')
 
+  " camcelcase
+  call assert_equal(['Cursor', 'CurSearch', 'CursorLine', 'lCursor', 'shCurlyIn', 'shCurlyError', 'TracesCursor'], matchfuzzy(['Cursor', 'lCursor', 'shCurlyIn', 'shCurlyError', 'TracesCursor', 'CurSearch', 'CursorLine'], 'Cur', {"camelcase": v:false}))
+  call assert_equal(['lCursor', 'shCurlyIn', 'shCurlyError', 'TracesCursor', 'Cursor', 'CurSearch', 'CursorLine'], matchfuzzy(['Cursor', 'lCursor', 'shCurlyIn', 'shCurlyError', 'TracesCursor', 'CurSearch', 'CursorLine'], 'Cur'))
+
   " Test in latin1 encoding
   let save_enc = &encoding
   set encoding=latin1
@@ -168,6 +172,15 @@
 
   let l = [{'id' : 5, 'name' : 'foo'}, {'id' : 6, 'name' : []}, {'id' : 7}]
   call assert_fails("let x = matchfuzzypos(l, 'foo', {'key' : 'name'})", 'E730:')
+
+  "camelcase
+  call assert_equal([['lCursor', 'shCurlyIn', 'shCurlyError', 'TracesCursor', 'Cursor', 'CurSearch', 'CursorLine'], [[1, 2, 3], [2, 3, 4], [2, 3, 4], [6, 7, 8], [0, 1, 2], [0, 1, 2], [0, 1, 2]], [318, 311, 308, 303, 267, 264, 263]],
+      \ matchfuzzypos(['Cursor', 'lCursor', 'shCurlyIn', 'shCurlyError', 'TracesCursor', 'CurSearch', 'CursorLine'], 'Cur'))
+
+  call assert_equal([['Cursor', 'CurSearch', 'CursorLine', 'lCursor', 'shCurlyIn', 'shCurlyError', 'TracesCursor'], [[0, 1, 2], [0, 1, 2], [0, 1, 2], [1, 2, 3], [2, 3, 4], [2, 3, 4], [6, 7, 8]], [267, 264, 263, 246, 239, 236, 231]],
+        \ matchfuzzypos(['Cursor', 'lCursor', 'shCurlyIn', 'shCurlyError', 'TracesCursor', 'CurSearch', 'CursorLine'], 'Cur', {"camelcase": v:false}))
+  call assert_equal([['things', 'sThings', 'thisThings'], [[0, 1, 2, 3], [1, 2, 3, 4], [0, 1, 2, 7]], [333, 287, 279]],
+        \ matchfuzzypos(['things','sThings', 'thisThings'], 'thin', {'camelcase': v:false}))
 endfunc
 
 " Test for matchfuzzy() with multibyte characters
diff --git a/src/version.c b/src/version.c
index b69d34d..b633dc8 100644
--- a/src/version.c
+++ b/src/version.c
@@ -705,6 +705,8 @@
 static int included_patches[] =
 {   /* Add new patch number below this line */
 /**/
+    1214,
+/**/
     1213,
 /**/
     1212,