Комбинаторы парсеров
Парсинг — это процесс преобразования менее структурированного входа, например текста, в более структурированные данные, такие как объекты.
Многие знают о регулярных выражениях для парсинга, а некоторые слышали о tree-sitter — мощной парсерской библиотеке, которая питает подсветку синтаксиса в редакторах кода.
Но регулярные выражения становятся беспорядочными на сложных задачах, а tree-sitter часто избыточен — для этой золотой середины идеальны комбинаторы парсеров. О них знают немногие, поэтому позвольте показать!
Частичный парсер
Наименьшая единица парсинга, которую мы рассмотрим, — это частичный парсер. Он решает, где разрезать входную строку, а затем возвращает кортеж, состоящий из какого-угодно значения и остатка строки.
Это может быть любая функция, лишь бы она возвращала такой кортеж! Она даже может использовать внутри регулярные выражения.
▸Определение кода·pInteger
const pInteger: Parser<number> = (input: string) => {
const match = input.match(/^\d+/);
if (match == null) throw new Error();
const digits = match[0];
return [Number(digits), input.substring(digits.length)];
};Цепочка
Один парсер сам по себе не многое сделает, но применяя несколько парсеров подряд, можно потребить весь вход шаг за шагом.
▸Определение кода·pHello
const pHello: Parser<string> = (input: string) => {
if (!input.startsWith("Hello")) throw new Error();
return ["Hello", input.substring(5)];
};▸Определение кода·pIgnoreWhitespace
const pIgnoreWhitespace: Parser<null> = (input: string) => {
const match = input.match(/^\s+/);
if (match == null) throw new Error();
const spaces = match[0];
return [null, input.substring(spaces.length)];
};▸Определение кода·pInteger
const pInteger: Parser<number> = (input: string) => {
const match = input.match(/^\d+/);
if (match == null) throw new Error();
const digits = match[0];
return [Number(digits), input.substring(digits.length)];
};const [token1, input2] = pHello("Hello 100");
const [token2, input3] = pIgnoreWhitespace(input2);
const [token3, input4] = pInteger(input3);
console.log(token1, token2, token3); // "Hello", null, 100
console.log(input4); // empty!
Цепочка
Или, и здесь раскрывается настоящая мощь частичных парсеров: обернём цепочку в новый парсер.
▸Определение кода·pHello100
const pHello100: Parser<[string, null, number]> = (input: string) => {
const [hello, rest1] = pHello(input);
const [space, rest2] = pIgnoreWhitespace(rest1);
const [number, rest3] = pInteger(rest2);
return [[hello, space, number], rest3];
};pSeq
Или обобщим и предоставим функцию, которая делает цепочку! "Подать остаток строки в следующий парсер" — это очень универсальная операция, которая не зависит от того, что делает какой-то конкретный парсер.
Правильно выражать типы непросто, но TypeScript вполне способен выразить эти сложные вариативные типы.
▸Определение кода·pSeq
type Parsers<T extends unknown[]> = { [K in keyof T]: Parser<T[K]> };
const pSeq = <T extends unknown[]>(...parsers: Parsers<T>): Parser<T> =>
(input) => {
const results = [] as unknown as T;
let rest = input;
for (const p of parsers) {
const [val, next] = (p as Parser<unknown>)(rest);
results.push(val);
rest = next;
}
return [results, rest];
};Комбинаторы
Так как pSeq объединяет много парсеров в новый парсер, мы его называем комбинатором парсеров.
Внимательно посмотрите на функциональное безумие, которое здесь происходит: pSeq — это функция, которая принимает парсеры (которые тоже функции) и возвращает ещё один парсер (тоже функцию).
И только потом мы начинаем парсинг, вызывая полученную функцию с данными.
Это похоже на то, что мы строим вложенную конструкцию из функций, создавая сложную программу. Неплохо!
Кстати
Если разбиение строки на много токенов кажется вам как-то связано с .reduce, то вы чуете правильное направление. На самом деле, то, что мы здесь делаем, — это двойственная операция, которая в функциональных кругах называется unfold (разворот). reduce называется fold (сворот).
Сворот использует промежуточное состояние — аккумулятор — чтобы объединить список вещей в одно, а разворот использует промежуточное состояние, чтобы разбить одно на много.
Математика, ура!
Кортежи неудобны
Если цель парсинга — получить из сырых строк структурированные, часто удобочитаемые данные, то кортеж вроде ["Hello", null, 100] едва ли может быть конечной целью.
Конечно, можно отобразить результат, чтобы превратить его в более удобный вид.
const pHello100 = pSeq(...);
const [result, rest] = pHello100("Hello 100");
console.log(result); // ["Hello", null, 100]
const parsed = {
greeting: result[0],
who: result[2],
};
pMap
Но это не в духе комбинаторов парсеров! Как и раньше, можно обернуть преобразование в новый комбинатор парсеров.
pMap принимает парсер и функцию; функция будет применена к конечному результату после парсинга.
▸Определение кода·pMap
const pMap = <T, R>(parser: Parser<T>, fn: (value: T) => R): Parser<R> =>
(input: string) => {
const [value, rest] = parser(input);
return [fn(value), rest];
};const pGreeting = pMap(
pHello100,
([greeting, _, who]) => ({ greeting, who }),
);
const [parsed, rest] = pGreeting("Hello 100");
console.log(parsed); // { greeting: "Hello", who: 100 }
pRepeat
Перед тем как перейти к практическому примеру, покажу третий фундаментальный комбинатор парсеров: pRepeat — он принимает парсер и применяет его снова и снова, пока не сможет.
Это * из регулярных выражений, только как комбинатор!
▸Определение кода·pRepeat
const pRepeat = <T>(parser: Parser<T>): Parser<T[]> =>
(input: string) => {
const values: T[] = [];
let rest = input;
while (true) {
try {
const [value, next] = parser(rest);
values.push(value);
rest = next;
} catch {
return [values, rest];
}
}
};▸Определение кода·pNumber
const pNumber: Parser<number> = (input: string) => {
const match = input.match(/^\d+/);
if (match == null) throw new Error();
const digits = match[0];
return [Number(digits), input.substring(digits.length)];
};▸Определение кода·pSemicolon
const pSemicolon: Parser<string> = (input: string) => {
if (!input.startsWith(";")) throw new Error();
return [";", input.substring(1)];
};const pNumberCsv = pRepeat(pSeq(pNumber, pSemicolon));
pNumberCsv("10;20;30;");
// [[[10, ";"], [20, ";"], [30, ";"]], ""]
Построим парсер!
Теперь у нас есть инструменты, чтобы начать парсить настоящие, реальные данные!
Выше — пример вывода, когда я пингую Cloudflare. Там множество полезной информации, поэтому давайте её спарсим в структурированные данные.
type PingResult = {
header: PingHeader;
packets: IcmpPacket[];
statistics: PingStatistics;
};
type IcmpPacket = {
byteCount: number; from: string;
seq: number; ttl: number; time: number;
};
type PingHeader = {
targetIp: string; // 1.1.1.1
resolvedIp: string; // 1.1.1.1 в скобках
icmpPayloadSize: number; // 56
totalPacketSize: number; // 84
};
type PingStatistics = {
packetsTransmitted: number; packetsReceived: number;
packetLoss: number; time: number;
rtt: { min: number; avg: number; max: number; mdev: number };
};
Кусочки и кое-что
В этом выводе есть повторяющиеся элементы вроде десятичных чисел или IP-адресов. Давайте их сначала подготовим.
▸Определение кода·pString
// Точный текст, вроде "PING"
const pString = (text: string): Parser<string> => (input: string) => {
if (!input.startsWith(text)) throw new Error();
return [text, input.substring(text.length)];
};▸Определение кода·pInteger
// Целые числа, вроде 64
const pInteger: Parser<number> = (input: string) => {
const match = input.match(/^\d+/);
if (match == null) throw new Error();
const digits = match[0];
return [Number(digits), input.substring(digits.length)];
};▸Определение кода·pDecimal
// Числа с опциональной дробной частью, вроде 68.7
const pDecimal: Parser<number> = (input: string) => {
const match = input.match(/^\d+(\.\d+)?/);
if (match == null) throw new Error();
const digits = match[0];
return [Number(digits), input.substring(digits.length)];
};▸Определение кода·pIpV4Address
// Четыре байта и три точки, вроде 1.1.1.1
const pByte = pMap(pInteger, (n) => {
if (n > 255) throw new Error();
return n;
});
const pDot = pString(".");
const pIpV4Address = pMap(
pSeq(pByte, pDot, pByte, pDot, pByte, pDot, pByte),
([a, _1, b, _2, c, _3, d]) => `${a}.${b}.${c}.${d}`,
);▸Определение кода·pWhitespace
// Минимум одно пространство
const pWhitespace: Parser<null> = (input: string) => {
const match = input.match(/^\s+/);
if (match == null) throw new Error();
return [null, input.substring(match[0].length)];
};Заголовок
Первую строку должно быть легко парсить, это просто pSeq парсеров и потом pMap, чтобы придать форму PingHeader.
▸Определение кода·pPingHeaderParts
const pPingHeaderParts = pSeq(
pString("PING"), pWhitespace, pIpV4Address, pWhitespace,
pString("("), pIpV4Address, pString(")"), pWhitespace,
pInteger, pString("("), pInteger, pString(")"),
pString(" bytes of data."),
);▸Определение кода·pPingHeader
const pPingHeader = pMap(
pPingHeaderParts,
([a, b, c, d, e, f, g, h, i, j, k, l, m]) => ({
targetIp: c,
resolvedIp: f,
icmpPayloadSize: i,
totalPacketSize: k,
}),
);Пакет
Пакеты — это тоже pSeq парсеров, за которым идёт pMap. Это парсинг точно простой!
▸Определение кода·pIcmpPacketParts
const pIcmpPacketParts = pSeq(
pInteger, pString(" bytes from "), pIpV4Address,
pString(": icmp_seq="), pInteger,
pString(" ttl="), pInteger,
pString(" time="), pDecimal, pString(" ms"),
);▸Определение кода·pIcmpPacket
const pIcmpPacket = pMap(
pIcmpPacketParts,
([a, b, c, d, e, f, g, h, i, j]) => ({
byteCount: a, from: c, seq: e, ttl: g, time: i,
}),
);