diff --git a/vscode/src/codeReader/blockView.test.ts b/vscode/src/codeReader/blockView.test.ts index e90b219..616e356 100644 --- a/vscode/src/codeReader/blockView.test.ts +++ b/vscode/src/codeReader/blockView.test.ts @@ -260,7 +260,7 @@ describe('blockView', () => { it('跨几行写的调用:一行译文下面带摊开实参的表;calleeKind 交给翻译插件分新建还是调用', async () => { const source = 'def f(user):\n return Detail(\n id=user.id,\n )\n' - const { blocks: list } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source, undefined, call => call.callee.text === 'Detail' ? 'class' : undefined) + const { blocks: list } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source, { calleeKind: call => call.callee.text === 'Detail' ? 'class' : undefined }) const returned = list[0].children[0] expect(translationText(returned)).toBe('返回 新建 Detail · 1 个参数') expect('table' in returned && returned.table).toMatchObject({ diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index cf69eca..b8c5ad8 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,4 +1,4 @@ -import type { BlockAttributes, BlockKind, CodeBlock, DisplayName, KindBadge, LanguageAdapter, LibraryRule, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, TextBlock, TokenRole, Translator } from './contract' +import type { BlockAttributes, BlockKind, CodeBlock, DisplayName, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, TextBlock, TokenRole, Translator } from './contract' import type { TreeSitterRuntime } from './runtime' import { blockAttributes } from './attributes' import { commonIndent, splitLines } from './editText' @@ -212,31 +212,42 @@ export function toBlockViews(blocks: CodeBlock[], document: SourceDocument, tran return blocks.map((block, i) => build(block, String(i), '')) } -/** 一份源码的视图数据,和它里面起了中文名的定义(预览找用到它们的各处要用,见 displayNames.ts)。 */ +/** + * 一份源码的视图数据,和它里面起了中文名的定义(预览找用到它们的各处要用,见 displayNames.ts)、 + * 语言插件自己认得出的名字(LanguageAdapter.symbols,语言扩展查不到定义、各处出现时用)。 + */ export interface DocumentViews { blocks: BlockView[] displayNames: DisplayName[] + symbols: LocalSymbol[] } /** - * 解析一份源码并转成视图数据(中文名记号交给翻译插件去掉);语法树用完即释放。 - * calleeKind 交出调用的名字的定义眼下查到是哪种块(SourceDocument.calleeKind)。 + * 排视图数据时宿主另外交给规则、翻译插件的:用哪些库规则(RuleRun),调用的名字眼下查到定义成哪种块(SourceDocument.calleeKind), + * 命令名的命令规则(SourceDocument.commandRule)。 */ +export interface DocumentExtras { + rules?: RuleRun + calleeKind?: SourceDocument['calleeKind'] + commandRule?: SourceDocument['commandRule'] +} + +/** 解析一份源码并转成视图数据(中文名记号交给翻译插件去掉);语法树用完即释放。 */ export async function readBlockViews( runtime: Pick, adapter: LanguageAdapter, translator: Translator | undefined, languageId: string, source: string, - rules: RuleRun = NO_RULES, - calleeKind?: SourceDocument['calleeKind'], + extras: DocumentExtras = {}, ): Promise { const tree = await runtime.parse(adapter, languageId, source) try { const { names, markers } = adapter.displayNames?.find(tree.rootNode, source, languageId) ?? { names: [], markers: [] } - const document: SourceDocument = { source, language: adapter, markers, ...(calleeKind && { calleeKind }) } - const blocks = toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), document, translator, rules) - return { blocks, displayNames: names } + const { calleeKind, commandRule } = extras + const document: SourceDocument = { source, language: adapter, markers, ...(calleeKind && { calleeKind }), ...(commandRule && { commandRule }) } + const blocks = toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), document, translator, extras.rules) + return { blocks, displayNames: names, symbols: adapter.symbols?.(tree.rootNode, source, languageId) ?? [] } } finally { tree.delete() diff --git a/vscode/src/codeReader/commandRules.ts b/vscode/src/codeReader/commandRules.ts new file mode 100644 index 0000000..9021cae --- /dev/null +++ b/vscode/src/codeReader/commandRules.ts @@ -0,0 +1,30 @@ +import type { CommandRule } from './contract' + +/** 规则读命令时出错:记下,当它没读(命令照抄)。 */ +function guarded(rule: CommandRule, report: (message: string) => void): CommandRule { + return { + ...rule, + read: (command, context) => { + try { + return rule.read(command, context) + } + catch (err) { + report(`命令规则 ${rule.id} 读「${command.name.node.text}」时出错,照抄命令:${err instanceof Error ? err.message : String(err)}`) + return undefined + } + }, + } +} + +/** 命令名 → 命令规则(SourceDocument.commandRule):几条规则都管同一个名字时先注册的算。 */ +export function commandRuleLookup(rules: readonly CommandRule[], report: (message: string) => void): (name: string) => CommandRule | undefined { + const byName = new Map() + for (const rule of rules) { + const safe = guarded(rule, report) + for (const name of rule.commands) { + if (!byName.has(name)) + byName.set(name, safe) + } + } + return name => byName.get(name) +} diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 2899eb4..6fa5f2c 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -3,9 +3,14 @@ import type { Disposable } from 'vscode' export type { Node } +/** + * 块的种类。后面几种给脚本语言(shell):interpreter 第一行 `#!` 写的解释器,options 打开 / 关掉解释器的开关(`set -euo pipefail`), + * case 按一个值分情况(整条语句,和它的每个情况),command 执行一条命令,pipeline 几条命令用管道串起来。 + */ export type BlockKind = | 'import' | 'function' | 'class' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable' | 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment' + | 'interpreter' | 'options' | 'case' | 'command' | 'pipeline' /** 源码里一段的下标:源码字符串下标(web-tree-sitter 的 startIndex / endIndex),end 为开区间。 */ export interface SourceRange { startIndex: number, endIndex: number } @@ -41,6 +46,50 @@ export interface FunctionFacts { decorators: Node[] parameters: ParameterFacts[] returnType?: Node + /** + * 不写参数表、按位置取参数的语言(shell 的 `$1`):函数里用到的第几个参数,照位置排; + * 函数里给它起了名字(`local url="$1"`)时 name 是那个名字。按参数表取参数的语言不填,填了 parameters 就是空的。 + */ + positional?: PositionalParameter[] + /** 函数打印到输出的那几句(shell 的 echo / printf,没重定向到别处):调用处拿到的就是它们打印的;没有这个概念的语言不填。 */ + prints?: Node[] + /** 函数的输出被调用处拿去用了(shell 同一文件里有 `$(函数名 …)`)。 */ + captured?: boolean + /** + * 函数的结果怎么来(shell 这种没有返回值、只有成不成功的语言):写了 return 的是那几句(return 语句的节点), + * 没写的是空数组,意思是最后一句成不成功。别的语言不填。 + */ + returns?: Node[] +} + +/** 按位置取的一个参数:第几个(1 起),函数里给它起的名字。 */ +export interface PositionalParameter { + position: number + name?: Node +} + +/** 第一行 `#!` 写的解释器。 */ +export interface InterpreterFacts { + /** 解释器叫什么:`#!/usr/bin/env bash` 和 `#!/bin/bash` 都是 bash。 */ + program: string + /** 怎么找到它:交给 env 按 PATH 找,还是写死的路径。 */ + lookup: 'env' | 'path' + /** `#!` 后面写的整句(`/usr/bin/env bash`、`/bin/bash -e`)。 */ + command: string +} + +/** 打开 / 关掉解释器的一个开关(shell 的 `set -e`、`set -o pipefail`)。 */ +export interface SwitchFacts { + /** 开关的 id:语言自己的叫法(bash 是长名字 errexit、nounset、pipefail……),翻译插件按它起名。 */ + id: string + on: boolean + /** 源码里怎么写的(`-e`、`-o pipefail`)。 */ + flag: string +} + +export interface OptionsFacts { + /** 照源码顺序。 */ + switches: SwitchFacts[] } /** @@ -205,6 +254,68 @@ export interface CallFacts { arguments: CallArgument[] } +/** + * 命令的一个重定向:把输出(1)、错误输出(2)或两样都写到 / 追加到一个文件,并进另一路,或从文件读输入。 + * `>&2` 是输出并进错误输出,`2>/dev/null` 是错误输出写到 /dev/null。 + */ +export type Redirect + = | { kind: 'write' | 'append', stream: 'output' | 'error' | 'both', target: Node } + | { kind: 'merge', stream: 'output' | 'error', into: 'output' | 'error' } + | { kind: 'read', source: Node } + +/** 命令的一个词:节点,和它写死的文字(去掉引号、转义;里面有展开 `$x`、`$(…)` 的没有),规则认选项、选项的值用它。 */ +export interface CommandWord { + node: Node + literal?: string +} + +/** 执行一条命令(shell):语言插件从语法树读出结构(LanguageAdapter.command),命令规则按命令名读成译文(CommandRule)。 */ +export interface CommandFacts { + /** 命令名(`rm`、`curl`、同一文件里定义的函数名)。 */ + name: CommandWord + /** 命令名后面的各个词,照源码顺序:选项、选项的值、要处理的东西都在里面,规则自己认。 */ + arguments: CommandWord[] + redirects: Redirect[] +} + +/** 命令规则读出来的:一行译文;value 表示读出来的是一个东西(`dirname "$0"` 是脚本所在目录),不是一个动作。 */ +export interface CommandReading { + text: Inline[] + value?: boolean +} + +/** 命令规则读命令时能用的。 */ +export interface CommandContext { + /** 当前翻译插件的 displayLanguage;规则按它挑自己的词表,没有这种语言的词表就不读(照抄源码)。 */ + displayLanguage: string + /** 一个词照常翻(变量、字符串、展开读成译文,名字带源码下标,能跳到定义、高亮各处出现)。 */ + translate: (word: Node) => Inline[] + /** 读另一条写在实参里的命令(`find … -exec rm -rf {} +` 里的 rm):name、args 是那条命令的词;没有它的规则时没有。 */ + command: (name: CommandWord, args: CommandWord[]) => CommandReading | undefined +} + +/** + * 命令规则:按命令名注册,像库规则一样把一条命令(它的选项)读成译文:`rm -rf dest` → 删除 dest(连同里面的东西,不提示)。 + * 认不出的选项照抄;读不了(不认识的写法)返回 undefined,宿主照抄命令名、实参照常翻。没有规则的命令同样照抄。 + */ +export interface CommandRule { + id: string // 'shell.rm' + label: string // 'rm' + languageIds: string[] + /** 管哪些命令名;几条规则都管同一个名字时先注册的算。 */ + commands: string[] + read: (command: CommandFacts, context: CommandContext) => CommandReading | undefined +} + +/** + * 一份源码里自己认得出的一个名字(语言插件认,不问语言扩展):定义处,和用到它的各处(含定义处)。 + * VS Code 里没有这种语言的定义提供者、高亮提供者时(shell),宿主拿它跳到定义、高亮各处出现:跳到的是用到的那处之前最近的定义。 + */ +export interface LocalSymbol { + definitions: SourceRange[] + occurrences: SourceRange[] +} + export interface DeclaratorFacts { /** 变量名;解构声明是整个模式。 */ name: Node @@ -266,7 +377,11 @@ export interface EnumBlock extends BlockShape { kind: 'enum', facts: EnumFacts } export interface ExceptionBlock extends BlockShape { kind: 'exception', facts: ExceptionFacts } export interface ImportBlock extends BlockShape { kind: 'import', facts: ImportFacts } export interface VariableBlock extends BlockShape { kind: 'const' | 'variable', facts: VariableFacts } -export type FactBlock = FunctionBlock | ClassBlock | EnumBlock | ExceptionBlock | ImportBlock | VariableBlock +/** 解释器块只有 `#!` 那一行。 */ +export interface InterpreterBlock extends BlockShape { kind: 'interpreter', facts: InterpreterFacts } +/** 开关块是一整条 `set …`,每个开关一行。 */ +export interface OptionsBlock extends BlockShape { kind: 'options', facts: OptionsFacts } +export type FactBlock = FunctionBlock | ClassBlock | EnumBlock | ExceptionBlock | ImportBlock | VariableBlock | InterpreterBlock | OptionsBlock /** 带说明(facts.doc)的块。 */ export type DocumentedBlock = FunctionBlock | ClassBlock | EnumBlock | ExceptionBlock | VariableBlock @@ -294,6 +409,7 @@ export type TokenRole | 'storage' // 声明关键字与字面量关键字:const function async class interface new true false null … | 'operator' // 被翻成文字的运算符:全等 并且 或者 非 | 'hint' // 属性表里的说明词:未标注 无 N 个 默认导出 … + | 'constant' // 按习惯全大写、当常量用的名字(shell 的 PY_VERSION、HOME) | 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain' /** @@ -392,6 +508,10 @@ export interface LanguageAdapter { iteration?: (node: Node) => IterationFacts | undefined /** node 是一次调用(含新建对象)时给出它的结构,别的节点没有;没给的语言不展开调用,照常一行译文。 */ call?: (node: Node) => CallFacts | undefined + /** node 是执行一条命令(shell)时给出它的结构,交给命令规则读;别的节点没有,没有命令的语言不填。 */ + command?: (node: Node) => CommandFacts | undefined + /** 一份源码里自己认得出的名字(见 LocalSymbol);VS Code 有这种语言的定义、高亮提供者时用不上,不填。 */ + symbols?: (root: Node, source: string, languageId: string) => LocalSymbol[] /** 认代码里给定义起的中文名(见「中文名」);不填就没有中文名。 */ displayNames?: DisplayNameReader } @@ -434,6 +554,8 @@ export interface SourceDocument { * 排视图数据是同步的,这里只交出已经查到的;还没查到、查不到、宿主不查(没给)都是 undefined,查到了宿主重排、重发。 */ calleeKind?: (call: CallFacts) => BlockKind | undefined + /** 这个命令名的命令规则(这种语言注册的,先注册的算);没有、或宿主不给时没有,命令照抄。规则出错由宿主记下、当它没读。 */ + commandRule?: (name: string) => CommandRule | undefined } /** 筛选属性和值的显示名,筛选栏、块的种类(图标的悬停提示)用它;没给名字的显示 id。 */ @@ -467,8 +589,9 @@ export interface Translator { export interface ThemeColor { dark: string, light: string } /** - * 种类图标:插件自己定义的一组 lucide 图标(https://lucide.dev/icons 上的 kebab-case 名字)。前 18 个给内置种类,后面的给库规则的种类; - * 库规则的种类(KindBadge.icon)只能从这组里挑,要新图标就加进这组(webview 的 kindIcons.tsx 同时给它配上图标),没有按任意名字找图标的路。 + * 种类图标:插件自己定义的一组 lucide 图标(https://lucide.dev/icons 上的 kebab-case 名字)。前 22 个给内置种类(命令、管道共用一个), + * 后面的给库规则的种类;库规则的种类(KindBadge.icon)只能从这组里挑,要新图标就加进这组(webview 的 kindIcons.tsx 同时给它配上图标), + * 没有按任意名字找图标的路。 */ export const KIND_ICON_NAMES = [ 'import', @@ -489,6 +612,10 @@ export const KIND_ICON_NAMES = [ 'corner-down-left', 'code', 'message-square-text', + 'terminal', + 'sliders-horizontal', + 'signpost', + 'square-terminal', 'database', 'package', 'server-cog', @@ -646,4 +773,5 @@ export interface CodeReaderApi { registerLanguage: (adapter: LanguageAdapter) => Disposable registerTranslator: (translator: Translator) => Disposable registerLibraryRule: (rule: LibraryRule) => Disposable + registerCommandRule: (rule: CommandRule) => Disposable } diff --git a/vscode/src/codeReader/editor/documentMessage.ts b/vscode/src/codeReader/editor/documentMessage.ts index 024e6a3..615d9eb 100644 --- a/vscode/src/codeReader/editor/documentMessage.ts +++ b/vscode/src/codeReader/editor/documentMessage.ts @@ -1,9 +1,10 @@ -import type { DisplayName, RuleContext, SourceDocument } from '../contract' +import type { DisplayName, LocalSymbol, RuleContext, SourceDocument } from '../contract' import type { CodeReaderRegistry } from '../registry' import type { TreeSitterRuntime } from '../runtime' import type { DocumentMessage } from './messages' import { fileAttributes, mergeAttributeNames } from '../attributes' import { readBlockViews } from '../blockView' +import { commandRuleLookup } from '../commandRules' import { ruleAttributeNames } from '../libraryRules' /** 生成预览数据要读的那点文档信息(TextDocument 的子集)。 */ @@ -13,10 +14,11 @@ export interface DocumentSnapshot { getText: () => string } -/** 发给预览的文档,和这份源码里起了中文名的定义(扩展端接着找用到它们的各处)。 */ +/** 发给预览的文档,和这份源码里起了中文名的定义(扩展端接着找用到它们的各处)、语言插件自己认得出的名字(LocalSymbol)。 */ export interface DocumentRead { message: DocumentMessage displayNames: DisplayName[] + symbols: LocalSymbol[] } /** @@ -26,7 +28,7 @@ export interface DocumentRead { */ export async function documentMessage( document: DocumentSnapshot, - registry: Pick, + registry: Pick, runtime: Pick, report: (message: string) => void, lookup?: RuleContext['lookup'], @@ -47,14 +49,18 @@ export async function documentMessage( } const adapter = registry.languageFor(languageId) if (!adapter) - return { message: { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }, displayNames: [] } + return { message: { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }, displayNames: [], symbols: [] } try { const rules = registry.rulesFor(languageId) - const { blocks, displayNames } = await readBlockViews(runtime, adapter, translator, languageId, source, { rules, report, lookup }, calleeKind) + const { blocks, displayNames, symbols } = await readBlockViews(runtime, adapter, translator, languageId, source, { + rules: { rules, report, lookup }, + calleeKind, + commandRule: commandRuleLookup(registry.commandRulesFor(languageId), report), + }) const names = mergeAttributeNames([translator?.attributeNames, ...ruleAttributeNames(rules, translator?.displayLanguage, report)]) - return { message: { ...message, blocks, attributes: fileAttributes(blocks, names) }, displayNames } + return { message: { ...message, blocks, attributes: fileAttributes(blocks, names) }, displayNames, symbols } } catch (err) { - return { message: { ...message, error: err instanceof Error ? err.message : String(err) }, displayNames: [] } + return { message: { ...message, error: err instanceof Error ? err.message : String(err) }, displayNames: [], symbols: [] } } } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 61a82b3..4f77d96 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,7 +1,7 @@ import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode' import type { ClaudeImage } from '../../cc/spawnClaude' import type { BlockView } from '../blockView' -import type { BlockKind, RuleLocation, RuleReference, WorkspaceIndex } from '../contract' +import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, WorkspaceIndex } from '../contract' import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution' import type { CodeQa } from '../qa/codeQa' import type { CodeReaderRegistry } from '../registry' @@ -10,13 +10,14 @@ import type { TreeSitterRuntime } from '../runtime' import type { IndexedEntry } from '../workspaceIndex' import type { BlockFilter } from './blockFilter' import type { Definition, FoundLocation } from './definitions' -import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages' +import type { ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages' import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode' import { logger } from '../../logging/logger' import { webviewHtml, webviewUiRoot } from '../../webview/html' import { DocumentCallees } from '../calleeKinds' import { blockReplacement } from '../editText' import { locate } from '../folderStore' +import { definitionFor, symbolAt, symbolOccurrences } from '../localSymbols' import { DocumentNames, resolveNames } from '../nameResolution' import { askPrompt, followUpPrompt } from '../qa/prompt' import { DocumentLookups } from '../ruleLookups' @@ -128,6 +129,8 @@ class Preview { visibleLine: number | undefined /** 最近一次发出去的块树和它的源码:问 AI 时按块的记号在里面找那块代码 */ private sent: { blocks: BlockView[], source: string } | undefined + /** 最近一次发出去的文档里语言插件自己认得出的名字(LanguageAdapter.symbols):语言扩展查不到定义、各处出现时用 */ + private symbols: { version: number, list: LocalSymbol[] } | undefined /** 这份文档各版本显示成中文名的名字 */ private readonly names: DocumentNames /** 库规则问的名字的定义查到了什么:查到的变了就重发文档 */ @@ -150,7 +153,10 @@ class Preview { ) { this.definitions = new DefinitionSources({ rule: async reference => ruleDefinitions(await owner.resolveReference(reference)), - language: offset => languageDefinitions(document.uri, document.positionAt(offset)), + language: async (offset) => { + const found = await languageDefinitions(document.uri, document.positionAt(offset)) + return found.length > 0 ? found : this.localDefinitions(offset) + }, }) this.names = new DocumentNames((version, names) => { if (this.ready && version === document.version) @@ -228,13 +234,14 @@ class Preview { const source = this.document.getText() const session = this.lookups.session() const callees = this.callees.session() - const { message, displayNames } = await documentMessage({ languageId, version, getText: () => source }, this.registry, this.runtime, (text) => { + const { message, displayNames, symbols } = await documentMessage({ languageId, version, getText: () => source }, this.registry, this.runtime, (text) => { logger.add({ level: 'warn', source: 'codeReader', message: `${this.document.uri.fsPath}:${text}` }) }, session.lookup, callees.calleeKind) if (request !== this.latest || !this.ready) return this.definitions.remember(message.version, message.blocks) this.sent = { blocks: message.blocks, source } + this.symbols = { version, list: symbols } const reveal = this.pendingReveal this.pendingReveal = undefined this.post({ ...message, names: this.names.known(version), ...(reveal && { reveal }) }) @@ -260,6 +267,28 @@ class Preview { return null } + /** 这份文档里语言插件自己认得出、盖住 offset 的名字(只认最近一次发出去的那个版本)。 */ + private localSymbol(offset: number): LocalSymbol | undefined { + return this.symbols?.version === this.document.version ? symbolAt(this.symbols.list, offset) : undefined + } + + /** 语言扩展查不到定义时:语言插件自己认得出的名字的定义(见 definitionFor)。 */ + private localDefinitions(offset: number): FoundLocation[] { + const symbol = this.localSymbol(offset) + const target = symbol && definitionFor(symbol, offset) + if (!target) + return [] + const { document } = this + return [{ uri: document.uri, range: new Range(document.positionAt(target.startIndex), document.positionAt(target.endIndex)) }] + } + + /** offset 处名字的各处出现:先问语言扩展,它答不上来时用语言插件自己认得出的名字(见 symbolOccurrences)。 */ + private async occurrences(offset: number): Promise { + const found = await occurrencesOf(this.document, offset) + const symbol = found.length > 0 ? undefined : this.localSymbol(offset) + return symbol ? symbolOccurrences(symbol) : found + } + /** 这份文档里 offset 处名字的定义(语言扩展查的)落在哪儿。 */ private async definitionStarts(offset: number): Promise { const { document } = this @@ -272,7 +301,7 @@ class Preview { const { document } = this return { uri: document.uri.toString(), - occurrences: async offset => (await occurrencesOf(document, offset)).map(({ start, end }) => ({ start, end })), + occurrences: async offset => (await this.occurrences(offset)).map(({ start, end }) => ({ start, end })), definitions: offset => this.definitionStarts(offset), indexed: name => this.owner.indexedNames(document.languageId, name), } @@ -403,7 +432,7 @@ class Preview { const { document } = this if (version !== document.version) return - const occurrences = await occurrencesOf(document, offset) + const occurrences = await this.occurrences(offset) if (version === document.version && this.ready) this.post({ type: 'occurrencesFound', id, occurrences }) } diff --git a/vscode/src/codeReader/languages/shared.ts b/vscode/src/codeReader/languages/shared.ts index 33de599..d686a51 100644 --- a/vscode/src/codeReader/languages/shared.ts +++ b/vscode/src/codeReader/languages/shared.ts @@ -206,7 +206,7 @@ function commentNodes(block: CodeBlock): Node[] { function ownDoc(block: CodeBlock): Node | undefined { if (!('facts' in block)) return block.doc - return block.kind === 'import' ? undefined : block.facts.doc + return block.kind === 'import' || block.kind === 'interpreter' || block.kind === 'options' ? undefined : block.facts.doc } /** 类属性的说明,照源码顺序:紧挨在它上面的注释块、它这一行行尾的注释、Python 紧跟在后面的属性 docstring。 */ diff --git a/vscode/src/codeReader/localSymbols.test.ts b/vscode/src/codeReader/localSymbols.test.ts new file mode 100644 index 0000000..a85cf12 --- /dev/null +++ b/vscode/src/codeReader/localSymbols.test.ts @@ -0,0 +1,33 @@ +import type { LocalSymbol, SourceRange } from './contract' +import { describe, expect, it } from 'vitest' +import { definitionFor, symbolAt, symbolOccurrences } from './localSymbols' + +function range(startIndex: number, endIndex: number): SourceRange { + return { startIndex, endIndex } +} + +// out=1(0-3);echo $out(10-13);out=2(15-18);echo $out(25-28) +const OUT: LocalSymbol = { definitions: [range(0, 3), range(15, 18)], occurrences: [range(0, 3), range(10, 13), range(15, 18), range(25, 28)] } +const URL: LocalSymbol = { definitions: [range(40, 43)], occurrences: [range(30, 33), range(40, 43)] } + +describe('语言插件认出的同一文件里的名字', () => { + it('找盖住位置的名字;名字外面没有', () => { + expect(symbolAt([URL, OUT], 11)).toBe(OUT) + expect(symbolAt([URL, OUT], 13)).toBeUndefined() + }) + + it('跳到之前最近的一处定义;前面没有定义就跳到第一处', () => { + expect(definitionFor(OUT, 11)).toEqual(range(0, 3)) + expect(definitionFor(OUT, 26)).toEqual(range(15, 18)) + expect(definitionFor(URL, 31)).toEqual(range(40, 43)) + }) + + it('各处出现:定义处算写,别处算读', () => { + expect(symbolOccurrences(OUT)).toEqual([ + { start: 0, end: 3, kind: 'write' }, + { start: 10, end: 13, kind: 'read' }, + { start: 15, end: 18, kind: 'write' }, + { start: 25, end: 28, kind: 'read' }, + ]) + }) +}) diff --git a/vscode/src/codeReader/localSymbols.ts b/vscode/src/codeReader/localSymbols.ts new file mode 100644 index 0000000..5ab82cb --- /dev/null +++ b/vscode/src/codeReader/localSymbols.ts @@ -0,0 +1,25 @@ +import type { LocalSymbol, SourceRange } from './contract' + +/** + * 语言插件自己认得出的名字(LanguageAdapter.symbols)怎么用:VS Code 里没有这种语言的定义、高亮提供者时(shell), + * 宿主拿它跳到定义、高亮各处出现。这里不碰 VS Code。 + */ + +/** 盖住 offset 的那个名字。 */ +export function symbolAt(symbols: readonly LocalSymbol[], offset: number): LocalSymbol | undefined { + return symbols.find(symbol => symbol.occurrences.some(range => range.startIndex <= offset && offset < range.endIndex)) +} + +/** offset 处用到的名字跳到哪个定义:它之前最近的一处(同名的变量前后赋过几次值),前面没有就是第一处。 */ +export function definitionFor(symbol: LocalSymbol, offset: number): SourceRange | undefined { + return symbol.definitions.filter(range => range.startIndex <= offset).at(-1) ?? symbol.definitions[0] +} + +/** 名字的各处出现:定义处算写,别处算读。 */ +export function symbolOccurrences(symbol: LocalSymbol): { start: number, end: number, kind: 'read' | 'write' }[] { + return symbol.occurrences.map(({ startIndex, endIndex }) => ({ + start: startIndex, + end: endIndex, + kind: symbol.definitions.some(range => range.startIndex === startIndex) ? 'write' : 'read', + })) +} diff --git a/vscode/src/codeReader/nameResolution.test.ts b/vscode/src/codeReader/nameResolution.test.ts index b085dbd..c02553e 100644 --- a/vscode/src/codeReader/nameResolution.test.ts +++ b/vscode/src/codeReader/nameResolution.test.ts @@ -25,7 +25,7 @@ const ROUTES = [ ].join('\n') function read(source: string): ReturnType { - return readBlockViews(runtime, py, pythonZhTranslator, 'python', source, { rules: [enumRule], report: () => {} }) + return readBlockViews(runtime, py, pythonZhTranslator, 'python', source, { rules: { rules: [enumRule], report: () => {} } }) } function allProperties(blocks: readonly BlockView[]): string[] { diff --git a/vscode/src/codeReader/registry.test.ts b/vscode/src/codeReader/registry.test.ts index 28d6305..4602fd9 100644 --- a/vscode/src/codeReader/registry.test.ts +++ b/vscode/src/codeReader/registry.test.ts @@ -1,5 +1,5 @@ import type { Memento } from 'vscode' -import type { LanguageAdapter, LibraryRule, Translator } from './contract' +import type { CommandRule, LanguageAdapter, LibraryRule, Translator } from './contract' import { describe, expect, it } from 'vitest' import { CodeReaderRegistry, TRANSLATOR_CHOICE_KEY } from './registry' @@ -96,6 +96,19 @@ describe('codeReaderRegistry', () => { expect(changes).toBe(4) }) + it('命令规则按语言过滤、保持注册顺序;注销后不再给', () => { + const registry = new CodeReaderRegistry(fakeMemento()) + const command = (id: string, languageIds: string[]): CommandRule => ({ id, label: id, languageIds, commands: [id], read: () => undefined }) + registry.registerCommandRule(command('rm', ['shellscript'])) + const curl = registry.registerCommandRule(command('curl', ['shellscript'])) + registry.registerCommandRule(command('ls', ['powershell'])) + expect(registry.commandRulesFor('shellscript').map(r => r.id)).toEqual(['rm', 'curl']) + expect(registry.commandRulesFor('python')).toEqual([]) + + curl.dispose() + expect(registry.commandRulesFor('shellscript').map(r => r.id)).toEqual(['rm']) + }) + it('变化事件可退订,也能放进 disposables', () => { const registry = new CodeReaderRegistry(fakeMemento()) let changes = 0 diff --git a/vscode/src/codeReader/registry.ts b/vscode/src/codeReader/registry.ts index edea70e..e59f1da 100644 --- a/vscode/src/codeReader/registry.ts +++ b/vscode/src/codeReader/registry.ts @@ -1,5 +1,5 @@ import type { Disposable, Event, Memento } from 'vscode' -import type { CodeReaderApi, LanguageAdapter, LibraryRule, Translator } from './contract' +import type { CodeReaderApi, CommandRule, LanguageAdapter, LibraryRule, Translator } from './contract' /** globalState 里按 languageId 记选中的翻译插件 id:Record。 */ export const TRANSLATOR_CHOICE_KEY = 'superpowers.codeReader.translatorByLanguage' @@ -7,13 +7,14 @@ export const TRANSLATOR_CHOICE_KEY = 'superpowers.codeReader.translatorByLanguag interface Entry { value: T } /** - * 语言插件、翻译插件、库规则的注册表。内置实现和其他扩展都通过它注册(它本身就是对外的 CodeReaderApi)。 + * 语言插件、翻译插件、库规则、命令规则的注册表。内置实现和其他扩展都通过它注册(它本身就是对外的 CodeReaderApi)。 * 不依赖 vscode 运行时:Disposable 和事件都是结构兼容的普通对象,便于单元测试。 */ export class CodeReaderRegistry implements CodeReaderApi { private readonly adapters: Entry[] = [] private readonly translators: Entry[] = [] private readonly rules: Entry[] = [] + private readonly commands: Entry[] = [] private readonly listeners = new Set<() => void>() constructor(private readonly state: Pick) {} @@ -41,6 +42,10 @@ export class CodeReaderRegistry implements CodeReaderApi { return this.add(this.rules, rule) } + registerCommandRule(rule: CommandRule): Disposable { + return this.add(this.commands, rule) + } + /** 当前语言插件覆盖的全部 languageId(去重、排序),给 when 子句用。 */ languageIds(): string[] { return [...new Set(this.adapters.flatMap(entry => entry.value.languageIds))].sort() @@ -61,6 +66,11 @@ export class CodeReaderRegistry implements CodeReaderApi { return this.rules.map(entry => entry.value).filter(rule => rule.languageIds.includes(languageId)) } + /** 某 languageId 的命令规则,按注册顺序:几条规则都管同一个命令名时先注册的算。 */ + commandRulesFor(languageId: string): CommandRule[] { + return this.commands.map(entry => entry.value).filter(rule => rule.languageIds.includes(languageId)) + } + /** 注册着的全部库规则,按注册顺序:查规则标的引用时按 id 找。 */ libraryRules(): LibraryRule[] { return this.rules.map(entry => entry.value) diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index 4c68b7b..2f64570 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -1,4 +1,4 @@ -import type { Access, AttributeNames, BaseFacts, BlockKind, CallArgument, CallFacts, ClassFacts, CommonAttribute, Defines, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, ParameterFacts, PreviewTable, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, TextBlock, TextTranslation, TokenRole, Translator, VariableBlock } from '../contract' +import type { Access, AttributeNames, BaseFacts, BlockKind, CallArgument, CallFacts, ClassFacts, CommandContext, CommandFacts, CommandReading, CommandRule, CommandWord, CommonAttribute, Defines, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, InterpreterFacts, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, OptionsFacts, ParameterFacts, PositionalParameter, PreviewTable, PropertyItem, PropertyRow, Redirect, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, TextBlock, TextTranslation, TokenRole, Translator, VariableBlock } from '../contract' import { commonIndent, splitLines } from '../editText' /** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */ @@ -51,8 +51,31 @@ export interface ZhTable { * 槽位里的子节点照常递归翻译。模板引用的子节点缺了,或节点没整个落在要翻的这一段里,就不重排、照常翻。 */ nodeTemplates: Record + /** + * 节点读法:节点类型 → 怎么读成一行译文,给模板排不了的写法(shell 的 `${x:-d}`、`[ -f x ]`、`case`)。 + * 命中的节点只输出读法给的,不再照常翻它的子节点;读法返回 undefined 就照常翻。读法里要翻子节点用 ReaderContext.say。 + */ + readers?: Record + /** 解释器开关(SwitchFacts.id)的叫法:errexit → 出错就停;没有的写源码里的写法。 */ + switches?: Record } +/** 读一个节点时能用的(见 ZhTable.readers)。 */ +export interface ReaderContext { + document: SourceDocument + /** 照常翻一个节点(照样认读法、命令),只翻落在要翻的这一段里的部分。 */ + say: (node: Node) => Inline[] + /** node 整个落在要翻的这一段里:复合语句的块只翻头部,读法按它决定写不写主体里的东西。 */ + whole: (node: Node) => boolean + /** 源码里一个名字照抄、按 role 着色(不给 role 按语言插件定的词性),名字带源码下标。 */ + name: (node: Node, role?: TokenRole) => Segment + /** 读一条命令(命令规则读的,没有规则时命令名照抄、各个词照常翻),连重定向;不是命令时没有。 */ + command: (node: Node) => CommandReading | undefined +} + +/** 一种节点的读法:一行译文,可以带一张排在这一段下面的表(分情况的「情况 / 做什么」);返回 undefined 就照常翻。 */ +export type NodeReader = (node: Node, context: ReaderContext) => Inline[] | { text: Inline[], table?: PreviewTable } | undefined + interface Token { /** 注释是空文字:不进译文,只在前后留一个空格。 */ text: string @@ -68,14 +91,17 @@ interface Token { group?: SegmentGroup } -/** 跨几行写的调用排成一行、实参摊成表(见 emitCall):只排遇到的第一个,它的表记在 table 里。 */ -interface CallSlot { +/** + * 排在这一段译文下面的那张表:跨几行写的调用的实参表(见 emitCall),或读法给的表(分情况的「情况 / 做什么」)。 + * 一段只排一张,排了的记在 table 里。 + */ +interface TableSlot { table?: PreviewTable } /** * 按译文顺序收集词:cursor 是上一个词在源码里的结尾,gap 是下一个词前面要不要空一格。 - * calls 是这一段里跨几行写的调用要不要排成一行、实参摊成表;没有就照常翻。 + * slot 是这一段下面能不能排一张表(跨几行写的调用、读法给的表);没有就照常翻。 */ interface Collector { document: SourceDocument @@ -84,7 +110,7 @@ interface Collector { tokens: Token[] cursor: number gap: boolean - calls?: CallSlot + slot?: TableSlot } const CONTEXT_SEPARATOR = ' > ' @@ -93,7 +119,7 @@ const CLOSING = new Set([')', ']', '}']) const SEPARATORS = new Set([...CLOSING, ',', ';', ':']) const HEADER_OPENERS = new Set(['{', ':']) /** 源码着色里代表名字的词性:着这几种色的具名叶子是标识符、属性名、类型名,能查定义。 */ -const NAME_ROLES = new Set(['variable', 'function', 'type']) +const NAME_ROLES = new Set(['variable', 'constant', 'function', 'type']) function firstLeaf(node: Node): Node { const first = node.child(0) @@ -194,6 +220,11 @@ const KIND_ZH: Record = { return: '返回', statement: '语句', comment: '注释', + interpreter: '解释器', + options: '选项', + case: '分情况', + command: '命令', + pipeline: '管道', } const ACCESS_ZH: Record = { public: '公共', private: '私有', protected: '受保护' } const ITERATION_ZH: Record = { generator: '生成器', list: '列表', set: '集合', dict: '字典' } @@ -363,8 +394,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { } /** 要排成一行、实参摊成表的调用:实参列表跨了几行,整个落在要翻的这一段里,这一段还没排过别的调用。 */ - function expandedCall(node: Node, span: SourceSpan, calls: CallSlot, { language: { call } }: SourceDocument): CallFacts | undefined { - if (calls.table || !call || node.startIndex < span.startIndex || node.endIndex > span.endIndex) + function expandedCall(node: Node, span: SourceSpan, slot: TableSlot, { language: { call } }: SourceDocument): CallFacts | undefined { + if (slot.table || !call || node.startIndex < span.startIndex || node.endIndex > span.endIndex) return undefined const facts = call(node) return facts && facts.argumentList.startPosition.row !== facts.argumentList.endPosition.row ? facts : undefined @@ -375,9 +406,9 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { * 一行译文的块、常量 / 变量的值、实参表里每个值各翻各的,各自排自己里面的那个调用。 */ function expandedText(node: Node, span: SourceSpan, document: SourceDocument): { text: Inline[], table?: PreviewTable } { - const calls: CallSlot = {} - const text = joinTokens(tokensIn(node, span, document, calls)) - return { text, ...(calls.table && { table: calls.table }) } + const slot: TableSlot = {} + const text = joinTokens(tokensIn(node, span, document, slot)) + return { text, ...(slot.table && { table: slot.table }) } } /** @@ -408,13 +439,13 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { } /** - * 调用排成一行「新建 / 调用 被调用的名字 · N 个参数」,实参摊成表记在 calls 里。被调用的名字照常翻、带源码下标, + * 调用排成一行「新建 / 调用 被调用的名字 · N 个参数」,实参摊成表记在 slot 里。被调用的名字照常翻、带源码下标, * 最后那个名字新建时按类型、调用时按函数着色,前面几节按变量着色。新建还是调用看写法(CallFacts.construct), * 写法看不出来的问名字的定义:是类(含异常、枚举)就是新建,还没查到、查不到按调用算。 * 调用后面还紧跟着同一个式子的别的部分(见 continued)时整行括起来:「(新建 X · 3 个参数).model_dump()」。 */ - function emitCall(out: Collector, calls: CallSlot, node: Node, facts: CallFacts, span: SourceSpan): void { - calls.table = argumentTable(facts, span, out.document) + function emitCall(out: Collector, slot: TableSlot, node: Node, facts: CallFacts, span: SourceSpan): void { + slot.table = argumentTable(facts, span, out.document) const construct = facts.construct ?? CONSTRUCTED.has(out.document.calleeKind?.(facts)) const wrapped = continued(node, out.root) advance(out, node.startIndex) @@ -436,6 +467,61 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { out.cursor = node.endIndex } + /** 读法用的:照常翻、看节点在不在这一段里、照抄名字、读命令。 */ + function readerContext(out: Collector, span: SourceSpan): ReaderContext { + const { document } = out + return { + document, + say: node => joinTokens(tokensIn(node, span, document)), + whole: node => node.startIndex >= span.startIndex && node.endIndex <= span.endIndex, + name: (node, role) => segment(node.text, role ?? document.language.tokenRole(node), node.startIndex), + command: (node) => { + const facts = document.language.command?.(node) + return facts && commandReading(facts, document) + }, + } + } + + /** 读出来的几段接着前面的词输出;读法给了表、这一段还没排表时表排在这一段下面。 */ + function emitReading(out: Collector, node: Node, reading: Inline[] | { text: Inline[], table?: PreviewTable }): void { + const { text, table: below } = Array.isArray(reading) ? { text: reading, table: undefined } : reading + if (below && out.slot && !out.slot.table) + out.slot.table = below + emitPieces(out, node, text) + } + + function emitPieces(out: Collector, node: Node, pieces: Inline[]): void { + advance(out, node.startIndex) + for (const piece of pieces) { + if (!('role' in piece)) + emit(out, { text: '', role: 'plain', translated: true, raw: '', group: piece }) + else if (piece.text) + emit(out, { text: piece.text, role: piece.role, translated: true, raw: '', ...(piece.offset !== undefined && { offset: piece.offset }) }) + } + out.cursor = node.endIndex + } + + /** + * 一条命令:交给命令名的命令规则读(SourceDocument.commandRule);没有规则、规则不读时命令名照抄(带源码下标)、各个词照常翻。 + * 后面跟着重定向的意思(见 redirectText)。 + */ + function commandReading(facts: CommandFacts, document: SourceDocument): CommandReading { + const say = (node: Node): Inline[] => expression(node, document) + const ruleFor = (name: CommandWord): CommandRule | undefined => document.commandRule?.(name.literal ?? name.node.text) + const context: CommandContext = { + displayLanguage: 'zh', + translate: say, + command: (name, args) => ruleFor(name)?.read({ name, arguments: args, redirects: [] }, context), + } + const reading = ruleFor(facts.name)?.read(facts, context) + const name = facts.name.node + const text = reading?.text ?? [ + segment(name.text, document.language.tokenRole(firstLeaf(name)), name.startIndex), + ...facts.arguments.flatMap(arg => [segment(' ', 'plain', undefined), ...say(arg.node)]), + ] + return { text: [...text, ...facts.redirects.flatMap(redirect => redirectText(redirect, say))], ...(reading?.value && { value: true }) } + } + function collect(node: Node, span: SourceSpan, out: Collector): void { if (node.endIndex <= span.startIndex || node.startIndex >= span.endIndex || node.startIndex === node.endIndex) return @@ -449,10 +535,20 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { emitIteration(out, node, iteration, span) return } - const { calls } = out - const call = calls && expandedCall(node, span, calls, out.document) - if (calls && call) { - emitCall(out, calls, node, call, span) + const { slot } = out + const call = slot && expandedCall(node, span, slot, out.document) + if (slot && call) { + emitCall(out, slot, node, call, span) + return + } + const reading = table.readers?.[node.type]?.(node, readerContext(out, span)) + if (reading) { + emitReading(out, node, reading) + return + } + const command = language.command?.(node) + if (command) { + emitPieces(out, node, commandReading(command, out.document).text) return } const parts = templateParts(node, span) @@ -486,9 +582,9 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { } } - /** 按译文顺序收集 node 落在 span 里的词;给了 calls,跨几行写的调用排成一行、实参摊成表(见 emitCall)。 */ - function tokensIn(node: Node, span: SourceSpan, document: SourceDocument, calls?: CallSlot): Token[] { - const out: Collector = { document, root: node, tokens: [], cursor: span.startIndex, gap: false, ...(calls && { calls }) } + /** 按译文顺序收集 node 落在 span 里的词;给了 slot,跨几行写的调用排成一行、实参摊成表(见 emitCall)。 */ + function tokensIn(node: Node, span: SourceSpan, document: SourceDocument, slot?: TableSlot): Token[] { + const out: Collector = { document, root: node, tokens: [], cursor: span.startIndex, gap: false, ...(slot && { slot }) } collect(node, span, out) return out.tokens } @@ -531,13 +627,13 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { function translate(block: TextBlock, document: SourceDocument): TextTranslation { if (block.kind === 'comment') return { text: translateComment(block, document) } - const calls: CallSlot = {} - const tokens = tokensIn(block.node, block.header, document, calls) + const slot: TableSlot = {} + const tokens = tokensIn(block.node, block.header, document, slot) const compound = block.children.length > 0 || block.header.endIndex < block.node.endIndex const last = tokens.at(-1) if (compound && last && !last.translated && HEADER_OPENERS.has(last.raw)) tokens.pop() - return { text: joinTokens(tokens), ...(calls.table && { table: calls.table }) } + return { text: joinTokens(tokens), ...(slot.table && { table: slot.table }) } } function expression(node: Node, document: SourceDocument): Inline[] { @@ -559,6 +655,11 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { translated: node => within(node, nodeSpan(node)), within, expanded: node => expandedText(node, clipped(nodeSpan(node)), document), + body: node => expression(node, document), + returned: (node) => { + const [value] = language.command?.(node)?.arguments ?? [] + return value ? expression(value.node, document) : [] + }, entryCount: (literal, size) => { const name = table.mapLiterals[literal.type] return [hint(name ? `${name} ${size} 项` : `${size} 项`)] @@ -580,6 +681,10 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return exceptionRows(block.facts, phrases) case 'import': return importRows(block.facts, phrases) + case 'interpreter': + return interpreterRows(block.facts) + case 'options': + return optionsRows(block.facts, table.switches ?? {}) default: return variableRows(block, phrases) } @@ -659,6 +764,10 @@ interface Phrases { within: (node: Node, span: SourceSpan) => Inline[] /** 同 translated,里面跨几行写的第一个调用排成一行「新建 / 调用 …」,实参摊成表(见 emitCall)。 */ expanded: (node: Node) => { text: Inline[], table?: PreviewTable } + /** 函数主体里的一句(不在块头部里)整句照常翻:函数打印的那几句。 */ + body: (node: Node) => Inline[] + /** 一句 return 返回的值(语言插件读出的命令的第一个词);只写了 return 时是空的。 */ + returned: (node: Node) => Inline[] /** 字典 / 对象字面量的项数:「字典 3 项」,叫法按字面量的节点类型查表。 */ entryCount: (literal: Node, size: number) => Segment[] /** 源码原样照抄、按源码着色(装饰器)。 */ @@ -675,6 +784,31 @@ function hint(text: string): Segment { return { text, role: 'hint' } } +const STREAM_ZH: Record<'output' | 'error' | 'both', string> = { output: '输出', error: '错误输出', both: '全部输出' } + +/** + * 重定向的意思,括号里淡色写在命令后面:`>&2` 打印到错误输出,`2>&1` 错误输出并进输出,`2>/dev/null` 丢掉错误输出, + * `> f` 写到 f,`>> f` 追加到 f,`< f` 从 f 读。 + */ +function redirectText(redirect: Redirect, say: (node: Node) => Inline[]): Inline[] { + const note = (...parts: Inline[]): Inline[] => [hint('('), ...parts, hint(')')] + switch (redirect.kind) { + case 'merge': + if (redirect.stream === redirect.into) + return [] + return note(hint(redirect.into === 'error' ? '打印到错误输出' : '错误输出并进输出')) + case 'read': + return note(hint('从 '), ...say(redirect.source), hint(' 读')) + default: { + const stream = STREAM_ZH[redirect.stream] + if (redirect.target.text === '/dev/null') + return note(hint(`丢掉${stream}`)) + const where = redirect.kind === 'append' ? '追加到 ' : '写到 ' + return note(hint(redirect.stream === 'output' ? where : `${stream}${where}`), ...say(redirect.target)) + } + } +} + /** 布尔属性的一行:没有这个概念的语言(facts 里没填)没有这一行。 */ function flagRows(attribute: 'async' | 'abstract' | 'typeOnly', value: boolean | undefined): PropertyRow[] { return value === undefined ? [] : [{ label: ATTRIBUTE_ZH[attribute], value: [{ text: YES_NO[`${value}`], role: 'storage' }] }] @@ -728,11 +862,58 @@ function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] { ...accessRows(facts.access), ...flagRows('async', facts.async), ...decoratorRows(facts.decorators, say), - parameterRow('输入参数', facts.parameters, say), - { label: '输出参数', value: facts.returnType ? say.translated(facts.returnType) : [hint('未标注')] }, + ...(facts.positional + ? positionalRows(facts, facts.positional, say) + : [ + parameterRow('输入参数', facts.parameters, say), + { label: '输出参数', value: facts.returnType ? say.translated(facts.returnType) : [hint('未标注')] }, + ]), ] } +/** + * 按位置取参数的函数(shell):输入参数写「N 个,按位置」,下面一张表「位置 | 函数里叫」(没起名的写 $N); + * 输出是它打印的那几句(调用处用 $(函数 …) 拿去用时注明);返回是写了的 return 值,没写 return、也不打印时是最后一句成不成功。 + */ +function positionalRows(facts: FunctionFacts, positional: PositionalParameter[], say: Phrases): PropertyRow[] { + const inputs: PropertyRow = positional.length === 0 + ? { label: '输入参数', value: [hint('无')] } + : { + label: '输入参数', + value: [hint(`${positional.length} 个,按位置`)], + columns: ['位置', '函数里叫'], + items: positional.map(param => ({ + cells: [[hint(`第 ${param.position} 个`)], param.name ? say.name(param.name, 'variable') : [segment(`$${param.position}`, 'variable', undefined)]], + ...defining(param.name), + })), + } + const prints = facts.prints ?? [] + const captured = facts.captured && facts.name ? [hint(`(调用处用 $(${facts.name.text} …) 拿到)`)] : [] + const output: PropertyRow[] = prints.length > 0 ? [{ label: '输出', value: [...joined(prints.map(say.body), '\n'), ...captured] }] : [] + const returns = facts.returns ?? [] + const values = returns.map(say.returned).filter(value => value.length > 0) + const result: PropertyRow[] = values.length > 0 + ? [{ label: '返回', value: joined(values, '、') }] + : prints.length === 0 ? [{ label: '返回', value: [hint('最后一句成不成功')] }] : [] + return [inputs, ...output, ...result] +} + +/** 解释器:叫什么,怎么找到它(按 PATH 找,或写死的路径)。 */ +function interpreterRows(facts: InterpreterFacts): PropertyRow[] { + return [ + { label: '解释器', value: [segment(facts.program, 'function', undefined)] }, + { label: '怎么找', value: [hint(facts.lookup === 'env' ? `按 PATH 去找(${facts.command})` : `写死的路径(${facts.command})`)] }, + ] +} + +/** 解释器开关:每个开关一行,属性名是开关的意思(没有叫法的写源码里的写法),值是开没开,后面淡色写源码里的写法。 */ +function optionsRows(facts: OptionsFacts, names: Record): PropertyRow[] { + return facts.switches.map(item => ({ + label: names[item.id] ?? item.flag, + value: [{ text: YES_NO[`${item.on}`], role: 'storage' }, { text: ' ', role: 'plain' }, hint(item.flag)], + })) +} + /** 参数表:列名是名称 / 类型 / 默认值,每个参数一行,讲的定义是参数名;没有默认值的格子留空。 */ function parameterRow(label: string, parameters: ParameterFacts[], say: Phrases): PropertyRow { return { diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 8505234..4184039 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -10,6 +10,7 @@ body { --cr-string: #ce9178; --cr-number: #b5cea8; --cr-comment: #6a9955; + --cr-constant: #4fc1ff; --cr-kind-import: #c586c0; --cr-kind-function: #dcdcaa; @@ -23,6 +24,8 @@ body { --cr-kind-return: #89d185; --cr-kind-statement: #9d9d9d; --cr-kind-comment: #6a9955; + --cr-kind-options: #e5c07b; + --cr-kind-command: #d7ba7d; margin: 0; font-family: var(--vscode-font-family, system-ui, sans-serif); @@ -41,6 +44,7 @@ body.vscode-high-contrast-light { --cr-string: #a31515; --cr-number: #098658; --cr-comment: #008000; + --cr-constant: #0070c1; --cr-kind-import: #af00db; --cr-kind-function: #795e26; @@ -54,6 +58,8 @@ body.vscode-high-contrast-light { --cr-kind-return: #388a34; --cr-kind-statement: #6f6f6f; --cr-kind-comment: #008000; + --cr-kind-options: #9a6a00; + --cr-kind-command: #7c5f12; } /* 种类颜色:同一类语义共用一个颜色,图标区分具体是哪种(kindIcons.tsx)。卡片取自己的种类,子层取所属块的种类 */ @@ -63,12 +69,14 @@ body.vscode-high-contrast-light { [data-kind="enum"] { --kind: var(--cr-kind-enum); } [data-kind="exception"] { --kind: var(--cr-kind-exception); } :is([data-kind="field"], [data-kind="const"], [data-kind="variable"]) { --kind: var(--cr-kind-data); } -:is([data-kind="if"], [data-kind="else"]) { --kind: var(--cr-kind-branch); } +:is([data-kind="if"], [data-kind="else"], [data-kind="case"]) { --kind: var(--cr-kind-branch); } [data-kind="loop"] { --kind: var(--cr-kind-loop); } :is([data-kind="try"], [data-kind="catch"], [data-kind="finally"]) { --kind: var(--cr-kind-error); } [data-kind="return"] { --kind: var(--cr-kind-return); } -[data-kind="statement"] { --kind: var(--cr-kind-statement); } +:is([data-kind="statement"], [data-kind="interpreter"]) { --kind: var(--cr-kind-statement); } [data-kind="comment"] { --kind: var(--cr-kind-comment); } +[data-kind="options"] { --kind: var(--cr-kind-options); } +:is([data-kind="command"], [data-kind="pipeline"]) { --kind: var(--cr-kind-command); } /* 库规则认领的块:颜色由规则给,暗色、亮色两个写在元素的 style 上(--kind-dark / --kind-light),这里按主题挑 */ [data-badge] { --kind: var(--kind-dark); } body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(--kind-light); } @@ -198,6 +206,7 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(- .cr-role-storage, .cr-role-operator { color: var(--cr-storage); } .cr-role-variable { color: var(--cr-variable); } +.cr-role-constant { color: var(--cr-constant); } .cr-role-function { color: var(--cr-function); } .cr-role-type { color: var(--cr-type); } .cr-role-string { color: var(--cr-string); } diff --git a/vscode/webview-ui/src/codeReader/kindIcons.test.tsx b/vscode/webview-ui/src/codeReader/kindIcons.test.tsx index 4ba0f8e..df01943 100644 --- a/vscode/webview-ui/src/codeReader/kindIcons.test.tsx +++ b/vscode/webview-ui/src/codeReader/kindIcons.test.tsx @@ -31,6 +31,11 @@ describe('块的种类图标', () => { return: 'corner-down-left', statement: 'code', comment: 'message-square-text', + interpreter: 'terminal', + options: 'sliders-horizontal', + case: 'signpost', + command: 'square-terminal', + pipeline: 'square-terminal', }) }) diff --git a/vscode/webview-ui/src/codeReader/kindIcons.tsx b/vscode/webview-ui/src/codeReader/kindIcons.tsx index d154b9f..c80e7e2 100644 --- a/vscode/webview-ui/src/codeReader/kindIcons.tsx +++ b/vscode/webview-ui/src/codeReader/kindIcons.tsx @@ -21,9 +21,13 @@ import { Shield, ShieldAlert, ShieldCheck, + Signpost, + SlidersHorizontal, Split, SquareFunction, + SquareTerminal, Tag, + Terminal, TriangleAlert, Variable, } from 'lucide-react' @@ -48,6 +52,10 @@ export const KIND_ICON_COMPONENTS: Record = { 'corner-down-left': CornerDownLeft, 'code': Code, 'message-square-text': MessageSquareText, + 'terminal': Terminal, + 'sliders-horizontal': SlidersHorizontal, + 'signpost': Signpost, + 'square-terminal': SquareTerminal, 'database': Database, 'package': Package, 'server-cog': ServerCog, @@ -79,6 +87,11 @@ export const KIND_ICONS: Record = { return: 'corner-down-left', statement: 'code', comment: 'message-square-text', + interpreter: 'terminal', + options: 'sliders-horizontal', + case: 'signpost', + command: 'square-terminal', + pipeline: 'square-terminal', } /** diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index a6ec6da..cd3e654 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -5,6 +5,7 @@ export type BlockKind = | 'import' | 'function' | 'class' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable' | 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment' + | 'interpreter' | 'options' | 'case' | 'command' | 'pipeline' /** 着色用的词性,源码着色和译文着色共用。 */ export type TokenRole @@ -12,6 +13,7 @@ export type TokenRole | 'storage' // 声明关键字与字面量关键字:const function async class interface new true false null … | 'operator' // 被翻成文字的运算符:全等 并且 或者 非 | 'hint' // 属性表里的说明词:未标注 无 N 个 默认导出 … + | 'constant' // 按习惯全大写、当常量用的名字 | 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain' /** @@ -86,7 +88,7 @@ export interface ThemeColor { dark: string, light: string } export type KindIconName = | 'import' | 'square-function' | 'box' | 'braces' | 'list-ordered' | 'triangle-alert' | 'tag' | 'lock' | 'variable' | 'split' | 'corner-down-right' | 'repeat' | 'shield' | 'shield-alert' | 'shield-check' | 'corner-down-left' | 'code' - | 'message-square-text' | 'database' | 'package' | 'server-cog' | 'calendar-clock' | 'globe' | 'network' + | 'message-square-text' | 'terminal' | 'sliders-horizontal' | 'signpost' | 'square-terminal' | 'database' | 'package' | 'server-cog' | 'calendar-clock' | 'globe' | 'network' /** 库规则的种类:名字(悬停提示、读屏念的文字)、颜色和图标(从 KindIconName 里挑);块左边只画图标 */ export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName }