IRC parsing, tokenization, and state handling in C#
at ircrobotsv2 189 lines 4.8 kB view raw
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}