diff --git a/vscode/src/codeReader/attributes.test.ts b/vscode/src/codeReader/attributes.test.ts index bafafe4..b3d089f 100644 --- a/vscode/src/codeReader/attributes.test.ts +++ b/vscode/src/codeReader/attributes.test.ts @@ -108,7 +108,7 @@ describe('块的筛选属性:宿主从 kind 和 facts 推出通用属性', () }) function view(id: string, attributes: Record, children: BlockView[] = []): BlockView { - return { id, anchor: id, kind: 'statement', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes, concepts: [{ id: 'statement', label: '语句', icon: 'code' }], children, translation: [] } + return { id, anchor: id, kind: 'statement', startLine: 0, endLine: 0, headerEndLine: 0, attributes, concepts: [{ id: 'statement', label: '语句', icon: 'code' }], children, translation: [] } } describe('文件里出现过的属性', () => { diff --git a/vscode/src/codeReader/blockView.test.ts b/vscode/src/codeReader/blockView.test.ts index be20e8d..e996169 100644 --- a/vscode/src/codeReader/blockView.test.ts +++ b/vscode/src/codeReader/blockView.test.ts @@ -1,13 +1,13 @@ import type { BlockView } from './blockView' -import type { LanguageAdapter, LibraryRule, Translator } from './contract' +import type { LanguageAdapter, LibraryRule, Segment, Translator } from './contract' import { describe, expect, it } from 'vitest' -import { kindAt, readBlockViews, readNamedKinds, toBlockViews } from './blockView' +import { blockCodes, kindAt, readBlockViews, readNamedKinds, toBlockViews } from './blockView' import { replaceLines } from './editText' import { createBashAdapter } from './languages/bash' import { createDockerfileAdapter } from './languages/dockerfile' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' -import { commentText, DOCKERFILE_WASM_DIR, previewText, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from './testSupport' +import { commentText, DOCKERFILE_WASM_DIR, lineColors, previewText, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from './testSupport' import { bashZhTranslator } from './translators/bashZh' import { dockerfileZhTranslator } from './translators/dockerfileZh' import { pythonZhTranslator } from './translators/pythonZh' @@ -65,14 +65,18 @@ describe('blockView', () => { ]) }) - it.each(SAMPLES)('%#: codeTokens 拼起来等于 code,code 是编辑范围整行去共同缩进', async (adapter, languageId, translator, fixture) => { + it.each(SAMPLES)('%#: 块的源码是编辑范围整行去共同缩进;整份源码的着色一段段接起来正好盖住源码', async (adapter, languageId, translator, fixture) => { const source = readFixture(fixture) const lines = source.split('\n') - for (const view of flatten(await views(adapter, languageId, translator, source))) { - expect(view.codeTokens.map(token => token.text).join(''), view.id).toBe(view.code) + const code = blockCodes(source) + const { blocks, colors } = await readBlockViews(runtime, adapter, translator, languageId, source) + expect(colors.runs.filter((_, index) => index % 2 === 0).reduce((sum, length) => sum + length, 0)).toBe(source.length) + expect(colors.runs.filter((_, index) => index % 2 === 1).every(role => role < colors.roles.length)).toBe(true) + for (const view of flatten(blocks)) { const rows = lines.slice(view.startLine, view.endLine + 1) const indent = Math.min(...rows.filter(row => row.trim()).map(row => row.length - row.trimStart().length)) - expect(view.code, view.id).toBe(rows.map(row => row.slice(indent)).join('\n')) + expect(code(view), view.id).toBe(rows.map(row => row.slice(indent)).join('\n')) + expect(lineColors(source, colors, view.startLine, view.endLine).map(token => token.text).join(''), view.id).toBe(code(view)) if ('properties' in view) expect(view.properties.length, view.id).toBeGreaterThan(0) else @@ -90,6 +94,7 @@ describe('blockView', () => { it.each(SAMPLES)('%#: 编辑范围是块的完整范围:盖住各子孙块,兄弟块之间最多共用一行,首尾不是空行,原样写回不变;头部是开头那几行', async (adapter, languageId, translator, fixture) => { const source = readFixture(fixture) const lines = source.split('\n') + const code = blockCodes(source) const visit = (list: BlockView[], parent: BlockView | undefined): void => { list.forEach((view, i) => { expect(lines[view.startLine].trim(), view.id).not.toBe('') @@ -103,7 +108,7 @@ describe('blockView', () => { const next = list[i + 1] if (next) expect(view.endLine, `${view.id} / ${next.id}`).toBeLessThanOrEqual(next.startLine) - expect(replaceLines(source, view.startLine, view.endLine, view.code), view.id).toBe(source) + expect(replaceLines(source, view.startLine, view.endLine, code(view)), view.id).toBe(source) visit(view.children, view) }) } @@ -124,15 +129,19 @@ describe('blockView', () => { ].join('\n') const list = await views(ts, 'typescriptreact', typescriptZhTranslator, source) const bar = byId(list, '0') - expect(bar).toMatchObject({ kind: 'function', startLine: 0, headerEndLine: 2, endLine: 7, code: source.trimEnd() }) - expect(byId(list, '0.0')).toMatchObject({ kind: 'if', startLine: 3, headerEndLine: 3, endLine: 5, code: 'if (platform === "macos") {\n return null\n}' }) - expect(byId(list, '0.0.0')).toMatchObject({ kind: 'return', startLine: 4, endLine: 4, code: 'return null' }) - expect(replaceLines(source, bar.startLine, bar.endLine, bar.code.replace('return null', 'return '))).toBe(source.replace('return null', 'return ')) + const code = blockCodes(source) + expect(bar).toMatchObject({ kind: 'function', startLine: 0, headerEndLine: 2, endLine: 7 }) + expect(code(bar)).toBe(source.trimEnd()) + expect(byId(list, '0.0')).toMatchObject({ kind: 'if', startLine: 3, headerEndLine: 3, endLine: 5 }) + expect(code(byId(list, '0.0'))).toBe('if (platform === "macos") {\n return null\n}') + expect(byId(list, '0.0.0')).toMatchObject({ kind: 'return', startLine: 4, endLine: 4 }) + expect(code(byId(list, '0.0.0'))).toBe('return null') + expect(replaceLines(source, bar.startLine, bar.endLine, code(bar).replace('return null', 'return '))).toBe(source.replace('return null', 'return ')) }) it('展平成兄弟块的 else、elif、catch:各到下一块开头之前,最后一块到整条语句结尾(Shell 的 fi 归它);同一行写的 `} else {` 两块都有', async () => { const code = async (adapter: LanguageAdapter, languageId: string, source: string): Promise<[string, number, number, string][]> => - (await views(adapter, languageId, undefined, source)).map(view => [view.kind, view.startLine, view.endLine, view.code]) + (await views(adapter, languageId, undefined, source)).map(view => [view.kind, view.startLine, view.endLine, blockCodes(source)(view)]) expect(await code(ts, 'typescript', 'if (a) {\n x()\n} else if (b) {\n y()\n}\nelse {\n z()\n}\ntry {\n x()\n}\ncatch {\n y()\n}\n')).toEqual([ ['if', 0, 2, 'if (a) {\n x()\n} else if (b) {'], @@ -156,7 +165,7 @@ describe('blockView', () => { it('函数的说明算在函数块的头部里:编辑时和签名一起显示、一起改', async () => { const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) expect(byId(env, '5')).toMatchObject({ kind: 'function', startLine: 17, headerEndLine: 22, endLine: 29 }) - expect(byId(env, '5').code.split('\n').slice(0, 6)).toEqual([ + expect(blockCodes(readFixture('envLock.ts.txt'))(byId(env, '5')).split('\n').slice(0, 6)).toEqual([ '/**', ' * Scan the workspace folder rooted at `workspaceRoot` for all `.env*`', ' * files, skipping `node_modules`, `.git`, and `.claude`. Returns absolute', @@ -169,7 +178,7 @@ describe('blockView', () => { const routes = await views(py, 'python', pythonZhTranslator, readFixture('routes.py')) const getUser = flatten(routes).find(view => view.kind === 'function' && view.startLine === 28) expect(getUser).toMatchObject({ startLine: 28, headerEndLine: 37 }) - expect(getUser?.code.split('\n').slice(5, 10)).toEqual([ + expect(blockCodes(readFixture('routes.py'))(getUser!).split('\n').slice(5, 10)).toEqual([ ') -> UserDetailResponse:', ' """获取用户详情。', '', @@ -183,7 +192,7 @@ describe('blockView', () => { it('类的说明同样算在类块的头部里:JSDoc 连着中间的装饰器,docstring 连着它前面的注释', async () => { const repo = byId(await views(ts, 'typescript', typescriptZhTranslator, readFixture('classes.ts.txt')), '1') expect(repo).toMatchObject({ kind: 'class', startLine: 2, headerEndLine: 8 }) - expect(repo.code.split('\n').slice(4, 7)).toEqual([ + expect(blockCodes(readFixture('classes.ts.txt'))(repo).split('\n').slice(4, 7)).toEqual([ ' */', '@Injectable()', 'export abstract class Repository extends Base implements Loader, Disposable {', @@ -192,7 +201,7 @@ describe('blockView', () => { const notFound = byId(await views(py, 'python', pythonZhTranslator, readFixture('classes.py')), '3') expect(notFound).toMatchObject({ kind: 'class', startLine: 17, headerEndLine: 19 }) - expect(notFound.code.split('\n').slice(0, 3)).toEqual([ + expect(blockCodes(readFixture('classes.py'))(notFound).split('\n').slice(0, 3)).toEqual([ 'class NotFound(AppResponse): # 行尾', ' # 说明前面的注释', ' """找不到资源。"""', @@ -237,11 +246,12 @@ describe('blockView', () => { { id: '1.0', kind: 'variable', lines: [5, 7], rows: ['名称: ANALYZING', '说明: 正在分析源 deployment.', '访问级别: 公共', '初始值: "analyzing"'] }, { id: '1.1', kind: 'field', lines: [8, 10], translation: 'title: 字符串 = ""', doc: { markdown: '案例标题,\n用户可编辑.' } }, ]) - expect(byId(list, '0').code).toBe(source.split('\n').slice(0, 2).join('\n')) - expect(byId(list, '1.0').code).toBe('ANALYZING = "analyzing"\n\n"""正在分析源 deployment."""') - expect(byId(list, '1.1').code).toBe('title: str = ""\n"""案例标题,\n用户可编辑."""') + const code = blockCodes(source) + expect(code(byId(list, '0'))).toBe(source.split('\n').slice(0, 2).join('\n')) + expect(code(byId(list, '1.0'))).toBe('ANALYZING = "analyzing"\n\n"""正在分析源 deployment."""') + expect(code(byId(list, '1.1'))).toBe('title: str = ""\n"""案例标题,\n用户可编辑."""') for (const view of flatten(list)) - expect(replaceLines(source, view.startLine, view.endLine, view.code), view.id).toBe(source) + expect(replaceLines(source, view.startLine, view.endLine, code(view)), view.id).toBe(source) expect(replaceLines(source, 8, 10, 'title: str = "x"\n"""新说明"""').split('\n').slice(8)).toEqual([' title: str = "x"', ' """新说明"""']) }) @@ -253,29 +263,31 @@ describe('blockView', () => { }) it('id 是块在树里的位置,行号 0 起,多行块去掉共同缩进', async () => { - const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) - expect(byId(env, '5.1.0')).toMatchObject({ kind: 'return', startLine: 25, endLine: 25, code: 'return []' }) - expect(byId(env, '5.1')).toMatchObject({ kind: 'if', startLine: 24, endLine: 25, code: 'if (!folder)\n return []' }) - expect(byId(env, '7.2.1')).toMatchObject({ - kind: 'catch', - startLine: 45, - endLine: 47, - code: 'catch (err) {\n result.failed.push({ path: f, reason: err instanceof Error ? err.message : String(err) })\n}', - }) + const envSource = readFixture('envLock.ts.txt') + const env = await views(ts, 'typescript', typescriptZhTranslator, envSource) + const envCode = blockCodes(envSource) + expect(byId(env, '5.1.0')).toMatchObject({ kind: 'return', startLine: 25, endLine: 25 }) + expect(envCode(byId(env, '5.1.0'))).toBe('return []') + expect(byId(env, '5.1')).toMatchObject({ kind: 'if', startLine: 24, endLine: 25 }) + expect(envCode(byId(env, '5.1'))).toBe('if (!folder)\n return []') + expect(byId(env, '7.2.1')).toMatchObject({ kind: 'catch', startLine: 45, endLine: 47 }) + expect(envCode(byId(env, '7.2.1'))).toBe('catch (err) {\n result.failed.push({ path: f, reason: err instanceof Error ? err.message : String(err) })\n}') - const issue = await views(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt')) - expect(byId(issue, '2.4')).toMatchObject({ - kind: 'return', - startLine: 19, - endLine: 23, - code: 'return {\n locked: true,\n prerequisiteNumber: prereq.number,\n prerequisiteColumn: prereq.column,\n}', - }) + const issueSource = readFixture('issueLock.ts.txt') + const issue = await views(ts, 'typescript', typescriptZhTranslator, issueSource) + expect(byId(issue, '2.4')).toMatchObject({ kind: 'return', startLine: 19, endLine: 23 }) + expect(blockCodes(issueSource)(byId(issue, '2.4'))).toBe('return {\n locked: true,\n prerequisiteNumber: prereq.number,\n prerequisiteColumn: prereq.column,\n}') }) - it('源码着色来自语法树:每个叶子一段,空白和标点归 plain', async () => { - const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) - const tokens = byId(env, '5.3').codeTokens - expect(byId(env, '5.3').code).toBe('const uris = await workspace.findFiles(include, EXCLUDE_PATTERN)') + it('源码着色来自语法树:每个叶子按词性着色,相邻同词性的连成一段,空白和标点归 plain', async () => { + const colored = async (adapter: LanguageAdapter, languageId: string, fixture: string, id: string): Promise => { + const source = readFixture(fixture) + const { blocks, colors } = await readBlockViews(runtime, adapter, undefined, languageId, source) + const view = byId(blocks, id) + return lineColors(source, colors, view.startLine, view.endLine) + } + const tokens = await colored(ts, 'typescript', 'envLock.ts.txt', '5.3') + expect(tokens.map(token => token.text).join('')).toBe('const uris = await workspace.findFiles(include, EXCLUDE_PATTERN)') expect(tokens.slice(0, 7)).toEqual([ { text: 'const', role: 'storage' }, { text: ' ', role: 'plain' }, @@ -286,11 +298,11 @@ describe('blockView', () => { { text: 'workspace', role: 'variable' }, ]) expect(tokens).toContainEqual({ text: 'findFiles', role: 'function' }) - expect(byId(env, '0').codeTokens).toEqual([{ text: byId(env, '0').code, role: 'comment' }]) + const comment = await colored(ts, 'typescript', 'envLock.ts.txt', '0') + expect(comment).toEqual([{ text: comment.map(token => token.text).join(''), role: 'comment' }]) // 中文注释之后的行:语法树下标按字符串下标算,不按 UTF-8 字节 - const issue = await views(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt')) - expect(byId(issue, '2').codeTokens.slice(0, 7)).toEqual([ + expect((await colored(ts, 'typescript', 'issueLock.ts.txt', '2')).slice(0, 7)).toEqual([ { text: '/**\n * 纯判定:工单被未完成的前置挡住才算锁定。快照里找不到工单或前置一律放行,\n * 与 webview 的 `isIssueLocked` 保持同一语义。\n */', role: 'comment' }, { text: '\n', role: 'plain' }, { text: 'export', role: 'keyword' }, @@ -300,8 +312,7 @@ describe('blockView', () => { { text: 'evaluateLockedReason', role: 'function' }, ]) - const sample = await views(py, 'python', pythonZhTranslator, readFixture('sample.py')) - const signature = byId(sample, '5').codeTokens + const signature = await colored(py, 'python', 'sample.py', '5') expect(signature).toContainEqual({ text: 'def', role: 'storage' }) expect(signature).toContainEqual({ text: 'find_env_files', role: 'function' }) // 签名里注解的 str 是类型,函数体里调用的 str(err) 是函数 @@ -317,12 +328,13 @@ describe('blockView', () => { expect(own(tsSource, await views(ts, 'typescript', typescriptZhTranslator, tsSource))).toEqual({ 0: undefined, 1: '{ u, v }', 2: 'h', 3: 'A', 4: 'B', 5: undefined }) }) - it('\\r\\n 和制表符缩进:code 用 \\n 连接,去掉共同的制表符', async () => { + it('\\r\\n 和制表符缩进:块的源码用 \\n 连接,去掉共同的制表符', async () => { const source = 'function f() {\r\n\tif (a &&\r\n\t\tb) {\r\n\t\treturn 1\r\n\t}\r\n}\r\n' - const list = await views(ts, 'typescript', undefined, source) + const { blocks: list, colors } = await readBlockViews(runtime, ts, undefined, 'typescript', source) const cond = byId(list, '0.0') - expect(cond).toMatchObject({ kind: 'if', startLine: 1, headerEndLine: 2, endLine: 4, code: 'if (a &&\n\tb) {\n\treturn 1\n}', translation: [] }) - expect(cond.codeTokens.map(token => token.text).join('')).toBe(cond.code) + expect(cond).toMatchObject({ kind: 'if', startLine: 1, headerEndLine: 2, endLine: 4, translation: [] }) + expect(blockCodes(source)(cond)).toBe('if (a &&\n\tb) {\n\treturn 1\n}') + expect(lineColors(source, colors, cond.startLine, cond.endLine).map(token => token.text).join('')).toBe(blockCodes(source)(cond)) expect(byId(list, '0')).toMatchObject({ kind: 'function', properties: [] }) }) @@ -432,7 +444,7 @@ describe('卡片上的「注释」一行', () => { '应用最早被 import 的域之一 (见 api_routers.py 的 include 顺序), 顶层导入', '会与该级联互相等待, 构成循环导入。', ].join('\n')) - expect(repo?.code.split('\n').slice(0, 7)).toEqual([ + expect(blockCodes(source)(repo!).split('\n').slice(0, 7)).toEqual([ '# luffy_agent.user.models.User 在各方法体内按需局部导入 (非模块顶层):', '# user 包 __init__ 会级联加载到 acquisition.service, 而 acquisition 是整个', '# 应用最早被 import 的域之一 (见 api_routers.py 的 include 顺序), 顶层导入', @@ -441,7 +453,7 @@ describe('卡片上的「注释」一行', () => { '', 'class AcquisitionChannelRepository(', ]) - expect(replaceLines(source, repo!.startLine, repo!.endLine, repo!.code)).toBe(source) + expect(replaceLines(source, repo!.startLine, repo!.endLine, blockCodes(source)(repo!))).toBe(source) }) it('说明和注释都有时排成 名称、说明、注释;几段注释是几段 Markdown;一行译文的卡片「注释」一行排在译文下面', async () => { diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 06da02b..190ff90 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,5 +1,5 @@ import type { Tree } from '@vscode/tree-sitter-wasm' -import type { BlockAttributes, BlockConcept, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, HttpEndpoint, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, NameBinding, Node, PreviewTable, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract' +import type { BlockAttributes, BlockConcept, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, HttpEndpoint, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, NameBinding, Node, PreviewTable, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract' import type { TreeSitterRuntime } from './runtime' import type { ImportedName } from './symbolLinks' import { blockAttributes } from './attributes' @@ -25,10 +25,6 @@ interface BlockViewBase { endLine: number /** 块自己那部分(头部)的最后一行:复合语句到主体开始之前,库规则认领的块是整条语句。问 AI 时贴外层块的开头用。 */ headerEndLine: number - /** 编辑范围的整行源码,去掉共同缩进。 */ - code: string - /** 源码着色(拼起来等于 code)。 */ - codeTokens: Segment[] /** 块自己的名字(见 ownName)在源码里的范围:跳到的定义就是它时闪整块,不闪属性表里的某一行。 */ defines?: SourceRange /** 筛选属性(含 kind),见 blockAttributes;库规则认领的块叠上规则给的,kind 是规则的种类 id。 */ @@ -77,17 +73,26 @@ export type CommentTarget */ export type BlockView = BlockViewBase & ({ translation: PreviewText, items?: PropertyItem[], table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] }) -interface Line { start: number, end: number } +/** + * 整份源码的着色,发给页面:页面编辑框给块的源码上色时按行取,父块、子块共用这一份。 + * 按源码先后一段段排,相邻同词性的连成一段;runs 两个数一组:这段有几个字符(UTF-16,换行符也算),词性是 roles 里的第几个。 + */ +export interface SourceColors { + roles: TokenRole[] + runs: number[] +} -/** 每行在源码里的起止下标(end 不含换行符)。 */ -function lineRanges(source: string): Line[] { - const lines: Line[] = [] - let start = 0 - for (const text of splitLines(source)) { - lines.push({ start, end: start + text.length }) - start += text.length + (source[start + text.length] === '\r' ? 2 : 1) +/** + * 一份源码里各块的编辑范围(startLine..endLine 整行)去掉共同缩进的样子,同页面编辑框里显示的(写回见 editText.reindentBlock): + * 源码按行切一次,各块按行取。 + */ +export function blockCodes(source: string): (block: Pick) => string { + const texts = splitLines(source) + return ({ startLine, endLine }) => { + const rows = texts.slice(startLine, endLine + 1) + const indent = commonIndent(rows).length + return rows.map(row => row.slice(Math.min(indent, row.length))).join('\n') } - return lines } function leavesIn(node: Node, from: number, to: number, out: Node[]): void { @@ -130,27 +135,33 @@ function coloring(layers: readonly ColorLayer[], source: string): Coloring { return { owner, roles } } -/** 按语法树叶子给 [startRow, endRow] 这几行着色:每个有词性的叶子一段,空白、标点等 plain 的连成一段。 */ -function colorLines({ owner, roles }: Coloring, source: string, lines: Line[], startRow: number, endRow: number, indent: number): Segment[] { - const segments: Segment[] = [] - let current = -2 - const put = (char: string, who: number): void => { - const prev = segments.at(-1) +/** 各着色层铺好后整份源码的着色(见 SourceColors):每个字符取它所在叶子的词性,不归哪个叶子的空白、标点是 plain。 */ +function sourceColors({ owner, roles }: Coloring): SourceColors { + const colors: SourceColors = { roles: [], runs: [] } + const order = new Map() + let current: TokenRole | undefined + let length = 0 + const close = (): void => { + if (current === undefined) + return + let index = order.get(current) + if (index === undefined) { + index = colors.roles.push(current) - 1 + order.set(current, index) + } + colors.runs.push(length, index) + } + for (const who of owner) { const role = who < 0 ? 'plain' : roles[who] - if (prev && (who === current || (role === 'plain' && prev.role === 'plain'))) - prev.text += char - else - segments.push({ text: char, role }) - current = who + if (role !== current) { + close() + current = role + length = 0 + } + length++ } - for (let row = startRow; row <= endRow; row++) { - const line = lines[row] - if (row > startRow) - put('\n', owner[line.start - 1]) - for (let at = line.start + Math.min(indent, line.end - line.start); at < line.end; at++) - put(source[at], owner[at]) - } - return segments + close() + return colors } /** @@ -291,7 +302,7 @@ function withCommentRow(rows: readonly PropertyRow[], row: PropertyRow): Propert } /** - * 代码块树 → 视图数据:块的完整范围整行源码去共同缩进、按语法树着色,调用翻译插件。 + * 代码块树 → 视图数据:各块的编辑范围、翻译插件给的译文;块的源码、着色不按块带,页面从整份源码里按编辑范围取(见 SourceColors)。 * 每块先问启用的库规则,认领了就换成规则给的替身(见 RuleBlock):规则的卡片,或认成的属性表块(交给翻译插件排,同语言插件报的这种块), * 并进去的子块不再单独成块。讲块的注释(BlockShape.comments)排成卡片上的「注释」一行(见 withCommentRow)。 */ @@ -303,24 +314,9 @@ export function toBlockViews( embedded: EmbeddedParts = new Map(), ): BlockView[] { const { source } = document - const lines = lineRanges(source) const texts = splitLines(source) - const outer = blocks[0]?.node.tree.rootNode - const colors = coloring([ - ...(outer ? [{ root: outer, tokenRole: document.language.tokenRole }] : []), - ...[...embedded.values()].map(part => ({ root: part.root, tokenRole: part.document.language.tokenRole })), - ], source) - /** 一段占的整行源码去共同缩进,和它的着色(嵌着的代码按它那种语言着色)。 */ - const sourceRows = ({ startRow, endRow }: SourceSpan): Pick => { - const rows = texts.slice(startRow, endRow + 1) - const indent = commonIndent(rows).length - return { - startLine: startRow, - endLine: endRow, - code: rows.map(row => row.slice(Math.min(indent, row.length))).join('\n'), - codeTokens: colorLines(colors, source, lines, startRow, endRow, indent), - } - } + /** 一段占的行:块的编辑范围 */ + const spanLines = ({ startRow, endRow }: SourceSpan): Pick => ({ startLine: startRow, endLine: endRow }) /** 一段占的整行源码:没名字的块按它认(见 BlockAnchors.place)。 */ const rowsText = ({ startRow, endRow }: SourceSpan): string => texts.slice(startRow, endRow + 1).join('\n') /** 按源码先后逐块给位置记号:先序遍历,块的记号在它的子块之前要 */ @@ -437,7 +433,7 @@ export function toBlockViews( id, anchor, kind: block.kind, - ...sourceRows(span), + ...spanLines(span), headerEndLine: block.header.endRow, ...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }), ...(name && (block.kind === 'function' || block.kind === 'class') && { callTree: {} }), @@ -474,7 +470,7 @@ export function toBlockViews( id, anchor, kind, - ...sourceRows(span), + ...spanLines(span), headerEndLine: statement.endRow, ...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }), ...(defines && endpoint && { callTree: { endpoint } }), @@ -512,6 +508,8 @@ export function toBlockViews( */ export interface DocumentViews { blocks: BlockView[] + /** 整份源码的着色(嵌着的代码按它那种语言着色) */ + colors: SourceColors displayNames: DisplayName[] symbols: LocalSymbol[] imports: ImportedName[] @@ -585,12 +583,16 @@ export async function readBlockViews( } const codeBlocks = adapter.toBlocks(tree.rootNode, source, languageId) const blocks = toBlockViews(codeBlocks, document, translator, extras.rules, parts) + const colors = sourceColors(coloring([ + { root: tree.rootNode, tokenRole: adapter.tokenRole }, + ...[...parts.values()].map(part => ({ root: part.root, tokenRole: part.document.language.tokenRole })), + ], source)) const symbols = [ ...adapter.symbols?.(tree.rootNode, source, languageId) ?? [], ...[...parts.values()].flatMap(part => part.document.language.symbols?.(part.root, source, part.code.languageId) ?? []), ] const bindings = adapter.bindings?.(tree.rootNode, source, languageId) ?? [] - return { blocks, displayNames: names, symbols, imports: importedNames(importsIn(codeBlocks)), kinds: namedKinds(codeBlocks), bindings } + return { blocks, colors, displayNames: names, symbols, imports: importedNames(importsIn(codeBlocks)), kinds: namedKinds(codeBlocks), bindings } } finally { for (const parsed of trees) diff --git a/vscode/src/codeReader/diff/blockDiff.test.ts b/vscode/src/codeReader/diff/blockDiff.test.ts index 9d99ebd..4ae486f 100644 --- a/vscode/src/codeReader/diff/blockDiff.test.ts +++ b/vscode/src/codeReader/diff/blockDiff.test.ts @@ -5,7 +5,7 @@ import { readBlockViews } from '../blockView' import { createPythonAdapter } from '../languages/python' import { runtime, WASM_DIR } from '../testSupport' import { pythonZhTranslator } from '../translators/pythonZh' -import { alignSiblings, diffBlockTrees, similarity } from './blockDiff' +import { alignSiblings, diffBlockTrees, similarity, sourced } from './blockDiff' const py = createPythonAdapter(WASM_DIR) @@ -14,17 +14,28 @@ async function python(source: string): Promise { return { blocks, source } } -/** 手写的一块:只填比对用得到的几样。 */ -function view(anchor: string, kind: string, code: string, extra: Partial = {}): BlockView { +/** 手写的几块拼成的源码:各块的代码、名字依次另起一行接上去。 */ +interface Written { text: string } + +/** text 另起一行接到源码后面:它占的行和起始下标。 */ +function append(source: Written, text: string): { startLine: number, endLine: number, startIndex: number } { + const startLine = source.text === '' ? 0 : source.text.split('\n').length + source.text += source.text === '' ? '' : '\n' + const startIndex = source.text.length + source.text += text + return { startLine, endLine: startLine + text.split('\n').length - 1, startIndex } +} + +/** 手写的一块:只填比对用得到的几样,代码接在源码后面,编辑范围是它占的行。 */ +function view(anchor: string, kind: string, code: string, source: Written, extra: Partial = {}): BlockView { + const { startLine, endLine } = append(source, code) return { id: anchor, anchor, kind, - startLine: 0, - endLine: 0, - headerEndLine: 0, - code, - codeTokens: [], + startLine, + endLine, + headerEndLine: startLine, attributes: { kind }, children: [], translation: [{ text: code, role: 'plain' }], @@ -32,11 +43,10 @@ function view(anchor: string, kind: string, code: string, extra: Partial { const source = { text: '' } const before = [named('f', 'function', 'f', 'def f(): pass', source), named('g', 'function', 'g', 'def g(): pass', source)] const after = [named('g2', 'function', 'g', 'def g(): return 1', source), named('h', 'function', 'h', 'def f(): pass', source)] - const { toOld, toNew } = alignSiblings(before, source.text, after, source.text) + const { toOld, toNew } = alignSiblings(before, sourced(source.text), after, sourced(source.text)) expect(toOld).toEqual([1, undefined]) expect(toNew).toEqual([undefined, 0]) }) @@ -136,38 +146,40 @@ describe('alignSiblings', () => { const source = { text: '' } const before = [named('a', 'function', 'f', 'one', source), named('b', 'function', 'f', 'two', source)] const after = [named('c', 'function', 'f', 'one', source), named('d', 'function', 'f', 'two!', source)] - expect(alignSiblings(before, source.text, after, source.text).toOld).toEqual([0, 1]) + expect(alignSiblings(before, sourced(source.text), after, sourced(source.text)).toOld).toEqual([0, 1]) }) it('种类不同的同名块不配', () => { const source = { text: '' } const before = [named('a', 'const', 'x', 'const x = 1', source)] const after = [named('b', 'variable', 'x', 'let x = 1', source)] - expect(alignSiblings(before, source.text, after, source.text).toOld).toEqual([undefined]) + expect(alignSiblings(before, sourced(source.text), after, sourced(source.text)).toOld).toEqual([undefined]) }) it('没名字的:源码没变的按最长公共子序列配,中间的按相似度配成修改,不像的算一删一增', () => { - const before = [view('1', 'statement', 'print(a)'), view('2', 'statement', 'call(alpha, beta, gamma)'), view('3', 'statement', 'print(b)')] + const source = { text: '' } + const before = [view('1', 'statement', 'print(a)', source), view('2', 'statement', 'call(alpha, beta, gamma)', source), view('3', 'statement', 'print(b)', source)] const after = [ - view('1', 'statement', 'print(a)'), - view('x', 'statement', 'totally_new()'), - view('2', 'statement', 'call(alpha, beta, delta)'), - view('3', 'statement', 'print(b)'), + view('1', 'statement', 'print(a)', source), + view('x', 'statement', 'totally_new()', source), + view('2', 'statement', 'call(alpha, beta, delta)', source), + view('3', 'statement', 'print(b)', source), ] - expect(alignSiblings(before, '', after, '').toOld).toEqual([0, undefined, 1, 2]) + expect(alignSiblings(before, sourced(source.text), after, sourced(source.text)).toOld).toEqual([0, undefined, 1, 2]) }) it('没名字的块种类不同就不按相似度配', () => { - const before = [view('1', 'statement', 'call(alpha, beta)')] - const after = [view('2', 'return', 'call(alpha, beta, gamma)')] - expect(alignSiblings(before, '', after, '').toOld).toEqual([undefined]) + const source = { text: '' } + const before = [view('1', 'statement', 'call(alpha, beta)', source)] + const after = [view('2', 'return', 'call(alpha, beta, gamma)', source)] + expect(alignSiblings(before, sourced(source.text), after, sourced(source.text)).toOld).toEqual([undefined]) }) it('有名字的块不按相似度配:改名算一删一增', () => { const source = { text: '' } const before = [named('a', 'function', 'old_name', 'def old_name(x): return x + 1', source)] const after = [named('b', 'function', 'new_name', 'def new_name(x): return x + 1', source)] - expect(alignSiblings(before, source.text, after, source.text).toOld).toEqual([undefined]) + expect(alignSiblings(before, sourced(source.text), after, sourced(source.text)).toOld).toEqual([undefined]) }) }) diff --git a/vscode/src/codeReader/diff/blockDiff.ts b/vscode/src/codeReader/diff/blockDiff.ts index 7592695..7825ffc 100644 --- a/vscode/src/codeReader/diff/blockDiff.ts +++ b/vscode/src/codeReader/diff/blockDiff.ts @@ -1,6 +1,7 @@ import type { BlockView } from '../blockView' import type { BlockMarks } from './viewDiff' import { diffArrays, diffWordsWithSpace } from 'diff' +import { blockCodes } from '../blockView' import { blockMarks, removedView } from './viewDiff' /** @@ -54,6 +55,16 @@ export interface SourcedBlocks { source: string } +/** 一份源码,和按行取一块编辑范围源码(去共同缩进)的办法:块的名字、源码都从它取。 */ +export interface Sourced { + source: string + code: (view: BlockView) => string +} + +export function sourced(source: string): Sourced { + return { source, code: blockCodes(source) } +} + /** 没名字的两块内容相似度(0~1)到这么多才配成修改,否则算一删一增。 */ const SIMILAR = 0.5 /** 没名字的块往后最多找这么多块配相似的,改动很大的文件不至于两两比个遍。 */ @@ -90,15 +101,15 @@ export interface Alignment { * ③ 夹在相邻两个锚中间、还没配上的没名字的块,照先后找种类相同、内容最像且相似度到 SIMILAR 的配上(算修改)。 * 有名字却没配上的(改了名、删了、新加的)不按相似度配:改名算一删一增。 */ -export function alignSiblings(before: readonly BlockView[], beforeSource: string, after: readonly BlockView[], afterSource: string): Alignment { +export function alignSiblings(before: readonly BlockView[], beforeSource: Sourced, after: readonly BlockView[], afterSource: Sourced): Alignment { const toOld: (number | undefined)[] = after.map(() => undefined) const toNew: (number | undefined)[] = before.map(() => undefined) const pair = (old: number, now: number): void => { toOld[now] = old toNew[old] = now } - const oldNames = before.map(view => nameOf(view, beforeSource)) - const newNames = after.map(view => nameOf(view, afterSource)) + const oldNames = before.map(view => nameOf(view, beforeSource.source)) + const newNames = after.map(view => nameOf(view, afterSource.source)) const waiting = new Map() before.forEach((view, old) => { const name = oldNames[old] @@ -113,7 +124,7 @@ export function alignSiblings(before: readonly BlockView[], beforeSource: string }) const unnamedSame = (old: number, now: number): boolean => oldNames[old] === undefined && newNames[now] === undefined - && before[old].kind === after[now].kind && before[old].code === after[now].code + && before[old].kind === after[now].kind && beforeSource.code(before[old]) === afterSource.code(after[now]) const changes = diffArrays(before.map((_, i) => i), after.map((_, i) => i), { comparator: (old, now) => toNew[old] === now || unnamedSame(old, now) }) const anchors: [number, number][] = [] let old = 0 @@ -144,7 +155,7 @@ export function alignSiblings(before: readonly BlockView[], beforeSource: string for (let n = from; n < Math.min(anchorNew, from + SIMILAR_WINDOW); n++) { if (toOld[n] !== undefined || newNames[n] !== undefined || after[n].kind !== before[o].kind) continue - const score = similarity(before[o].code, after[n].code) + const score = similarity(beforeSource.code(before[o]), afterSource.code(after[n])) if (score >= SIMILAR && score > bestScore) { best = n bestScore = score @@ -176,13 +187,15 @@ export function diffBlockTrees(before: SourcedBlocks, after: SourcedBlocks): Tre const blocks: Record = {} const removed: RemovedBlock[] = [] const stats: DiffStats = { add: 0, mod: 0, del: 0 } + const oldSource = sourced(before.source) + const newSource = sourced(after.source) /** * 比一层兄弟块:parent 是这一层所属的新块(顶层没有),inside 表示外层已经带了标记(这一层的改动不再计数)。 * @returns 这一层有没有块新增、修改、删除 */ const level = (old: readonly BlockView[], now: readonly BlockView[], parent: BlockView | undefined, inside: boolean): boolean => { - const { toOld, toNew } = alignSiblings(old, before.source, now, after.source) + const { toOld, toNew } = alignSiblings(old, oldSource, now, newSource) let changed = false now.forEach((view, index) => { const paired = toOld[index] @@ -194,7 +207,7 @@ export function diffBlockTrees(before: SourcedBlocks, after: SourcedBlocks): Tre return } const previous = old[paired] - if (previous.code === view.code) + if (oldSource.code(previous) === newSource.code(view)) return const marks = blockMarks(previous, view) const childrenChanged = level(previous.children, view.children, view, true) diff --git a/vscode/src/codeReader/diff/viewDiff.test.ts b/vscode/src/codeReader/diff/viewDiff.test.ts index 5d9742b..178da1c 100644 --- a/vscode/src/codeReader/diff/viewDiff.test.ts +++ b/vscode/src/codeReader/diff/viewDiff.test.ts @@ -16,11 +16,11 @@ function row(label: string, value: Inline[] | { markdown: string }, items?: Prop } function textView(translation: Inline[] | { markdown: string }, extra: Partial = {}): BlockView { - return { id: '0', anchor: 'a', kind: 'statement', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes: {}, children: [], translation, ...extra } as BlockView + return { id: '0', anchor: 'a', kind: 'statement', startLine: 0, endLine: 0, headerEndLine: 0, attributes: {}, children: [], translation, ...extra } as BlockView } function propertyView(properties: PropertyRow[]): BlockView { - return { id: '0', anchor: 'a', kind: 'function', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes: {}, concepts: [{ id: 'function', label: '函数', icon: 'square-function' }], children: [], properties } + return { id: '0', anchor: 'a', kind: 'function', startLine: 0, endLine: 0, headerEndLine: 0, attributes: {}, concepts: [{ id: 'function', label: '函数', icon: 'square-function' }], children: [], properties } } describe('inlineDiff', () => { diff --git a/vscode/src/codeReader/editText.test.ts b/vscode/src/codeReader/editText.test.ts index 4ec742a..8d71bbd 100644 --- a/vscode/src/codeReader/editText.test.ts +++ b/vscode/src/codeReader/editText.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { toBlockViews } from './blockView' +import { blockCodes, toBlockViews } from './blockView' import { blockReplacement, commonIndent, reindentBlock, replaceLines } from './editText' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' @@ -48,7 +48,7 @@ describe('行替换', () => { return [view, ...view.children.flatMap(flat)] }) for (const view of all) - expect(replaceLines(source, view.startLine, view.endLine, view.code), view.id).toBe(source) + expect(replaceLines(source, view.startLine, view.endLine, blockCodes(source)(view)), view.id).toBe(source) }) }) diff --git a/vscode/src/codeReader/editor/documentMessage.ts b/vscode/src/codeReader/editor/documentMessage.ts index 2f82f0d..759fc95 100644 --- a/vscode/src/codeReader/editor/documentMessage.ts +++ b/vscode/src/codeReader/editor/documentMessage.ts @@ -57,6 +57,8 @@ export async function documentMessage( version, languageId, blocks: [], + source: '', + colors: { roles: [], runs: [] }, attributes: [], translators: registry.translatorsFor(languageId).map(({ id, label }) => ({ id, label })), translatorId: translator?.id ?? '', @@ -67,7 +69,7 @@ export async function documentMessage( return { message: { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }, displayNames: [], symbols: [], imports: [], kinds: [], bindings: [] } try { const rules = registry.rulesFor(languageId) - const { blocks, displayNames, symbols, imports, kinds, bindings } = await readBlockViews(runtime, adapter, translator, languageId, source, { + const { blocks, colors, displayNames, symbols, imports, kinds, bindings } = await readBlockViews(runtime, adapter, translator, languageId, source, { ...(document.fileName && { fileName: document.fileName }), rules: { rules, report, lookup }, calleeKind, @@ -82,7 +84,7 @@ export async function documentMessage( }, }) const names = mergeAttributeNames([translator?.attributeNames, ...ruleAttributeNames(rules, translator?.displayLanguage, report)]) - return { message: { ...message, blocks: markEntryBlocks(blocks, source, document.entryNames ?? []), attributes: fileAttributes(blocks, names) }, displayNames, symbols, imports, kinds, bindings } + return { message: { ...message, blocks: markEntryBlocks(blocks, source, document.entryNames ?? []), source, colors, attributes: fileAttributes(blocks, names) }, displayNames, symbols, imports, kinds, bindings } } catch (err) { return { message: { ...message, error: err instanceof Error ? err.message : String(err) }, displayNames: [], symbols: [], imports: [], kinds: [], bindings: [] } diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 0e7dc55..e212aa7 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -4,7 +4,7 @@ import type { ClaudeImage } from '../../cc/spawnClaude' import type { FileAttribute } from '../attributes' -import type { BlockView } from '../blockView' +import type { BlockView, SourceColors } from '../blockView' import type { ContractMiss } from '../callTree/endpoint' import type { CallTree } from '../callTree/model' import type { OpenedFrom } from '../diff/diffBase' @@ -65,6 +65,9 @@ export interface DocumentMessage { version: number languageId: string blocks: BlockView[] + /** 整份源码和它的着色,只这一份:编辑框里一块的源码、着色按它的编辑范围取;解析失败、语言不支持时是空的(没有块) */ + source: string + colors: SourceColors /** 这份文件里出现过的筛选属性和值(带显示名、块数),按筛选栏的先后排,见 fileAttributes */ attributes: FileAttribute[] translators: TranslatorOption[] diff --git a/vscode/src/codeReader/editor/previewEditor.test.ts b/vscode/src/codeReader/editor/previewEditor.test.ts index 6072693..70a62ab 100644 --- a/vscode/src/codeReader/editor/previewEditor.test.ts +++ b/vscode/src/codeReader/editor/previewEditor.test.ts @@ -530,7 +530,7 @@ describe('注释里不带路径的引用按它写在哪块的注释里找', () = /** 一块有名字的代码:名字在源码里的下标是 at */ function declared(anchor: string, at: number, children: BlockView[] = []): BlockView { - return { id: anchor, anchor, kind: 'function', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], defines: { startIndex: at, endIndex: at + 1 }, attributes: { kind: 'function' }, concepts: [], children, translation: [] } + return { id: anchor, anchor, kind: 'function', startLine: 0, endLine: 0, headerEndLine: 0, defines: { startIndex: at, endIndex: at + 1 }, attributes: { kind: 'function' }, concepts: [], children, translation: [] } } function symbol(name: string, start: number, end: number, children: SymbolNode[] = []): SymbolNode { return { name, range: { startIndex: start, endIndex: end }, selection: { startIndex: start, endIndex: start + name.length }, children } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 63319e9..ee1c365 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -402,6 +402,8 @@ class Preview { version: this.document.version, languageId: '', blocks: [], + source: '', + colors: { roles: [], runs: [] }, attributes: [], translators: [], translatorId: '', diff --git a/vscode/src/codeReader/languages/exceptions.test.ts b/vscode/src/codeReader/languages/exceptions.test.ts index ab6454d..7a28f49 100644 --- a/vscode/src/codeReader/languages/exceptions.test.ts +++ b/vscode/src/codeReader/languages/exceptions.test.ts @@ -2,7 +2,7 @@ import type { BlockView } from '../blockView' import type { Inline, LanguageAdapter, PropertyRow, Segment, Translator } from '../contract' import { describe, expect, it } from 'vitest' import { fileAttributes } from '../attributes' -import { readBlockViews, toBlockViews } from '../blockView' +import { blockCodes, readBlockViews, toBlockViews } from '../blockView' import { replaceLines } from '../editText' import { celeryRule } from '../rules/celery' import { dataclassRule } from '../rules/dataclass' @@ -231,7 +231,7 @@ describe('异常卡片(Python)', () => { const source = PY_CASES.split('\n') const base = named(await pyViews(PY_CASES), 'Base') expect([source[base.startLine], source[base.endLine]]).toEqual(['class Base(Exception):', ' # 收尾']) - expect(replaceLines(PY_CASES, base.startLine, base.endLine, base.code.replace('code = "E"', 'code = "F"'))) + expect(replaceLines(PY_CASES, base.startLine, base.endLine, blockCodes(PY_CASES)(base).replace('code = "E"', 'code = "F"'))) .toBe(PY_CASES.replace('code = "E"', 'code = "F"')) expect(PY_CASES.slice(base.defines?.startIndex, base.defines?.endIndex)).toBe('Base') const rows = properties(base) @@ -303,13 +303,13 @@ describe('异常卡片(宿主、筛选、中文名)', () => { .toContainEqual({ id: 'exception', name: '异常', count: 10 }) }) - it('各块的源码着色拼起来等于编辑范围的整行源码', async () => { + it('各块的源码是编辑范围的整行源码去共同缩进', async () => { for (const [list, source] of [[await pyViews(APP), APP], [await pyViews(COMMERCE), COMMERCE], [await tsViews(TS), TS]] as const) { const rows = source.split('\n') + const code = blockCodes(source) for (const view of flatten(list)) { - expect(view.codeTokens.map(token => token.text).join(''), view.id).toBe(view.code) const indent = Math.min(...rows.slice(view.startLine, view.endLine + 1).filter(row => row.trim()).map(row => row.length - row.trimStart().length)) - expect(view.code, view.id).toBe(rows.slice(view.startLine, view.endLine + 1).map(row => row.slice(indent)).join('\n')) + expect(code(view), view.id).toBe(rows.slice(view.startLine, view.endLine + 1).map(row => row.slice(indent)).join('\n')) } } }) diff --git a/vscode/src/codeReader/languages/rust.test.ts b/vscode/src/codeReader/languages/rust.test.ts index c5d218f..1b32617 100644 --- a/vscode/src/codeReader/languages/rust.test.ts +++ b/vscode/src/codeReader/languages/rust.test.ts @@ -1,7 +1,7 @@ import type { BlockView } from '../blockView' import type { Outline } from '../testSupport' import { describe, expect, it } from 'vitest' -import { toBlockViews } from '../blockView' +import { blockCodes, toBlockViews } from '../blockView' import { blockMarks } from '../diff/viewDiff' import { rustImplRule } from '../rules/rustImpl' import { isCrateRoot } from '../rules/rustTree' @@ -143,8 +143,9 @@ describe('rust 语言卡片', () => { const list = await views(source) const mod = list.find(block => block.kind === 'module') expect(mod?.kind).toBe('module') - expect(list.some(block => block.kind === 'statement' && block.code.includes('const LIMIT'))).toBe(false) - expect(list.filter(block => block.kind === 'statement').map(block => block.code)).toEqual(['mod other;']) + const code = blockCodes(source) + expect(list.some(block => block.kind === 'statement' && code(block).includes('const LIMIT'))).toBe(false) + expect(list.filter(block => block.kind === 'statement').map(code)).toEqual(['mod other;']) expect(mod?.children.map(block => block.kind)).toEqual(['import', 'const', 'variable', 'function', 'function', 'statement', 'statement']) expect(viewLines(mod ? [mod] : [])).toEqual([ 'module 1', diff --git a/vscode/src/codeReader/libraryRules.test.ts b/vscode/src/codeReader/libraryRules.test.ts index b6f6dde..2e78b1d 100644 --- a/vscode/src/codeReader/libraryRules.test.ts +++ b/vscode/src/codeReader/libraryRules.test.ts @@ -2,7 +2,7 @@ import type { BlockView, RuleRun } from './blockView' import type { CodeBlock, LibraryRule, RuleBlock, RuleLocation, RuleSlot, RuleWorkspace, Segment } from './contract' import { describe, expect, it } from 'vitest' import { fileAttributes } from './attributes' -import { toBlockViews } from './blockView' +import { blockCodes, toBlockViews } from './blockView' import { replaceLines } from './editText' import { createPythonAdapter } from './languages/python' import { activeRules, fromLibrary, importsIn, resolveReference } from './libraryRules' @@ -113,10 +113,10 @@ describe('库规则:认领', () => { badge: { label: '卡片' }, startLine: 2, endLine: 8, - code: 'class User(Base):\n __tablename__ = "users"\n # 基本信息\n name = mapped_column(String)\n\n def greet(self) -> str:\n return self.name', attributes: { kind: 'orm.card', access: 'public' }, properties: [{ label: '名称' }], }) + expect(blockCodes(MODEL)(card)).toBe('class User(Base):\n __tablename__ = "users"\n # 基本信息\n name = mapped_column(String)\n\n def greet(self) -> str:\n return self.name') expect(card.children.map(child => [child.id, child.kind, child.startLine])).toEqual([['1.0', 'function', 7]]) }) @@ -139,8 +139,9 @@ describe('库规则:认领', () => { const [, card] = await views(source, [fields]) expect(card.children.map(child => [child.kind, child.startLine, child.endLine])).toEqual([['function', 5, 6]]) expect([card.startLine, card.endLine]).toEqual([2, 8]) - expect(replaceLines(source, card.startLine, card.endLine, card.code)).toBe(source) - const edited = replaceLines(source, card.startLine, card.endLine, card.code.replace('greet', 'hello').replace('String', 'Text')) + const code = blockCodes(source)(card) + expect(replaceLines(source, card.startLine, card.endLine, code)).toBe(source) + const edited = replaceLines(source, card.startLine, card.endLine, code.replace('greet', 'hello').replace('String', 'Text')) expect(edited).toBe(source.replace('greet', 'hello').replace('String', 'Text')) }) diff --git a/vscode/src/codeReader/qa/anchor.test.ts b/vscode/src/codeReader/qa/anchor.test.ts index 3a36f58..ad37b3c 100644 --- a/vscode/src/codeReader/qa/anchor.test.ts +++ b/vscode/src/codeReader/qa/anchor.test.ts @@ -1,6 +1,6 @@ import type { BlockView } from '../blockView' import { describe, expect, it } from 'vitest' -import { toBlockViews } from '../blockView' +import { blockCodes, toBlockViews } from '../blockView' import { createPythonAdapter } from '../languages/python' import { WASM_DIR, withBlocks } from '../testSupport' import { BlockAnchors } from './anchor' @@ -17,7 +17,8 @@ function flatten(list: BlockView[]): BlockView[] { /** 各块的记号,按源码先后,连同它这块的第一行源码 */ async function anchors(source: string): Promise<[string, string][]> { - return flatten(await views(source)).map(view => [view.anchor, view.code.split('\n')[0]]) + const code = blockCodes(source) + return flatten(await views(source)).map(view => [view.anchor, code(view).split('\n')[0]]) } const SOURCE = `class ReferralRelation(Base): diff --git a/vscode/src/codeReader/rules/celery.test.ts b/vscode/src/codeReader/rules/celery.test.ts index 7dd5243..ebcc413 100644 --- a/vscode/src/codeReader/rules/celery.test.ts +++ b/vscode/src/codeReader/rules/celery.test.ts @@ -1,7 +1,7 @@ import type { BlockView } from '../blockView' import type { Inline, PropertyRow, Segment } from '../contract' import { describe, expect, it } from 'vitest' -import { toBlockViews } from '../blockView' +import { blockCodes, toBlockViews } from '../blockView' import { createPythonAdapter } from '../languages/python' import { flatSegments, inlineOf, MemoryIndexHost, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport' import { pythonZhTranslator } from '../translators/pythonZh' @@ -54,7 +54,7 @@ describe('celery 库规则:应用', () => { '结果存储: settings.CELERY_RESULT_BACKEND', ]) expect(SNAPSHOT.slice(app.defines?.startIndex, app.defines?.endIndex)).toBe('celery_app') - expect(app.code.split('\n')[0]).toBe('celery_app = Celery(') + expect(blockCodes(SNAPSHOT)(app).split('\n')[0]).toBe('celery_app = Celery(') expect(app.attributes).toMatchObject({ kind: 'celery.app', access: 'public' }) }) @@ -127,7 +127,7 @@ describe('celery 库规则:定时任务', () => { { text: 'STORAGE_USAGE_POLL_INTERVAL_SEC', role: 'variable' }, { text: ' 秒', role: 'keyword' }, ]) - expect(beat.code.split('\n')[0]).toBe('celery_app.conf.beat_schedule = {') + expect(blockCodes(SNAPSHOT)(beat).split('\n')[0]).toBe('celery_app.conf.beat_schedule = {') expect(beat.endLine - beat.startLine).toBe(SNAPSHOT.split('\n').indexOf('}') - SNAPSHOT.split('\n').indexOf('celery_app.conf.beat_schedule = {')) }) diff --git a/vscode/src/codeReader/rules/dataclass.test.ts b/vscode/src/codeReader/rules/dataclass.test.ts index 546c51a..e0ad424 100644 --- a/vscode/src/codeReader/rules/dataclass.test.ts +++ b/vscode/src/codeReader/rules/dataclass.test.ts @@ -2,7 +2,7 @@ import type { BlockView } from '../blockView' import type { Inline, PropertyRow, Segment } from '../contract' import { describe, expect, it } from 'vitest' import { fileAttributes } from '../attributes' -import { toBlockViews } from '../blockView' +import { blockCodes, toBlockViews } from '../blockView' import { replaceLines } from '../editText' import { createPythonAdapter } from '../languages/python' import { flatSegments, inlineOf, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport' @@ -179,7 +179,7 @@ describe('dataclasses 库规则(Python)', () => { const sandbox = named(await views(SNAPSHOT), 'SandboxAccess') expect(lines[sandbox.startLine]).toBe('@dataclass(frozen=True, slots=True)') expect(lines[sandbox.endLine]).toBe(' object.__setattr__(self, "binding", sandbox_binding(self.sandbox_id))') - expect(replaceLines(SNAPSHOT, sandbox.startLine, sandbox.endLine, sandbox.code)).toBe(SNAPSHOT) + expect(replaceLines(SNAPSHOT, sandbox.startLine, sandbox.endLine, blockCodes(SNAPSHOT)(sandbox))).toBe(SNAPSHOT) }) it('跳到定义、高亮出现处:卡片自己是类名的定义,字段行是字段名的定义,带下标的词都指着源码里的这个名字', async () => { @@ -252,7 +252,7 @@ describe('dataclasses 库规则(Python)', () => { ]) expect(lines[point.startLine]).toBe('@final') expect(lines[point.endLine]).toBe(' color: str = "red"') - expect(replaceLines(RULE_CASES, point.startLine, point.endLine, point.code.replace('return self.x', 'return self.y'))) + expect(replaceLines(RULE_CASES, point.startLine, point.endLine, blockCodes(RULE_CASES)(point).replace('return self.x', 'return self.y'))) .toBe(RULE_CASES.replace('return self.x', 'return self.y')) }) diff --git a/vscode/src/codeReader/rules/enum.test.ts b/vscode/src/codeReader/rules/enum.test.ts index 04db764..2ba9ecb 100644 --- a/vscode/src/codeReader/rules/enum.test.ts +++ b/vscode/src/codeReader/rules/enum.test.ts @@ -2,7 +2,7 @@ import type { BlockView } from '../blockView' import type { Inline, PropertyRow, Segment } from '../contract' import { describe, expect, it } from 'vitest' import { fileAttributes } from '../attributes' -import { toBlockViews } from '../blockView' +import { blockCodes, toBlockViews } from '../blockView' import { replaceLines } from '../editText' import { createPythonAdapter } from '../languages/python' import { createTypeScriptAdapter } from '../languages/typescript' @@ -181,8 +181,9 @@ describe('enum 库规则(Python)', () => { ]) expect(commentText(color.children.at(-1))).toBe('收尾') expect([lines[color.startLine], lines[color.endLine]]).toEqual(['class Color(E):', ' # 收尾']) - expect(replaceLines(RULE_CASES, color.startLine, color.endLine, color.code)).toBe(RULE_CASES) - const edited = replaceLines(RULE_CASES, color.startLine, color.endLine, color.code.replace('GREEN = 2', 'GREEN = 20')) + const code = blockCodes(RULE_CASES)(color) + expect(replaceLines(RULE_CASES, color.startLine, color.endLine, code)).toBe(RULE_CASES) + const edited = replaceLines(RULE_CASES, color.startLine, color.endLine, code.replace('GREEN = 2', 'GREEN = 20')) expect(edited).toBe(RULE_CASES.replace('GREEN = 2', 'GREEN = 20')) }) diff --git a/vscode/src/codeReader/rules/fastapi.test.ts b/vscode/src/codeReader/rules/fastapi.test.ts index 93bdb82..55cad53 100644 --- a/vscode/src/codeReader/rules/fastapi.test.ts +++ b/vscode/src/codeReader/rules/fastapi.test.ts @@ -2,7 +2,7 @@ import type { BlockView } from '../blockView' import type { CodeBlock, Inline, PropertyRow, RuleContext, Segment } from '../contract' import type { DefinitionStart } from '../nameResolution' import { describe, expect, it, vi } from 'vitest' -import { toBlockViews } from '../blockView' +import { blockCodes, toBlockViews } from '../blockView' import { createPythonAdapter } from '../languages/python' import { importsIn } from '../libraryRules' import { DocumentLookups } from '../ruleLookups' @@ -90,8 +90,8 @@ describe('fastapi 库规则:接口卡片', () => { expect(ROUTES.slice(card.defines?.startIndex, card.defines?.endIndex)).toBe('update_user') // 函数体照常是子块,卡片的编辑范围是整个函数(连装饰器) expect(card.children.length).toBeGreaterThan(0) - expect(card.code.split('\n')[0]).toBe('@router.put("/{user_id}", response_model=UserDetailResponse)') - expect(card.code.split('\n').at(-1)).toBe(' )') + expect(blockCodes(ROUTES)(card).split('\n')[0]).toBe('@router.put("/{user_id}", response_model=UserDetailResponse)') + expect(blockCodes(ROUTES)(card).split('\n').at(-1)).toBe(' )') }) it('着色:{参数} 按变量、别的路径按字符串,带源码下标;方法用 Swagger UI 的颜色;成功的状态码绿色,FastAPI 自动带的 422 淡色', async () => { @@ -374,7 +374,7 @@ describe('fastapi 库规则:路由器卡片', () => { ' POST | /users/me/avatar | upload_current_user_avatar | 上传当前登录用户头像。', ]) expect(properties(card).at(-1)?.columns).toEqual(['方法', '路径', '函数', '说明']) - expect(card.code).toBe('router = APIRouter(tags=["用户管理"], prefix="/users")') + expect(blockCodes(ROUTES)(card)).toBe('router = APIRouter(tags=["用户管理"], prefix="/users")') expect(ROUTES.slice(card.defines?.startIndex, card.defines?.endIndex)).toBe('router') }) diff --git a/vscode/src/codeReader/rules/pydantic.test.ts b/vscode/src/codeReader/rules/pydantic.test.ts index 29cd97d..ac4605b 100644 --- a/vscode/src/codeReader/rules/pydantic.test.ts +++ b/vscode/src/codeReader/rules/pydantic.test.ts @@ -2,7 +2,7 @@ import type { BlockView } from '../blockView' import type { IndexEntry, PropertyRow, RuleContext, Segment } from '../contract' import { describe, expect, it } from 'vitest' import { fileAttributes } from '../attributes' -import { toBlockViews } from '../blockView' +import { blockCodes, toBlockViews } from '../blockView' import { diffBlockTrees } from '../diff/blockDiff' import { createPythonAdapter } from '../languages/python' import { flatSegments, inlineOf, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport' @@ -115,7 +115,7 @@ describe('pydantic 库规则(Python)', () => { expect(request.children.map(child => [child.kind, lines[child.startLine].trim()])).toEqual([ ['function', '@field_validator("preview_url", "thumbnail_url")'], ]) - expect(request.code).toContain('def validate_http_scheme') + expect(blockCodes(SHARE)(request)).toContain('def validate_http_scheme') }) it('同一文件里继承模型的也是模型;TypedDict、NamedTuple、普通类、没导入 pydantic 的不是', async () => { diff --git a/vscode/src/codeReader/rules/sqlalchemy.test.ts b/vscode/src/codeReader/rules/sqlalchemy.test.ts index 343e6fd..50611d7 100644 --- a/vscode/src/codeReader/rules/sqlalchemy.test.ts +++ b/vscode/src/codeReader/rules/sqlalchemy.test.ts @@ -1,7 +1,7 @@ import type { BlockView } from '../blockView' import type { Inline, PropertyRow, Segment } from '../contract' import { describe, expect, it } from 'vitest' -import { toBlockViews } from '../blockView' +import { blockCodes, toBlockViews } from '../blockView' import { replaceLines } from '../editText' import { createPythonAdapter } from '../languages/python' import { commentText, flatSegments, inlineOf, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport' @@ -161,8 +161,9 @@ describe('sqlAlchemy 库规则', () => { expect(order.children.map(child => [child.kind, child.startLine])).toEqual([['function', 9]]) expect(commentText(order.children[0])).toBe('算税') expect([order.startLine, order.endLine]).toEqual([3, 13]) - expect(replaceLines(source, order.startLine, order.endLine, order.code)).toBe(source) - expect(replaceLines(source, order.startLine, order.endLine, order.code.replace('// 10', '// 8'))).toBe(source.replace('// 10', '// 8')) + const code = blockCodes(source)(order) + expect(replaceLines(source, order.startLine, order.endLine, code)).toBe(source) + expect(replaceLines(source, order.startLine, order.endLine, code.replace('// 10', '// 8'))).toBe(source.replace('// 10', '// 8')) }) it('跳到定义、高亮出现处:卡片自己是类名的定义,字段行是字段名的定义,带下标的词都指着源码里的这个名字', async () => { diff --git a/vscode/src/codeReader/testSupport.ts b/vscode/src/codeReader/testSupport.ts index c451c60..90da38a 100644 --- a/vscode/src/codeReader/testSupport.ts +++ b/vscode/src/codeReader/testSupport.ts @@ -1,11 +1,12 @@ import type { Tree } from '@vscode/tree-sitter-wasm' -import type { BlockView } from './blockView' -import type { BlockKind, CodeBlock, Inline, LanguageAdapter, PreviewTable, PreviewText, PropertyItem, PropertyRow, Segment } from './contract' +import type { BlockView, SourceColors } from './blockView' +import type { BlockKind, CodeBlock, Inline, LanguageAdapter, PreviewTable, PreviewText, PropertyItem, PropertyRow, Segment, TokenRole } from './contract' import type { FileEvent, IndexHost, WorkspaceFile } from './workspaceIndex' import { readFileSync } from 'node:fs' import * as path from 'node:path' import { Node } from '@vscode/tree-sitter-wasm' import { minimatch } from 'minimatch' +import { commonIndent, splitLines } from './editText' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' import { indexGrammar, TreeSitterRuntime } from './runtime' @@ -34,6 +35,37 @@ export function readFixture(name: string): string { return readFileSync(path.join(__dirname, 'fixtures', name), 'utf8') } +/** + * 整份源码的着色(SourceColors)里 startLine..endLine 这几行的样子,去掉共同缩进、行间用 \n,相邻同词性的连成一段: + * 同页面编辑框给一块源码上的色(webview-ui 的 blockSource)。行间的换行符取源码里那个换行符的词性。 + */ +export function lineColors(source: string, colors: SourceColors, startLine: number, endLine: number): Segment[] { + const roles: TokenRole[] = [] + for (let index = 0; index < colors.runs.length; index += 2) + roles.push(...Array.from({ length: colors.runs[index] }, () => colors.roles[colors.runs[index + 1]])) + const starts = [0] + for (const match of source.matchAll(/\r?\n/g)) + starts.push(match.index + match[0].length) + const rows = splitLines(source).slice(startLine, endLine + 1) + const indent = commonIndent(rows).length + const segments: Segment[] = [] + const put = (text: string, role: TokenRole): void => { + const prev = segments.at(-1) + if (prev?.role === role) + prev.text += text + else + segments.push({ text, role }) + } + rows.forEach((row, index) => { + const start = starts[startLine + index] + if (index > 0) + put('\n', roles[start - 1]) + for (let at = start + Math.min(indent, row.length); at < start + row.length; at++) + put(source[at], roles[at]) + }) + return segments +} + /** 解析 source,把代码块树交给 use;语法树随后释放。 */ export async function withBlocks(adapter: LanguageAdapter, languageId: string, source: string, use: (blocks: CodeBlock[]) => T): Promise { const tree = await runtime.parse(adapter, languageId, source) diff --git a/vscode/src/codeReader/translators/bashZh.test.ts b/vscode/src/codeReader/translators/bashZh.test.ts index ac1450d..fd776bf 100644 --- a/vscode/src/codeReader/translators/bashZh.test.ts +++ b/vscode/src/codeReader/translators/bashZh.test.ts @@ -6,7 +6,7 @@ import { packageCommandRules } from '../commands/packageCommands' import { shellCommandRules } from '../commands/shellCommands' import { systemCommandRules } from '../commands/systemCommands' import { createBashAdapter } from '../languages/bash' -import { flatSegments, readFixture, runtime, viewLines, WASM_DIR } from '../testSupport' +import { flatSegments, lineColors, readFixture, runtime, viewLines, WASM_DIR } from '../testSupport' import { bashZhTranslator } from './bashZh' const bash = createBashAdapter(WASM_DIR) @@ -212,7 +212,8 @@ describe('shell 中文直译', () => { }) it('着色:字符串里写死的部分是字符串色,展开的变量是变量色,全大写的名字是常量色', async () => { - const [block] = await read(`PY_URL="https://x/$PY_RELEASE/c-\${PY_VERSION}-$target.tgz"\n`) + const source = `PY_URL="https://x/$PY_RELEASE/c-\${PY_VERSION}-$target.tgz"\n` + const { blocks: [block], colors } = await readBlockViews(runtime, bash, bashZhTranslator, 'shellscript', source) const value = 'properties' in block ? block.properties.find(row => row.label === '初始值')?.value : undefined expect(value && !('markdown' in value) && flatSegments(value).map(({ text, role }) => [text, role])).toEqual([ ['https://x/', 'string'], @@ -223,7 +224,7 @@ describe('shell 中文直译', () => { ['target', 'variable'], ['.tgz', 'string'], ]) - expect(block.codeTokens.filter(({ role }) => role !== 'plain').map(({ text, role }) => [text, role])).toContainEqual(['PY_URL', 'constant']) + expect(lineColors(source, colors, block.startLine, block.endLine).filter(({ role }) => role !== 'plain').map(({ text, role }) => [text, role])).toContainEqual(['PY_URL', 'constant']) }) it('命令规则出错时照抄命令并报告,别的命令不受影响', async () => { diff --git a/vscode/src/codeReader/translators/dockerfileZh.test.ts b/vscode/src/codeReader/translators/dockerfileZh.test.ts index 07b9722..095a436 100644 --- a/vscode/src/codeReader/translators/dockerfileZh.test.ts +++ b/vscode/src/codeReader/translators/dockerfileZh.test.ts @@ -1,4 +1,4 @@ -import type { BlockView } from '../blockView' +import type { BlockView, DocumentViews } from '../blockView' import { describe, expect, it } from 'vitest' import { readBlockViews } from '../blockView' import { commandRuleLookup } from '../commandRules' @@ -7,7 +7,7 @@ import { shellCommandRules } from '../commands/shellCommands' import { systemCommandRules } from '../commands/systemCommands' import { createBashAdapter } from '../languages/bash' import { createDockerfileAdapter } from '../languages/dockerfile' -import { DOCKERFILE_WASM_DIR, flatSegments, readFixture, runtime, viewLines, WASM_DIR } from '../testSupport' +import { DOCKERFILE_WASM_DIR, flatSegments, lineColors, readFixture, runtime, viewLines, WASM_DIR } from '../testSupport' import { bashZhTranslator } from './bashZh' import { dockerfileZhTranslator } from './dockerfileZh' @@ -16,13 +16,16 @@ const docker = createDockerfileAdapter(DOCKERFILE_WASM_DIR) const RULES = [...shellCommandRules, ...systemCommandRules, ...packageCommandRules] /** RUN 里的 shell 交给 bash 的语言插件和中文直译(withShell 为假时不给,照抄)。 */ -async function read(source: string, withShell = true): Promise { +function readViews(source: string, withShell = true): Promise { const commandRule = commandRuleLookup(RULES, () => {}) - const { blocks } = await readBlockViews(runtime, docker, dockerfileZhTranslator, 'dockerfile', source, { + return readBlockViews(runtime, docker, dockerfileZhTranslator, 'dockerfile', source, { commandRule, ...(withShell && { embedded: (id: string) => id === 'shellscript' ? { adapter: bash, translator: bashZhTranslator, commandRule } : undefined }), }) - return blocks +} + +async function read(source: string, withShell = true): Promise { + return (await readViews(source, withShell)).blocks } async function readLines(source: string): Promise { @@ -313,11 +316,11 @@ describe('dockerfile 中文直译', () => { it('能跳到定义的名字:从阶段复制的阶段名、表里的变量名带源码下标;RUN 的源码按 shell 着色', async () => { const source = 'FROM node AS deps\nFROM node\nCOPY --from=deps /a /a\nENV A=1\nENV B=2\nRUN apt-get update\n' - const [, stage] = await read(source) + const { blocks: [, stage], colors } = await readViews(source) const [copy, env, run] = stage.children const deps = 'translation' in copy && Array.isArray(copy.translation) ? flatSegments(copy.translation).find(segment => segment.text === 'deps') : undefined expect(deps?.offset).toBe(source.indexOf('deps', 20)) expect('properties' in env && env.properties[0].items?.map(item => item.defines && source.slice(item.defines.startIndex, item.defines.endIndex))).toEqual(['A', 'B']) - expect(run.codeTokens.filter(token => token.role !== 'plain').map(token => [token.text, token.role])).toEqual([['RUN', 'keyword'], ['apt-get', 'function']]) + expect(lineColors(source, colors, run.startLine, run.endLine).filter(token => token.role !== 'plain').map(token => [token.text, token.role])).toEqual([['RUN', 'keyword'], ['apt-get', 'function']]) }) }) diff --git a/vscode/webview-ui/src/codeReader/App.test.tsx b/vscode/webview-ui/src/codeReader/App.test.tsx index a33d28c..ec41e66 100644 --- a/vscode/webview-ui/src/codeReader/App.test.tsx +++ b/vscode/webview-ui/src/codeReader/App.test.tsx @@ -120,8 +120,6 @@ function block(id: string, anchor: string): BlockView { callTree: {}, startLine: 0, endLine: 1, - code: '', - codeTokens: [], attributes: { kind: 'function' }, concepts: [{ id: 'function', label: 'function', icon: 'braces' }], children: [], @@ -130,7 +128,7 @@ function block(id: string, anchor: string): BlockView { } function documentOf(version: number, blocks: BlockView[]): DocumentMessage { - return { type: 'document', version, languageId: 'python', blocks, attributes: [], translators: [], translatorId: '', names: [] } + return { type: 'document', version, languageId: 'python', blocks, source: '', colors: { roles: [], runs: [] }, attributes: [], translators: [], translatorId: '', names: [] } } function tree(id: string, name: string): CallTree { diff --git a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx index be4bf5c..3733541 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx @@ -6,10 +6,10 @@ import type { BlockView, KindBadge } from './types' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import { fileAttributes } from '../../../src/codeReader/attributes' -import { toBlockViews } from '../../../src/codeReader/blockView' +import { readBlockViews, toBlockViews } from '../../../src/codeReader/blockView' import { createPythonAdapter } from '../../../src/codeReader/languages/python' import { fastapiRule } from '../../../src/codeReader/rules/fastapi' -import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport' +import { runtime, WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport' import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh' import { BlockTree, treeState } from './BlockTree' import { applyFilter } from './filter' @@ -31,8 +31,6 @@ function view(id: string, kind: string, text: string, extra: Partial kind, startLine: 0, endLine: 0, - code: '', - codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: badge?.label ?? kind, icon: badge?.icon ?? 'box', ...(badge && { color: badge.color }) }], children: [], @@ -68,7 +66,7 @@ function rewriteTree(state: ChatState): RewriteTree { function tree(view: Partial> = {}, rewrite: ChatState = { ...EMPTY_CHAT, available: true }): BlockTreeHandle { return { state: staticStore(treeState({ - edit: { version: 1, editing: new Set(), focusId: null }, + edit: { version: 1, sourceText: { source: '', colors: { roles: [], runs: [] } }, editing: new Set(), focusId: null }, collapsed: new Set(), hidden: new Set(), temporary: new Set(), @@ -321,3 +319,17 @@ describe('一行译文下面拆开的各项', () => { expect(before(byId(root, `${block.id}/i/1`), removed!) && before(removed!, byId(root, `${block.id}/i/2`))).toBe(true) }) }) + +describe('编辑框', () => { + it('编辑着的块:编辑框里是文档源码按它的编辑范围取的整行、去掉共同缩进,着色取自整份源码的着色', async () => { + const source = 'def f(x):\n if x:\n return "a"\n' + const { blocks, colors } = await readBlockViews(runtime, createPythonAdapter(WASM_DIR), pythonZhTranslator, 'python', source) + const html = markup(blocks, tree({ + edit: { version: 1, sourceText: { source, colors }, editing: new Set(['0.0']), focusId: null }, + collapsed: new Set(), + })) + expect(html).toContain('>if x:\n return "a"') + expect(html).toContain('if') + expect(html).toContain('"a"') + }) +}) diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 3db07e5..3e286f3 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -1,4 +1,5 @@ import type { CSSProperties, ReactElement, ReactNode } from 'react' +import type { SourceText } from './blockSource' import type { RewriteTool } from './BlockTools' import type { DiffTree, FoldedRuns, PlacedRemoved } from './diffView' import type { GridSlots } from './PropertyGrid' @@ -24,6 +25,8 @@ import { foldsByCard, listId, tableId } from './viewState' /** 块树里改代码的那部分状态:哪些块在编辑。页面上的块树才有;定义卡片浮窗里的块树没有,卡片只读,不画右上角的按钮 */ export interface EditState { version: number + /** 这一版文档的源码和着色:编辑框按块的编辑范围从里面取 */ + sourceText: SourceText editing: ReadonlySet /** 刚点了「编辑」、要把光标放进去的块 */ focusId: string | null @@ -208,6 +211,8 @@ interface BlockShape { focus: boolean /** 编辑框每来一份新文档都要对一次内容;不在编辑的卡片不跟着版本重画 */ version?: number + /** 编辑着的卡片才有:这一版文档的源码和着色 */ + sourceText?: SourceText /** 调用树:扩展端标了能看调用树(BlockView.callTree)的块才有棵数 */ calls?: number callOpen: boolean @@ -229,6 +234,7 @@ function shapeOf(block: BlockView, state: BlockTreeState): BlockShape { temporary: state.temporary.has(block.id), focus: state.edit?.focusId === block.id, version: editing ? state.edit?.version : undefined, + sourceText: editing ? state.edit?.sourceText : undefined, calls: state.callTree && block.callTree ? state.callTree.counts.get(block.anchor) ?? 0 : undefined, callOpen: state.callTree?.openAnchor === block.anchor, } @@ -493,10 +499,11 @@ function BlockCard({ block, shape, ask, rewrite }: { block: BlockView, shape: Bl : }
- {edit && shape.version !== undefined + {edit && shape.version !== undefined && shape.sourceText ? ( void }, edit = true): BlockTreeHandle { return { state: staticStore(treeState({ - ...(edit && { edit: { version: 1, editing: new Set(), focusId: null } }), + ...(edit && { edit: { version: 1, sourceText: { source: '', colors: { roles: [], runs: [] } }, editing: new Set(), focusId: null } }), collapsed: new Set(), hidden: new Set(), temporary: new Set(), diff --git a/vscode/webview-ui/src/codeReader/CodeEditor.tsx b/vscode/webview-ui/src/codeReader/CodeEditor.tsx index 5d5143c..83bd83b 100644 --- a/vscode/webview-ui/src/codeReader/CodeEditor.tsx +++ b/vscode/webview-ui/src/codeReader/CodeEditor.tsx @@ -1,6 +1,8 @@ import type { ChangeEvent, KeyboardEvent, ReactElement } from 'react' +import type { SourceText } from './blockSource' import type { BlockView } from './types' -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { blockSource } from './blockSource' import { recolor } from './segments' import { SegmentText } from './SegmentText' import { postMessage } from './vscode' @@ -9,6 +11,8 @@ const EDIT_DEBOUNCE_MS = 200 interface CodeEditorProps { block: BlockView + /** 这一版文档的源码和着色:这块的源码按它的编辑范围从里面取 */ + sourceText: SourceText /** 文档版本:每来一份新文档都要对一次输入框内容,哪怕这一块的代码没变 */ version: number autoFocus: boolean @@ -17,8 +21,9 @@ interface CodeEditorProps { } /** 一块源码的编辑状态:着色层显示,透明输入框接收输入,改动防抖后整行替换回文件。 */ -export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement { - const [text, setText] = useState(block.code) +export function CodeEditor({ block, sourceText, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement { + const { code, tokens } = useMemo(() => blockSource(sourceText, block.startLine, block.endLine), [sourceText, block.startLine, block.endLine]) + const [text, setText] = useState(code) const textareaRef = useRef(null) const blockRef = useRef(block) blockRef.current = block @@ -51,16 +56,16 @@ export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }: useEffect(() => { const el = textareaRef.current const focused = el !== null && document.activeElement === el - if (focused && unconfirmed.current !== null && unconfirmed.current !== block.code) + if (focused && unconfirmed.current !== null && unconfirmed.current !== code) return unconfirmed.current = null sentLineCount.current = null - if (block.code === textRef.current) + if (code === textRef.current) return if (focused) restoreSelection.current = [el.selectionStart, el.selectionEnd] - setText(block.code) - }, [version, block.code]) + setText(code) + }, [version, code]) useLayoutEffect(() => { const el = textareaRef.current @@ -121,7 +126,7 @@ export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }: onBlur={flush} />
diff --git a/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.test.tsx b/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.test.tsx index ed74592..0560c0d 100644 --- a/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.test.tsx +++ b/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.test.tsx @@ -12,8 +12,6 @@ function block(id: string, kind: string, extra: Partial): BlockView { kind, startLine: 0, endLine: 0, - code: '', - codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children: [], diff --git a/vscode/webview-ui/src/codeReader/StickyLines.test.tsx b/vscode/webview-ui/src/codeReader/StickyLines.test.tsx index a36c8c4..914772a 100644 --- a/vscode/webview-ui/src/codeReader/StickyLines.test.tsx +++ b/vscode/webview-ui/src/codeReader/StickyLines.test.tsx @@ -15,8 +15,6 @@ function block(kind: string, extra: Partial): BlockView { kind, startLine: 0, endLine: 0, - code: '', - codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'square-function' }], children: [], diff --git a/vscode/webview-ui/src/codeReader/TextPreview.test.tsx b/vscode/webview-ui/src/codeReader/TextPreview.test.tsx index f4ac6df..f694aeb 100644 --- a/vscode/webview-ui/src/codeReader/TextPreview.test.tsx +++ b/vscode/webview-ui/src/codeReader/TextPreview.test.tsx @@ -134,7 +134,7 @@ describe.each(['TextPreview', 'DiffText'] as const)('%s 的 Markdown 页面元 const peek: DefinitionPeek = { path: 'client.py', line: 1, - block: { id: '1', anchor: '1', kind: 'class', startLine: 1, endLine: 2, code: '', codeTokens: [], attributes: { kind: 'class' }, concepts: [], children: [], translation: [] }, + block: { id: '1', anchor: '1', kind: 'class', startLine: 1, endLine: 2, attributes: { kind: 'class' }, concepts: [], children: [], translation: [] }, names: [], } await act(async () => controller.answer(1, peek)) diff --git a/vscode/webview-ui/src/codeReader/blockSource.test.ts b/vscode/webview-ui/src/codeReader/blockSource.test.ts new file mode 100644 index 0000000..43616be --- /dev/null +++ b/vscode/webview-ui/src/codeReader/blockSource.test.ts @@ -0,0 +1,68 @@ +import type { SourceColors, TokenRole } from './types' +import { describe, expect, it } from 'vitest' +import { blockSource } from './blockSource' + +/** 按字给词性拼出整份源码的着色:pieces 是 [文字, 词性] 依次接起来,同词性相邻的连成一段 */ +function colored(pieces: [string, TokenRole][]): { source: string, colors: SourceColors } { + const colors: SourceColors = { roles: [], runs: [] } + let last: TokenRole | undefined + for (const [text, role] of pieces) { + let index = colors.roles.indexOf(role) + if (index < 0) + index = colors.roles.push(role) - 1 + if (role === last) + colors.runs[colors.runs.length - 2] += text.length + else + colors.runs.push(text.length, index) + last = role + } + return { source: pieces.map(([text]) => text).join(''), colors } +} + +describe('编辑框里一块的源码和着色', () => { + const text = colored([ + ['def', 'storage'], + [' ', 'plain'], + ['f', 'function'], + ['():\n ', 'plain'], + ['if', 'keyword'], + [' ', 'plain'], + ['x', 'variable'], + [':\n ', 'plain'], + ['return', 'keyword'], + [' ', 'plain'], + ['"a\n b"', 'string'], + ['\n', 'plain'], + ]) + + it('编辑范围整行,去掉共同缩进,行间用 \\n', () => { + expect(blockSource(text, 1, 3).code).toBe('if x:\n return "a\nb"') + expect(blockSource(text, 0, 0).code).toBe('def f():') + }) + + it('着色按源码里每个字的词性,缩进去掉,相邻同词性的连成一段;行间的换行符取源码里那个换行符的词性', () => { + expect(blockSource(text, 1, 3).tokens).toEqual([ + { text: 'if', role: 'keyword' }, + { text: ' ', role: 'plain' }, + { text: 'x', role: 'variable' }, + { text: ':\n ', role: 'plain' }, + { text: 'return', role: 'keyword' }, + { text: ' ', role: 'plain' }, + { text: '"a\nb"', role: 'string' }, + ]) + }) + + it('\\r\\n 换行:\\r 不进源码;制表符缩进按原样去掉;空行不算缩进', () => { + const crlf = colored([ + ['f() {\r\n\t', 'plain'], + ['if', 'keyword'], + [' (a) {\r\n\r\n\t\t', 'plain'], + ['return', 'keyword'], + ['\r\n\t}\r\n}', 'plain'], + ]) + const { code, tokens } = blockSource(crlf, 1, 4) + expect(code).toBe('if (a) {\n\n\treturn\n}') + expect(tokens.map(token => token.text).join('')).toBe(code) + expect(tokens).toContainEqual({ text: 'return', role: 'keyword' }) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/blockSource.ts b/vscode/webview-ui/src/codeReader/blockSource.ts new file mode 100644 index 0000000..8bfdb02 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/blockSource.ts @@ -0,0 +1,81 @@ +import type { DocumentMessage, Segment, TokenRole } from './types' + +/** 文档的整份源码和它的着色(DocumentMessage 只带这一份,各块按编辑范围取) */ +export type SourceText = Pick + +/** 一块在编辑框里的样子:编辑范围整行源码去掉共同缩进、行间用 \n,和它的着色(拼起来等于 code) */ +export interface BlockSource { + code: string + tokens: Segment[] +} + +/** 非空行共同的前导空白(空格 / 制表符按原样比,不换算),同扩展端写回时补缩进用的那份(editText.commonIndent) */ +function commonIndent(rows: readonly string[]): number { + let common: string | undefined + for (const row of rows) { + if (!row.trim()) + continue + const indent = row.slice(0, row.length - row.trimStart().length) + if (common === undefined) { + common = indent + continue + } + let i = 0 + while (i < common.length && i < indent.length && common[i] === indent[i]) + i++ + common = common.slice(0, i) + } + return common?.length ?? 0 +} + +/** + * startLine..endLine 这几行在编辑框里的样子(见 BlockSource):从文档开头数到这几行,每行的字按整份着色里它那段的词性上色, + * 去掉的缩进不上色,行间的换行符取源码里那个换行符的词性;\r\n 的 \r 不算进行里。 + */ +export function blockSource({ source, colors }: SourceText, startLine: number, endLine: number): BlockSource { + const rows: { start: number, text: string }[] = [] + let start = 0 + for (let line = 0; line <= endLine && start <= source.length; line++) { + const newline = source.indexOf('\n', start) + const end = newline < 0 ? source.length : newline + if (line >= startLine) + rows.push({ start, text: source.slice(start, end > start && source[end - 1] === '\r' ? end - 1 : end) }) + start = end + 1 + } + const indent = commonIndent(rows.map(row => row.text)) + const { runs, roles } = colors + /** 整份着色里眼下这段:第几段(runs 里的下标),它从源码第几个字起、到第几个字前止 */ + let run = 0 + let runStart = 0 + const roleAt = (at: number): TokenRole => { + while (run < runs.length && runStart + runs[run] <= at) { + runStart += runs[run] + run += 2 + } + return run < runs.length ? roles[runs[run + 1]] : 'plain' + } + const tokens: Segment[] = [] + const put = (from: number, to: number, text: string): void => { + for (let at = from; at < to;) { + const role = roleAt(at) + const until = Math.min(to, runStart + (runs[run] ?? to - runStart)) + const piece = text.slice(at - from, until - from) + const prev = tokens.at(-1) + if (prev?.role === role) + prev.text += piece + else + tokens.push({ text: piece, role }) + at = until + } + } + rows.forEach((row, index) => { + if (index > 0) + put(row.start - 1, row.start, '\n') + const from = row.start + Math.min(indent, row.text.length) + put(from, row.start + row.text.length, row.text.slice(from - row.start)) + }) + return { + code: rows.map(row => row.text.slice(Math.min(indent, row.text.length))).join('\n'), + tokens, + } +} diff --git a/vscode/webview-ui/src/codeReader/definitionPeek.test.ts b/vscode/webview-ui/src/codeReader/definitionPeek.test.ts index e0b1d58..0fa3a1c 100644 --- a/vscode/webview-ui/src/codeReader/definitionPeek.test.ts +++ b/vscode/webview-ui/src/codeReader/definitionPeek.test.ts @@ -10,7 +10,7 @@ const HIDING = 500 const PEEK: DefinitionPeek = { path: 'app/devices.py', line: 41, - block: { id: '3', anchor: '3', kind: 'class', startLine: 41, endLine: 60, code: '', codeTokens: [], attributes: { kind: 'class' }, concepts: [], children: [], translation: [] }, + block: { id: '3', anchor: '3', kind: 'class', startLine: 41, endLine: 60, attributes: { kind: 'class' }, concepts: [], children: [], translation: [] }, names: [], } diff --git a/vscode/webview-ui/src/codeReader/diffView.test.ts b/vscode/webview-ui/src/codeReader/diffView.test.ts index 4a8cb98..955a9ac 100644 --- a/vscode/webview-ui/src/codeReader/diffView.test.ts +++ b/vscode/webview-ui/src/codeReader/diffView.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { changeStops, diffTree, foldUnchanged, nextStop, openedByDiff, removedTitle, statsParts } from './diffView' function block(id: string, anchor: string, children: BlockView[] = [], extra: Partial = {}): BlockView { - return { id, anchor, kind: 'function', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'function' }, concepts: [{ id: 'function', label: '函数', icon: 'square-function' }], children, translation: [], ...extra } as BlockView + return { id, anchor, kind: 'function', startLine: 0, endLine: 0, attributes: { kind: 'function' }, concepts: [{ id: 'function', label: '函数', icon: 'square-function' }], children, translation: [], ...extra } as BlockView } function removed(parent: string, after: string | undefined, kind = 'function'): RemovedView { diff --git a/vscode/webview-ui/src/codeReader/editing.test.ts b/vscode/webview-ui/src/codeReader/editing.test.ts index 21d282a..be4c378 100644 --- a/vscode/webview-ui/src/codeReader/editing.test.ts +++ b/vscode/webview-ui/src/codeReader/editing.test.ts @@ -4,7 +4,7 @@ import { coveredByEditing, startEditing } from './editing' import { revealPlan, uncoveredPlan } from './reveal' function block(id: string, kind: string, [startLine, endLine]: [number, number], children: BlockView[] = []): BlockView { - return { id, kind, startLine, endLine, code: '', codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, properties: [] } + return { id, kind, startLine, endLine, attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, properties: [] } } /** diff --git a/vscode/webview-ui/src/codeReader/filter.test.ts b/vscode/webview-ui/src/codeReader/filter.test.ts index 38cbde8..e4a551b 100644 --- a/vscode/webview-ui/src/codeReader/filter.test.ts +++ b/vscode/webview-ui/src/codeReader/filter.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { applyFilter, blockPath, excludedBy, facetLabel, facetsOf, keptWhileEditing, setChecked, toggleValue } from './filter' function block(id: string, kind: BlockView['kind'], extra: Record = {}, children: BlockView[] = []): BlockView { - return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind, ...extra }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation: [] } + return { id, kind, startLine: 0, endLine: 0, attributes: { kind, ...extra }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation: [] } } const NONE = new Set() diff --git a/vscode/webview-ui/src/codeReader/itemHeight.test.ts b/vscode/webview-ui/src/codeReader/itemHeight.test.ts index 254b76d..4499f1a 100644 --- a/vscode/webview-ui/src/codeReader/itemHeight.test.ts +++ b/vscode/webview-ui/src/codeReader/itemHeight.test.ts @@ -9,8 +9,6 @@ function block(id: string, kind: string, extra: Partial = {}): BlockV kind, startLine: 0, endLine: 0, - code: '', - codeTokens: [], attributes: { kind }, concepts: [], children: [], diff --git a/vscode/webview-ui/src/codeReader/pageState.ts b/vscode/webview-ui/src/codeReader/pageState.ts index c7ada57..4984f25 100644 --- a/vscode/webview-ui/src/codeReader/pageState.ts +++ b/vscode/webview-ui/src/codeReader/pageState.ts @@ -1,8 +1,9 @@ +import type { SourceText } from './blockSource' import type { BlockTreeState } from './BlockTree' import type { ChangeStop, DiffTree, FoldedRuns } from './diffView' import type { FilterView } from './filter' import type { ReadableStore } from './store' -import type { BlockView, CallTree, ContractMiss, DocumentDiff, DocumentMessage, Excluded, KindBadge, Reveal } from './types' +import type { BlockView, CallTree, ContractMiss, DocumentDiff, DocumentMessage, Excluded, KindBadge, Reveal, SourceColors } from './types' import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView' import { coveredByEditing } from './editing' import { applyFilter, excludedBy } from './filter' @@ -72,6 +73,7 @@ export interface PageStore extends ReadableStore { const NONE: ReadonlySet = new Set() const NO_BLOCKS: BlockView[] = [] const NO_COUNTS: ReadonlyMap = new Map() +const NO_COLORS: SourceColors = { roles: [], runs: [] } /** 只记上一次:参数(逐个同一个对象)和上次一样就交回上次的结果,同 memoize-one */ function memoizeOne(compute: (...args: A) => R): (...args: A) => R { @@ -108,8 +110,10 @@ export function createPageStore(initial: Pick): const countsOf = memoizeOne((version: number | undefined, callCounts: PageInputs['callCounts']) => callCounts && callCounts.version === version ? new Map(Object.entries(callCounts.counts)) : NO_COUNTS) + const sourceTextOf = memoizeOne((source: string, colors: SourceColors): SourceText => ({ source, colors })) const treeOf = memoizeOne(( version: number, + sourceText: SourceText, editing: ReadonlySet, focusId: string | null, counts: ReadonlyMap, @@ -121,7 +125,7 @@ export function createPageStore(initial: Pick): changes: DiffTree | undefined, folded: FoldedRuns | undefined, ): BlockTreeState => ({ - edit: { version, editing, focusId }, + edit: { version, sourceText, editing, focusId }, callTree: { counts, ...(openAnchor !== undefined && { openAnchor }) }, collapsed, folds, @@ -152,7 +156,7 @@ export function createPageStore(initial: Pick): folded, unshown: unshownOf(filter, folded), stops: stopsOf(blocks, changes, filter), - tree: treeOf(version, editing, inputs.focusId, countsOf(doc?.version, inputs.callCounts), openAnchor, collapsed, foldsOf(collapsed), filter, covered, changes, folded), + tree: treeOf(version, sourceTextOf(doc?.source ?? '', doc?.colors ?? NO_COLORS), editing, inputs.focusId, countsOf(doc?.version, inputs.callCounts), openAnchor, collapsed, foldsOf(collapsed), filter, covered, changes, folded), } } diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index 781bd99..91ce47a 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -21,11 +21,11 @@ function word(text: string): Segment { } function textBlock(id: string, kind: BlockKind, startLine: number, translation: Inline[], children: BlockView[] = []): BlockView { - return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation } + return { id, kind, startLine, endLine: startLine, attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation } } function factBlock(id: string, kind: BlockKind, startLine: number, properties: PropertyRow[], children: BlockView[] = []): BlockView { - return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, properties } + return { id, kind, startLine, endLine: startLine, attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, properties } } /** @@ -162,7 +162,7 @@ describe('跳到定义:滚到哪块、展开哪些', () => { it('注释块、函数「说明」的 Markdown 里的名字不带下标,不算显示着定义', () => { const blocks: BlockView[] = [ - { id: '0', kind: 'comment', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'comment' }, concepts: [{ id: 'comment', label: '注释', icon: 'message-square-text' }], children: [], translation: { markdown: '见 `get_db`' } }, + { id: '0', kind: 'comment', startLine: 0, endLine: 0, attributes: { kind: 'comment' }, concepts: [{ id: 'comment', label: '注释', icon: 'message-square-text' }], children: [], translation: { markdown: '见 `get_db`' } }, factBlock('1', 'function', 1, [ { label: '说明', value: { markdown: '同 `get_db`' } }, { label: '输入参数', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 30)]] }] }, @@ -302,7 +302,7 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { } /** 闪的那一处:整块写成它的第一行源码,属性行写成「属性名: 值」,子行各列用 | 隔开。 */ - function flashed(views: BlockView[], plan: RevealPlan | undefined): string { + function flashed(views: BlockView[], plan: RevealPlan | undefined, source: string): string { const block = flatten(views).find(view => view.id === plan?.id) if (!block || !plan) return '' @@ -316,7 +316,7 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { return item.cells.map(text).join(' | ') } } - return block.code.split('\n')[0] + return source.split('\n')[block.startLine].trimStart() } /** 定义提供者说定义在 source 里 needle 开头那个名字上:预览滚到哪、展开哪些、闪哪一处。 */ @@ -325,7 +325,7 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { expect(offset, needle).toBeGreaterThanOrEqual(0) const views: BlockView[] = await withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, { source, language: adapter, markers: [] }, translator)) const plan = revealPlan(views, source.slice(0, offset).split('\n').length - 1, offset) - return { plan, flashed: flashed(views, plan) } + return { plan, flashed: flashed(views, plan, source) } } const PY = [ @@ -458,7 +458,7 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { const at = (needle: string): { plan: RevealPlan | undefined, flashed: string } => { const offset = source.indexOf(needle) const plan = revealPlan(views, source.slice(0, offset).split('\n').length - 1, offset) - return { plan, flashed: flashed(views, plan) } + return { plan, flashed: flashed(views, plan, source) } } expect(at('User')).toEqual({ plan: { id: '1', expand: [] }, flashed: 'class User(Base):' }) expect(at('name:')).toEqual({ plan: { id: '1', row: '1/p3/0', expand: ['1/p3'] }, flashed: 'name | 字符串 | | 名字' }) diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 40197ef..49ac19f 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -133,13 +133,9 @@ interface BlockViewBase { anchor: string /** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table)。筛选用它;图标不看它,看 concepts */ kind: string - /** 编辑范围的起止行,0 起:块的完整源码,头部连同各子孙块、结尾(块编辑时子孙都不画,见 coveredByEditing) */ + /** 编辑范围的起止行,0 起:块的完整源码,头部连同各子孙块、结尾(块编辑时子孙都不画,见 coveredByEditing);编辑框里的源码按它从文档的源码里取(见 blockSource) */ startLine: number endLine: number - /** 编辑范围的源码,去掉共同缩进 */ - code: string - /** 源码着色(拼起来等于 code) */ - codeTokens: Segment[] /** 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围:跳到的定义就是它时闪整块 */ defines?: SourceRange /** 筛选属性:属性 id → 值 id(含 kind),都是和语言无关的英文 id,显示名在 DocumentMessage.attributes 里 */ @@ -245,11 +241,23 @@ export interface DefinitionPeek { names: ShownName[] } +/** + * 整份源码的着色:按源码先后一段段排,相邻同词性的连成一段;runs 两个数一组:这段有几个字符(UTF-16,换行符也算),词性是 roles 里的第几个。 + * 编辑框给一块源码上色时按行取(见 blockSource)。 + */ +export interface SourceColors { + roles: TokenRole[] + runs: number[] +} + export interface DocumentMessage { type: 'document' version: number languageId: string blocks: BlockView[] + /** 整份源码和它的着色,只这一份:编辑框里一块的源码、着色按它的编辑范围取;解析失败、语言不支持时是空的(没有块) */ + source: string + colors: SourceColors /** 这份文件里出现过的筛选属性和值(带显示名、块数),按筛选栏的先后排:通用属性在前,语言自己的在后 */ attributes: FileAttribute[] translators: TranslatorOption[] diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index 7d3b442..1e130c6 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -15,7 +15,7 @@ function memoryStorage(initial?: unknown): ViewStateStorage { } function block(id: string, kind: BlockKind, children: BlockView[] = []): BlockView { - return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation: [] } + return { id, kind, startLine: 0, endLine: 0, attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation: [] } } /**