중첩 괄호 텍스트를 배열 트리로 변환하려면 왼쪽에서 오른쪽으로 훑으면서 여는 괄호마다 배열을 만들고 닫는 괄호마다 부모 배열로 돌아갑니다. 이 가이드에서는 Mac의 Safari에서 로컬로 실행할 수 있는 완전한 JavaScript 파서를 제공합니다.
여기서 할 일은 직접 작성한 짧은 레이블 개요를 순서, 따옴표로 묶인 텍스트, 빈 그룹을 보존하면서 중첩 배열로 변환하는 것입니다. 모든 예시는 인위적으로 만든 문자열을 사용합니다. 파서에는 명시적인 문법 규칙과 검사가 포함되어 있으므로 결과 구조가 의도한 의미와 일치하는지 확인할 수 있습니다.
괄호와 따옴표로 묶인 레이블의 의미 정하기
변환하기 전에 구조를 나타내는 괄호와 레이블 안에 포함된 괄호를 구분합니다. 이 예시에서는 바깥 괄호가 그룹을 만들고, 큰따옴표로 묶인 레이블 안의 괄호는 텍스트로 남습니다.
(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
Safari에서 로컬 JavaScript 작업 공간 열기
Safari의 Web Inspector는 이 데스크톱 변환에 필요한 JavaScript 프롬프트를 제공합니다. 패키지를 설치하거나 별도의 프로젝트를 만들 필요가 없습니다.
- Safari에서
Safari→설정…→고급을 선택합니다. 웹 개발자용 기능 보기를 활성화합니다.- 탭을 열고 주소 필드에
about:blank를 입력합니다. 개발자용→Web Inspector 보기를 선택하거나Option–Command–I를 누릅니다.콘솔탭을 선택하고 입력 프롬프트를 사용합니다.
WebKit은 Web Inspector 활성화에서 개발자 설정과 Inspector 단축키를 설명합니다. Web Inspector 인터페이스 참고 자료에서는 콘솔 프롬프트가 JavaScript를 평가하고 결과를 표시한다고 설명합니다.
아래 프로그램 전체를 해당 프롬프트에 붙여 넣고 실행합니다. 프로그램에는 파서, 직접 작성한 샘플, 유효한 입력 검사 6개, 잘못된 입력 검사 7개가 들어 있습니다. 가져오기는 필요하지 않습니다. 이 프로그램은 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", () => "$&")); // $&
두 결과 모두 준비 과정에서 확인했습니다. 트리 파서는 배열에 디코딩된 문자열을 직접 추가하므로 치환 의미를 전혀 사용하지 않습니다. 나중에 레이블 이름 바꾸기 단계를 추가한다면 파싱된 레이블을 대상으로 치환하고, 치환 텍스트를 해석할지 문자 그대로 삽입할지 명시적으로 정하세요.
트리를 다시 사용하기 전에 예상치 못한 결과 진단하기
포함된 검사는 간단한 판단 도구 역할을 합니다. 빈 입력, 빈 그룹, 서로 붙어 있는 그룹과 레이블, 공백이 포함된 따옴표 문자열, 문자열만 있는 값, 이스케이프, 잘못된 문법을 다룹니다. 의도한 개요와 정의된 문법 사이의 불일치를 찾으려면 다음 증상을 활용하세요.
| 증상 | 설명 및 다음 조치 |
|---|---|
| 결과에 바깥 배열이 추가로 있음 | 루트는 문서를 나타냅니다. 다음 단계에서 최상위 그룹이 정확히 하나여야 한다고 요구하는 경우가 아니라면 그대로 두세요. |
| 문구가 여러 문자열로 나뉨 | 공백은 따옴표로 묶이지 않은 레이블을 구분합니다. 문구를 큰따옴표 안에 넣으세요. |
| 쉼표가 레이블에 남아 있음 | 여기서 쉼표는 일반 문자입니다. 별개의 레이블은 공백으로 구분하세요. |
| 괄호가 예상치 않게 중첩 배열이 됨 | 리터럴 괄호를 큰따옴표로 묶은 레이블 안에 넣으세요. |
닫는 괄호가 일치하지 않음 |
대응하는 열린 그룹이 없는 닫는 괄호가 있습니다. 보고된 위치를 확인하세요. |
닫히지 않은 여는 괄호 |
끝까지 열린 그룹이 남아 있습니다. 위치는 닫히지 않은 여는 괄호 중 가장 안쪽의 것을 가리킵니다. |
잘못된 JSON 문자열 |
따옴표로 묶인 토큰에 잘못된 이스케이프가 있거나 이스케이프되지 않은 제어 문자가 있는지 확인하세요. |
| 따옴표와 관련된 구분자 오류 | 이웃한 레이블을 공백으로 구분하거나 의도한 레이블 전체를 따옴표 하나로 묶으세요. |
이 구현의 오프셋은 0부터 시작하며 JavaScript 문자열 인덱싱과 마찬가지로 UTF-16 코드 단위를 셉니다. 줄 번호나 화면에 보이는 문자 수가 아닙니다. 따옴표로 묶인 토큰의 검증 오류에서는 보고된 위치가 토큰의 시작을 가리키므로 따옴표로 묶인 레이블 전체를 확인하세요.
잘못된 입력은 함수가 반환되기 전에 예외를 발생시킵니다. 스캔이 진행되는 동안 내부적으로 배열이 만들어지더라도 호출자는 부분 트리를 받지 않습니다. 파서는 재귀 함수 호출 대신 명시적인 스택을 사용하지만, 이 예시에서는 지원되는 문서 크기나 중첩 깊이의 최댓값을 정하지 않습니다.
작은 데스크톱 개요라면 문법을 정하고, 샘플과 검사를 수정하지 않은 채 실행한 다음, 직접 작성한 텍스트로 바꾸는 순서가 실용적입니다. 결과를 다시 사용하기 전에 따옴표로 묶인 레이블, 빈 그룹, 문서 루트를 확인하세요. 이 세 가지가 배열 트리에 의도한 구조가 보존되는지를 결정합니다.
출처
- Web Inspector 활성화 — WebKit.
- Web Inspector 인터페이스 — WebKit.
- ECMAScript® 2027 언어 명세: 구조화된 데이터 — Ecma International / TC39, 지속적으로 갱신되는 초안.
- ECMAScript® 2027 언어 명세: 텍스트 처리 — Ecma International / TC39, 지속적으로 갱신되는 초안.