From eecf1e3e29b4d7c4eb2bf6a303866007722acb8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Helleu?= Date: Tue, 26 May 2026 18:43:09 +0200 Subject: [PATCH] core: implement theme file parsing and transient file reads in /theme apply Add a small INI-style parser for *.theme files and wire it into the /theme command so user themes living in directory "themes" inside the WeeChat configuration directory can be applied (and inspected) without ever being cached. Parser (theme_file_parse in core-theme.c) accepts two sections: [info] name = "..." \ shown by /theme info; ignored for apply description = "..." | date = "..." | weechat = "..." / (unknown keys are ignored with a warning) [options] full.option.name = "value" Surrounding single or double quotes around a value are stripped (same rule used by the regular config file reader). The parsed result is a heap-allocated t_theme; the caller frees with theme_free. Resolution rule in theme_apply: if the path "${weechat_config_dir}/themes/.theme" is readable it is parsed and used (file shadows any built-in of the same name); otherwise the built-in registry is consulted. The transient t_theme is freed before the final refresh, so user themes have no steady-state memory footprint regardless of how many .theme files have accumulated. /theme list now also scans the themes directory and appends user files to the listing (each marked "(file)"). backup-*.theme are hidden by default; pass "-backups" to include them. /theme info works for both sources: file path is shown when the information comes from disk; "built-in (in-memory)" otherwise. --- src/core/core-command.c | 171 +++++++++++++++--- src/core/core-theme.c | 260 +++++++++++++++++++++++++++- src/core/core-theme.h | 3 + tests/unit/core/test-core-theme.cpp | 154 ++++++++++++++++ 4 files changed, 557 insertions(+), 31 deletions(-) diff --git a/src/core/core-command.c b/src/core/core-command.c index a3c453a4c..f98e8efe9 100644 --- a/src/core/core-command.c +++ b/src/core/core-command.c @@ -7181,16 +7181,65 @@ COMMAND_CALLBACK(sys) COMMAND_ERROR; } +/* + * Callback for "dir_exec_on_files": collects names of files matching + * "*.theme" into an arraylist passed as data; "backup-*.theme" is + * excluded when *(int *)data->show_backups is zero. + * + * The arraylist is iterated outside this callback so all dirent + * processing can happen in one place. + */ + +struct t_command_theme_dir_collect +{ + struct t_arraylist *names; /* arraylist of char * (owned) */ + int show_backups; /* include backup-*.theme ? */ +}; + +void +command_theme_collect_file_cb (void *data, const char *filename) +{ + struct t_command_theme_dir_collect *ctx; + const char *base; + char *name; + size_t len; + + ctx = (struct t_command_theme_dir_collect *)data; + base = strrchr (filename, '/'); + base = (base) ? base + 1 : filename; + len = strlen (base); + if ((len < 7) || (strcmp (base + len - 6, ".theme") != 0)) + return; + if (!ctx->show_backups && (strncmp (base, "backup-", 7) == 0)) + return; + name = string_strndup (base, len - 6); + if (name) + arraylist_add (ctx->names, name); +} + +int +command_theme_strcmp_cb (void *data, struct t_arraylist *arraylist, + void *pointer1, void *pointer2) +{ + /* make C compiler happy */ + (void) data; + (void) arraylist; + + return strcmp ((const char *)pointer1, (const char *)pointer2); +} + /* * Callback for command "/theme": list or display details on themes. */ COMMAND_CALLBACK(theme) { - struct t_arraylist *list; - struct t_theme *ptr_theme; - const char *ptr_active; - int i, size; + struct t_arraylist *list, *file_names; + struct t_command_theme_dir_collect collect; + struct t_theme *ptr_theme, *file_theme; + const char *ptr_active, *ptr_name; + char *path, *dir; + int i, size, show_backups; /* make C compiler happy */ (void) pointer; @@ -7198,20 +7247,46 @@ COMMAND_CALLBACK(theme) (void) buffer; (void) argv_eol; - /* "/theme" or "/theme list": list themes */ + /* "/theme" or "/theme list [-backups]": list themes */ if ((argc == 1) || (string_strcmp (argv[1], "list") == 0)) { + show_backups = ((argc >= 3) + && (string_strcmp (argv[2], "-backups") == 0)); + ptr_active = CONFIG_STRING(config_look_theme); + list = theme_list (); - if (!list || (arraylist_size (list) == 0)) + + /* scan ${weechat_config_dir}/themes/ for *.theme files */ + file_names = arraylist_new (8, 1, 0, + &command_theme_strcmp_cb, NULL, + NULL, NULL); + if (file_names) { - gui_chat_printf (NULL, _("No theme registered")); + dir = NULL; + string_asprintf (&dir, "%s/themes", weechat_config_dir); + if (dir) + { + collect.names = file_names; + collect.show_backups = show_backups; + dir_exec_on_files (dir, 0, 0, + &command_theme_collect_file_cb, + &collect); + free (dir); + } + } + + if ((!list || (arraylist_size (list) == 0)) + && (!file_names || (arraylist_size (file_names) == 0))) + { + gui_chat_printf (NULL, _("No theme available")); arraylist_free (list); + arraylist_free (file_names); return WEECHAT_RC_OK; } - ptr_active = CONFIG_STRING(config_look_theme); + gui_chat_printf (NULL, ""); gui_chat_printf (NULL, _("Themes:")); - size = arraylist_size (list); + size = (list) ? arraylist_size (list) : 0; for (i = 0; i < size; i++) { ptr_theme = (struct t_theme *)arraylist_get (list, i); @@ -7227,6 +7302,26 @@ COMMAND_CALLBACK(theme) ? ": " : "", (ptr_theme->description) ? ptr_theme->description : ""); } + size = (file_names) ? arraylist_size (file_names) : 0; + for (i = 0; i < size; i++) + { + ptr_name = (const char *)arraylist_get (file_names, i); + gui_chat_printf ( + NULL, + " %s %s%s%s (file)", + (ptr_active && (strcmp (ptr_active, ptr_name) == 0)) + ? "->" : " ", + GUI_COLOR(GUI_COLOR_CHAT_BUFFER), + ptr_name, + GUI_COLOR(GUI_COLOR_CHAT)); + } + if (file_names) + { + size = arraylist_size (file_names); + for (i = 0; i < size; i++) + free (arraylist_get (file_names, i)); + arraylist_free (file_names); + } arraylist_free (list); return WEECHAT_RC_OK; } @@ -7242,21 +7337,46 @@ COMMAND_CALLBACK(theme) if (string_strcmp (argv[1], "info") == 0) { COMMAND_MIN_ARGS(3, "info"); - ptr_theme = theme_search (argv[2]); - if (!ptr_theme) + /* file shadows registry: try user file first */ + path = theme_user_file_path (argv[2]); + file_theme = NULL; + if (path && (access (path, R_OK) == 0)) + file_theme = theme_file_parse (path); + if (!file_theme) { - gui_chat_printf (NULL, - _("%sTheme \"%s\" not found"), - gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], - argv[2]); - return WEECHAT_RC_ERROR; + free (path); + path = NULL; + ptr_theme = theme_search (argv[2]); + if (!ptr_theme) + { + gui_chat_printf (NULL, + _("%sTheme \"%s\" not found"), + gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], + argv[2]); + return WEECHAT_RC_ERROR; + } + } + else + { + ptr_theme = file_theme; } gui_chat_printf (NULL, ""); gui_chat_printf (NULL, _("Theme \"%s%s%s\":"), GUI_COLOR(GUI_COLOR_CHAT_BUFFER), - ptr_theme->name, + (ptr_theme->name && ptr_theme->name[0]) + ? ptr_theme->name : argv[2], GUI_COLOR(GUI_COLOR_CHAT)); + if (path) + { + gui_chat_printf (NULL, + _(" source : %s"), path); + } + else + { + gui_chat_printf (NULL, + _(" source : built-in (in-memory)")); + } gui_chat_printf (NULL, _(" description : %s"), (ptr_theme->description) ? ptr_theme->description : ""); @@ -7271,6 +7391,8 @@ COMMAND_CALLBACK(theme) _(" overrides : %d"), (ptr_theme->overrides) ? ptr_theme->overrides->items_count : 0); + free (path); + theme_free (file_theme); return WEECHAT_RC_OK; } @@ -9933,14 +10055,19 @@ command_init (void) NULL, "theme", N_("manage color themes"), /* TRANSLATORS: only text between angle brackets (eg: "") may be translated */ - N_("[list]" + N_("[list [-backups]]" " || apply " " || info "), CMD_ARGS_DESC( - N_("raw[list]: list registered themes (default action with no " - "argument); active theme is marked with \"->\""), + N_("raw[list]: list registered themes and any *.theme files in " + "the WeeChat configuration directory; the active theme " + "(matching weechat.look.theme) is marked with \"->\". By " + "default backup-*.theme files are hidden; pass \"-backups\" " + "to include them"), N_("raw[apply]: apply a theme (set every themable option to the " - "value from the theme)"), + "value from the theme); if a file named .theme " + "exists in directory \"themes\" it shadows any built-in " + "theme of the same name"), N_("raw[info]: display details on a theme (name, description, " "creation date, WeeChat version, number of option overrides)"), N_("name: name of a theme"), @@ -9957,7 +10084,7 @@ command_init (void) "state can be restored with: /theme apply " "backup-. This is controlled by the option " "weechat.look.theme_backup.")), - "list" + "list -backups" " || apply" " || info", &command_theme, NULL, NULL); diff --git a/src/core/core-theme.c b/src/core/core-theme.c index e7a33a8ac..983b376f2 100644 --- a/src/core/core-theme.c +++ b/src/core/core-theme.c @@ -30,6 +30,7 @@ #include #include #include +#include #include "weechat.h" #include "core-arraylist.h" @@ -448,6 +449,216 @@ theme_apply_set_option_cb (void *data, config_file_option_set (option, (const char *)value, 1); } +/* + * Strips one optional pair of matching surrounding quotes (' or ") from + * the in-place string; returns a pointer that may differ from the input + * (advances past an opening quote). + */ + +char * +theme_file_strip_quotes (char *value) +{ + size_t len; + + if (!value) + return value; + len = strlen (value); + if ((len >= 2) + && (((value[0] == '"') && (value[len - 1] == '"')) + || ((value[0] == '\'') && (value[len - 1] == '\'')))) + { + value[len - 1] = '\0'; + return value + 1; + } + return value; +} + +/* + * Parses a .theme file into a transient t_theme. + * + * The file uses two INI-like sections: [info] (keys: name, description, + * date, weechat) and [options] (key = full option name like + * "irc.color.input_nick", value = string). Unknown [info] keys produce a + * warning and are ignored; unknown sections produce a warning and the + * lines in them are skipped. + * + * Returns a heap-allocated t_theme (caller frees with theme_free), or + * NULL if the file cannot be opened. + */ + +struct t_theme * +theme_file_parse (const char *path) +{ + FILE *file; + char line[8192], *ptr, *end, *eq, *key, *value; + int line_number, in_options; + struct t_theme *theme; + + if (!path) + return NULL; + + file = fopen (path, "r"); + if (!file) + return NULL; + + theme = theme_alloc (""); + if (!theme) + { + fclose (file); + return NULL; + } + /* clear the placeholder name; the file should provide it */ + free (theme->name); + theme->name = NULL; + /* description/date/weechat_version come from the file too */ + free (theme->description); + theme->description = NULL; + free (theme->date); + theme->date = NULL; + free (theme->weechat_version); + theme->weechat_version = NULL; + + line_number = 0; + in_options = 0; + while (fgets (line, sizeof (line) - 1, file)) + { + line_number++; + + /* trim trailing CR / LF */ + end = strchr (line, '\r'); + if (end) + *end = '\0'; + end = strchr (line, '\n'); + if (end) + *end = '\0'; + + /* skip leading whitespace */ + ptr = line; + while ((ptr[0] == ' ') || (ptr[0] == '\t')) + ptr++; + + /* skip empty lines and comments */ + if (!ptr[0] || (ptr[0] == '#')) + continue; + + /* section header */ + if (ptr[0] == '[') + { + end = strchr (ptr, ']'); + if (!end) + { + gui_chat_printf ( + NULL, + _("%s%s: line %d: malformed section header"), + gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], + path, line_number); + continue; + } + *end = '\0'; + if (strcmp (ptr + 1, "info") == 0) + { + in_options = 0; + } + else if (strcmp (ptr + 1, "options") == 0) + { + in_options = 1; + } + else + { + gui_chat_printf ( + NULL, + _("%s%s: line %d: ignoring unknown section \"%s\""), + gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], + path, line_number, ptr + 1); + in_options = -1; /* skip lines until next known section */ + } + continue; + } + + if (in_options < 0) + continue; + + /* "key = value" */ + eq = strchr (ptr, '='); + if (!eq) + { + gui_chat_printf ( + NULL, + _("%s%s: line %d: missing '=' separator"), + gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], + path, line_number); + continue; + } + + /* trim key */ + key = ptr; + end = eq - 1; + while ((end > key) && ((end[0] == ' ') || (end[0] == '\t'))) + end--; + end[1] = '\0'; + + /* trim value */ + value = eq + 1; + while ((value[0] == ' ') || (value[0] == '\t')) + value++; + end = value + strlen (value) - 1; + while ((end > value) && ((end[0] == ' ') || (end[0] == '\t'))) + end--; + end[1] = '\0'; + + value = theme_file_strip_quotes (value); + + if (in_options) + { + hashtable_set (theme->overrides, key, value); + } + else + { + /* [info] section */ + if (strcmp (key, "name") == 0) + { + free (theme->name); + theme->name = strdup (value); + } + else if (strcmp (key, "description") == 0) + { + free (theme->description); + theme->description = strdup (value); + } + else if (strcmp (key, "date") == 0) + { + free (theme->date); + theme->date = strdup (value); + } + else if (strcmp (key, "weechat") == 0) + { + free (theme->weechat_version); + theme->weechat_version = strdup (value); + } + else + { + gui_chat_printf ( + NULL, + _("%s%s: line %d: ignoring unknown [info] key \"%s\""), + gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], + path, line_number, key); + } + } + } + fclose (file); + + if (!theme->name) + theme->name = strdup (""); + if (!theme->description) + theme->description = strdup (""); + if (!theme->date) + theme->date = strdup (""); + if (!theme->weechat_version) + theme->weechat_version = strdup (""); + + return theme; +} + /* * Applies a theme registered in memory. * @@ -466,21 +677,48 @@ theme_apply_set_option_cb (void *data, int theme_apply (const char *name) { - struct t_theme *theme; + struct t_theme *file_theme = NULL; + struct t_theme *registry_theme = NULL; + struct t_hashtable *overrides = NULL; + char *path = NULL; char *backup_name = NULL; if (!name || !name[0]) return WEECHAT_RC_ERROR; - theme = theme_search (name); - if (!theme) + /* Resolution: a user file with the given name shadows any built-in + of the same name. Read the file transiently (parse, apply, free) + so user themes have no steady-state memory footprint. */ + path = theme_user_file_path (name); + if (path && (access (path, R_OK) == 0)) { - gui_chat_printf (NULL, - _("%sTheme \"%s\" not found"), - gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], - name); - return WEECHAT_RC_ERROR; + file_theme = theme_file_parse (path); + if (!file_theme) + { + gui_chat_printf (NULL, + _("%sFailed to parse theme file \"%s\""), + gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], + path); + free (path); + return WEECHAT_RC_ERROR; + } + overrides = file_theme->overrides; } + else + { + registry_theme = theme_search (name); + if (!registry_theme) + { + gui_chat_printf (NULL, + _("%sTheme \"%s\" not found"), + gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], + name); + free (path); + return WEECHAT_RC_ERROR; + } + overrides = registry_theme->overrides; + } + free (path); /* create a backup of current themable state, if enabled */ if (CONFIG_BOOLEAN(config_look_theme_backup) @@ -489,6 +727,7 @@ theme_apply (const char *name) backup_name = theme_make_backup (); if (!backup_name) { + theme_free (file_theme); gui_chat_printf ( NULL, _("%sUnable to create theme backup; aborting apply " @@ -501,9 +740,12 @@ theme_apply (const char *name) /* apply each override; per-option refreshes are suppressed via the theme_applying flag (see config_change_color) */ theme_applying = 1; - hashtable_map (theme->overrides, &theme_apply_set_option_cb, NULL); + hashtable_map (overrides, &theme_apply_set_option_cb, NULL); theme_applying = 0; + /* file_theme (if any) is transient: discard now */ + theme_free (file_theme); + /* single refresh at the end */ if (gui_init_ok) { diff --git a/src/core/core-theme.h b/src/core/core-theme.h index 35af5dd8e..93a3d4dbd 100644 --- a/src/core/core-theme.h +++ b/src/core/core-theme.h @@ -46,6 +46,9 @@ extern struct t_theme *theme_register (const char *name, extern struct t_arraylist *theme_list (void); extern int theme_apply (const char *name); extern char *theme_make_backup (void); +extern char *theme_user_file_path (const char *name); +extern struct t_theme *theme_file_parse (const char *path); +extern void theme_free (struct t_theme *theme); extern void theme_init (void); extern void theme_end (void); diff --git a/tests/unit/core/test-core-theme.cpp b/tests/unit/core/test-core-theme.cpp index c6204c192..65be980c4 100644 --- a/tests/unit/core/test-core-theme.cpp +++ b/tests/unit/core/test-core-theme.cpp @@ -48,6 +48,8 @@ extern void theme_free (struct t_theme *theme); extern char *theme_user_file_path (const char *name); extern char *theme_make_backup_name (void); extern int theme_write_file_full (const char *name, const char *description); +extern char *theme_file_strip_quotes (char *value); +extern struct t_theme *theme_file_parse (const char *path); } TEST_GROUP(CoreTheme) @@ -465,6 +467,158 @@ TEST(CoreTheme, Apply) free (saved_theme_label); } +/* + * Test functions: + * theme_file_strip_quotes + */ + +TEST(CoreTheme, FileStripQuotes) +{ + char buf[64]; + + /* NULL passes through */ + POINTERS_EQUAL(NULL, theme_file_strip_quotes (NULL)); + + /* len < 2: too short to be a matched quote pair */ + strcpy (buf, ""); + STRCMP_EQUAL("", theme_file_strip_quotes (buf)); + strcpy (buf, "a"); + STRCMP_EQUAL("a", theme_file_strip_quotes (buf)); + strcpy (buf, "\""); + STRCMP_EQUAL("\"", theme_file_strip_quotes (buf)); + + /* no quotes: returned as-is */ + strcpy (buf, "hello"); + STRCMP_EQUAL("hello", theme_file_strip_quotes (buf)); + + /* matched double quotes are stripped */ + strcpy (buf, "\"hello\""); + STRCMP_EQUAL("hello", theme_file_strip_quotes (buf)); + + /* matched single quotes are stripped */ + strcpy (buf, "'world'"); + STRCMP_EQUAL("world", theme_file_strip_quotes (buf)); + + /* mismatched: unchanged */ + strcpy (buf, "\"unmatched'"); + STRCMP_EQUAL("\"unmatched'", theme_file_strip_quotes (buf)); + strcpy (buf, "'unmatched\""); + STRCMP_EQUAL("'unmatched\"", theme_file_strip_quotes (buf)); + + /* exactly two quotes => empty string after stripping */ + strcpy (buf, "\"\""); + STRCMP_EQUAL("", theme_file_strip_quotes (buf)); + + /* internal quotes only on one side: unchanged */ + strcpy (buf, "no\"quote"); + STRCMP_EQUAL("no\"quote", theme_file_strip_quotes (buf)); +} + +/* + * Test functions: + * theme_file_parse + */ + +TEST(CoreTheme, FileParse) +{ + const char *path = "/tmp/weechat_test_theme_parse.theme"; + FILE *file; + struct t_theme *theme; + + /* NULL and missing file => NULL */ + POINTERS_EQUAL(NULL, theme_file_parse (NULL)); + unlink (path); /* belt-and-suspenders */ + POINTERS_EQUAL(NULL, theme_file_parse (path)); + + /* write a well-formed file: [info] + [options], mixed quoting, + blanks and comments scattered around */ + file = fopen (path, "w"); + CHECK(file != NULL); + fprintf (file, "# leading comment\n"); + fprintf (file, "\n"); + fprintf (file, "[info]\n"); + fprintf (file, "name = \"solarized_light\"\n"); + fprintf (file, "description = \"Light-bg theme\"\n"); + fprintf (file, "date = \"2026-05-26 09:42:10\"\n"); + fprintf (file, "weechat = \"4.10.0-dev\"\n"); + fprintf (file, "unknown_info_key = \"ignored\"\n"); + fprintf (file, "\n"); + fprintf (file, "[options]\n"); + fprintf (file, "weechat.color.chat = default\n"); /* unquoted */ + fprintf (file, " weechat.color.separator = \"blue\"\n"); /* whitespace + quotes */ + fprintf (file, "irc.color.input_nick = 'lightcyan'\n"); /* single quotes */ + fclose (file); + + theme = theme_file_parse (path); + CHECK(theme != NULL); + + /* [info] fields populated */ + STRCMP_EQUAL("solarized_light", theme->name); + STRCMP_EQUAL("Light-bg theme", theme->description); + STRCMP_EQUAL("2026-05-26 09:42:10", theme->date); + STRCMP_EQUAL("4.10.0-dev", theme->weechat_version); + + /* [options] entries: three known keys, "unknown_info_key" must NOT + leak in (it lives under [info]) */ + LONGS_EQUAL(3, theme->overrides->items_count); + STRCMP_EQUAL("default", + (const char *)hashtable_get (theme->overrides, + "weechat.color.chat")); + STRCMP_EQUAL("blue", + (const char *)hashtable_get (theme->overrides, + "weechat.color.separator")); + STRCMP_EQUAL("lightcyan", + (const char *)hashtable_get (theme->overrides, + "irc.color.input_nick")); + POINTERS_EQUAL(NULL, hashtable_get (theme->overrides, + "unknown_info_key")); + + theme_free (theme); + unlink (path); + + /* parse a file that has only [info]: overrides hashtable empty, + missing [info] keys default to empty string */ + file = fopen (path, "w"); + CHECK(file != NULL); + fprintf (file, "[info]\n"); + fprintf (file, "name = \"only_info\"\n"); + fclose (file); + + theme = theme_file_parse (path); + CHECK(theme != NULL); + STRCMP_EQUAL("only_info", theme->name); + STRCMP_EQUAL("", theme->description); + STRCMP_EQUAL("", theme->date); + STRCMP_EQUAL("", theme->weechat_version); + LONGS_EQUAL(0, theme->overrides->items_count); + theme_free (theme); + unlink (path); + + /* malformed lines must not crash; a missing-'=' line and a stray + section header are tolerated, the rest of the file still parses */ + file = fopen (path, "w"); + CHECK(file != NULL); + fprintf (file, "[info]\n"); + fprintf (file, "name = \"robust\"\n"); + fprintf (file, "broken line without equals\n"); + fprintf (file, "[unknown_section]\n"); + fprintf (file, "ignored = value\n"); + fprintf (file, "[options]\n"); + fprintf (file, "weechat.color.chat = red\n"); + fclose (file); + + theme = theme_file_parse (path); + CHECK(theme != NULL); + STRCMP_EQUAL("robust", theme->name); + LONGS_EQUAL(1, theme->overrides->items_count); + STRCMP_EQUAL("red", + (const char *)hashtable_get (theme->overrides, + "weechat.color.chat")); + POINTERS_EQUAL(NULL, hashtable_get (theme->overrides, "ignored")); + theme_free (theme); + unlink (path); +} + /* * Test functions: * theme_init