24 июля 2026 г.

Как разобрать шаблоны #placeholder#, когда регулярки мало.

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.