# HG changeset patch
# Node ID 3b049ac2de40
# Parent  108bed9875bd
diff --git a/libpurple/protocols/ircv3/meson.build b/libpurple/protocols/ircv3/meson.build
--- a/libpurple/protocols/ircv3/meson.build
+++ b/libpurple/protocols/ircv3/meson.build
@@ -1,5 +1,8 @@
 IRCV3_SOURCES = [
 	'purpleircv3core.c',
+	'purpleircv3core.h',
+	'purpleircv3parser.c',
+	'purpleircv3parser.h',
 	'purpleircv3protocol.c',
 	'purpleircv3protocol.h',
 ]
@@ -18,3 +21,5 @@
 
 	devenv.append('PURPLE_PLUGIN_PATH', meson.current_build_dir())
 endif
+
+subdir('tests')
diff --git a/libpurple/protocols/ircv3/purpleircv3core.h b/libpurple/protocols/ircv3/purpleircv3core.h
new file mode 100644
--- /dev/null
+++ b/libpurple/protocols/ircv3/purpleircv3core.h
@@ -0,0 +1,26 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * This program 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 program 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 program; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#ifndef PURPLE_IRCV3_CORE_H
+#define PURPLE_IRCV3_CORE_H
+
+#include <glib.h>
+
+#define PURPLE_IRCV3_DOMAIN (g_quark_from_static_string("ircv3-plugin"))
+
+#endif /* PURPLE_IRCV3_CORE_H */
diff --git a/libpurple/protocols/ircv3/purpleircv3core.c b/libpurple/protocols/ircv3/purpleircv3core.c
--- a/libpurple/protocols/ircv3/purpleircv3core.c
+++ b/libpurple/protocols/ircv3/purpleircv3core.c
@@ -24,13 +24,13 @@
 
 #include <purple.h>
 
+#include "purpleircv3core.h"
+
 #include "purpleircv3protocol.h"
 
 /******************************************************************************
  * Globals
  *****************************************************************************/
-#define PURPLE_IRCV3_DOMAIN (g_quark_from_static_string("ircv3-plugin"))
-
 static PurpleProtocol *ircv3_protocol = NULL;
 
 /******************************************************************************
diff --git a/libpurple/protocols/ircv3/purpleircv3parser.h b/libpurple/protocols/ircv3/purpleircv3parser.h
new file mode 100644
--- /dev/null
+++ b/libpurple/protocols/ircv3/purpleircv3parser.h
@@ -0,0 +1,79 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * This program 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 program 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 program; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#ifndef PURPLE_IRCV3_PROTOCOL_H
+#define PURPLE_IRCV3_PROTOCOL_H
+
+#include <glib.h>
+#include <glib-object.h>
+
+#include <purple.h>
+
+G_BEGIN_DECLS
+
+typedef gboolean (*PurpleIRCv3MessageHandler)(GHashTable *tags,
+                                              const gchar *source,
+                                              const gchar *command,
+                                              guint n_params,
+                                              GStrv params,
+                                              GError **error,
+                                              gpointer data);
+
+#define PURPLE_IRCV3_TYPE_PARSER (purple_ircv3_parser_get_type())
+G_DECLARE_FINAL_TYPE(PurpleIRCv3Parser, purple_ircv3_parser, PURPLE_IRCV3,
+                     PARSER, GObject)
+
+/**
+ * purple_ircv3_parser_new:
+ *
+ * Creates a new instance.
+ *
+ * Since: 3.0.0
+ */
+G_GNUC_INTERNAL PurpleIRCv3Parser *purple_ircv3_parser_new(void);
+
+/**
+ * purple_ircv3_parser_set_fallback_handler: (skip):
+ * @parser: The instance.
+ * @handler: A [func@PurpleIRCv3.MessageHandler].
+ *
+ * Sets @handler to be called for any messages that @parser doesn't know how to
+ * handle.
+ *
+ * Since: 3.0.0
+ */
+G_GNUC_INTERNAL void purple_ircv3_parser_set_fallback_handler(PurpleIRCv3Parser *parser, PurpleIRCv3MessageHandler handler);
+
+/**
+ * purple_ircv3_parser_parse:
+ * @parser: The instance.
+ * @buffer: The buffer to parse.
+ * @error: (nullable) (out): A return address for a [boxed@Glib.GError].
+ * @data: (nullable): Optional data to pass to the handler.
+ *
+ * Parses @buffer with @parser.
+ *
+ * Returns: %TRUE if the buffer was parsed correctly or %FALSE with @error set.
+ *
+ * Since: 3.0.0
+ */
+G_GNUC_INTERNAL gboolean purple_ircv3_parser_parse(PurpleIRCv3Parser *parser, const gchar *buffer, GError **error, gpointer data);
+
+G_END_DECLS
+
+#endif /* PURPLE_IRCV3_PROTOCOL_H */
diff --git a/libpurple/protocols/ircv3/purpleircv3parser.c b/libpurple/protocols/ircv3/purpleircv3parser.c
new file mode 100644
--- /dev/null
+++ b/libpurple/protocols/ircv3/purpleircv3parser.c
@@ -0,0 +1,338 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * This program 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 program 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 program; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "purpleircv3parser.h"
+
+#include "purpleircv3core.h"
+
+struct _PurpleIRCv3Parser {
+	GObject parent;
+
+	GRegex *regex_message;
+	GRegex *regex_tags;
+
+	PurpleIRCv3MessageHandler fallback_handler;
+	GHashTable *handlers;
+};
+
+G_DEFINE_TYPE(PurpleIRCv3Parser, purple_ircv3_parser, G_TYPE_OBJECT)
+
+/******************************************************************************
+ * Helpers
+ *****************************************************************************/
+static GHashTable *
+purple_ircv3_parser_parse_tags(PurpleIRCv3Parser *parser,
+                               const gchar *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);
+
+	/* If we were passed an empty string, just return the empty hash table. */
+	if(tags_string != NULL && *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) {
+		const gchar *message = "unknown error";
+
+		if(local_error) {
+			message = local_error->message;
+		}
+
+		g_set_error(error, PURPLE_IRCV3_DOMAIN, 0, "failed to parse tags: %s",
+		            message);
+
+		g_match_info_unref(info);
+
+		return tags;
+	}
+
+	while(g_match_info_matches(info)) {
+		gchar *key = NULL;
+		gchar *value = NULL;
+
+		key = g_match_info_fetch_named(info, "key");
+		value = g_match_info_fetch_named(info, "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, value);
+		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
+purple_ircv3_parser_extract_params(PurpleIRCv3Parser *parser,
+                                   GStrvBuilder *builder, const gchar *str)
+{
+	gchar *ptr = NULL;
+	guint count = 0;
+
+	/* Loop through str finding each space separated string. */
+	while(str != NULL && *str != '\0') {
+		/* Look for a space. */
+		ptr = strchr(str, ' ');
+
+		/* If we found one, set it to null terminator and add the string to our
+		 * builder.
+		 */
+		if(ptr != NULL) {
+			*ptr = '\0';
+			g_strv_builder_add(builder, str);
+
+			/* Move str to the next character as we know there's another
+			 * character which might be another null terminator.
+			 */
+			str = ptr + 1;
+
+			/* And don't forget to increment the count... ah ah ah! */
+			count++;
+		} else {
+			/* Add the remaining string. */
+			g_strv_builder_add(builder, str);
+
+			/* Give the count another one, ah ah ah! */
+			count++;
+
+			/* Finally break out of the loop. */
+			break;
+		}
+	}
+
+	return count;
+}
+
+static GStrv
+purple_ircv3_parser_build_params(PurpleIRCv3Parser *parser,
+                                 const gchar *middle, const gchar *coda,
+                                 const gchar *trailing, guint *n_params)
+{
+	GStrvBuilder *builder = g_strv_builder_new();
+	GStrv result = NULL;
+	guint count = 0;
+
+	*n_params = 0;
+
+	count = purple_ircv3_parser_extract_params(parser, builder, middle);
+	*n_params = *n_params + count;
+
+	if(*coda != '\0') {
+		g_strv_builder_add(builder, trailing);
+		*n_params = *n_params + 1;
+	}
+
+	result = g_strv_builder_end(builder);
+
+	g_strv_builder_unref(builder);
+
+	return result;
+}
+
+/******************************************************************************
+ * Handlers
+ *****************************************************************************/
+static gboolean
+purple_ircv3_fallback_handler(G_GNUC_UNUSED GHashTable *tags,
+                              G_GNUC_UNUSED const gchar *source,
+                              G_GNUC_UNUSED const gchar *command,
+                              G_GNUC_UNUSED guint n_params,
+                              G_GNUC_UNUSED GStrv params,
+                              GError **error,
+                              G_GNUC_UNUSED gpointer data)
+{
+	g_set_error(error, PURPLE_IRCV3_DOMAIN, 0, "no handle for command %s",
+	            command);
+
+	return FALSE;
+}
+
+/******************************************************************************
+ * GObject Implementation
+ *****************************************************************************/
+static void
+purple_ircv3_parser_finalize(GObject *obj) {
+	PurpleIRCv3Parser *parser = PURPLE_IRCV3_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);
+
+	G_OBJECT_CLASS(purple_ircv3_parser_parent_class)->finalize(obj);
+}
+
+static void
+purple_ircv3_parser_init(PurpleIRCv3Parser *parser) {
+	parser->regex_message =  g_regex_new("(?:@(?<tags>[^ ]+) )?"
+	                                     "(?::(?<source>[^ ]+) +)?"
+	                                     "(?<command>[^ :]+)"
+	                                     "(?: +(?<middle>(?:[^ :]+(?: +[^ :]+)*)))*"
+	                                     "(?<coda> +:(?<trailing>.*)?)?",
+	                                     0, 0, NULL);
+	g_assert(parser->regex_message != NULL);
+
+	parser->regex_tags = g_regex_new("(?:(?<key>[A-Za-z0-9-\\/]+)"
+	                                 "(?:=(?<value>[^\\r\\n;]*))?(?:;|$))",
+	                                 0, 0, NULL);
+	g_assert(parser->regex_tags != NULL);
+
+	parser->fallback_handler = purple_ircv3_fallback_handler;
+	parser->handlers = g_hash_table_new_full(g_str_hash, g_str_equal, g_free,
+	                                         NULL);
+}
+
+static void
+purple_ircv3_parser_class_init(PurpleIRCv3ParserClass *klass) {
+	GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+
+	obj_class->finalize = purple_ircv3_parser_finalize;
+}
+
+/******************************************************************************
+ * Public API
+ *****************************************************************************/
+PurpleIRCv3Parser *
+purple_ircv3_parser_new(void) {
+	return g_object_new(PURPLE_IRCV3_TYPE_PARSER, NULL);
+}
+
+void
+purple_ircv3_parser_set_fallback_handler(PurpleIRCv3Parser *parser,
+                                         PurpleIRCv3MessageHandler handler)
+{
+	g_return_if_fail(PURPLE_IRCV3_IS_PARSER(parser));
+
+	parser->fallback_handler = handler;
+}
+
+gboolean
+purple_ircv3_parser_parse(PurpleIRCv3Parser *parser, const gchar *buffer,
+                          GError **error, gpointer data)
+{
+	PurpleIRCv3MessageHandler handler = NULL;
+	GError *local_error = NULL;
+	GHashTable *tags = NULL;
+	GMatchInfo *info = NULL;
+	GStrv params = NULL;
+	gchar *coda = NULL;
+	gchar *command = NULL;
+	gchar *middle = NULL;
+	gchar *source = NULL;
+	gchar *tags_string = NULL;
+	gchar *trailing = NULL;
+	gboolean matches = FALSE;
+	gboolean result = FALSE;
+	guint n_params = 0;
+
+	g_return_val_if_fail(PURPLE_IRCV3_IS_PARSER(parser), FALSE);
+	g_return_val_if_fail(buffer != NULL, FALSE);
+
+	/* Check if the buffer matches our regex for messages. */
+	matches = g_regex_match(parser->regex_message, buffer, 0, &info);
+	if(!matches) {
+		g_set_error(error, PURPLE_IRCV3_DOMAIN, 0,
+		            "failed to parser buffer '%s'", buffer);
+
+		g_match_info_unref(info);
+
+		return FALSE;
+	}
+
+	/* Extract the command from the buffer, so we can find the handler. */
+	command = g_match_info_fetch_named(info, "command");
+	handler = g_hash_table_lookup(parser->handlers, command);
+	if(handler == NULL) {
+		if(parser->fallback_handler == NULL) {
+			g_set_error(error, PURPLE_IRCV3_DOMAIN, 0,
+			            "no handler found for command %s and no default "
+			            "handler set.", command);
+
+			g_free(command);
+			g_match_info_unref(info);
+
+			return FALSE;
+		}
+
+		handler = parser->fallback_handler;
+	}
+
+	/* If we made it this far, we have our handler, so lets get the rest of the
+	 * parameters and call the handler.
+	 */
+	tags_string = g_match_info_fetch_named(info, "tags");
+	tags = purple_ircv3_parser_parse_tags(parser, tags_string, &local_error);
+	g_free(tags_string);
+	if(local_error != NULL) {
+		g_propagate_error(error, local_error);
+
+		g_free(command);
+		g_hash_table_destroy(tags);
+		g_match_info_unref(info);
+
+		return FALSE;
+	}
+
+	source = g_match_info_fetch_named(info, "source");
+	middle = g_match_info_fetch_named(info, "middle");
+	coda = g_match_info_fetch_named(info, "coda");
+	trailing = g_match_info_fetch_named(info, "trailing");
+
+	params = purple_ircv3_parser_build_params(parser, middle, coda, trailing,
+	                                          &n_params);
+
+	/* Call the handler. */
+	result = handler(tags, source, command, n_params, params, error, data);
+
+	/* Cleanup everything. */
+	g_free(source);
+	g_free(command);
+	g_free(middle);
+	g_free(coda);
+	g_free(trailing);
+	g_strfreev(params);
+	g_hash_table_destroy(tags);
+	g_match_info_unref(info);
+
+	return result;
+}
diff --git a/libpurple/protocols/ircv3/tests/meson.build b/libpurple/protocols/ircv3/tests/meson.build
new file mode 100644
--- /dev/null
+++ b/libpurple/protocols/ircv3/tests/meson.build
@@ -0,0 +1,12 @@
+TESTS = [
+	'parser',
+]
+
+foreach prog : TESTS
+	e = executable(
+	    'test_ircv3_' + prog, 'test_ircv3_@0@.c'.format(prog),
+	    dependencies : [libpurple_dep, glib],
+		objects : ircv3_prpl.extract_all_objects())
+
+	test('ircv3_' + prog, e)
+endforeach
diff --git a/libpurple/protocols/ircv3/tests/test_ircv3_parser.c b/libpurple/protocols/ircv3/tests/test_ircv3_parser.c
new file mode 100644
--- /dev/null
+++ b/libpurple/protocols/ircv3/tests/test_ircv3_parser.c
@@ -0,0 +1,680 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * 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 <purple.h>
+
+#include "../purpleircv3parser.h"
+
+typedef struct {
+	GHashTable *tags;
+	gchar *source;
+	gchar *command;
+	guint n_params;
+	const gchar * const params[16];
+} TestPurpleIRCv3ParserData;
+
+/******************************************************************************
+ * Handlers
+ *****************************************************************************/
+static gboolean
+test_purple_ircv3_test_handler(GHashTable *tags, const gchar *source,
+                               const gchar *command, guint n_params,
+                               GStrv params, GError **error, gpointer data)
+{
+	TestPurpleIRCv3ParserData *d = data;
+	GHashTableIter iter;
+
+	/* Make sure we have an expected tags hash table before checking them. */
+	if(d->tags != NULL) {
+		gpointer expected_key;
+		gpointer expected_value;
+		guint actual_size;
+		guint expected_size;
+
+		/* Make sure the tag hash tables have the same size. */
+		expected_size = g_hash_table_size(d->tags);
+		actual_size = g_hash_table_size(tags);
+		g_assert_cmpuint(actual_size, ==, expected_size);
+
+		/* Since the tables have the same size, we can walk through the expected
+		 * table and use it to verify the actual table.
+		 */
+		g_hash_table_iter_init(&iter, d->tags);
+		while(g_hash_table_iter_next(&iter, &expected_key, &expected_value)) {
+			gpointer actual_value = NULL;
+			gboolean found = FALSE;
+
+			found = g_hash_table_lookup_extended(tags, expected_key, NULL,
+			                                     &actual_value);
+			g_assert_true(found);
+			g_assert_cmpstr(actual_value, ==, expected_value);
+		}
+	}
+
+	/* If the expected strings values are NULL, set them to empty string as
+	 * that's what g_match_info_get_named will return for them.
+	 */
+	if(d->source == NULL) {
+		d->source = "";
+	}
+
+	if(d->command == NULL) {
+		d->command = "";
+	}
+
+	/* Wall through the params checking against the expected values. */
+	if(d->n_params > 0) {
+		g_assert_cmpuint(n_params, ==, d->n_params);
+
+		for(guint i = 0; i < d->n_params; i++) {
+			g_assert_cmpstr(params[i], ==, d->params[i]);
+		}
+	}
+
+	/* Validate all the string parameters. */
+	g_assert_cmpstr(source, ==, d->source);
+	g_assert_cmpstr(command, ==, d->command);
+	// g_assert_cmpstr(params, ==, d->params);
+
+	/* Cleanup everything the caller allocated. */
+	g_clear_pointer(&d->tags, g_hash_table_destroy);
+
+	/* Return the return value the caller asked for. */
+	return TRUE;
+}
+
+/******************************************************************************
+ * Helpers
+ *****************************************************************************/
+static void
+test_purple_ircv3_parser(const gchar *source, TestPurpleIRCv3ParserData *d) {
+	PurpleIRCv3Parser *parser = purple_ircv3_parser_new();
+	GError *error = NULL;
+	gboolean result = FALSE;
+
+	purple_ircv3_parser_set_fallback_handler(parser,
+	                                         test_purple_ircv3_test_handler);
+
+	result = purple_ircv3_parser_parse(parser, source, &error, d);
+
+	g_assert_no_error(error);
+	g_assert_true(result);
+
+	g_clear_object(&parser);
+}
+
+/******************************************************************************
+ * Tests
+ *****************************************************************************/
+static void
+test_purple_ircv3_parser_simple(void) {
+	TestPurpleIRCv3ParserData data = {
+		.command = "foo",
+		.n_params = 3,
+		.params = {"bar", "baz", "asdf"},
+	};
+
+	test_purple_ircv3_parser("foo bar baz asdf", &data);
+}
+
+static void
+test_purple_ircv3_parser_with_source(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "coolguy",
+		.command = "foo",
+		.n_params = 3,
+		.params = {"bar", "baz", "asdf"},
+	};
+
+	test_purple_ircv3_parser(":coolguy foo bar baz asdf", &data);
+}
+
+static void
+test_purple_ircv3_parser_with_trailing(void) {
+	TestPurpleIRCv3ParserData data = {
+		.command = "foo",
+		.n_params = 3,
+		.params = {"bar", "baz", "asdf quux"},
+	};
+
+	test_purple_ircv3_parser("foo bar baz :asdf quux", &data);
+}
+
+static void
+test_purple_ircv3_parser_with_empty_trailing(void) {
+	TestPurpleIRCv3ParserData data = {
+		.command = "foo",
+		.n_params = 3,
+		.params = {"bar", "baz", ""},
+	};
+
+	test_purple_ircv3_parser("foo bar baz :", &data);
+}
+
+static void
+test_purple_ircv3_parser_with_trailing_starting_colon(void) {
+	TestPurpleIRCv3ParserData data = {
+		.command = "foo",
+		.n_params = 3,
+		.params = {"bar", "baz", ":asdf"},
+	};
+
+	test_purple_ircv3_parser("foo bar baz ::asdf", &data);
+}
+
+static void
+test_purple_ircv3_parser_with_source_and_trailing(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "coolguy",
+		.command = "foo",
+		.n_params = 3,
+		.params = {"bar", "baz", "asdf quux"},
+	};
+
+	test_purple_ircv3_parser(":coolguy foo bar baz :asdf quux", &data);
+}
+
+static void
+test_purple_ircv3_parser_with_source_and_trailing_whitespace(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "coolguy",
+		.command = "foo",
+		.n_params = 3,
+		.params = {"bar", "baz", "  asdf quux "},
+	};
+
+	test_purple_ircv3_parser(":coolguy foo bar baz :  asdf quux ", &data);
+}
+
+static void
+test_purple_ircv3_parser_with_source_and_trailing_colon(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "coolguy",
+		.command = "PRIVMSG",
+		.n_params = 2,
+		.params = {"bar", "lol :) "},
+	};
+
+	test_purple_ircv3_parser(":coolguy PRIVMSG bar :lol :) ", &data);
+}
+
+static void
+test_purple_ircv3_parser_with_source_and_empty_trailing(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "coolguy",
+		.command = "foo",
+		.n_params = 3,
+		.params = {"bar", "baz", ""},
+	};
+
+	test_purple_ircv3_parser(":coolguy foo bar baz :", &data);
+}
+
+static void
+test_purple_ircv3_parser_with_source_and_trailing_only_whitespace(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "coolguy",
+		.command = "foo",
+		.n_params = 3,
+		.params = {"bar", "baz", "  "},
+	};
+
+	test_purple_ircv3_parser(":coolguy foo bar baz :  ", &data);
+}
+
+static void
+test_purple_ircv3_parser_with_tags(void) {
+	TestPurpleIRCv3ParserData data = {
+		.command = "foo",
+	};
+
+	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
+	g_hash_table_insert(data.tags, "a", "b");
+	g_hash_table_insert(data.tags, "c", "32");
+	g_hash_table_insert(data.tags, "k", "");
+	g_hash_table_insert(data.tags, "rt", "ql7");
+
+	test_purple_ircv3_parser("@a=b;c=32;k;rt=ql7 foo", &data);
+}
+
+static void
+test_purple_ircv3_parser_with_escaped_tags(void) {
+#if 0
+/* Escaped tags aren't implemented yet. */
+	TestPurpleIRCv3ParserData data = {
+		.command = "foo",
+	};
+
+	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
+	g_hash_table_insert(data.tags, "a", "b\\and\nk");
+	g_hash_table_insert(data.tags, "c", "72 45");
+	g_hash_table_insert(data.tags, "d", "gh;764");
+
+	test_purple_ircv3_parser("@a=b\\\\and\\nk;c=72\\s45;d=gh\\:764 foo",
+	                         &data);
+#endif
+}
+
+static void
+test_purple_ircv3_with_tags_and_source(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "quux",
+		.command = "ab",
+		.n_params = 1,
+		.params = { "cd" },
+	};
+
+	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
+	g_hash_table_insert(data.tags, "c", "");
+	g_hash_table_insert(data.tags, "h", "");
+	g_hash_table_insert(data.tags, "a", "b");
+
+	test_purple_ircv3_parser("@c;h=;a=b :quux ab cd", &data);
+}
+
+static void
+test_purple_ircv3_last_param_no_colon(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "src",
+		.command = "JOIN",
+		.n_params = 1,
+		.params = { "#chan" },
+	};
+
+	test_purple_ircv3_parser(":src JOIN #chan", &data);
+}
+
+static void
+test_purple_ircv3_last_param_with_colon(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "src",
+		.command = "JOIN",
+		.n_params = 1,
+		.params = { "#chan" },
+	};
+
+	test_purple_ircv3_parser(":src JOIN :#chan", &data);
+}
+
+static void
+test_purple_ircv3_with_last_param(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "src",
+		.command = "AWAY",
+	};
+
+	test_purple_ircv3_parser(":src AWAY ", &data);
+}
+
+static void
+test_purple_ircv3_without_last_param(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "src",
+		.command = "AWAY",
+	};
+
+	test_purple_ircv3_parser(":src AWAY", &data);
+}
+
+static void
+test_purple_ircv3_tab_is_not_space(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "cool\tguy",
+		.command = "foo",
+		.n_params = 2,
+		.params = { "bar", "baz" },
+	};
+
+	test_purple_ircv3_parser(":cool\tguy foo bar baz", &data);
+}
+
+static void
+test_purple_ircv3_source_control_characters_1(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "coolguy!ag@net\x035w\x03ork.admin",
+		.command = "PRIVMSG",
+		.n_params = 2,
+		.params = { "foo", "bar baz" },
+	};
+	const gchar *msg = NULL;
+
+	msg = ":coolguy!ag@net\x035w\x03ork.admin PRIVMSG foo :bar baz";
+
+	test_purple_ircv3_parser(msg, &data);
+}
+
+static void
+test_purple_ircv3_source_control_characters_2(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "coolguy!~ag@n\x02et\x0305w\x0fork.admin",
+		.command = "PRIVMSG",
+		.n_params = 2,
+		.params = { "foo", "bar baz" },
+	};
+	const gchar *msg = NULL;
+
+	msg = ":coolguy!~ag@n\x02et\x0305w\x0fork.admin PRIVMSG foo :bar baz";
+
+	test_purple_ircv3_parser(msg, &data);
+}
+
+static void
+test_purple_ircv3_everything(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "irc.example.com",
+		.command = "COMMAND",
+		.n_params = 3,
+		.params = { "param1", "param2", "param3 param3" },
+	};
+	const gchar *msg = NULL;
+
+	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
+	g_hash_table_insert(data.tags, "tag1", "value1");
+	g_hash_table_insert(data.tags, "tag2", "");
+	g_hash_table_insert(data.tags, "vendor1/tag3", "value2");
+	g_hash_table_insert(data.tags, "vendor2/tag4", "");
+
+	msg = "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4= "
+	      ":irc.example.com COMMAND param1 param2 :param3 param3";
+
+	test_purple_ircv3_parser(msg, &data);
+}
+
+static void
+test_purple_ircv3_everything_but_tags(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "irc.example.com",
+		.command = "COMMAND",
+		.n_params = 3,
+		.params = { "param1", "param2", "param3 param3" },
+	};
+	const gchar *msg = NULL;
+
+	msg = ":irc.example.com COMMAND param1 param2 :param3 param3";
+
+	test_purple_ircv3_parser(msg, &data);
+}
+
+static void
+test_purple_ircv3_everything_but_source(void) {
+	TestPurpleIRCv3ParserData data = {
+		.command = "COMMAND",
+		.n_params = 3,
+		.params = { "param1", "param2", "param3 param3" },
+	};
+	const gchar *msg = NULL;
+
+	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
+	g_hash_table_insert(data.tags, "tag1", "value1");
+	g_hash_table_insert(data.tags, "tag2", "");
+	g_hash_table_insert(data.tags, "vendor1/tag3", "value2");
+	g_hash_table_insert(data.tags, "vendor2/tag4", "");
+
+	msg = "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4 "
+	      "COMMAND param1 param2 :param3 param3";
+
+	test_purple_ircv3_parser(msg, &data);
+}
+
+static void
+test_purple_ircv3_command_only(void) {
+	TestPurpleIRCv3ParserData data = {
+		.command = "COMMAND",
+	};
+
+	test_purple_ircv3_parser("COMMAND", &data);
+}
+
+static void
+test_purple_ircv3_slashes_are_fun(void) {
+#if 0
+/* Escaped tags aren't implemented yet. */
+	TestPurpleIRCv3ParserData data = {
+		.command = "COMMAND",
+	};
+
+	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
+	g_hash_table_insert(data.tags, "foo", "\\\\;\\s \r\n");
+
+	test_purple_ircv3_parser("@foo=\\\\\\\\\\:\\\\s\\s\\r\\n COMMAND", &data);
+#endif
+}
+
+static void
+test_purple_ircv3_unreal_broken_1(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "gravel.mozilla.org",
+		.command = "432",
+		.n_params = 2,
+		.params = { "#momo", "Erroneous Nickname: Illegal characters" },
+	};
+	const gchar *msg = NULL;
+
+	msg = ":gravel.mozilla.org 432  #momo :Erroneous Nickname: Illegal "
+	      "characters";
+
+	test_purple_ircv3_parser(msg, &data);
+}
+
+static void
+test_purple_ircv3_unreal_broken_2(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "gravel.mozilla.org",
+		.command = "MODE",
+		.n_params = 2,
+		.params = { "#tckk", "+n" },
+	};
+
+	test_purple_ircv3_parser(":gravel.mozilla.org MODE #tckk +n ", &data);
+}
+
+static void
+test_purple_ircv3_unreal_broken_3(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "services.esper.net",
+		.command = "MODE",
+		.n_params = 3,
+		.params = { "#foo-bar", "+o", "foobar" },
+	};
+
+	test_purple_ircv3_parser(":services.esper.net MODE #foo-bar +o foobar  ",
+	                         &data);
+}
+
+static void
+test_purple_ircv3_tag_escape_char_at_a_time(void) {
+#if 0
+/* Escaped tags aren't implemented yet. */
+	TestPurpleIRCv3ParserData data = {
+		.command = "COMMAND",
+	};
+
+	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
+	g_hash_table_insert(data.tags, "tag1", "value\\ntest");
+
+	test_purple_ircv3_parser("@tag1=value\\\\ntest COMMAND", &data);
+#endif
+}
+
+static void
+test_purple_ircv3_tag_drop_trailing_slash(void) {
+#if 0
+/* Escaped tags aren't implemented yet. */
+	TestPurpleIRCv3ParserData data = {
+		.command = "COMMAND",
+	};
+
+	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
+	g_hash_table_insert(data.tags, "tag1", "value1");
+
+	test_purple_ircv3_parser("@tag1=value1\\ COMMAND", &data);
+#endif
+}
+
+static void
+test_purple_ircv3_duplicate_tags(void) {
+	TestPurpleIRCv3ParserData data = {
+		.command = "COMMAND",
+	};
+
+	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
+	g_hash_table_insert(data.tags, "tag1", "5");
+	g_hash_table_insert(data.tags, "tag2", "3");
+	g_hash_table_insert(data.tags, "tag3", "4");
+
+	test_purple_ircv3_parser("@tag1=1;tag2=3;tag3=4;tag1=5 COMMAND", &data);
+}
+
+static void
+test_purple_ircv3_vendor_tags_are_namespaced(void) {
+	TestPurpleIRCv3ParserData data = {
+		.command = "COMMAND",
+	};
+	const gchar *msg = NULL;
+
+	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
+	g_hash_table_insert(data.tags, "tag1", "5");
+	g_hash_table_insert(data.tags, "tag2", "3");
+	g_hash_table_insert(data.tags, "tag3", "4");
+	g_hash_table_insert(data.tags, "vendor/tag2", "8");
+
+	msg = "@tag1=1;tag2=3;tag3=4;tag1=5;vendor/tag2=8 COMMAND";
+
+	test_purple_ircv3_parser(msg, &data);
+}
+
+static void
+test_purple_ircv3_special_mode_1(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "SomeOp",
+		.command = "MODE",
+		.n_params = 2,
+		.params = { "#channel", "+i" }
+	};
+
+	test_purple_ircv3_parser(":SomeOp MODE #channel :+i", &data);
+}
+
+static void
+test_purple_ircv3_special_mode_2(void) {
+	TestPurpleIRCv3ParserData data = {
+		.source = "SomeOp",
+		.command = "MODE",
+		.n_params = 4,
+		.params = { "#channel", "+oo", "SomeUser", "AnotherUser"}
+	};
+
+	test_purple_ircv3_parser(":SomeOp MODE #channel +oo SomeUser :AnotherUser",
+	                         &data);
+}
+
+/******************************************************************************
+ * Main
+ *****************************************************************************/
+gint
+main(gint argc, gchar *argv[]) {
+	g_test_init(&argc, &argv, NULL);
+
+	/* These tests are based on the msg-split tests from
+	 * https://github.com/ircdocs/parser-tests/blob/master/tests/msg-split.yaml
+	 */
+	g_test_add_func("/ircv3/parser/simple",
+	                test_purple_ircv3_parser_simple);
+	g_test_add_func("/ircv3/parser/with-source",
+	                test_purple_ircv3_parser_with_source);
+	g_test_add_func("/ircv3/parser/with-trailing",
+	                test_purple_ircv3_parser_with_trailing);
+	g_test_add_func("/ircv3/parser/with-empty-trailing",
+	                test_purple_ircv3_parser_with_empty_trailing);
+	g_test_add_func("/ircv3/parser/with-trailing-starting-colon",
+	                test_purple_ircv3_parser_with_trailing_starting_colon);
+	g_test_add_func("/ircv3/parser/with-source-and-trailing",
+	                test_purple_ircv3_parser_with_source_and_trailing);
+	g_test_add_func("/ircv3/parser/with-source-and-trailing-whitespace",
+	                test_purple_ircv3_parser_with_source_and_trailing_whitespace);
+	g_test_add_func("/ircv3/parser/with-source-and-trailing-colon",
+	                test_purple_ircv3_parser_with_source_and_trailing_colon);
+	g_test_add_func("/ircv3/parser/with-source-and-empty-trailing",
+	                test_purple_ircv3_parser_with_source_and_empty_trailing);
+	g_test_add_func("/ircv3/parser/with-source-and-trailing-only-whitespace",
+	                test_purple_ircv3_parser_with_source_and_trailing_only_whitespace);
+
+	g_test_add_func("/ircv3/parser/with-tags",
+	                test_purple_ircv3_parser_with_tags);
+	g_test_add_func("/ircv3/parser/with-escaped-tags",
+	                test_purple_ircv3_parser_with_escaped_tags);
+	g_test_add_func("/ircv3/parser/with-tags-and-source",
+	                test_purple_ircv3_with_tags_and_source);
+
+	g_test_add_func("/ircv3/parser/last-param-no-colon",
+	                test_purple_ircv3_last_param_no_colon);
+	g_test_add_func("/ircv3/parser/last-param-with-colon",
+	                test_purple_ircv3_last_param_with_colon);
+
+	g_test_add_func("/ircv3/parser/with-last-parsm",
+	                test_purple_ircv3_with_last_param);
+	g_test_add_func("/ircv3/parser/without-last-param",
+	                test_purple_ircv3_without_last_param);
+
+	g_test_add_func("/ircv3/parser/tab-is-not-space",
+	                test_purple_ircv3_tab_is_not_space);
+
+	g_test_add_func("/ircv3/parser/source_control_characters_1",
+	                test_purple_ircv3_source_control_characters_1);
+	g_test_add_func("/ircv3/parser/source_control_characters_2",
+	                test_purple_ircv3_source_control_characters_2);
+
+	g_test_add_func("/ircv3/parser/everything",
+	                test_purple_ircv3_everything);
+	g_test_add_func("/ircv3/parser/everything-but-tags",
+	                test_purple_ircv3_everything_but_tags);
+	g_test_add_func("/ircv3/parser/everything-but-source",
+	                test_purple_ircv3_everything_but_source);
+
+	g_test_add_func("/ircv3/parser/command-only",
+	                test_purple_ircv3_command_only);
+
+	g_test_add_func("/ircv3/parser/slashes-are-fun",
+	                test_purple_ircv3_slashes_are_fun);
+
+	g_test_add_func("/ircv3/parser/unreal-broken-1",
+	                test_purple_ircv3_unreal_broken_1);
+	g_test_add_func("/ircv3/parser/unreal-broken-2",
+	                test_purple_ircv3_unreal_broken_2);
+	g_test_add_func("/ircv3/parser/unreal-broken-3",
+	                test_purple_ircv3_unreal_broken_3);
+
+	g_test_add_func("/ircv3/parser/tag-escape-char-at-a-time",
+	                test_purple_ircv3_tag_escape_char_at_a_time);
+	g_test_add_func("/ircv3/parser/tag-drop-trailing-slash",
+	                test_purple_ircv3_tag_drop_trailing_slash);
+
+	g_test_add_func("/ircv3/parser/duplicate-tags",
+	                test_purple_ircv3_duplicate_tags);
+	g_test_add_func("/ircv3/parser/vendor-tags-are-namespaced",
+	                test_purple_ircv3_vendor_tags_are_namespaced);
+
+	g_test_add_func("/ircv3/parser/special-mode-1",
+	                test_purple_ircv3_special_mode_1);
+	g_test_add_func("/ircv3/parser/special-mode-2",
+	                test_purple_ircv3_special_mode_2);
+
+	return g_test_run();
+}
