☰
从零构建一个编辑器:Buffer、Cursor 与 AST 的工程实践与 TaoToken 接入
2026/10/7 20:01:03 网站建设 项目流程

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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_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。这里用环境变量管理 Key,Base 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): Promise<string> { 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_KEY=sk-你的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.ts:

import { 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, world,after 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 上下移动越界。从长行移到短行时,列号超过目标行长度会抛异常。正确做法是 clamp:targetColumn = Math.min(currentColumn, targetLineLength)。这个逻辑要放在lineColumnToOffset调用之前。

6. 语义一致的 CTA:把编辑器内核接到 TaoToken 上继续扩展

到这里,你已经有了一个能跑通的最小编辑器内核:Gap Buffer 管文本,CursorModel 管坐标,parseExpression 管 AST,chat 管模型调用。接下来最自然的扩展方向是让模型基于 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 完全不用动。编辑器内核的稳定性和模型层的灵活性分开,是这个项目能持续演进的关键。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询