IRC parsing, tokenization, and state handling in C#
at ircrobots 234 lines 7.3 kB view raw
1using System; 2using System.Collections.Generic; 3using System.Globalization; 4using System.Linq; 5using System.Text; 6// ReSharper disable CommentTypo 7 8namespace IRCTokens 9{ 10 /// <summary> 11 /// Tools to represent, parse, and format IRC lines 12 /// </summary> 13 public class Line : IEquatable<Line> 14 { 15 private static readonly string[] TagUnescaped = {"\\", " ", ";", "\r", "\n"}; 16 17 private static readonly string[] TagEscaped = {@"\\", "\\s", "\\:", "\\r", "\\n"}; 18 19 private Hostmask _hostmask; 20 21 public Line() 22 { 23 } 24 25 public Line(string command, params string[] parameters) 26 { 27 Command = command.ToUpperInvariant(); 28 Params = parameters.ToList(); 29 } 30 31 /// <summary> 32 /// Build new <see cref="Line" /> object parsed from 33 /// <param name="line">a string</param> 34 /// . Analogous to irctokens.tokenise() 35 /// </summary> 36 /// <param name="line">irc line to parse</param> 37 public Line(string line) 38 { 39 if (string.IsNullOrWhiteSpace(line)) throw new ArgumentNullException(nameof(line)); 40 41 string[] split; 42 43 if (line.StartsWith('@')) 44 { 45 Tags = new Dictionary<string, string>(); 46 47 split = line.Split(" ", 2); 48 var messageTags = split[0]; 49 line = split[1]; 50 51 foreach (var part in messageTags[1..].Split(';')) 52 if (part.Contains('=', StringComparison.Ordinal)) 53 { 54 split = part.Split('=', 2); 55 Tags[split[0]] = UnescapeTag(split[1]); 56 } 57 else 58 { 59 Tags[part] = null; 60 } 61 } 62 63 string trailing; 64 if (line.Contains(" :", StringComparison.Ordinal)) 65 { 66 split = line.Split(" :", 2); 67 line = split[0]; 68 trailing = split[1]; 69 } 70 else 71 { 72 trailing = null; 73 } 74 75 Params = line.Contains(' ', StringComparison.Ordinal) 76 ? line.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList() 77 : new List<string> {line}; 78 79 if (Params[0].StartsWith(':')) 80 { 81 Source = Params[0][1..]; 82 Params.RemoveAt(0); 83 } 84 85 if (Params.Count > 0) 86 { 87 Command = Params[0].ToUpperInvariant(); 88 Params.RemoveAt(0); 89 } 90 91 if (trailing != null) Params.Add(trailing); 92 } 93 94 public Dictionary<string, string> Tags { get; set; } 95 public string Source { get; set; } 96 public string Command { get; set; } 97 public List<string> Params { get; set; } 98 99 public Hostmask Hostmask => 100 _hostmask ??= new Hostmask(Source); 101 102 public bool Equals(Line other) 103 { 104 if (other == null) return false; 105 106 return Format() == other.Format(); 107 } 108 109 /// <summary> 110 /// Unescape ircv3 tag 111 /// </summary> 112 /// <param name="val">escaped string</param> 113 /// <returns>unescaped string</returns> 114 private static string UnescapeTag(string val) 115 { 116 var unescaped = new StringBuilder(); 117 118 var graphemeIterator = StringInfo.GetTextElementEnumerator(val); 119 graphemeIterator.Reset(); 120 121 while (graphemeIterator.MoveNext()) 122 { 123 var current = graphemeIterator.GetTextElement(); 124 125 if (current == @"\") 126 try 127 { 128 graphemeIterator.MoveNext(); 129 var next = graphemeIterator.GetTextElement(); 130 var pair = current + next; 131 unescaped.Append(TagEscaped.Contains(pair) 132 ? TagUnescaped[Array.IndexOf(TagEscaped, pair)] 133 : next); 134 } 135 catch (InvalidOperationException) 136 { 137 // ignored 138 } 139 else 140 unescaped.Append(current); 141 } 142 143 return unescaped.ToString(); 144 } 145 146 /// <summary> 147 /// Escape strings for use in ircv3 tags 148 /// </summary> 149 /// <param name="val">string to escape</param> 150 /// <returns>escaped string</returns> 151 private static string EscapeTag(string val) 152 { 153 for (var i = 0; i < TagUnescaped.Length; ++i) 154 val = val?.Replace(TagUnescaped[i], TagEscaped[i], StringComparison.Ordinal); 155 156 return val; 157 } 158 159 public override string ToString() 160 { 161 var vars = new List<string>(); 162 163 if (Command != null) vars.Add($"command={Command}"); 164 165 if (Source != null) vars.Add($"source={Source}"); 166 167 if (Params != null && Params.Any()) vars.Add($"params=[{string.Join(",", Params)}]"); 168 169 if (Tags != null && Tags.Any()) 170 vars.Add($"tags=[{string.Join(";", Tags.Select(kvp => $"{kvp.Key}={kvp.Value}"))}]"); 171 172 return $"Line({string.Join(", ", vars)})"; 173 } 174 175 public override int GetHashCode() 176 { 177 return Format().GetHashCode(StringComparison.Ordinal); 178 } 179 180 public override bool Equals(object obj) 181 { 182 return Equals(obj as Line); 183 } 184 185 /// <summary> 186 /// Format a <see cref="Line" /> as a standards-compliant IRC line 187 /// </summary> 188 /// <returns>formatted irc line</returns> 189 public string Format() 190 { 191 var outs = new List<string>(); 192 193 if (Tags != null && Tags.Any()) 194 { 195 var tags = Tags.Keys 196 .OrderBy(k => k) 197 .Select(key => 198 string.IsNullOrWhiteSpace(Tags[key]) ? key : $"{key}={EscapeTag(Tags[key])}") 199 .ToList(); 200 201 outs.Add($"@{string.Join(";", tags)}"); 202 } 203 204 if (Source != null) outs.Add($":{Source}"); 205 206 outs.Add(Command); 207 208 if (Params != null && Params.Any()) 209 { 210 var last = Params[^1]; 211 var withoutLast = Params.SkipLast(1).ToList(); 212 213 foreach (var p in withoutLast) 214 { 215 if (p.Contains(' ', StringComparison.Ordinal)) 216 throw new ArgumentException("non-last parameters cannot have spaces", p); 217 218 if (p.StartsWith(':')) 219 throw new ArgumentException("non-last parameters cannot start with colon", p); 220 } 221 222 outs.AddRange(withoutLast); 223 224 if (string.IsNullOrWhiteSpace(last) || last.Contains(' ', StringComparison.Ordinal) || 225 last.StartsWith(':')) 226 last = $":{last}"; 227 228 outs.Add(last); 229 } 230 231 return string.Join(" ", outs); 232 } 233 } 234}