diff --git a/src/CSTBuilder.js b/src/CSTBuilder.js index df20002..e22ac40 100644 --- a/src/CSTBuilder.js +++ b/src/CSTBuilder.js @@ -1,78 +1,181 @@ +/** + * CST(具体语法树)构建器类 + * + * 什么是 CST? + * CST (Concrete Syntax Tree,具体语法树) 是对源代码结构的精确表示, + * 它保留了所有细节,包括空格、注释、标点符号等。 + * + * CST 与 AST 的区别: + * - AST (抽象语法树):只保留语义信息,忽略格式细节 + * 例如:" { "a" : 1 }" 和 '{"a":1}' 生成相同的 AST + * - CST (具体语法树):保留所有细节,包括空格和注释的位置 + * 例如:" { "a" : 1 }" 会完整记录每个空格的位置 + * + * 为什么要用 CST? + * 因为我们需要修改 JSON 的同时保持原有的格式和注释, + * 所以必须知道每个元素在原文本中的精确位置。 + * + * 工作流程: + * 1. 接收 Tokenizer 生成的 token 数组 + * 2. 按照 JSON 语法规则,将 token 组装成树形结构 + * 3. 每个节点记录其在原文本中的位置(start 和 end) + */ export class CSTBuilder { + /** + * 构造函数 + * @param {Array} tokens - Tokenizer 生成的 token 数组 + */ constructor(tokens) { - this.tokens = tokens; - this.pos = 0; + this.tokens = tokens; // 保存 token 数组 + this.pos = 0; // 当前处理的 token 位置 } + /** + * 主构建方法:将 token 数组转换为 CST + * + * @returns {Object} CST 根节点 + */ build() { - this.skipTrivia(); - const node = this.parseValue(); - this.skipTrivia(); + this.skipTrivia(); // 跳过开头的空白和注释 + const node = this.parseValue(); // 解析主值 + this.skipTrivia(); // 跳过结尾的空白和注释 return node; } + /** + * 获取当前位置的 token + * @returns {Object} 当前 token + */ current() { return this.tokens[this.pos]; } + /** + * 跳过无关紧要的 token(空白符和注释) + * + * 什么是 Trivia? + * Trivia 是编程语言中对代码逻辑无影响的内容,如: + * - 空格、换行、制表符等空白字符 + * - 注释 + * + * 为什么要跳过? + * 在解析语法结构时,我们关心的是实际的值和符号, + * 而不是它们之间的空白和注释。 + * 但我们不会删除它们,只是在解析时暂时忽略。 + */ skipTrivia() { while (this.pos < this.tokens.length && (this.tokens[this.pos].type === "whitespace" || this.tokens[this.pos].type === "comment")) { this.pos++; } } + /** + * 消费(读取并移动到下一个)指定类型的 token + * + * @param {string} type - 期望的 token 类型 + * @returns {Object} 被消费的 token + * @throws {Error} 如果当前 token 类型不匹配 + */ consume(type) { const token = this.current(); + // 检查 token 类型是否匹配 if (!token || token.type !== type) { throw new Error(`Expected ${type}, got ${token && token.type}`); } - this.pos++; + this.pos++; // 移动到下一个 token return token; } + /** + * 解析一个 JSON 值 + * + * JSON 值可以是以下任意类型: + * - 对象:{ "key": "value" } + * - 数组:[1, 2, 3] + * - 字符串:"hello" + * - 数字:123 + * - 布尔值:true 或 false + * - 空值:null + * + * @returns {Object} 值节点 + */ parseValue() { - this.skipTrivia(); + this.skipTrivia(); // 跳过值前面的空白和注释 const token = this.current(); if (!token) { throw new Error("Unexpected end of input"); } + // 根据 token 类型,调用相应的解析方法 switch (token.type) { - case "braceL": + case "braceL": // { - 左花括号,解析对象 return this.parseObject(); - case "bracketL": + case "bracketL": // [ - 左方括号,解析数组 return this.parseArray(); - case "string": + case "string": // 字符串 return this.parsePrimitive("String"); - case "number": + case "number": // 数字 return this.parsePrimitive("Number"); - case "boolean": + case "boolean": // 布尔值 return this.parsePrimitive("Boolean"); - case "null": + case "null": // 空值 return this.parsePrimitive("Null"); default: throw new Error(`Unexpected token: ${token.type}`); } } + /** + * 解析基本类型值(字符串、数字、布尔值、null) + * + * 基本类型的特点: + * - 它们都是单个 token,不包含子节点 + * - 只需要记录类型和位置信息 + * + * @param {string} type - 节点类型 + * @returns {Object} 基本类型节点 + */ parsePrimitive(type) { const token = this.current(); - this.pos++; + this.pos++; // 移动到下一个 token return { type, - start: token.start, - end: token.end, + start: token.start, // 在原文本中的起始位置 + end: token.end, // 在原文本中的结束位置 }; } + /** + * 解析 JSON 对象 + * + * 对象的语法结构: + * { + * "key1": value1, + * "key2": value2, + * ... + * } + * + * 解析步骤: + * 1. 读取左花括号 { + * 2. 循环读取键值对,直到遇到右花括号 } + * - 读取字符串作为键 + * - 读取冒号 : + * - 递归解析值 + * - 如果有逗号 , 则继续读取下一个键值对 + * 3. 读取右花括号 } + * + * @returns {Object} 对象节点 + */ parseObject() { - const startToken = this.consume("braceL"); - const properties = []; + const startToken = this.consume("braceL"); // 消费左花括号 { + const properties = []; // 存储所有属性(键值对) - this.skipTrivia(); + this.skipTrivia(); // 跳过 { 后面的空白 + // 循环读取属性,直到遇到右花括号 while (this.current() && this.current().type !== "braceR") { + // 1. 读取键(必须是字符串) const keyToken = this.consume("string"); const keyNode = { type: "String", @@ -80,55 +183,90 @@ export class CSTBuilder { end: keyToken.end, }; + // 2. 跳过键后面的空白 this.skipTrivia(); + // 3. 读取冒号 : this.consume("colon"); + // 4. 跳过冒号后面的空白 this.skipTrivia(); + // 5. 递归解析值(值可以是任意 JSON 类型) const valueNode = this.parseValue(); + // 6. 将键值对添加到属性列表 properties.push({ key: keyNode, value: valueNode }); + // 7. 跳过值后面的空白 this.skipTrivia(); + // 8. 如果有逗号,消费它并继续;否则准备结束 if (this.current() && this.current().type === "comma") { this.pos++; this.skipTrivia(); } } + // 消费右花括号 } const endToken = this.consume("braceR"); + // 返回对象节点 return { type: "Object", - start: startToken.start, - end: endToken.end, - properties, + start: startToken.start, // 对象起始位置(左花括号的位置) + end: endToken.end, // 对象结束位置(右花括号的位置) + properties, // 所有属性 }; } + /** + * 解析 JSON 数组 + * + * 数组的语法结构: + * [ + * value1, + * value2, + * ... + * ] + * + * 解析步骤: + * 1. 读取左方括号 [ + * 2. 循环读取元素,直到遇到右方括号 ] + * - 递归解析值 + * - 如果有逗号 , 则继续读取下一个元素 + * 3. 读取右方括号 ] + * + * @returns {Object} 数组节点 + */ parseArray() { - const startToken = this.consume("bracketL"); - const elements = []; + const startToken = this.consume("bracketL"); // 消费左方括号 [ + const elements = []; // 存储所有元素 - this.skipTrivia(); + this.skipTrivia(); // 跳过 [ 后面的空白 + // 循环读取元素,直到遇到右方括号 while (this.current() && this.current().type !== "bracketR") { + // 1. 递归解析值(值可以是任意 JSON 类型) const valueNode = this.parseValue(); + // 2. 将值添加到元素列表 elements.push(valueNode); + // 3. 跳过值后面的空白 this.skipTrivia(); + // 4. 如果有逗号,消费它并继续;否则准备结束 if (this.current() && this.current().type === "comma") { this.pos++; this.skipTrivia(); } } + // 消费右方括号 ] const endToken = this.consume("bracketR"); + // 返回数组节点 return { type: "Array", - start: startToken.start, - end: endToken.end, - elements, + start: startToken.start, // 数组起始位置(左方括号的位置) + end: endToken.end, // 数组结束位置(右方括号的位置) + elements, // 所有元素 }; } } diff --git a/src/Tokenizer.js b/src/Tokenizer.js index 75002de..933f47b 100644 --- a/src/Tokenizer.js +++ b/src/Tokenizer.js @@ -1,23 +1,64 @@ +/** + * 词法分析器(Tokenizer)类 + * + * 作用:将 JSON 文本字符串转换为一系列的"词法单元"(Token) + * + * 什么是 Token? + * Token 是代码解析的最小单位,类似于将句子拆分成单词。 + * 例如:"{"name": 123}" 会被拆分为: + * - { (左花括号) + * - "name" (字符串) + * - : (冒号) + * - 123 (数字) + * - } (右花括号) + * + * 每个 Token 包含三个信息: + * 1. type: 类型(如 "string"、"number"、"braceL" 等) + * 2. start: 在原文本中的起始位置 + * 3. end: 在原文本中的结束位置 + */ class Tokenizer { + /** + * 构造函数 + * @param {string} text - 要解析的 JSON 文本 + */ constructor(text) { - this.text = text; - this.pos = 0; - this.tokens = []; + this.text = text; // 保存原始文本 + this.pos = 0; // 当前读取位置,从 0 开始 + this.tokens = []; // 存储生成的所有 token } + /** + * 主解析方法:将文本转换为 token 数组 + * + * 工作原理: + * 1. 从头到尾遍历文本的每个字符 + * 2. 根据当前字符判断它是什么类型(空白符、字符串、数字等) + * 3. 调用相应的读取方法来处理这个类型的内容 + * 4. 重复直到文本结束 + * + * @returns {Array} token 数组 + */ tokenize() { + // 遍历整个文本 while (this.pos < this.text.length) { - const ch = this.text[this.pos]; + const ch = this.text[this.pos]; // 获取当前字符 + // 根据字符类型,调用不同的读取方法 if (this.isWhitespace(ch)) { + // 空白字符(空格、换行、制表符等) this.readWhitespace(); } else if (ch === '"') { + // 字符串(以双引号开头) this.readString(); } else if (this.isNumberStart(ch)) { + // 数字(以数字或负号开头) this.readNumber(); } else if (this.isAlpha(ch)) { + // 关键字(以字母开头,如 true、false、null) this.readKeyword(); } else { + // 其他符号(如 {}[],:)或注释 this.readPunctuationOrComment(); } } @@ -25,27 +66,52 @@ class Tokenizer { return this.tokens; } - // ---------- helpers ---------- + // ========== 辅助判断方法 ========== + // 这些方法用于判断字符的类型 + /** + * 判断字符是否为空白符 + * @param {string} ch - 要判断的字符 + * @returns {boolean} + */ isWhitespace(ch) { return ch === " " || ch === "\n" || ch === "\r" || ch === "\t"; } + /** + * 判断字符是否为数字的起始字符 + * @param {string} ch - 要判断的字符 + * @returns {boolean} + */ isNumberStart(ch) { return ch === "-" || (ch >= "0" && ch <= "9"); } + /** + * 判断字符是否为字母 + * @param {string} ch - 要判断的字符 + * @returns {boolean} + */ isAlpha(ch) { return (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z"); } - // ---------- readers ---------- + // ========== Token 读取方法 ========== + // 这些方法负责读取特定类型的内容,并生成对应的 token + /** + * 读取连续的空白字符 + * + * 为什么要保留空白符? + * 为了在修改 JSON 后保持原有的格式和缩进 + */ readWhitespace() { - const start = this.pos; + const start = this.pos; // 记录起始位置 + // 持续读取,直到遇到非空白字符 while (this.pos < this.text.length && this.isWhitespace(this.text[this.pos])) { this.pos++; } + // 生成一个 whitespace 类型的 token this.tokens.push({ type: "whitespace", start, @@ -53,27 +119,39 @@ class Tokenizer { }); } + /** + * 读取字符串 + * + * 字符串规则: + * - 以双引号 " 开始和结束 + * - 支持转义字符,如 \" 表示引号本身,\n 表示换行 + * - 遇到 \ 时需要跳过下一个字符,因为它是转义序列 + */ readString() { const start = this.pos; - this.pos++; // skip opening " + this.pos++; // 跳过开头的双引号 " + // 持续读取直到遇到结束的双引号 while (this.pos < this.text.length) { const ch = this.text[this.pos]; if (ch === "\\") { - // skip escaped char + // 遇到转义符 \,跳过它和下一个字符 + // 例如:\" 或 \n 都占两个字符 this.pos += 2; continue; } if (ch === '"') { - this.pos++; // closing " + // 遇到结束的双引号 + this.pos++; // 包含结束的双引号 break; } this.pos++; } + // 生成一个 string 类型的 token this.tokens.push({ type: "string", start, @@ -81,32 +159,49 @@ class Tokenizer { }); } + /** + * 读取数字 + * + * JSON 数字格式支持: + * 1. 整数:123 + * 2. 负数:-123 + * 3. 小数:123.456 + * 4. 科学计数法:1.23e10 或 1.23E-5 + */ readNumber() { const start = this.pos; + // 1. 处理可选的负号 if (this.text[this.pos] === "-") this.pos++; + // 2. 读取整数部分 while (this.pos < this.text.length && this.isDigit(this.text[this.pos])) { this.pos++; } + // 3. 处理可选的小数部分 if (this.text[this.pos] === ".") { - this.pos++; + this.pos++; // 跳过小数点 + // 读取小数部分的数字 while (this.pos < this.text.length && this.isDigit(this.text[this.pos])) { this.pos++; } } + // 4. 处理可选的指数部分(科学计数法) if (this.text[this.pos] === "e" || this.text[this.pos] === "E") { - this.pos++; + this.pos++; // 跳过 e 或 E + // 处理可选的 + 或 - 号 if (this.text[this.pos] === "+" || this.text[this.pos] === "-") { this.pos++; } + // 读取指数部分的数字 while (this.pos < this.text.length && this.isDigit(this.text[this.pos])) { this.pos++; } } + // 生成一个 number 类型的 token this.tokens.push({ type: "number", start, @@ -114,24 +209,37 @@ class Tokenizer { }); } + /** + * 读取关键字 + * + * JSON 只支持三个关键字: + * - true (布尔值真) + * - false (布尔值假) + * - null (空值) + */ readKeyword() { const start = this.pos; + // 读取连续的字母 while (this.pos < this.text.length && this.isAlpha(this.text[this.pos])) { this.pos++; } + // 提取读取到的单词 const word = this.text.slice(start, this.pos); + // 判断是哪个关键字 let type; if (word === "true" || word === "false") { type = "boolean"; } else if (word === "null") { type = "null"; } else { + // 如果不是合法的关键字,抛出错误 throw new Error(`Unexpected identifier: ${word}`); } + // 生成对应类型的 token this.tokens.push({ type, start, @@ -139,16 +247,26 @@ class Tokenizer { }); } + /** + * 读取标点符号或注释 + * + * 处理三种情况: + * 1. 行注释:双斜杠开头,到行末结束 + * 2. 块注释:斜杠星号开头,星号斜杠结束 + * 3. JSON 标点符号:左右花括号、左右方括号、冒号、逗号 + */ readPunctuationOrComment() { const start = this.pos; const ch = this.text[this.pos]; - // line comment // + // 1. 处理行注释 // if (ch === "/" && this.text[this.pos + 1] === "/") { - this.pos += 2; + this.pos += 2; // 跳过 // + // 读取到行末 while (this.pos < this.text.length && this.text[this.pos] !== "\n") { this.pos++; } + // 生成注释 token this.tokens.push({ type: "comment", start, @@ -157,13 +275,15 @@ class Tokenizer { return; } - // block comment /* */ + // 2. 处理块注释 /* */ if (ch === "/" && this.text[this.pos + 1] === "*") { - this.pos += 2; + this.pos += 2; // 跳过 /* + // 查找结束标记 */ while (this.pos < this.text.length && !(this.text[this.pos] === "*" && this.text[this.pos + 1] === "/")) { this.pos++; } - this.pos += 2; // skip */ + this.pos += 2; // 跳过 */ + // 生成注释 token this.tokens.push({ type: "comment", start, @@ -172,23 +292,26 @@ class Tokenizer { return; } - // punctuation - this.pos++; + // 3. 处理标点符号 + this.pos++; // 移动到下一个字符 + // 符号到类型的映射表 const map = { - "{": "braceL", - "}": "braceR", - "[": "bracketL", - "]": "bracketR", - ":": "colon", - ",": "comma", + "{": "braceL", // 左花括号 + "}": "braceR", // 右花括号 + "[": "bracketL", // 左方括号 + "]": "bracketR", // 右方括号 + ":": "colon", // 冒号 + ",": "comma", // 逗号 }; const type = map[ch]; if (!type) { + // 遇到不认识的字符,抛出错误 throw new Error(`Unexpected character: ${ch} at ${start}`); } + // 生成对应的标点符号 token this.tokens.push({ type, start, @@ -196,6 +319,11 @@ class Tokenizer { }); } + /** + * 判断字符是否为数字(0-9) + * @param {string} ch - 要判断的字符 + * @returns {boolean} + */ isDigit(ch) { return ch >= "0" && ch <= "9"; }