1 module irc.url;
2 
3 import std.array;
4 import std.conv : to, ConvException;
5 import std.regex;
6 import std.string : icmp, indexOf;
7 
8 import irc.protocol : channelPrefixes;
9 
10 /// Result of the $(MREF parse) and $(MREF tryParse) functions,
11 /// containing the parsed connection information.
12 struct ConnectionInfo
13 {
14 	/// Server address.
15 	string address;
16 
17 	/// Explicitly specified server port, or $(D 0) when unspecified.
18 	ushort explicitPort;
19 
20 	/**
21 	 * Server port.
22 	 *
23 	 * Evaluates to $(MREF ConnectionInfo.explicitPort) when an explicit
24 	 * port was specified, and $(MREF ConnectionInfo.defaultPort) otherwise.
25 	 */
26 	ushort port() @property @safe pure nothrow
27 	{
28 		return explicitPort == 0? defaultPort : explicitPort;
29 	}
30 
31 	/// Security protocol. Is $(D true) for TLS/SSL,
32 	/// and $(D false) for no security.
33 	bool secure;
34 
35 	/// Channels to join immediately after a successful connect. Can be empty.
36 	string[] channels;
37 
38 	/// Key/passphrase to use when joining channels.
39 	/// Is $(D null) when unspecified.
40 	string channelKey;
41 
42 	/// Default port for the specified security protocol.
43 	/// $(D 6697) for TLS/SSL, and $(D 6667) otherwise.
44 	ushort defaultPort() @property @safe pure nothrow
45 	{
46 		return secure? 6697 : 6667;
47 	}
48 }
49 
50 ///
51 class IrcUrlException : Exception
52 {
53 	/// Same as $(MREF ParseError.location).
54 	size_t location;
55 
56 	this(string msg, size_t location, string file = __FILE__, size_t line = __LINE__, Throwable next = null) @safe pure nothrow
57 	{
58 		this.location = location;
59 		super(msg, file, line, next);
60 	}
61 }
62 
63 /**
64  * Parse IRC URLs (also known as "chat links").
65  *
66  * Channels without a valid prefix are automatically
67  * prefixed with '#'.
68  */
69 // TODO: describe supported URL format in detail
70 ConnectionInfo parse(string url) @safe
71 {
72 	ConnectionInfo info;
73 
74 	if(auto error = url.tryParse(info))
75 		throw new IrcUrlException(error.message, error.location);
76 
77 	return info;
78 }
79 
80 ///
81 unittest
82 {
83 	ConnectionInfo info;
84 
85 	info = parse("ircs://irc.example.com:6697/foo,bar");
86 
87 	assert(info.address == "irc.example.com");
88 	assert(info.explicitPort == 6697);
89 	assert(info.port == 6697);
90 	assert(info.secure);
91 	assert(info.channels == ["#foo", "#bar"]);
92 
93 	info = parse("irc://irc.example.org/foo?pass");
94 
95 	assert(info.address == "irc.example.org");
96 	assert(info.explicitPort == 0);
97 	assert(info.port == 6667); // No explicit port, so it falls back to the default IRC port
98 	assert(!info.secure);
99 	assert(info.channels == ["#foo"]);
100 	assert(info.channelKey == "pass");
101 }
102 
103 ///
104 struct ParseError
105 {
106 	/// Error message.
107 	string message;
108 
109 	/// Location in input (zero-based column) the error occured.
110 	/// Ranges from $(D 0 .. $ - 1), where the $(D $) symbol is the length of the input.
111 	size_t location = 0;
112 
113 	private bool wasError = true;
114 
115 	/// Boolean whether or not an error occured. If an error did not occur,
116 	/// $(D message) and $(D location) will not have meaningful values.
117 	bool opCast(T)() @safe pure if(is(T == bool))
118 	{
119 		return wasError;
120 	}
121 
122 	///
123 	@safe pure unittest
124 	{
125 		auto error = ParseError("error occured!", 0);
126 		assert(error); // conversion to $(D bool)
127 	}
128 }
129 
130 /**
131  * Same as $(MREF parse), but returning an error message instead of throwing.
132  * Useful for high-volume parsing.
133  */
134 ParseError tryParse(string url, out ConnectionInfo info) @trusted /+ @safe nothrow +/
135 {
136 	static urlPattern =
137 		ctRegex!(
138 			`^([^:]+)://` ~ // Protocol
139 			`([^:/]+)(:\+?[^/]+)?` ~ // Address and optional port
140 			`/?([^\?]+)?` ~ // Optional channel list
141 			`\??(.*)$`, // Optional channel key
142 			"ix"
143 		);
144 
145 	typeof(url.match(urlPattern)) m;
146 
147 	try m = url.match(urlPattern);
148 	catch(Exception ex)
149 	{
150 		return ParseError(ex.msg);
151 	}
152 
153 	if(!m)
154 		return ParseError("input is not a URL");
155 
156 	auto captures = m.captures;
157 	captures.popFront(); // skip whole match
158 
159 	// Handle protocol
160 	auto protocol = captures.front;
161 	captures.popFront();
162 
163 	if(protocol.icmp("irc") != 0 && protocol.icmp("ircs") != 0)
164 		return ParseError(`connection protocol must be "irc" or "ircs", not ` ~ protocol);
165 
166 	info.secure = protocol.icmp("ircs") == 0;
167 
168 	// Handle address
169 	info.address = captures.front;
170 	captures.popFront();
171 
172 	// Handle port
173 	auto strPort = captures.front;
174 
175 	auto pre = captures.pre;
176 	auto post = captures.post;
177 	auto hit = captures.hit;
178 
179 	if(strPort.length > 1)
180 	{
181 		strPort.popFront; // Skip colon
182 
183 		if(strPort.front == '+')
184 		{
185 			info.secure = true;
186 			strPort.popFront;
187 		}
188 
189 		try info.explicitPort = to!ushort(strPort);
190 		catch(ConvException e)
191 			return ParseError("Error parsing port: " ~ e.msg, url.indexOf(strPort)); // TODO: shouldn't have to search
192 	}
193 
194 	captures.popFront();
195 
196 	// Handle channels
197 	auto tail = captures.front;
198 
199 	if(!tail.empty)
200 	{
201 		info.channels = tail.split(",");
202 
203 		foreach(ref channel; info.channels)
204 		{
205 			switch(channel[0])
206 			{
207 				foreach(prefix; channelPrefixes)
208 					case prefix:
209 						break;
210 
211 				default:
212 					channel = '#' ~ channel;
213 			}
214 		}
215 	}
216 
217 	captures.popFront();
218 
219 	// Handle channel key
220 	auto key = captures.front;
221 
222 	if(!key.empty)
223 		info.channelKey = key;
224 
225 	return ParseError(null, 0, false);
226 }
227 
228 /// Parse list of URLs and write any errors to $(D stderr)
229 /// with column information.
230 unittest
231 {
232 	import std.stdio : stderr;
233 
234 	auto urls = ["irc://example.com", "ircs://example.org/foo?pass"];
235 
236 	foreach(url; urls)
237 	{
238 		ConnectionInfo info;
239 
240 		if(auto error = url.tryParse(info))
241 		{
242 			stderr.writefln("Error parsing URL:\n%s\n%*s\n%s", url, error.location + 1, "^", error.message);
243 			continue;
244 		}
245 
246 		// Use `info`
247 	}
248 }
249 
250 unittest
251 {
252 	import std.stdio : writeln;
253 
254 	static struct Test
255 	{
256 		string url;
257 		ConnectionInfo expectedResult;
258 	}
259 
260 	auto tests = [
261 		Test("irc://example.com",
262 			 ConnectionInfo("example.com", 0, false)
263 		),
264 		Test("ircs://example.com",
265 			 ConnectionInfo("example.com", 0, true)
266 		),
267 		Test("irc://example.org:6667",
268 			 ConnectionInfo("example.org", 6667, false)
269 		),
270 		Test("irc://example.org:+6697",
271 			 ConnectionInfo("example.org", 6697, true)
272 		),
273 		Test("iRc://example.info/example",
274 			 ConnectionInfo("example.info", 0, false, ["#example"])
275 		),
276 		Test("IRCS://example.info/example?passphrase",
277 			 ConnectionInfo("example.info", 0, true, ["#example"], "passphrase")
278 		),
279 		Test("irc://test/example,test",
280 			 ConnectionInfo("test", 0, false, ["#example", "#test"])
281 		),
282 		Test("ircs://test/example,test,foo?pass",
283 			 ConnectionInfo("test", 0, true, ["#example", "#test", "#foo"], "pass")
284 		),
285 		Test("ircs://example.com:+6697/foo,bar,baz?pass",
286 			 ConnectionInfo("example.com", 6697, true, ["#foo", "#bar", "#baz"], "pass")
287 		)
288 	];
289 
290 	foreach(i, ref test; tests)
291 	{
292 		immutable msg = "test #" ~ to!string(i + 1);
293 
294 		ConnectionInfo result;
295 		auto error = tryParse(test.url, result);
296 
297 		scope(failure) debug writeln(error);
298 
299 		assert(!error);
300 
301 		scope(failure) debug writeln(result);
302 
303 		assert(result.address == test.expectedResult.address, msg);
304 		assert(result.port == test.expectedResult.port, msg);
305 		assert(result.secure == test.expectedResult.secure, msg);
306 		assert(result.channels == test.expectedResult.channels, msg);
307 		assert(result.channelKey == test.expectedResult.channelKey, msg);
308 	}
309 
310 	// TODO: test error paths
311 }