Техническая статья

REGEXTEST и REGEXEXTRACT в Delphi через статический PCRE2

HotXLS вычисляет функции регулярных выражений Microsoft 365 REGEXTEST, REGEXEXTRACT и REGEXREPLACE внутри собственного движка формул Delphi — без установленного Excel и без системной regex-DLL. Калькулятор HotXLS статически линкует зафиксированную 16-битную сборку PCRE2 10.49, матчит UTF-16-текст напрямую, бродкастит по массивам и списывает каждый шаг бэктрекинга с ресурсного бюджета формулы

Последняя часть — причина существования этой статьи. Книга, пришедшая от клиента, — недоверенный вход, а регулярное выражение внутри формулы — код, написанный клиентом. Паттерн плюс текст — всё, что нужно, чтобы на минуты оккупировать ядро CPU катастрофическим бэктрекингом — классическим ReDoS-паттерном (a+)+$ против длинной серии символов a с b на конце. Сервер, пересчитывающий загруженные книги, позволить этого не может, поэтому интересная работа в HotXLS 2.384.112 была не в вызове PCRE2, а в том, чтобы сделать PCRE2 останавливаемой, подотчётной и одинаковой на всех целевых платформах

Что возвращают REGEXTEST, REGEXEXTRACT и REGEXREPLACE в HotXLS?

Движок формул HotXLS следует опубликованным сигнатурам Microsoft, а семантика аргументов — та, что вы уже знаете по Excel. Сопоставление по умолчанию чувствительно к регистру. Последний аргумент case_sensitivity принимает 0 для учёта регистра и 1 без учёта, любое другое значение возвращает #VALUE!. Встроенные опции PCRE2 вроде (?i) или (*UCP) тоже работают, если предпочитаете держать поведение внутри паттерна

REGEXTEST(text, pattern, [case_sensitivity]) возвращает Boolean, и несостоявшееся совпадение — просто FALSE, никогда не ошибка. У REGEXEXTRACT(text, pattern, [return_mode], [case_sensitivity]) три режима: 0 возвращает первое полное совпадение, 1 — каждое полное совпадение вертикальным массивом, а 2 — группы захвата первого совпадения горизонтальным массивом. Без совпадения возвращается #N/A, режим 2 на паттерне без групп захвата даёт #VALUE!, а необязательная группа, не участвовавшая в совпадении, даёт пустую строку вместо ошибки

REGEXREPLACE(text, pattern, replacement, [occurrence], [case_sensitivity]) использует синтаксис замены PCRE2, поэтому $1 и ${name} ссылаются на захваты. occurrence со значением 0 заменяет каждое совпадение, положительное значение берёт n-е совпадение от начала, отрицательное считает с конца. Если выбранное вхождение не существует, HotXLS возвращает исходный текст без изменений. Числовые селекторы усекаются к нулю — так же, как Excel поступает с дробными целочисленными аргументами

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Orders');
    Sheet.Cells[2, 1].Value := 'PO-2026-00417 shipped to Graz';

    // TRUE, когда в ячейке номер заказа на поставку
    Sheet.Cells[2, 2].Formula := 'REGEXTEST(A2, "PO-\d{4}-\d{5}")';

    // Первое полное совпадение: "PO-2026-00417"
    Sheet.Cells[2, 3].Formula := 'REGEXEXTRACT(A2, "PO-\d{4}-\d{5}")';

    // Поменять год и номер местами нумерованными захватами: "PO-00417-2026 ..."
    Sheet.Cells[2, 4].Formula :=
      'REGEXREPLACE(A2, "PO-(\d{4})-(\d{5})", "PO-$2-$1")';

    // Проверка без учёта регистра последним аргументом
    Sheet.Cells[2, 5].Formula := 'REGEXTEST(A2, "graz", 1)';

    Book.Recalculate;
    Book.SaveAs('orders.xlsx');
  finally
    Book.Free;
  end;
end;

Текст формулы в Delphi — обычный строковый литерал, поэтому \d не требует экранирования на стороне Pascal. Важны только двойные кавычки Excel внутри формулы, и они подчиняются обычным правилам Excel. При сохранении XLSX-писатель хранит эти имена с префиксом future-функций _xlfn., которого ждёт Excel, а заново открытая книга пересчитывает их снова. Классический движок BIFF8 может вычислить функции в контекстном вызове, но не может записать их нативными токенами формул .xls, так что держите regex-формулы в XLSX-выводе

Как работает бродкаст массивов с regex-формулами?

REGEXTEST, REGEXREPLACE и режим 0 REGEXEXTRACT бродкастятся по совместимым операндам — именно это делает их полезными на настоящих колонках. Каждый аргумент может быть скаляром, строкой, колонкой или прямоугольником, и HotXLS берёт наибольшее число строк и колонок как форму результата. Операнд, чья размерность не 1 и не этот максимум, заставляет весь вызов вернуть #VALUE! — обычное несовпадение массивов Excel. Так REGEXTEST(A2:A500, "^[A-Z]{2}\d{9}$") даёт 499 Boolean, а колонка паттернов против одной текстовой ячейки работает так же в обратную сторону

Режимы 1 и 2 REGEXEXTRACT бродкаститься отказываются. Каждый элемент произвёл бы вектор другой длины, а рваный набор векторов не может занять прямоугольный результат формулы. Вместо того чтобы молча обрезать или дополнять, HotXLS сообщает о вызове как о неподдерживаемом. Если нужны все совпадения из многих ячеек, итерируйте ячейки и извлекайте по одной или переформулируйте задачу через замыкания LET и LAMBDA в движке формул HotXLS, чтобы каждый вызов замыкания видел одно текстовое значение

Размещение ошибок внутри результата бродкаста — намеренное. Ошибка Excel для одного элемента, скажем #N/A от извлечения, не нашедшего ничего в строке 37, ложится в ту одну ячейку результата, пока остальные сохраняют значения. Лимит ресурса или отмена — другое: они останавливают весь вызов и никогда не публикуют частично заполненный массив, потому что половина результата, выглядящая целой, хуже явного провала

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Opts: TXLSFormulaEvaluationOptions;
  Eval: TXLSFormulaEvaluationResult;
  I: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Log');
    Sheet.Cells[1, 1].Value := 'GET /api/v2/orders 200 18ms';

    Opts := XLSDefaultFormulaEvaluationOptions;
    Opts.MaxEvaluationSteps := 200000;   // потолок работы этой формулы

    // Режим 2: группы захвата первого совпадения как массив 1 x 3
    Eval := Sheet.EvaluateFormulaAt(1, 2,
      'REGEXEXTRACT(A1, "(GET|POST) (\S+) (\d{3})", 2)', xlfrsA1, Opts);

    case Eval.Status of
      xlfeOk:
        if VarIsArray(Eval.Value) then
          for I := VarArrayLowBound(Eval.Value, 2) to
                   VarArrayHighBound(Eval.Value, 2) do
            Log(VarToStr(Eval.Value[0, I]));
      xlfeResourceLimit:
        Log('Pattern exceeded its budget: ' + Eval.Issue.Message);
    else
      Log('Evaluation failed: ' + Eval.Issue.Message);
    end;
  finally
    Book.Free;
  end;
end;

TXLSXWorksheet.EvaluateFormulaAt — правильная точка входа, когда паттерн приходит откуда-то, что вы не контролируете. Он вычисляет формулу так, будто та живёт в заданной строке и колонке, возвращает весь массив вместо единственного кэшированного значения ячейки и принимает TXLSFormulaEvaluationOptions с явными управлениями шагов, памяти массивов и отмены. Остальную часть этого API вычисления описывает руководство по движку формул и пользовательским функциям HotXLS

Как HotXLS останавливает паттерн с катастрофическим бэктрекингом?

HotXLS останавливает убежавшие паттерны, заставляя PCRE2 отчитываться перед контекстом формулы на каждом шаге. Бэкенд компилирует каждый паттерн с PCRE2_AUTO_CALLOUT, и PCRE2 вызывает Pascal-колбэк на каждом элементе паттерна при сопоставлении. Колбэк списывает единицу работы с активного состояния вычисления, проверяет флаг отмены и резидентную память. Как только что-то из этого говорит «стоп», колбэк возвращает код callout-abort PCRE2, и совпадение аккуратно разматывается внутри PCRE2. Формула выдаётся как xlfeResourceLimit или статус отмены, а не как зависший поток

Память учитывается так же. Каждый контекст PCRE2 создаётся с общим контекстом на экземпляр, чей аллокатор — Pascal-функция, поэтому скомпилированный паттерн, данные совпадения и внутренние кадры бэктрекинга PCRE2 все размещаются через HotXLS. Каждый блок несёт небольшой заголовок, выровнен на 16 байт, и его размер добавляется к бегущей сумме, которую проверка бюджета сверяет с лимитом памяти массивов формулы. Выходные буферы, векторы извлечения и строка замены идут в ту же сумму — значит, паттерн, совпавший с миллионами крошечных строк, упрётся в потолок так же неотвратимо, как паттерн, бэктрекящий вечно

Бюджетный колбэк к тому же работает под защитой, значимой больше, чем кажется. Исключение Delphi, поднятое внутри колбэка, который вызвал PCRE2, никогда не должно распространяться вверх: между колбэком и вашим кодом лежат кадры C-стека, скомпилированные без всякого знания о таблицах исключений Delphi. Разматывать исключение сквозь них — в лучшем случае undefined behavior, в худшем — побитый стек. Мост HotXLS ловит каждое исключение в колбэке, защёлкивает экземпляр в состояние «прервано» и возвращает PCRE2 обычный код ошибки. Ваш код видит провалившуюся формулу, а не исключение, пересёкшее чужой кадр

Цена реальна, и о ней стоит сказать. Auto callouts делают сопоставление медленнее, чем голый интерпретатор PCRE2, а HotXLS компилирует паттерн заново на каждый вычисленный элемент, так что бродкаст по 50 000 ячеек платит 50 000 компиляций. Для массовой текстовой работы на доверенных данных быстрее будет отдельный regex-проход на Delphi вне книги. Внутри книги, которую присылают чужие люди, ограниченное и предсказуемое бьёт быстрое

Почему статическая UTF-16-сборка PCRE2 без JIT?

Строки Excel — UTF-16, как и значения Delphi WideString, что сделало 16-битную библиотеку PCRE2 единственным разумным выбором. HotXLS отдаёт текст формулы в pcre2_match_16 с выставленным PCRE2_UTF, и каждое возвращённое смещение — индекс кодовой единицы UTF-16. Никакого кругового рейса через UTF-8 не происходит, поэтому на суррогатных парах ничего не может съехать. Там, где пустое совпадение заставляет скан продвигаться, HotXLS следует правилу глобального совпадения PCRE2: повторить непустой якорный матч на той же позиции, затем шагнуть на одно скалярное значение Unicode, что переносит на две кодовые единицы за суррогатную пару, а не разрезает её

JIT выключен намеренно. JIT-компилятору нужны исполняемая память и собственный JIT-стек, а это тянет платформозависимый код и размещения мимо персонального аллокатора формул. Интерпретатор держит каждый байт под бюджетом HotXLS, каждый матч — под колбэком и ведёт себя одинаково на всех пяти целевых платформах. Каждый экземпляр TXLSRegex — внутреннего класса за этими формулами — владеет своими контекстами, поэтому параллельные рабочие расчёта не делят никакого изменяемого состояния PCRE2

История линковки — самое необычное место сборки. Delphi линкует C-объектники через {$L}, и его линкер достаточно хорошо разрешает символы между Pascal-юнитами и C-объектниками, но произвольные циклические ссылки между отдельными C-объектниками он не разрешает. PCRE2 — это пара десятков единиц трансляции, вызывающих друг друга во все стороны. Решение — unity build: весь PCRE2 компилируется как одна единица трансляции в один объект pcre2_unity на целевую платформу, поэтому каждая внутренняя ссылка разрешается внутри единственного объекта, а линкер Delphi видит только экспортируемые функции _16

Рантайм C стал второй проблемой. PCRE2 вызывает malloc, memcpy, memcmp и компанию, а другие C-бэкенды, слинкованные в тот же исполняемый файл, могут уже нести собственные копии этих имён. HotXLS прогоняет PCRE2 через препроцессирование так, что эти вызовы уходят на приватные имена вроде hotxls_regex_memcpy, и реализует их на Pascal. Clang после препроцессирования всё ещё может синтезировать голые вызовы memset и memchr, поэтому и эти два существуют, в том же юните. Декорирование символов обязано совпадать с каждым объектным форматом: объект Win32 OMF ждёт C-имена с ведущим подчёркиванием, тогда как Pascal-объявления для объекта Win64 ELF, объекта Win64 COFF и сборок FPC связывают голые имена

Пробы стека были самой тонкой частью. C-функция с кадром больше страницы сначала вызывает routine проб, которая трогает каждую страницу 4 KB по порядку, чтобы Windows мог коммитить стек через свою guard-страницу. Заглушка, которая просто возвращается, линкуется прекрасно и проходит любой юнит-тест с маленьким паттерном, а потом падает при первом глубоком кадре, перепрыгнувшем guard-страницу. HotXLS реализует настоящие пробы на ассемблере для каждого объектного формата Windows: __chkstk_noalloc для объекта Win32, __chkstk для объекта в формате Win64 ELF и ___chkstk_ms для объекта Win64 COFF. На Linux x64 и macOS ARM64 FPC вместо этого линкует системную C-библиотеку, а все прочие цели FPC сообщают бэкенд как недоступный, вместо того чтобы тихо откатываться на другой диалект regex

Где проходят границы?

Regex-функции работают на сборках Delphi и C++Builder под Windows и на FPC Linux x64 и macOS ARM64. На любой другой цели формула сообщает о неподдерживаемой функции через контекстное вычисление, вместо того чтобы выдавать другие ответы другим движком. Это правильный отказ: книга, считающая по-разному на разных платформах, — баг, который никто не найдёт до аудита

У эквивалентности с Excel тоже заявлен предел. Сигнатуры и документированные примеры следуют страницам Microsoft по REGEXTEST, REGEXEXTRACT и REGEXREPLACE, но сборка Excel, использованная для локальной валидации, этих функций Microsoft 365 пока не открывает. Поэтому краевые случаи следуют явным контрактам PCRE2 и массивов, описанным выше, а не заявлению о побайтовом совпадении с каждой сборкой Excel. Если ваши книги зависят от тёмного угла — конкретного поведения обратных ссылок или класса свойства Unicode, — проверьте этот угол напрямую

FAQ

Нужны ли HotXLS Excel или regex-DLL для вычисления REGEXTEST?

Нет. HotXLS линкует статический UTF-16 объект PCRE2 в ваш исполняемый файл и вычисляет REGEXTEST, REGEXEXTRACT и REGEXREPLACE в собственном движке формул Delphi — без Excel, COM и внешних DLL

Может ли злонамеренный паттерн повесить мой сервер?

Не дальше настроенного бюджета. Каждый шаг сопоставления проходит через callout, списывающий с контекста формулы, поэтому ReDoS-паттерн заканчивается xlfeResourceLimit или статусом отмены и без частичного результата

Почему режим 1 REGEXEXTRACT падает на аргументе-диапазоне?

Каждая ячейка могла бы вернуть разное число совпадений, а такие рваные векторы не образуют прямоугольный результат. HotXLS сообщает о вызове как о неподдерживаемом вместо дополнения или обрезки, поэтому извлекайте по одной ячейке за раз

HotXLS поставляет regex-функции впридачу к LAMBDA, GROUPBY, PIVOTBY и остальному расчётному движку для Delphi, C++Builder и Lazarus. Подробности, издания и пробная загрузка — на странице HotXLS Delphi spreadsheet component