⚡ perf(vscode): 代码阅读预览的文档消息里源码和着色只带一份:块不再各带一份连子块在内的整段源码和上色,编辑框按块的编辑范围从整份源码和着色里取,5126 行的消息 4.3MB → 1.9MB

This commit is contained in:
cruldra committed 2026-10-13 15:50:33 +11:00
1 parent 0371712176
commit 2e3602242e
43 files changed
+501 -239

No files matched your search

+1 -1
View File
@@ -108,7 +108,7 @@ describe('块的筛选属性:宿主从 kind 和 facts 推出通用属性', ()
})
function view(id: string, attributes: Record<string, string>, 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('文件里出现过的属性', () => {
+64 -52
View File
@@ -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 <span />'))).toBe(source.replace('return null', 'return <span />'))
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 <span />'))).toBe(source.replace('return null', 'return <span />'))
})
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<K, T extends Entity = Entity> extends Base<string> implements Loader<T>, 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<Segment[]> => {
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 () => {
+56 -54
View File
@@ -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<BlockView, 'startLine' | 'endLine'>) => 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<TokenRole, number>()
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<BlockView, 'startLine' | 'endLine' | 'code' | 'codeTokens'> => {
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<BlockView, 'startLine' | 'endLine'> => ({ 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)
+38 -26
View File
@@ -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<SourcedBlocks> {
return { blocks, source }
}
/** 手写的一块:只填比对用得到的几样。 */
function view(anchor: string, kind: string, code: string, extra: Partial<BlockView> = {}): 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> = {}): 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<BlockVi
} as BlockView
}
/** 名字写在源码开头:defines 指向它。 */
function named(anchor: string, kind: string, name: string, code: string, source: { text: string }): BlockView {
const startIndex = source.text.length
source.text += name
return view(anchor, kind, code, { defines: { startIndex, endIndex: startIndex + name.length } })
/** 名字先接在源码后面(defines 指向它),再接代码。 */
function named(anchor: string, kind: string, name: string, code: string, source: Written): BlockView {
const { startIndex } = append(source, name)
return view(anchor, kind, code, source, { defines: { startIndex, endIndex: startIndex + name.length } })
}
const RELEASE_LUA = `_RELEASE_LUA = """
@@ -127,7 +137,7 @@ describe('alignSiblings', () => {
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])
})
})
+20 -7
View File
@@ -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<string, number[]>()
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<string, ChangedBlock> = {}
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)
+2 -2
View File
@@ -16,11 +16,11 @@ function row(label: string, value: Inline[] | { markdown: string }, items?: Prop
}
function textView(translation: Inline[] | { markdown: string }, extra: Partial<BlockView> = {}): 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', () => {
+2 -2
View File
@@ -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)
})
})
@@ -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: [] }
+4 -1
View File
@@ -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[]
@@ -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 }
@@ -402,6 +402,8 @@ class Preview {
version: this.document.version,
languageId: '',
blocks: [],
source: '',
colors: { roles: [], runs: [] },
attributes: [],
translators: [],
translatorId: '',
@@ -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'))
}
}
})
+4 -3
View File
@@ -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',
+5 -4
View File
@@ -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'))
})
+3 -2
View File
@@ -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):
+3 -3
View File
@@ -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 = {'))
})
@@ -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'))
})
+4 -3
View File
@@ -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'))
})
+4 -4
View File
@@ -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')
})
+2 -2
View File
@@ -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 () => {
@@ -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 () => {
+34 -2
View File
@@ -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<T>(adapter: LanguageAdapter, languageId: string, source: string, use: (blocks: CodeBlock[]) => T): Promise<T> {
const tree = await runtime.parse(adapter, languageId, source)
@@ -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 () => {
@@ -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<BlockView[]> {
function readViews(source: string, withShell = true): Promise<DocumentViews> {
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<BlockView[]> {
return (await readViews(source, withShell)).blocks
}
async function readLines(source: string): Promise<string[]> {
@@ -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']])
})
})
@@ -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 {
@@ -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<BlockView>
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<Omit<BlockTreeState, 'folds'>> = {}, 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 &quot;a&quot;</textarea>')
expect(html).toContain('<span class="cr-role-keyword">if</span>')
expect(html).toContain('<span class="cr-role-string">&quot;a&quot;</span>')
})
})
@@ -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<string>
/** 刚点了「编辑」、要把光标放进去的块 */
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
: <span className="w-3.5 shrink-0" />}
<KindIcon concepts={block.concepts} />
<div className="min-w-0 flex-1">
{edit && shape.version !== undefined
{edit && shape.version !== undefined && shape.sourceText
? (
<CodeEditor
block={block}
sourceText={shape.sourceText}
version={shape.version}
autoFocus={shape.focus}
onAutoFocused={edit.onFocused}
@@ -41,8 +41,6 @@ function block(kind: string, id = kind, callTree?: BlockView['callTree']): Block
...(callTree && { callTree }),
startLine: 0,
endLine: 1,
code: '',
codeTokens: [],
attributes: { kind },
concepts: [{ id: kind, label: kind, icon: kind === 'class' ? 'box' : 'braces' }],
children: [],
@@ -54,7 +52,7 @@ function block(kind: string, id = kind, callTree?: BlockView['callTree']): Block
function tree(callTree?: CallTreeState & { onOpen: (anchor: string) => void }, edit = true): BlockTreeHandle {
return {
state: staticStore(treeState({
...(edit && { edit: { version: 1, editing: new Set<string>(), focusId: null } }),
...(edit && { edit: { version: 1, sourceText: { source: '', colors: { roles: [], runs: [] } }, editing: new Set<string>(), focusId: null } }),
collapsed: new Set(),
hidden: new Set(),
temporary: new Set(),
@@ -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<HTMLTextAreaElement>(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}
/>
<pre className="cr-code" aria-hidden="true">
<SegmentText segments={recolor(block.codeTokens, block.code, text)} />
<SegmentText segments={recolor(tokens, code, text)} />
{text === '' || text.endsWith('\n') ? ' ' : ''}
</pre>
</div>
@@ -12,8 +12,6 @@ function block(id: string, kind: string, extra: Partial<BlockView>): BlockView {
kind,
startLine: 0,
endLine: 0,
code: '',
codeTokens: [],
attributes: { kind },
concepts: [{ id: kind, label: kind, icon: 'box' }],
children: [],
@@ -15,8 +15,6 @@ function block(kind: string, extra: Partial<BlockView>): BlockView {
kind,
startLine: 0,
endLine: 0,
code: '',
codeTokens: [],
attributes: { kind },
concepts: [{ id: kind, label: kind, icon: 'square-function' }],
children: [],
@@ -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))
@@ -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' })
})
})
@@ -0,0 +1,81 @@
import type { DocumentMessage, Segment, TokenRole } from './types'
/** 文档的整份源码和它的着色(DocumentMessage 只带这一份,各块按编辑范围取) */
export type SourceText = Pick<DocumentMessage, 'source' | 'colors'>
/** 一块在编辑框里的样子:编辑范围整行源码去掉共同缩进、行间用 \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,
}
}
@@ -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: [],
}
@@ -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> = {}): 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 {
@@ -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: [] }
}
/**
@@ -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<string, string> = {}, 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<string>()
@@ -9,8 +9,6 @@ function block(id: string, kind: string, extra: Partial<BlockView> = {}): BlockV
kind,
startLine: 0,
endLine: 0,
code: '',
codeTokens: [],
attributes: { kind },
concepts: [],
children: [],
@@ -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<Page> {
const NONE: ReadonlySet<string> = new Set()
const NO_BLOCKS: BlockView[] = []
const NO_COUNTS: ReadonlyMap<string, number> = new Map()
const NO_COLORS: SourceColors = { roles: [], runs: [] }
/** 只记上一次:参数(逐个同一个对象)和上次一样就交回上次的结果,同 memoize-one */
function memoizeOne<A extends unknown[], R>(compute: (...args: A) => R): (...args: A) => R {
@@ -108,8 +110,10 @@ export function createPageStore(initial: Pick<PageInputs, 'editing' | 'folds'>):
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<string>,
focusId: string | null,
counts: ReadonlyMap<string, number>,
@@ -121,7 +125,7 @@ export function createPageStore(initial: Pick<PageInputs, 'editing' | 'folds'>):
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<PageInputs, 'editing' | 'folds'>):
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),
}
}
@@ -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 | 字符串 | | 名字' })
+13 -5
View File
@@ -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[]
@@ -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: [] }
}
/**