IRC parsing, tokenization, and state handling in C#
1// ReSharper disable ReplaceWithFieldKeyword
2// ReSharper disable CommentTypo
3
4namespace IRCTokens;
5
6/// <summary>Tools to represent, parse, and format IRC lines</summary>
7public class Line : IEquatable<Line>
8{
9 private Hostmask _hostmask;
10
11 public Line()
12 {
13 }
14
15 public Line(string command, params string[] parameters)
16 {
17 Command = command.ToUpperInvariant();
18 Params = parameters.ToList();
19 }
20
21 /// <summary>Parse a <see cref="Line" /> from a string. Analogous to <c>irctokens.tokenise()</c>.</summary>
22 /// <param name="line">irc line to parse</param>
23 public Line(string line)
24 {
25 if (string.IsNullOrWhiteSpace(line))
26 {
27 throw new ArgumentNullException(nameof(line));
28 }
29
30 string[] split;
31
32 if (line.StartsWith("@"))
33 {
34 Tags = [];
35
36 split = line.Split([' '], 2);
37 var messageTags = split[0];
38 line = split[1];
39
40 foreach (var part in messageTags.Substring(1).Split(';'))
41 {
42 if (part.Contains('='))
43 {
44 split = part.Split(['='], 2);
45 Tags[split[0]] = TagEscape.Unescape(split[1]);
46 }
47 else
48 {
49 Tags[part] = null;
50 }
51 }
52 }
53
54 string trailing;
55 if (line.Contains(" :"))
56 {
57 split = line.Split([" :"], 2, StringSplitOptions.None);
58 line = split[0];
59 trailing = split[1];
60 }
61 else
62 {
63 trailing = null;
64 }
65
66 Params = line.Contains(' ')
67 ? [.. line.Split([" "], StringSplitOptions.RemoveEmptyEntries)]
68 : [line];
69
70 if (Params[0].StartsWith(":"))
71 {
72 Source = Params[0].Substring(1);
73 Params.RemoveAt(0);
74 }
75
76 if (Params.Count > 0)
77 {
78 Command = Params[0].ToUpperInvariant();
79 Params.RemoveAt(0);
80 }
81
82 if (trailing != null)
83 {
84 Params.Add(trailing);
85 }
86 }
87
88 public Dictionary<string, string> Tags { get; set; }
89 public string Source { get; set; }
90 public string Command { get; set; }
91 public List<string> Params { get; set; }
92
93 public Hostmask Hostmask => _hostmask ??= new(Source);
94
95 public bool Equals(Line other)
96 {
97 if (other == null)
98 {
99 return false;
100 }
101
102 return Format() == other.Format();
103 }
104
105 public override string ToString()
106 {
107 List<string> vars = [];
108
109 if (Command != null)
110 {
111 vars.Add($"command={Command}");
112 }
113
114 if (Source != null)
115 {
116 vars.Add($"source={Source}");
117 }
118
119 if (Params != null && Params.Any())
120 {
121 vars.Add($"params=[{string.Join(",", Params)}]");
122 }
123
124 if (Tags != null && Tags.Any())
125 {
126 vars.Add($"tags=[{string.Join(";", Tags.Select(kvp => $"{kvp.Key}={kvp.Value}"))}]");
127 }
128
129 return $"Line({string.Join(", ", vars)})";
130 }
131
132 public override int GetHashCode() => Format().GetHashCode();
133
134 public override bool Equals(object obj) => Equals(obj as Line);
135
136 /// <summary>Format a <see cref="Line" /> as a standards-compliant IRC line</summary>
137 /// <returns>formatted irc line</returns>
138 public string Format()
139 {
140 List<string> outs = [];
141
142 if (Tags != null && Tags.Any())
143 {
144 var tags = Tags.Keys
145 .OrderBy(k => k)
146 .Select(key => string.IsNullOrWhiteSpace(Tags[key]) ? key : $"{key}={TagEscape.Escape(Tags[key])}")
147 .ToList();
148
149 outs.Add($"@{string.Join(";", tags)}");
150 }
151
152 if (Source != null)
153 {
154 outs.Add($":{Source}");
155 }
156
157 outs.Add(Command);
158
159 if (Params != null && Params.Any())
160 {
161 var last = Params.Last();
162 var withoutLast = Params.SkipLast(1).ToList();
163
164 foreach (var p in withoutLast)
165 {
166 if (p.Contains(' '))
167 {
168 throw new ArgumentException("non-last parameters cannot have spaces", p);
169 }
170
171 if (p.StartsWith(":"))
172 {
173 throw new ArgumentException("non-last parameters cannot start with colon", p);
174 }
175 }
176
177 outs.AddRange(withoutLast);
178
179 if (string.IsNullOrWhiteSpace(last) || last.Contains(' ') || last.StartsWith(":"))
180 {
181 last = $":{last}";
182 }
183
184 outs.Add(last);
185 }
186
187 return string.Join(" ", outs);
188 }
189}