From 68979546447658159728544dbacbff1533497af3 Mon Sep 17 00:00:00 2001 From: cruldra Date: Fri, 25 Sep 2026 03:19:42 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E8=A1=A8=E8=BE=BE=E5=BC=8F=E6=8C=89=E3=80=8C=E5=A6=82=E6=9E=9C?= =?UTF-8?q?=E2=80=A6=E5=88=99=E2=80=A6=E5=90=A6=E5=88=99=E2=80=A6=E3=80=8D?= =?UTF-8?q?=E7=BF=BB=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 17 ++- vscode/src/codeReader/translators/pythonZh.ts | 4 + .../codeReader/translators/typescriptZh.ts | 3 + .../codeReader/translators/zhTemplate.test.ts | 122 ++++++++++++++++-- .../src/codeReader/translators/zhTemplate.ts | 93 +++++++++++-- 5 files changed, 212 insertions(+), 27 deletions(-) diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 10a84aa..14c51ef 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -153,7 +153,7 @@ export interface CodeReaderApi { ## 中文模板直译 -结构照抄源码,只替换三类东西,其余(标识符、属性名、字面量、标点)原样保留并按词性着色: +结构照抄源码(按节点模板重排的语法结构除外,见下文),只替换三类东西,其余(标识符、属性名、字面量、标点)原样保留并按词性着色: 1. 关键字:只认语法树里的关键字节点(匿名节点),所以属性名 `i.number`、对象键不会被误翻。 2. 内置类型:只在类型位置翻(TS 的 `predefined_type`、Python 注解里的内置类型名)。 @@ -163,6 +163,13 @@ export interface CodeReaderApi { 关键字按所在语法节点选择译法:同一个关键字在不同语法结构里意思不同时,表里写一个默认译法,再加若干「语境 → 译法」。语境是从关键字节点往上的节点类型链,外层在前:`raise_statement` 指父节点是 raise_statement,`for_statement > else_clause` 指父节点是 else_clause、祖父节点是 for_statement(关键字包在一层子句节点里时要看到祖父节点)。几条语境同时匹配时取最长的,都不匹配用默认译法。节点类型以 tree-sitter 语法为准。下面两张表里写成「(语境)译法 / (语境)译法」的关键字就是按这条规则区分的,排第一个的是默认译法。 +节点模板:照抄源码结构读不通的语法结构,按语法节点类型给一个模板,把译文重排成中文语序。模板是一串项,每项要么是插入的译词,要么是子节点槽位:槽位按 tree-sitter 字段名引用子节点;语法没给字段名的,按具名子节点序号(0 起,注释不占序号)引用。命中模板的节点只输出模板里的项,节点自己的关键字和标点(`if` / `else`、`?` / `:`)不再输出;插入的译词一律按关键字(keyword)着色,模板各项之间空一格;槽位里的子节点照常递归翻译,里面的关键字、运算符、嵌套的同类结构照样处理。两种情况不重排、照常翻:模板引用的子节点不存在;节点没整个落在要翻的这一段里。目前的模板: + +| 语言 | 节点类型 | 模板 | +| --- | --- | --- | +| TypeScript | `ternary_expression`(`c ? a : b`) | 如果 〈condition〉 则 〈consequence〉 否则 〈alternative〉 | +| Python | `conditional_expression`(`a if c else b`,没有字段名,具名子节点依次是 a、c、b) | 如果 〈1 号〉 则 〈0 号〉 否则 〈2 号〉 | + TypeScript 关键字表(流程 = keyword,声明 = storage): | 流程 | 声明 / 字面量 | @@ -171,11 +178,11 @@ TypeScript 关键字表(流程 = keyword,声明 = storage): TypeScript 内置类型:string 字符串、number 数值、boolean 布尔、any 任意、unknown 未知、never 永不、void 无、object 对象、bigint 大整数、symbol 符号。`Promise` 等库类型不翻。 -Python 关键字:import 导入、from(导入)从 / (`raise … from`)源自 / (`yield from`)自、as(导入别名、`with … as`、`except … as`)作为、def 函数、async 异步、await 等待、class 类、return 返回、if 如果、elif 否则如果、else(`if` 后、条件表达式里)否则 / (`for` / `while` 后,循环没被 break 才执行)未中断时 / (`try` 后,没出错才执行)未出错时、for 遍历、in(比较里)在 / (for 头部与推导式里)取自、while 当、try 尝试、except 捕获、finally 最终、raise 抛出、with 使用、yield 产出、lambda 匿名函数、pass 跳过、break 跳出、continue 继续、global 全局、nonlocal 非局部、del 删除、assert 断言、not 非、and 与、or 或、is 是、not in 不在、is not 不是、None 空、True 真、False 假、match 匹配、case 情况。 +Python 关键字:import 导入、from(导入)从 / (`raise … from`)源自 / (`yield from`)自、as(导入别名、`with … as`、`except … as`)作为、def 函数、async 异步、await 等待、class 类、return 返回、if 如果、elif 否则如果、else(`if` 后)否则 / (`for` / `while` 后,循环没被 break 才执行)未中断时 / (`try` 后,没出错才执行)未出错时、for 遍历、in(比较里)在 / (for 头部与推导式里)取自、while 当、try 尝试、except 捕获、finally 最终、raise 抛出、with 使用、yield 产出、lambda 匿名函数、pass 跳过、break 跳出、continue 继续、global 全局、nonlocal 非局部、del 删除、assert 断言、not 非、and 与、or 或、is 是、not in 不在、is not 不是、None 空、True 真、False 假、match 匹配、case 情况。 Python 内置类型(仅注解里):str 字符串、int 整数、float 浮点数、bool 布尔、list 列表、dict 字典、set 集合、tuple 元组、bytes 字节串。 -样例(必须逐字一致,来自原型 `test.js`): +样例(必须逐字一致;条件表达式两行以外来自原型 `test.js`): | 源码 | 译文 | | --- | --- | @@ -189,6 +196,8 @@ Python 内置类型(仅注解里):str 字符串、int 整数、float 浮 | `const issue = issues.find(i => i.number === issueNumber)` | `常量 issue = issues.find(i => i.number 全等 issueNumber)` | | `return {⏎ locked: true,⏎ … }`(issueLock.ts 20–24 行) | `返回 { locked: 真, prerequisiteNumber: prereq.number, prerequisiteColumn: prereq.column }` | | `def find_env_files(root: str) -> list[str]:` | `函数 find_env_files(root: 字符串) -> 列表[字符串]` | +| `err instanceof Error ? err.message : String(err)` | `如果 err 属于 Error 则 err.message 否则 String(err)` | +| `_default_port = 5680 if _role == "beat" else 5679` | `_default_port = 如果 _role 等于 "beat" 则 5680 否则 5679` | ## 预览数据与消息 @@ -255,7 +264,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit ## 测试 - 语言插件:`envLock.ts`、`issueLock.ts` 的代码块树(种类、header 行号、层级)与原型 `CODE_TREE` 一致;Python 样例覆盖 import / def / docstring / if-elif-else / for / try-except / with / 推导式。 -- 翻译:上面样例表逐字一致;属性名不被翻;每个被翻的词单独成段。 +- 翻译:上面样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄。 - `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确。 - 行替换:多行块替换后缩进正确。 - 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中。 diff --git a/vscode/src/codeReader/translators/pythonZh.ts b/vscode/src/codeReader/translators/pythonZh.ts index 43f28f9..3d534af 100644 --- a/vscode/src/codeReader/translators/pythonZh.ts +++ b/vscode/src/codeReader/translators/pythonZh.ts @@ -82,4 +82,8 @@ export const pythonZhTranslator: Translator = createZhTemplateTranslator({ emptyLiterals: { list: '空列表', dictionary: '空字典' }, stringNodes: ['string'], commentText, + // `a if c else b` 的三个具名子节点依次是 a、c、b,语法没给它们起字段名 + nodeTemplates: { + conditional_expression: ['如果', { namedChild: 1 }, '则', { namedChild: 0 }, '否则', { namedChild: 2 }], + }, }) diff --git a/vscode/src/codeReader/translators/typescriptZh.ts b/vscode/src/codeReader/translators/typescriptZh.ts index f316057..779b2a2 100644 --- a/vscode/src/codeReader/translators/typescriptZh.ts +++ b/vscode/src/codeReader/translators/typescriptZh.ts @@ -88,4 +88,7 @@ export const typescriptZhTranslator: Translator = createZhTemplateTranslator({ emptyLiterals: { array: '空数组' }, stringNodes: ['string', 'template_string', 'regex'], commentText, + nodeTemplates: { + ternary_expression: ['如果', { field: 'condition' }, '则', { field: 'consequence' }, '否则', { field: 'alternative' }], + }, }) diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index 34fe5ab..7900fa4 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -1,4 +1,5 @@ import type { LanguageAdapter, Segment, Translator } from '../contract' +import type { ZhTable } from './zhTemplate' import { describe, expect, it } from 'vitest' import { createPythonAdapter } from '../languages/python' import { createTypeScriptAdapter } from '../languages/typescript' @@ -79,7 +80,7 @@ describe('typeScript 中文直译', () => { expect(env[32]).toBe('公共 接口 EnvLockResult') expect(env[42]).toBe('尝试') expect(env[46]).toBe('捕获 (err)') - expect(env[47]).toBe('result.failed.push({ path: f, reason: err 属于 Error ? err.message : String(err) })') + expect(env[47]).toBe('result.failed.push({ path: f, reason: 如果 err 属于 Error 则 err.message 否则 String(err) })') expect(env[1]).toBe('.env file lock / unlock helpers. Recursively scans the main workspace folder for `.env*` files (skipping `node_modules`, `.git`, `.claude`) and chmods them to 0o444 (locked) or 0o644 (unlocked) in bulk. Backs the toolbar Lock toggle in `PanelHeader`. No persistent watcher — re-scan happens on every toggle, so newly created `.env` files are only picked up the next time the user clicks the button.') expect(issue[1]).toBe('导入 类型 { Issue } 来自 \'../gitea/types\'') expect(issue[5]).toBe('prerequisiteNumber?: 数值') @@ -113,7 +114,7 @@ describe('typeScript 中文直译', () => { expect(map[1]).toBe('常量 o = { if: 1, return: i.number, string: x!.type, new: a.default }') expect(map[2]).toBe('变量 n = a 不全等 b 与 非 c 或 d 等于 e 不等于 f') // eslint-disable-next-line no-template-curly-in-string -- 模板字符串原样照抄进译文 - expect(map[3]).toBe('常量 s: 字符串 | 无 = 类型为 v 全等 \'string\' ? `${v}` : \'number\'') + expect(map[3]).toBe('常量 s: 字符串 | 无 = 如果 类型为 v 全等 \'string\' 则 `${v}` 否则 \'number\'') }) it('多行合成一行、去掉右括号前的尾逗号、行中注释不进译文', async () => { @@ -158,6 +159,28 @@ describe('typeScript 中文直译', () => { expect(map[11]).toBe('公共 默认 函数 g()') expect(map[13]).toBe('默认情况') }) + + it('三元表达式按「如果…则…否则…」重排,嵌套的、槽位里的关键字和运算符照样翻', async () => { + const source = [ + 'err instanceof Error ? err.message : String(err)', + 'z = x ? (y ? 1 : 2) : 3', + 'const w = a ? b : c ? d : e', + 'const v = a !== b && !c ? typeof d : await e', + ].join('\n') + const map = await translateAll(ts, 'typescript', typescriptZhTranslator, source) + expect(texts(map)).toEqual({ + 1: '如果 err 属于 Error 则 err.message 否则 String(err)', + 2: 'z = 如果 x 则 (如果 y 则 1 否则 2) 否则 3', + 3: '常量 w = 如果 a 则 b 否则 如果 c 则 d 否则 e', + 4: '常量 v = 如果 a 不全等 b 与 非 c 则 类型为 d 否则 等待 e', + }) + expect(map.get(1)?.segments.filter(segment => segment.role === 'keyword')).toEqual([ + { text: '如果', role: 'keyword' }, + { text: '则', role: 'keyword' }, + { text: '否则', role: 'keyword' }, + ]) + expectWordsApart(map) + }) }) describe('python 中文直译', () => { @@ -196,7 +219,7 @@ describe('python 中文直译', () => { 36: 'failed: 字典[字符串, 字符串] = 空字典', 37: 'label = \'env\'', 39: '函数 summary(self, verbose: 布尔 = 假) -> 字符串', - 40: '返回 f\'{self.total} files\' 如果 verbose 不是 空 否则 \'\'', + 40: '返回 如果 verbose 不是 空 则 f\'{self.total} files\' 否则 \'\'', }) expectWordsApart(map) }) @@ -250,19 +273,39 @@ describe('python 中文直译', () => { expect(map[10]).toBe('未中断时') expect(map[16]).toBe('未出错时') }) + + it('条件表达式按「如果…则…否则…」重排,嵌套的、槽位里的关键字和运算符照样翻', async () => { + const source = [ + '_default_port = 5680 if _role == "beat" else 5679', + 'y = a if x else b if y else c', + 'z = (a if x else b) if y else c', + 'v = [k for k in items if k not in seen] if not done and ok else None', + ].join('\n') + const map = await translateAll(py, 'python', pythonZhTranslator, source) + expect(texts(map)).toEqual({ + 1: '_default_port = 如果 _role 等于 "beat" 则 5680 否则 5679', + 2: 'y = 如果 x 则 a 否则 如果 y 则 b 否则 c', + 3: 'z = 如果 y 则 (如果 x 则 a 否则 b) 否则 c', + 4: 'v = 如果 非 done 与 ok 则 [k 遍历 k 取自 items 如果 k 不在 seen] 否则 空', + }) + expect(map.get(1)?.segments.filter(segment => segment.role === 'keyword' || segment.role === 'operator')).toEqual([ + { text: '如果', role: 'keyword' }, + { text: '等于', role: 'operator' }, + { text: '则', role: 'keyword' }, + { text: '否则', role: 'keyword' }, + ]) + expectWordsApart(map) + }) }) describe('中文模板引擎', () => { - it('关键字按父节点类型选译法,更长的节点类型链优先,都不匹配用默认', async () => { - const translator = createZhTemplateTranslator({ + function testTranslator(table: Partial): Translator { + return createZhTemplateTranslator({ id: 'test.zh-template', label: '测试', languageIds: ['python'], tokenRole: () => 'keyword', - keywords: { - in: { zh: '甲', byContext: { for_statement: '乙' } }, - else: { zh: '丙', byContext: { 'else_clause': '丁', 'for_statement > else_clause': '戊' } }, - }, + keywords: {}, literals: {}, builtinTypes: {}, isTypePosition: () => false, @@ -271,6 +314,17 @@ describe('中文模板引擎', () => { emptyLiterals: {}, stringNodes: [], commentText: node => node.text, + nodeTemplates: {}, + ...table, + }) + } + + it('关键字按父节点类型选译法,更长的节点类型链优先,都不匹配用默认', async () => { + const translator = testTranslator({ + keywords: { + in: { zh: '甲', byContext: { for_statement: '乙' } }, + else: { zh: '丙', byContext: { 'else_clause': '丁', 'for_statement > else_clause': '戊' } }, + }, }) const source = [ 'for k in items:', @@ -288,4 +342,54 @@ describe('中文模板引擎', () => { expect(map[5]).toBe('x = a if c 丙 b') expect(map[6]).toBe('戊') }) + + it('节点模板按节点类型重排:槽位按字段名或具名子节点序号取子节点并递归翻译,译词按关键字着色', async () => { + const translator = testTranslator({ + tokenRole: () => 'variable', + keywords: { in: '在', else: { zh: '丙', byContext: { else_clause: '丁' } } }, + operators: { '==': '等于' }, + operatorParents: ['comparison_operator'], + nodeTemplates: { + conditional_expression: ['若', { namedChild: 1 }, '则', { namedChild: 0 }, '不然', { namedChild: 2 }], + assignment: ['令', { field: 'left' }, '类型', { field: 'type' }, '为', { field: 'right' }], + }, + }) + const source = [ + 'n: int = a if k in seen else b', + 'print(a if c else b if d else e)', + 'w = (a # 注释不占序号', + ' if k == 1 else b)', + ].join('\n') + const map = await translateAll(py, 'python', translator, source) + expect(texts(map)).toEqual({ + 1: '令 n 类型 int 为 若 k 在 seen 则 a 不然 b', + 2: 'print(若 c 则 a 不然 若 d 则 b 不然 e)', + 3: 'w = (若 k 等于 1 则 a 不然 b)', + }) + expect(map.get(1)?.segments.filter(segment => segment.role === 'keyword').map(segment => segment.text)) + .toEqual(['令', '类型', '为', '若', '则', '不然']) + }) + + it('没命中模板的节点照抄源码结构;模板引用的子节点不存在时也不重排', async () => { + const translator = testTranslator({ + keywords: { in: '在', else: { zh: '丙', byContext: { else_clause: '丁' } } }, + nodeTemplates: { + assignment: ['令', { field: 'left' }, '类型', { field: 'type' }, '为', { field: 'right' }], + }, + }) + const source = [ + 'x = a if k in seen else b', + 'if k in seen:', + ' pass', + 'else:', + ' pass', + ].join('\n') + expect(texts(await translateAll(py, 'python', translator, source))).toEqual({ + 1: 'x = a if k 在 seen 丙 b', + 2: 'if k 在 seen', + 3: 'pass', + 4: '丁', + 5: 'pass', + }) + }) }) diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index b32b807..d4c969f 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -12,7 +12,13 @@ export interface ContextualWord { byContext: Record } -/** 一种语言的中文直译表。引擎只替换表里的三类东西,其余原样照抄。 */ +/** + * 节点模板的一项。字符串是插入的译词,按关键字着色;另外两种是子节点槽位: + * `{ field }` 按 tree-sitter 字段名取子节点,`{ namedChild }` 按具名子节点序号(0 起,不计注释)取,给语法没定义字段名的节点用。 + */ +export type TemplatePart = string | { field: string } | { namedChild: number } + +/** 一种语言的中文直译表。引擎替换表里的关键字、内置类型、运算符,按节点模板重排少数语法结构,其余原样照抄。 */ export interface ZhTable { id: string label: string @@ -35,16 +41,29 @@ export interface ZhTable { stringNodes: string[] /** 注释(或 docstring)节点去掉注释符号后的原文,可以多行。 */ commentText: (node: Node) => string + /** + * 节点模板:节点类型 → 按中文语序重排的译文。命中的节点只输出模板里的译词和槽位,节点自己的关键字、标点不再输出; + * 槽位里的子节点照常递归翻译。模板引用的子节点缺了,或节点没整个落在要翻的这一段里,就不重排、照常翻。 + */ + nodeTemplates: Record } interface Token { - start: number - end: number text: string role: TokenRole translated: boolean - /** 源码里的原文,用来认出逗号、括号。 */ + /** 源码里的原文,用来认出逗号、括号;模板插入的译词没有原文。 */ raw: string + /** 前面要空一格:源码里它前面有空白,或者模板在它前面插了别的项。 */ + gap: boolean +} + +/** 按译文顺序收集词:cursor 是上一个词在源码里的结尾,gap 是下一个词前面要不要空一格。 */ +interface Collector { + source: string + tokens: Token[] + cursor: number + gap: boolean } const CONTEXT_SEPARATOR = ' > ' @@ -77,6 +96,13 @@ function oneLine(text: string): string { return text.replace(/\s*\n\s*/g, ' ') } +/** 模板槽位引用的子节点;注释是语法之外的节点,不占具名子节点序号。 */ +function slotChild(node: Node, slot: Exclude): Node | null | undefined { + if ('field' in slot) + return node.childForFieldName(slot.field) + return node.namedChildren.filter(child => child && !child.isExtra)[slot.namedChild] +} + export function createZhTemplateTranslator(table: ZhTable): Translator { const operatorParents = new Set(table.operatorParents) const stringNodes = new Set(table.stringNodes) @@ -120,22 +146,62 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return undefined } - function collect(node: Node, span: SourceSpan, out: Token[]): void { + /** 节点命中模板时,模板各项换成译词或子节点;不该重排时返回 undefined。 */ + function templateParts(node: Node, span: SourceSpan): (string | Node)[] | undefined { + const template = table.nodeTemplates[node.type] + if (!template || node.startIndex < span.startIndex || node.endIndex > span.endIndex) + return undefined + const parts = template.map(part => typeof part === 'string' ? part : slotChild(node, part)) + return parts.every((part): part is string | Node => part != null) ? parts : undefined + } + + /** 源码里上一个词到 index 之间有空白,下一个词前面就空一格。 */ + function advance(out: Collector, index: number): void { + out.gap ||= /\s/.test(out.source.slice(out.cursor, index)) + out.cursor = index + } + + function emit(out: Collector, token: Omit): void { + out.tokens.push({ ...token, gap: out.gap }) + out.gap = false + } + + function emitSource(out: Collector, node: Node, token: Omit): void { + advance(out, node.startIndex) + emit(out, { ...token, raw: node.text }) + out.cursor = node.endIndex + } + + function collect(node: Node, span: SourceSpan, out: Collector): void { if (node.endIndex <= span.startIndex || node.startIndex >= span.endIndex || node.startIndex === node.endIndex) return - const base = { start: node.startIndex, end: node.endIndex } if (node.type === 'comment') { - out.push({ ...base, raw: node.text, text: '', role: 'comment', translated: false }) + emitSource(out, node, { text: '', role: 'comment', translated: false }) + return + } + const parts = templateParts(node, span) + if (parts) { + advance(out, node.startIndex) + parts.forEach((part, i) => { + out.gap ||= i > 0 + if (typeof part === 'string') { + emit(out, { text: part, role: 'keyword', translated: true, raw: '' }) + return + } + out.cursor = part.startIndex + collect(part, span, out) + }) + out.cursor = node.endIndex return } const translated = translateNode(node) if (translated) { - out.push({ ...base, raw: node.text, ...translated, translated: true }) + emitSource(out, node, { ...translated, translated: true }) return } if (node.childCount === 0 || stringNodes.has(node.type)) { const role = stringNodes.has(node.type) ? 'string' : table.tokenRole(node) - out.push({ ...base, raw: node.text, text: oneLine(node.text), role, translated: false }) + emitSource(out, node, { text: oneLine(node.text), role, translated: false }) return } for (const child of node.children) { @@ -155,8 +221,9 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { function translate(block: CodeBlock, source: string): Segment[] { if (block.kind === 'comment') return translateComment(block) - const tokens: Token[] = [] - collect(block.node, block.header, tokens) + const out: Collector = { source, tokens: [], cursor: block.header.startIndex, gap: false } + collect(block.node, block.header, out) + const { tokens } = out const compound = block.children.length > 0 || block.header.endIndex < block.node.endIndex const last = tokens.at(-1) if (compound && last && !last.translated && HEADER_OPENERS.has(last.raw)) @@ -171,10 +238,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { segments.push({ text, role }) } let gap = false - let prevEnd = block.header.startIndex tokens.forEach((token, i) => { - gap ||= /\s/.test(source.slice(prevEnd, token.start)) - prevEnd = token.end + gap ||= token.gap const trailingComma = token.raw === ',' && !token.translated && CLOSING.has(nextRaw(tokens, i)) if (token.text === '' || trailingComma) { gap ||= token.role === 'comment'