IRC parsing, tokenization, and state handling in C#
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}