Files
superwork/vscode/src/codeReader/translators/dockerfileZh.ts
T

425 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '检查' },
},
})