🐛 fix(vscode): 代码阅读编辑父卡片时显示整块代码(含子块)
- 块的编辑范围改成它在源码里的完整范围:头部连同各子孙块、收尾括号,保存时按整段写回;子卡片只有它自己连同子孙 - 范围由宿主统一算(blockSpans),各语言插件同一条路:else / elif / catch 这类展平的兄弟块按各块开头切开整条语句,最后一块到语句结尾(Shell 的 fi 归它) - 库规则卡片的编辑范围并进同一套算法,去掉 claimedShape - 块编辑时它的子孙都不画(代码都在编辑框里),点「编辑」时重叠的祖先、子孙、共用一行的兄弟块退出编辑 - 问 AI 的外层块开头改用新加的 headerEndLine,位置记号照旧按头部算,已有问答不受影响 - 着色改成整份源码算一次、各块按行取,编辑范围变大后排视图数据不变慢
This commit is contained in:
1 parent
af63d2bc5c
commit
70ea22543f
13 files changed
+251
-143
No files matched your search
@@ -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, code: '', codeTokens: [], attributes, children, translation: [] }
|
||||
return { id, anchor: id, kind: 'statement', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes, children, translation: [] }
|
||||
}
|
||||
|
||||
describe('文件里出现过的属性', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { LanguageAdapter, Translator } from './contract'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readBlockViews, readDefinitionKind, toBlockViews } from './blockView'
|
||||
import { replaceLines } from './editText'
|
||||
import { createBashAdapter } from './languages/bash'
|
||||
import { createPythonAdapter } from './languages/python'
|
||||
import { createTypeScriptAdapter } from './languages/typescript'
|
||||
import { previewText, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from './testSupport'
|
||||
@@ -11,6 +12,7 @@ import { typescriptZhTranslator } from './translators/typescriptZh'
|
||||
|
||||
const ts = createTypeScriptAdapter(WASM_DIR)
|
||||
const py = createPythonAdapter(WASM_DIR)
|
||||
const bash = createBashAdapter(WASM_DIR)
|
||||
|
||||
function views(adapter: LanguageAdapter, languageId: string, translator: Translator | undefined, source: string): Promise<BlockView[]> {
|
||||
return withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, { source, language: adapter, markers: [] }, translator))
|
||||
@@ -46,7 +48,7 @@ function translationText(view: BlockView): string {
|
||||
}
|
||||
|
||||
describe('blockView', () => {
|
||||
it.each(SAMPLES)('%#: codeTokens 拼起来等于 code,code 是 header 整行去共同缩进', async (adapter, languageId, translator, fixture) => {
|
||||
it.each(SAMPLES)('%#: codeTokens 拼起来等于 code,code 是编辑范围整行去共同缩进', 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))) {
|
||||
@@ -68,20 +70,76 @@ describe('blockView', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each(SAMPLES)('%#: 各块头部的行互不重叠,每行源码最多归一块编辑', async (adapter, languageId, translator, fixture) => {
|
||||
const owner = new Map<number, string>()
|
||||
for (const view of flatten(await views(adapter, languageId, translator, readFixture(fixture)))) {
|
||||
for (let line = view.startLine; line <= view.endLine; line++) {
|
||||
expect(owner.get(line), `第 ${line} 行又归 ${view.id}`).toBeUndefined()
|
||||
owner.set(line, view.id)
|
||||
}
|
||||
it.each(SAMPLES)('%#: 编辑范围是块的完整范围:盖住各子孙块,兄弟块之间最多共用一行,首尾不是空行,原样写回不变;头部是开头那几行', async (adapter, languageId, translator, fixture) => {
|
||||
const source = readFixture(fixture)
|
||||
const lines = source.split('\n')
|
||||
const visit = (list: BlockView[], parent: BlockView | undefined): void => {
|
||||
list.forEach((view, i) => {
|
||||
expect(lines[view.startLine].trim(), view.id).not.toBe('')
|
||||
expect(lines[view.endLine].trim(), view.id).not.toBe('')
|
||||
expect(view.headerEndLine, view.id).toBeGreaterThanOrEqual(view.startLine)
|
||||
expect(view.headerEndLine, view.id).toBeLessThanOrEqual(view.endLine)
|
||||
if (parent) {
|
||||
expect(view.startLine, `${parent.id} 盖住 ${view.id}`).toBeGreaterThanOrEqual(parent.startLine)
|
||||
expect(view.endLine, `${parent.id} 盖住 ${view.id}`).toBeLessThanOrEqual(parent.endLine)
|
||||
}
|
||||
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)
|
||||
visit(view.children, view)
|
||||
})
|
||||
}
|
||||
visit(await views(adapter, languageId, translator, source), undefined)
|
||||
})
|
||||
|
||||
it('编辑父块时编辑框里是整块代码:头部、各子块、结尾都在,改完整块写回;子块的编辑框只有它自己连同它的子块', async () => {
|
||||
const source = [
|
||||
'/** 窗口顶部 40px.',
|
||||
'侧栏开关只在侧栏顶部. */',
|
||||
'export const DesktopTitlebar = ({ platform }: { platform: DesktopShell["platform"] }) => {',
|
||||
' if (platform === "macos") {',
|
||||
' return null',
|
||||
' }',
|
||||
' return <div data-tauri-drag-region="deep" />',
|
||||
'}',
|
||||
'',
|
||||
].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 />'))
|
||||
})
|
||||
|
||||
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])
|
||||
|
||||
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) {'],
|
||||
['else', 2, 4, '} else if (b) {\n y()\n}'],
|
||||
['else', 5, 7, 'else {\n z()\n}'],
|
||||
['try', 8, 10, 'try {\n x()\n}'],
|
||||
['catch', 11, 13, 'catch {\n y()\n}'],
|
||||
])
|
||||
expect(await code(py, 'python', 'if a:\n x()\nelif b:\n y()\nelse:\n z()\n')).toEqual([
|
||||
['if', 0, 1, 'if a:\n x()'],
|
||||
['else', 2, 3, 'elif b:\n y()'],
|
||||
['else', 4, 5, 'else:\n z()'],
|
||||
])
|
||||
expect(await code(bash, 'shellscript', 'if [ -n "$a" ]; then\n x\nelif [ -n "$b" ]; then\n y\nelse\n z\nfi\n')).toEqual([
|
||||
['if', 0, 1, 'if [ -n "$a" ]; then\n x'],
|
||||
['else', 2, 3, 'elif [ -n "$b" ]; then\n y'],
|
||||
['else', 4, 6, 'else\n z\nfi'],
|
||||
])
|
||||
})
|
||||
|
||||
it('函数的说明算在函数块的头部里:编辑时和签名一起显示、一起改', async () => {
|
||||
const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))
|
||||
expect(byId(env, '5')).toMatchObject({ kind: 'function', startLine: 17, endLine: 22 })
|
||||
expect(byId(env, '5').code.split('\n')).toEqual([
|
||||
expect(byId(env, '5')).toMatchObject({ kind: 'function', startLine: 17, headerEndLine: 22, endLine: 29 })
|
||||
expect(byId(env, '5').code.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',
|
||||
@@ -93,8 +151,8 @@ 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, endLine: 37 })
|
||||
expect(getUser?.code.split('\n').slice(-5)).toEqual([
|
||||
expect(getUser).toMatchObject({ startLine: 28, headerEndLine: 37 })
|
||||
expect(getUser?.code.split('\n').slice(5, 10)).toEqual([
|
||||
') -> UserDetailResponse:',
|
||||
' """获取用户详情。',
|
||||
'',
|
||||
@@ -106,8 +164,8 @@ 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, endLine: 8 })
|
||||
expect(repo.code.split('\n').slice(-3)).toEqual([
|
||||
expect(repo).toMatchObject({ kind: 'class', startLine: 2, headerEndLine: 8 })
|
||||
expect(repo.code.split('\n').slice(4, 7)).toEqual([
|
||||
' */',
|
||||
'@Injectable()',
|
||||
'export abstract class Repository<K, T extends Entity = Entity> extends Base<string> implements Loader<T>, Disposable {',
|
||||
@@ -115,8 +173,8 @@ describe('blockView', () => {
|
||||
expect('properties' in repo && repo.properties[1]).toEqual({ label: '说明', value: { markdown: '仓库基类。\n\n子类实现 `load`。' } })
|
||||
|
||||
const notFound = byId(await views(py, 'python', pythonZhTranslator, readFixture('classes.py')), '3')
|
||||
expect(notFound).toMatchObject({ kind: 'class', startLine: 17, endLine: 19 })
|
||||
expect(notFound.code.split('\n')).toEqual([
|
||||
expect(notFound).toMatchObject({ kind: 'class', startLine: 17, headerEndLine: 19 })
|
||||
expect(notFound.code.split('\n').slice(0, 3)).toEqual([
|
||||
'class NotFound(AppResponse): # 行尾',
|
||||
' # 说明前面的注释',
|
||||
' """找不到资源。"""',
|
||||
@@ -157,7 +215,7 @@ describe('blockView', () => {
|
||||
'值: "uq_case_library_deployment"',
|
||||
],
|
||||
},
|
||||
{ id: '1', kind: 'class', lines: [4, 4], rows: ['名称: CaseStatus', '访问级别: 公共'] },
|
||||
{ id: '1', kind: 'class', lines: [4, 10], rows: ['名称: CaseStatus', '访问级别: 公共'] },
|
||||
{ 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用户可编辑.' } },
|
||||
])
|
||||
@@ -179,8 +237,13 @@ 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, code: 'if (!folder)' })
|
||||
expect(byId(env, '7.2.1')).toMatchObject({ kind: 'catch', startLine: 45, code: 'catch (err) {' })
|
||||
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 issue = await views(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt'))
|
||||
expect(byId(issue, '2.4')).toMatchObject({
|
||||
@@ -223,7 +286,8 @@ describe('blockView', () => {
|
||||
const signature = byId(sample, '5').codeTokens
|
||||
expect(signature).toContainEqual({ text: 'def', role: 'storage' })
|
||||
expect(signature).toContainEqual({ text: 'find_env_files', role: 'function' })
|
||||
expect(signature.filter(token => token.text === 'str')).toEqual([{ text: 'str', role: 'type' }, { text: 'str', role: 'type' }])
|
||||
// 签名里注解的 str 是类型,函数体里调用的 str(err) 是函数
|
||||
expect(signature.filter(token => token.text === 'str')).toEqual([{ text: 'str', role: 'type' }, { text: 'str', role: 'type' }, { text: 'str', role: 'function' }])
|
||||
})
|
||||
|
||||
it('块自己的名字(defines):函数名、类名(赋给变量的类表达式取变量名)、只声明一个变量时的变量名(解构模式整个算);导入、一条语句几个变量、一行译文的块、匿名类没有', async () => {
|
||||
@@ -239,7 +303,7 @@ describe('blockView', () => {
|
||||
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 cond = byId(list, '0.0')
|
||||
expect(cond).toMatchObject({ kind: 'if', startLine: 1, endLine: 2, code: 'if (a &&\n\tb) {', translation: [] })
|
||||
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(byId(list, '0')).toMatchObject({ kind: 'function', properties: [] })
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Tree } from '@vscode/tree-sitter-wasm'
|
||||
import type { BlockAttributes, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, StepBlocks, TextBlock, TokenRole, Translator } from './contract'
|
||||
import type { BlockAttributes, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract'
|
||||
import type { TreeSitterRuntime } from './runtime'
|
||||
import { blockAttributes } from './attributes'
|
||||
import { commonIndent, splitLines } from './editText'
|
||||
import { activeRules, claimBlock, claimedShape, importsIn } from './libraryRules'
|
||||
import { activeRules, claimBlock, importsIn } from './libraryRules'
|
||||
import { BlockAnchors } from './qa/anchor'
|
||||
|
||||
interface BlockViewBase {
|
||||
@@ -14,11 +14,13 @@ interface BlockViewBase {
|
||||
/** BlockKind,或库规则卡片的种类 id(RuleCard.kind,带点,例如 sqlalchemy.table)。 */
|
||||
kind: string
|
||||
/**
|
||||
* 编辑范围的起止行,0 起:一般是 header;库规则认领的块是原块整条语句(见 claimedShape),盖住它留下的子块的行。
|
||||
* 同一时刻能编辑的几段不重叠:块编辑时,webview 不画编辑范围和它重叠的子孙块,点「编辑」时和它重叠的别的块退出编辑。
|
||||
* 编辑范围的起止行,0 起:块在源码里的完整范围,头部连同各子孙块、结尾(见 blockSpans);库规则认领的块是原块整条语句。
|
||||
* 块编辑时它的子孙都在编辑框里,webview 不画它们;点「编辑」时和它重叠的别的块退出编辑,同一行不会同时在两处能改。
|
||||
*/
|
||||
startLine: number
|
||||
endLine: number
|
||||
/** 块自己那部分(头部)的最后一行:复合语句到主体开始之前,库规则认领的块是整条语句。问 AI 时贴外层块的开头用。 */
|
||||
headerEndLine: number
|
||||
/** 编辑范围的整行源码,去掉共同缩进。 */
|
||||
code: string
|
||||
/** 源码着色(拼起来等于 code)。 */
|
||||
@@ -70,22 +72,29 @@ interface ColorLayer {
|
||||
tokenRole: (leaf: Node) => TokenRole
|
||||
}
|
||||
|
||||
/** 按语法树叶子给 [startRow, endRow] 这几行着色:每个有词性的叶子一段,空白、标点等 plain 的连成一段;后面的层盖过前面的。 */
|
||||
function colorLines(layers: readonly ColorLayer[], source: string, lines: Line[], startRow: number, endRow: number, indent: number): Segment[] {
|
||||
const from = lines[startRow].start
|
||||
const to = lines[endRow].end
|
||||
const owner = new Int32Array(to - from).fill(-1)
|
||||
/** 源码每个字符归哪个叶子(-1 是不归哪个叶子的空白),和各叶子的词性:一份源码算一次,各块按行取。 */
|
||||
interface Coloring {
|
||||
owner: Int32Array
|
||||
roles: TokenRole[]
|
||||
}
|
||||
|
||||
/** 各着色层依次铺上去,后面的层(嵌着的代码)盖住前面的。 */
|
||||
function coloring(layers: readonly ColorLayer[], source: string): Coloring {
|
||||
const owner = new Int32Array(source.length).fill(-1)
|
||||
const roles: TokenRole[] = []
|
||||
for (const { root, tokenRole } of layers) {
|
||||
const leaves: Node[] = []
|
||||
leavesIn(root.descendantForIndex(from, to) ?? root, from, to, leaves)
|
||||
leavesIn(root, 0, source.length, leaves)
|
||||
for (const leaf of leaves) {
|
||||
roles.push(tokenRole(leaf))
|
||||
for (let at = Math.max(leaf.startIndex, from); at < Math.min(leaf.endIndex, to); at++)
|
||||
owner[at - from] = roles.length - 1
|
||||
owner.fill(roles.length - 1, leaf.startIndex, Math.min(leaf.endIndex, source.length))
|
||||
}
|
||||
}
|
||||
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 => {
|
||||
@@ -100,9 +109,9 @@ function colorLines(layers: readonly ColorLayer[], source: string, lines: Line[]
|
||||
for (let row = startRow; row <= endRow; row++) {
|
||||
const line = lines[row]
|
||||
if (row > startRow)
|
||||
put('\n', owner[line.start - 1 - from])
|
||||
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 - from])
|
||||
put(source[at], owner[at])
|
||||
}
|
||||
return segments
|
||||
}
|
||||
@@ -170,8 +179,50 @@ export interface RuleRun {
|
||||
|
||||
const NO_RULES: RuleRun = { rules: [], report: () => {} }
|
||||
|
||||
/** 源码里一段的结尾:到哪个字符为止(不含),那个字符在第几行。 */
|
||||
type SpanEnd = Pick<SourceSpan, 'endIndex' | 'endRow'>
|
||||
|
||||
function nodeEnd(node: Node): SpanEnd {
|
||||
return { endIndex: node.endIndex, endRow: node.endPosition.row }
|
||||
}
|
||||
|
||||
function later(a: SpanEnd, b: SpanEnd): SpanEnd {
|
||||
return b.endIndex > a.endIndex ? b : a
|
||||
}
|
||||
|
||||
/** 各子孙块的头部、节点里最靠后的结尾,和 end 比取靠后的:子孙一般写在块的节点里,Dockerfile 阶段的指令写在 FROM 后面。 */
|
||||
function descendantsEnd(blocks: readonly CodeBlock[], end: SpanEnd): SpanEnd {
|
||||
return blocks.reduce((acc, child) => descendantsEnd(child.children, later(later(acc, child.header), nodeEnd(child.node))), end)
|
||||
}
|
||||
|
||||
/** index 之前最后一个非空白字符的结尾,不早于头部开头。 */
|
||||
function lastCharBefore(source: string, header: SourceSpan, index: number): SpanEnd {
|
||||
let end = index
|
||||
while (end > header.startIndex && /\s/.test(source[end - 1]))
|
||||
end--
|
||||
return { endIndex: end, endRow: header.startRow + splitLines(source.slice(header.startIndex, end)).length - 1 }
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码块树 → 视图数据:header 整行源码去共同缩进、按语法树着色、调用翻译插件。
|
||||
* 一串兄弟块各自在源码里的完整范围:从头部开头,到头部、所在语句、各子孙块里最靠后的那个字符。
|
||||
* 语言插件把 else、catch 这类子句展平成紧跟着的兄弟块,它们的节点都落在打头那块的节点(整条 if / try)里:
|
||||
* 这条语句按各块的开头切开,每块到下一块开头之前最后一个非空白字符为止,最后一块到语句结尾(Shell 的 fi 归它)。
|
||||
*/
|
||||
function blockSpans(list: readonly CodeBlock[], source: string): SourceSpan[] {
|
||||
let statement: Node | undefined
|
||||
return list.map((block, i) => {
|
||||
if (!statement || block.node.startIndex < statement.startIndex || block.node.endIndex > statement.endIndex)
|
||||
statement = block.node
|
||||
const next = list[i + 1]
|
||||
const nextStart = next && Math.min(next.header.startIndex, next.node.startIndex)
|
||||
const until = nextStart !== undefined && nextStart < statement.endIndex ? lastCharBefore(source, block.header, nextStart) : nodeEnd(statement)
|
||||
const { endIndex, endRow } = descendantsEnd(block.children, later(block.header, until))
|
||||
return { startIndex: block.header.startIndex, endIndex, startRow: block.header.startRow, endRow }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码块树 → 视图数据:块的完整范围整行源码去共同缩进、按语法树着色,调用翻译插件。
|
||||
* 每块先问启用的库规则,认领了就换成规则给的替身(见 RuleBlock):规则的卡片,或认成的属性表块(交给翻译插件排,同语言插件报的这种块),
|
||||
* 并进去的子块不再单独成块。
|
||||
*/
|
||||
@@ -186,12 +237,12 @@ export function toBlockViews(
|
||||
const lines = lineRanges(source)
|
||||
const texts = splitLines(source)
|
||||
const outer = blocks[0]?.node.tree.rootNode
|
||||
const colors: ColorLayer[] = [
|
||||
const colors = coloring([
|
||||
...(outer ? [{ root: outer, tokenRole: document.language.tokenRole }] : []),
|
||||
...[...embedded.values()].map(part => ({ root: part.root, tokenRole: part.document.language.tokenRole })),
|
||||
]
|
||||
/** startRow..endRow 这几行整行源码去共同缩进,和它的着色(嵌着的代码按它那种语言着色)。 */
|
||||
const sourceRows = (startRow: number, endRow: number): Pick<BlockView, 'startLine' | 'endLine' | 'code' | 'codeTokens'> => {
|
||||
], 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 {
|
||||
@@ -201,11 +252,16 @@ export function toBlockViews(
|
||||
codeTokens: colorLines(colors, source, lines, startRow, endRow, indent),
|
||||
}
|
||||
}
|
||||
/** 一段占的整行源码:没名字的块按它认(见 BlockAnchors.place)。 */
|
||||
const rowsText = ({ startRow, endRow }: SourceSpan): string => texts.slice(startRow, endRow + 1).join('\n')
|
||||
/** 按源码先后逐块给位置记号:先序遍历,块的记号在它的子块之前要 */
|
||||
const anchors = new BlockAnchors()
|
||||
|
||||
/** 一种语言的块怎么转:用这种语言的源码、翻译插件和库规则(嵌着的代码没有库规则)。 */
|
||||
function layer(layerBlocks: readonly CodeBlock[], layerDocument: SourceDocument, layerTranslator: Translator | undefined, layerRules: RuleRun): (block: CodeBlock, id: string, scope: string) => BlockView {
|
||||
/**
|
||||
* 一种语言的块怎么转:用这种语言的源码、翻译插件和库规则(嵌着的代码没有库规则)。
|
||||
* 转的是一串兄弟块:各块的完整范围(编辑范围)按这一串算(见 blockSpans),id 是 prefix 接上它在这一串里的位置(从 from 数起)。
|
||||
*/
|
||||
function layer(layerBlocks: readonly CodeBlock[], layerDocument: SourceDocument, layerTranslator: Translator | undefined, layerRules: RuleRun): (list: readonly CodeBlock[], prefix: string, scope: string, from?: number) => BlockView[] {
|
||||
const imports = importsIn(layerBlocks)
|
||||
const active = activeRules(layerRules.rules, imports)
|
||||
const context: RuleContext = {
|
||||
@@ -231,58 +287,63 @@ export function toBlockViews(
|
||||
return []
|
||||
const steps = part.steps.blocks
|
||||
const [only] = steps
|
||||
const kids = steps.length === 1 && only ? only.children : steps
|
||||
const build = layer(steps, part.document, part.translator, NO_RULES)
|
||||
return kids.map((child, i) => build(child, `${id}.${from + i}`, scope))
|
||||
return layer(steps, part.document, part.translator, NO_RULES)(steps.length === 1 && only ? only.children : steps, `${id}.`, scope, from)
|
||||
}
|
||||
/** 块照它自己的样子(语言插件报的,或库规则认成的属性表块):头部是编辑范围,子块照常交给各规则看。scope 见 BlockAnchors.place */
|
||||
function plain(block: CodeBlock, id: string, scope: string): BlockView {
|
||||
/** 块照它自己的样子(语言插件报的,或库规则认成的属性表块):编辑范围是 span,头部是块自己那部分,子块照常交给各规则看。scope 见 BlockAnchors.place */
|
||||
function plain(block: CodeBlock, span: SourceSpan, id: string, scope: string): BlockView {
|
||||
const name = ownName(block)
|
||||
const rows = sourceRows(block.header.startRow, block.header.endRow)
|
||||
const { anchor, inner } = anchors.place(scope, block.kind, name?.text, rows.code)
|
||||
const { anchor, inner } = anchors.place(scope, block.kind, name?.text, rowsText(block.header))
|
||||
return {
|
||||
id,
|
||||
anchor,
|
||||
kind: block.kind,
|
||||
...rows,
|
||||
...sourceRows(span),
|
||||
headerEndLine: block.header.endRow,
|
||||
...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }),
|
||||
attributes: blockAttributes(block),
|
||||
children: [
|
||||
...block.children.map((child, i) => build(child, `${id}.${i}`, inner)),
|
||||
...list(block.children, `${id}.`, inner),
|
||||
...embeddedChildren(block, id, inner, block.children.length),
|
||||
],
|
||||
...('facts' in block ? { properties: layerTranslator ? layerTranslator.describe(block, layerDocument) : [] } : textView(block)),
|
||||
}
|
||||
}
|
||||
function build(block: CodeBlock, id: string, scope: string): BlockView {
|
||||
/**
|
||||
* 块先问启用的库规则,没认领就照它自己的样子。认领了的:规则讲的是整条语句,头部就是整个范围;
|
||||
* 规则没并进来的子块(方法、别的成员)不管写在哪,都照源码顺序留作它的子块。
|
||||
*/
|
||||
function build(block: CodeBlock, span: SourceSpan, id: string, scope: string): BlockView {
|
||||
const claim = active.length > 0 ? claimBlock(block, active, context, layerRules.report) : undefined
|
||||
if (!claim)
|
||||
return plain(block, id, scope)
|
||||
const { span, kept } = claimedShape(block, claim.block.absorbs)
|
||||
return plain(block, span, id, scope)
|
||||
const kept = block.children.filter(child => !claim.block.absorbs.includes(child))
|
||||
if ('as' in claim.block) {
|
||||
const { attributes } = block
|
||||
return plain({ ...claim.block.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, id, scope)
|
||||
return plain({ ...claim.block.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, span, id, scope)
|
||||
}
|
||||
const { kind, badge, rows, defines, attributes } = claim.block
|
||||
const lines = sourceRows(span.startRow, span.endRow)
|
||||
const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), lines.code)
|
||||
const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), rowsText(span))
|
||||
return {
|
||||
id,
|
||||
anchor,
|
||||
kind,
|
||||
...lines,
|
||||
...sourceRows(span),
|
||||
headerEndLine: span.endRow,
|
||||
...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }),
|
||||
attributes: { ...blockAttributes(block), ...attributes, kind },
|
||||
badge,
|
||||
children: kept.map((child, i) => build(child, `${id}.${i}`, inner)),
|
||||
children: list(kept, `${id}.`, inner),
|
||||
properties: rows,
|
||||
}
|
||||
}
|
||||
return build
|
||||
function list(siblings: readonly CodeBlock[], prefix: string, scope: string, from = 0): BlockView[] {
|
||||
const spans = blockSpans(siblings, source)
|
||||
return siblings.map((block, i) => build(block, spans[i], `${prefix}${from + i}`, scope))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
const build = layer(blocks, document, translator, rules)
|
||||
return blocks.map((block, i) => build(block, String(i), ''))
|
||||
return layer(blocks, document, translator, rules)(blocks, '', '')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -459,7 +459,7 @@ export interface BlockShape {
|
||||
/** 这一块对应的语法树节点(整条语句)。 */
|
||||
node: Node
|
||||
/**
|
||||
* 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句。
|
||||
* 块自己那一段(预览讲的就是它):复合语句只到主体开始之前,其余是整条语句。编辑时宿主给的是整块:头部连同各子块、结尾。
|
||||
* 块自己的说明(facts.doc、TextBlock.doc)也算在里面:JSDoc 往上延到注释开头,docstring、属性 docstring 往下延到字符串结尾。
|
||||
*/
|
||||
header: SourceSpan
|
||||
@@ -788,7 +788,7 @@ export interface RuleCard {
|
||||
/**
|
||||
* 并进这一块的子块(原块 children 里的对象本身):不再单独成块,内容由 rows 讲。
|
||||
* 没并进来的子块(方法、别的成员、规则不讲的注释)不管写在哪,都照源码顺序照常是这一块的子块,也照常交给各规则看。
|
||||
* 这一块的编辑范围是原块整条语句(类就是整个类),盖住留下的子块:它编辑时预览不画这些子块,同一行不会同时在两处能改。
|
||||
* 这一块的编辑范围同别的块,是原块整条语句(类就是整个类),盖住留下的子块:它编辑时预览不画这些子块,同一行不会同时在两处能改。
|
||||
*/
|
||||
absorbs: CodeBlock[]
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ describe('库规则:认领', () => {
|
||||
? { kind: 'orm.card', badge: { label: '卡片', color: { dark: '#fff', light: '#000' }, icon: 'box' }, rows: [], absorbs: block.children.filter(child => child.kind === 'variable') }
|
||||
: undefined }
|
||||
const [, card] = await views(source, [fields])
|
||||
expect(card.children.map(child => [child.kind, child.startLine, child.endLine])).toEqual([['function', 5, 5]])
|
||||
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'))
|
||||
@@ -159,7 +159,7 @@ describe('库规则:认领', () => {
|
||||
expect(reports[1]).toBe('库规则 throwing 看第 3 行的 class 块时出错,这块照常显示:坏了')
|
||||
|
||||
const alone = await views(MODEL, [throwing])
|
||||
expect(alone[1]).toMatchObject({ kind: 'class', startLine: 2, endLine: 2 })
|
||||
expect(alone[1]).toMatchObject({ kind: 'class', startLine: 2, headerEndLine: 2, endLine: 8 })
|
||||
expect(alone[1].children.map(child => child.kind)).toEqual(['variable', 'comment', 'variable', 'function'])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AttributeNames, CodeBlock, ImportFacts, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, RuleLocation, RuleReference, RuleWorkspace, Segment, SourceSpan } from './contract'
|
||||
import type { AttributeNames, CodeBlock, ImportFacts, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, RuleLocation, RuleReference, RuleWorkspace, Segment } from './contract'
|
||||
|
||||
/** 宿主这边怎么用库规则:什么时候启用、问哪个规则、认领了之后编辑哪一段、留下哪些子块。规则本身只经 contract 拿信息。 */
|
||||
/** 宿主这边怎么用库规则:什么时候启用、问哪个规则、规则出错时怎么记。规则本身只经 contract 拿信息。 */
|
||||
|
||||
/** 这份源码里各处导入的 facts,照源码顺序(先父块、再子块)。 */
|
||||
export function importsIn(blocks: readonly CodeBlock[]): ImportFacts[] {
|
||||
@@ -97,25 +97,6 @@ export function ruleAttributeNames(rules: readonly LibraryRule[], displayLanguag
|
||||
})
|
||||
}
|
||||
|
||||
/** 源码里一个节点占的那段。 */
|
||||
function nodeSpan(node: Node): SourceSpan {
|
||||
return { startIndex: node.startIndex, endIndex: node.endIndex, startRow: node.startPosition.row, endRow: node.endPosition.row }
|
||||
}
|
||||
|
||||
/**
|
||||
* 认领的块的编辑范围和留下的子块:编辑范围是原块整条语句(从头部开始,到它连同各子块的最后一行,类就是整个类),「编辑」时整段一起改;
|
||||
* 规则没并进来的子块(方法、别的成员)不管写在哪,都照源码顺序留作它的子块。
|
||||
* 这些子块的行也在这段里:webview 在这块编辑时不画它们,同一行不会同时在两处能改。
|
||||
*/
|
||||
export function claimedShape(block: CodeBlock, absorbs: readonly CodeBlock[]): { span: SourceSpan, kept: CodeBlock[] } {
|
||||
const ends = [block.header, nodeSpan(block.node), ...block.children.flatMap(child => [child.header, nodeSpan(child.node)])]
|
||||
const last = ends.reduce((end, span) => span.endIndex > end.endIndex ? span : end)
|
||||
return {
|
||||
span: { ...block.header, endIndex: last.endIndex, endRow: last.endRow },
|
||||
kept: block.children.filter(child => !absorbs.includes(child)),
|
||||
}
|
||||
}
|
||||
|
||||
/** 文字里(连框里)带引用的段。 */
|
||||
function referencesInText(pieces: readonly Inline[]): Segment[] {
|
||||
return pieces.flatMap(piece => 'role' in piece ? (piece.reference ? [piece] : []) : referencesInText(piece.segments))
|
||||
|
||||
@@ -32,14 +32,9 @@ export function findBlock(blocks: BlockView[], anchor: string): { block: BlockVi
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 块连同子孙管到的最后一行(0 起):复合语句的头部之外还有主体。 */
|
||||
function lastLine(block: BlockView): number {
|
||||
return Math.max(block.endLine, ...block.children.map(lastLine))
|
||||
}
|
||||
|
||||
/** 这块代码占的行(1 起,给人看的写法)。 */
|
||||
/** 这块代码占的行(1 起,给人看的写法):块的完整范围,复合语句连同主体。 */
|
||||
export function blockLines(block: BlockView): { from: number, to: number } {
|
||||
return { from: block.startLine + 1, to: lastLine(block) + 1 }
|
||||
return { from: block.startLine + 1, to: block.endLine + 1 }
|
||||
}
|
||||
|
||||
function numbered(lines: string[], from: number, to: number, width: number): string[] {
|
||||
@@ -53,7 +48,7 @@ export function askPrompt(subject: AskSubject, question: string): string | undef
|
||||
return undefined
|
||||
const { block, outer } = found
|
||||
const lines = splitLines(subject.source)
|
||||
const last = Math.min(lastLine(block), lines.length - 1)
|
||||
const last = Math.min(block.endLine, lines.length - 1)
|
||||
const width = String(last + 1).length
|
||||
const shownLast = Math.min(last, block.startLine + BLOCK_LINES - 1)
|
||||
const { from, to } = blockLines(block)
|
||||
@@ -64,8 +59,8 @@ export function askPrompt(subject: AskSubject, question: string): string | undef
|
||||
]
|
||||
if (outer.length > 0) {
|
||||
const heads = outer.flatMap((item) => {
|
||||
const end = Math.min(item.endLine, item.startLine + OUTER_LINES - 1)
|
||||
return [...numbered(lines, item.startLine, end, width), ...(end < item.endLine ? [`${' '.repeat(width)} | …`] : [])]
|
||||
const end = Math.min(item.headerEndLine, item.startLine + OUTER_LINES - 1)
|
||||
return [...numbered(lines, item.startLine, end, width), ...(end < item.headerEndLine ? [`${' '.repeat(width)} | …`] : [])]
|
||||
})
|
||||
parts.push(`这块代码外面套着(各层外层块的开头):\n${heads.join('\n')}`)
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('sqlAlchemy 库规则', () => {
|
||||
expect(view.children, view.id).toEqual([])
|
||||
})
|
||||
|
||||
it('卡片的编辑范围是整个类,每行只在一块里能编辑', async () => {
|
||||
it('卡片的编辑范围是整个类,同一行只在块和它的子孙之间共用', async () => {
|
||||
const lines = SNAPSHOT.split('\n')
|
||||
const table = await model(SNAPSHOT, 'ReferralRelation')
|
||||
expect(lines[table.startLine]).toBe('class ReferralRelation(IntPK, TimestampMixin, Base):')
|
||||
@@ -134,7 +134,8 @@ describe('sqlAlchemy 库规则', () => {
|
||||
for (let line = view.startLine; line <= view.endLine; line++)
|
||||
owners.set(line, [...owners.get(line) ?? [], view.id])
|
||||
}
|
||||
expect([...owners.values()].filter(ids => ids.length > 1)).toEqual([])
|
||||
const nested = (ids: string[]): boolean => ids.every((id, i) => i === 0 || id.startsWith(`${ids[i - 1]}.`))
|
||||
expect([...owners.values()].filter(ids => !nested(ids))).toEqual([])
|
||||
expect(owners.get(lines.indexOf(' __tablename__ = "referral_relations"'))).toEqual([table.id])
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user