1 /++
2 + IRCv3 tags capability support.
3 +/
4 module virc.ircv3.tags;
5 
6 
7 import core.time : Duration, seconds;
8 import std.algorithm : filter, findSplit, map, splitter, startsWith;
9 import std.array : empty, front;
10 import std.datetime : msecs, SysTime, UTC;
11 import std.exception : enforce;
12 import std.meta : aliasSeqOf;
13 import std.range : dropOne, isInputRange, only;
14 import std.traits : isArray;
15 import std.typecons : Nullable;
16 import std.utf;
17 
18 import virc.ircv3.batch;
19 /++
20 +
21 +/
22 struct IRCTags {
23 	///
24 	string[string] tags;
25 	alias tags this;
26 	this(string tagString) @safe pure {
27 		tags = parseTagString(tagString).tags;
28 	}
29 	this(string[string] inTags) @safe pure nothrow {
30 		tags = inTags;
31 	}
32 	string toString() const @safe pure {
33 		alias escape = replaceEscape!(string, only(`\`, `\\`), only(`;`, `\:`), only("\r", `\r`), only("\n", `\n`), only(" ", `\s`));
34 		import std.string : join;
35 		string[] pieces;
36 		foreach (key, value; tags) {
37 			string piece = escape(key);
38 			if (value != "") {
39 				piece ~= "="~escape(value);
40 			}
41 			pieces ~= piece;
42 		}
43 		return pieces.join(";");
44 	}
45 	bool empty() const @safe pure nothrow {
46 		return tags.length == 0;
47 	}
48 	void reply(string id) @safe pure nothrow {
49 		tags["+draft/reply"] = id;
50 	}
51 	void typing(TypingState state) @safe pure nothrow {
52 		tags["+typing"] = cast(string)state;
53 	}
54 	void batch(string id) @safe pure nothrow {
55 		tags["batch"] = id;
56 	}
57 	void multilineConcat() @safe pure nothrow {
58 		tags["draft/multiline-concat"] = "";
59 	}
60 }
61 
62 ///
63 @safe unittest {
64 	{
65 		const tags = IRCTags("a=b;x=");
66 		assert(tags["a"] == "b");
67 		assert(tags["x"] == "");
68 	}
69 	{
70 		const tags = IRCTags(["a": "b", "x": ""]);
71 		assert(tags["a"] == "b");
72 		assert(tags["x"] == "");
73 	}
74 	{
75 		const tags = IRCTags(["a": "\\"]);
76 		assert(tags["a"] == "\\");
77 		//assert(tags.toString() == "a=\\\\");
78 	}
79 }
80 
81 /++
82 +
83 +/
84 Nullable!bool booleanTag(string tag)(IRCTags tags) {
85 	Nullable!bool output;
86 	if (tag in tags) {
87 		if (tags[tag] == "1") {
88 			output = true;
89 		} else if (tags[tag] == "0") {
90 			output = false;
91 		} //Other values treated as if tag not present
92 	}
93 	return output;
94 }
95 ///
96 @safe pure nothrow unittest {
97 	assert(IRCTags(string[string].init).booleanTag!"test".isNull);
98 	assert(IRCTags(["test": "aaaaa"]).booleanTag!"test".isNull);
99 	assert(!IRCTags(["test": "0"]).booleanTag!"test".get);
100 	assert(IRCTags(["test": "1"]).booleanTag!"test".get);
101 }
102 /++
103 +
104 +/
105 Nullable!string stringTag(string tag)(IRCTags tags) {
106 	return typeTag!(tag, string)(tags);
107 }
108 /++
109 +
110 +/
111 Nullable!Type typeTag(string tag, Type)(IRCTags tags) {
112 	import std.conv : to;
113 	Nullable!Type output;
114 	if (tag in tags) {
115 		try {
116 			output = tags[tag].to!Type;
117 		} catch (Exception) {} //Act as if tag doesn't exist if malformed
118 	}
119 	return output;
120 }
121 ///
122 @safe pure nothrow unittest {
123 	assert(IRCTags(string[string].init).typeTag!("test", uint).isNull);
124 	assert(IRCTags(["test": "a"]).typeTag!("test", uint).isNull);
125 	assert(IRCTags(["test": "0"]).typeTag!("test", uint) == 0);
126 	assert(IRCTags(["test": "10"]).typeTag!("test", uint) == 10);
127 	assert(IRCTags(["test": "words"]).typeTag!("test", string) == "words");
128 	assert(IRCTags(["test": "words"]).stringTag!"test" == "words");
129 	static struct Something {
130 		char val;
131 		this(string str) @safe pure nothrow {
132 			val = str[0];
133 		}
134 	}
135 	assert(IRCTags(["test": "words"]).typeTag!("test", Something).get.val == 'w');
136 }
137 /++
138 +
139 +/
140 auto arrayTag(string tag, string delimiter = ",", Type = string[])(IRCTags tags) if (isArray!Type){
141 	import std.algorithm : splitter;
142 	import std.conv : to;
143 	import std.range : ElementType;
144 	Nullable!Type output;
145 	if (tag in tags) {
146 		auto split = tags[tag].splitter(delimiter);
147 		output = [];
148 		foreach (element; split) {
149 			try {
150 				output.get ~= element.to!(ElementType!Type);
151 			} catch (Exception) { //Malformed, reset everything
152 				output = output.init;
153 				break;
154 			}
155 		}
156 	}
157 	return output;
158 }
159 ///
160 @safe pure nothrow unittest {
161 	assert(IRCTags(string[string].init).arrayTag!("test").isNull);
162 	assert(IRCTags(["test":""]).arrayTag!("test").get.empty);
163 	assert(IRCTags(["test":"a"]).arrayTag!("test").get.front == "a");
164 	assert(IRCTags(["test":"a,b"]).arrayTag!("test") == ["a", "b"]);
165 	assert(IRCTags(["test":"a:b"]).arrayTag!("test", ":") == ["a", "b"]);
166 	assert(IRCTags(["test":"9,1"]).arrayTag!("test", ",", uint[]) == [9, 1]);
167 	assert(IRCTags(["test":"9,a"]).arrayTag!("test", ",", uint[]).isNull);
168 }
169 /++
170 +
171 +/
172 Nullable!Duration secondDurationTag(string tag)(IRCTags tags) {
173 	import std.conv : to;
174 	Nullable!Duration output;
175 	if (tag in tags) {
176 		try {
177 			output = tags[tag].to!long.seconds;
178 		} catch (Exception) {} //Not a duration. Act like tag is nonexistent.
179 	}
180 	return output;
181 }
182 ///
183 @safe pure nothrow unittest {
184 	import core.time : hours;
185 	assert(IRCTags(string[string].init).secondDurationTag!("test").isNull);
186 	assert(IRCTags(["test": "a"]).secondDurationTag!("test").isNull);
187 	assert(IRCTags(["test": "3600"]).secondDurationTag!("test") == 1.hours);
188 }
189 /++
190 +
191 +/
192 auto parseTime(string[string] tags) {
193 	enforce("time" in tags);
194 	return SysTime.fromISOExtString(tags["time"], UTC());
195 }
196 
197 auto parseTagString(string input) {
198 	import std.algorithm.comparison : among;
199 	IRCTags output;
200 	auto splitTags = input.splitter(";").filter!(a => !a.empty);
201 	foreach (tag; splitTags) {
202 		auto splitKV = tag.findSplit("=");
203 		auto key = splitKV[0];
204 		if (!splitKV[1].empty) {
205 			auto value = splitKV[2];
206 			if ((value.length > 0) && (value[$-1] == '\\')) {
207 				value = value[0..$-1];
208 			}
209 			if (value.length > 0) {
210 				for (int i = 0; i < value.length-1; i++) {
211 					if ((value[i] == '\\') && !value[i+1].among('\\', ':', 'r', 'n', 's')) {
212 						value = value[0 .. i] ~ value[i +1 .. $];
213 					}
214 				}
215 			}
216 			output[key] = value.replaceEscape!(string, only(`\\`, `\`), only(`\:`, `;`), only(`\r`, "\r"), only(`\n`, "\n"), only(`\s`, " "));
217 		} else {
218 			output[key] = "";
219 		}
220 	}
221 	return output;
222 }
223 
224 ///
225 @safe pure /+nothrow @nogc+/ unittest {
226 	//Example from http://ircv3.net/specs/core/message-tags-3.2.html
227 	{
228 		immutable tags = parseTagString("");
229 		assert(tags.length == 0);
230 	}
231 	//ditto
232 	{
233 		immutable tags = parseTagString("aaa=bbb;ccc;example.com/ddd=eee");
234 		assert(tags.length == 3);
235 		assert(tags["aaa"] == "bbb");
236 		assert(tags["ccc"] == "");
237 		assert(tags["example.com/ddd"] == "eee");
238 	}
239 	//escape test
240 	{
241 		immutable tags = parseTagString(`whatevs=\\s`);
242 		assert(tags.length == 1);
243 		assert("whatevs" in tags);
244 		assert(tags["whatevs"] == `\s`);
245 	}
246 	//Example from http://ircv3.net/specs/extensions/batch-3.2.html
247 	{
248 		immutable tags = parseTagString(`batch=yXNAbvnRHTRBv`);
249 		assert(tags.length == 1);
250 		assert("batch" in tags);
251 		assert(tags["batch"] == "yXNAbvnRHTRBv");
252 	}
253 	//Example from http://ircv3.net/specs/extensions/account-tag-3.2.html
254 	{
255 		immutable tags = parseTagString(`account=hax0r`);
256 		assert(tags.length == 1);
257 		assert("account" in tags);
258 		assert(tags["account"] == "hax0r");
259 	}
260 	{
261 		immutable tags = parseTagString(`testk=test\`);
262 		assert("testk" in tags);
263 		assert(tags["testk"] == "test");
264 	}
265 }
266 ///
267 @safe /+pure nothrow @nogc+/ unittest {
268 	import std.datetime : DateTime, msecs, SysTime, UTC;
269 	//Example from http://ircv3.net/specs/extensions/server-time-3.2.html
270 	{
271 		auto tags = parseTagString("time=2011-10-19T16:40:51.620Z");
272 		assert(tags.length == 1);
273 		assert("time" in tags);
274 		assert(tags["time"] == "2011-10-19T16:40:51.620Z");
275 		immutable testTime = SysTime(DateTime(2011,10,19,16,40,51), 620.msecs, UTC());
276 		assert(parseTime(tags) == testTime);
277 	}
278 	//ditto
279 	{
280 		immutable tags = parseTagString("time=2012-06-30T23:59:60.419Z");
281 		assert(tags.length == 1);
282 		assert("time" in tags);
283 		assert(tags["time"] == "2012-06-30T23:59:60.419Z");
284 		//leap seconds not currently representable
285 		//assert(parseTime(splitStr.tags) == SysTime(DateTime(2012,06,30,23,59,60), 419.msecs, UTC()));
286 	}
287 }
288 /++
289 +
290 +/
291 T replaceEscape(T, replacements...)(T input) {
292 	static if (replacements.length == 0) {
293 		return input;
294 	} else {
295 		T output;
296 		enum findStrs = aliasSeqOf!([replacements].map!((x) => x[0].byCodeUnit));
297 		for (size_t position = 0; position < input.length; position++) {
298 			sw: final switch(input[position..$].byCodeUnit.startsWith(findStrs)) {
299 				case 0:
300 					output ~= input[position];
301 					break;
302 				static foreach (index, replacement; replacements) {
303 					static assert(replacements[index][0].length >= 1);
304 					case index+1:
305 						output ~= replacements[index][1];
306 						position += replacements[index][0].length-1;
307 						break sw;
308 				}
309 			}
310 		}
311 		return output;
312 	}
313 }
314 ///
315 @safe pure nothrow unittest {
316 	assert(replaceEscape("") == "");
317 	assert(replaceEscape!(string, only("a", "b"))("a") == "b");
318 	assert(replaceEscape!(string, only("a", "b"), only("aa", "b"))("aa") == "bb");
319 }
320 
321 enum TypingState {
322 	active = "active",
323 	paused = "paused",
324 	done = "done",
325 }