From 3353cf870c8a96181c1b1399d14087f42fc9c006 Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 28 Sep 2026 11:53:47 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E8=83=BD=E8=AF=BB=E5=B5=8C=E5=9C=A8=E4=B8=80?= =?UTF-8?q?=E7=A7=8D=E8=AF=AD=E8=A8=80=E9=87=8C=E7=9A=84=E5=8F=A6=E4=B8=80?= =?UTF-8?q?=E7=A7=8D=E8=AF=AD=E8=A8=80=E7=9A=84=E4=BB=A3=E7=A0=81=EF=BC=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E6=8F=92=E4=BB=B6=E6=8A=A5=E5=87=BA=E6=98=AF?= =?UTF-8?q?=E5=93=AA=E5=87=A0=E6=AE=B5=EF=BC=8C=E5=AE=BF=E4=B8=BB=E4=BA=A4?= =?UTF-8?q?=E7=BB=99=E9=82=A3=E7=A7=8D=E8=AF=AD=E8=A8=80=E7=9A=84=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=8F=AA=E8=A7=A3=E6=9E=90=E8=BF=99=E5=87=A0=E6=AE=B5?= =?UTF-8?q?=E3=80=81=E5=88=86=E6=AD=A5=E3=80=81=E7=BF=BB=E6=88=90=E5=AD=90?= =?UTF-8?q?=E5=9D=97=E5=B9=B6=E5=8F=A0=E7=9D=80=E4=B8=8A=E8=89=B2=EF=BC=8C?= =?UTF-8?q?=E5=A4=96=E5=B1=82=E7=BF=BB=E8=AF=91=E6=8F=92=E4=BB=B6=E5=8F=AA?= =?UTF-8?q?=E9=97=AE=E8=AF=BB=E5=87=BA=E6=9D=A5=E7=9A=84=E6=A0=B7=E5=AD=90?= =?UTF-8?q?=EF=BC=9B=E5=A5=91=E7=BA=A6=E5=8A=A0=E6=9E=84=E5=BB=BA=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E7=9A=84=E5=87=A0=E7=A7=8D=E5=9D=97=EF=BC=88=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E6=8C=87=E4=BB=A4=E3=80=81=E9=98=B6=E6=AE=B5=E3=80=81?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E3=80=81=E5=A4=8D=E5=88=B6=E3=80=81=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E3=80=81=E6=A0=87=E7=AD=BE=E3=80=81=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E3=80=81=E5=90=AF=E5=8A=A8=EF=BC=89=E3=80=81?= =?UTF-8?q?=E5=88=86=E4=BD=9C=E7=94=A8=E8=8C=83=E5=9B=B4=E7=9A=84=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E3=80=81=E5=B9=B6=E6=88=90=E4=B8=80=E5=9D=97=E7=9A=84?= =?UTF-8?q?=E5=87=A0=E6=9D=A1=E8=AF=AD=E5=8F=A5=EF=BC=8C=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E6=8E=92=E8=BF=99=E4=BA=9B=E5=9D=97=E7=9A=84=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/codeReader/blockView.ts | 266 +++++++++++++----- vscode/src/codeReader/contract.ts | 150 +++++++++- .../src/codeReader/editor/documentMessage.ts | 8 + vscode/src/codeReader/languages/shared.ts | 12 +- vscode/src/codeReader/runtime.ts | 26 +- vscode/src/codeReader/testSupport.ts | 24 ++ .../src/codeReader/translators/zhTemplate.ts | 175 +++++++++++- .../webview-ui/src/codeReader/codeReader.css | 17 +- .../src/codeReader/kindIcons.test.tsx | 8 + .../webview-ui/src/codeReader/kindIcons.tsx | 16 ++ vscode/webview-ui/src/codeReader/types.ts | 4 +- 11 files changed, 603 insertions(+), 103 deletions(-) diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index b8c5ad8..a671284 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,4 +1,5 @@ -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 { 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 { TreeSitterRuntime } from './runtime' import { blockAttributes } from './attributes' import { commonIndent, splitLines } from './editText' @@ -63,19 +64,27 @@ function leavesIn(node: Node, from: number, to: number, out: Node[]): void { } } -/** 按语法树叶子给 [startRow, endRow] 这几行着色:每个有词性的叶子一段,空白、标点等 plain 的连成一段。 */ -function colorLines(root: Node, source: string, lines: Line[], startRow: number, endRow: number, indent: number, tokenRole: (leaf: Node) => TokenRole): Segment[] { +/** 着色的一层:一棵语法树,和给它的叶子定词性的语言插件。嵌着的代码的树叠在外层的树上面,盖住的地方按它着色。 */ +interface ColorLayer { + root: Node + 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) const roles: TokenRole[] = [] - const leaves: Node[] = [] - leavesIn(root.descendantForIndex(from, to) ?? root, from, to, leaves) - leaves.forEach((leaf, i) => { - roles.push(tokenRole(leaf)) - for (let at = Math.max(leaf.startIndex, from); at < Math.min(leaf.endIndex, to); at++) - owner[at - from] = i - }) + for (const { root, tokenRole } of layers) { + const leaves: Node[] = [] + leavesIn(root.descendantForIndex(from, to) ?? root, from, to, 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 + } + } const segments: Segment[] = [] let current = -2 @@ -112,11 +121,43 @@ function ownName(block: CodeBlock): Node | undefined { case 'const': case 'variable': return block.facts.declarators.length === 1 ? block.facts.declarators[0].name : undefined + case 'stage': + return block.facts.name default: return undefined } } +/** 解析好的一段嵌着的代码(见 EmbeddedCode):它那种语言的语法树、源码 / 语言插件、翻译插件,分好的几步。 */ +export interface EmbeddedPart { + code: EmbeddedCode + root: Node + document: SourceDocument + translator: Translator | undefined + steps: StepBlocks +} + +/** 一份源码里嵌着的各段代码,按 EmbeddedCode.node 的起始下标找。 */ +export type EmbeddedParts = ReadonlyMap + +/** 嵌着的代码读成的样子(SourceDocument.embedded):各步交给那种语言的翻译插件翻成一行,注释不算一步。 */ +function embeddedReading({ steps, document, translator }: EmbeddedPart): EmbeddedReading { + const read = (block: CodeBlock): { text: Inline[], table?: PreviewTable } => { + if (!translator) + return { text: [{ text: block.node.text, role: 'plain' }] } + if ('facts' in block) + return { text: translator.expression(block.node, document) } + const { text, table } = translator.translate(block, document) + return { text: 'markdown' in text ? [{ text: text.markdown, role: 'comment' }] : text, ...(table && { table }) } + } + const [only] = steps.blocks + return { + steps: steps.blocks.filter(block => block.kind !== 'comment').map(read), + chained: steps.chained, + whole: steps.blocks.length === 1 && !!only, + } +} + /** * 用哪些库规则(某 languageId 注册的,按注册顺序;这份源码导入了规则关心的库才启用),规则出错时怎么记, * 规则查名字的定义时交出眼下知道的(RuleContext.lookup,见 ruleLookups.ts);没给时总是没查到。 @@ -134,81 +175,113 @@ const NO_RULES: RuleRun = { rules: [], report: () => {} } * 每块先问启用的库规则,认领了就换成规则给的替身(见 RuleBlock):规则的卡片,或认成的属性表块(交给翻译插件排,同语言插件报的这种块), * 并进去的子块不再单独成块。 */ -export function toBlockViews(blocks: CodeBlock[], document: SourceDocument, translator: Translator | undefined, rules: RuleRun = NO_RULES): BlockView[] { - const { source, language: adapter } = document +export function toBlockViews( + blocks: CodeBlock[], + document: SourceDocument, + translator: Translator | undefined, + rules: RuleRun = NO_RULES, + embedded: EmbeddedParts = new Map(), +): BlockView[] { + const { source } = document const lines = lineRanges(source) const texts = splitLines(source) - const imports = importsIn(blocks) - const active = activeRules(rules.rules, imports) - const context: RuleContext = { - document, - imports, - ...(translator && { displayLanguage: translator.displayLanguage }), - translate: node => translator ? translator.expression(node, document) : [{ text: node.text, role: 'plain' }], - doc: node => translator?.doc(node, document), - lookup: rules.lookup ?? (() => undefined), - } - /** startRow..endRow 这几行整行源码去共同缩进,和它的着色。 */ - const sourceRows = (root: Node, startRow: number, endRow: number): Pick => { + const outer = blocks[0]?.node.tree.rootNode + const colors: ColorLayer[] = [ + ...(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 => { const rows = texts.slice(startRow, endRow + 1) const indent = commonIndent(rows).length return { startLine: startRow, endLine: endRow, code: rows.map(row => row.slice(Math.min(indent, row.length))).join('\n'), - codeTokens: colorLines(root, source, lines, startRow, endRow, indent, adapter.tokenRole), + codeTokens: colorLines(colors, source, lines, startRow, endRow, indent), } } - /** 一行译文,连着摊开实参的表、块的说明(有才有)。 */ - const textView = (block: TextBlock): { translation: PreviewText, table?: PreviewTable, doc?: Markdown } => { - if (!translator) - return { translation: [] } - const { text, table } = translator.translate(block, document) - const doc = block.doc && translator.doc(block.doc, document) - return { translation: text, ...(table && { table }), ...(doc && { doc }) } - } /** 按源码先后逐块给位置记号:先序遍历,块的记号在它的子块之前要 */ const anchors = new BlockAnchors() - /** 块照它自己的样子(语言插件报的,或库规则认成的属性表块):头部是编辑范围,子块照常交给各规则看。scope 见 BlockAnchors.place */ - function plain(block: CodeBlock, id: string, scope: string): BlockView { - const name = ownName(block) - const rows = sourceRows(block.node.tree.rootNode, block.header.startRow, block.header.endRow) - const { anchor, inner } = anchors.place(scope, block.kind, name?.text, rows.code) - return { - id, - anchor, - kind: block.kind, - ...rows, - ...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }), - attributes: blockAttributes(block), - children: block.children.map((child, i) => build(child, `${id}.${i}`, inner)), - ...('facts' in block ? { properties: translator ? translator.describe(block, document) : [] } : textView(block)), - } - } - function build(block: CodeBlock, id: string, scope: string): BlockView { - const claim = active.length > 0 ? claimBlock(block, active, context, rules.report) : undefined - if (!claim) - return plain(block, id, scope) - const { span, kept } = claimedShape(block, claim.block.absorbs) - if ('as' in claim.block) { - const { attributes } = block - return plain({ ...claim.block.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, id, scope) - } - const { kind, badge, rows, defines, attributes } = claim.block - const lines = sourceRows(block.node.tree.rootNode, span.startRow, span.endRow) - const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), lines.code) - return { - id, - anchor, - kind, - ...lines, - ...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }), - attributes: { ...blockAttributes(block), ...attributes, kind }, - badge, - children: kept.map((child, i) => build(child, `${id}.${i}`, inner)), - properties: rows, + + /** 一种语言的块怎么转:用这种语言的源码、翻译插件和库规则(嵌着的代码没有库规则)。 */ + function layer(layerBlocks: readonly CodeBlock[], layerDocument: SourceDocument, layerTranslator: Translator | undefined, layerRules: RuleRun): (block: CodeBlock, id: string, scope: string) => BlockView { + const imports = importsIn(layerBlocks) + const active = activeRules(layerRules.rules, imports) + const context: RuleContext = { + document: layerDocument, + imports, + ...(layerTranslator && { displayLanguage: layerTranslator.displayLanguage }), + translate: node => layerTranslator ? layerTranslator.expression(node, layerDocument) : [{ text: node.text, role: 'plain' }], + doc: node => layerTranslator?.doc(node, layerDocument), + lookup: layerRules.lookup ?? (() => undefined), } + /** 一行译文,连着摊开实参的表、块的说明(有才有)。 */ + const textView = (block: TextBlock): { translation: PreviewText, table?: PreviewTable, doc?: Markdown } => { + if (!layerTranslator) + return { translation: [] } + const { text, table } = layerTranslator.translate(block, layerDocument) + const doc = block.doc && layerTranslator.doc(block.doc, layerDocument) + return { translation: text, ...(table && { table }), ...(doc && { doc }) } + } + /** 块执行的嵌着的代码分成的几步:只有一步时是它的子块,几步时是各步,交给那种语言转(见 TextBlock.embedded)。 */ + const embeddedChildren = (block: CodeBlock, id: string, scope: string, from: number): BlockView[] => { + const part = 'embedded' in block && block.embedded ? embedded.get(block.embedded.node.startIndex) : undefined + if (!part) + 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)) + } + /** 块照它自己的样子(语言插件报的,或库规则认成的属性表块):头部是编辑范围,子块照常交给各规则看。scope 见 BlockAnchors.place */ + function plain(block: CodeBlock, 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) + return { + id, + anchor, + kind: block.kind, + ...rows, + ...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }), + attributes: blockAttributes(block), + children: [ + ...block.children.map((child, i) => build(child, `${id}.${i}`, 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 { + 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) + if ('as' in claim.block) { + const { attributes } = block + return plain({ ...claim.block.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, 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) + return { + id, + anchor, + kind, + ...lines, + ...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }), + attributes: { ...blockAttributes(block), ...attributes, kind }, + badge, + children: kept.map((child, i) => build(child, `${id}.${i}`, inner)), + properties: rows, + } + } + return build } + + const build = layer(blocks, document, translator, rules) return blocks.map((block, i) => build(block, String(i), '')) } @@ -230,6 +303,15 @@ export interface DocumentExtras { rules?: RuleRun calleeKind?: SourceDocument['calleeKind'] commandRule?: SourceDocument['commandRule'] + /** 嵌着的代码的那种语言用哪个语言插件、翻译插件、命令规则(见 EmbeddedCode);没有的不读,照抄。 */ + embedded?: (languageId: string) => EmbeddedLanguage | undefined +} + +/** 读嵌着的代码用的:那种语言的语言插件、选中的翻译插件、命令名的命令规则。 */ +export interface EmbeddedLanguage { + adapter: LanguageAdapter + translator: Translator | undefined + commandRule?: SourceDocument['commandRule'] } /** 解析一份源码并转成视图数据(中文名记号交给翻译插件去掉);语法树用完即释放。 */ @@ -242,15 +324,49 @@ export async function readBlockViews( extras: DocumentExtras = {}, ): Promise { const tree = await runtime.parse(adapter, languageId, source) + const trees: Tree[] = [tree] try { + const parts = new Map() + for (const code of adapter.embedded?.(tree.rootNode, source, languageId) ?? []) { + const language = extras.embedded?.(code.languageId) + if (!language) + continue + const inner = await runtime.parse(language.adapter, code.languageId, source, code.ranges) + trees.push(inner) + const root = inner.rootNode + const steps = language.adapter.steps?.(root, source, code.languageId) ?? { blocks: language.adapter.toBlocks(root, source, code.languageId), chained: false } + const document: SourceDocument = { source, language: language.adapter, markers: [], ...(language.commandRule && { commandRule: language.commandRule }) } + parts.set(code.node.startIndex, { code, root, document, translator: language.translator, steps }) + } const { names, markers } = adapter.displayNames?.find(tree.rootNode, source, languageId) ?? { names: [], markers: [] } 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) ?? [] } + const readings = new Map() + const embedded = (node: Node): EmbeddedReading | undefined => { + const part = parts.get(node.startIndex) + if (!part) + return undefined + const reading = readings.get(node.startIndex) ?? embeddedReading(part) + readings.set(node.startIndex, reading) + return reading + } + const document: SourceDocument = { + source, + language: adapter, + markers, + ...(calleeKind && { calleeKind }), + ...(commandRule && { commandRule }), + ...(parts.size > 0 && { embedded }), + } + const blocks = toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), document, translator, extras.rules, parts) + const symbols = [ + ...adapter.symbols?.(tree.rootNode, source, languageId) ?? [], + ...[...parts.values()].flatMap(part => part.document.language.symbols?.(part.root, source, part.code.languageId) ?? []), + ] + return { blocks, displayNames: names, symbols } } finally { - tree.delete() + for (const parsed of trees) + parsed.delete() } } diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 6fa5f2c..9bd03b1 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -4,13 +4,17 @@ import type { Disposable } from 'vscode' export type { Node } /** - * 块的种类。后面几种给脚本语言(shell):interpreter 第一行 `#!` 写的解释器,options 打开 / 关掉解释器的开关(`set -euo pipefail`), - * case 按一个值分情况(整条语句,和它的每个情况),command 执行一条命令,pipeline 几条命令用管道串起来。 + * 块的种类。interpreter 到 pipeline 给脚本语言(shell):interpreter 第一行 `#!` 写的解释器,options 打开 / 关掉解释器的开关 + * (`set -euo pipefail`),case 按一个值分情况(整条语句,和它的每个情况),command 执行一条命令,pipeline 几条命令用管道串起来。 + * directive 往后给构建文件(Dockerfile):directive 文件开头给构建工具看的解析指令(`# syntax=…`),stage 一个构建阶段(FROM 和它后面的指令), + * run 构建时执行的命令,copy 往镜像里复制文件,setting 一条设置(工作目录、运行身份、端口……),label 镜像的标签, + * health 健康检查,start 容器启动时执行的命令。 */ 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' + | 'directive' | 'stage' | 'run' | 'copy' | 'setting' | 'label' | 'health' | 'start' /** 源码里一段的下标:源码字符串下标(web-tree-sitter 的 startIndex / endIndex),end 为开区间。 */ export interface SourceRange { startIndex: number, endIndex: number } @@ -92,6 +96,96 @@ export interface OptionsFacts { switches: SwitchFacts[] } +/** 文件开头给构建工具看的一条解析指令(Dockerfile 的 `# syntax=docker/dockerfile:1.7`):名字和值照写。 */ +export interface ParserDirective { + name: string + value: string +} + +export interface DirectiveFacts { + /** 照源码顺序,一行一条。 */ + directives: ParserDirective[] +} + +/** + * 写在指令后面的一个选项(Dockerfile 的 `--from=deps`、`--chown=u:g`、`--interval=30s`):名字、值,值在源码里从哪开始 + * (值里的阶段名、变量能跳到定义)。node 是整个选项。 + */ +export interface InstructionFlag { + node: Node + name: string + value: string + valueStart: number +} + +/** 一个构建阶段(Dockerfile 的 FROM),它后面到下一个 FROM 之前的指令是它的子块。 */ +export interface StageFacts { + /** 阶段名(`AS deps` 的 deps);没起名的没有。 */ + name?: Node + /** 第几个阶段,从 0 数:没起名的阶段按它引用(`COPY --from=0`)。 */ + index: number + /** 紧贴在 FROM 上面的几行注释(中间没空行):阶段的说明,算在块的头部里。 */ + doc?: Node[] + /** 基础镜像的名字(`node:22-alpine` 的 node);stage 为真时是前面某个阶段的名字。 */ + image: Node + /** 前面的阶段名(`FROM builder`):从那个阶段接着往下建。 */ + stage: boolean + /** 标签(`:22-alpine`,带冒号)、摘要(`@sha256:…`,带 @),写了才有。 */ + tag?: Node + digest?: Node + /** FROM 后面的选项(`--platform=linux/amd64`)。 */ + flags: InstructionFlag[] +} + +/** 一条要执行的命令的两种写法:数组(`["node", "server.js"]`,不经过 shell,node 是整个数组),或交给 shell 执行的一段代码。 */ +export type CommandForm + = | { form: 'exec', node: Node } + | { form: 'shell', code: EmbeddedCode } + +/** 健康检查(Dockerfile 的 HEALTHCHECK):none 是关掉健康检查(`HEALTHCHECK NONE`);否则有选项(间隔、超时……)和检查命令。 */ +export interface HealthFacts { + none: boolean + flags: InstructionFlag[] + command?: CommandForm +} + +/** 容器启动时执行的命令:CMD(默认命令,`docker run` 后面写了命令就换掉它)或 ENTRYPOINT(入口,CMD 成了它的参数)。 */ +export interface StartFacts { + instruction: 'cmd' | 'entrypoint' + command: CommandForm +} + +/** + * 写在这门语言里的另一种语言的代码(Dockerfile 的 RUN、shell 写法的 CMD、HEALTHCHECK 的检查命令是 shell)。 + * ranges 是源码里属于它的几段,去掉了外层语言的记号(续行的 `\`),拼起来是一份那种语言的代码;宿主按 languageId 找语言插件, + * 只解析这几段(下标照旧是这份源码的),块交给那种语言的翻译插件。node 是外层语法树里装着它的节点,翻译插件拿它问 SourceDocument.embedded。 + */ +export interface EmbeddedCode { + node: Node + languageId: string + ranges: SourceRange[] +} + +/** + * 一段嵌着的代码被当成一串步骤执行时(Dockerfile 的 RUN)怎么分块:一步一块;chained 表示前一步失败就不往下走(shell 用 && 连着的一串)。 + * 见 LanguageAdapter.steps。 + */ +export interface StepBlocks { + blocks: CodeBlock[] + chained: boolean +} + +/** + * 宿主读嵌着的代码读出来的(SourceDocument.embedded):各步读成的一行(那种语言的翻译插件翻的,可以带一张表;注释不算一步), + * 是不是一步失败就停。whole 表示整段就是一步(没有别的块):这一步就是整段,它的子块归嵌着它的块; + * 否则各块是嵌着它的块的子块(见 TextBlock.embedded)。连词、「N 条命令」这类叫法归本语言的翻译插件。 + */ +export interface EmbeddedReading { + steps: { text: Inline[], table?: PreviewTable }[] + chained: boolean + whole: boolean +} + /** * 父类的一项:TypeScript `extends Base` 里的 Base(一个表达式)和跟在它后面的泛型实参 ``(语法里是两个节点); * Python 类参数列表里的一项,`metaclass=ABCMeta` 这类关键字参数、`*bases` 也算。 @@ -278,10 +372,14 @@ export interface CommandFacts { redirects: Redirect[] } -/** 命令规则读出来的:一行译文;value 表示读出来的是一个东西(`dirname "$0"` 是脚本所在目录),不是一个动作。 */ +/** + * 命令规则读出来的:一行译文;value 表示读出来的是一个东西(`dirname "$0"` 是脚本所在目录),不是一个动作; + * table 是排在这一行下面的表(选项写得多的命令:「选项 | 意思」)。 + */ export interface CommandReading { text: Inline[] value?: boolean + table?: PreviewTable } /** 命令规则读命令时能用的。 */ @@ -302,7 +400,7 @@ export interface CommandRule { id: string // 'shell.rm' label: string // 'rm' languageIds: string[] - /** 管哪些命令名;几条规则都管同一个名字时先注册的算。 */ + /** 管哪些命令名;几条规则都管同一个名字时先注册的算。'*' 管没有自己的规则的命令(`pg_repack --version` → 打印 pg_repack 的版本)。 */ commands: string[] read: (command: CommandFacts, context: CommandContext) => CommandReading | undefined } @@ -333,8 +431,13 @@ export interface VariableFacts { * 中间可以隔空行和赋值行尾的注释)。它算在块的头部里,不再单独成一块。 */ doc?: Node - /** 一条语句声明几个变量(`let a = 1, b = 2`)就有几项。 */ + /** 一条语句声明几个变量(`let a = 1, b = 2`)就有几项;几条连着写的声明并成一块时(Dockerfile 连着的 ENV)是它们的全部。 */ declarators: DeclaratorFacts[] + /** + * 变量在哪儿有效,语言分这个才填:build 是构建参数(Dockerfile 的 ARG,只在构建时有,值是默认值、构建时可以另传), + * environment 是环境变量(Dockerfile 的 ENV,构建时和容器运行时都有)。 + */ + scope?: 'build' | 'environment' } /** @@ -381,7 +484,15 @@ export interface VariableBlock extends BlockShape { kind: 'const' | 'variable', 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 +/** 解析指令块是文件开头连着的几行 `# 名字=值`,一条一行。 */ +export interface DirectiveBlock extends BlockShape { kind: 'directive', facts: DirectiveFacts } +/** 阶段块的头部是 FROM 那一行(连同上面的说明),它后面的指令是子块。 */ +export interface StageBlock extends BlockShape { kind: 'stage', facts: StageFacts } +export interface HealthBlock extends BlockShape { kind: 'health', facts: HealthFacts } +export interface StartBlock extends BlockShape { kind: 'start', facts: StartFacts } +export type FactBlock + = | FunctionBlock | ClassBlock | EnumBlock | ExceptionBlock | ImportBlock | VariableBlock | InterpreterBlock | OptionsBlock + | DirectiveBlock | StageBlock | HealthBlock | StartBlock /** 带说明(facts.doc)的块。 */ export type DocumentedBlock = FunctionBlock | ClassBlock | EnumBlock | ExceptionBlock | VariableBlock @@ -399,6 +510,17 @@ export interface TextBlock extends BlockShape { * 算在块的头部里;预览在一行译文下面按 Markdown 显示(Translator.doc)。 */ doc?: Node + /** + * 几条连着写的同种语句并成的一块(Dockerfile 连着的 COPY、LABEL):照源码顺序的各条,node 是第一条,头部盖住全部。 + * 翻译插件排成一行字加一张表。 + */ + group?: Node[] + /** + * 这块执行的是嵌着的另一种语言的代码(Dockerfile 的 RUN):宿主交给那种语言的语言插件分成几步(LanguageAdapter.steps), + * 只有一步时这一步的子块是这块的子块,几步时各步是这块的子块,都交给那种语言的翻译插件;这块自己的译文照旧归本语言的翻译插件 + * (它从 SourceDocument.embedded 拿到读出来的样子)。 + */ + embedded?: EmbeddedCode } export type CodeBlock = TextBlock | FactBlock @@ -512,6 +634,10 @@ export interface LanguageAdapter { command?: (node: Node) => CommandFacts | undefined /** 一份源码里自己认得出的名字(见 LocalSymbol);VS Code 有这种语言的定义、高亮提供者时用不上,不填。 */ symbols?: (root: Node, source: string, languageId: string) => LocalSymbol[] + /** 这份源码里嵌着的别的语言的代码(见 EmbeddedCode),块里的(TextBlock.embedded)、属性表里的(HealthFacts.command)都算;没有的语言不填。 */ + embedded?: (root: Node, source: string, languageId: string) => EmbeddedCode[] + /** 这门语言的代码被别的语言嵌着、当一串步骤执行时怎么分块(见 StepBlocks);不填就按 toBlocks 分、不算一步失败就停。 */ + steps?: (root: Node, source: string, languageId: string) => StepBlocks /** 认代码里给定义起的中文名(见「中文名」);不填就没有中文名。 */ displayNames?: DisplayNameReader } @@ -556,6 +682,11 @@ export interface SourceDocument { calleeKind?: (call: CallFacts) => BlockKind | undefined /** 这个命令名的命令规则(这种语言注册的,先注册的算);没有、或宿主不给时没有,命令照抄。规则出错由宿主记下、当它没读。 */ commandRule?: (name: string) => CommandRule | undefined + /** + * 嵌着的代码(EmbeddedCode.node 是 node 的那段)读出来的样子:宿主用那种语言的语言插件、翻译插件读好了交出来。 + * 那种语言没有注册、宿主不给时没有,翻译插件照抄。 + */ + embedded?: (node: Node) => EmbeddedReading | undefined } /** 筛选属性和值的显示名,筛选栏、块的种类(图标的悬停提示)用它;没给名字的显示 id。 */ @@ -589,7 +720,8 @@ export interface Translator { export interface ThemeColor { dark: string, light: string } /** - * 种类图标:插件自己定义的一组 lucide 图标(https://lucide.dev/icons 上的 kebab-case 名字)。前 22 个给内置种类(命令、管道共用一个), + * 种类图标:插件自己定义的一组 lucide 图标(https://lucide.dev/icons 上的 kebab-case 名字)。前 26 个给内置种类(几种共用一个: + * 命令、管道、执行共用 square-terminal,解释器、解析指令共用 terminal,选项、设置共用 sliders-horizontal,字段、标签共用 tag), * 后面的给库规则的种类;库规则的种类(KindBadge.icon)只能从这组里挑,要新图标就加进这组(webview 的 kindIcons.tsx 同时给它配上图标), * 没有按任意名字找图标的路。 */ @@ -616,6 +748,10 @@ export const KIND_ICON_NAMES = [ 'sliders-horizontal', 'signpost', 'square-terminal', + 'layers', + 'copy', + 'activity', + 'play', 'database', 'package', 'server-cog', diff --git a/vscode/src/codeReader/editor/documentMessage.ts b/vscode/src/codeReader/editor/documentMessage.ts index 615d9eb..aeaa63b 100644 --- a/vscode/src/codeReader/editor/documentMessage.ts +++ b/vscode/src/codeReader/editor/documentMessage.ts @@ -56,6 +56,14 @@ export async function documentMessage( rules: { rules, report, lookup }, calleeKind, commandRule: commandRuleLookup(registry.commandRulesFor(languageId), report), + embedded: (embeddedId) => { + const embeddedAdapter = registry.languageFor(embeddedId) + return embeddedAdapter && { + adapter: embeddedAdapter, + translator: registry.selectedTranslator(embeddedId), + commandRule: commandRuleLookup(registry.commandRulesFor(embeddedId), report), + } + }, }) const names = mergeAttributeNames([translator?.attributeNames, ...ruleAttributeNames(rules, translator?.displayLanguage, report)]) return { message: { ...message, blocks, attributes: fileAttributes(blocks, names) }, displayNames, symbols } diff --git a/vscode/src/codeReader/languages/shared.ts b/vscode/src/codeReader/languages/shared.ts index d686a51..8bf8a47 100644 --- a/vscode/src/codeReader/languages/shared.ts +++ b/vscode/src/codeReader/languages/shared.ts @@ -206,7 +206,17 @@ function commentNodes(block: CodeBlock): Node[] { function ownDoc(block: CodeBlock): Node | undefined { if (!('facts' in block)) return block.doc - return block.kind === 'import' || block.kind === 'interpreter' || block.kind === 'options' ? undefined : block.facts.doc + switch (block.kind) { + case 'function': + case 'class': + case 'enum': + case 'exception': + case 'const': + case 'variable': + return block.facts.doc + default: + return undefined + } } /** 类属性的说明,照源码顺序:紧挨在它上面的注释块、它这一行行尾的注释、Python 紧跟在后面的属性 docstring。 */ diff --git a/vscode/src/codeReader/runtime.ts b/vscode/src/codeReader/runtime.ts index 16e1140..2c3bce1 100644 --- a/vscode/src/codeReader/runtime.ts +++ b/vscode/src/codeReader/runtime.ts @@ -1,5 +1,5 @@ -import type { Tree } from '@vscode/tree-sitter-wasm' -import type { LanguageAdapter } from './contract' +import type { Point, Range, Tree } from '@vscode/tree-sitter-wasm' +import type { LanguageAdapter, SourceRange } from './contract' import * as path from 'node:path' import { Language, Parser } from '@vscode/tree-sitter-wasm' @@ -8,6 +8,19 @@ export function bundledWasmDir(): string { return path.join(__dirname, 'tree-sitter') } +/** 源码下标的行列(行从 0 起,列按 UTF-16,同 web-tree-sitter 解析 JS 字符串时的算法)。 */ +function pointAt(source: string, index: number): Point { + const lineStart = source.lastIndexOf('\n', index - 1) + 1 + let row = 0 + for (let at = source.indexOf('\n'); at !== -1 && at < index; at = source.indexOf('\n', at + 1)) + row++ + return { row, column: index - lineStart } +} + +function includedRange(source: string, { startIndex, endIndex }: SourceRange): Range { + return { startIndex, endIndex, startPosition: pointAt(source, startIndex), endPosition: pointAt(source, endIndex) } +} + // web-tree-sitter 的运行时是进程级单例:Parser.init 只能有效调用一次,后来的 wasm 目录不再生效 let runtimeReady: Promise | undefined @@ -27,10 +40,13 @@ export class TreeSitterRuntime { /** @param wasmDir 放 tree-sitter.wasm 的目录 */ constructor(private readonly wasmDir: string) {} - /** 返回的语法树占着 wasm 内存,用完必须 `tree.delete()`。 */ - async parse(adapter: LanguageAdapter, languageId: string, source: string): Promise { + /** + * 返回的语法树占着 wasm 内存,用完必须 `tree.delete()`。给了 ranges 就只解析源码里的这几段(嵌着的代码,见 EmbeddedCode), + * 节点的下标、行列照旧是整份源码的。 + */ + async parse(adapter: LanguageAdapter, languageId: string, source: string, ranges?: readonly SourceRange[]): Promise { const parser = await this.parserFor(adapter.grammarPath(languageId)) - const tree = parser.parse(source) + const tree = parser.parse(source, null, ranges ? { includedRanges: ranges.map(range => includedRange(source, range)) } : undefined) if (!tree) throw new Error(`tree-sitter 没能解析 ${languageId} 源码`) return tree diff --git a/vscode/src/codeReader/testSupport.ts b/vscode/src/codeReader/testSupport.ts index 831bf76..4be21a2 100644 --- a/vscode/src/codeReader/testSupport.ts +++ b/vscode/src/codeReader/testSupport.ts @@ -1,4 +1,5 @@ import type { Tree } from '@vscode/tree-sitter-wasm' +import type { BlockView } from './blockView' import type { BlockKind, CodeBlock, Inline, LanguageAdapter, PreviewTable, PreviewText, PropertyItem, PropertyRow, Segment } from './contract' import type { FileEvent, IndexHost, WorkspaceFile } from './workspaceIndex' import { readFileSync } from 'node:fs' @@ -9,6 +10,9 @@ import { TreeSitterRuntime } from './runtime' /** 单元测试直接用依赖包里的 wasm,不走打包产物。 */ export const WASM_DIR = path.resolve(__dirname, '../../node_modules/@vscode/tree-sitter-wasm/wasm') +/** Dockerfile 的语法包在 tree-sitter-wasm 里(打包时 esbuild 把它和上面的一起复制进 dist/tree-sitter)。 */ +export const DOCKERFILE_WASM_DIR = path.resolve(__dirname, '../../node_modules/tree-sitter-wasm/out/dockerfile') + export const runtime = new TreeSitterRuntime(WASM_DIR) export function readFixture(name: string): string { @@ -81,6 +85,26 @@ export function tableLines(table: PreviewTable | undefined): string[] { return itemLines(table?.items, '') } +/** + * 视图数据写成文字(整份源码读出来的样子):一行译文的块是「种类 行号: 译文」,属性表块是「种类 行号」下面每行「属性: 值」, + * 子块比上一层多缩进两格;Markdown 只取第一行。表(属性行展开的、译文下面的、格子里套的)每行比上一层多缩进, + * 格子用 | 隔开,格子里的换行写成 ⏎。 + */ +export function viewLines(blocks: readonly BlockView[], indent = ''): string[] { + const value = (text: PreviewText): string => 'markdown' in text ? text.markdown.split('\n')[0] : segmentText(text) + const items = (list: PropertyItem[] | undefined, pad: string): string[] => (list ?? []).flatMap(item => [ + pad + item.cells.map(segmentText).join(' | ').replace(/\n/g, ' ⏎ '), + ...items(item.items, `${pad} `), + ...items(item.table?.items, `${pad} `), + ]) + return blocks.flatMap((block) => { + const head = 'properties' in block + ? [`${indent}${block.kind} ${block.startLine + 1}`, ...block.properties.flatMap(row => [`${indent} ${row.label}: ${value(row.value)}`, ...items(row.items, `${indent} `)])] + : [`${indent}${block.kind} ${block.startLine + 1}: ${value(block.translation)}`, ...items(block.table?.items, `${indent} `)] + return [...head, ...viewLines(block.children, `${indent} `)] + }) +} + /** 语法树节点换成它的源码,没填的可选字段去掉。 */ export function nodesToText(value: unknown): unknown { if (value instanceof Node) diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index 2f64570..c69e126 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, 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 type { Access, AttributeNames, BaseFacts, BlockKind, CallArgument, CallFacts, ClassFacts, CommandContext, CommandFacts, CommandForm, CommandReading, CommandRule, CommandWord, CommonAttribute, DeclaratorFacts, Defines, DirectiveFacts, EmbeddedReading, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, HealthFacts, ImportFacts, ImportItem, Inline, InstructionFlag, InterpreterFacts, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, OptionsFacts, ParameterFacts, PositionalParameter, PreviewTable, PropertyItem, PropertyRow, Redirect, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, StageFacts, StartFacts, TextBlock, TextTranslation, TokenRole, Translator, VariableBlock } from '../contract' import { commonIndent, splitLines } from '../editText' /** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */ @@ -56,8 +56,12 @@ export interface ZhTable { * 命中的节点只输出读法给的,不再照常翻它的子节点;读法返回 undefined 就照常翻。读法里要翻子节点用 ReaderContext.say。 */ readers?: Record + /** 并成一块的几条同种语句(TextBlock.group)怎么读:按第一条的节点类型查,读成一行字加一张表;没有的照常翻第一条。 */ + groups?: Record /** 解释器开关(SwitchFacts.id)的叫法:errexit → 出错就停;没有的写源码里的写法。 */ switches?: Record + /** 解析指令(ParserDirective.name)的叫法和补充说明:syntax → 语法(BuildKit 按这个版本读这份文件);没有的写原名。 */ + directives?: Record } /** 读一个节点时能用的(见 ZhTable.readers)。 */ @@ -76,6 +80,9 @@ export interface ReaderContext { /** 一种节点的读法:一行译文,可以带一张排在这一段下面的表(分情况的「情况 / 做什么」);返回 undefined 就照常翻。 */ export type NodeReader = (node: Node, context: ReaderContext) => Inline[] | { text: Inline[], table?: PreviewTable } | undefined +/** 并成一块的几条同种语句的读法(见 ZhTable.groups):照源码顺序的各条。 */ +export type GroupReader = (nodes: Node[], context: ReaderContext) => { text: Inline[], table?: PreviewTable } + interface Token { /** 注释是空文字:不进译文,只在前后留一个空格。 */ text: string @@ -225,6 +232,14 @@ const KIND_ZH: Record = { case: '分情况', command: '命令', pipeline: '管道', + directive: '解析指令', + stage: '阶段', + run: '执行', + copy: '复制', + setting: '设置', + label: '标签', + health: '健康检查', + start: '启动', } const ACCESS_ZH: Record = { public: '公共', private: '私有', protected: '受保护' } const ITERATION_ZH: Record = { generator: '生成器', list: '列表', set: '集合', dict: '字典' } @@ -502,8 +517,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { } /** - * 一条命令:交给命令名的命令规则读(SourceDocument.commandRule);没有规则、规则不读时命令名照抄(带源码下标)、各个词照常翻。 - * 后面跟着重定向的意思(见 redirectText)。 + * 一条命令:交给命令名的命令规则读(SourceDocument.commandRule);没有规则、规则不读时命令名照抄(按函数着色、带源码下标)、 + * 各个词照常翻。后面跟着重定向的意思(见 redirectText);规则给了表(选项写得多的命令)就带着。 */ function commandReading(facts: CommandFacts, document: SourceDocument): CommandReading { const say = (node: Node): Inline[] => expression(node, document) @@ -516,10 +531,14 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { 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), + segment(facts.name.literal ?? name.text, 'function', 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 }) } + return { + text: [...text, ...facts.redirects.flatMap(redirect => redirectText(redirect, say))], + ...(reading?.value && { value: true }), + ...(reading?.table && { table: reading.table }), + } } function collect(node: Node, span: SourceSpan, out: Collector): void { @@ -548,7 +567,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { } const command = language.command?.(node) if (command) { - emitPieces(out, node, commandReading(command, out.document).text) + emitReading(out, node, commandReading(command, out.document)) return } const parts = templateParts(node, span) @@ -627,6 +646,11 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { function translate(block: TextBlock, document: SourceDocument): TextTranslation { if (block.kind === 'comment') return { text: translateComment(block, document) } + const group = block.group && table.groups?.[block.node.type] + if (block.group && group) { + const out: Collector = { document, root: block.node, tokens: [], cursor: block.header.startIndex, gap: false } + return group(block.group, readerContext(out, block.header)) + } const slot: TableSlot = {} const tokens = tokensIn(block.node, block.header, document, slot) const compound = block.children.length > 0 || block.header.endIndex < block.node.endIndex @@ -669,6 +693,12 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { module: node => stringNodes.has(node.type) ? [segment(node.text, 'string', offsetOf(node, language))] : verbatim(node, document, 'type'), name: (node, role) => [segment(node.text, role, offsetOf(node, language))], doc: node => doc(node, document), + command: (form) => { + if (form.form === 'exec') + return expandedText(form.node, clipped(nodeSpan(form.node)), document) + const reading = document.embedded?.(form.code.node) + return reading ? embeddedText(reading) : { text: verbatim(form.code.node, document) } + }, } switch (block.kind) { case 'function': @@ -685,8 +715,16 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return interpreterRows(block.facts) case 'options': return optionsRows(block.facts, table.switches ?? {}) + case 'directive': + return directiveRows(block.facts, table.directives ?? {}) + case 'stage': + return stageRows(block.facts, phrases) + case 'health': + return healthRows(block.facts, phrases) + case 'start': + return startRows(block.facts, phrases) default: - return variableRows(block, phrases) + return block.facts.scope ? scopedVariableRows(block.facts.declarators, block.facts.scope, phrases) : variableRows(block, phrases) } } @@ -778,6 +816,20 @@ interface Phrases { name: (node: Node, role: TokenRole) => Segment[] /** 函数、类的说明(JSDoc、docstring):去掉注释符号、引号和共同缩进的 Markdown 原文;没有文字时没有。 */ doc: (node: Node) => Markdown | undefined + /** 一条要执行的命令:数组写法交给命令规则读,shell 写法是宿主读好的嵌着的代码(SourceDocument.embedded),可以带一张选项表。 */ + command: (form: CommandForm) => { text: Inline[], table?: PreviewTable } +} + +/** + * 嵌着的一段代码读成一行:整段只有一步时是这一步的译文(连它的表),几步时各步的译文连起来, + * 一步失败就停的(shell 的 &&)用「然后」连,别的用分号。 + */ +export function embeddedText(reading: EmbeddedReading): { text: Inline[], table?: PreviewTable } { + const [first] = reading.steps + if (reading.whole && first) + return first + const separator: Inline[] = reading.chained ? [segment(' ', 'plain', undefined), segment('然后', 'keyword', undefined), segment(' ', 'plain', undefined)] : [segment(';', 'plain', undefined)] + return { text: reading.steps.flatMap((step, i) => i > 0 ? [...separator, ...step.text] : step.text) } } function hint(text: string): Segment { @@ -914,6 +966,115 @@ function optionsRows(facts: OptionsFacts, names: Record): Proper })) } +/** 解析指令:一条一行,行名是指令的叫法,值照写,后面淡色写补充说明(语法版本是给 BuildKit 看的)。 */ +function directiveRows(facts: DirectiveFacts, names: Record): PropertyRow[] { + return facts.directives.map(({ name, value }) => { + const known = names[name] + return { label: known?.label ?? name, value: [segment(value, 'string', undefined), ...(known?.note ? [hint(known.note)] : [])] } + }) +} + +/** 选项的值:只是一个变量(`$BUILDPLATFORM`、`${X}`)时按变量着色、能跳到定义,别的照写成字符串。 */ +function flagValue(flag: InstructionFlag): Inline[] { + const match = /^\$(\{)?(\w+)\}?$/.exec(flag.value) + if (!match) + return [segment(flag.value, 'string', flag.valueStart)] + const name = match[2] + return [segment(name, /^[A-Z][A-Z0-9_]*$/.test(name) ? 'constant' : 'variable', flag.valueStart + (match[1] ? 2 : 1))] +} + +/** + * 阶段:名称(没起名的写按序号引用)/ 说明(紧贴在 FROM 上面的注释)/ 基础镜像(镜像名,标签、摘要写了才写; + * 接着前面某个阶段建的写「阶段 X」)/ 平台(写了 --platform 才有)。 + */ +function stageRows(facts: StageFacts, say: Phrases): PropertyRow[] { + const doc = (facts.doc ?? []).map(node => say.doc(node)?.markdown).filter(Boolean).join('\n') + const platform = facts.flags.find(flag => flag.name === 'platform') + const image: Inline[] = facts.stage + ? [segment('阶段', 'keyword', undefined), segment(' ', 'plain', undefined), ...say.name(facts.image, 'variable')] + : [ + ...say.translated(facts.image), + ...(facts.tag ? [hint(',标签 '), ...say.translated(facts.tag)] : []), + ...(facts.digest ? [hint(',摘要 '), ...say.translated(facts.digest)] : []), + ] + return [ + facts.name + ? { label: '名称', value: say.name(facts.name, 'variable'), ...defining(facts.name) } + : { label: '名称', value: [hint(`没起名,按序号 ${facts.index} 引用`)] }, + ...(doc ? [{ label: '说明', value: { markdown: doc } }] : []), + { label: '基础镜像', value: image }, + ...(platform ? [{ label: '平台', value: flagValue(platform) }] : []), + ] +} + +const DURATION_UNITS: Record = { h: '小时', m: '分', s: '秒', ms: '毫秒', us: '微秒', ns: '纳秒' } + +/** 时长(`30s`、`1m30s`、`500ms`):数字按数字着色,单位写成中文;认不出的照写。 */ +function durationText(flag: InstructionFlag): Inline[] { + const parts = [...flag.value.matchAll(/(\d+(?:\.\d+)?)(ms|us|ns|[hms])/g)] + if (parts.length === 0 || parts.map(part => part[0]).join('') !== flag.value) + return [segment(flag.value, 'string', flag.valueStart)] + return parts.flatMap(([, amount, unit], i) => [...(i > 0 ? [hint(' ')] : []), segment(amount, 'number', undefined), hint(` ${DURATION_UNITS[unit]}`)]) +} + +/** 一行命令连着它的表(选项写得多的命令的「选项 | 意思」)。 */ +function commandRow(label: string, command: { text: Inline[], table?: PreviewTable }): PropertyRow { + return { label, value: command.text, ...(command.table && { columns: command.table.columns, items: command.table.items }) } +} + +/** 健康检查:间隔 / 超时 / 启动后宽限 / 宽限期里的间隔 / 失败几次算不健康(写了才有)/ 检查命令;NONE 只有一行「不做健康检查」。 */ +function healthRows(facts: HealthFacts, say: Phrases): PropertyRow[] { + if (facts.none) + return [{ label: '检查', value: [hint('不做健康检查(基础镜像里设的也关掉)')] }] + const flag = (name: string): InstructionFlag | undefined => facts.flags.find(item => item.name === name) + const durations: [string, string][] = [['interval', '间隔'], ['timeout', '超时'], ['start-period', '启动后宽限'], ['start-interval', '宽限期里的间隔']] + const retries = flag('retries') + return [ + ...durations.flatMap(([name, label]) => { + const found = flag(name) + return found ? [{ label, value: durationText(found) }] : [] + }), + ...(retries ? [{ label: '失败几次算不健康', value: [segment(retries.value, 'number', retries.valueStart), hint(' 次')] }] : []), + ...(facts.command ? [commandRow('检查命令', say.command(facts.command))] : []), + ] +} + +/** 启动:命令(ENTRYPOINT 叫入口命令)、写法(数组不经过 shell / 经过 shell)。 */ +function startRows(facts: StartFacts, say: Phrases): PropertyRow[] { + return [ + commandRow(facts.instruction === 'cmd' ? '命令' : '入口命令', say.command(facts.command)), + { label: '写法', value: [hint(facts.command.form === 'exec' ? '数组,不经过 shell' : '经过 shell')] }, + ] +} + +/** 节点所在的那条顶层语句(父节点是根)。 */ +function statementOf(node: Node): Node { + let current = node + while (current.parent?.parent) + current = current.parent + return current +} + +/** + * 分在哪儿有效的变量(Dockerfile 的构建参数、环境变量):一个变量是「构建参数 / 环境变量」和「默认值 / 值」两行(构建参数没写默认值的 + * 写「没写,构建时传」);几个变量(一条写了几个,或几条连着写)并成一行,下面一张「名称 | 值」表,每行讲的定义是那个变量名。 + */ +function scopedVariableRows(declarators: DeclaratorFacts[], scope: 'build' | 'environment', say: Phrases): PropertyRow[] { + const label = scope === 'build' ? '构建参数' : '环境变量' + const valueLabel = scope === 'build' ? '默认值' : '值' + const valueOf = (declarator: DeclaratorFacts): Inline[] => declarator.value ? say.translated(declarator.value) : [hint(scope === 'build' ? '没写,构建时传' : '空')] + const [only] = declarators + if (declarators.length === 1 && only) + return [{ label, value: say.translated(only.name), ...defining(only.name) }, { label: valueLabel, value: valueOf(only) }] + const statements = new Set(declarators.map(declarator => statementOf(declarator.name).id)).size + return [{ + label, + value: [hint(statements > 1 ? `${statements} 条连着写` : `${declarators.length} 个`)], + columns: ['名称', valueLabel], + items: declarators.map(declarator => ({ cells: [say.translated(declarator.name), valueOf(declarator)], ...defining(declarator.name) })), + }] +} + /** 参数表:列名是名称 / 类型 / 默认值,每个参数一行,讲的定义是参数名;没有默认值的格子留空。 */ 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 4184039..f3bd87e 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -26,6 +26,7 @@ body { --cr-kind-comment: #6a9955; --cr-kind-options: #e5c07b; --cr-kind-command: #d7ba7d; + --cr-kind-stage: #56b6c2; margin: 0; font-family: var(--vscode-font-family, system-ui, sans-serif); @@ -60,23 +61,25 @@ body.vscode-high-contrast-light { --cr-kind-comment: #008000; --cr-kind-options: #9a6a00; --cr-kind-command: #7c5f12; + --cr-kind-stage: #127f8c; } /* 种类颜色:同一类语义共用一个颜色,图标区分具体是哪种(kindIcons.tsx)。卡片取自己的种类,子层取所属块的种类 */ -[data-kind="import"] { --kind: var(--cr-kind-import); } +:is([data-kind="import"], [data-kind="copy"]) { --kind: var(--cr-kind-import); } [data-kind="function"] { --kind: var(--cr-kind-function); } :is([data-kind="class"], [data-kind="type"]) { --kind: var(--cr-kind-type); } [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="field"], [data-kind="const"], [data-kind="variable"], [data-kind="label"]) { --kind: var(--cr-kind-data); } :is([data-kind="if"], [data-kind="else"], [data-kind="case"]) { --kind: var(--cr-kind-branch); } +[data-kind="stage"] { --kind: var(--cr-kind-stage); } [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); } -:is([data-kind="statement"], [data-kind="interpreter"]) { --kind: var(--cr-kind-statement); } +:is([data-kind="try"], [data-kind="catch"], [data-kind="finally"], [data-kind="health"]) { --kind: var(--cr-kind-error); } +:is([data-kind="return"], [data-kind="start"]) { --kind: var(--cr-kind-return); } +:is([data-kind="statement"], [data-kind="interpreter"], [data-kind="directive"]) { --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); } +:is([data-kind="options"], [data-kind="setting"]) { --kind: var(--cr-kind-options); } +:is([data-kind="command"], [data-kind="pipeline"], [data-kind="run"]) { --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); } diff --git a/vscode/webview-ui/src/codeReader/kindIcons.test.tsx b/vscode/webview-ui/src/codeReader/kindIcons.test.tsx index df01943..5637d51 100644 --- a/vscode/webview-ui/src/codeReader/kindIcons.test.tsx +++ b/vscode/webview-ui/src/codeReader/kindIcons.test.tsx @@ -36,6 +36,14 @@ describe('块的种类图标', () => { case: 'signpost', command: 'square-terminal', pipeline: 'square-terminal', + directive: 'terminal', + stage: 'layers', + run: 'square-terminal', + copy: 'copy', + setting: 'sliders-horizontal', + label: 'tag', + health: 'activity', + start: 'play', }) }) diff --git a/vscode/webview-ui/src/codeReader/kindIcons.tsx b/vscode/webview-ui/src/codeReader/kindIcons.tsx index c80e7e2..5577849 100644 --- a/vscode/webview-ui/src/codeReader/kindIcons.tsx +++ b/vscode/webview-ui/src/codeReader/kindIcons.tsx @@ -2,20 +2,24 @@ import type { LucideIcon } from 'lucide-react' import type { ReactElement } from 'react' import type { BlockKind, BlockView, KindIconName } from './types' import { + Activity, Box, Braces, CalendarClock, Code, + Copy, CornerDownLeft, CornerDownRight, Database, Globe, Import, + Layers, ListOrdered, Lock, MessageSquareText, Network, Package, + Play, Repeat, ServerCog, Shield, @@ -56,6 +60,10 @@ export const KIND_ICON_COMPONENTS: Record = { 'sliders-horizontal': SlidersHorizontal, 'signpost': Signpost, 'square-terminal': SquareTerminal, + 'layers': Layers, + 'copy': Copy, + 'activity': Activity, + 'play': Play, 'database': Database, 'package': Package, 'server-cog': ServerCog, @@ -92,6 +100,14 @@ export const KIND_ICONS: Record = { case: 'signpost', command: 'square-terminal', pipeline: 'square-terminal', + directive: 'terminal', + stage: 'layers', + run: 'square-terminal', + copy: 'copy', + setting: 'sliders-horizontal', + label: 'tag', + health: 'activity', + start: 'play', } /** diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index cd3e654..6ac4e25 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -6,6 +6,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' + | 'directive' | 'stage' | 'run' | 'copy' | 'setting' | 'label' | 'health' | 'start' /** 着色用的词性,源码着色和译文着色共用。 */ export type TokenRole @@ -88,7 +89,8 @@ 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' | 'terminal' | 'sliders-horizontal' | 'signpost' | 'square-terminal' | 'database' | 'package' | 'server-cog' | 'calendar-clock' | 'globe' | 'network' + | 'message-square-text' | 'terminal' | 'sliders-horizontal' | 'signpost' | 'square-terminal' | 'layers' | 'copy' | 'activity' | 'play' + | 'database' | 'package' | 'server-cog' | 'calendar-clock' | 'globe' | 'network' /** 库规则的种类:名字(悬停提示、读屏念的文字)、颜色和图标(从 KindIconName 里挑);块左边只画图标 */ export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName }