Чтобы преобразовать вложенный текст в скобках в дерево массивов, просматривайте его слева направо: при каждой открывающей скобке создавайте массив, а при каждой закрывающей возвращайтесь к родительскому массиву. В этом руководстве приведён полный парсер на JavaScript, который можно запустить локально в Safari на Mac.
Задача состоит в том, чтобы преобразовать небольшой составленный вручную план с метками во вложенные массивы, сохранив порядок, текст в кавычках и пустые группы. Во всех примерах используются искусственные строки. Парсер содержит явные правила грамматики и проверки, позволяющие убедиться, что полученная структура соответствует замыслу.
Определите значение скобок и меток в кавычках
Прежде чем что-либо преобразовывать, отличите структурные скобки от скобок, входящих в состав метки. В этом примере внешние скобки создают группу, а скобки внутри метки в двойных кавычках остаются текстом:
(desk ("blue (round)" "say \"hello\"") () cards)
Предполагаемая структура массива:
[
[
"desk",
["blue (round)", "say \"hello\""],
[],
"cards"
]
]
Дополнительный внешний массив добавлен намеренно: он представляет весь документ. Каждая группа в скобках становится массивом внутри него. Благодаря этому возвращаемый тип остаётся одинаковым, содержит ли ввод одну группу, несколько групп или метки без окружающих их скобок.
Следующие правила определяют формат ввода в этой статье. Это проектные решения для данного парсера, а не встроенный формат JavaScript для текста в скобках.
| Элемент ввода | Значение в результирующем дереве |
|---|---|
( и ) вне двойных кавычек |
Начало и конец вложенного массива |
| Пробельные символы вне двойных кавычек | Разделяют метки; разделитель не сохраняется |
Токен без кавычек, например cards |
Создаёт строку |
Строка в двойных кавычках, например "blue tile" |
Создаёт одну строку, включая пробелы внутри неё |
() |
Создаёт пустой массив |
"" |
Создаёт пустую строку |
12, true или null вне кавычек |
Сохраняет написание как строку |
| Запятые и одинарные кавычки | Считает обычными символами токена без кавычек |
Скобки также разделяют токены, поэтому a(b)c становится ["a", ["b"], "c"]. Между соседними метками нужны пробельные символы: запись "a"b будет отклонена. Если метка содержит пробелы, двойные кавычки или буквальные скобки, используйте форму в двойных кавычках.
Токены в двойных кавычках должны соответствовать синтаксису строк JSON. Парсер передаёт каждый полный токен в кавычках функции JSON.parse(), которая принимает текст JSON и выдаёт SyntaxError при неверном синтаксисе. Таким образом, проверка экранирования поручается документированному парсеру. Спецификация ECMAScript: JSON.parse
Откройте локальную рабочую среду JavaScript в Safari
Web Inspector в Safari предоставляет командную строку JavaScript, необходимую для этого преобразования на компьютере. Устанавливать пакеты или создавать отдельный проект не нужно.
- В Safari выберите
Safari→Settings…→Advanced. - Включите
Show features for web developers. - Откройте вкладку и введите
about:blankв адресную строку. - Выберите
Develop→Show Web Inspectorили нажмитеOption–Command–I. - Перейдите на вкладку
Consoleи используйте поле ввода команд.
В документации WebKit описаны настройка для разработчиков и сочетание клавиш для инспектора: Включение Web Inspector. В справочнике Интерфейс Web Inspector сказано, что командная строка консоли выполняет JavaScript и отображает результаты.
Вставьте в это поле ввода всю приведённую ниже программу и выполните её. Она включает парсер, составленный вручную пример, шесть проверок корректного ввода и семь проверок некорректного ввода. Импортировать ничего не нужно. Программа успешно выполнилась в Node.js 20.19.2; шаги для интерфейса Safari задокументированы, но отдельно для этой статьи не проверялись.
Запустите полный парсер текста в скобках
Парсер хранит открытые массивы в стеке. Открывающая скобка создаёт дочерний массив и помещает его в стек. Закрывающая скобка удаляет текущий массив из стека. Метки добавляются в тот массив, который открыт в данный момент.
function parenthesizedToTree(text) {
if (typeof text !== "string") {
throw new TypeError("Expected a string");
}
const root = [];
const stack = [{ items: root, start: -1 }];
const separator = c => /\s|[()]/u.test(c);
let i = 0;
function fail(message, at = i) {
throw new SyntaxError(message + " at UTF-16 offset " + at);
}
while (i < text.length) {
const c = text[i];
const current = stack[stack.length - 1].items;
if (/\s/u.test(c)) {
i++;
} else if (c === "(") {
const child = [];
current.push(child);
stack.push({ items: child, start: i });
i++;
} else if (c === ")") {
if (stack.length === 1) fail("Unexpected closing parenthesis");
stack.pop();
i++;
} else if (c === '"') {
const start = i++;
let closed = false;
while (i < text.length) {
if (text[i] === "\\") {
i += 2;
} else if (text[i] === '"') {
i++;
closed = true;
break;
} else {
i++;
}
}
if (!closed) fail("Unclosed quoted string", start);
let value;
try {
value = JSON.parse(text.slice(start, i));
} catch {
fail("Invalid JSON string", start);
}
if (i < text.length && !separator(text[i])) {
fail("Expected whitespace or a parenthesis");
}
current.push(value);
} else {
const start = i;
while (i < text.length && !separator(text[i])) {
if (text[i] === '"') fail("Quote inside an unquoted token");
i++;
}
current.push(text.slice(start, i));
}
}
if (stack.length !== 1) {
fail("Unclosed opening parenthesis", stack[stack.length - 1].start);
}
return root;
}
const input = String.raw`(desk ("blue (round)" "say \"hello\"") () cards)`;
console.log(JSON.stringify(parenthesizedToTree(input), null, 2));
const validCases = [
["", []],
["()", [[]]],
["a(b)c", ["a", ["b"], "c"]],
['("a b" "")', [["a b", ""]]],
["(12 true)", [["12", "true"]]],
[String.raw`("a\\b" "\u0061" "$&")`, [["a\\b", "a", "$&"]]]
];
for (const [source, expected] of validCases) {
const actual = parenthesizedToTree(source);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error("Unexpected tree for " + source);
}
}
const invalidCases = [
"(a", "a)", '("a)', String.raw`("\q")`,
'"a"b', 'a"b"', '("line\nbreak")'
];
for (const source of invalidCases) {
let rejected = false;
try {
parenthesizedToTree(source);
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;
rejected = true;
}
if (!rejected) throw new Error("Expected rejection for " + source);
}
console.log("All 13 checks passed.");
Первый выведенный результат — показанная выше вложенная структура, отформатированная в несколько строк. Последнее сообщение должно быть All 13 checks passed. Функция возвращает настоящий массив JavaScript; JSON.stringify() преобразует его в текст для вывода, а третий аргумент задаёт отступы. Спецификация ECMAScript: JSON.stringify
Чтобы преобразовать другой искусственный план, измените только пример, присвоенный input, и повторно запустите программу. Проверки оставьте без изменений, чтобы они и дальше подтверждали предполагаемое поведение парсера. Если вам нужно текстовое представление, скопируйте выведенный текст массива в рабочий документ.
Сохраните семантику кавычек и экранирования
Нужно учитывать два уровня: исходный код JavaScript, содержащий пример, и текст в скобках, который разбирается. В примере используется тегированный шаблон String.raw, благодаря чему обратные косые черты передаются парсеру без обычной обработки строковых экранирований на уровне исходного кода JavaScript. Документированная операция считывает необработанные сегменты шаблона. Спецификация ECMAScript: String.raw
Оставьте ограничивающие обратные апострофы вокруг предоставленного примера. В рамках этой простой процедуры редактирования составляйте текст примера без обратных апострофов и без открывающей последовательности интерполяции шаблона ${, поскольку они имеют специальное значение в синтаксисе шаблонов JavaScript.
В токене ввода в двойных кавычках \" обозначает буквальную двойную кавычку, \\ — одну обратную косую черту, а \u0061 — a. Это декодированные значения строк: дерево сохраняет смысл метки, а исходное написание с экранированием и окружающие кавычки отбрасываются. За пределами двойных кавычек пользовательский парсер считает обратные косые черты обычными символами токена.
Например, последняя проверка корректного ввода подтверждает, что токены "a\\b", "\u0061" и "$&" преобразуются в метку с одной обратной косой чертой, метку a и буквальную метку $&. Если нужно точно воспроизвести исходные пробелы и написание с экранированием, массива декодированных строк недостаточно; представление должно также хранить исходный текст или позиции токенов.
Почему простая замена меняет результат
Замена каждой открывающей скобки квадратной скобкой и каждой закрывающей скобки другой квадратной скобкой не реализует эту грамматику. Она также изменит скобки внутри "blue (round)", но не решит вопросы меток без кавычек и отсутствующих разделителей элементов. Этот вывод прямо следует из составленного вручную примера.
У замены есть и другая важная особенность: строковый аргумент замены интерпретирует специальные последовательности, такие как $&, которая вставляет совпавший текст. Функция замены возвращает текст без такого этапа подстановки. Спецификация ECMAScript: replaceAll и подстановка в строку замены
Этот независимый пример показывает разницу:
const label = "tile";
console.log(label.replaceAll("tile", "$&")); // tile
console.log(label.replaceAll("tile", () => "$&")); // $&
Оба результата были проверены при подготовке. Парсер дерева полностью избегает семантики замены: он напрямую добавляет декодированные строки в массивы. Если позднее вы добавите переименование меток, выполняйте замену в уже разобранных метках и явно выбирайте, следует ли интерпретировать текст замены или вставлять его буквально.
Разберитесь с неожиданным результатом, прежде чем использовать дерево повторно
Включённые проверки служат кратким справочником для принятия решений. Они охватывают пустой ввод, пустые группы, соседние группы и метки, пробелы в кавычках, значения только в виде строк, экранирование и некорректный синтаксис. По следующим симптомам можно найти расхождение между предполагаемым планом и заданной грамматикой.
| Симптом | Объяснение и дальнейшее действие |
|---|---|
| В результате есть дополнительный внешний массив | Корневой массив представляет документ. Оставьте его, если на следующем шаге вам явно не нужна ровно одна группа верхнего уровня. |
| Фраза превращается в несколько строк | Пробельные символы разделяют метки без кавычек. Возьмите фразу в двойные кавычки. |
| Запятая остаётся в метке | Здесь запятые считаются обычными символами. Используйте пробелы между отдельными метками. |
| Скобки неожиданно становятся вложенными массивами | Поместите буквальные скобки внутрь метки в двойных кавычках. |
Unexpected closing parenthesis |
Для закрывающей скобки нет соответствующей открывающей группы. Проверьте указанную позицию. |
Unclosed opening parenthesis |
К концу текста осталась открытая группа. Указанная позиция — это самая внутренняя незакрытая открывающая скобка. |
Invalid JSON string |
Проверьте токен в кавычках на неверную escape-последовательность или неэкранированный управляющий символ. |
| Ошибка разделителя, связанная с кавычками | Разделите соседние метки пробелами или заключите всю нужную метку в одну строку в кавычках. |
Смещения в этой реализации начинаются с нуля и считаются в 16-разрядных кодовых единицах UTF-16, как и при индексации строк JavaScript. Это не номера строк и не количество видимых символов. При ошибках проверки токена в кавычках указанное положение соответствует началу токена, поэтому проверьте всю метку в кавычках.
Некорректный ввод вызывает исключение до того, как функция возвращает результат. Хотя массивы собираются внутри парсера в процессе чтения, вызывающие код не получает частичное дерево. Парсер использует явный стек вместо рекурсивных вызовов функций, но пример не устанавливает максимальный размер документа или глубину вложенности.
Для небольшого плана на компьютере практический порядок действий таков: согласуйте грамматику, запустите исходный пример и проверки без изменений, затем подставьте составленный вами текст. Прежде чем использовать результат, проверьте метки в кавычках, пустые группы и корень документа: именно эти три детали определяют, сохранит ли дерево массивов нужную структуру.
Источники
- Включение Web Inspector — WebKit.
- Интерфейс Web Inspector — WebKit.
- Спецификация языка ECMAScript® 2027: структурированные данные — Ecma International / TC39, текущий черновик.
- Спецификация языка ECMAScript® 2027: обработка текста — Ecma International / TC39, текущий черновик.