an experimental irc client

format: refactor message formatting. add tests

Signed-off-by: Tim Culverhouse <tim@timculverhouse.com>

+410 -260
+2 -260
src/app.zig
··· 6 6 const ziglua = @import("ziglua"); 7 7 const Scrollbar = @import("Scrollbar.zig"); 8 8 const main = @import("main.zig"); 9 + const format = @import("format.zig"); 9 10 10 11 const irc = comlink.irc; 11 12 const lua = comlink.lua; ··· 1287 1288 if (y_off == 0) break; 1288 1289 1289 1290 const user = try client.getOrCreateUser(sender); 1290 - try formatMessage(&segments, user, message); 1291 + try format.message(&segments, user, message); 1291 1292 1292 1293 // Get the line count for this message 1293 1294 const content_height = lineCountForWindow(message_offset_win, segments.items); ··· 1868 1869 return; 1869 1870 }, 1870 1871 } 1871 - } 1872 - } 1873 - 1874 - /// generate vaxis.Segments for the message content 1875 - fn formatMessage(segments: *std.ArrayList(vaxis.Segment), user: *const irc.User, msg: irc.Message) !void { 1876 - const ColorState = enum { 1877 - ground, 1878 - fg, 1879 - bg, 1880 - }; 1881 - const LinkState = enum { 1882 - h, 1883 - t1, 1884 - t2, 1885 - p, 1886 - s, 1887 - colon, 1888 - slash, 1889 - consume, 1890 - }; 1891 - 1892 - var iter = msg.paramIterator(); 1893 - _ = iter.next() orelse return error.InvalidMessage; 1894 - const content = iter.next() orelse return error.InvalidMessage; 1895 - var start: usize = 0; 1896 - var i: usize = 0; 1897 - var style: vaxis.Style = .{}; 1898 - while (i < content.len) : (i += 1) { 1899 - const b = content[i]; 1900 - switch (b) { 1901 - 0x01 => { 1902 - if (i == 0 and 1903 - content.len > 7 and 1904 - mem.startsWith(u8, content[1..], "ACTION")) 1905 - { 1906 - style.italic = true; 1907 - const user_style: vaxis.Style = .{ 1908 - .fg = user.color, 1909 - .italic = true, 1910 - }; 1911 - try segments.append(.{ 1912 - .text = user.nick, 1913 - .style = user_style, 1914 - }); 1915 - i += 6; // "ACTION" 1916 - } else { 1917 - try segments.append(.{ 1918 - .text = content[start..i], 1919 - .style = style, 1920 - }); 1921 - } 1922 - start = i + 1; 1923 - }, 1924 - 0x02 => { 1925 - try segments.append(.{ 1926 - .text = content[start..i], 1927 - .style = style, 1928 - }); 1929 - style.bold = !style.bold; 1930 - start = i + 1; 1931 - }, 1932 - 0x03 => { 1933 - try segments.append(.{ 1934 - .text = content[start..i], 1935 - .style = style, 1936 - }); 1937 - i += 1; 1938 - var state: ColorState = .ground; 1939 - var fg_idx: ?u8 = null; 1940 - var bg_idx: ?u8 = null; 1941 - while (i < content.len) : (i += 1) { 1942 - const d = content[i]; 1943 - switch (state) { 1944 - .ground => { 1945 - switch (d) { 1946 - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => { 1947 - state = .fg; 1948 - fg_idx = d - '0'; 1949 - }, 1950 - else => { 1951 - style.fg = .default; 1952 - style.bg = .default; 1953 - start = i; 1954 - break; 1955 - }, 1956 - } 1957 - }, 1958 - .fg => { 1959 - switch (d) { 1960 - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => { 1961 - const fg = fg_idx orelse 0; 1962 - if (fg > 9) { 1963 - style.fg = irc.toVaxisColor(fg); 1964 - start = i; 1965 - break; 1966 - } else { 1967 - fg_idx = fg * 10 + (d - '0'); 1968 - } 1969 - }, 1970 - else => { 1971 - if (fg_idx) |fg| { 1972 - style.fg = irc.toVaxisColor(fg); 1973 - start = i; 1974 - } 1975 - if (d == ',') state = .bg else break; 1976 - }, 1977 - } 1978 - }, 1979 - .bg => { 1980 - switch (d) { 1981 - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => { 1982 - const bg = bg_idx orelse 0; 1983 - if (i - start == 2) { 1984 - style.bg = irc.toVaxisColor(bg); 1985 - start = i; 1986 - break; 1987 - } else { 1988 - bg_idx = bg * 10 + (d - '0'); 1989 - } 1990 - }, 1991 - else => { 1992 - if (bg_idx) |bg| { 1993 - style.bg = irc.toVaxisColor(bg); 1994 - start = i; 1995 - } 1996 - break; 1997 - }, 1998 - } 1999 - }, 2000 - } 2001 - } 2002 - }, 2003 - 0x0F => { 2004 - try segments.append(.{ 2005 - .text = content[start..i], 2006 - .style = style, 2007 - }); 2008 - style = .{}; 2009 - start = i + 1; 2010 - }, 2011 - 0x16 => { 2012 - try segments.append(.{ 2013 - .text = content[start..i], 2014 - .style = style, 2015 - }); 2016 - style.reverse = !style.reverse; 2017 - start = i + 1; 2018 - }, 2019 - 0x1D => { 2020 - try segments.append(.{ 2021 - .text = content[start..i], 2022 - .style = style, 2023 - }); 2024 - style.italic = !style.italic; 2025 - start = i + 1; 2026 - }, 2027 - 0x1E => { 2028 - try segments.append(.{ 2029 - .text = content[start..i], 2030 - .style = style, 2031 - }); 2032 - style.strikethrough = !style.strikethrough; 2033 - start = i + 1; 2034 - }, 2035 - 0x1F => { 2036 - try segments.append(.{ 2037 - .text = content[start..i], 2038 - .style = style, 2039 - }); 2040 - 2041 - style.ul_style = if (style.ul_style == .off) .single else .off; 2042 - start = i + 1; 2043 - }, 2044 - else => { 2045 - if (b == 'h') { 2046 - var state: LinkState = .h; 2047 - const h_start = i; 2048 - // consume until a space or EOF 2049 - i += 1; 2050 - while (i < content.len) : (i += 1) { 2051 - const b1 = content[i]; 2052 - switch (state) { 2053 - .h => { 2054 - if (b1 == 't') state = .t1 else break; 2055 - }, 2056 - .t1 => { 2057 - if (b1 == 't') state = .t2 else break; 2058 - }, 2059 - .t2 => { 2060 - if (b1 == 'p') state = .p else break; 2061 - }, 2062 - .p => { 2063 - if (b1 == 's') 2064 - state = .s 2065 - else if (b1 == ':') 2066 - state = .colon 2067 - else 2068 - break; 2069 - }, 2070 - .s => { 2071 - if (b1 == ':') state = .colon else break; 2072 - }, 2073 - .colon => { 2074 - if (b1 == '/') state = .slash else break; 2075 - }, 2076 - .slash => { 2077 - if (b1 == '/') { 2078 - state = .consume; 2079 - try segments.append(.{ 2080 - .text = content[start..h_start], 2081 - .style = style, 2082 - }); 2083 - start = h_start; 2084 - } else break; 2085 - }, 2086 - .consume => { 2087 - switch (b1) { 2088 - 0x00...0x20, 0x7F => { 2089 - try segments.append(.{ 2090 - .text = content[h_start..i], 2091 - .style = .{ 2092 - .fg = .{ .index = 4 }, 2093 - }, 2094 - .link = .{ 2095 - .uri = content[h_start..i], 2096 - }, 2097 - }); 2098 - start = i; 2099 - // backup one 2100 - i -= 1; 2101 - break; 2102 - }, 2103 - else => { 2104 - if (i == content.len) { 2105 - try segments.append(.{ 2106 - .text = content[h_start..], 2107 - .style = .{ 2108 - .fg = .{ .index = 4 }, 2109 - }, 2110 - .link = .{ 2111 - .uri = content[h_start..], 2112 - }, 2113 - }); 2114 - return; 2115 - } 2116 - }, 2117 - } 2118 - }, 2119 - } 2120 - } 2121 - } 2122 - }, 2123 - } 2124 - } 2125 - if (start < i and start < content.len) { 2126 - try segments.append(.{ 2127 - .text = content[start..], 2128 - .style = style, 2129 - }); 2130 1872 } 2131 1873 } 2132 1874
+407
src/format.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("vaxis"); 3 + const irc = @import("irc.zig"); 4 + 5 + const mem = std.mem; 6 + 7 + const ColorState = enum { 8 + ground, 9 + fg, 10 + bg, 11 + }; 12 + 13 + const LinkState = enum { 14 + h, 15 + t1, 16 + t2, 17 + p, 18 + s, 19 + colon, 20 + slash, 21 + consume, 22 + }; 23 + 24 + /// generate vaxis.Segments for the message content 25 + pub fn message(segments: *std.ArrayList(vaxis.Segment), user: *const irc.User, msg: irc.Message) !void { 26 + var iter = msg.paramIterator(); 27 + // skip the first param, this is the receiver of the message 28 + _ = iter.next() orelse return error.InvalidMessage; 29 + const content = iter.next() orelse return error.InvalidMessage; 30 + 31 + var start: usize = 0; 32 + var i: usize = 0; 33 + var style: vaxis.Style = .{}; 34 + while (i < content.len) : (i += 1) { 35 + const b = content[i]; 36 + switch (b) { 37 + 0x01 => { 38 + if (i == 0 and 39 + content.len > 7 and 40 + mem.startsWith(u8, content[1..], "ACTION")) 41 + { 42 + style.italic = true; 43 + const user_style: vaxis.Style = .{ 44 + .fg = user.color, 45 + .italic = true, 46 + }; 47 + try segments.append(.{ 48 + .text = user.nick, 49 + .style = user_style, 50 + }); 51 + i += 6; // "ACTION" 52 + } else { 53 + try segments.append(.{ 54 + .text = content[start..i], 55 + .style = style, 56 + }); 57 + } 58 + start = i + 1; 59 + }, 60 + 0x02 => { 61 + if (i > start) { 62 + try segments.append(.{ 63 + .text = content[start..i], 64 + .style = style, 65 + }); 66 + } 67 + style.bold = !style.bold; 68 + start = i + 1; 69 + }, 70 + 0x03 => { 71 + if (i > start) { 72 + try segments.append(.{ 73 + .text = content[start..i], 74 + .style = style, 75 + }); 76 + } 77 + i += 1; 78 + var state: ColorState = .ground; 79 + var fg_idx: ?u8 = null; 80 + var bg_idx: ?u8 = null; 81 + while (i < content.len) : (i += 1) { 82 + const d = content[i]; 83 + switch (state) { 84 + .ground => { 85 + switch (d) { 86 + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => { 87 + state = .fg; 88 + fg_idx = d - '0'; 89 + }, 90 + else => { 91 + style.fg = .default; 92 + style.bg = .default; 93 + start = i; 94 + break; 95 + }, 96 + } 97 + }, 98 + .fg => { 99 + switch (d) { 100 + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => { 101 + const fg = fg_idx orelse 0; 102 + if (fg > 9) { 103 + style.fg = irc.toVaxisColor(fg); 104 + start = i; 105 + break; 106 + } else { 107 + fg_idx = fg * 10 + (d - '0'); 108 + } 109 + }, 110 + else => { 111 + if (fg_idx) |fg| { 112 + style.fg = irc.toVaxisColor(fg); 113 + start = i; 114 + } 115 + if (d == ',') state = .bg else break; 116 + }, 117 + } 118 + }, 119 + .bg => { 120 + switch (d) { 121 + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => { 122 + const bg = bg_idx orelse 0; 123 + if (i - start == 2) { 124 + style.bg = irc.toVaxisColor(bg); 125 + start = i; 126 + break; 127 + } else { 128 + bg_idx = bg * 10 + (d - '0'); 129 + } 130 + }, 131 + else => { 132 + if (bg_idx) |bg| { 133 + style.bg = irc.toVaxisColor(bg); 134 + start = i; 135 + } 136 + break; 137 + }, 138 + } 139 + }, 140 + } 141 + } 142 + }, 143 + 0x0F => { 144 + if (i > start) { 145 + try segments.append(.{ 146 + .text = content[start..i], 147 + .style = style, 148 + }); 149 + } 150 + style = .{}; 151 + start = i + 1; 152 + }, 153 + 0x16 => { 154 + if (i > start) { 155 + try segments.append(.{ 156 + .text = content[start..i], 157 + .style = style, 158 + }); 159 + } 160 + style.reverse = !style.reverse; 161 + start = i + 1; 162 + }, 163 + 0x1D => { 164 + if (i > start) { 165 + try segments.append(.{ 166 + .text = content[start..i], 167 + .style = style, 168 + }); 169 + } 170 + style.italic = !style.italic; 171 + start = i + 1; 172 + }, 173 + 0x1E => { 174 + if (i > start) { 175 + try segments.append(.{ 176 + .text = content[start..i], 177 + .style = style, 178 + }); 179 + } 180 + style.strikethrough = !style.strikethrough; 181 + start = i + 1; 182 + }, 183 + 0x1F => { 184 + if (i > start) { 185 + try segments.append(.{ 186 + .text = content[start..i], 187 + .style = style, 188 + }); 189 + } 190 + 191 + style.ul_style = if (style.ul_style == .off) .single else .off; 192 + start = i + 1; 193 + }, 194 + else => { 195 + if (b == 'h') { 196 + var state: LinkState = .h; 197 + const h_start = i; 198 + // consume until a space or EOF 199 + i += 1; 200 + while (i < content.len) : (i += 1) { 201 + const b1 = content[i]; 202 + switch (state) { 203 + .h => { 204 + if (b1 == 't') state = .t1 else break; 205 + }, 206 + .t1 => { 207 + if (b1 == 't') state = .t2 else break; 208 + }, 209 + .t2 => { 210 + if (b1 == 'p') state = .p else break; 211 + }, 212 + .p => { 213 + if (b1 == 's') 214 + state = .s 215 + else if (b1 == ':') 216 + state = .colon 217 + else 218 + break; 219 + }, 220 + .s => { 221 + if (b1 == ':') state = .colon else break; 222 + }, 223 + .colon => { 224 + if (b1 == '/') state = .slash else break; 225 + }, 226 + .slash => { 227 + if (b1 == '/') { 228 + state = .consume; 229 + if (h_start > start) { 230 + try segments.append(.{ 231 + .text = content[start..h_start], 232 + .style = style, 233 + }); 234 + } 235 + start = h_start; 236 + } else break; 237 + }, 238 + .consume => { 239 + switch (b1) { 240 + 0x00...0x20, 0x7F => { 241 + try segments.append(.{ 242 + .text = content[h_start..i], 243 + .style = .{ 244 + .fg = .{ .index = 4 }, 245 + }, 246 + .link = .{ 247 + .uri = content[h_start..i], 248 + }, 249 + }); 250 + start = i; 251 + // backup one 252 + i -= 1; 253 + break; 254 + }, 255 + else => { 256 + if (i == content.len - 1) { 257 + try segments.append(.{ 258 + .text = content[h_start..], 259 + .style = .{ 260 + .fg = .{ .index = 4 }, 261 + }, 262 + .link = .{ 263 + .uri = content[h_start..], 264 + }, 265 + }); 266 + return; 267 + } 268 + }, 269 + } 270 + }, 271 + } 272 + } 273 + } 274 + }, 275 + } 276 + } 277 + if (start < i and start < content.len) { 278 + try segments.append(.{ 279 + .text = content[start..], 280 + .style = style, 281 + }); 282 + } 283 + } 284 + 285 + test "format.zig: no format" { 286 + const user: irc.User = .{ .nick = "rockorager" }; 287 + const msg: irc.Message = .{ .bytes = "PRIVMSG #comlink :foo" }; 288 + 289 + var list = std.ArrayList(vaxis.Segment).init(std.testing.allocator); 290 + defer list.deinit(); 291 + try message(&list, &user, msg); 292 + try std.testing.expectEqual(1, list.items.len); 293 + const expected: vaxis.Segment = .{ .text = "foo" }; 294 + try std.testing.expectEqualDeep(expected, list.items[0]); 295 + } 296 + 297 + test "format.zig: bold" { 298 + const user: irc.User = .{ .nick = "rockorager" }; 299 + const msg: irc.Message = .{ .bytes = "PRIVMSG #comlink :\x02foo\x02" }; 300 + 301 + var list = std.ArrayList(vaxis.Segment).init(std.testing.allocator); 302 + defer list.deinit(); 303 + try message(&list, &user, msg); 304 + try std.testing.expectEqual(1, list.items.len); 305 + const expected: vaxis.Segment = .{ .text = "foo", .style = .{ .bold = true } }; 306 + try std.testing.expectEqualDeep(expected, list.items[0]); 307 + } 308 + 309 + test "format.zig: italic" { 310 + const user: irc.User = .{ .nick = "rockorager" }; 311 + const msg: irc.Message = .{ .bytes = "PRIVMSG #comlink :\x1dfoo\x1d" }; 312 + 313 + var list = std.ArrayList(vaxis.Segment).init(std.testing.allocator); 314 + defer list.deinit(); 315 + try message(&list, &user, msg); 316 + try std.testing.expectEqual(1, list.items.len); 317 + const expected: vaxis.Segment = .{ .text = "foo", .style = .{ .italic = true } }; 318 + try std.testing.expectEqualDeep(expected, list.items[0]); 319 + } 320 + 321 + test "format.zig: strikethrough, reverse, underline" { 322 + const user: irc.User = .{ .nick = "rockorager" }; 323 + const msg: irc.Message = .{ 324 + .bytes = "PRIVMSG #comlink :\x16foo\x16\x1Dbar\x1D\x1Ebaz\x1E\x1Ffoo\x1F", 325 + }; 326 + 327 + var list = std.ArrayList(vaxis.Segment).init(std.testing.allocator); 328 + defer list.deinit(); 329 + try message(&list, &user, msg); 330 + const expected: []const vaxis.Segment = &.{ 331 + .{ .text = "foo", .style = .{ .reverse = true } }, 332 + .{ .text = "bar", .style = .{ .italic = true } }, 333 + .{ .text = "baz", .style = .{ .strikethrough = true } }, 334 + .{ .text = "foo", .style = .{ .ul_style = .single } }, 335 + }; 336 + try std.testing.expectEqual(expected.len, list.items.len); 337 + for (expected, 0..) |seg, i| { 338 + try std.testing.expectEqualDeep(seg, list.items[i]); 339 + } 340 + } 341 + 342 + test "format.zig: format without closer" { 343 + const user: irc.User = .{ .nick = "rockorager" }; 344 + const msg: irc.Message = .{ 345 + .bytes = "PRIVMSG #comlink :\x16foo\x16\x1Dbar\x1D\x1Ebaz\x1E\x1Ffoo", 346 + }; 347 + 348 + var list = std.ArrayList(vaxis.Segment).init(std.testing.allocator); 349 + defer list.deinit(); 350 + try message(&list, &user, msg); 351 + const expected: []const vaxis.Segment = &.{ 352 + .{ .text = "foo", .style = .{ .reverse = true } }, 353 + .{ .text = "bar", .style = .{ .italic = true } }, 354 + .{ .text = "baz", .style = .{ .strikethrough = true } }, 355 + .{ .text = "foo", .style = .{ .ul_style = .single } }, 356 + }; 357 + try std.testing.expectEqual(expected.len, list.items.len); 358 + for (expected, 0..) |seg, i| { 359 + try std.testing.expectEqualDeep(seg, list.items[i]); 360 + } 361 + } 362 + 363 + test "format.zig: hyperlink" { 364 + const user: irc.User = .{ .nick = "rockorager" }; 365 + const msg: irc.Message = .{ 366 + .bytes = "PRIVMSG #comlink :https://example.org", 367 + }; 368 + 369 + var list = std.ArrayList(vaxis.Segment).init(std.testing.allocator); 370 + defer list.deinit(); 371 + try message(&list, &user, msg); 372 + const expected: []const vaxis.Segment = &.{ 373 + .{ 374 + .text = "https://example.org", 375 + .style = .{ .fg = .{ .index = 4 } }, 376 + .link = .{ .uri = "https://example.org" }, 377 + }, 378 + }; 379 + try std.testing.expectEqual(expected.len, list.items.len); 380 + for (expected, 0..) |seg, i| { 381 + try std.testing.expectEqualDeep(seg, list.items[i]); 382 + } 383 + } 384 + 385 + test "format.zig: more than hyperlink" { 386 + const user: irc.User = .{ .nick = "rockorager" }; 387 + const msg: irc.Message = .{ 388 + .bytes = "PRIVMSG #comlink :look https://example.org here", 389 + }; 390 + 391 + var list = std.ArrayList(vaxis.Segment).init(std.testing.allocator); 392 + defer list.deinit(); 393 + try message(&list, &user, msg); 394 + const expected: []const vaxis.Segment = &.{ 395 + .{ .text = "look " }, 396 + .{ 397 + .text = "https://example.org", 398 + .style = .{ .fg = .{ .index = 4 } }, 399 + .link = .{ .uri = "https://example.org" }, 400 + }, 401 + .{ .text = " here" }, 402 + }; 403 + try std.testing.expectEqual(expected.len, list.items.len); 404 + for (expected, 0..) |seg, i| { 405 + try std.testing.expectEqualDeep(seg, list.items[i]); 406 + } 407 + }
+1
src/main.zig
··· 112 112 } 113 113 114 114 test { 115 + _ = @import("format.zig"); 115 116 _ = @import("irc.zig"); 116 117 }