Files
profanity/src/tools/autocomplete.c
Michael Vetter e9c6b3b3d8 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.
2026-03-19 08:41:50 +01:00

422 lines
10 KiB
C

/*
* autocomplete.c
* vim: expandtab:ts=4:sts=4:sw=4
*
* Copyright (C) 2012 - 2019 James Booth <boothj5@gmail.com>
*
* SPDX-License-Identifier: GPL-3.0-or-later WITH OpenSSL-exception
*/
#include "config.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <glib.h>
#include "common.h"
#include "tools/autocomplete.h"
#include "tools/parser.h"
#include "ui/ui.h"
typedef enum {
PREVIOUS,
NEXT
} search_direction;
struct autocomplete_t
{
GList* items;
GList* last_found;
gchar* search_str;
};
static gchar* _search(Autocomplete ac, GList* curr, gboolean quote, search_direction direction);
Autocomplete
autocomplete_new(void)
{
return g_new0(struct autocomplete_t, 1);
}
void
autocomplete_clear(Autocomplete ac)
{
if (ac) {
if (ac->items) {
g_list_free_full(ac->items, free);
ac->items = NULL;
}
autocomplete_reset(ac);
}
}
void
autocomplete_reset(Autocomplete ac)
{
ac->last_found = NULL;
FREE_SET_NULL(ac->search_str);
}
void
autocomplete_free(Autocomplete ac)
{
if (ac) {
autocomplete_clear(ac);
free(ac);
}
}
gint
autocomplete_length(Autocomplete ac)
{
if (!ac) {
return 0;
} else if (!ac->items) {
return 0;
} else {
return g_list_length(ac->items);
}
}
void
autocomplete_update(Autocomplete ac, char** items)
{
auto_gchar gchar* last_found = NULL;
auto_gchar gchar* search_str = NULL;
if (ac->last_found) {
last_found = strdup(ac->last_found->data);
}
if (ac->search_str) {
search_str = strdup(ac->search_str);
}
autocomplete_clear(ac);
autocomplete_add_all(ac, items);
if (last_found) {
// NULL if last_found was removed on update.
ac->last_found = g_list_find_custom(ac->items, last_found, (GCompareFunc)strcmp);
}
if (search_str) {
ac->search_str = strdup(search_str);
}
}
void
autocomplete_add_unsorted(Autocomplete ac, const char* item, const gboolean is_reversed)
{
if (ac) {
char* item_cpy;
GList* curr = g_list_find_custom(ac->items, item, (GCompareFunc)strcmp);
// if item already exists
if (curr) {
return;
}
item_cpy = strdup(item);
if (is_reversed) {
ac->items = g_list_prepend(ac->items, item_cpy);
} else {
ac->items = g_list_append(ac->items, item_cpy);
}
}
}
void
autocomplete_add(Autocomplete ac, const char* item)
{
if (ac) {
char* item_cpy;
GList* curr = g_list_find_custom(ac->items, item, (GCompareFunc)strcmp);
// if item already exists
if (curr) {
return;
}
item_cpy = strdup(item);
ac->items = g_list_insert_sorted(ac->items, item_cpy, (GCompareFunc)strcmp);
}
}
void
autocomplete_add_all(Autocomplete ac, char** items)
{
for (guint i = 0; i < g_strv_length(items); i++) {
autocomplete_add(ac, items[i]);
}
}
void
autocomplete_remove(Autocomplete ac, const char* const item)
{
if (ac) {
GList* curr = g_list_find_custom(ac->items, item, (GCompareFunc)strcmp);
if (!curr) {
return;
}
// reset last found if it points to the item to be removed
if (ac->last_found == curr) {
ac->last_found = NULL;
}
free(curr->data);
ac->items = g_list_delete_link(ac->items, curr);
}
return;
}
void
autocomplete_remove_all(Autocomplete ac, char** items)
{
for (guint i = 0; i < g_strv_length(items); i++) {
autocomplete_remove(ac, items[i]);
}
}
GList*
autocomplete_create_list(Autocomplete ac)
{
GList* copy = NULL;
GList* curr = ac->items;
while (curr) {
copy = g_list_append(copy, strdup(curr->data));
curr = g_list_next(curr);
}
return copy;
}
gboolean
autocomplete_contains(Autocomplete ac, const char* value)
{
GList* curr = ac->items;
while (curr) {
if (strcmp(curr->data, value) == 0) {
return TRUE;
}
curr = g_list_next(curr);
}
return FALSE;
}
gchar*
autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean quote, gboolean previous)
{
gchar* found = NULL;
// no autocomplete to search
if (!ac) {
return NULL;
}
// no items to search
if (!ac->items) {
return NULL;
}
// first search attempt
if (!ac->last_found) {
if (ac->search_str) {
FREE_SET_NULL(ac->search_str);
}
ac->search_str = strdup(search_str);
found = _search(ac, ac->items, quote, NEXT);
return found;
// subsequent search attempt
} else {
if (previous) {
// search from here-1 to beginning
found = _search(ac, g_list_previous(ac->last_found), quote, PREVIOUS);
if (found) {
return found;
}
} else {
// search from here+1 to end
found = _search(ac, g_list_next(ac->last_found), quote, NEXT);
if (found) {
return found;
}
}
if (previous) {
// search from end
found = _search(ac, g_list_last(ac->items), quote, PREVIOUS);
if (found) {
return found;
}
} else {
// search from beginning
found = _search(ac, ac->items, quote, NEXT);
if (found) {
return found;
}
}
// we found nothing, reset search
autocomplete_reset(ac);
return NULL;
}
}
// autocomplete_func func is used -> autocomplete_param_with_func
// Autocomplete ac, gboolean quote are used -> autocomplete_param_with_ac
static char*
_autocomplete_param_common(const char* const input, char* command, autocomplete_func func, Autocomplete ac, gboolean quote, gboolean previous, void* context)
{
int len;
auto_gchar gchar* command_cpy = g_strdup_printf("%s ", command);
if (!command_cpy) {
return NULL;
}
len = strlen(command_cpy);
if (strncmp(input, command_cpy, len) == 0) {
int inp_len = strlen(input);
auto_char char* found = NULL;
auto_char char* prefix = malloc(inp_len + 1);
if (!prefix) {
return NULL;
}
for (int i = len; i < inp_len; i++) {
prefix[i - len] = input[i];
}
prefix[inp_len - len] = '\0';
if (func) {
found = func(prefix, previous, context);
} else {
found = autocomplete_complete(ac, prefix, quote, previous);
}
if (found) {
return g_strdup_printf("%s%s", command_cpy, found);
}
}
return NULL;
}
char*
autocomplete_param_with_func(const char* const input, char* command, autocomplete_func func, gboolean previous, void* context)
{
return _autocomplete_param_common(input, command, func, NULL, FALSE, previous, context);
}
char*
autocomplete_param_with_ac(const char* const input, char* command, Autocomplete ac, gboolean quote, gboolean previous)
{
return _autocomplete_param_common(input, command, NULL, ac, quote, previous, NULL);
}
char*
autocomplete_param_no_with_func(const char* const input, char* command, int arg_number, autocomplete_func func, gboolean previous, void* context)
{
if (strncmp(input, command, strlen(command)) == 0) {
// count tokens properly
int num_tokens = count_tokens(input);
// if correct number of tokens, then candidate for autocompletion of last param
if (num_tokens == arg_number) {
auto_gchar gchar* start_str = get_start(input, arg_number);
auto_gchar gchar* comp_str = g_strdup(&input[strlen(start_str)]);
// autocomplete param
if (comp_str) {
auto_gchar gchar* found = func(comp_str, previous, context);
if (found) {
return g_strdup_printf("%s%s", start_str, found);
}
}
}
}
return NULL;
}
/* remove the last message if we have more than max */
void
autocomplete_remove_older_than_max_reverse(Autocomplete ac, int maxsize)
{
if (autocomplete_length(ac) > maxsize) {
GList* last = g_list_last(ac->items);
if (last) {
free(last->data);
ac->items = g_list_delete_link(ac->items, last);
}
}
}
static gchar*
_search(Autocomplete ac, GList* curr, gboolean quote, search_direction direction)
{
auto_gchar gchar* search_str_fold = g_utf8_casefold(ac->search_str, -1);
auto_gchar gchar* search_str_ascii = g_str_to_ascii(ac->search_str, NULL);
auto_gchar gchar* search_str_ascii_lower = g_ascii_strdown(search_str_ascii, -1);
while (curr) {
gboolean match = FALSE;
// Try exact UTF-8 case insensitive match
auto_gchar gchar* curr_fold = g_utf8_casefold(curr->data, -1);
if (g_str_has_prefix(curr_fold, search_str_fold)) {
match = TRUE;
}
// Try transliterated match (allow typing 'e' for 'è')
if (!match) {
auto_gchar gchar* curr_ascii = g_str_to_ascii(curr->data, NULL);
auto_gchar gchar* curr_ascii_lower = g_ascii_strdown(curr_ascii, -1);
// We only use transliterated match if the search string conversion didn't result
// in unknown characters ('?'), to avoid false matches between different scripts.
if (strchr(search_str_ascii_lower, '?') == NULL) {
if (g_str_has_prefix(curr_ascii_lower, search_str_ascii_lower)) {
match = TRUE;
}
}
}
if (match) {
// set pointer to last found
ac->last_found = curr;
// if contains space, quote before returning
if (quote && g_strrstr(curr->data, " ")) {
return g_strdup_printf("\"%s\"", (gchar*)curr->data);
// otherwise just return the string
} else {
return strdup(curr->data);
}
}
if (direction == PREVIOUS) {
curr = g_list_previous(curr);
} else {
curr = g_list_next(curr);
}
}
return NULL;
}