Para transformar texto aninhado entre parênteses em uma árvore de arrays, percorra-o da esquerda para a direita, criando um array para cada parêntese de abertura e voltando ao array pai a cada parêntese de fechamento. Este guia oferece um parser JavaScript completo que você pode executar localmente no Safari em um Mac.

A tarefa aqui é converter um pequeno esquema de rótulos, criado manualmente, em arrays aninhados, preservando a ordem, o texto entre aspas e os grupos vazios. Todos os exemplos usam strings sintéticas. O parser inclui regras gramaticais e verificações explícitas, para que você possa confirmar se a estrutura resultante corresponde ao que pretendia.

Decida o significado dos parênteses e dos rótulos entre aspas

Antes de converter qualquer coisa, diferencie os parênteses estruturais daqueles que fazem parte de um rótulo. Neste exemplo, os parênteses externos criam um grupo, enquanto os parênteses dentro do rótulo entre aspas duplas permanecem como texto:

(desk ("blue (round)" "say \"hello\"") () cards)

A estrutura de arrays pretendida é:

[
  [
    "desk",
    ["blue (round)", "say \"hello\""],
    [],
    "cards"
  ]
]

O array externo adicional é intencional: ele representa o documento inteiro. Cada grupo entre parênteses se torna um array dentro dele. Isso mantém o tipo de retorno consistente, quer a entrada contenha um grupo, vários grupos ou rótulos sem parênteses ao redor.

As regras a seguir definem o formato de entrada deste artigo. São escolhas de projeto deste parser, e não um formato integrado do JavaScript para texto entre parênteses.

Recurso da entrada Significado na árvore resultante
( e ) fora de aspas duplas Iniciam e encerram um array aninhado
Espaços em branco fora de aspas duplas Separam rótulos; o separador não é mantido
Um token sem aspas, como cards Produz uma string
Uma string entre aspas duplas, como "blue tile" Produz uma string, incluindo os espaços internos
() Produz um array vazio
"" Produz uma string vazia
12, true ou null fora de aspas Mantêm a grafia como string
Vírgulas e aspas simples São tratadas como caracteres comuns em um token sem aspas

Os parênteses também separam tokens, então a(b)c se torna ["a", ["b"], "c"]. Rótulos vizinhos precisam de espaços em branco: "a"b é rejeitado. Se um rótulo contiver espaços, aspas duplas ou parênteses literais, use a forma entre aspas duplas.

Os tokens entre aspas duplas seguem a sintaxe de strings JSON. O parser passa cada token completo entre aspas para JSON.parse(), que aceita texto JSON e lança um SyntaxError quando a sintaxe é inválida. Isso delega a validação de escapes ao parser documentado. Especificação ECMAScript: JSON.parse

Abra um espaço de trabalho local de JavaScript no Safari

O Web Inspector do Safari oferece o prompt de JavaScript necessário para essa transformação no computador. Você não precisa instalar um pacote nem criar um projeto separado.

  1. No Safari, escolha Safari → Settings… → Advanced.
  2. Ative Show features for web developers.
  3. Abra uma aba e digite about:blank no campo de endereço.
  4. Escolha Develop → Show Web Inspector ou pressione Option–Command–I.
  5. Selecione a aba Console e use o prompt de entrada.

A WebKit documenta a configuração de desenvolvedor e o atalho do inspector em Ativação do Web Inspector. A referência Interface do Web Inspector explica que o prompt do console avalia JavaScript e exibe os resultados.

Cole o programa inteiro abaixo nesse prompt e execute-o. Ele inclui o parser, um exemplo criado manualmente, seis verificações para entradas válidas e sete para entradas malformadas. Não são necessárias importações. O programa foi executado com sucesso no Node.js 20.19.2; as etapas da interface do Safari estão documentadas, mas não foram testadas de forma independente para este artigo.

Execute o parser completo de texto entre parênteses

O parser mantém uma pilha de arrays abertos. Um parêntese de abertura cria um array filho e o coloca na pilha. Um parêntese de fechamento remove o array atual da pilha. Os rótulos são acrescentados ao array que estiver aberto no momento.

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.");

O primeiro resultado impresso é a estrutura aninhada mostrada anteriormente, formatada em várias linhas. A mensagem final deve ser All 13 checks passed. A função retorna um array JavaScript de verdade; JSON.stringify() transforma esse valor em texto imprimível, e o terceiro argumento define a indentação. Especificação ECMAScript: JSON.stringify

Para converter outro esquema sintético, altere apenas o exemplo atribuído a input e execute o programa novamente. Mantenha as verificações sem alterações para que continuem confirmando o comportamento pretendido do parser. Copie o texto do array impresso para o seu documento de trabalho quando precisar de uma representação textual.

Preserve a semântica das aspas e dos escapes

Há duas camadas a considerar: o código-fonte JavaScript que contém o exemplo e o texto entre parênteses que será analisado. O exemplo usa um template marcado com String.raw para que as barras invertidas cheguem ao parser sem passar pela decodificação comum de escapes de strings na camada do código-fonte JavaScript. A operação documentada lê os segmentos brutos do template. Especificação ECMAScript: String.raw

Mantenha os delimitadores de crase ao redor do exemplo fornecido. Para esse fluxo simples de edição, escreva o texto do exemplo sem crases nem o iniciador de interpolação de template ${, pois eles têm significado na sintaxe de templates do JavaScript.

Dentro de um token de entrada entre aspas duplas, \" representa uma aspa dupla literal, \\ representa uma barra invertida e \u0061 representa a. Esses são valores de string decodificados: a árvore preserva o significado do rótulo, enquanto a grafia original dos escapes e as aspas ao redor são descartadas. Fora das aspas duplas, o parser personalizado trata barras invertidas como caracteres comuns do token.

Por exemplo, a última verificação para entradas válidas confirma que os tokens de entrada "a\\b", "\u0061" e "$&" se tornam um rótulo contendo uma barra invertida, o rótulo a e o rótulo literal $&. Se precisar reproduzir exatamente os espaços e a grafia dos escapes originais, um array de strings decodificadas não é suficiente; a representação também precisaria manter o texto de origem ou as posições dos tokens.

Por que uma substituição indiscriminada altera o resultado

Substituir todo parêntese de abertura por um colchete de abertura e todo parêntese de fechamento por outro colchete não implementa essa gramática. Isso também alteraria os parênteses dentro de "blue (round)" e deixaria sem solução os rótulos sem aspas e a falta de separadores entre itens. Essa conclusão decorre diretamente do exemplo criado manualmente.

A substituição tem outro detalhe relevante: um argumento de substituição em string interpreta sequências especiais, como $&, que insere o texto encontrado. Uma função de substituição fornece o texto que retorna sem essa etapa de substituição. Especificação ECMAScript: replaceAll e substituição do texto de reposição

Este exemplo independente demonstra a diferença:

const label = "tile";
console.log(label.replaceAll("tile", "$&"));       // tile
console.log(label.replaceAll("tile", () => "$&")); // $&

Ambos os resultados foram verificados durante a preparação. O parser da árvore evita completamente a semântica de substituição: ele acrescenta as strings decodificadas diretamente aos arrays. Se depois você adicionar uma etapa de renomeação de rótulos, faça as substituições nos rótulos analisados e escolha explicitamente se o texto de substituição deve ser interpretado ou inserido literalmente.

Diagnostique resultados inesperados antes de reutilizar a árvore

As verificações incluídas funcionam como um guia de decisão compacto. Elas abrangem entrada vazia, grupos vazios, grupos e rótulos adjacentes, espaços em rótulos entre aspas, valores que são apenas strings, escapes e sintaxe malformada. Use os sintomas a seguir para localizar uma divergência entre o esquema pretendido e a gramática definida.

Sintoma Explicação e próxima ação
O resultado tem um array externo adicional A raiz representa o documento. Mantenha-a, a menos que a próxima etapa exija explicitamente um único grupo de nível superior.
Uma frase se torna várias strings Espaços em branco separam rótulos sem aspas. Coloque a frase entre aspas duplas.
Uma vírgula permanece em um rótulo Aqui, vírgulas são caracteres comuns. Use espaços em branco entre rótulos distintos.
Parênteses se tornam arrays aninhados inesperadamente Coloque os parênteses literais dentro de um rótulo entre aspas duplas.
Unexpected closing parenthesis Um parêntese de fechamento não tem um grupo de abertura correspondente. Inspecione a posição informada.
Unclosed opening parenthesis Há um grupo ainda aberto no fim. A posição identifica o parêntese de abertura não fechado mais interno.
Invalid JSON string Verifique se o token entre aspas contém um escape inválido ou um caractere de controle sem escape.
Erro de separador relacionado a aspas Separe rótulos vizinhos com espaços em branco ou coloque todo o rótulo pretendido entre aspas.

Os deslocamentos desta implementação começam em zero e contam unidades de código UTF-16, de acordo com a indexação de strings do JavaScript. Eles não são números de linha nem uma contagem dos caracteres visíveis. Em erros de validação de tokens entre aspas, a posição informada é o início do token; por isso, inspecione o rótulo inteiro entre aspas.

Uma entrada malformada lança uma exceção antes que a função retorne. Embora os arrays sejam montados internamente durante a leitura, os chamadores não recebem uma árvore parcial. O parser usa uma pilha explícita em vez de chamadas recursivas de função, mas o exemplo não estabelece um tamanho máximo de documento nem uma profundidade máxima de aninhamento.

Para um pequeno esquema de computador, a sequência prática é definir a gramática, executar o exemplo e as verificações sem alterações e, então, substituir o texto pelo que você criou. Confirme os rótulos entre aspas, os grupos vazios e a raiz do documento antes de reutilizar o resultado; esses três detalhes determinam se a árvore de arrays preserva a estrutura pretendida.

Fontes