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