Generative Grammar Engine записывает правила в нотации Tracery: #name# отмечает плейсхолдер, остальное это литеральный текст. Чтобы исполнить правило, движок сначала делит строку вроде #greeting#, #name#! на плейсхолдеры и литералы. Первым напрашивается регулярное выражение, и оно выручает ровно до того момента, когда кому-то понадобится литеральная # в тексте.
Где регулярка ломается
Привычный шаблон это /#([^#]+)#/g: решётка, несколько не-решёток, закрывающая решётка. Про экранирование он ничего не знает. Как только вы разрешаете \# обозначать литеральную решётку, регулярка перестаёт различать эти два случая:
#weight\#1# один плейсхолдер, внутри которого текст weight#1
#a# #b# два плейсхолдера, a и b
Для регулярки оба случая выглядят одинаково: решётки и символы между ними. Можно было взять выражение похитрее, но экранируемый разделитель это как раз тот случай, с которым регулярные выражения справляются плохо, поэтому вместо неё написан небольшой сканер. Он вышел и яснее, и корректнее.
Поиск настоящей закрывающей решётки
Идём вперёд от открывающей решётки. Обратный слеш экранирует следующий символ, поэтому пары \\ и \# пропускаются и не закрывают плейсхолдер. Первая неэкранированная решётка его закрывает:
function findClosingHash(template, openIdx) {
let j = openIdx + 1;
while (j < template.length) {
if (template[j] === '\\' && j + 1 < template.length) { j += 2; continue; } // skip escaped pair
if (template[j] === '#') return j;
j += 1;
}
return -1; // unterminated
}
Деление на сегменты
Главный проход идёт по строке и копит литеральный кусок, пока не встретит плейсхолдер. Обратный слеш сворачивает свою пару в литерал. Неэкранированная решётка сбрасывает литерал и выдаёт плейсхолдер по найденному выше индексу закрытия. Незакрытая решётка откатывается в литерал, и это ровно то, что наивная регулярка и так оставила бы как есть:
while (i < template.length) {
const c = template[i];
if (c === '\\' && i + 1 < template.length) { // escape: keep next char literally
lit += template[i + 1] === '#' ? '#' : template[i + 1];
i += 2;
continue;
}
if (c === '#') {
const close = findClosingHash(template, i);
if (close < 0) { lit += template.slice(i); break; } // unterminated -> literal
flushLit();
out.push({ kind: 'placeholder', innerRaw: template.slice(i + 1, close) });
i = close + 1;
continue;
}
lit += c;
i += 1;
}
По одной задаче на проход
Деление оставляет внутренний текст плейсхолдера сырым. Раскодирование экранов это отдельный шаг, который идёт по внутренней строке, прежде чем её прочитают как имя правила и модификаторы:
// "weight\#1" -> "weight#1", "a\\b" -> "a\b"
function decodePlaceholderInner(raw) { /* same backslash walk, returns decoded text */ }
Токенизация и раскодирование разведены по разным функциям, и поэтому каждая остаётся лёгкой. Сканер лишь решает, где плейсхолдер начинается и заканчивается; декодер только превращает \# в #. Ни один не рассуждает о другом, и те же две функции стоят за движком, экстрактором параметров и граф-видом, поэтому каждая часть приложения читает грамматику одинаково.
Когда отказаться от регулярки
Для плоских шаблонов регулярка годится. Но как только разделитель можно экранировать, вкладывать или он несёт собственный мини-синтаксис, короткий посимвольный сканер оказывается легче для чтения и доверия, чем выражение, которое пытается делать всё разом, во многом потому что поиск токенов и их раскодирование в нём остаются двумя отдельными задачами.
Generative Grammar Engine бесплатен на grammar.tinygods.dev, а код открыт на github.com/tinygodsdev/metatracery.