import type { Inline, Node, PropertyItem, Segment, TokenRole } from '../contract' import type { GroupReader, NodeReader, ReaderContext } from './zhTemplate' import { createZhTemplateTranslator, embeddedText } from './zhTemplate' /** * Dockerfile 的中文直译:每条指令读成一行(RUN 里的 shell 由宿主交给 shell 的翻译插件读好,见 SourceDocument.embedded), * 连着写的 COPY / LABEL 读成一行字加一张表。阶段、变量、健康检查、启动这些属性表块的行由引擎按 facts 排。 */ const NAME = /^[A-Z_]\w*/i const CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/ /** BuildKit 自动给的构建参数。 */ const PREDEFINED: Record = { TARGETPLATFORM: '目标平台', TARGETOS: '目标系统', TARGETARCH: '目标架构', TARGETVARIANT: '目标架构变体', BUILDPLATFORM: '构建机的平台', BUILDOS: '构建机的系统', BUILDARCH: '构建机的架构', BUILDVARIANT: '构建机的架构变体', } function seg(text: string, role: TokenRole, offset?: number): Segment { return { text, role, ...(offset !== undefined && { offset }) } } const hint = (text: string): Segment => seg(text, 'hint') const word = (text: string): Segment => seg(text, 'keyword') const verb = (text: string): Segment => seg(text, 'function') const space = (): Segment => seg(' ', 'plain') function named(node: Node | null | undefined): Node[] { return node ? node.namedChildren.filter((child): child is Node => child !== null) : [] } function joined(parts: Inline[][], separator: Inline): Inline[] { return parts.flatMap((part, i) => i > 0 ? [separator, ...part] : part) } /** 括号里淡色写几条意思,用顿号隔开;一条都没有时什么都不写。 */ function notes(items: Inline[][]): Inline[] { return items.length > 0 ? [hint('('), ...joined(items, hint('、')), hint(')')] : [] } /** 指令后面的一个选项 `--名字=值`:值在源码里从哪开始。 */ interface Flag { name: string, value: string, valueStart: number } function flagsOf(instruction: Node): Flag[] { return named(instruction).flatMap((node): Flag[] => { if (node.type !== 'param' && node.type !== 'mount_param') return [] const match = /^--([a-z][-a-z]*)=(.*)$/s.exec(node.text) return match ? [{ name: match[1], value: match[2], valueStart: node.startIndex + match[1].length + 3 }] : [] }) } /** 节点所在的那条指令(父节点是根)。 */ function statementOf(node: Node): Node { let current = node while (current.parent?.parent) current = current.parent return current } /** * 一段文字里夹着变量(`$HOME/x`、`"a ${B}"`):写死的部分按 role 着色(带源码下标),变量照常读;from / to 去掉引号、冒号这些。 */ function mixed(node: Node, context: ReaderContext, role: TokenRole, from = node.startIndex, to = node.endIndex): Inline[] { const { source } = context.document const out: Inline[] = [] let cursor = from for (const child of named(node)) { if (child.type !== 'expansion') continue if (child.startIndex > cursor) out.push(seg(source.slice(cursor, child.startIndex), role, cursor)) out.push(...context.say(child)) cursor = child.endIndex } if (cursor < to) out.push(seg(source.slice(cursor, to), role, cursor)) return out } // ─── 变量 ──────────────────────────────────────────────────────────────────── /** 变量是在这条指令之前(同一阶段里;FROM 那一行看文件开头的)用 ARG 声明的构建参数,不是 ENV 设的环境变量。 */ function isBuildArgument(name: string, node: Node): boolean { const statement = statementOf(node) const inFrom = statement.type === 'from_instruction' for (let previous = statement.previousNamedSibling; previous; previous = previous.previousNamedSibling) { if (previous.type === 'from_instruction' && !inFrom) return false if (previous.type === 'arg_instruction' && previous.childForFieldName('name')?.text === name) return true if (previous.type === 'env_instruction' && named(previous).some(pair => pair.childForFieldName('name')?.text === name)) return false } return false } /** * `$X`、`${X}`:变量名(带源码下标,全大写的按常量着色),后面淡色写它是什么:ARG 声明的写「构建参数」,BuildKit 自动给的写它的意思; * `${X:-d}` 写「(没设就用 d)」,`${X:+d}` 写「(设了就是 d)」。 */ const expansion: NodeReader = (node) => { const variable = named(node).find(child => child.type === 'variable') const name = variable && NAME.exec(variable.text)?.[0] if (!variable || !name) return undefined const rest = variable.text.slice(name.length) const modifier = /^:?([-+])(.*)$/s.exec(rest) const origin = isBuildArgument(name, node) ? '构建参数' : PREDEFINED[name] const remarks: Inline[][] = [ ...(origin ? [[hint(origin)]] : []), ...(modifier ? [[hint(modifier[1] === '-' ? '没设就用 ' : '设了就是 '), seg(modifier[2], 'string')]] : []), ] return [ seg(name, CONSTANT_NAME.test(name) ? 'constant' : 'variable', variable.startIndex), ...(remarks.length > 0 ? [hint('('), ...joined(remarks, hint(',')), hint(')')] : []), ] } /** 没引号的一段:数字按数字着色,夹着变量的拆开读;只是一个名字、一段文字的照常(名字能跳到定义)。 */ const unquoted: NodeReader = (node, context) => { if (named(node).length > 0) return mixed(node, context, 'string') return /^\d+(?:\.\d+)?$/.test(node.text) ? [seg(node.text, 'number')] : undefined } /** 引号里的一段:去掉引号,写死的部分按字符串着色,变量照常读;空的写「空字符串」。 */ const quoted: NodeReader = (node, context) => node.endIndex - node.startIndex <= 2 ? [hint('空字符串')] : mixed(node, context, 'string', node.startIndex + 1, node.endIndex - 1) /** JSON 字符串(数组写法里的一项):去掉引号、转义。 */ const jsonString: NodeReader = (node) => { try { const value: unknown = JSON.parse(node.text) return typeof value === 'string' ? [seg(value, 'string', node.startIndex + 1)] : undefined } catch { return undefined } } // ─── 复制 ──────────────────────────────────────────────────────────────────── /** 名字是文件里某个阶段的名字(`AS deps`),或阶段的序号。 */ function isStage(name: string, node: Node): boolean { return /^\d+$/.test(name) || node.tree.rootNode.descendantsOfType('image_alias').some(alias => alias?.text.toLowerCase() === name.toLowerCase()) } /** `--from=` 的值:阶段写「阶段 X」(阶段名能跳过去),镜像写「镜像 名字:标签」。 */ function fromText(flag: Flag, node: Node): { stage: boolean, text: Inline[] } { if (isStage(flag.value, node)) return { stage: true, text: [seg(flag.value, 'variable', flag.valueStart)] } const at = flag.value.search(/[:@]/) const text = at < 0 ? [seg(flag.value, 'type', flag.valueStart)] : [seg(flag.value.slice(0, at), 'type', flag.valueStart), seg(flag.value[at], 'plain'), seg(flag.value.slice(at + 1), 'string', flag.valueStart + at + 1)] return { stage: false, text } } /** COPY / ADD 的选项写成意思(`--from` 另外写在前面)。 */ function copyNotes(flags: readonly Flag[], skipOwner: boolean): Inline[][] { return flags.flatMap((flag): Inline[][] => { switch (flag.name) { case 'from': return [] case 'chown': return skipOwner ? [] : [[hint('属主 '), seg(flag.value, 'variable', flag.valueStart)]] case 'chmod': return [[hint('权限 '), seg(flag.value, 'string', flag.valueStart)]] case 'parents': return [[hint('保留目录层级')]] case 'link': return [[hint('单独成层')]] case 'exclude': return [[hint('跳过 '), seg(flag.value, 'string', flag.valueStart)]] case 'checksum': return [[hint('校验 '), seg(flag.value, 'string', flag.valueStart)]] case 'keep-git-dir': return [[hint('保留 .git 目录')]] default: return [[seg(`--${flag.name}=${flag.value}`, 'plain')]] } }) } /** 一条 COPY / ADD 拆开的样子:从哪(阶段 / 镜像)、源、目标、选项,和动作(ADD 下载网址、解压本地的压缩包)。 */ interface CopyParts { from?: { stage: boolean, text: Inline[] } sources: Inline[] target: Inline[] flags: Flag[] action: string heredoc: boolean } const ARCHIVE = /\.(?:tar|tgz|tar\.gz|tar\.bz2|tbz2?|tar\.xz|txz)$/i function copyParts(node: Node, context: ReaderContext): CopyParts { const flags = flagsOf(node) const from = flags.find(flag => flag.name === 'from') const paths = named(node).filter(child => child.type === 'path') const heredoc = paths.some(path => named(path).some(child => child.type === 'heredoc_marker')) const sourcePaths = paths.slice(0, -1) const target = paths.at(-1) const texts = sourcePaths.map(path => path.text) let action = '复制' if (node.type === 'add_instruction') { if (texts.every(text => /^https?:\/\//.test(text))) action = '下载' else if (texts.every(text => /^git@|\.git(?:#.*)?$/.test(text))) action = '拉取仓库' else if (texts.every(text => ARCHIVE.test(text))) action = '解压' } return { ...(from && { from: fromText(from, node) }), sources: joined(sourcePaths.map(path => path.text === '.' ? [hint('整个构建目录')] : context.say(path)), seg('、', 'plain')), target: target ? target.text === '.' ? [seg('./', 'string', target.startIndex)] : context.say(target) : [], flags, action, heredoc, } } /** 一条 COPY / ADD:「[从阶段 X | 从镜像 Y] 复制 源 → 目标(属主 u:g)」;带 heredoc 的是「写入 目标(内容写在下面)」。 */ const copy: NodeReader = (node, context) => { const parts = copyParts(node, context) if (parts.heredoc) return [verb('写入'), space(), ...parts.target, hint('(内容写在下面)'), ...notes(copyNotes(parts.flags, false))] return [ ...(parts.from ? [word(parts.from.stage ? '从阶段' : '从镜像'), space(), ...parts.from.text, space()] : []), verb(parts.action), space(), ...parts.sources, space(), word('→'), space(), ...parts.target, ...notes(copyNotes(parts.flags, false)), ] } /** * 连着写的几条 COPY(从同一个地方复制的):一行「复制(N 条连着写,属主都是 u:g)」,下面一张表「从 | 源 | 到」 * (从构建目录复制的没有「从」这一列),属主都一样才写在头一行,别的选项写在「到」那一格里。 */ const copyGroup: GroupReader = (nodes, context) => { const parts = nodes.map(node => copyParts(node, context)) const owners = new Set(nodes.map(node => flagsOf(node).find(flag => flag.name === 'chown')?.value)) const [owner] = owners const shared = owners.size === 1 && owner !== undefined ? flagsOf(nodes[0]).find(flag => flag.name === 'chown') : undefined const withFrom = parts.some(part => part.from) const items: PropertyItem[] = parts.map(part => ({ cells: [ ...(withFrom ? [part.from ? [word(part.from.stage ? '阶段' : '镜像'), space(), ...part.from.text] : [hint('构建目录')]] : []), part.sources, [...part.target, ...notes(copyNotes(part.flags, !!shared))], ], })) return { text: [verb('复制'), hint(`(${nodes.length} 条连着写`), ...(shared ? [hint(',属主都是 '), seg(shared.value, 'variable', shared.valueStart)] : []), hint(')')], table: { columns: [...(withFrom ? ['从'] : []), '源', '到'], items }, } } // ─── 标签 ──────────────────────────────────────────────────────────────────── function labelRows(nodes: Node[], context: ReaderContext): PropertyItem[] { return nodes.flatMap(node => named(node).filter(child => child.type === 'label_pair')).map((pair) => { const key = pair.childForFieldName('key') const value = pair.childForFieldName('value') return { cells: [key ? context.say(key) : [], value ? context.say(value) : [hint('空')]] } }) } /** LABEL:「标签」,下面一张「名称 | 值」表(连着写的几条并成一张)。 */ const labelGroup: GroupReader = (nodes, context) => ({ text: [word('标签'), ...(nodes.length > 1 ? [hint(`(${nodes.length} 条连着写)`)] : [])], table: { columns: ['名称', '值'], items: labelRows(nodes, context) }, }) // ─── 执行、设置 ────────────────────────────────────────────────────────────── /** `--mount=type=cache,target=…` 这类挂载的意思。 */ function mountNote(flag: Flag): Inline[] { const fields = new Map(flag.value.split(',').map((pair) => { const at = pair.indexOf('=') return at < 0 ? [pair, ''] : [pair.slice(0, at), pair.slice(at + 1)] })) const target = fields.get('target') ?? fields.get('dst') ?? fields.get('destination') const text = (value: string | undefined): Segment[] => value ? [seg(value, 'string')] : [] switch (fields.get('type') ?? 'bind') { case 'cache': return [hint('缓存目录 '), ...text(target), hint(' 跨构建保留')] case 'secret': return [hint('临时挂上密钥 '), ...text(fields.get('id') ?? target), ...(fields.get('env') ? [hint('(放进环境变量 '), ...text(fields.get('env')), hint(')')] : [])] case 'ssh': return [hint('转发 SSH 密钥')] case 'tmpfs': return [...text(target), hint(' 用内存临时目录')] default: { const from = fields.get('from') return [hint('临时挂上 '), ...(from ? [hint('阶段 '), seg(from, 'variable'), hint(' 的 ')] : []), ...(fields.get('source') ? text(fields.get('source')) : [hint('构建目录')]), ...(target ? [hint(' 到 '), ...text(target)] : [])] } } } const NETWORK: Record = { none: '不联网', host: '用宿主机的网络', default: '用默认网络' } const SECURITY: Record = { insecure: '不隔离权限', sandbox: '隔离权限' } /** RUN 的选项:挂载、网络、权限。 */ function runNotes(node: Node): Inline[][] { return flagsOf(node).map((flag) => { if (flag.name === 'mount') return mountNote(flag) const known = flag.name === 'network' ? NETWORK[flag.value] : flag.name === 'security' ? SECURITY[flag.value] : undefined return known ? [hint(known)] : [seg(`--${flag.name}=${flag.value}`, 'plain')] }) } /** 几条命令时写在「执行」后面的:3 条以上写条数,&& 连着的写「一条失败就停」。 */ const COUNTED_STEPS = 3 /** * RUN:shell 只有一条命令(一步)时就是那条命令读成的一行(带它的表);几步时写「执行」,各步是子块。 * 数组写法的命令交给命令规则读。选项(挂载、网络)的意思写在括号里。 */ const run: NodeReader = (node, context) => { const flags = runNotes(node) const shell = named(node).find(child => child.type === 'shell_command') const reading = shell && context.document.embedded?.(shell) if (reading?.whole) { const { text, table } = embeddedText(reading) return { text: [...text, ...notes(flags)], ...(table && { table }) } } if (reading) { const count = reading.steps.length >= COUNTED_STEPS ? [[hint(`${reading.steps.length} 条命令${reading.chained ? ',一条失败就停' : ''}`)]] : [] return [word('执行'), ...notes([...count, ...flags])] } const array = named(node).find(child => child.type === 'json_string_array') if (array) return [...context.say(array), ...notes(flags)] return [word('执行'), space(), seg(shell?.text ?? '', 'plain'), ...notes(flags)] } /** 指令关键字后面的原文(STOPSIGNAL、MAINTAINER 的值在语法树里没有单独的节点)。 */ function afterKeyword(node: Node): string { return node.text.replace(/^\S+\s+/, '').trim() } /** 数组写法的各项、或空格隔开的几项,用 separator 隔开。 */ function items(node: Node, context: ReaderContext, types: string[], separator: Inline): Inline[] { const array = named(node).find(child => child.type === 'json_string_array') const parts = named(array ?? node).filter(child => types.includes(child.type)) return joined(parts.map(part => context.say(part)), separator) } const SETTINGS: Record = { workdir_instruction: (node, context) => [word('工作目录'), space(), ...items(node, context, ['path'], space())], user_instruction: (node, context) => { const user = node.childForFieldName('user') const group = node.childForFieldName('group') return user ? [word('之后以'), space(), context.name(user, 'variable'), ...(group ? [seg(':', 'plain'), context.name(group, 'variable')] : []), space(), word('身份运行')] : undefined }, expose_instruction: (node, context) => [word('开放端口'), space(), ...joined(named(node).map((port) => { if (port.type !== 'expose_port') return context.say(port) const [number, protocol] = port.text.split('/') return [seg(number, 'number'), ...(protocol ? [hint(`(${protocol})`)] : [])] }), seg('、', 'plain'))], volume_instruction: (node, context) => [word('声明数据卷'), space(), ...items(node, context, ['path', 'json_string'], seg('、', 'plain'))], stopsignal_instruction: node => [word('停止容器时发'), space(), seg(afterKeyword(node), 'string'), space(), word('信号')], shell_instruction: (node, context) => [word('之后的 RUN、CMD、ENTRYPOINT 用'), space(), ...items(node, context, ['json_string'], space()), space(), word('执行')], maintainer_instruction: node => [word('维护者'), space(), seg(afterKeyword(node), 'string')], onbuild_instruction: () => [word('以它为基础镜像构建时再执行')], } export const dockerfileZhTranslator = createZhTemplateTranslator({ id: 'dockerfile.zh-template', label: '中文直译', languageIds: ['dockerfile'], keywords: {}, literals: {}, builtinTypes: {}, isTypePosition: () => false, operators: {}, operatorParents: [], emptyLiterals: {}, mapLiterals: {}, stringNodes: [], commentText: (_node, text) => text.replace(/^#[ \t]?/, ''), nodeTemplates: {}, readers: { ...SETTINGS, expansion, unquoted_string: unquoted, double_quoted_string: quoted, single_quoted_string: quoted, json_string: jsonString, path: (node, context) => named(node).length > 0 ? mixed(node, context, 'string') : [seg(node.text, 'string', node.startIndex)], image_name: (node, context) => named(node).length > 0 ? mixed(node, context, 'type') : undefined, image_tag: (node, context) => mixed(node, context, 'string', node.startIndex + 1), image_digest: (node, context) => mixed(node, context, 'string', node.startIndex + 1), run_instruction: run, copy_instruction: copy, add_instruction: copy, label_instruction: (node, context) => labelGroup([node], context), }, groups: { copy_instruction: copyGroup, label_instruction: labelGroup, }, directives: { syntax: { label: '语法', note: '(BuildKit 按这个版本读这份文件)' }, escape: { label: '转义字符' }, check: { label: '检查' }, }, })