From 21f1e95fb8e935134a969bc3d729964d8d2aadfa Mon Sep 17 00:00:00 2001 From: Ben Harris Date: Thu, 14 May 2020 23:06:10 -0400 Subject: rename Irc to IRC --- IRCTokens/Extensions.cs | 62 +++++++ IRCTokens/Hostmask.cs | 64 +++++++ IRCTokens/IRCTokens.csproj | 29 +++ IRCTokens/Line.cs | 233 ++++++++++++++++++++++++ IRCTokens/README.md | 96 ++++++++++ IRCTokens/StatefulDecoder.cs | 79 +++++++++ IRCTokens/StatefulEncoder.cs | 71 ++++++++ IRCTokens/Tests/Data/JoinModel.cs | 30 ++++ IRCTokens/Tests/Data/SplitModel.cs | 15 ++ IRCTokens/Tests/Data/msg-join.yaml | 221 +++++++++++++++++++++++ IRCTokens/Tests/Data/msg-split.yaml | 343 ++++++++++++++++++++++++++++++++++++ IRCTokens/Tests/Format.cs | 105 +++++++++++ IRCTokens/Tests/Hostmask.cs | 64 +++++++ IRCTokens/Tests/Parser.cs | 55 ++++++ IRCTokens/Tests/StatefulDecoder.cs | 88 +++++++++ IRCTokens/Tests/StatefulEncoder.cs | 84 +++++++++ IRCTokens/Tests/Tokenization.cs | 118 +++++++++++++ 17 files changed, 1757 insertions(+) create mode 100644 IRCTokens/Extensions.cs create mode 100644 IRCTokens/Hostmask.cs create mode 100644 IRCTokens/IRCTokens.csproj create mode 100644 IRCTokens/Line.cs create mode 100644 IRCTokens/README.md create mode 100644 IRCTokens/StatefulDecoder.cs create mode 100644 IRCTokens/StatefulEncoder.cs create mode 100644 IRCTokens/Tests/Data/JoinModel.cs create mode 100644 IRCTokens/Tests/Data/SplitModel.cs create mode 100644 IRCTokens/Tests/Data/msg-join.yaml create mode 100644 IRCTokens/Tests/Data/msg-split.yaml create mode 100644 IRCTokens/Tests/Format.cs create mode 100644 IRCTokens/Tests/Hostmask.cs create mode 100644 IRCTokens/Tests/Parser.cs create mode 100644 IRCTokens/Tests/StatefulDecoder.cs create mode 100644 IRCTokens/Tests/StatefulEncoder.cs create mode 100644 IRCTokens/Tests/Tokenization.cs (limited to 'IRCTokens') diff --git a/IRCTokens/Extensions.cs b/IRCTokens/Extensions.cs new file mode 100644 index 0000000..e346a43 --- /dev/null +++ b/IRCTokens/Extensions.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace IRCTokens +{ + public static class Extensions + { + public static IEnumerable Split(this byte[] bytes, byte separator) + { + if (bytes == null || bytes.Length == 0) return new List(); + + var newLineIndices = bytes.Select((b, i) => b == separator ? i : -1).Where(i => i != -1).ToArray(); + var lines = new byte[newLineIndices.Length + 1][]; + var currentIndex = 0; + var arrIndex = 0; + + for (var i = 0; i < newLineIndices.Length && currentIndex < bytes.Length; ++i) + { + var n = new byte[newLineIndices[i] - currentIndex]; + Array.Copy(bytes, currentIndex, n, 0, newLineIndices[i] - currentIndex); + currentIndex = newLineIndices[i] + 1; + lines[arrIndex++] = n; + } + + // Handle the last string at the end of the array if there is one. + if (currentIndex < bytes.Length) + lines[arrIndex] = bytes.Skip(currentIndex).ToArray(); + else if (arrIndex == newLineIndices.Length) + // We had a separator character at the end of a string. Rather than just allowing + // a null character, we'll replace the last element in the array with an empty string. + lines[arrIndex] = Array.Empty(); + + return lines.ToArray(); + } + + public static byte[] Trim(this IEnumerable bytes, byte separator) + { + if (bytes == null) return Array.Empty(); + + var byteList = new List(bytes); + var i = 0; + + if (!byteList.Any()) return byteList.ToArray(); + + while (byteList[i] == separator) + { + byteList.RemoveAt(i); + i++; + } + + i = byteList.Count - 1; + while (byteList[i] == separator) + { + byteList.RemoveAt(i); + i--; + } + + return byteList.ToArray(); + } + } +} diff --git a/IRCTokens/Hostmask.cs b/IRCTokens/Hostmask.cs new file mode 100644 index 0000000..2e1549a --- /dev/null +++ b/IRCTokens/Hostmask.cs @@ -0,0 +1,64 @@ +using System; + +namespace IRCTokens +{ + /// + /// Represents the three parts of a hostmask. Parse with the constructor. + /// + public class Hostmask : IEquatable + { + private readonly string _source; + + public Hostmask(string source) + { + if (source == null) return; + + _source = source; + + if (source.Contains('@', StringComparison.Ordinal)) + { + var split = source.Split('@'); + + NickName = split[0]; + HostName = split[1]; + } + else + { + NickName = source; + } + + if (NickName.Contains('!', StringComparison.Ordinal)) + { + var userSplit = NickName.Split('!'); + NickName = userSplit[0]; + UserName = userSplit[1]; + } + } + + public string NickName { get; set; } + public string UserName { get; set; } + public string HostName { get; set; } + + public bool Equals(Hostmask other) + { + if (other == null) return false; + + return _source == other._source; + } + + public override string ToString() + { + return _source; + } + + public override int GetHashCode() + { + return _source.GetHashCode(StringComparison.Ordinal); + } + + public override bool Equals(object obj) + { + return Equals(obj as Hostmask); + } + } +} diff --git a/IRCTokens/IRCTokens.csproj b/IRCTokens/IRCTokens.csproj new file mode 100644 index 0000000..2fe9300 --- /dev/null +++ b/IRCTokens/IRCTokens.csproj @@ -0,0 +1,29 @@ + + + + netcoreapp3.1 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/IRCTokens/Line.cs b/IRCTokens/Line.cs new file mode 100644 index 0000000..bf3cc91 --- /dev/null +++ b/IRCTokens/Line.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; + +namespace IRCTokens +{ + /// + /// Tools to represent, parse, and format IRC lines + /// + public class Line : IEquatable + { + private static readonly string[] TagUnescaped = {"\\", " ", ";", "\r", "\n"}; + + private static readonly string[] TagEscaped = {"\\\\", "\\s", "\\:", "\\r", "\\n"}; + + private Hostmask _hostmask; + + public Line() + { + } + + public Line(string command, params string[] parameters) + { + Command = command; + Params = parameters.ToList(); + } + + /// + /// Build new object parsed from + /// a string + /// . Analogous to irctokens.tokenise() + /// + /// irc line to parse + public Line(string line) + { + if (string.IsNullOrWhiteSpace(line)) throw new ArgumentNullException(nameof(line)); + + string[] split; + + if (line.StartsWith('@')) + { + Tags = new Dictionary(); + + split = line.Split(" ", 2); + var messageTags = split[0]; + line = split[1]; + + foreach (var part in messageTags.Substring(1).Split(';')) + if (part.Contains('=', StringComparison.Ordinal)) + { + split = part.Split('=', 2); + Tags[split[0]] = UnescapeTag(split[1]); + } + else + { + Tags[part] = string.Empty; + } + } + + string trailing; + if (line.Contains(" :", StringComparison.Ordinal)) + { + split = line.Split(" :", 2); + line = split[0]; + trailing = split[1]; + } + else + { + trailing = null; + } + + Params = line.Contains(' ', StringComparison.Ordinal) + ? line.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList() + : new List {line}; + + if (Params[0].StartsWith(':')) + { + Source = Params[0].Substring(1); + Params.RemoveAt(0); + } + + if (Params.Count > 0) + { + Command = Params[0].ToUpper(CultureInfo.InvariantCulture); + Params.RemoveAt(0); + } + + if (trailing != null) Params.Add(trailing); + } + + public Dictionary Tags { get; set; } + public string Source { get; set; } + public string Command { get; set; } + public List Params { get; set; } + + public Hostmask Hostmask => + _hostmask ??= new Hostmask(Source); + + public bool Equals(Line other) + { + if (other == null) return false; + + return Format() == other.Format(); + } + + /// + /// Unescape ircv3 tag + /// + /// escaped string + /// unescaped string + private static string UnescapeTag(string val) + { + var unescaped = new StringBuilder(); + + var graphemeIterator = StringInfo.GetTextElementEnumerator(val); + graphemeIterator.Reset(); + + while (graphemeIterator.MoveNext()) + { + var current = graphemeIterator.GetTextElement(); + + if (current == @"\") + try + { + graphemeIterator.MoveNext(); + var next = graphemeIterator.GetTextElement(); + var pair = current + next; + unescaped.Append(TagEscaped.Contains(pair) + ? TagUnescaped[Array.IndexOf(TagEscaped, pair)] + : next); + } + catch (InvalidOperationException) + { + // ignored + } + else + unescaped.Append(current); + } + + return unescaped.ToString(); + } + + /// + /// Escape strings for use in ircv3 tags + /// + /// string to escape + /// escaped string + private static string EscapeTag(string val) + { + for (var i = 0; i < TagUnescaped.Length; ++i) + val = val?.Replace(TagUnescaped[i], TagEscaped[i], StringComparison.Ordinal); + + return val; + } + + public override string ToString() + { + var vars = new List(); + + if (Command != null) vars.Add($"command={Command}"); + + if (Source != null) vars.Add($"source={Source}"); + + if (Params != null && Params.Any()) vars.Add($"params=[{string.Join(",", Params)}]"); + + if (Tags != null && Tags.Any()) + vars.Add($"tags=[{string.Join(";", Tags.Select(kvp => $"{kvp.Key}={kvp.Value}"))}]"); + + return $"Line({string.Join(", ", vars)})"; + } + + public override int GetHashCode() + { + return Format().GetHashCode(StringComparison.Ordinal); + } + + public override bool Equals(object obj) + { + return Equals(obj as Line); + } + + /// + /// Format a as a standards-compliant IRC line + /// + /// formatted irc line + public string Format() + { + var outs = new List(); + + if (Tags != null && Tags.Any()) + { + var tags = Tags.Keys + .OrderBy(k => k) + .Select(key => + string.IsNullOrWhiteSpace(Tags[key]) ? key : $"{key}={EscapeTag(Tags[key])}") + .ToList(); + + outs.Add($"@{string.Join(";", tags)}"); + } + + if (Source != null) outs.Add($":{Source}"); + + outs.Add(Command); + + if (Params != null && Params.Any()) + { + var last = Params[^1]; + var withoutLast = Params.SkipLast(1).ToList(); + + foreach (var p in withoutLast) + { + if (p.Contains(' ', StringComparison.Ordinal)) + throw new ArgumentException(@"non-last parameters cannot have spaces", p); + + if (p.StartsWith(':')) + throw new ArgumentException(@"non-last parameters cannot start with colon", p); + } + + outs.AddRange(withoutLast); + + if (string.IsNullOrWhiteSpace(last) || last.Contains(' ', StringComparison.Ordinal) || + last.StartsWith(':')) + last = $":{last}"; + + outs.Add(last); + } + + return string.Join(" ", outs); + } + } +} diff --git a/IRCTokens/README.md b/IRCTokens/README.md new file mode 100644 index 0000000..d3769aa --- /dev/null +++ b/IRCTokens/README.md @@ -0,0 +1,96 @@ +# IRCTokens + +this is a c\# port of jesopo's [irctokens]( +https://github.com/jesopo/irctokens) + +## usage + +### tokenization + + using IRCTokens; + + ... + + var line = new Line("@id=123 :ben!~ben@host.tld PRIVMSG #channel :hello there!"); + Console.WriteLine(line); + Console.WriteLine(line.Format()); + +### formatting + + var line = new Line {Command = "USER", Params = new List {"user", "0", "*", "real name"}}; + Console.WriteLine(line); + Console.WriteLine(line.Format()); + +### stateful + +see the full example in [TokensSample/Client.cs](../Examples/Tokens/Client.cs) + + public class Client + { + private readonly byte[] _bytes; + private readonly StatefulDecoder _decoder; + private readonly StatefulEncoder _encoder; + private readonly Socket _socket; + + public Client() + { + _decoder = new StatefulDecoder(); + _encoder = new StatefulEncoder(); + _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.IP); + _bytes = new byte[1024]; + } + + public void Start() + { + _socket.Connect("127.0.0.1", 6667); + while (!_socket.Connected) Thread.Sleep(1000); + + Send(new Line {Command = "NICK", Params = new List {"tokensbot"}}); + Send(new Line {Command = "USER", Params = new List {"tokensbot", "0", "*", "real name"}}); + + while (true) + { + var bytesReceived = _socket.Receive(_bytes); + + if (bytesReceived == 0) + { + Console.WriteLine("! disconnected"); + _socket.Shutdown(SocketShutdown.Both); + _socket.Close(); + break; + } + + var lines = _decoder.Push(_bytes, bytesReceived); + + foreach (var line in lines) + { + Console.WriteLine($"< {line.Format()}"); + + switch (line.Command) + { + case "PING": + Send(new Line {Command = "PONG", Params = line.Params}); + break; + case "001": + Send(new Line {Command = "JOIN", Params = new List {"#test"}}); + break; + case "PRIVMSG": + Send(new Line + { + Command = "PRIVMSG", Params = new List {line.Params[0], "hello there"} + }); + break; + } + } + } + } + + private void Send(Line line) + { + Console.WriteLine($"> {line.Format()}"); + _encoder.Push(line); + while (_encoder.PendingBytes.Length > 0) + _encoder.Pop(_socket.Send(_encoder.PendingBytes, SocketFlags.None)); + } + } + diff --git a/IRCTokens/StatefulDecoder.cs b/IRCTokens/StatefulDecoder.cs new file mode 100644 index 0000000..82630f6 --- /dev/null +++ b/IRCTokens/StatefulDecoder.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace IRCTokens +{ + public class StatefulDecoder + { + private byte[] _buffer; + private Encoding _encoding; + private Encoding _fallback; + + public StatefulDecoder() + { + Clear(); + } + + public Encoding Encoding + { + get => _encoding ?? Encoding.GetEncoding(Encoding.UTF8.CodePage, EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + set + { + if (value != null) + _encoding = Encoding.GetEncoding(value.CodePage, EncoderFallback.ExceptionFallback, + DecoderFallback.ReplacementFallback); + } + } + + public Encoding Fallback + { + get => _fallback ?? Encoding.GetEncoding(Encoding.GetEncoding("iso-8859-1").CodePage, + EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback); + set + { + if (value != null) + _fallback = Encoding.GetEncoding(value.CodePage, EncoderFallback.ReplacementFallback, + DecoderFallback.ReplacementFallback); + } + } + + public string Pending => Encoding.GetString(_buffer); + + public void Clear() + { + _buffer = Array.Empty(); + } + + public List Push(string data) + { + var bytes = Encoding.GetBytes(data); + return Push(bytes, bytes.Length); + } + + public List Push(byte[] data, int bytesReceived) + { + if (data == null) return null; + + _buffer = _buffer == null ? Array.Empty() : _buffer.Concat(data.Take(bytesReceived)).ToArray(); + + var listLines = _buffer.Split((byte) '\n').Select(l => l.Trim((byte) '\r')).ToList(); + _buffer = listLines.LastOrDefault() ?? Array.Empty(); + + var decodeLines = new List(); + foreach (var line in listLines.SkipLast(1).Select(l => l.ToArray())) + try + { + decodeLines.Add(new Line(Encoding.GetString(line))); + } + catch (DecoderFallbackException) + { + decodeLines.Add(new Line(Fallback.GetString(line))); + } + + return decodeLines; + } + } +} diff --git a/IRCTokens/StatefulEncoder.cs b/IRCTokens/StatefulEncoder.cs new file mode 100644 index 0000000..46949dd --- /dev/null +++ b/IRCTokens/StatefulEncoder.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace IRCTokens +{ + public class StatefulEncoder + { + private List _bufferedLines; + private Encoding _encoding; + + public StatefulEncoder() + { + Clear(); + } + + public Encoding Encoding + { + get => _encoding ?? Encoding.GetEncoding(Encoding.UTF8.CodePage, EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + set + { + if (value != null) + _encoding = Encoding.GetEncoding(value.CodePage, EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + } + } + + public byte[] PendingBytes { get; private set; } + + public string Pending() + { + try + { + return Encoding.GetString(PendingBytes); + } + catch (DecoderFallbackException e) + { + Console.WriteLine(e); + throw; + } + } + + public void Clear() + { + PendingBytes = Array.Empty(); + _bufferedLines = new List(); + } + + public void Push(Line line) + { + if (line == null) throw new ArgumentNullException(nameof(line)); + + PendingBytes = PendingBytes.Concat(Encoding.GetBytes($"{line.Format()}\r\n")).ToArray(); + _bufferedLines.Add(line); + } + + public List Pop(int byteCount) + { + var sent = PendingBytes.Take(byteCount).Count(c => c == '\n'); + + PendingBytes = PendingBytes.Skip(byteCount).ToArray(); + + var sentLines = _bufferedLines.Take(sent).ToList(); + _bufferedLines = _bufferedLines.Skip(sent).ToList(); + + return sentLines; + } + } +} diff --git a/IRCTokens/Tests/Data/JoinModel.cs b/IRCTokens/Tests/Data/JoinModel.cs new file mode 100644 index 0000000..e54f4cf --- /dev/null +++ b/IRCTokens/Tests/Data/JoinModel.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using YamlDotNet.Serialization; + +namespace IRCTokens.Tests.Data +{ + public class JoinModel + { + public List Tests { get; set; } + + public class Test + { + [YamlMember(Alias = "desc")] public string Description { get; set; } + + public Atoms Atoms { get; set; } + + public List Matches { get; set; } + } + + public class Atoms + { + public Dictionary Tags { get; set; } + + public string Source { get; set; } + + public string Verb { get; set; } + + public List Params { get; set; } + } + } +} diff --git a/IRCTokens/Tests/Data/SplitModel.cs b/IRCTokens/Tests/Data/SplitModel.cs new file mode 100644 index 0000000..5386326 --- /dev/null +++ b/IRCTokens/Tests/Data/SplitModel.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace IRCTokens.Tests.Data +{ + public class SplitModel + { + public List Tests { get; set; } + + public class Test + { + public string Input { get; set; } + public JoinModel.Atoms Atoms { get; set; } + } + } +} diff --git a/IRCTokens/Tests/Data/msg-join.yaml b/IRCTokens/Tests/Data/msg-join.yaml new file mode 100644 index 0000000..d1d7429 --- /dev/null +++ b/IRCTokens/Tests/Data/msg-join.yaml @@ -0,0 +1,221 @@ +# IRC parser tests +# joining atoms into sendable messages + +# Written in 2015 by Daniel Oaks +# +# To the extent possible under law, the author(s) have dedicated all copyright +# and related and neighboring rights to this software to the public domain +# worldwide. This software is distributed without any warranty. +# +# You should have received a copy of the CC0 Public Domain Dedication along +# with this software. If not, see +# . + +# some of the tests here originate from grawity's test vectors, which is WTFPL v2 licensed +# https://github.com/grawity/code/tree/master/lib/tests +# some of the tests here originate from Mozilla's test vectors, which is public domain +# https://dxr.mozilla.org/comm-central/source/chat/protocols/irc/test/test_ircMessage.js +# some of the tests here originate from SaberUK's test vectors, which he's indicated I am free to include here +# https://github.com/SaberUK/ircparser/tree/master/test + +tests: + # the desc string holds a description of the test, if it exists + + # the atoms dict has the keys: + # * tags: tags dict + # tags with no value are an empty string + # * source: source string, without single leading colon + # * verb: verb string + # * params: params split up as a list + # if the params key does not exist, assume it is empty + # if any other keys do no exist, assume they are null + # a key that is null does not exist or is not specified with the + # given input string + + # matches is a list of messages that match + + # simple tests + - desc: Simple test with verb and params. + atoms: + verb: "foo" + params: + - "bar" + - "baz" + - "asdf" + matches: + - "foo bar baz asdf" + - "foo bar baz :asdf" + + # with no regular params + - desc: Simple test with source and no params. + atoms: + source: "src" + verb: "AWAY" + matches: + - ":src AWAY" + + - desc: Simple test with source and empty trailing param. + atoms: + source: "src" + verb: "AWAY" + params: + - "" + matches: + - ":src AWAY :" + + # with source + - desc: Simple test with source. + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - "asdf" + matches: + - ":coolguy foo bar baz asdf" + - ":coolguy foo bar baz :asdf" + + # with trailing param + - desc: Simple test with trailing param. + atoms: + verb: "foo" + params: + - "bar" + - "baz" + - "asdf quux" + matches: + - "foo bar baz :asdf quux" + + - desc: Simple test with empty trailing param. + atoms: + verb: "foo" + params: + - "bar" + - "baz" + - "" + matches: + - "foo bar baz :" + + - desc: Simple test with trailing param containing colon. + atoms: + verb: "foo" + params: + - "bar" + - "baz" + - ":asdf" + matches: + - "foo bar baz ::asdf" + + # with source and trailing param + - desc: Test with source and trailing param. + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - "asdf quux" + matches: + - ":coolguy foo bar baz :asdf quux" + + - desc: Test with trailing containing beginning+end whitespace. + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - " asdf quux " + matches: + - ":coolguy foo bar baz : asdf quux " + + - desc: Test with trailing containing what looks like another trailing param. + atoms: + source: "coolguy" + verb: "PRIVMSG" + params: + - "bar" + - "lol :) " + matches: + - ":coolguy PRIVMSG bar :lol :) " + + - desc: Simple test with source and empty trailing. + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - "" + matches: + - ":coolguy foo bar baz :" + + - desc: Trailing contains only spaces. + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - " " + matches: + - ":coolguy foo bar baz : " + + - desc: Param containing tab (tab is not considered SPACE for message splitting). + atoms: + source: "coolguy" + verb: "foo" + params: + - "b\tar" + - "baz" + matches: + - ":coolguy foo b\tar baz" + - ":coolguy foo b\tar :baz" + + # with tags + - desc: Tag with no value and space-filled trailing. + atoms: + tags: + "asd": "" + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - " " + matches: + - "@asd :coolguy foo bar baz : " + + - desc: Tags with escaped values. + atoms: + verb: "foo" + tags: + "a": "b\\and\nk" + "d": "gh;764" + matches: + - "@a=b\\\\and\\nk;d=gh\\:764 foo" + - "@d=gh\\:764;a=b\\\\and\\nk foo" + + - desc: Tags with escaped values and params. + atoms: + verb: "foo" + tags: + "a": "b\\and\nk" + "d": "gh;764" + params: + - "par1" + - "par2" + matches: + - "@a=b\\\\and\\nk;d=gh\\:764 foo par1 par2" + - "@a=b\\\\and\\nk;d=gh\\:764 foo par1 :par2" + - "@d=gh\\:764;a=b\\\\and\\nk foo par1 par2" + - "@d=gh\\:764;a=b\\\\and\\nk foo par1 :par2" + + - desc: Tag with long, strange values (including LF and newline). + atoms: + tags: + foo: "\\\\;\\s \r\n" + verb: "COMMAND" + matches: + - "@foo=\\\\\\\\\\:\\\\s\\s\\r\\n COMMAND" diff --git a/IRCTokens/Tests/Data/msg-split.yaml b/IRCTokens/Tests/Data/msg-split.yaml new file mode 100644 index 0000000..fa3f4aa --- /dev/null +++ b/IRCTokens/Tests/Data/msg-split.yaml @@ -0,0 +1,343 @@ +# IRC parser tests +# splitting messages into usable atoms + +# Written in 2015 by Daniel Oaks +# +# To the extent possible under law, the author(s) have dedicated all copyright +# and related and neighboring rights to this software to the public domain +# worldwide. This software is distributed without any warranty. +# +# You should have received a copy of the CC0 Public Domain Dedication along +# with this software. If not, see +# . + +# some of the tests here originate from grawity's test vectors, which is WTFPL v2 licensed +# https://github.com/grawity/code/tree/master/lib/tests +# some of the tests here originate from Mozilla's test vectors, which is public domain +# https://dxr.mozilla.org/comm-central/source/chat/protocols/irc/test/test_ircMessage.js +# some of the tests here originate from SaberUK's test vectors, which he's indicated I am free to include here +# https://github.com/SaberUK/ircparser/tree/master/test + +# we follow RFC1459 with regards to multiple ascii spaces splitting atoms: +# The prefix, command, and all parameters are +# separated by one (or more) ASCII space character(s) (0x20). +# because doing it as RFC2812 says (strictly as a single ascii space) isn't sane + +tests: + # input is the string coming directly from the server to parse + + # the atoms dict has the keys: + # * tags: tags dict + # tags with no value are an empty string + # * source: source string, without single leading colon + # * verb: verb string + # * params: params split up as a list + # if the params key does not exist, assume it is empty + # if any other keys do no exist, assume they are null + # a key that is null does not exist or is not specified with the + # given input string + + # simple + - input: "foo bar baz asdf" + atoms: + verb: "foo" + params: + - "bar" + - "baz" + - "asdf" + + # with source + - input: ":coolguy foo bar baz asdf" + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - "asdf" + + # with trailing param + - input: "foo bar baz :asdf quux" + atoms: + verb: "foo" + params: + - "bar" + - "baz" + - "asdf quux" + + - input: "foo bar baz :" + atoms: + verb: "foo" + params: + - "bar" + - "baz" + - "" + + - input: "foo bar baz ::asdf" + atoms: + verb: "foo" + params: + - "bar" + - "baz" + - ":asdf" + + # with source and trailing param + - input: ":coolguy foo bar baz :asdf quux" + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - "asdf quux" + + - input: ":coolguy foo bar baz : asdf quux " + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - " asdf quux " + + - input: ":coolguy PRIVMSG bar :lol :) " + atoms: + source: "coolguy" + verb: "PRIVMSG" + params: + - "bar" + - "lol :) " + + - input: ":coolguy foo bar baz :" + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - "" + + - input: ":coolguy foo bar baz : " + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - " " + + # with tags + - input: "@a=b;c=32;k;rt=ql7 foo" + atoms: + verb: "foo" + tags: + "a": "b" + "c": "32" + "k": "" + "rt": "ql7" + + # with escaped tags + - input: "@a=b\\\\and\\nk;c=72\\s45;d=gh\\:764 foo" + atoms: + verb: "foo" + tags: + "a": "b\\and\nk" + "c": "72 45" + "d": "gh;764" + + # with tags and source + - input: "@c;h=;a=b :quux ab cd" + atoms: + tags: + "c": "" + "h": "" + "a": "b" + source: "quux" + verb: "ab" + params: + - "cd" + + # different forms of last param + - input: ":src JOIN #chan" + atoms: + source: "src" + verb: "JOIN" + params: + - "#chan" + + - input: ":src JOIN :#chan" + atoms: + source: "src" + verb: "JOIN" + params: + - "#chan" + + # with and without last param + - input: ":src AWAY" + atoms: + source: "src" + verb: "AWAY" + + - input: ":src AWAY " + atoms: + source: "src" + verb: "AWAY" + + # tab is not considered + - input: ":cool\tguy foo bar baz" + atoms: + source: "cool\tguy" + verb: "foo" + params: + - "bar" + - "baz" + + # with weird control codes in the source + - input: ":coolguy!ag@net\x035w\x03ork.admin PRIVMSG foo :bar baz" + atoms: + source: "coolguy!ag@net\x035w\x03ork.admin" + verb: "PRIVMSG" + params: + - "foo" + - "bar baz" + + - input: ":coolguy!~ag@n\x02et\x0305w\x0fork.admin PRIVMSG foo :bar baz" + atoms: + source: "coolguy!~ag@n\x02et\x0305w\x0fork.admin" + verb: "PRIVMSG" + params: + - "foo" + - "bar baz" + + - input: "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4= :irc.example.com COMMAND param1 param2 :param3 param3" + atoms: + tags: + tag1: "value1" + tag2: "" + vendor1/tag3: "value2" + vendor2/tag4: "" + source: "irc.example.com" + verb: "COMMAND" + params: + - "param1" + - "param2" + - "param3 param3" + + - input: ":irc.example.com COMMAND param1 param2 :param3 param3" + atoms: + source: "irc.example.com" + verb: "COMMAND" + params: + - "param1" + - "param2" + - "param3 param3" + + - input: "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4 COMMAND param1 param2 :param3 param3" + atoms: + tags: + tag1: "value1" + tag2: "" + vendor1/tag3: "value2" + vendor2/tag4: "" + verb: "COMMAND" + params: + - "param1" + - "param2" + - "param3 param3" + + - input: "COMMAND" + atoms: + verb: "COMMAND" + + # yaml encoding + slashes is fun + - input: "@foo=\\\\\\\\\\:\\\\s\\s\\r\\n COMMAND" + atoms: + tags: + foo: "\\\\;\\s \r\n" + verb: "COMMAND" + + # broken messages from unreal + - input: ":gravel.mozilla.org 432 #momo :Erroneous Nickname: Illegal characters" + atoms: + source: "gravel.mozilla.org" + verb: "432" + params: + - "#momo" + - "Erroneous Nickname: Illegal characters" + + - input: ":gravel.mozilla.org MODE #tckk +n " + atoms: + source: "gravel.mozilla.org" + verb: "MODE" + params: + - "#tckk" + - "+n" + + - input: ":services.esper.net MODE #foo-bar +o foobar " + atoms: + source: "services.esper.net" + verb: "MODE" + params: + - "#foo-bar" + - "+o" + - "foobar" + + # tag values should be parsed char-at-a-time to prevent wayward replacements. + - input: "@tag1=value\\\\ntest COMMAND" + atoms: + tags: + tag1: "value\\ntest" + verb: "COMMAND" + + # If a tag value has a slash followed by a character which doesn't need + # to be escaped, the slash should be dropped. + - input: "@tag1=value\\1 COMMAND" + atoms: + tags: + tag1: "value1" + verb: "COMMAND" + + # A slash at the end of a tag value should be dropped + - input: "@tag1=value1\\ COMMAND" + atoms: + tags: + tag1: "value1" + verb: "COMMAND" + + # Duplicate tags: Parsers SHOULD disregard all but the final occurence + - input: "@tag1=1;tag2=3;tag3=4;tag1=5 COMMAND" + atoms: + tags: + tag1: "5" + tag2: "3" + tag3: "4" + verb: "COMMAND" + + # vendored tags can have the same name as a non-vendored tag + - input: "@tag1=1;tag2=3;tag3=4;tag1=5;vendor/tag2=8 COMMAND" + atoms: + tags: + tag1: "5" + tag2: "3" + tag3: "4" + vendor/tag2: "8" + verb: "COMMAND" + + # Some parsers handle /MODE in a special way, make sure they do it right + - input: ":SomeOp MODE #channel :+i" + atoms: + source: "SomeOp" + verb: "MODE" + params: + - "#channel" + - "+i" + + - input: ":SomeOp MODE #channel +oo SomeUser :AnotherUser" + atoms: + source: "SomeOp" + verb: "MODE" + params: + - "#channel" + - "+oo" + - "SomeUser" + - "AnotherUser" diff --git a/IRCTokens/Tests/Format.cs b/IRCTokens/Tests/Format.cs new file mode 100644 index 0000000..8319069 --- /dev/null +++ b/IRCTokens/Tests/Format.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace IRCTokens.Tests +{ + [TestClass] + public class Format + { + [TestMethod] + public void TestTags() + { + var line = new Line("PRIVMSG", "#channel", "hello") + { + Tags = new Dictionary {{"id", "\\" + " " + ";" + "\r\n"}} + }.Format(); + + Assert.AreEqual("@id=\\\\\\s\\:\\r\\n PRIVMSG #channel hello", line); + } + + [TestMethod] + public void TestMissingTag() + { + var line = new Line("PRIVMSG", "#channel", "hello").Format(); + + Assert.AreEqual("PRIVMSG #channel hello", line); + } + + [TestMethod] + public void TestNullTag() + { + var line = new Line("PRIVMSG", "#channel", "hello") {Tags = new Dictionary {{"a", null}}} + .Format(); + + Assert.AreEqual("@a PRIVMSG #channel hello", line); + } + + [TestMethod] + public void TestEmptyTag() + { + var line = new Line("PRIVMSG", "#channel", "hello") {Tags = new Dictionary {{"a", ""}}} + .Format(); + + Assert.AreEqual("@a PRIVMSG #channel hello", line); + } + + [TestMethod] + public void TestSource() + { + var line = new Line("PRIVMSG", "#channel", "hello") {Source = "nick!user@host"}.Format(); + + Assert.AreEqual(":nick!user@host PRIVMSG #channel hello", line); + } + + [TestMethod] + public void TestCommandLowercase() + { + var line = new Line {Command = "privmsg"}.Format(); + Assert.AreEqual("privmsg", line); + } + + [TestMethod] + public void TestCommandUppercase() + { + var line = new Line {Command = "PRIVMSG"}.Format(); + Assert.AreEqual("PRIVMSG", line); + } + + [TestMethod] + public void TestTrailingSpace() + { + var line = new Line("PRIVMSG", "#channel", "hello world").Format(); + + Assert.AreEqual("PRIVMSG #channel :hello world", line); + } + + [TestMethod] + public void TestTrailingNoSpace() + { + var line = new Line("PRIVMSG", "#channel", "helloworld").Format(); + + Assert.AreEqual("PRIVMSG #channel helloworld", line); + } + + [TestMethod] + public void TestTrailingDoubleColon() + { + var line = new Line("PRIVMSG", "#channel", ":helloworld").Format(); + + Assert.AreEqual("PRIVMSG #channel ::helloworld", line); + } + + [TestMethod] + public void TestInvalidNonLastSpace() + { + Assert.ThrowsException(() => { new Line("USER", "user", "0 *", "real name").Format(); }); + } + + [TestMethod] + public void TestInvalidNonLastColon() + { + Assert.ThrowsException(() => { new Line("PRIVMSG", ":#channel", "hello").Format(); }); + } + } +} diff --git a/IRCTokens/Tests/Hostmask.cs b/IRCTokens/Tests/Hostmask.cs new file mode 100644 index 0000000..2446013 --- /dev/null +++ b/IRCTokens/Tests/Hostmask.cs @@ -0,0 +1,64 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace IRCTokens.Tests +{ + [TestClass] + public class Hostmask + { + [TestMethod] + public void TestHostmask() + { + var hostmask = new IRCTokens.Hostmask("nick!user@host"); + Assert.AreEqual("nick", hostmask.NickName); + Assert.AreEqual("user", hostmask.UserName); + Assert.AreEqual("host", hostmask.HostName); + } + + [TestMethod] + public void TestNoHostName() + { + var hostmask = new IRCTokens.Hostmask("nick!user"); + Assert.AreEqual("nick", hostmask.NickName); + Assert.AreEqual("user", hostmask.UserName); + Assert.IsNull(hostmask.HostName); + } + + [TestMethod] + public void TestNoUserName() + { + var hostmask = new IRCTokens.Hostmask("nick@host"); + Assert.AreEqual("nick", hostmask.NickName); + Assert.IsNull(hostmask.UserName); + Assert.AreEqual("host", hostmask.HostName); + } + + [TestMethod] + public void TestOnlyNickName() + { + var hostmask = new IRCTokens.Hostmask("nick"); + Assert.AreEqual("nick", hostmask.NickName); + Assert.IsNull(hostmask.UserName); + Assert.IsNull(hostmask.HostName); + } + + [TestMethod] + public void TestHostmaskFromLine() + { + var line = new Line(":nick!user@host PRIVMSG #channel hello"); + var hostmask = new IRCTokens.Hostmask("nick!user@host"); + Assert.AreEqual(hostmask.ToString(), line.Hostmask.ToString()); + Assert.AreEqual("nick", line.Hostmask.NickName); + Assert.AreEqual("user", line.Hostmask.UserName); + Assert.AreEqual("host", line.Hostmask.HostName); + } + + [TestMethod] + public void TestEmptyHostmaskFromLine() + { + var line = new Line("PRIVMSG #channel hello"); + Assert.IsNull(line.Hostmask.HostName); + Assert.IsNull(line.Hostmask.UserName); + Assert.IsNull(line.Hostmask.NickName); + } + } +} diff --git a/IRCTokens/Tests/Parser.cs b/IRCTokens/Tests/Parser.cs new file mode 100644 index 0000000..bd0a92d --- /dev/null +++ b/IRCTokens/Tests/Parser.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using IRCTokens.Tests.Data; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace IRCTokens.Tests +{ + [TestClass] + public class Parser + { + private static T LoadYaml(string path) + { + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + return deserializer.Deserialize(File.ReadAllText(path)); + } + + [TestMethod] + public void TestSplit() + { + foreach (var test in LoadYaml("Tests/Data/msg-split.yaml").Tests) + { + var tokens = new Line(test.Input); + var atoms = test.Atoms; + + Assert.AreEqual(atoms.Verb.ToUpper(CultureInfo.InvariantCulture), tokens.Command, + $"command failed on: '{test.Input}'"); + Assert.AreEqual(atoms.Source, tokens.Source, $"source failed on: '{test.Input}'"); + CollectionAssert.AreEqual(atoms.Tags, tokens.Tags, $"tags failed on: '{test.Input}'"); + CollectionAssert.AreEqual(atoms.Params ?? new List(), tokens.Params, + $"params failed on: '{test.Input}'"); + } + } + + [TestMethod] + public void TestJoin() + { + foreach (var test in LoadYaml("Tests/Data/msg-join.yaml").Tests) + { + var atoms = test.Atoms; + var line = new Line + { + Command = atoms.Verb, Params = atoms.Params, Source = atoms.Source, Tags = atoms.Tags + }.Format(); + + Assert.IsTrue(test.Matches.Contains(line), test.Description); + } + } + } +} diff --git a/IRCTokens/Tests/StatefulDecoder.cs b/IRCTokens/Tests/StatefulDecoder.cs new file mode 100644 index 0000000..d37310f --- /dev/null +++ b/IRCTokens/Tests/StatefulDecoder.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace IRCTokens.Tests +{ + [TestClass] + public class StatefulDecoder + { + private IRCTokens.StatefulDecoder _decoder; + + [TestInitialize] + public void TestInitialize() + { + _decoder = new IRCTokens.StatefulDecoder(); + } + + [TestMethod] + public void TestPartial() + { + var lines = _decoder.Push("PRIVMSG "); + Assert.AreEqual(0, lines.Count); + + lines = _decoder.Push("#channel hello\r\n"); + Assert.AreEqual(1, lines.Count); + + var line = new Line("PRIVMSG #channel hello"); + CollectionAssert.AreEqual(new List {line}, lines); + } + + [TestMethod] + public void TestMultiple() + { + var lines = _decoder.Push("PRIVMSG #channel1 hello\r\nPRIVMSG #channel2 hello\r\n"); + Assert.AreEqual(2, lines.Count); + + var line1 = new Line("PRIVMSG #channel1 hello"); + var line2 = new Line("PRIVMSG #channel2 hello"); + Assert.AreEqual(line1, lines[0]); + Assert.AreEqual(line2, lines[1]); + } + + [TestMethod] + public void TestEncoding() + { + var iso8859 = Encoding.GetEncoding("iso-8859-1"); + _decoder = new IRCTokens.StatefulDecoder {Encoding = iso8859}; + var bytes = iso8859.GetBytes("PRIVMSG #channel :hello Ç\r\n"); + var lines = _decoder.Push(bytes, bytes.Length); + var line = new Line("PRIVMSG #channel :hello Ç"); + Assert.IsTrue(line.Equals(lines[0])); + } + + [TestMethod] + public void TestEncodingFallback() + { + var latin1 = Encoding.GetEncoding("iso-8859-1"); + _decoder = new IRCTokens.StatefulDecoder {Encoding = null, Fallback = latin1}; + var bytes = latin1.GetBytes("PRIVMSG #channel hélló\r\n"); + var lines = _decoder.Push(bytes, bytes.Length); + Assert.AreEqual(1, lines.Count); + Assert.IsTrue(new Line("PRIVMSG #channel hélló").Equals(lines[0])); + } + + [TestMethod] + public void TestEmpty() + { + var lines = _decoder.Push(string.Empty); + Assert.AreEqual(0, lines.Count); + } + + [TestMethod] + public void TestBufferUnfinished() + { + _decoder.Push("PRIVMSG #channel hello"); + var lines = _decoder.Push(string.Empty); + Assert.AreEqual(0, lines.Count); + } + + [TestMethod] + public void TestClear() + { + _decoder.Push("PRIVMSG "); + _decoder.Clear(); + Assert.AreEqual(string.Empty, _decoder.Pending); + } + } +} diff --git a/IRCTokens/Tests/StatefulEncoder.cs b/IRCTokens/Tests/StatefulEncoder.cs new file mode 100644 index 0000000..5ced4d2 --- /dev/null +++ b/IRCTokens/Tests/StatefulEncoder.cs @@ -0,0 +1,84 @@ +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace IRCTokens.Tests +{ + [TestClass] + public class StatefulEncoder + { + private IRCTokens.StatefulEncoder _encoder; + + [TestInitialize] + public void TestInitialize() + { + _encoder = new IRCTokens.StatefulEncoder(); + } + + [TestMethod] + public void TestPush() + { + var line = new Line("PRIVMSG #channel hello"); + _encoder.Push(line); + Assert.AreEqual("PRIVMSG #channel hello\r\n", _encoder.Pending()); + } + + [TestMethod] + public void TestPopPartial() + { + var line = new Line("PRIVMSG #channel hello"); + _encoder.Push(line); + _encoder.Pop("PRIVMSG #channel hello".Length); + Assert.AreEqual("\r\n", _encoder.Pending()); + } + + [TestMethod] + public void TestPopReturned() + { + var line = new Line("PRIVMSG #channel hello"); + _encoder.Push(line); + _encoder.Push(line); + var lines = _encoder.Pop("PRIVMSG #channel hello\r\n".Length); + Assert.AreEqual(1, lines.Count); + Assert.AreEqual(line, lines[0]); + } + + [TestMethod] + public void TestPopNoneReturned() + { + var line = new Line("PRIVMSG #channel hello"); + _encoder.Push(line); + var lines = _encoder.Pop(1); + Assert.AreEqual(0, lines.Count); + } + + [TestMethod] + public void TestPopMultipleLines() + { + var line1 = new Line("PRIVMSG #channel1 hello"); + _encoder.Push(line1); + var line2 = new Line("PRIVMSG #channel2 hello"); + _encoder.Push(line2); + + var lines = _encoder.Pop(_encoder.Pending().Length); + Assert.AreEqual(2, lines.Count); + Assert.AreEqual(string.Empty, _encoder.Pending()); + } + + [TestMethod] + public void TestClear() + { + _encoder.Push(new Line("PRIVMSG #channel hello")); + _encoder.Clear(); + Assert.AreEqual(string.Empty, _encoder.Pending()); + } + + [TestMethod] + public void TestEncoding() + { + var iso8859 = Encoding.GetEncoding("iso-8859-1"); + _encoder = new IRCTokens.StatefulEncoder {Encoding = iso8859}; + _encoder.Push(new Line("PRIVMSG #channel :hello Ç")); + CollectionAssert.AreEqual(iso8859.GetBytes("PRIVMSG #channel :hello Ç\r\n"), _encoder.PendingBytes); + } + } +} diff --git a/IRCTokens/Tests/Tokenization.cs b/IRCTokens/Tests/Tokenization.cs new file mode 100644 index 0000000..03959de --- /dev/null +++ b/IRCTokens/Tests/Tokenization.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace IRCTokens.Tests +{ + [TestClass] + public class Tokenization + { + [TestMethod] + public void TestTagsMissing() + { + var line = new Line("PRIVMSG #channel"); + Assert.IsNull(line.Tags); + } + + [TestMethod] + public void TestTagsMissingValue() + { + var line = new Line("@id= PRIVMSG #channel"); + Assert.AreEqual(string.Empty, line.Tags["id"]); + } + + [TestMethod] + public void TestTagsMissingEqual() + { + var line = new Line("@id PRIVMSG #channel"); + Assert.AreEqual(string.Empty, line.Tags["id"]); + } + + [TestMethod] + public void TestTagsUnescape() + { + var line = new Line(@"@id=1\\\:\r\n\s2 PRIVMSG #channel"); + Assert.AreEqual("1\\;\r\n 2", line.Tags["id"]); + } + + [TestMethod] + public void TestTagsOverlap() + { + var line = new Line(@"@id=1\\\s\\s PRIVMSG #channel"); + Assert.AreEqual("1\\ \\s", line.Tags["id"]); + } + + [TestMethod] + public void TestTagsLoneEndSlash() + { + var line = new Line("@id=1\\ PRIVMSG #channel"); + Assert.AreEqual("1", line.Tags["id"]); + } + + [TestMethod] + public void TestSourceWithoutTags() + { + var line = new Line(":nick!user@host PRIVMSG #channel"); + Assert.AreEqual("nick!user@host", line.Source); + } + + [TestMethod] + public void TestSourceWithTags() + { + var line = new Line("@id=123 :nick!user@host PRIVMSG #channel"); + Assert.AreEqual("nick!user@host", line.Source); + } + + [TestMethod] + public void TestSourceMissingWithoutTags() + { + var line = new Line("PRIVMSG #channel"); + Assert.IsNull(line.Source); + } + + [TestMethod] + public void TestSourceMissingWithTags() + { + var line = new Line("@id=123 PRIVMSG #channel"); + Assert.IsNull(line.Source); + } + + [TestMethod] + public void TestCommand() + { + var line = new Line("privmsg #channel"); + Assert.AreEqual("PRIVMSG", line.Command); + } + + [TestMethod] + public void TestParamsTrailing() + { + var line = new Line("PRIVMSG #channel :hello world"); + CollectionAssert.AreEqual(new List {"#channel", "hello world"}, line.Params); + } + + [TestMethod] + public void TestParamsOnlyTrailing() + { + var line = new Line("PRIVMSG :hello world"); + CollectionAssert.AreEqual(new List {"hello world"}, line.Params); + } + + [TestMethod] + public void TestParamsMissing() + { + var line = new Line("PRIVMSG"); + Assert.AreEqual("PRIVMSG", line.Command); + CollectionAssert.AreEqual(new List(), line.Params); + } + + [TestMethod] + public void TestAllTokens() + { + var line = new Line("@id=123 :nick!user@host PRIVMSG #channel :hello world"); + CollectionAssert.AreEqual(new Dictionary {{"id", "123"}}, line.Tags); + Assert.AreEqual("nick!user@host", line.Source); + Assert.AreEqual("PRIVMSG", line.Command); + CollectionAssert.AreEqual(new List {"#channel", "hello world"}, line.Params); + } + } +} -- cgit 1.4.1