# HG changeset patch
# Node ID 877aa53c13ed
# Parent  c22f75f9b543
diff --git a/ibis/ibisparser.c b/ibis/ibisparser.c
--- a/ibis/ibisparser.c
+++ b/ibis/ibisparser.c
@@ -25,12 +25,12 @@
 #include "ibiscore.h"
 #include "ibismessage.h"
 #include "ibisstring.h"
+#include "ibistags.h"
 
 struct _IbisParser {
 	GObject parent;
 
 	GRegex *regex_message;
-	GRegex *regex_tags;
 
 	IbisMessageHandler fallback_handler;
 	GHashTable *handlers;
@@ -41,113 +41,6 @@
 /******************************************************************************
  * Helpers
  *****************************************************************************/
-static char *
-ibis_parser_unescape_tag_value(const char *value) {
-	GString *unescaped = g_string_new("");
-	gboolean escaping = FALSE;
-
-	/* Walk the string and replace escaped values according to
-	 * https://ircv3.net/specs/extensions/message-tags.html#escaping-values
-	 */
-	for(int i = 0; value[i] != '\0'; i++) {
-		if(escaping) {
-			/* Set the replacement to the current character which will fall
-			 * through for everything, including '\\'
-			 */
-			char replacement = value[i];
-
-			if(value[i] == ':') {
-				replacement = ';';
-			} else if(value[i] == 's') {
-				replacement = ' ';
-			} else if(value[i] == 'r') {
-				replacement = '\r';
-			} else if(value[i] == 'n') {
-				replacement = '\n';
-			}
-
-			g_string_append_c(unescaped, replacement);
-			escaping = FALSE;
-		} else {
-			if(value[i] == '\\') {
-				escaping = TRUE;
-			} else {
-				g_string_append_c(unescaped, value[i]);
-			}
-		}
-	}
-
-	return g_string_free(unescaped, FALSE);
-}
-
-static GHashTable *
-ibis_parser_parse_tags(IbisParser *parser, const char *tags_string,
-                       GError **error)
-{
-	GError *local_error = NULL;
-	GHashTable *tags = NULL;
-	GMatchInfo *info = NULL;
-	gboolean matches = FALSE;
-
-	tags = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
-
-	/* tags_string can never be NULL, because g_match_info_fetch_named always
-	 * returns a string. So if we were passed an empty string, just return the
-	 * empty hash table.
-	 */
-	if(*tags_string == '\0') {
-		return tags;
-	}
-
-	matches = g_regex_match_full(parser->regex_tags, tags_string, -1, 0, 0,
-	                             &info, &local_error);
-
-	if(local_error != NULL) {
-		g_propagate_error(error, local_error);
-
-		g_match_info_unref(info);
-
-		return tags;
-	}
-
-	if(!matches) {
-		g_set_error_literal(error, IBIS_DOMAIN, 0,
-		                    "failed to parse tags: unknown error");
-
-		g_match_info_unref(info);
-
-		return tags;
-	}
-
-	while(g_match_info_matches(info)) {
-		char *key = NULL;
-		char *value = NULL;
-		char *unescaped = NULL;
-
-		key = g_match_info_fetch_named(info, "key");
-		value = g_match_info_fetch_named(info, "value");
-
-		unescaped = ibis_parser_unescape_tag_value(value);
-		g_free(value);
-
-		/* the hash table is created with destroy notifies for both key and
-		 * value, so there's no need to free the allocated memory right now.
-		 */
-		g_hash_table_insert(tags, key, unescaped);
-		g_match_info_next(info, &local_error);
-
-		if(local_error != NULL) {
-			g_propagate_error(error, local_error);
-
-			break;
-		}
-	}
-
-	g_match_info_unref(info);
-
-	return tags;
-}
-
 static guint
 ibis_parser_extract_params(G_GNUC_UNUSED IbisParser *parser,
                            GStrvBuilder *builder, const char *str)
@@ -235,7 +128,6 @@
 	IbisParser *parser = IBIS_PARSER(obj);
 
 	g_clear_pointer(&parser->regex_message, g_regex_unref);
-	g_clear_pointer(&parser->regex_tags, g_regex_unref);
 
 	g_hash_table_destroy(parser->handlers);
 
@@ -252,14 +144,6 @@
 	                                     0, 0, NULL);
 	g_assert(parser->regex_message != NULL);
 
-	parser->regex_tags = g_regex_new("(?<key>(?<client_prefix>\\+?)"
-	                                        "(?:(?<vendor>[A-Za-z0-9-\\.]+)\\/)?"
-	                                        "(?<key_name>[A-Za-z0-9-]+)"
-	                                 ")"
-	                                 "(?:=(?<value>[^\r\n;]*))?(?:;|$)",
-	                                 0, 0, NULL);
-	g_assert(parser->regex_tags != NULL);
-
 	parser->fallback_handler = ibis_fallback_handler;
 	parser->handlers = g_hash_table_new_full(g_str_hash, g_str_equal, g_free,
 	                                         NULL);
@@ -349,18 +233,21 @@
 	message = ibis_message_new(command);
 
 	tags_string = g_match_info_fetch_named(info, "tags");
-	tags = ibis_parser_parse_tags(parser, tags_string, &local_error);
-	g_free(tags_string);
-	if(local_error != NULL) {
-		g_propagate_error(error, local_error);
+	if(!ibis_str_is_empty(tags_string)) {
+		tags = ibis_tags_parse(tags_string, &local_error);
+		if(local_error != NULL) {
+			g_propagate_error(error, local_error);
 
-		g_free(command);
-		g_clear_object(&message);
-		g_hash_table_destroy(tags);
-		g_match_info_unref(info);
+			g_free(tags_string);
+			g_free(command);
+			g_clear_object(&message);
+			g_hash_table_destroy(tags);
+			g_match_info_unref(info);
 
-		return FALSE;
+			return FALSE;
+		}
 	}
+	g_free(tags_string);
 
 	if(tags != NULL) {
 		ibis_message_set_tags(message, tags);
diff --git a/ibis/ibistags.h b/ibis/ibistags.h
new file mode 100644
--- /dev/null
+++ b/ibis/ibistags.h
@@ -0,0 +1,74 @@
+/*
+ * Ibis - IRCv3 Library
+ * Copyright (C) 2022-2024 Ibis Developers
+ *
+ * This library is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#if !defined(IBIS_GLOBAL_HEADER_INSIDE) && !defined(IBIS_COMPILATION)
+# error "only <ibis.h> may be included directly"
+#endif
+
+#ifndef IBIS_TAGS_H
+#define IBIS_TAGS_H
+
+#include <glib.h>
+
+#include "ibisversion.h"
+
+/**
+ * IBIS_TAGS_ERROR:
+ *
+ * A #GError domain for tags.
+ *
+ * Since: 0.1
+ */
+#define IBIS_TAGS_ERROR \
+	(g_quark_from_static_string("ibis-tags")) \
+	IBIS_AVAILABLE_MACRO_IN_0_1
+
+G_BEGIN_DECLS
+
+/**
+ * ibis_tag_unescape_value:
+ * @escaped: The escaped value.
+ *
+ * Unescapes @escaped.
+ *
+ * Returns: (transfer full): The unescaped value.
+ *
+ * Since: 0.1
+ */
+IBIS_AVAILABLE_IN_0_1
+char *ibis_tag_unescape_value(const char *escaped);
+
+/**
+ * ibis_tags_parse:
+ * @tags_string: The string to parse.
+ * @error: (nullable): A return address for a #GError.
+ *
+ * Parses @tags_string and returns the result.
+ *
+ * If an error was encountered, %NULL will be return and @error will be set.
+ *
+ * Returns: (transfer full): The new tags object or %NULL on error.
+ *
+ * Since: 0.1
+ */
+IBIS_AVAILABLE_IN_0_1
+GHashTable *ibis_tags_parse(const char *tags_string, GError **error);
+
+G_END_DECLS
+
+#endif /* IBIS_TAGS_H */
diff --git a/ibis/ibistags.c b/ibis/ibistags.c
new file mode 100644
--- /dev/null
+++ b/ibis/ibistags.c
@@ -0,0 +1,146 @@
+/*
+ * Ibis - IRCv3 Library
+ * Copyright (C) 2022-2024 Ibis Developers
+ *
+ * This library is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ibistags.h"
+
+#include "ibisstring.h"
+
+/******************************************************************************
+ * Public API
+ *****************************************************************************/
+char *
+ibis_tag_unescape_value(const char *escaped) {
+	GString *unescaped = NULL;
+	gboolean escaping = FALSE;
+
+	g_return_val_if_fail(escaped != NULL, NULL);
+
+	unescaped = g_string_new("");
+
+	/* Walk the string and replace escaped values according to
+	 * https://ircv3.net/specs/extensions/message-tags.html#escaping-values
+	 */
+	for(int i = 0; escaped[i] != '\0'; i++) {
+		if(escaping) {
+			/* Set the replacement to the current character which will fall
+			 * through for everything, including '\\'
+			 */
+			char replacement = escaped[i];
+
+			if(escaped[i] == ':') {
+				replacement = ';';
+			} else if(escaped[i] == 's') {
+				replacement = ' ';
+			} else if(escaped[i] == 'r') {
+				replacement = '\r';
+			} else if(escaped[i] == 'n') {
+				replacement = '\n';
+			}
+
+			g_string_append_c(unescaped, replacement);
+			escaping = FALSE;
+		} else {
+			if(escaped[i] == '\\') {
+				escaping = TRUE;
+			} else {
+				g_string_append_c(unescaped, escaped[i]);
+			}
+		}
+	}
+
+	return g_string_free(unescaped, FALSE);
+}
+
+GHashTable *
+ibis_tags_parse(const char *tags_string, GError **error) {
+	GError *local_error = NULL;
+	GHashTable *tags = NULL;
+	GMatchInfo *info = NULL;
+	GRegex *regex = NULL;
+	gboolean matches = FALSE;
+
+	g_return_val_if_fail(!ibis_str_is_empty(tags_string), NULL);
+
+	tags = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
+
+	/* tags_string can never be NULL, because g_match_info_fetch_named always
+	 * returns a string. So if we were passed an empty string, just return the
+	 * empty hash table.
+	 */
+	if(*tags_string == '\0') {
+		return tags;
+	}
+
+	regex = g_regex_new("(?<key>(?<client_prefix>\\+?)"
+	                           "(?:(?<vendor>[A-Za-z0-9-\\.]+)\\/)?"
+	                           "(?<key_name>[A-Za-z0-9-]+)"
+	                    ")"
+	                    "(?:=(?<value>[^\r\n;]*))?(?:;|$)",
+	                    0, 0, NULL);
+	g_assert(regex != NULL);
+
+	matches = g_regex_match_full(regex, tags_string, -1, 0, 0, &info,
+	                             &local_error);
+
+	g_clear_pointer(&regex, g_regex_unref);
+
+	if(local_error != NULL) {
+		g_propagate_error(error, local_error);
+
+		g_match_info_unref(info);
+
+		return tags;
+	}
+
+	if(!matches) {
+		g_set_error_literal(error, IBIS_TAGS_ERROR, 0,
+		                    "failed to parse tags: unknown error");
+
+		g_match_info_unref(info);
+
+		return tags;
+	}
+
+	while(g_match_info_matches(info)) {
+		char *key = NULL;
+		char *value = NULL;
+		char *unescaped = NULL;
+
+		key = g_match_info_fetch_named(info, "key");
+		value = g_match_info_fetch_named(info, "value");
+
+		unescaped = ibis_tag_unescape_value(value);
+		g_free(value);
+
+		/* the hash table is created with destroy notifies for both key and
+		 * value, so there's no need to free the allocated memory right now.
+		 */
+		g_hash_table_insert(tags, key, unescaped);
+		g_match_info_next(info, &local_error);
+
+		if(local_error != NULL) {
+			g_propagate_error(error, local_error);
+
+			break;
+		}
+	}
+
+	g_match_info_unref(info);
+
+	return tags;
+}
diff --git a/ibis/meson.build b/ibis/meson.build
--- a/ibis/meson.build
+++ b/ibis/meson.build
@@ -5,6 +5,7 @@
   'ibismessage.c',
   'ibisparser.c',
   'ibissource.c',
+  'ibistags.c',
   'ibisversion.c',
 ]
 
@@ -18,6 +19,7 @@
   'ibisparser.h',
   'ibissource.h',
   'ibisstring.h',
+  'ibistags.h',
   'ibisversion.h',
 ]
 
diff --git a/ibis/tests/meson.build b/ibis/tests/meson.build
--- a/ibis/tests/meson.build
+++ b/ibis/tests/meson.build
@@ -2,6 +2,7 @@
   'formatting',
   'parser',
   'source',
+  'tags',
 ]
 
 foreach test_program : test_programs
diff --git a/ibis/tests/test_parser.c b/ibis/tests/test_parser.c
--- a/ibis/tests/test_parser.c
+++ b/ibis/tests/test_parser.c
@@ -141,8 +141,7 @@
 	GError *error = NULL;
 	gboolean result = FALSE;
 
-	ibis_parser_set_fallback_handler(parser,
-	                                         test_ibis_test_handler_error);
+	ibis_parser_set_fallback_handler(parser, test_ibis_test_handler_error);
 
 	result = ibis_parser_parse(parser, "COMMAND", &error, NULL);
 	g_assert_error(error, TEST_IBIS_PARSER_DOMAIN, 0);
diff --git a/ibis/tests/test_tags.c b/ibis/tests/test_tags.c
new file mode 100644
--- /dev/null
+++ b/ibis/tests/test_tags.c
@@ -0,0 +1,112 @@
+/*
+ * Ibis - IRCv3 Library
+ * Copyright (C) 2022-2024 Ibis Developers
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <glib.h>
+
+#include <ibis.h>
+
+/******************************************************************************
+ * Tests
+ *****************************************************************************/
+static void
+test_ibis_tag_unescape_simple(void) {
+	const char *input = "foo";
+	char *escaped = NULL;
+
+	escaped = ibis_tag_unescape_value(input);
+	g_assert_cmpstr(escaped, ==, "foo");
+
+	g_clear_pointer(&escaped, g_free);
+}
+
+static void
+test_ibis_tag_unescape_semi_colon(void) {
+	const char *input = "foo\\:bar";
+	char *escaped = NULL;
+
+	escaped = ibis_tag_unescape_value(input);
+	g_assert_cmpstr(escaped, ==, "foo;bar");
+
+	g_clear_pointer(&escaped, g_free);
+}
+
+static void
+test_ibis_tag_unescape_space(void) {
+	const char *input = "foo\\sbar";
+	char *escaped = NULL;
+
+	escaped = ibis_tag_unescape_value(input);
+	g_assert_cmpstr(escaped, ==, "foo bar");
+
+	g_clear_pointer(&escaped, g_free);
+}
+
+static void
+test_ibis_tag_unescape_backslash(void) {
+	const char *input = "foo\\\\bar";
+	char *escaped = NULL;
+
+	escaped = ibis_tag_unescape_value(input);
+	g_assert_cmpstr(escaped, ==, "foo\\bar");
+
+	g_clear_pointer(&escaped, g_free);
+}
+
+static void
+test_ibis_tag_unescape_carriage_return(void) {
+	const char *input = "foo\\rbar";
+	char *escaped = NULL;
+
+	escaped = ibis_tag_unescape_value(input);
+	g_assert_cmpstr(escaped, ==, "foo\rbar");
+
+	g_clear_pointer(&escaped, g_free);
+}
+
+static void
+test_ibis_tag_unescape_line_feed(void) {
+	const char *input = "foo\\nbar";
+	char *escaped = NULL;
+
+	escaped = ibis_tag_unescape_value(input);
+	g_assert_cmpstr(escaped, ==, "foo\nbar");
+
+	g_clear_pointer(&escaped, g_free);
+}
+
+/******************************************************************************
+ * Main
+ *****************************************************************************/
+int
+main(int argc, char *argv[]) {
+	g_test_init(&argc, &argv, NULL);
+
+	g_test_add_func("/ibis/tag/unescape/simple",
+	                test_ibis_tag_unescape_simple);
+	g_test_add_func("/ibis/tag/unescape/semi-colon",
+	                test_ibis_tag_unescape_semi_colon);
+	g_test_add_func("/ibis/tag/unescape/space", test_ibis_tag_unescape_space);
+	g_test_add_func("/ibis/tag/unescape/backslash",
+	                test_ibis_tag_unescape_backslash);
+	g_test_add_func("/ibis/tag/unescape/carriage-return",
+	                test_ibis_tag_unescape_carriage_return);
+	g_test_add_func("/ibis/tag/unescape/line-feed",
+	                test_ibis_tag_unescape_line_feed);
+
+	return g_test_run();
+}
