⚡ 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']])
})
})