425 lines
19 KiB
TypeScript
425 lines
19 KiB
TypeScript
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<string, string> = {
|
||
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<string, string> = { none: '不联网', host: '用宿主机的网络', default: '用默认网络' }
|
||
const SECURITY: Record<string, string> = { 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<string, NodeReader> = {
|
||
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: '检查' },
|
||
},
|
||
})
|