✨ feat(vscode): 代码阅读预览按住 ⌘ / Ctrl 点名字跳到定义
This commit is contained in:
1 parent
e5db1a2e65
commit
4e146eb48f
20 files changed
+870
-60
No files matched your search
@@ -93,7 +93,11 @@ export type TokenRole
|
||||
| 'hint' // 属性表里的说明词:未标注 无 N 个 默认导出 …
|
||||
| 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain'
|
||||
|
||||
export interface Segment { text: string, role: TokenRole }
|
||||
/**
|
||||
* 一段着色的文字。offset:这段照抄自源码里的名字(标识符、属性名、类型名、点分模块名的一节)或字符串时,
|
||||
* 它在源码里的起始下标(同 SourceSpan 的下标),预览拿它向 VS Code 查定义、跳过去;译词、提示词没有。
|
||||
*/
|
||||
export interface Segment { text: string, role: TokenRole, offset?: number }
|
||||
|
||||
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值。 */
|
||||
export interface PropertyItem { cells: Segment[][] }
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { DefinitionTarget } from './definitionAction'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { definitionAction } from './definitionAction'
|
||||
|
||||
const CURRENT = 'file:///ws/app/routes.py'
|
||||
|
||||
function at(uri: string, line: number, supported = true, character = 4): DefinitionTarget {
|
||||
return { uri, line, character, supported }
|
||||
}
|
||||
|
||||
describe('⌘ + 点击查到定义之后怎么跳', () => {
|
||||
it('没有定义:不动', () => {
|
||||
expect(definitionAction([], CURRENT)).toEqual({ kind: 'none' })
|
||||
})
|
||||
|
||||
it('定义在当前文件:预览原地滚过去', () => {
|
||||
const target = at(CURRENT, 12)
|
||||
expect(definitionAction([target], CURRENT)).toEqual({ kind: 'here', target })
|
||||
})
|
||||
|
||||
it('另一个文件、语言有代码阅读:用预览打开', () => {
|
||||
const target = at('file:///ws/luffy_agent/core/db.py', 30)
|
||||
expect(definitionAction([target], CURRENT)).toEqual({ kind: 'preview', target })
|
||||
})
|
||||
|
||||
it('另一个文件、语言没有代码阅读:用普通文本编辑器打开', () => {
|
||||
const target = at('file:///ws/native/ext.c', 7, false)
|
||||
expect(definitionAction([target], CURRENT)).toEqual({ kind: 'text', target })
|
||||
})
|
||||
|
||||
it('几处定义:让用户挑,挑好的一处再按上面的规则跳', () => {
|
||||
const stub = at('file:///ws/typings/db.pyi', 3)
|
||||
const source = at('file:///ws/luffy_agent/core/db.py', 30)
|
||||
expect(definitionAction([stub, source], CURRENT)).toEqual({ kind: 'pick', targets: [stub, source] })
|
||||
expect(definitionAction([source], CURRENT)).toEqual({ kind: 'preview', target: source })
|
||||
})
|
||||
|
||||
it('同一处报了几遍算一处;同一行不同列算两处', () => {
|
||||
const target = at('file:///ws/luffy_agent/core/db.py', 30)
|
||||
expect(definitionAction([target, { ...target }], CURRENT)).toEqual({ kind: 'preview', target })
|
||||
const other = at(target.uri, 30, true, 20)
|
||||
expect(definitionAction([target, other], CURRENT)).toEqual({ kind: 'pick', targets: [target, other] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
/** 一处定义。uri 是 Uri.toString(),用来和当前文件比、去重;supported:目标文件的语言有代码阅读。 */
|
||||
export interface DefinitionTarget {
|
||||
uri: string
|
||||
line: number
|
||||
character: number
|
||||
supported: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* ⌘ / Ctrl + 点击查到定义之后怎么做:
|
||||
* none 不动;pick 让用户从几处里挑一处,挑好的那处再交给 definitionAction 决定;
|
||||
* here 定义就在当前文件,预览原地滚过去;preview 用代码阅读预览打开目标文件;text 目标语言没有代码阅读,用普通文本编辑器打开。
|
||||
*/
|
||||
export type DefinitionAction<T extends DefinitionTarget>
|
||||
= | { kind: 'none' }
|
||||
| { kind: 'pick', targets: T[] }
|
||||
| { kind: 'here' | 'preview' | 'text', target: T }
|
||||
|
||||
/** 定义提供者查到的几处(可能重复)→ 怎么跳。current 是当前文件的 Uri.toString()。 */
|
||||
export function definitionAction<T extends DefinitionTarget>(targets: readonly T[], current: string): DefinitionAction<T> {
|
||||
const unique = targets.filter((target, i) =>
|
||||
targets.findIndex(other => other.uri === target.uri && other.line === target.line && other.character === target.character) === i)
|
||||
if (unique.length === 0)
|
||||
return { kind: 'none' }
|
||||
if (unique.length > 1)
|
||||
return { kind: 'pick', targets: unique }
|
||||
const [target] = unique
|
||||
if (target.uri === current)
|
||||
return { kind: 'here', target }
|
||||
return { kind: target.supported ? 'preview' : 'text', target }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Location, LocationLink, Position, Range, Uri } from 'vscode'
|
||||
import type { DefinitionTarget } from './definitionAction'
|
||||
import { commands, window, workspace } from 'vscode'
|
||||
import { logger } from '../../logging/logger'
|
||||
|
||||
/** VS Code 查到的一处定义,加上打开目标文件才知道的信息。 */
|
||||
export interface Definition extends DefinitionTarget {
|
||||
location: { uri: Uri, range: Range }
|
||||
/** 定义处在目标文件里的下标,预览靠它找到是哪块、哪一行属性 */
|
||||
offset: number
|
||||
/** 定义所在那一行的源码,列在挑选框里 */
|
||||
lineText: string
|
||||
}
|
||||
|
||||
/** 交给 VS Code 注册的定义提供者(Pylance、TypeScript 语言服务……),和普通编辑器里 ⌘ + 点击查的是同一套。 */
|
||||
async function provided(uri: Uri, position: Position): Promise<(Location | LocationLink)[]> {
|
||||
try {
|
||||
return await commands.executeCommand<(Location | LocationLink)[] | undefined>('vscode.executeDefinitionProvider', uri, position) ?? []
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `查定义失败 ${uri.toString()}:${position.line + 1}:${err instanceof Error ? err.message : String(err)}` })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasDefinition(uri: Uri, position: Position): Promise<boolean> {
|
||||
return (await provided(uri, position)).length > 0
|
||||
}
|
||||
|
||||
/** uri 里 position 处名字的所有定义;目标打不开成文本的(例如 Python 命名空间包指向的目录)略过。 */
|
||||
export async function findDefinitions(uri: Uri, position: Position, supports: (languageId: string) => boolean): Promise<Definition[]> {
|
||||
const found = await Promise.all((await provided(uri, position)).map(async (item): Promise<Definition[]> => {
|
||||
const location = 'targetUri' in item
|
||||
? { uri: item.targetUri, range: item.targetSelectionRange ?? item.targetRange }
|
||||
: { uri: item.uri, range: item.range }
|
||||
const { start } = location.range
|
||||
try {
|
||||
const document = await workspace.openTextDocument(location.uri)
|
||||
return [{
|
||||
uri: location.uri.toString(),
|
||||
line: start.line,
|
||||
character: start.character,
|
||||
supported: supports(document.languageId),
|
||||
location,
|
||||
offset: document.offsetAt(start),
|
||||
lineText: document.lineAt(start.line).text,
|
||||
}]
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `定义所在的 ${location.uri.toString()} 打不开:${err instanceof Error ? err.message : String(err)}` })
|
||||
return []
|
||||
}
|
||||
}))
|
||||
return found.flat()
|
||||
}
|
||||
|
||||
/** 几处定义里挑一处:每项是「相对路径:行号」,下面一行是那行源码。 */
|
||||
export async function pickDefinition<T extends Definition>(definitions: T[]): Promise<T | undefined> {
|
||||
const picked = await window.showQuickPick(
|
||||
definitions.map(definition => ({
|
||||
label: `${workspace.asRelativePath(definition.location.uri)}:${definition.line + 1}`,
|
||||
detail: definition.lineText.trim(),
|
||||
definition,
|
||||
})),
|
||||
{ placeHolder: '跳到哪一处定义', matchOnDetail: true },
|
||||
)
|
||||
return picked?.definition
|
||||
}
|
||||
@@ -6,6 +6,15 @@ import type { BlockView } from '../blockView'
|
||||
|
||||
export interface TranslatorOption { id: string, label: string }
|
||||
|
||||
/**
|
||||
* 让预览滚到包含 line 的块。definition 只在跳到定义时有:定义处那个名字在源码里的下标(Segment.offset),
|
||||
* 预览展开把它折叠住的块和属性行,再让那块闪几下。
|
||||
*/
|
||||
export interface Reveal {
|
||||
line: number
|
||||
definition?: number
|
||||
}
|
||||
|
||||
export interface DocumentMessage {
|
||||
type: 'document'
|
||||
/** TextDocument.version:webview 靠它判断是不是新一份文档 */
|
||||
@@ -15,18 +24,29 @@ export interface DocumentMessage {
|
||||
translators: TranslatorOption[]
|
||||
translatorId: string
|
||||
error?: string
|
||||
/** 从文本编辑器切过来时它顶部那一行:新预览只在第一份 document 里带,webview 滚到包含这行的块 */
|
||||
revealLine?: number
|
||||
/** 新预览只在第一份 document 里带:从文本编辑器切过来时是它顶部那一行,跳到定义时是定义处 */
|
||||
reveal?: Reveal
|
||||
}
|
||||
|
||||
export type ExtensionToWebview = DocumentMessage
|
||||
/**
|
||||
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
|
||||
* definitionProbed:probeDefinition 的回答,found 表示 VS Code 查得到定义
|
||||
*/
|
||||
export type ExtensionToWebview
|
||||
= | DocumentMessage
|
||||
| ({ type: 'reveal' } & Reveal)
|
||||
| { type: 'definitionProbed', version: number, offset: number, found: boolean }
|
||||
|
||||
/**
|
||||
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进)
|
||||
* visibleLine:预览视口顶部那块的起始行,切回文本编辑器时把这行放到顶部
|
||||
* probeDefinition:按住 ⌘ / Ctrl 悬停到带下标的名字上,问源码 offset 处有没有定义;version 是这份下标所属的文档版本
|
||||
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
|
||||
*/
|
||||
export type WebviewToExtension
|
||||
= | { type: 'ready' }
|
||||
| { type: 'edit', startLine: number, endLine: number, text: string }
|
||||
| { type: 'selectTranslator', id: string }
|
||||
| { type: 'visibleLine', line: number }
|
||||
| { type: 'probeDefinition', version: number, offset: number }
|
||||
| { type: 'goToDefinition', version: number, offset: number }
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { CustomTextEditorProvider, Disposable, TextDocument, Uri, WebviewPanel } from 'vscode'
|
||||
import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode'
|
||||
import type { CodeReaderRegistry } from '../registry'
|
||||
import type { TreeSitterRuntime } from '../runtime'
|
||||
import type { ExtensionToWebview, WebviewToExtension } from './messages'
|
||||
import { Range, window, workspace, WorkspaceEdit } from 'vscode'
|
||||
import type { Definition } from './definitions'
|
||||
import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages'
|
||||
import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { webviewHtml, webviewUiRoot } from '../../webview/html'
|
||||
import { blockReplacement } from '../editText'
|
||||
import { definitionAction } from './definitionAction'
|
||||
import { findDefinitions, hasDefinition, pickDefinition } from './definitions'
|
||||
import { documentMessage } from './documentMessage'
|
||||
|
||||
export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview'
|
||||
@@ -19,8 +22,8 @@ const REFRESH_DELAY_MS = 150
|
||||
*/
|
||||
export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
private readonly previews = new Set<Preview>()
|
||||
/** 按文档 uri:从文本编辑器切过来时它顶部那一行,等着交给马上要打开的新预览 */
|
||||
private readonly revealLines = new Map<string, number>()
|
||||
/** 按文档 uri:等着交给马上要打开的新预览的位置(从文本编辑器切过来时它顶部那一行,或跳到的定义) */
|
||||
private readonly reveals = new Map<string, Reveal>()
|
||||
|
||||
constructor(
|
||||
private readonly extensionUri: Uri,
|
||||
@@ -32,8 +35,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri)] }
|
||||
panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html')
|
||||
const key = document.uri.toString()
|
||||
const preview = new Preview(document, panel, this.registry, this.runtime, this.revealLines.get(key), languageId => this.refresh(languageId))
|
||||
this.revealLines.delete(key)
|
||||
const preview = new Preview(document, panel, this, this.registry, this.runtime, this.reveals.get(key))
|
||||
this.reveals.delete(key)
|
||||
this.previews.add(preview)
|
||||
panel.onDidDispose(() => {
|
||||
this.previews.delete(preview)
|
||||
@@ -49,17 +52,24 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/** 用 open 打开 uri 的预览,新预览先滚到包含 line 的块。 */
|
||||
async openAtLine(uri: Uri, line: number, open: () => Thenable<unknown>): Promise<void> {
|
||||
/**
|
||||
* 用 open 打开 uri 的预览并滚到 reveal:新建的预览随第一份文档带过去;
|
||||
* open 只是切到了已经开着的预览(没有新建)时,直接发给这个预览,不重新打开。
|
||||
*/
|
||||
async openAt(uri: Uri, reveal: Reveal, open: () => Thenable<unknown>): Promise<void> {
|
||||
const key = uri.toString()
|
||||
this.revealLines.set(key, line)
|
||||
this.reveals.set(key, reveal)
|
||||
let claimed: boolean
|
||||
try {
|
||||
await open()
|
||||
claimed = this.reveals.get(key) !== reveal
|
||||
}
|
||||
finally {
|
||||
// 切换命令等新预览解析完才返回;切换没成时也不能把这行留给以后别处的打开
|
||||
this.revealLines.delete(key)
|
||||
// 打开命令等新预览解析完才返回;没打开成时也不能把这一处留给以后别处的打开
|
||||
this.reveals.delete(key)
|
||||
}
|
||||
if (!claimed)
|
||||
[...this.previews].find(p => p.uri.toString() === key && p.active)?.reveal(reveal)
|
||||
}
|
||||
|
||||
/** uri 的预览视口顶部那块的起始行;几个编辑器组里都开着这个文件的预览时取活动的那个。 */
|
||||
@@ -71,7 +81,7 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
}
|
||||
|
||||
class Preview {
|
||||
/** webview 发过 ready 之后才发文档,避免脚本还没起来时消息丢掉 */
|
||||
/** webview 发过 ready 之后才发消息;标签页切走时页面被销毁,切回来重新加载、再发 ready,这期间也不发 */
|
||||
private ready = false
|
||||
/** 解析是异步的:只发最后一次请求的结果,慢的旧结果不能盖掉新的 */
|
||||
private latest = 0
|
||||
@@ -83,14 +93,21 @@ class Preview {
|
||||
constructor(
|
||||
private readonly document: TextDocument,
|
||||
private readonly panel: WebviewPanel,
|
||||
private readonly owner: PreviewEditorProvider,
|
||||
private readonly registry: CodeReaderRegistry,
|
||||
private readonly runtime: TreeSitterRuntime,
|
||||
/** 只随第一份真正发出去的文档走:之后页面因标签页切走重新加载,要按 webview 自己记的位置恢复 */
|
||||
private revealLine: number | undefined,
|
||||
private readonly onTranslatorSelected: (languageId: string) => void,
|
||||
/**
|
||||
* 随下一份真正发出去的文档走、只走一次:新预览第一份文档要滚到的位置,或页面正在重新加载时收不到的 reveal。
|
||||
* 之后页面因标签页切走重新加载,按 webview 自己记的位置恢复
|
||||
*/
|
||||
private pendingReveal: Reveal | undefined,
|
||||
) {
|
||||
this.disposables = [
|
||||
panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)),
|
||||
panel.onDidChangeViewState(() => {
|
||||
if (!panel.visible)
|
||||
this.ready = false
|
||||
}),
|
||||
workspace.onDidChangeTextDocument((e) => {
|
||||
if (e.document === document && e.contentChanges.length > 0)
|
||||
this.schedulePush()
|
||||
@@ -116,11 +133,19 @@ class Preview {
|
||||
return
|
||||
const request = ++this.latest
|
||||
const message: ExtensionToWebview = await documentMessage(this.document, this.registry, this.runtime)
|
||||
if (request !== this.latest)
|
||||
if (request !== this.latest || !this.ready)
|
||||
return
|
||||
const { revealLine } = this
|
||||
this.revealLine = undefined
|
||||
void this.panel.webview.postMessage(revealLine === undefined ? message : { ...message, revealLine })
|
||||
const reveal = this.pendingReveal
|
||||
this.pendingReveal = undefined
|
||||
this.post(reveal === undefined ? message : { ...message, reveal })
|
||||
}
|
||||
|
||||
/** 滚到 reveal:页面在就直接发,正在重新加载就等它发 ready 后随文档带过去。 */
|
||||
reveal(reveal: Reveal): void {
|
||||
if (this.ready)
|
||||
this.post({ type: 'reveal', ...reveal })
|
||||
else
|
||||
this.pendingReveal = reveal
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -129,6 +154,10 @@ class Preview {
|
||||
disposable.dispose()
|
||||
}
|
||||
|
||||
private post(message: ExtensionToWebview): void {
|
||||
void this.panel.webview.postMessage(message)
|
||||
}
|
||||
|
||||
private schedulePush(): void {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => void this.push(), REFRESH_DELAY_MS)
|
||||
@@ -145,13 +174,74 @@ class Preview {
|
||||
return
|
||||
case 'selectTranslator':
|
||||
await this.registry.selectTranslator(this.languageId, msg.id)
|
||||
this.onTranslatorSelected(this.languageId)
|
||||
this.owner.refresh(this.languageId)
|
||||
return
|
||||
case 'visibleLine':
|
||||
this.visibleLine = msg.line
|
||||
return
|
||||
case 'probeDefinition':
|
||||
await this.probeDefinition(msg.version, msg.offset)
|
||||
return
|
||||
case 'goToDefinition':
|
||||
await this.goToDefinition(msg.version, msg.offset)
|
||||
}
|
||||
}
|
||||
|
||||
/** 回答 webview:offset 处的名字有没有定义。下标属于别的文档版本时不答,webview 收到新文档后会重新问 */
|
||||
private async probeDefinition(version: number, offset: number): Promise<void> {
|
||||
const { document } = this
|
||||
if (version !== document.version)
|
||||
return
|
||||
const found = await hasDefinition(document.uri, document.positionAt(offset))
|
||||
if (version === document.version && this.ready)
|
||||
this.post({ type: 'definitionProbed', version, offset, found })
|
||||
}
|
||||
|
||||
/** ⌘ / Ctrl + 点击:跳到 offset 处名字的定义,几处时先让用户挑。 */
|
||||
private async goToDefinition(version: number, offset: number): Promise<void> {
|
||||
const { document } = this
|
||||
if (version !== document.version)
|
||||
return
|
||||
const current = document.uri.toString()
|
||||
const supports = (languageId: string): boolean => this.registry.languageFor(languageId) !== undefined
|
||||
let action = definitionAction(await findDefinitions(document.uri, document.positionAt(offset), supports), current)
|
||||
if (action.kind === 'pick') {
|
||||
const picked = await pickDefinition(action.targets)
|
||||
action = definitionAction(picked ? [picked] : [], current)
|
||||
}
|
||||
if (action.kind === 'none' || action.kind === 'pick')
|
||||
return
|
||||
const { target } = action
|
||||
const reveal: Reveal = { line: target.line, definition: target.offset }
|
||||
if (action.kind === 'here') {
|
||||
this.reveal(reveal)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.openDefinition(action.kind, target, reveal)
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `没能打开定义 ${target.uri}:${message}` })
|
||||
void window.showErrorMessage(`代码阅读:没能打开定义所在的 ${workspace.asRelativePath(target.location.uri)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 和普通编辑器里 ⌘ + 点击一样:在同一个编辑器组打开目标(是否按「预览标签」打开听 VS Code 的设置),
|
||||
* 并按 workbench.editor.enablePreviewFromCodeNavigation 把当前这个预览标签固定住,免得被目标替掉。
|
||||
*/
|
||||
private async openDefinition(kind: 'preview' | 'text', target: Definition, reveal: Reveal): Promise<void> {
|
||||
if (this.panel.active && !workspace.getConfiguration('workbench.editor').get<boolean>('enablePreviewFromCodeNavigation', false))
|
||||
await commands.executeCommand('workbench.action.keepEditor')
|
||||
const options: TextDocumentShowOptions = { viewColumn: this.panel.viewColumn }
|
||||
const { uri, range } = target.location
|
||||
if (kind === 'text')
|
||||
await window.showTextDocument(uri, { ...options, selection: range })
|
||||
else
|
||||
await this.owner.openAt(uri, reveal, () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options))
|
||||
}
|
||||
|
||||
private async applyEdit(startLine: number, endLine: number, text: string): Promise<void> {
|
||||
const { document } = this
|
||||
const replacement = blockReplacement(line => document.lineAt(line).text, document.lineCount, startLine, endLine, text)
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function showPreview(previews: PreviewEditorProvider, args: unknown
|
||||
const topLine = uri ? textEditorOf(uri)?.visibleRanges[0]?.start.line : undefined
|
||||
const reopen = (): Thenable<unknown> => commands.executeCommand(REOPEN_WITH, PREVIEW_VIEW_TYPE, ...args)
|
||||
if (uri && topLine !== undefined)
|
||||
await previews.openAtLine(uri, topLine, reopen)
|
||||
await previews.openAt(uri, { line: topLine }, reopen)
|
||||
else
|
||||
await reopen()
|
||||
}
|
||||
|
||||
@@ -55,6 +55,11 @@ function texts(map: Map<number, Translated>): Record<number, string> {
|
||||
return Object.fromEntries([...map].map(([line, { segments }]) => [line, text(segments)]))
|
||||
}
|
||||
|
||||
/** 只看文字和着色:去掉各段的源码下标(下标另有用例)。 */
|
||||
function colors<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value, (key, field: unknown) => key === 'offset' ? undefined : field)) as T
|
||||
}
|
||||
|
||||
const CJK = /\p{Script=Han}/u
|
||||
const CJK_ONLY = /^\p{Script=Han}+$/u
|
||||
const WORD_CHAR = /[\p{Script=Han}\w]/u
|
||||
@@ -105,7 +110,7 @@ describe('typeScript 中文直译', () => {
|
||||
it('每个被翻的词单独成段,着色取语法树词性', async () => {
|
||||
const env = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))
|
||||
const issue = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt'))
|
||||
expect(env.get(32)?.segments.filter(segment => segment.role !== 'plain')).toEqual([
|
||||
expect(colors(env.get(32)?.segments.filter(segment => segment.role !== 'plain'))).toEqual([
|
||||
{ text: '公共', role: 'keyword' },
|
||||
{ text: '接口', role: 'storage' },
|
||||
{ text: 'EnvLockResult', role: 'type' },
|
||||
@@ -375,7 +380,7 @@ describe('属性表', () => {
|
||||
|
||||
it('属性名是纯文字;值按词性着色,说明词(未标注、N 个、默认)用 hint', async () => {
|
||||
const env = await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))
|
||||
const rows = env.get(23) ?? []
|
||||
const rows = colors(env.get(23) ?? [])
|
||||
expect(rows.map(row => row.label)).toEqual(['名称', '访问级别', '异步', '输入参数', '输出参数'])
|
||||
expect(rows[0].value).toEqual([{ text: 'findEnvFiles', role: 'function' }])
|
||||
expect(rows[1].value).toEqual([{ text: '公共', role: 'keyword' }])
|
||||
@@ -388,8 +393,8 @@ describe('属性表', () => {
|
||||
{ text: '字符串', role: 'type' },
|
||||
{ text: '[]>', role: 'plain' },
|
||||
])
|
||||
expect(valueOf(env.get(12), '来源')).toEqual([{ text: '\'node:fs\'', role: 'string' }])
|
||||
expect(env.get(12)?.at(-1)?.items?.[0].cells[0]).toEqual([
|
||||
expect(colors(valueOf(env.get(12), '来源'))).toEqual([{ text: '\'node:fs\'', role: 'string' }])
|
||||
expect(colors(env.get(12)?.at(-1)?.items?.[0].cells[0])).toEqual([
|
||||
{ text: 'promises', role: 'variable' },
|
||||
{ text: ' ', role: 'plain' },
|
||||
{ text: '作为', role: 'keyword' },
|
||||
@@ -398,8 +403,8 @@ describe('属性表', () => {
|
||||
])
|
||||
|
||||
const sample = await describeAll(py, 'python', pythonZhTranslator, readFixture('sample.py'))
|
||||
expect(valueOf(sample.get(3), '来源')).toEqual([{ text: 'os', role: 'type' }])
|
||||
expect(sample.get(39)?.find(row => row.label === '输入参数')?.items).toEqual([
|
||||
expect(colors(valueOf(sample.get(3), '来源'))).toEqual([{ text: 'os', role: 'type' }])
|
||||
expect(colors(sample.get(39)?.find(row => row.label === '输入参数')?.items)).toEqual([
|
||||
{ cells: [[{ text: 'self', role: 'variable' }], [{ text: '未标注', role: 'hint' }], []] },
|
||||
{ cells: [[{ text: 'verbose', role: 'variable' }], [{ text: '布尔', role: 'type' }], [{ text: '默认 ', role: 'hint' }, { text: '假', role: 'storage' }]] },
|
||||
])
|
||||
@@ -464,6 +469,107 @@ describe('属性表', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('源码下标(跳到定义用)', () => {
|
||||
/** 带下标的段,按出现顺序写成 [文字, 下标]。 */
|
||||
function linked(segments: Segment[] | undefined): [string, number][] {
|
||||
return (segments ?? []).flatMap(segment => segment.offset === undefined ? [] : [[segment.text, segment.offset] as [string, number]])
|
||||
}
|
||||
|
||||
function itemCells(rows: PropertyRow[] | undefined, label: string): Segment[][] {
|
||||
return rows?.find(row => row.label === label)?.items?.flatMap(item => item.cells) ?? []
|
||||
}
|
||||
|
||||
function propertySegments(rows: PropertyRow[]): Segment[] {
|
||||
return rows.flatMap(row => [...row.value, ...(row.items ?? []).flatMap(item => item.cells.flat())])
|
||||
}
|
||||
|
||||
it('python:点分模块名每一节、导入项、别名各自带下标,点号不带;前面有中文注释也对得上', async () => {
|
||||
const source = [
|
||||
'# 数据库连接',
|
||||
'from luffy_agent.core.db import get_db as conn',
|
||||
'import os.path',
|
||||
'from . import x',
|
||||
].join('\n')
|
||||
const at = (word: string): number => source.indexOf(word)
|
||||
const rows = await describeAll(py, 'python', pythonZhTranslator, source)
|
||||
expect(text(valueOf(rows.get(2), '来源'))).toBe('luffy_agent.core.db')
|
||||
expect(linked(valueOf(rows.get(2), '来源'))).toEqual([['luffy_agent', at('luffy_agent')], ['core', at('core')], ['db', at('db')]])
|
||||
expect(itemCells(rows.get(2), '导入项').map(linked)).toEqual([[['get_db', at('get_db')], ['conn', at('conn')]]])
|
||||
expect(linked(valueOf(rows.get(3), '来源'))).toEqual([['os', at('os')], ['path', at('path')]])
|
||||
expect(linked(valueOf(rows.get(4), '来源'))).toEqual([])
|
||||
expect(itemCells(rows.get(4), '导入项').map(linked)).toEqual([[['x', at('x')]]])
|
||||
})
|
||||
|
||||
it('python:一行译文里的名字带下标,关键字、运算符、字面量译词不带', async () => {
|
||||
const source = [
|
||||
'def f(user):',
|
||||
' if not user.active and user is not None:',
|
||||
' raise HTTPException(status_code=404)',
|
||||
].join('\n')
|
||||
const at = (word: string, from = 0): number => source.indexOf(word, from)
|
||||
const map = await translateAll(py, 'python', pythonZhTranslator, source)
|
||||
const ifLine = at('if')
|
||||
expect(text(map.get(2)?.segments)).toBe('如果 非 user.active 并且 user 不是 空')
|
||||
expect(linked(map.get(2)?.segments)).toEqual([['user', at('user', ifLine)], ['active', at('active')], ['user', at('user is')]])
|
||||
expect(linked(map.get(3)?.segments)).toEqual([['HTTPException', at('HTTPException')], ['status_code', at('status_code')]])
|
||||
})
|
||||
|
||||
it('typeScript:模块字符串、导入项、函数名称、参数名和类型、返回类型、译文里的名字带下标', async () => {
|
||||
const source = [
|
||||
'// 读取配置',
|
||||
'import { readFile as rf } from \'./fs\'',
|
||||
'export async function load(path: string, opts?: Options): Promise<Result> {',
|
||||
' if (opts === undefined) return rf(path)',
|
||||
'}',
|
||||
].join('\n')
|
||||
const at = (word: string): number => source.indexOf(word)
|
||||
const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, source)
|
||||
expect(linked(valueOf(rows.get(2), '来源'))).toEqual([['\'./fs\'', at('\'./fs\'')]])
|
||||
expect(itemCells(rows.get(2), '导入项').map(linked)).toEqual([[['readFile', at('readFile')], ['rf', at('rf }')]]])
|
||||
expect(linked(valueOf(rows.get(3), '名称'))).toEqual([['load', at('load')]])
|
||||
expect(itemCells(rows.get(3), '输入参数').map(linked)).toEqual([
|
||||
[['path', at('path:')]],
|
||||
[],
|
||||
[],
|
||||
[['opts', at('opts?')]],
|
||||
[['Options', at('Options')]],
|
||||
[],
|
||||
])
|
||||
expect(linked(valueOf(rows.get(3), '输出参数'))).toEqual([['Promise', at('Promise')], ['Result', at('Result')]])
|
||||
const map = await translateAll(ts, 'typescript', typescriptZhTranslator, source)
|
||||
expect(text(map.get(4)?.segments)).toBe('如果 (opts 全等 未定义)')
|
||||
expect(linked(map.get(4)?.segments)).toEqual([['opts', at('opts ===')]])
|
||||
})
|
||||
|
||||
it('样例全文:带下标的只有照抄的名字和字符串,下标处的源码就是这段;变量名、函数名都带,译词、说明词都不带', async () => {
|
||||
const samples: [LanguageAdapter, string, Translator, string][] = [
|
||||
[ts, 'typescript', typescriptZhTranslator, 'envLock.ts.txt'],
|
||||
[ts, 'typescript', typescriptZhTranslator, 'issueLock.ts.txt'],
|
||||
[py, 'python', pythonZhTranslator, 'sample.py'],
|
||||
[py, 'python', pythonZhTranslator, 'routes.py'],
|
||||
]
|
||||
for (const [adapter, languageId, translator, name] of samples) {
|
||||
const source = readFixture(name)
|
||||
const translated = [...(await translateAll(adapter, languageId, translator, source)).values()].flatMap(block => block.segments)
|
||||
const described = [...(await describeAll(adapter, languageId, translator, source)).values()].flatMap(propertySegments)
|
||||
const segments = [...translated, ...described]
|
||||
expect(linked(segments).length, name).toBeGreaterThan(10)
|
||||
for (const segment of segments) {
|
||||
const where = `${name} 「${segment.text}」`
|
||||
if (segment.role === 'variable' || segment.role === 'function')
|
||||
expect(segment.offset, where).toBeTypeOf('number')
|
||||
if (segment.offset === undefined)
|
||||
continue
|
||||
expect(['variable', 'function', 'type', 'string'], where).toContain(segment.role)
|
||||
if (segment.role === 'string')
|
||||
expect(source[segment.offset], where).toBe(segment.text[0])
|
||||
else
|
||||
expect(source.startsWith(segment.text, segment.offset), where).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('中文模板引擎', () => {
|
||||
function testTranslator(table: Partial<ZhTable>): Translator {
|
||||
return createZhTemplateTranslator({
|
||||
|
||||
@@ -56,6 +56,8 @@ interface Token {
|
||||
raw: string
|
||||
/** 前面要空一格:源码里它前面有空白,或者模板在它前面插了别的项。 */
|
||||
gap: boolean
|
||||
/** 照抄的名字、字符串在源码里的起始下标,见 Segment.offset。 */
|
||||
offset?: number
|
||||
}
|
||||
|
||||
/** 按译文顺序收集词:cursor 是上一个词在源码里的结尾,gap 是下一个词前面要不要空一格。 */
|
||||
@@ -69,6 +71,8 @@ interface Collector {
|
||||
const CONTEXT_SEPARATOR = ' > '
|
||||
const CLOSING = new Set([')', ']', '}'])
|
||||
const HEADER_OPENERS = new Set(['{', ':'])
|
||||
/** 源码着色里代表名字的词性:着这几种色的具名叶子是标识符、属性名、类型名,能查定义。 */
|
||||
const NAME_ROLES = new Set<TokenRole>(['variable', 'function', 'type'])
|
||||
|
||||
function firstLeaf(node: Node): Node {
|
||||
const first = node.child(0)
|
||||
@@ -123,6 +127,12 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
||||
return text
|
||||
}
|
||||
|
||||
/** 照抄这个节点时带不带源码下标:名字(按名字着色的具名叶子)和整段字符串带,标点、数字、关键字不带。 */
|
||||
function offsetOf(node: Node): number | undefined {
|
||||
const linkable = stringNodes.has(node.type) || (node.childCount === 0 && node.isNamed && NAME_ROLES.has(table.tokenRole(node)))
|
||||
return linkable ? node.startIndex : undefined
|
||||
}
|
||||
|
||||
/** 能整个换成中文的节点 → 译文和词性。 */
|
||||
function translateNode(node: Node): { text: string, role: TokenRole } | undefined {
|
||||
if (node.childCount === 0 && table.isTypePosition(node)) {
|
||||
@@ -201,7 +211,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
||||
}
|
||||
if (node.childCount === 0 || stringNodes.has(node.type)) {
|
||||
const role = stringNodes.has(node.type) ? 'string' : table.tokenRole(node)
|
||||
emitSource(out, node, { text: oneLine(node.text), role, translated: false })
|
||||
emitSource(out, node, { text: oneLine(node.text), role, translated: false, offset: offsetOf(node) })
|
||||
return
|
||||
}
|
||||
for (const child of node.children) {
|
||||
@@ -217,8 +227,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
||||
return out.tokens
|
||||
}
|
||||
|
||||
/** node 的源码原样照抄:叶子按源码着色,叶子之间的空白、标点照抄。 */
|
||||
function verbatim(node: Node, source: string): Segment[] {
|
||||
/** node 的源码原样照抄:叶子按源码着色(给了 role 就都按它),叶子之间的空白、标点照抄。 */
|
||||
function verbatim(node: Node, source: string, role?: TokenRole): Segment[] {
|
||||
const segments: Segment[] = []
|
||||
let cursor = node.startIndex
|
||||
const visit = (current: Node): void => {
|
||||
@@ -232,7 +242,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
||||
return
|
||||
}
|
||||
pushSegment(segments, source.slice(cursor, current.startIndex), 'plain')
|
||||
pushSegment(segments, current.text, table.tokenRole(current))
|
||||
pushSegment(segments, current.text, role ?? table.tokenRole(current), offsetOf(current))
|
||||
cursor = current.endIndex
|
||||
}
|
||||
visit(node)
|
||||
@@ -274,7 +284,9 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
||||
return joinTokens(tokensIn(node, span, source))
|
||||
},
|
||||
verbatim: node => verbatim(node, source),
|
||||
module: node => [{ text: node.text, role: stringNodes.has(node.type) ? 'string' : 'type' }],
|
||||
// 点分模块名逐节照抄,每一节各自能跳到定义,和 VS Code 里一样
|
||||
module: node => stringNodes.has(node.type) ? [segment(node.text, 'string', offsetOf(node))] : verbatim(node, source, 'type'),
|
||||
name: (node, role) => [segment(node.text, role, offsetOf(node))],
|
||||
}
|
||||
switch (block.kind) {
|
||||
case 'function':
|
||||
@@ -289,14 +301,18 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
||||
return { id: table.id, label: table.label, languageIds: table.languageIds, translate, describe }
|
||||
}
|
||||
|
||||
function pushSegment(segments: Segment[], text: string, role: TokenRole): void {
|
||||
function segment(text: string, role: TokenRole, offset: number | undefined): Segment {
|
||||
return offset === undefined ? { text, role } : { text, role, offset }
|
||||
}
|
||||
|
||||
function pushSegment(segments: Segment[], text: string, role: TokenRole, offset?: number): void {
|
||||
if (!text)
|
||||
return
|
||||
const prev = segments.at(-1)
|
||||
if (role === 'plain' && prev?.role === 'plain')
|
||||
prev.text += text
|
||||
else
|
||||
segments.push({ text, role })
|
||||
segments.push(segment(text, role, offset))
|
||||
}
|
||||
|
||||
/** 词连成译文:源码里隔着空白的词之间空一格,注释不进译文,右括号前的尾逗号去掉。 */
|
||||
@@ -312,7 +328,7 @@ function joinTokens(tokens: Token[]): Segment[] {
|
||||
}
|
||||
if (gap && segments.length > 0)
|
||||
pushSegment(segments, ' ', 'plain')
|
||||
pushSegment(segments, token.text, token.role)
|
||||
pushSegment(segments, token.text, token.role, token.offset)
|
||||
// 「非」是个词,后面补一个空格,不和操作数粘在一起
|
||||
gap = token.translated && token.raw === '!'
|
||||
})
|
||||
@@ -329,6 +345,8 @@ interface Phrases {
|
||||
verbatim: (node: Node) => Segment[]
|
||||
/** 导入来源:字符串字面量按字符串着色,模块名按类型着色。 */
|
||||
module: (node: Node) => Segment[]
|
||||
/** 源码里的一个名字原样照抄、按 role 着色(函数名称)。 */
|
||||
name: (node: Node, role: TokenRole) => Segment[]
|
||||
}
|
||||
|
||||
const ACCESS_ZH: Record<Access, string> = { public: '公共', private: '私有', protected: '受保护' }
|
||||
@@ -363,7 +381,7 @@ function aliased(item: Segment[], alias: Node | undefined, say: Phrases): Segmen
|
||||
function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] {
|
||||
const decorators = facts.decorators.map(say.verbatim)
|
||||
return [
|
||||
{ label: '名称', value: facts.name ? [{ text: facts.name.text, role: 'function' }] : [hint('匿名')] },
|
||||
{ label: '名称', value: facts.name ? say.name(facts.name, 'function') : [hint('匿名')] },
|
||||
...accessRows(facts.access),
|
||||
{ label: '异步', value: yesNo(facts.async) },
|
||||
...(decorators.length > 0 ? [{ label: '装饰器', value: joined(decorators, '\n') }] : []),
|
||||
|
||||
Reference in new issue
Block a user