From 497296880a58932e03471398f3d2d3d1ac4052b4 Mon Sep 17 00:00:00 2001 From: Jerome Ludmann Date: Fri, 3 Apr 2026 19:20:07 -0300 Subject: [PATCH] perf(core/parsers): optimize core parser --- core/client.ts | 10 ++-- core/parsers.ts | 70 +++++++++++++++------------ core/parsers_test.ts | 113 ++++++++++++++++--------------------------- 3 files changed, 87 insertions(+), 106 deletions(-) diff --git a/core/client.ts b/core/client.ts index 82818d46..3c5c807d 100644 --- a/core/client.ts +++ b/core/client.ts @@ -1,7 +1,7 @@ import { type ClientError, type ErrorArgs, toClientError } from "./errors.ts"; import { EventEmitter, type EventEmitterOptions } from "./events.ts"; import { Hooks } from "./hooks.ts"; -import { escapeTagValue, Parser, type Raw } from "./parsers.ts"; +import { escapeTagValue, parseChunk, type Raw } from "./parsers.ts"; import { loadPlugins, type Plugin } from "./plugins.ts"; import { type AnyCommand, @@ -98,7 +98,7 @@ export class CoreClient< private decoder = new TextDecoder(); private encoder = new TextEncoder(); - private parser = new Parser(); + private chunk = ""; private buffer: Uint8Array; constructor( @@ -211,9 +211,11 @@ export class CoreClient< const chunks = await this.read(conn); if (chunks === null) break; - const messageGenerator = this.parser.parseMessages(chunks); + const input = this.chunk ? this.chunk + chunks : chunks; + const [messages, remainder] = parseChunk(input); + this.chunk = remainder; - for (const msg of messageGenerator) { + for (const msg of messages) { this.emit(`raw:${msg.command}`, msg); } } diff --git a/core/parsers.ts b/core/parsers.ts index 79e511bf..87e82571 100644 --- a/core/parsers.ts +++ b/core/parsers.ts @@ -51,15 +51,20 @@ export type Raw = Message & { /** Parses an IRC prefix string (e.g. `nick!user@host`) into a {@link Source}. */ export function parseSource(prefix: string): Source { - const source = {} as Source; - const [name, user, host] = prefix.split(/[@!]+/); - - source.name = name; - if (user !== undefined && host !== undefined) { - source.mask = { user, host }; + const bangIdx = prefix.indexOf("!"); + const atIdx = prefix.indexOf("@"); + + if (bangIdx !== -1 && atIdx > bangIdx) { + return { + name: prefix.slice(0, bangIdx), + mask: { + user: prefix.slice(bangIdx + 1, atIdx), + host: prefix.slice(atIdx + 1), + }, + }; } - return source; + return { name: prefix }; } const UNESCAPE_MAP: Record = { @@ -121,11 +126,15 @@ function parseMessage(raw: string): Raw { msg.tags = {}; while (start < end) { let pos = raw.indexOf(";", start); - if (pos === -1) pos = end; - const [key, rawValue] = raw.slice(start, pos).split("="); - msg.tags[key] = rawValue !== undefined - ? unescapeTagValue(rawValue) - : undefined; + if (pos === -1 || pos > end) pos = end; + const eqIdx = raw.indexOf("=", start); + if (eqIdx !== -1 && eqIdx < pos) { + msg.tags[raw.slice(start, eqIdx)] = unescapeTagValue( + raw.slice(eqIdx + 1, pos), + ); + } else { + msg.tags[raw.slice(start, pos)] = undefined; + } start = pos + 1; } } @@ -165,25 +174,24 @@ function parseMessage(raw: string): Raw { return msg; } -/** Stateful parser that handles incremental IRC message chunks split across TCP reads. */ -export class Parser { - private chunk = ""; +/** + * Parses raw IRC messages from `input` and returns parsed messages with any + * incomplete trailing chunk. + * + * `input` is a string of raw messages each ending with `\r\n`. If the last + * raw message does not end with `\r\n`, it is returned as the remainder to + * be prepended to the next call. + */ +export function parseChunk(input: string): [Raw[], string] { + const results: Raw[] = []; - /** - * Parses `chunks` of raw messages and provides a `Generator`. - * - * `chunks` is a string of raw messages each ending with `\r\n`. If the last - * raw message of the `batch` does not end with `\r\n`, it means the message - * is not complete and will be temporarily stored in the `Parser` instance to - * be processed on the next call. - */ - *parseMessages(chunks: string): Generator { - this.chunk += chunks; - const batch = this.chunk.split("\r\n"); - this.chunk = batch.pop()!; - - for (const raw of batch) { - yield parseMessage(raw); - } + let start = 0; + let end: number; + + while ((end = input.indexOf("\r\n", start)) !== -1) { + results.push(parseMessage(input.slice(start, end))); + start = end + 2; // skip \r\n } + + return [results, start < input.length ? input.slice(start) : ""]; } diff --git a/core/parsers_test.ts b/core/parsers_test.ts index 45f7da78..deab82bf 100644 --- a/core/parsers_test.ts +++ b/core/parsers_test.ts @@ -1,29 +1,23 @@ import { assertEquals } from "@std/assert"; import { describe } from "../testing/helpers.ts"; -import { escapeTagValue, Parser, unescapeTagValue } from "./parsers.ts"; +import { escapeTagValue, parseChunk, unescapeTagValue } from "./parsers.ts"; describe("core/parsers", (test) => { test("parse message without prefix", () => { - const parser = new Parser(); + const [msgs] = parseChunk("PING :QimVSbibZg\r\n"); - const msg = Array.from(parser.parseMessages("PING :QimVSbibZg\r\n")); - - assertEquals(msg, [{ + assertEquals(msgs, [{ command: "ping", params: ["QimVSbibZg"], }]); }); test("parse message with server prefix", () => { - const parser = new Parser(); - - const msg = Array.from( - parser.parseMessages( - ":serverhost NOTICE * :*** Looking up your hostname...\r\n", - ), + const [msgs] = parseChunk( + ":serverhost NOTICE * :*** Looking up your hostname...\r\n", ); - assertEquals(msg, [{ + assertEquals(msgs, [{ command: "notice", params: ["*", "*** Looking up your hostname..."], source: { name: "serverhost" }, @@ -31,13 +25,11 @@ describe("core/parsers", (test) => { }); test("parse message with user prefix", () => { - const parser = new Parser(); - - const msg = Array.from( - parser.parseMessages(":someone!user@host JOIN #channel\r\n"), + const [msgs] = parseChunk( + ":someone!user@host JOIN #channel\r\n", ); - assertEquals(msg, [{ + assertEquals(msgs, [{ command: "join", params: ["#channel"], source: { mask: { host: "host", user: "user" }, name: "someone" }, @@ -45,15 +37,11 @@ describe("core/parsers", (test) => { }); test("parse message with tags", () => { - const parser = new Parser(); - - const msg = Array.from( - parser.parseMessages( - "@aaa=bbb;ccc;example.com/ddd=eee :someone!user@host JOIN #channel\r\n", - ), + const [msgs] = parseChunk( + "@aaa=bbb;ccc;example.com/ddd=eee :someone!user@host JOIN #channel\r\n", ); - assertEquals(msg, [{ + assertEquals(msgs, [{ command: "join", params: ["#channel"], source: { mask: { host: "host", user: "user" }, name: "someone" }, @@ -62,13 +50,11 @@ describe("core/parsers", (test) => { }); test("parse message with tags but no source", () => { - const parser = new Parser(); - - const msg = Array.from( - parser.parseMessages("@time=2026-03-24T12:00:00Z PING :server\r\n"), + const [msgs] = parseChunk( + "@time=2026-03-24T12:00:00Z PING :server\r\n", ); - assertEquals(msg, [{ + assertEquals(msgs, [{ command: "ping", params: ["server"], tags: { "time": "2026-03-24T12:00:00Z" }, @@ -76,53 +62,39 @@ describe("core/parsers", (test) => { }); test("parse message with empty tag value", () => { - const parser = new Parser(); - - const msg = Array.from( - parser.parseMessages("@key= :nick!u@h PRIVMSG #chan :text\r\n"), + const [msgs] = parseChunk( + "@key= :nick!u@h PRIVMSG #chan :text\r\n", ); - assertEquals(msg[0].tags, { "key": "" }); + assertEquals(msgs[0].tags, { "key": "" }); }); test("parse message with multiple escaped tags", () => { - const parser = new Parser(); - - const msg = Array.from( - parser.parseMessages( - "@a=1\\s2;b=x\\:y;c :nick!u@h PRIVMSG #chan :text\r\n", - ), + const [msgs] = parseChunk( + "@a=1\\s2;b=x\\:y;c :nick!u@h PRIVMSG #chan :text\r\n", ); - assertEquals(msg[0].tags, { "a": "1 2", "b": "x;y", "c": undefined }); + assertEquals(msgs[0].tags, { "a": "1 2", "b": "x;y", "c": undefined }); }); test("parse tags split across chunks", () => { - const parser = new Parser(); - - const raw1 = Array.from( - parser.parseMessages("@time=2026-03-24T12:00:00Z :nick!u@h PRI"), + const [msgs1, chunk] = parseChunk( + "@time=2026-03-24T12:00:00Z :nick!u@h PRI", ); - assertEquals(raw1, []); + assertEquals(msgs1, []); - const raw2 = Array.from( - parser.parseMessages("VMSG #chan :hello\r\n"), - ); - assertEquals(raw2.length, 1); - assertEquals(raw2[0].tags, { "time": "2026-03-24T12:00:00Z" }); - assertEquals(raw2[0].params, ["#chan", "hello"]); + const [msgs2] = parseChunk(chunk + "VMSG #chan :hello\r\n"); + assertEquals(msgs2.length, 1); + assertEquals(msgs2[0].tags, { "time": "2026-03-24T12:00:00Z" }); + assertEquals(msgs2[0].params, ["#chan", "hello"]); }); test("parse message with escaped tag values", () => { - const parser = new Parser(); - - const msg = Array.from( - parser.parseMessages( - "@msg=hello\\sworld\\:test\\\\end :nick!u@h PRIVMSG #chan :text\r\n", - ), + const [msgs] = parseChunk( + "@msg=hello\\sworld\\:test\\\\end :nick!u@h PRIVMSG #chan :text\r\n", ); - assertEquals(msg[0].tags, { "msg": "hello world;test\\end" }); + assertEquals(msgs[0].tags, { "msg": "hello world;test\\end" }); }); test("unescape tag values", () => { @@ -150,16 +122,14 @@ describe("core/parsers", (test) => { }); test("parse chunks of raw messages", () => { - const parser = new Parser(); - - const raw1 = Array.from( - parser.parseMessages(":serverhost NOTICE Auth :*** Looking up"), + const [raw1, chunk1] = parseChunk( + ":serverhost NOTICE Auth :*** Looking up", ); assertEquals(raw1, []); - const raw2 = Array.from(parser.parseMessages( - " your hostname...\r\n:serverhost 001 nick :Wel", - )); + const [raw2, chunk2] = parseChunk( + chunk1 + " your hostname...\r\n:serverhost 001 nick :Wel", + ); assertEquals(raw2, [ { source: { name: "serverhost" }, @@ -168,9 +138,9 @@ describe("core/parsers", (test) => { }, ]); - const raw3 = Array.from(parser.parseMessages( - "come to the server\r\n:nick!user@host JOIN #channel\r\n", - )); + const [raw3, chunk3] = parseChunk( + chunk2 + "come to the server\r\n:nick!user@host JOIN #channel\r\n", + ); assertEquals(raw3, [ { source: { name: "serverhost" }, @@ -183,10 +153,11 @@ describe("core/parsers", (test) => { params: ["#channel"], }, ]); + assertEquals(chunk3, ""); - const raw4 = Array.from(parser.parseMessages( + const [raw4] = parseChunk( "PING serverhost\r\n:nick!user@host PRIVMSG #channel ::!@ ;\r\n", - )); + ); assertEquals(raw4, [ { command: "ping",