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