To turn nested parenthesized text into an array tree, scan it from left to right, creating an array for each opening parenthesis and returning to its parent at each closing one. This guide provides a complete JavaScript parser you can run locally in Safari on a Mac.
The task here is to convert a small, authored outline of labels into nested arrays while preserving their order, quoted text, and empty groups. All examples use synthetic strings. The parser includes explicit grammar rules and checks, so you can tell whether the resulting structure means what you intended.
Decide what parentheses and quoted labels mean
Before converting anything, distinguish structural parentheses from parentheses that belong inside a label. In this example, the outer parentheses create a group, while the parentheses inside the double-quoted label remain text:
(desk ("blue (round)" "say \"hello\"") () cards)
The intended array structure is:
[
[
"desk",
["blue (round)", "say \"hello\""],
[],
"cards"
]
]
The extra outer array is deliberate: it represents the whole document. Each parenthesized group becomes one array inside it. This makes the return type consistent whether the input contains one group, several groups, or labels without surrounding parentheses.
The following rules define this article’s input format. They are design choices for this parser, rather than a built-in JavaScript parenthesized-text format.
| Input feature | Meaning in the resulting tree |
|---|---|
( and ) outside double quotes |
Start and end a nested array |
| Whitespace outside double quotes | Separate labels; do not retain the separator |
An unquoted token such as cards |
Produce a string |
A double-quoted string such as "blue tile" |
Produce one string, including its internal spaces |
() |
Produce an empty array |
"" |
Produce an empty string |
12, true, or null outside quotes |
Keep the spelling as a string |
| Commas and single quotes | Treat them as ordinary characters in an unquoted token |
Parentheses also separate tokens, so a(b)c becomes ["a", ["b"], "c"]. Two neighboring labels need whitespace: "a"b is rejected. If a label contains spaces, double quotes, or literal parentheses, use the double-quoted form.
Double-quoted tokens follow JSON string syntax. The parser passes each complete quoted token to JSON.parse(), which accepts JSON text and throws a SyntaxError for invalid syntax. This delegates escape validation to the documented parser. ECMAScript specification: JSON.parse
Open a local JavaScript workspace in Safari
Safari’s Web Inspector provides the JavaScript prompt needed for this desktop transformation. You do not need a package installation or a separate project.
- In Safari, choose
Safari→Settings…→Advanced. - Enable
Show features for web developers. - Open a tab and enter
about:blankin the address field. - Choose
Develop→Show Web Inspector, or pressOption–Command–I. - Select the
Consoletab and use its input prompt.
WebKit documents the developer setting and inspector shortcut in Enabling Web Inspector. Its Web Inspector Interface reference explains that the console prompt evaluates JavaScript and displays the results.
Paste the entire program below into that prompt and execute it. It includes the parser, an authored sample, six valid-input checks, and seven malformed-input checks. No imports are required. The program was executed successfully in Node.js 20.19.2; the Safari interface steps are documented, but were not independently exercised for this article.
Run the complete parenthesized-text parser
The parser maintains a stack of open arrays. An opening parenthesis creates a child array and places it on the stack. A closing parenthesis removes the current array from the stack. Labels are appended to whichever array is currently open.
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.");
The first printed result is the nested structure shown earlier, formatted across multiple lines. The final message should be All 13 checks passed. The function returns an actual JavaScript array; JSON.stringify() turns that value into printable text, and its third argument supplies indentation. ECMAScript specification: JSON.stringify
To convert another synthetic outline, change only the sample assigned to input and rerun the program. Keep the checks unchanged so they continue to verify the parser’s intended behavior. Copy the printed array text into your working document when you need a textual representation.
Preserve quoting and escape semantics
There are two layers to consider: the JavaScript source that holds the sample, and the parenthesized text being parsed. The sample uses a String.raw tagged template so its backslashes reach the parser without ordinary string escape decoding at the JavaScript source layer. The documented operation reads the template’s raw segments. ECMAScript specification: String.raw
Keep the backtick delimiters around the supplied sample. For this simple editing workflow, author sample text without backticks or the template interpolation opener ${, which have meaning in JavaScript template syntax.
Inside a double-quoted input token, \" represents a literal double quote, \\ represents one backslash, and \u0061 represents a. These are decoded string values: the tree preserves the label’s meaning, while the original escape spelling and surrounding quote marks are discarded. Outside double quotes, the custom parser treats backslashes as ordinary token characters.
For example, the last valid-input check confirms that the input tokens "a\\b", "\u0061", and "$&" become a label containing one backslash, the label a, and the literal label $&. If you need to reproduce the original spacing and escape spelling exactly, an array of decoded strings is insufficient; the representation would also need to retain source text or token positions.
Why a blanket replacement changes the result
Replacing every opening parenthesis with a square bracket and every closing parenthesis with another square bracket does not implement this grammar. It would also change the parentheses inside "blue (round)", and it would leave unquoted labels and missing item separators unresolved. That conclusion follows directly from the authored sample.
Replacement has another relevant detail: a string replacement argument interprets special sequences such as $&, which inserts the matched text. A replacement function supplies its returned text without that substitution step. ECMAScript specification: replaceAll and replacement substitution
This independent example demonstrates the difference:
const label = "tile";
console.log(label.replaceAll("tile", "$&")); // tile
console.log(label.replaceAll("tile", () => "$&")); // $&
Both results were verified during preparation. The tree parser avoids replacement semantics entirely: it appends decoded strings directly to arrays. If you later add a label-renaming step, make replacements on the parsed labels and choose explicitly whether replacement text should be interpreted or inserted literally.
Diagnose unexpected output before reusing the tree
The included checks act as a compact decision aid. They cover empty input, empty groups, adjacent groups and labels, quoted spaces, string-only values, escapes, and malformed syntax. Use the following symptoms to locate a mismatch between your intended outline and the defined grammar.
| Symptom | Explanation and next action |
|---|---|
| The result has an extra outer array | The root represents the document. Keep it unless your next step explicitly requires exactly one top-level group. |
| A phrase becomes several strings | Whitespace separates unquoted labels. Put the phrase inside double quotes. |
| A comma remains in a label | Commas are ordinary characters here. Use whitespace between separate labels. |
| Parentheses become nested arrays unexpectedly | Put literal parentheses inside a double-quoted label. |
Unexpected closing parenthesis |
A closing parenthesis has no matching open group. Inspect the reported position. |
Unclosed opening parenthesis |
A group remains open at the end. The position identifies the innermost unclosed opening parenthesis. |
Invalid JSON string |
Check the quoted token for an invalid escape or an unescaped control character. |
| A quote-related separator error | Separate neighboring labels with whitespace, or enclose the whole intended label in one quoted string. |
Offsets in this implementation start at zero and count UTF-16 code units, matching its JavaScript string indexing. They are not line numbers or a count of visible characters. For quoted-token validation errors, the reported position is the start of the token, so inspect the entire quoted label.
Malformed input throws an exception before the function returns. Although arrays are assembled internally as scanning proceeds, callers receive no partial tree. The parser uses an explicit stack rather than recursive function calls, but the example establishes no maximum supported document size or nesting depth.
For a small desktop outline, the practical sequence is to agree on the grammar, run the unchanged sample and checks, then substitute your authored text. Confirm quoted labels, empty groups, and the document root before reusing the result; those three details determine whether the array tree retains the structure you intended.
Sources
- Enabling Web Inspector — WebKit.
- Web Inspector Interface — WebKit.
- ECMAScript® 2027 Language Specification: Structured Data — Ecma International / TC39, living draft.
- ECMAScript® 2027 Language Specification: Text Processing — Ecma International / TC39, living draft.