ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零构建一个编辑器:Buffer、Cursor 与 AST 的工程实践与 TaoToken 接入

从零构建一个编辑器:Buffer、Cursor 与 AST 的工程实践与 TaoToken 接入 1. 为什么编辑器内核值得亲手写一遍Buffer、Cursor 与 AST 的真实工程场景如果你用过 Cursor、VS Code 或者 JetBrains 系列大概率会有一种错觉文本编辑这件事好像天生就该这么顺滑。敲一个字符屏幕立刻响应删一段代码撤销栈精准回退输入obj.之后补全列表里冒出来的成员名还带着类型信息。这些体验背后其实站着三套彼此咬合的核心机制Buffer 负责“文本存在哪里”Cursor 负责“人在哪里”AST 负责“代码是什么意思”。把这三件事拆开看你会发现它们各自都是独立的数据结构问题合起来才构成一个编辑器内核。这篇内容面向的是想真正动手写一遍编辑器内核的开发者。你可能已经能熟练调用 Monaco 或 CodeMirror但一直没搞明白 Gap Buffer 的“空洞”到底怎么移动、offset 和 line-column 为什么需要两套坐标、AST 遍历时 parent 指针该不该存。我会用可复制的 TypeScript 模块把这三层搭起来最后接上 TaoToken 的统一 API 通道让编辑器具备调用大模型做代码解释或补全的能力。整条链路是Buffer 存文本 → Cursor 定位 → Lexer/Parser 产出 AST → 把 AST 上下文喂给模型 → 模型返回结果再写回 Buffer。先明确一个边界这里不追求做成生产级编辑器而是把内核的“最小可运行闭环”跑通。你跟着做下来能拿到一个支持插入删除、撤销重做、光标移动、语法树解析、并且能调用模型接口的 Demo。踩过的坑我也会标出来比如 Gap Buffer 扩容时的数组搬移、line-column 转换的边界、以及模型返回内容里带 Markdown 代码块时怎么清洗。核心检索词先摆出来编辑器 Buffer 数据结构、Cursor 位置模型、AST 抽象语法树解析、TaoToken 统一 API 接入。这几个词会贯穿全文也是你在搜索相关资料时最该盯住的锚点。2. TaoToken 前置准备统一 Key 与 API 通道让编辑器能调用模型在写 Buffer 和 Parser 之前先把模型调用这条链路打通原因是后面 AST 解析出来的结构要直接喂给模型如果接口没通整个闭环就断在最后一步。TaoToken 在这里的角色是一个统一的模型调用通道你不需要为每个模型单独维护一套 Key 和 Base URL换模型时只改 Model ID 就行。先注册并拿到 API Key。打开官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 完成账号注册后进入控制台。控制台地址是 https://taotoken.net/console 在 API Keys 页面创建一个新 Key复制出来保存好后面配置文件里要用。这个 Key 就是你在编辑器里调用模型的凭证不要硬编码进前端代码Demo 阶段可以放本地.env或者一个不提交的配置文件。接下来确认两件事Base URL 和 Model ID。Base URL 统一用 https://taotoken.net/api 注意这个地址不带任何查询参数是纯 API 根路径。Model ID 取决于你想用哪个模型在模型对话页面 https://taotoken.net/models 可以看到当前可用的模型列表选一个你熟悉的比如通用的对话模型或者偏代码的模型。把这三个值记下来Base URL、API Key、Model ID后面配置里反复出现。如果你用的是 Claude Code 这类命令行工具做辅助开发TaoToken 也提供了对应的接入方式文档在 https://taotoken.net/doc 。Coding Plan 适合长期做编码和 Agent 场景地址是 https://taotoken.net/coding-plan 如果你打算把编辑器做成一个持续调用模型的工具可以关注这个方案。API Keys 管理页再贴一次https://taotoken.net/api-keys 方便你随时回来轮换 Key。这里要强调一个工程习惯把模型调用封装成一个独立的 client 模块不要让 Buffer 或 Parser 直接依赖 HTTP 请求。编辑器内核应该对“模型”这件事无感知它只负责产出 AST 和接收文本。这样你后面换模型、加缓存、做流式输出都不会污染内核代码。下面第三节会给出这个 client 的可复制配置。3. 可复制配置Buffer、Cursor 与模型 Client 的完整代码这一节是全文的技术重心给出三个可直接落地的模块Gap Buffer、Cursor 坐标转换、以及模型调用 client。每个模块都标了文件路径你可以按这个结构建目录。3.1 Gap Buffer 的 TypeScript 实现文件路径src/core/gap-buffer.ts。Gap Buffer 的核心是在字符数组中间留一段空洞插入时把空洞移到插入点然后往空洞里写字符删除时把空洞扩大覆盖掉要删的字符。这样插入删除在空洞足够大时是均摊 O(1)。// src/core/gap-buffer.ts export class GapBuffer { private buffer: (string | null)[]; private gapStart: number; private gapEnd: number; constructor(initialText: string ) { const chars initialText.split(); // 初始空洞长度为 0放在文本末尾 this.buffer [...chars, null]; this.gapStart chars.length; this.gapEnd chars.length 1; } private moveGap(target: number): void { if (target this.gapStart) return; if (target this.gapStart) { // 空洞左移把 target 到 gapStart 之间的字符搬到空洞右侧 const len this.gapStart - target; for (let i 0; i len; i) { this.buffer[this.gapEnd - 1 - i] this.buffer[this.gapStart - 1 - i]; this.buffer[this.gapStart - 1 - i] null; } this.gapStart target; this.gapEnd - len; } else { // 空洞右移把 gapEnd 到 target 之间的字符搬到空洞左侧 const len target - this.gapStart; for (let i 0; i len; i) { this.buffer[this.gapStart i] this.buffer[this.gapEnd i]; this.buffer[this.gapEnd i] null; } this.gapStart target; this.gapEnd len; } } insert(position: number, text: string): void { this.moveGap(position); const chars text.split(); const gapSize this.gapEnd - this.gapStart; if (chars.length gapSize) { // 空洞不够扩容重建数组空洞放大到两倍文本长度 const content this.getText(); const newGap Math.max(chars.length, content.length); const newBuffer: (string | null)[] []; for (let i 0; i position; i) newBuffer.push(content[i]); for (let i 0; i newGap; i) newBuffer.push(null); for (let i position; i content.length; i) newBuffer.push(content[i]); this.buffer newBuffer; this.gapStart position; this.gapEnd position newGap; } for (let i 0; i chars.length; i) { this.buffer[this.gapStart i] chars[i]; } this.gapStart chars.length; } delete(position: number, length: number): void { this.moveGap(position); this.gapEnd length; } getText(): string { const left this.buffer.slice(0, this.gapStart); const right this.buffer.slice(this.gapEnd); return [...left, ...right].filter((c) c ! null).join(); } get length(): number { return this.buffer.length - (this.gapEnd - this.gapStart); } }这里有个容易踩的坑moveGap的左右移动逻辑方向容易写反。判断依据是目标位置在空洞左边还是右边左边就把字符往右搬右边就往左搬。我建议你写完之后用一组小数据手动跑一遍比如abc在位置 1 插入X看结果是不是aXbc。3.2 Cursor 坐标转换模块文件路径src/core/cursor.ts。编辑器里同时存在两套坐标offset 是线性偏移line-column 是二维坐标。转换的关键是维护一个lineStarts数组记录每一行起始的 offset然后用二分查找定位行号。// src/core/cursor.ts export interface LineColumn { line: number; // 1-based column: number; // 0-based } export class CursorModel { private lineStarts: number[] [0]; private textLength 0; update(text: string): void { this.lineStarts [0]; this.textLength text.length; for (let i 0; i text.length; i) { if (text[i] \n) this.lineStarts.push(i 1); } } offsetToLineColumn(offset: number): LineColumn { let lo 0; let hi this.lineStarts.length - 1; while (lo hi) { const mid Math.floor((lo hi 1) / 2); if (this.lineStarts[mid] offset) lo mid; else hi mid - 1; } return { line: lo 1, column: offset - this.lineStarts[lo] }; } lineColumnToOffset(line: number, column: number): number { const idx line - 1; if (idx 0 || idx this.lineStarts.length) { throw new Error(line ${line} out of range); } const offset this.lineStarts[idx] column; if (offset this.textLength) { throw new Error(column ${column} exceeds text length); } return offset; } get lineCount(): number { return this.lineStarts.length; } }边界情况要特别注意当 offset 正好落在换行符\n上时它属于上一行的行尾还是下一行的行首这里的实现把\n的 offset 归到上一行因为lineStarts记录的是\n之后的位置。你在做光标上下移动时如果列号超过目标行长度要 clamp 到行尾否则会抛异常。3.3 模型调用 Client 配置文件路径src/llm/client.ts。这里用环境变量管理 KeyBase URL 固定为 TaoToken 的 API 根路径。// src/llm/client.ts export interface ChatMessage { role: system | user | assistant; content: string; } export interface ChatOptions { model: string; messages: ChatMessage[]; temperature?: number; stream?: boolean; } const BASE_URL https://taotoken.net/api; const API_KEY process.env.TAOTOKEN_API_KEY ?? ; export async function chat(options: ChatOptions): Promisestring { if (!API_KEY) throw new Error(TAOTOKEN_API_KEY is not set); const res await fetch(${BASE_URL}/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${API_KEY}, }, body: JSON.stringify({ model: options.model, messages: options.messages, temperature: options.temperature ?? 0.2, stream: options.stream ?? false, }), }); if (!res.ok) { const errText await res.text(); throw new Error(TaoToken request failed: ${res.status} ${errText}); } const data await res.json(); return data.choices?.[0]?.message?.content ?? ; }对应的.env文件内容TAOTOKEN_API_KEYsk-你的Key TAOTOKEN_MODEL你的ModelID注意 Base URL 是https://taotoken.net/api请求路径拼上/v1/chat/completions。如果你用的是 Claude Code 或 Cline 这类工具配置项里填的 Base URL 也是这个根路径Model ID 从模型列表里选。Cline 的 MCP 配置里如果需要填三件套就是 Base URL、API Key、Model ID 这三个值缺一不可。4. 验证请求与成功结果从 AST 解析到模型返回的完整链路配置写完了现在验证整条链路能不能跑通。验证分两步先确认 Buffer 和 Cursor 的行为正确再确认模型调用返回预期结果。4.1 Buffer 与 Cursor 的行为验证写一个简单的测试脚本src/test/core.test.tsimport { GapBuffer } from ../core/gap-buffer; import { CursorModel } from ../core/cursor; const buf new GapBuffer(hello world); buf.insert(5, ,); console.log(after insert:, buf.getText()); // hello, world buf.delete(5, 1); console.log(after delete:, buf.getText()); // hello world const cursor new CursorModel(); cursor.update(line1\nline2\nline3); console.log(cursor.offsetToLineColumn(8)); // { line: 2, column: 2 } console.log(cursor.lineColumnToOffset(2, 2)); // 8跑起来之后after insert应该是hello, worldafter delete回到hello world。Cursor 的转换结果{ line: 2, column: 2 }对应 offset 8因为line1\n占 6 个字符line2的第二个字符是 offset 8。如果这两个结果对不上回去检查moveGap的搬移方向和lineStarts的构建。4.2 AST 解析验证用一个极简的表达式解析器验证 AST 产出。文件路径src/core/parser.ts这里只处理let x 1 2;这种语句重点是让你看到 Token 流到 AST 的转换过程。// src/core/parser.ts export interface ASTNode { type: string; [key: string]: unknown; } export function parseExpression(input: string): ASTNode { const tokens input.match(/\d|[\-*/()]/g) ?? []; let pos 0; function parsePrimary(): ASTNode { const tok tokens[pos]; if (/^\d$/.test(tok)) { pos; return { type: NumericLiteral, value: Number(tok) }; } if (tok () { pos; const expr parseAdditive(); pos; // skip ) return expr; } throw new Error(unexpected token: ${tok}); } function parseMultiplicative(): ASTNode { let left parsePrimary(); while (tokens[pos] * || tokens[pos] /) { const op tokens[pos]; const right parsePrimary(); left { type: BinaryExpression, operator: op, left, right }; } return left; } function parseAdditive(): ASTNode { let left parseMultiplicative(); while (tokens[pos] || tokens[pos] -) { const op tokens[pos]; const right parseMultiplicative(); left { type: BinaryExpression, operator: op, left, right }; } return left; } return parseAdditive(); }调用parseExpression(1 2 * 3)你会得到一棵左结合被正确处理的树的右子节点是2 * 3因为乘法优先级更高。这个结构就是后面喂给模型的上下文。4.3 模型调用验证把 AST 序列化成 JSON作为 user message 发给模型让它解释这段代码。写一个验证脚本import { parseExpression } from ../core/parser; import { chat } from ../llm/client; async function main() { const ast parseExpression(1 2 * 3); const reply await chat({ model: process.env.TAOTOKEN_MODEL!, messages: [ { role: system, content: 你是一个代码解释助手用一句话说明表达式的求值顺序。 }, { role: user, content: AST: ${JSON.stringify(ast)} }, ], }); console.log(model reply:, reply); } main().catch(console.error);成功的话控制台会打印出模型对求值顺序的解释比如“先计算 2 乘 3 得到 6再与 1 相加得到 7”。这一步跑通说明从 Buffer 到 AST 再到模型调用的闭环成立了。如果返回空字符串检查choices[0].message.content的路径对不对不同模型的返回结构可能略有差异。5. 本篇常见错误排查401、local proxy failed、reading choices 与 OAuth这一节把接入过程中最容易撞上的几类报错列出来对照着排查。401 Unauthorized。最常见的原因是 API Key 没读到或者格式不对。先确认.env里的TAOTOKEN_API_KEY确实被加载了Node 环境下需要dotenv或者启动时用--env-file。如果 Key 是从控制台复制的注意别把首尾空格带进去。还有一种情况是 Key 被撤销了去 https://taotoken.net/api-keys 重新生成一个。401 的响应体里通常会带invalid_api_key之类的提示打印出来看。local proxy failed。这个报错一般出现在你本地配了某个代理工具但代理没启动或者端口不对。TaoToken 的 API 是直连的不需要额外代理层。检查你的环境变量里有没有HTTP_PROXY、HTTPS_PROXY这类设置如果有就临时清掉再试。另外如果你在 Cline 或 Claude Code 里配置了自定义 Base URL确认填的是https://taotoken.net/api不要多加/v1或者尾部斜杠路径拼接由客户端负责。reading choices。报错信息类似Cannot read properties of undefined (reading choices)说明res.json()返回的对象里没有choices字段。原因通常是请求根本没成功返回的是一个错误对象但代码直接去取data.choices[0]。修复方式是在取choices之前先判断res.ok并且把错误响应体打印出来。另一个可能是流式请求stream: true时返回的是 SSE 流不能按普通 JSON 解析需要逐行读取data:前缀的内容。OAuth 相关报错。如果你用的是 Claude Code 并且走 OAuth 登录流程可能会遇到 token 过期或者 scope 不足。TaoToken 的接入方式是用 API Key不依赖 OAuth所以如果你在配置里看到 OAuth 相关的字段可以清掉改用ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY这类环境变量指向 TaoToken 的地址和你的 Key。Claude Code 的配置文档在 https://taotoken.net/doc 里面有具体的环境变量名。模型返回内容带 Markdown 代码块。模型经常把代码包在里返回如果你要直接把结果写回 Buffer需要先清洗。写一个 stripCodeFence 函数匹配首尾的并去掉语言标识行。这个坑不报错但会让你的编辑器里多出反引号。Buffer 扩容后文本错乱。这是 Gap Buffer 实现里的高频 bug。扩容时重建数组如果position之后的内容搬移顺序错了文本就会乱。建议扩容逻辑单独写单元测试用abcdef在位置 3 插入长字符串触发扩容验证结果。Cursor 上下移动越界。从长行移到短行时列号超过目标行长度会抛异常。正确做法是 clamptargetColumn Math.min(currentColumn, targetLineLength)。这个逻辑要放在lineColumnToOffset调用之前。6. 语义一致的 CTA把编辑器内核接到 TaoToken 上继续扩展到这里你已经有了一个能跑通的最小编辑器内核Gap Buffer 管文本CursorModel 管坐标parseExpression 管 ASTchat 管模型调用。接下来最自然的扩展方向是让模型基于 AST 做更聪明的补全比如把当前光标所在的 AST 节点路径作为上下文让模型预测下一个标识符。如果你要继续打磨模型调用这一层建议从 API Keys 管理和接入文档入手。API Keys 页面 https://taotoken.net/api-keys 用来轮换和创建 Key接入文档 https://taotoken.net/doc 里有不同工具和语言的配置示例。想先直观感受模型返回质量可以去模型对话页面 https://taotoken.net/models 直接试几个 prompt确认 Model ID 和返回格式符合预期。如果你打算把这个编辑器做成长期使用的编码工具或者要接 Agent 做多轮代码修改Coding Plan 是更合适的方案地址 https://taotoken.net/coding-plan 。它面向的就是持续编码和 Agent 场景省去你反复管理单次调用的麻烦。最后留一个实操建议把模型调用做成可替换的接口chat函数只是其中一种实现。这样你后面想加缓存、加流式渲染、或者换成本地模型都只需要换一个实现类Buffer 和 Parser 完全不用动。编辑器内核的稳定性和模型层的灵活性分开是这个项目能持续演进的关键。
返回列表