changeset 935:b0451fc0a17d

irccd: add subst.h header
author David Demelier <markand@malikania.fr>
date Sun, 10 Jan 2021 17:29:30 +0100
parents 243f9f51b0ff
children 6866d0d0e360
files .hgignore Makefile irccd/irccd irccd/subst.c irccd/subst.h tests/test-subst.c
diffstat 6 files changed, 1038 insertions(+), 1 deletions(-) [+]
line wrap: on
line diff
--- a/.hgignore	Sun Jan 10 16:56:32 2021 +0100
+++ b/.hgignore	Sun Jan 10 17:29:30 2021 +0100
@@ -23,6 +23,7 @@
 \.d$
 
 # tests.
+^tests/test-subst$
 ^tests/test-util$
 
 # macOS specific.
--- a/Makefile	Sun Jan 10 16:56:32 2021 +0100
+++ b/Makefile	Sun Jan 10 17:29:30 2021 +0100
@@ -25,11 +25,13 @@
 
 IRCCD=          irccd/irccd
 IRCCD_SRCS=     extern/libduktape/duktape.c     \
+                irccd/subst.c                   \
                 irccd/util.c
 IRCCD_OBJS=     ${IRCCD_SRCS:.c=.o}
 IRCCD_DEPS=     ${IRCCD_SRCS:.c=.d}
 
-TESTS=          tests/test-util.c
+TESTS=          tests/test-util.c               \
+                tests/test-subst.c
 TESTS_OBJS=     ${TESTS:.c=}
 
 FLAGS=          -D_BSD_SOURCE                   \
Binary file irccd/irccd has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/irccd/subst.c	Sun Jan 10 17:29:30 2021 +0100
@@ -0,0 +1,485 @@
+/*
+ * subst.c -- pattern substitution
+ *
+ * Copyright (c) 2013-2021 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <assert.h>
+#include <errno.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "subst.h"
+
+struct pair {
+	const char *key;
+	const char *value;
+};
+
+struct attributes {
+	char fg[16];
+	char bg[16];
+	char attrs[4][16];
+	size_t attrsz;
+};
+
+static const struct pair irc_colors[] = {
+	{ "white",      "0"     },
+	{ "black",      "1"     },
+	{ "blue",       "2"     },
+	{ "green",      "3"     },
+	{ "red",        "4"     },
+	{ "brown",      "5"     },
+	{ "purple",     "6"     },
+	{ "orange",     "7"     },
+	{ "yellow",     "8"     },
+	{ "lightgreen", "9"     },
+	{ "cyan",       "10"    },
+	{ "lightcyan",  "11"    },
+	{ "lightblue",  "12"    },
+	{ "pink",       "13"    },
+	{ "grey",       "14"    },
+	{ "lightgrey",  "15"    },
+	{ NULL,         NULL    }
+};
+
+static const struct pair irc_attrs[] = {
+	{ "bold",       "\x02"  },
+	{ "italic",     "\x09"  },
+	{ "reverse",    "\x16"  },
+	{ "strike",     "\x13"  },
+	{ "underline",  "\x15"  },
+	{ "underline2", "\x1f"  },
+	{ NULL,         NULL    }
+};
+
+static const struct pair shell_fg[] = {
+	{ "black",      "30"    },
+	{ "red",        "31"    },
+	{ "green",      "32"    },
+	{ "orange",     "33"    },
+	{ "blue",       "34"    },
+	{ "purple",     "35"    },
+	{ "cyan",       "36"    },
+	{ "white",      "37"    },
+	{ "default",    "39"    },
+	{ NULL,         NULL    }
+};
+
+static const struct pair shell_bg[] = {
+	{ "black",      "40"    },
+	{ "red",        "41"    },
+	{ "green",      "42"    },
+	{ "orange",     "43"    },
+	{ "blue",       "44"    },
+	{ "purple",     "45"    },
+	{ "cyan",       "46"    },
+	{ "white",      "47"    },
+	{ "default",    "49"    },
+	{ NULL,         NULL    }
+};
+
+static const struct pair shell_attrs[] = {
+	{ "bold",       "1"     },
+	{ "dim",        "2"     },
+	{ "underline",  "4"     },
+	{ "blink",      "5"     },
+	{ "reverse",    "7"     },
+	{ "hidden",     "8"     },
+	{ NULL,         NULL    }
+};
+
+static inline bool
+is_reserved(char token)
+{
+	return token == '#' || token == '@' || token == '$' || token == '!';
+}
+
+static inline bool
+scat(char **out, size_t *outsz, const char *value)
+{
+	size_t written;
+
+	if ((written = strlcpy(*out, value, *outsz)) >= *outsz) {
+		errno = ENOMEM;
+		return false;
+	}
+
+	*out += written;
+	*outsz -= written;
+
+	return true;
+}
+
+static inline bool
+ccat(char **out, size_t *outsz, char c)
+{
+	if (*outsz == 0)
+		return false;
+
+	*(*out)++ = c;
+	*(outsz) -= 1;
+
+	return true;
+}
+
+static inline void
+attributes_parse(const char *key, struct attributes *attrs)
+{
+	char attributes[64] = {0};
+
+	memset(attrs, 0, sizeof (*attrs));
+	sscanf(key, "%15[^,],%15[^,],%63s", attrs->fg, attrs->bg, attributes);
+
+	for (char *attr = attributes; *attr; ) {
+		char *p = strchr(attr, ',');
+
+		if (p)
+			*p = 0;
+
+		strlcpy(attrs->attrs[attrs->attrsz++], attr, sizeof (attrs->attrs[0]));
+
+		if (p)
+			attr = p + 1;
+		else
+			*attr = '\0';
+	}
+}
+
+static inline const char *
+find(const struct pair *pairs, const char *key)
+{
+	for (const struct pair *pair = pairs; pair->key; ++pair)
+		if (strcmp(pair->key, key) == 0)
+			return pair->value;
+
+	return NULL;
+}
+
+static bool
+subst_date(char *out, size_t outsz, const char *input, const struct irc_subst *subst)
+{
+	struct tm *tm;
+
+	if (!(subst->flags & IRC_SUBST_DATE))
+		return true;
+
+	tm = localtime(&subst->time);
+
+	if (strftime(out, outsz, input, tm) == 0) {
+		errno = ENOMEM;
+		return false;
+	}
+
+	return true;
+}
+
+static bool
+subst_keyword(const char *key, char **out, size_t *outsz, const struct irc_subst *subst)
+{
+	const char *value = NULL;
+
+	for (size_t i = 0; i < subst->keywordsz; ++i) {
+		if (strcmp(subst->keywords[i].key, key) == 0) {
+			value = subst->keywords[i].value;
+			break;
+		}
+	}
+
+	if (!value)
+		return true;
+
+	return scat(out, outsz, value);
+}
+
+static bool
+subst_env(const char *key, char **out, size_t *outsz)
+{
+	const char *value;
+
+	if (!(value = getenv(key)))
+		return true;
+
+	return scat(out, outsz, value);
+}
+
+static bool
+subst_shell(const char *key, char **out, size_t *outsz)
+{
+	FILE *fp;
+	size_t written;
+
+	/* Accept silently. */
+	if (!(fp = popen(key, "r")))
+		return true;
+
+	/*
+	 * Since we cannot determine the number of bytes that must be read, read until the end of
+	 * the output string and cut at the number of bytes read if lesser.
+	 */
+	if ((written = fread(*out, 1, *outsz - 1, fp)) > 0) {
+		/* Remove '\r\n' */
+		char *end;
+
+		if ((end = memchr(*out, '\r', written)) || (end = memchr(*out, '\n', written)))
+			*end = '\0';
+		else
+			end = *out + written;
+
+		*outsz -= end - *out;
+		*out = end;
+	}
+
+	pclose(fp);
+
+	return true;
+}
+
+static bool
+subst_irc_attrs(const char *key, char **out, size_t *outsz)
+{
+	const char *value;
+	struct attributes attrs;
+
+	if (!key[0])
+		return ccat(out, outsz, '\x03');
+
+	attributes_parse(key, &attrs);
+
+	if (attrs.fg[0] || attrs.attrs[0]) {
+		if (!ccat(out, outsz, '\x03'))
+			return false;
+
+		/* Foreground. */
+		if ((value = find(irc_colors, attrs.fg)) && !scat(out, outsz, value))
+			return false;
+
+		/* Background. */
+		if (attrs.bg[0]) {
+			if (!ccat(out, outsz, ','))
+				return false;
+			if ((value = find(irc_colors, attrs.bg)) && !scat(out, outsz, value))
+				return false;
+		}
+
+		/* Attributes. */
+		for (size_t i = 0; i < attrs.attrsz; ++i)
+			if ((value = find(irc_attrs, attrs.attrs[i])) && !scat(out, outsz, value))
+				return false;
+	}
+
+	return true;
+}
+
+static bool
+subst_shell_attrs(char *key, char **out, size_t *outsz)
+{
+	const char *value;
+	struct attributes attrs;
+
+	/* Empty attributes means reset: @{}. */
+	if (!key[0])
+		return scat(out, outsz, "\033[0m");
+
+	attributes_parse(key, &attrs);
+
+	if (!scat(out, outsz, "\033["))
+		return false;
+
+	/* Attributes first. */
+	for (size_t i = 0; i < attrs.attrsz; ++i) {
+		if ((value = find(shell_attrs, attrs.attrs[i])) && !scat(out, outsz, value))
+			return false;
+
+		/* Need to append ; if we have still more attributes or colors next. */
+		if ((i < attrs.attrsz || attrs.fg[0] || attrs.bg[0]) && !ccat(out, outsz, ';'))
+			return false;
+	}
+
+	/* Foreground. */
+	if (attrs.fg[0]) {
+		if ((value = find(shell_fg, attrs.fg)) && !scat(out, outsz, value))
+			return false;
+		if (attrs.bg[0] && !ccat(out, outsz, ';'))
+			return false;
+	}
+
+	/* Background. */
+	if (attrs.bg[0]) {
+		if ((value = find(shell_bg, attrs.bg)) && !scat(out, outsz, value))
+			return false;
+	}
+
+	return ccat(out, outsz, 'm');
+}
+
+static bool
+subst_default(const char **p, char **out, size_t *outsz, const char *key)
+{
+	return ccat(out, outsz, (*p)[-2]) &&
+	       ccat(out, outsz, '{') &&
+	       scat(out, outsz, key) &&
+	       ccat(out, outsz, '}');
+}
+
+static bool
+substitute(const char **p, char **out, size_t *outsz, const struct irc_subst *subst)
+{
+	char key[64] = {0};
+	size_t keysz;
+	char *end;
+	bool replaced = true;
+
+	if (!**p)
+		return true;
+
+	/* Find end of construction. */
+	if (!(end = strchr(*p, '}'))) {
+		errno = EINVAL;
+		return false;
+	}
+
+	/* Copy key. */
+	if ((keysz = end - *p) >= sizeof (key)) {
+		errno = ENOMEM;
+		return false;
+	}
+
+	memcpy(key, *p, keysz);
+
+	switch ((*p)[-2]) {
+	case '@':
+		/* attributes */
+		if (subst->flags & IRC_SUBST_IRC_ATTRS) {
+			if (!subst_irc_attrs(key, out, outsz))
+				return false;
+		} else if (subst->flags & IRC_SUBST_SHELL_ATTRS) {
+			if (!subst_shell_attrs(key, out, outsz))
+				return false;
+		} else
+			replaced = false;
+		break;
+	case '#':
+		/* keyword */
+		if (subst->flags & IRC_SUBST_KEYWORDS) {
+			if (!subst_keyword(key, out, outsz, subst))
+				return false;
+		} else
+			replaced = false;
+		break;
+	case '$':
+		/* environment variable */
+		if (subst->flags & IRC_SUBST_ENV) {
+			if (!subst_env(key, out, outsz))
+				return false;
+		} else
+			replaced = false;
+		break;
+	case '!':
+		/* shell */
+		if (subst->flags & IRC_SUBST_SHELL) {
+			if (!subst_shell(key, out, outsz))
+				return false;
+		} else
+			replaced = false;
+		break;
+	default:
+		break;
+	}
+
+	/* If substitution was disabled, put the token verbatim. */
+	if (!replaced && !subst_default(p, out, outsz, key))
+		return false;
+
+	/* Move after '}' */
+	*p = end + 1;
+
+	return true;
+}
+
+ssize_t
+irc_subst(char *out, size_t outsz, const char *input, const struct irc_subst *subst)
+{
+	assert(out);
+	assert(subst);
+
+	char *o = out;
+
+	if (!outsz)
+		return true;
+
+	/* Always start with the date first. */
+	if (!subst_date(out, outsz, input, subst))
+		goto err;
+
+	for (const char *i = input; *i && outsz; ) {
+		/*
+		 * Check if this is a reserved character, if it isn't go to the next character to
+		 * see if it's valid otherwise we print it as last token.
+		 *
+		 * Example:
+		 *   "#{abc}" -> keyword sequence
+		 *   "abc #"  -> keyword sequence interrupted, kept as-is.
+		 */
+		if (!is_reserved(*i)) {
+			if (!ccat(&o, &outsz, *i++))
+				goto err;
+			continue;
+		}
+
+		/*
+		 * Test if after the reserved token we have the opening { construct. If it's the
+		 * case we start substitution.
+		 *
+		 * Otherwise depending on what's after:
+		 *   If it is the same reserved token, it is "escaped" and printed
+		 *   If it is something else, we print the token and skip iteration.
+		 *
+		 * Examples:
+		 *   ## => #
+		 *   #@ => #@
+		 *   ##{foo} => #{foo}
+		 *   #{foo} => value
+		 */
+		if (*++i == '{') {
+			/* Skip '{'. */
+			++i;
+
+			if (!substitute(&i, &o, &outsz, subst))
+				goto err;
+		} else {
+			if (*i == i[-1])
+				++i;
+			if (!ccat(&o, &outsz, i[-1]))
+				goto err;
+		}
+	}
+
+	if (outsz < 1) {
+		errno = ENOMEM;
+		goto err;
+	}
+
+	*o = '\0';
+
+	return o - out;
+
+err:
+	out[0] = '\0';
+
+	return -1;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/irccd/subst.h	Sun Jan 10 17:29:30 2021 +0100
@@ -0,0 +1,50 @@
+/*
+ * subst.h -- pattern substitution
+ *
+ * Copyright (c) 2013-2021 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef IRCCD_SUBST_H
+#define IRCCD_SUBST_H
+
+#include <sys/types.h>
+#include <stddef.h>
+#include <time.h>
+
+enum irc_subst_flags {
+	IRC_SUBST_DATE          = (1 << 0),
+	IRC_SUBST_KEYWORDS      = (1 << 1),
+	IRC_SUBST_ENV           = (1 << 2),
+	IRC_SUBST_SHELL         = (1 << 3),
+	IRC_SUBST_IRC_ATTRS     = (1 << 4),
+	IRC_SUBST_SHELL_ATTRS   = (1 << 5)
+};
+
+struct irc_subst_keyword {
+	const char *key;
+	const char *value;
+};
+
+struct irc_subst {
+	time_t time;
+	enum irc_subst_flags flags;
+	const struct irc_subst_keyword *keywords;
+	size_t keywordsz;
+};
+
+ssize_t
+irc_subst(char *, size_t, const char *, const struct irc_subst *);
+
+#endif /* !IRCCD_SUBST_H */
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-subst.c	Sun Jan 10 17:29:30 2021 +0100
@@ -0,0 +1,499 @@
+/*
+ * test-subst.c -- test subst.h functions
+ *
+ * Copyright (c) 2013-2021 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <errno.h>
+
+#define GREATEST_USE_ABBREVS 0
+#include <greatest.h>
+
+#include <irccd/subst.h>
+#include <irccd/util.h>
+
+GREATEST_TEST
+basics_test(void)
+{
+	struct irc_subst params = {0};
+	char buf[1024] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "hello world!", &params), 12);
+	GREATEST_ASSERT_STR_EQ("hello world!", buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_escape(void)
+{
+	struct irc_subst_keyword kw[] = {
+		{ "target", "hello" }
+	};
+	struct irc_subst params = {
+		.flags = IRC_SUBST_KEYWORDS,
+		.keywords = kw,
+		.keywordsz = IRC_UTIL_SIZE(kw)
+	};
+	char buf[1024] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "$@#", &params), 3);
+	GREATEST_ASSERT_STR_EQ("$@#", buf);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), " $ @ # ", &params), 7);
+	GREATEST_ASSERT_STR_EQ(" $ @ # ", buf);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "#", &params), 1);
+	GREATEST_ASSERT_STR_EQ("#", buf);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), " # ", &params), 3);
+	GREATEST_ASSERT_STR_EQ(" # ", buf);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "#@", &params), 2);
+	GREATEST_ASSERT_STR_EQ("#@", buf);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "##", &params), 1);
+	GREATEST_ASSERT_STR_EQ("#", buf);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "#!", &params), 2);
+	GREATEST_ASSERT_STR_EQ("#!", buf);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "##{target}", &params), 9);
+	GREATEST_ASSERT_STR_EQ("#{target}", buf);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "@#{target}", &params), 6);
+	GREATEST_ASSERT_STR_EQ("@hello", buf);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "#{target}#", &params), 6);
+	GREATEST_ASSERT_STR_EQ("hello#", buf);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "abc##xyz", &params), 7);
+	GREATEST_ASSERT_STR_EQ("abc#xyz", buf);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "abc###xyz", &params), 8);
+	GREATEST_ASSERT_STR_EQ("abc##xyz", buf);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "#{failure", &params), -1);
+	GREATEST_ASSERT_EQ(errno, EINVAL);
+	GREATEST_ASSERT_STR_EQ("", buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+disable_date(void)
+{
+	struct irc_subst params = {0};
+	char buf[1024] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "%H:%M", &params), 5);
+	GREATEST_ASSERT_STR_EQ("%H:%M", buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+disable_keywords(void)
+{
+	struct irc_subst_keyword kw[] = {
+		{ "target", "hello" }
+	};
+	struct irc_subst params = {
+		.keywords = kw,
+		.keywordsz = IRC_UTIL_SIZE(kw)
+	};
+	char buf[1024] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "#{target}", &params), 9);
+	GREATEST_ASSERT_STR_EQ("#{target}", buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+disable_env(void)
+{
+	struct irc_subst params = {0};
+	char buf[1024] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "${HOME}", &params), 7);
+	GREATEST_ASSERT_STR_EQ("${HOME}", buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+disable_shell(void)
+{
+	struct irc_subst params = {0};
+	char buf[1024] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "!{hostname}", &params), 11);
+	GREATEST_ASSERT_STR_EQ("!{hostname}", buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+keywords_simple(void)
+{
+	struct irc_subst_keyword kw[] = {
+		{ "target", "irccd" }
+	};
+	struct irc_subst params = {
+		.flags = IRC_SUBST_KEYWORDS,
+		.keywords = kw,
+		.keywordsz = IRC_UTIL_SIZE(kw)
+	};
+	char buf[1024] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "hello #{target}!", &params), 12);
+	GREATEST_ASSERT_STR_EQ("hello irccd!", buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+keywords_multiple(void)
+{
+	struct irc_subst_keyword kw[] = {
+		{ "target", "irccd" },
+		{ "source", "nightmare" }
+	};
+	struct irc_subst params = {
+		.flags = IRC_SUBST_KEYWORDS,
+		.keywords = kw,
+		.keywordsz = IRC_UTIL_SIZE(kw)
+	};
+	char buf[1024] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "hello #{target} from #{source}!", &params), 27);
+	GREATEST_ASSERT_STR_EQ("hello irccd from nightmare!", buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+keywords_adj_twice(void)
+{
+	struct irc_subst_keyword kw[] = {
+		{ "target", "irccd" }
+	};
+	struct irc_subst params = {
+		.flags = IRC_SUBST_KEYWORDS,
+		.keywords = kw,
+		.keywordsz = IRC_UTIL_SIZE(kw)
+	};
+	char buf[1024] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "hello #{target}#{target}!", &params), 17);
+	GREATEST_ASSERT_STR_EQ("hello irccdirccd!", buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+keywords_missing(void)
+{
+	struct irc_subst params = {
+		.flags = IRC_SUBST_KEYWORDS
+	};
+	char buf[1024] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "hello #{target}!", &params), 7);
+	GREATEST_ASSERT_STR_EQ("hello !", buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+keywords_enomem(void)
+{
+	struct irc_subst_keyword kw[] = {
+		{ "target", "irccd" }
+	};
+	struct irc_subst params = {
+		.flags = IRC_SUBST_KEYWORDS,
+		.keywords = kw,
+		.keywordsz = IRC_UTIL_SIZE(kw)
+	};
+	char buf[10] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "hello #{target}!", &params), -1);
+	GREATEST_ASSERT_EQ(ENOMEM, errno);
+	GREATEST_ASSERT_STR_EQ("", buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+keywords_einval(void)
+{
+	struct irc_subst_keyword kw[] = {
+		{ "target", "irccd" }
+	};
+	struct irc_subst params = {
+		.flags = IRC_SUBST_KEYWORDS,
+		.keywords = kw,
+		.keywordsz = IRC_UTIL_SIZE(kw)
+	};
+	char buf[1024] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "hello #{target!", &params), -1);
+	GREATEST_ASSERT_EQ(EINVAL, errno);
+	GREATEST_ASSERT_STR_EQ("", buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+env_simple(void)
+{
+	const char *home = getenv("HOME");
+	char tmp[1024];
+
+	if (home) {
+		struct irc_subst params = {
+			.flags = IRC_SUBST_ENV,
+		};
+		char buf[1024] = {0};
+
+		snprintf(tmp, sizeof (tmp), "my home is %s", home);
+
+		GREATEST_ASSERT_EQ((size_t)irc_subst(buf, sizeof (buf), "my home is ${HOME}", &params), strlen(tmp));
+		GREATEST_ASSERT_STR_EQ(tmp, buf);
+	}
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+env_missing(void)
+{
+	struct irc_subst params = {
+		.flags = IRC_SUBST_ENV,
+	};
+	char buf[1024] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "value is ${HOPE_THIS_VAR_NOT_EXIST}", &params), 9);
+	GREATEST_ASSERT_STR_EQ("value is ", buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+env_enomem(void)
+{
+	const char *home = getenv("HOME");
+
+	if (home) {
+		struct irc_subst params = {
+			.flags = IRC_SUBST_ENV
+		};
+		char buf[10];
+
+		GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "value is ${HOME}", &params), -1);
+		GREATEST_ASSERT_EQ(ENOMEM, errno);
+		GREATEST_ASSERT_STR_EQ("", buf);
+	}
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+shell_simple(void)
+{
+	struct irc_subst params = {
+		.flags = IRC_SUBST_SHELL
+	};
+	char buf[1024] = {0};
+	char tmp[1024] = {0};
+	time_t now = time(NULL);
+	struct tm *cal = localtime(&now);
+
+	strftime(tmp, sizeof (tmp), "year: %Y", cal);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "year: !{date +%Y}", &params), 10);
+	GREATEST_ASSERT_STR_EQ(tmp, buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+shell_no_new_line(void)
+{
+	struct irc_subst params = {
+		.flags = IRC_SUBST_SHELL
+	};
+	char buf[1024] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "hello !{printf world}", &params), 11);
+	GREATEST_ASSERT_STR_EQ("hello world", buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+shattrs_simple(void)
+{
+	struct irc_subst params = {
+		.flags = IRC_SUBST_SHELL_ATTRS
+	};
+	char buf[1024] = {0};
+
+	/* On shell attributes, all components are optional. */
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "@{red}red@{}", &params), 12);
+	GREATEST_ASSERT_STR_EQ("\033[31mred\033[0m", buf);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "@{red,blue}red on blue@{}", &params), 23);
+	GREATEST_ASSERT_STR_EQ("\033[31;44mred on blue\033[0m", buf);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "@{red,blue,bold}bold red on blue@{}", &params), 30);
+	GREATEST_ASSERT_STR_EQ("\033[1;31;44mbold red on blue\033[0m", buf);
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+shattrs_enomem(void)
+{
+	struct irc_subst params = {
+		.flags = IRC_SUBST_SHELL_ATTRS
+	};
+	char buf[10] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "@{red}hello world in red@{}", &params), -1);
+	GREATEST_ASSERT_EQ(ENOMEM, errno);
+	GREATEST_ASSERT_STR_EQ("", buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+shattrs_invalid_color(void)
+{
+	struct irc_subst params = {
+		.flags = IRC_SUBST_SHELL_ATTRS
+	};
+	char buf[1024] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "@{invalid}standard@{}", &params), 15);
+	GREATEST_ASSERT_STR_EQ("\033[mstandard\033[0m", buf);
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+ircattrs_simple(void)
+{
+	struct irc_subst params = {
+		.flags = IRC_SUBST_IRC_ATTRS
+	};
+	char buf[1024] = {0};
+
+	/* In IRC the foreground is required if the background is desired. */
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "@{red}red@{}", &params), 6);
+	GREATEST_ASSERT_STR_EQ("\x03""4red\x03", buf);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "@{red,blue}red on blue@{}", &params), 16);
+	GREATEST_ASSERT_STR_EQ("\x03""4,2red on blue\x03", buf);
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "@{red,blue,bold}bold red on blue@{}", &params), 22);
+	GREATEST_ASSERT_STR_EQ("\x03" "4,2" "\x02" "bold red on blue\x03", buf);
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+ircattrs_enomem(void)
+{
+	struct irc_subst params = {
+		.flags = IRC_SUBST_IRC_ATTRS
+	};
+	char buf[10] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "@{red}hello world in red@{}", &params), -1);
+	GREATEST_ASSERT_EQ(ENOMEM, errno);
+	GREATEST_ASSERT_STR_EQ("", buf);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+ircattrs_invalid_color(void)
+{
+	struct irc_subst params = {
+		.flags = IRC_SUBST_IRC_ATTRS
+	};
+	char buf[1024] = {0};
+
+	GREATEST_ASSERT_EQ(irc_subst(buf, sizeof (buf), "@{invalid}standard@{}", &params), 10);
+	GREATEST_ASSERT_STR_EQ("\x03" "standard" "\x03", buf);
+
+	GREATEST_PASS();
+}
+
+
+GREATEST_SUITE(suite_basics)
+{
+	GREATEST_RUN_TEST(basics_test);
+	GREATEST_RUN_TEST(basics_escape);
+}
+
+GREATEST_SUITE(suite_disable)
+{
+	GREATEST_RUN_TEST(disable_date);
+	GREATEST_RUN_TEST(disable_keywords);
+	GREATEST_RUN_TEST(disable_env);
+	GREATEST_RUN_TEST(disable_shell);
+}
+
+GREATEST_SUITE(suite_keywords)
+{
+	GREATEST_RUN_TEST(keywords_simple);
+	GREATEST_RUN_TEST(keywords_multiple);
+	GREATEST_RUN_TEST(keywords_adj_twice);
+	GREATEST_RUN_TEST(keywords_missing);
+	GREATEST_RUN_TEST(keywords_enomem);
+	GREATEST_RUN_TEST(keywords_einval);
+}
+
+GREATEST_SUITE(suite_env)
+{
+	GREATEST_RUN_TEST(env_simple);
+	GREATEST_RUN_TEST(env_missing);
+	GREATEST_RUN_TEST(env_enomem);
+}
+
+GREATEST_SUITE(suite_shell)
+{
+	GREATEST_RUN_TEST(shell_simple);
+	GREATEST_RUN_TEST(shell_no_new_line);
+}
+
+GREATEST_SUITE(suite_shattrs)
+{
+	GREATEST_RUN_TEST(shattrs_simple);
+	GREATEST_RUN_TEST(shattrs_enomem);
+	GREATEST_RUN_TEST(shattrs_invalid_color);
+}
+
+GREATEST_SUITE(suite_ircattrs)
+{
+	GREATEST_RUN_TEST(ircattrs_simple);
+	GREATEST_RUN_TEST(ircattrs_enomem);
+	GREATEST_RUN_TEST(ircattrs_invalid_color);
+}
+
+GREATEST_MAIN_DEFS();
+
+int
+main(int argc, char **argv)
+{
+	GREATEST_MAIN_BEGIN();
+	GREATEST_RUN_SUITE(suite_basics);
+	GREATEST_RUN_SUITE(suite_disable);
+	GREATEST_RUN_SUITE(suite_keywords);
+	GREATEST_RUN_SUITE(suite_env);
+	GREATEST_RUN_SUITE(suite_shell);
+	GREATEST_RUN_SUITE(suite_shattrs);
+	GREATEST_RUN_SUITE(suite_ircattrs);
+	GREATEST_MAIN_END();
+
+	return 0;
+}