feat(tools): support UTF-8 characters in autocompletion

Autocompletion failed for nicknames using non Latin scripts. We used
`g_str_to_ascii`, which replaces characters it cannot transliterate with
'?', leading search failures and false matches between different
scripts.

Now we do:
Use `g_utf8_casefold` for case-insensitive UTF-8 comparison. This
ensures that like 'Σ' correctly match 'σ' across all Unicode scripts,
providing correct results for non English nicknames.

If we don't fine anything typing a base ASCII character matches an
accented one (typing `e` matches `è`). This pass uses `g_str_to_ascii`
followed by `g_ascii_strdown` for comparison. It is now restricted to
run if the search string itself is valid ASCII, preventing the
"everything matches '?'" bug in non Latin scripts.

Autocomplete items are sorted using `strcmp`. `g_utf8_collate` provides
linguistical ordering for a specific language. But its behavior is
locale dependent and undefined when comparing strings from different
scripts. `strcmp` does byte-wise ordering that correctly follows Unicode
code order for UTF-8 strings.
This commit is contained in:
Michael Vetter
2026-03-19 08:22:08 +01:00
parent 9de455ceea
commit e9c6b3b3d8
10 changed files with 238 additions and 5 deletions

View File

@@ -233,3 +233,115 @@ autocomplete_complete__returns__previous(void** state)
free(result3);
free(result4);
}
void
autocomplete_complete__returns__greek_false_match(void** state)
{
Autocomplete ac = autocomplete_new();
// Σωκράτης (Socrates) and Πλάτων (Plato)
autocomplete_add(ac, "Σωκράτης");
autocomplete_add(ac, "Πλάτων");
char* result = autocomplete_complete(ac, "Π", TRUE, FALSE);
assert_string_equal("Πλάτων", result);
autocomplete_free(ac);
free(result);
}
void
autocomplete_complete__returns__greek(void** state)
{
Autocomplete ac = autocomplete_new();
autocomplete_add(ac, "Αριστοτέλης");
char* result = autocomplete_complete(ac, "Αριστ", TRUE, FALSE);
assert_non_null(result);
assert_string_equal("Αριστοτέλης", result);
autocomplete_free(ac);
free(result);
}
void
autocomplete_complete__returns__greek_case_insensitive(void** state)
{
Autocomplete ac = autocomplete_new();
autocomplete_add(ac, "Σωκράτης");
// Case insensitive search for Socrates
// σω is the lowercase of Σω
char* result = autocomplete_complete(ac, "σω", TRUE, FALSE);
assert_non_null(result);
assert_string_equal("Σωκράτης", result);
autocomplete_free(ac);
free(result);
}
void
autocomplete_complete__returns__russian(void** state)
{
Autocomplete ac = autocomplete_new();
autocomplete_add(ac, "Достоевский");
autocomplete_add(ac, "Толстой");
autocomplete_add(ac, "Пушкин");
char* result = autocomplete_complete(ac, "дост", TRUE, FALSE);
assert_non_null(result);
assert_string_equal("Достоевский", result);
autocomplete_free(ac);
free(result);
}
void
autocomplete_complete__returns__chinese(void** state)
{
Autocomplete ac = autocomplete_new();
autocomplete_add(ac, "孙子");
autocomplete_add(ac, "诸葛亮");
char* result = autocomplete_complete(ac, "", TRUE, FALSE);
assert_non_null(result);
assert_string_equal("孙子", result);
autocomplete_free(ac);
free(result);
}
void
autocomplete_complete__returns__transliterated(void** state)
{
Autocomplete ac = autocomplete_new();
autocomplete_add(ac, "München");
// Match 'ü' with 'u'
char* result = autocomplete_complete(ac, "mun", TRUE, FALSE);
assert_non_null(result);
assert_string_equal("München", result);
autocomplete_free(ac);
free(result);
}
void
autocomplete_complete__returns__regular_ascii(void** state)
{
Autocomplete ac = autocomplete_new();
autocomplete_add(ac, "London");
char* result = autocomplete_complete(ac, "lon", TRUE, FALSE);
assert_non_null(result);
assert_string_equal("London", result);
autocomplete_free(ac);
free(result);
}

View File

@@ -17,5 +17,12 @@ void autocomplete_complete__returns__both_with_accented(void** state);
void autocomplete_complete__returns__both_with_base(void** state);
void autocomplete_complete__is__case_insensitive(void** state);
void autocomplete_complete__returns__previous(void** state);
void autocomplete_complete__returns__greek(void** state);
void autocomplete_complete__returns__greek_false_match(void** state);
void autocomplete_complete__returns__greek_case_insensitive(void** state);
void autocomplete_complete__returns__russian(void** state);
void autocomplete_complete__returns__chinese(void** state);
void autocomplete_complete__returns__transliterated(void** state);
void autocomplete_complete__returns__regular_ascii(void** state);
#endif

View File

@@ -132,6 +132,13 @@ main(int argc, char* argv[])
cmocka_unit_test(autocomplete_complete__returns__both_with_base),
cmocka_unit_test(autocomplete_complete__is__case_insensitive),
cmocka_unit_test(autocomplete_complete__returns__previous),
cmocka_unit_test(autocomplete_complete__returns__greek),
cmocka_unit_test(autocomplete_complete__returns__greek_false_match),
cmocka_unit_test(autocomplete_complete__returns__greek_case_insensitive),
cmocka_unit_test(autocomplete_complete__returns__russian),
cmocka_unit_test(autocomplete_complete__returns__chinese),
cmocka_unit_test(autocomplete_complete__returns__transliterated),
cmocka_unit_test(autocomplete_complete__returns__regular_ascii),
cmocka_unit_test(jid_create__returns__null_from_null),
cmocka_unit_test(jid_create__returns__null_from_empty_string),
@@ -240,6 +247,7 @@ main(int argc, char* argv[])
cmocka_unit_test(roster_contact_autocomplete__returns__second_when_two_match),
cmocka_unit_test(roster_contact_autocomplete__returns__fifth_when_multiple_match),
cmocka_unit_test(roster_contact_autocomplete__returns__first_when_two_match_and_reset),
cmocka_unit_test(roster_contact_autocomplete__returns__utf8),
cmocka_unit_test(roster_get_groups__returns__empty_for_no_group),
cmocka_unit_test(roster_get_groups__returns__one_group),
cmocka_unit_test(roster_get_groups__returns__two_groups),

View File

@@ -305,8 +305,30 @@ roster_contact_autocomplete__returns__first_when_two_match_and_reset(void** stat
roster_destroy();
}
void
roster_contact_autocomplete__returns__utf8(void** state)
{
roster_create();
roster_add("Σωκράτης", NULL, NULL, NULL, FALSE);
roster_add("Πλάτων", NULL, NULL, NULL, FALSE);
// Byte-wise (strcmp): Πλάτων (CE A0...) < Σωκράτης (CE A3...)
char* result = roster_contact_autocomplete("Π", FALSE, NULL);
assert_string_equal("Πλάτων", result);
g_free(result);
roster_reset_search_attempts();
result = roster_contact_autocomplete("σω", FALSE, NULL);
assert_string_equal("Σωκράτης", result);
g_free(result);
roster_destroy();
}
void
roster_get_groups__returns__empty_for_no_group(void** state)
{
roster_create();
roster_add("person@server.org", NULL, NULL, NULL, FALSE);

View File

@@ -19,6 +19,7 @@ void roster_contact_autocomplete__returns__null_on_empty_roster(void** state);
void roster_contact_autocomplete__returns__second_when_two_match(void** state);
void roster_contact_autocomplete__returns__fifth_when_multiple_match(void** state);
void roster_contact_autocomplete__returns__first_when_two_match_and_reset(void** state);
void roster_contact_autocomplete__returns__utf8(void** state);
void roster_get_groups__returns__empty_for_no_group(void** state);
void roster_get_groups__returns__one_group(void** state);
void roster_get_groups__returns__two_groups(void** state);