✨ feat(vscode): 代码阅读支持 Dockerfile:解析指令、每个 FROM 一个阶段(阶段名能跳过去)、RUN 交给 shell 按命令拆、复制、构建参数 / 环境变量、设置、标签、健康检查、启动,连着写的 ENV / ARG / COPY / LABEL 并成一块加一张表;语法包取自 npm 包 tree-sitter-wasm,打包时复制进扩展
This commit is contained in:
1 parent
0665f35821
commit
d4ec0e7de4
11 files changed
+1619
-4
No files matched your search
@@ -0,0 +1,323 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readBlockViews } from '../blockView'
|
||||
import { commandRuleLookup } from '../commandRules'
|
||||
import { packageCommandRules } from '../commands/packageCommands'
|
||||
import { shellCommandRules } from '../commands/shellCommands'
|
||||
import { systemCommandRules } from '../commands/systemCommands'
|
||||
import { createBashAdapter } from '../languages/bash'
|
||||
import { createDockerfileAdapter } from '../languages/dockerfile'
|
||||
import { DOCKERFILE_WASM_DIR, flatSegments, readFixture, runtime, viewLines, WASM_DIR } from '../testSupport'
|
||||
import { bashZhTranslator } from './bashZh'
|
||||
import { dockerfileZhTranslator } from './dockerfileZh'
|
||||
|
||||
const bash = createBashAdapter(WASM_DIR)
|
||||
const docker = createDockerfileAdapter(DOCKERFILE_WASM_DIR)
|
||||
const RULES = [...shellCommandRules, ...systemCommandRules, ...packageCommandRules]
|
||||
|
||||
/** RUN 里的 shell 交给 bash 的语言插件和中文直译(withShell 为假时不给,照抄)。 */
|
||||
async function read(source: string, withShell = true): Promise<BlockView[]> {
|
||||
const commandRule = commandRuleLookup(RULES, () => {})
|
||||
const { blocks } = await readBlockViews(runtime, docker, dockerfileZhTranslator, 'dockerfile', source, {
|
||||
commandRule,
|
||||
...(withShell && { embedded: (id: string) => id === 'shellscript' ? { adapter: bash, translator: bashZhTranslator, commandRule } : undefined }),
|
||||
})
|
||||
return blocks
|
||||
}
|
||||
|
||||
async function readLines(source: string): Promise<string[]> {
|
||||
return viewLines(await read(source))
|
||||
}
|
||||
|
||||
/** luffy 前端 frontend/client-nextjs/Dockerfile 读出来的样子(原型 NEXT_DOCKERFILE)。 */
|
||||
const NEXT_READING = [
|
||||
'directive 1',
|
||||
' 语法: docker/dockerfile:1.7(BuildKit 按这个版本读这份文件)',
|
||||
'stage 3',
|
||||
' 名称: deps',
|
||||
' 基础镜像: node,标签 22-alpine',
|
||||
' setting 4: 工作目录 /app',
|
||||
' run 5: 执行',
|
||||
' command 5: 开启 corepack',
|
||||
' command 5: 准备 pnpm latest(并设为当前用的版本)',
|
||||
' copy 6: 复制 package.json、pnpm-lock.yaml → ./',
|
||||
' run 7: pnpm 装依赖(严格按 lock 文件、不跑安装脚本)',
|
||||
'stage 9',
|
||||
' 名称: builder',
|
||||
' 基础镜像: node,标签 22-alpine',
|
||||
' setting 10: 工作目录 /app',
|
||||
' run 11: 执行',
|
||||
' command 11: 开启 corepack',
|
||||
' command 11: 准备 pnpm latest(并设为当前用的版本)',
|
||||
' copy 12: 从阶段 deps 复制 /app/node_modules → ./node_modules',
|
||||
' copy 13: 复制 整个构建目录 → ./',
|
||||
' variable 14',
|
||||
' 环境变量: NEXT_TELEMETRY_DISABLED',
|
||||
' 值: 1',
|
||||
' comment 15: 部署 ID 烙进本次构建: Next 据此给静态资源加 ?dpl=、页内跳转时比对版本;',
|
||||
' variable 17',
|
||||
' 构建参数: DEPLOYMENT_ID',
|
||||
' 默认值: 没写,构建时传',
|
||||
' variable 18',
|
||||
' 环境变量: NEXT_DEPLOYMENT_ID',
|
||||
' 值: DEPLOYMENT_ID(构建参数)',
|
||||
' run 19: pnpm 构建',
|
||||
'stage 21',
|
||||
' 名称: runner',
|
||||
' 基础镜像: node,标签 22-alpine',
|
||||
' setting 22: 工作目录 /app',
|
||||
' variable 23',
|
||||
' 环境变量: 4 条连着写',
|
||||
' NODE_ENV | production',
|
||||
' NEXT_TELEMETRY_DISABLED | 1',
|
||||
' PORT | 3000',
|
||||
' HOSTNAME | 0.0.0.0',
|
||||
' variable 27',
|
||||
' 构建参数: DEPLOYMENT_ID',
|
||||
' 默认值: 没写,构建时传',
|
||||
' variable 28',
|
||||
' 环境变量: NEXT_DEPLOYMENT_ID',
|
||||
' 值: DEPLOYMENT_ID(构建参数)',
|
||||
' run 30: 执行',
|
||||
' command 30: 新建系统用户组 nodejs(gid 1001)',
|
||||
' command 31: 新建系统用户 nextjs(uid 1001)',
|
||||
' copy 33: 复制(2 条连着写,属主都是 nextjs:nodejs)',
|
||||
' 阶段 builder | /app/.next/standalone | ./',
|
||||
' 阶段 builder | /app/.next/static | ./.next/static',
|
||||
' comment 35: standalone 输出不含 public/, 必须显式拷贝 (字体等静态资源)',
|
||||
' copy 36: 从阶段 builder 复制 /app/public → ./public(属主 nextjs:nodejs)',
|
||||
' setting 38: 之后以 nextjs 身份运行',
|
||||
' setting 39: 开放端口 3000',
|
||||
' start 41',
|
||||
' 命令: node server.js',
|
||||
' 写法: 数组,不经过 shell',
|
||||
]
|
||||
|
||||
/** luffy 后端 backend/docker/Dockerfile.backend 读出来的样子(原型 BACKEND_DOCKERFILE,原型略去的几段也在)。 */
|
||||
const BACKEND_READING = [
|
||||
'stage 1',
|
||||
' 名称: base',
|
||||
' 说明: ── 统一 Backend 镜像 ─────────────────────────────────────────────────',
|
||||
' 基础镜像: python,标签 3.12-slim',
|
||||
' label 6: 标签',
|
||||
' maintainer | Cruldra',
|
||||
' description | luffy-agent-platform - 统一 Backend 镜像 (API/Worker/Beat/Flower)',
|
||||
' comment 9: git: deployment 模块创建 bare repo;postgresql-client: alembic / psql 运维;curl: healthcheck',
|
||||
' run 13: 执行(10 条命令,一条失败就停)',
|
||||
' command 13: 更新软件包列表',
|
||||
' command 14: 安装 ca-certificates、curl、gnupg、postgresql-client、git(不装推荐的包、不用确认)',
|
||||
' command 16: 建目录 /usr/share/keyrings',
|
||||
' pipeline 17: 请求 https://www.postgresql.org/media/keys/ACCC4CF8.asc(不打印进度、失败就报错、跟着跳转走) → 转成二进制密钥 写到 /usr/share/keyrings/postgresql.gpg',
|
||||
' command 19: 读入 /etc/os-release 里的变量和函数',
|
||||
' command 20: 打印 "deb [signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt VERSION_CODENAME-pgdg main"(写到 /etc/apt/sources.list.d/pgdg.list)',
|
||||
' command 22: 更新软件包列表',
|
||||
' command 23: 安装 postgresql-16-repack(不装推荐的包、不用确认)',
|
||||
' command 24: 打印 pg_repack 的版本',
|
||||
' command 25: 删除 /var/lib/apt/lists/*(连同里面的东西、不提示)',
|
||||
' comment 27: Node.js:agent MCP 节点 (如 context7) 用 stdio + npx 拉子进程, api 容器必须有 npx。',
|
||||
' copy 32: 复制(2 条连着写)',
|
||||
' 镜像 node:22-slim | /usr/local/bin/node | /usr/local/bin/node',
|
||||
' 镜像 node:22-slim | /usr/local/lib/node_modules | /usr/local/lib/node_modules',
|
||||
' run 34: 执行',
|
||||
' command 34: 建符号链接 /usr/local/bin/npm → /usr/local/lib/node_modules/npm/bin/npm-cli.js(已有就替换)',
|
||||
' command 35: 建符号链接 /usr/local/bin/npx → /usr/local/lib/node_modules/npm/bin/npx-cli.js(已有就替换)',
|
||||
' copy 37: 从镜像 ghcr.io/astral-sh/uv:latest 复制 /uv、/uvx → /bin/',
|
||||
' comment 39: appuser 先建、/app /lib 先归属:.venv 由 appuser 直接创建,末尾无需 chown -R',
|
||||
' run 42: 执行(3 条命令,一条失败就停)',
|
||||
' command 42: 新建用户 appuser(建主目录、不能登录)',
|
||||
' command 43: 建目录 /app、/lib(连同上级目录)',
|
||||
' command 44: 把 /app、/lib 的属主改成 appuser:appuser',
|
||||
' setting 45: 之后以 appuser 身份运行',
|
||||
' variable 46',
|
||||
' 环境变量: UV_CACHE_DIR',
|
||||
' 值: /home/appuser/.cache/uv',
|
||||
' setting 48: 工作目录 /app',
|
||||
' comment 50: ── 依赖层(仅随 pyproject/uv.lock 变化重建)───────────────────────────',
|
||||
' copy 54: 复制(12 条连着写,属主都是 appuser:appuser)',
|
||||
' backend/pyproject.toml、backend/uv.lock | ./',
|
||||
' lib/agent-dsl/pyproject.toml | /lib/agent-dsl/',
|
||||
' lib/agent-compiler-deepagents/pyproject.toml | /lib/agent-compiler-deepagents/',
|
||||
' lib/vendor-tools/pyproject.toml | /lib/vendor-tools/',
|
||||
' lib/code-sandbox-core/pyproject.toml | /lib/code-sandbox-core/',
|
||||
' lib/code-sandbox-daytona/pyproject.toml | /lib/code-sandbox-daytona/',
|
||||
' lib/code-sandbox-e2b/pyproject.toml | /lib/code-sandbox-e2b/',
|
||||
' lib/code-sandbox-local/pyproject.toml | /lib/code-sandbox-local/',
|
||||
' lib/code-publish-core/pyproject.toml | /lib/code-publish-core/',
|
||||
' lib/code-publish-compose-runner/pyproject.toml | /lib/code-publish-compose-runner/',
|
||||
' lib/luffy-billing-sdk/pyproject.toml | /lib/luffy-billing-sdk/',
|
||||
' lib/luffy-observability/pyproject.toml | /lib/luffy-observability/',
|
||||
' comment 67: 三方依赖先装;本项目与 lib editable 包此时无源码,跳过安装留给第二次 sync',
|
||||
' run 68: uv 按 lock 同步依赖 · 4 个选项',
|
||||
' --frozen | 严格按 lock 文件,不改它',
|
||||
' --no-dev | 不装开发依赖',
|
||||
' --no-install-project | 不装本项目',
|
||||
' --no-install-package × 11 | 不装这些包:agent-dsl、agent-compiler-deepagents、vendor-tools、code-sandbox-core、code-sandbox-daytona、code-sandbox-e2b、code-sandbox-local、code-publish-core、code-publish-compose-runner、luffy-billing-sdk、luffy-observability',
|
||||
' comment 81: ── 源码层(每次代码变更只重建这里往下)────────────────────────────────',
|
||||
' copy 82: 复制 lib → /lib(属主 appuser:appuser)',
|
||||
' comment 83: agent 定义 (vendor/agents/*/agent.yaml + nodes/*.py) 与代码是同一个版本, 必须随镜像原子切换;',
|
||||
' copy 85: 复制(5 条连着写,属主都是 appuser:appuser)',
|
||||
' vendor | /vendor',
|
||||
' backend/src/ | ./src/',
|
||||
' backend/alembic/ | ./alembic/',
|
||||
' backend/alembic.ini | ./',
|
||||
' backend/scripts/ | ./scripts/',
|
||||
' comment 91: 补装本项目 + lib editable 包(三方依赖已就位,秒级)',
|
||||
' run 92: uv 按 lock 同步依赖(严格按 lock 文件,不改它、不装开发依赖)',
|
||||
' comment 94: 远程调试支持:装入 uv 创建的 venv,按需通过 DEBUGPY_ENABLE=1 开启。',
|
||||
' run 96: uv 安装 debugpy',
|
||||
' variable 98',
|
||||
' 环境变量: UV_NO_SYNC',
|
||||
' 值: 1',
|
||||
' comment 100: 部署 ID (Jenkins IMAGE_TAG): /health 回显、日志定位后端出自哪次构建. 选择性发布时 api 与 client 镜像 tag 可不同, 客户端是否过期以 client 镜像 (浏览器包 vs BFF) 为准.',
|
||||
' variable 101',
|
||||
' 构建参数: DEPLOYMENT_ID',
|
||||
' 默认值: 没写,构建时传',
|
||||
' variable 102',
|
||||
' 环境变量: DEPLOYMENT_ID',
|
||||
' 值: DEPLOYMENT_ID(构建参数)',
|
||||
' setting 104: 开放端口 8000',
|
||||
' comment 106: 默认 CMD 走 api,celery worker/beat/flower 由 compose 覆盖 command',
|
||||
' health 107',
|
||||
' 间隔: 30 秒',
|
||||
' 超时: 5 秒',
|
||||
' 启动后宽限: 10 秒',
|
||||
' 失败几次算不健康: 3 次',
|
||||
' 检查命令: 请求 http://localhost:8000/health(失败就报错),失败就 退出,退出码 1',
|
||||
' start 110',
|
||||
' 命令: 用 uv 运行 uvicorn luffy_agent.main:app,监听 0.0.0.0:8000',
|
||||
' 写法: 数组,不经过 shell',
|
||||
]
|
||||
|
||||
describe('dockerfile 中文直译', () => {
|
||||
it('luffy 前端 Dockerfile:解析指令、三个阶段、RUN 按命令拆、从阶段复制、连着写的 ENV / COPY 并表、构建参数、启动', async () => {
|
||||
expect(await readLines(readFixture('clientNextjs.Dockerfile'))).toEqual(NEXT_READING)
|
||||
})
|
||||
|
||||
it('luffy 后端 Dockerfile:阶段说明、标签表、10 条命令的 RUN(管道、重定向)、从镜像复制、12 条连着的 COPY、uv sync 的选项表、健康检查、启动', async () => {
|
||||
expect(await readLines(readFixture('backend.Dockerfile'))).toEqual(BACKEND_READING)
|
||||
})
|
||||
|
||||
it('阶段:平台、标签和摘要、接着前面的阶段建、没起名的按序号引用;基础镜像写成构建参数时注明', async () => {
|
||||
expect(await readLines('FROM --platform=$BUILDPLATFORM golang:1.22@sha256:abc AS build\nFROM build\n')).toEqual([
|
||||
'stage 1',
|
||||
' 名称: build',
|
||||
' 基础镜像: golang,标签 1.22,摘要 sha256:abc',
|
||||
' 平台: BUILDPLATFORM',
|
||||
'stage 2',
|
||||
' 名称: 没起名,按序号 1 引用',
|
||||
' 基础镜像: 阶段 build',
|
||||
])
|
||||
expect(await readLines(`ARG BASE=python:3.12\nFROM \${BASE}\n`)).toEqual([
|
||||
'variable 1',
|
||||
' 构建参数: BASE',
|
||||
' 默认值: python:3.12',
|
||||
'stage 2',
|
||||
' 名称: 没起名,按序号 0 引用',
|
||||
' 基础镜像: BASE(构建参数)',
|
||||
])
|
||||
})
|
||||
|
||||
it('变量:一条 ENV 写几个是「N 个」加一张表;没设时用默认值的展开、BuildKit 自动给的构建参数写成意思', async () => {
|
||||
expect(await readLines(`FROM x\nENV A=1 B="two words" C=\${VERSION:-x} D=$TARGETARCH\n`)).toEqual([
|
||||
'stage 1',
|
||||
' 名称: 没起名,按序号 0 引用',
|
||||
' 基础镜像: x',
|
||||
' variable 2',
|
||||
' 环境变量: 4 个',
|
||||
' A | 1',
|
||||
' B | two words',
|
||||
' C | VERSION(没设就用 x)',
|
||||
' D | TARGETARCH(目标架构)',
|
||||
])
|
||||
})
|
||||
|
||||
it('执行:挂载、网络的选项写成意思;数组写法交给命令规则;heredoc 按脚本分步;一条循环的子块归 RUN;&& 里带重定向的只算在最后一条上', async () => {
|
||||
const source = [
|
||||
'FROM x',
|
||||
'RUN --mount=type=cache,target=/root/.cache/uv --mount=type=secret,id=npmrc --network=none uv sync',
|
||||
'RUN ["apt-get", "update"]',
|
||||
'RUN <<EOT',
|
||||
'set -e',
|
||||
'apt-get update',
|
||||
'EOT',
|
||||
'RUN for f in a b; do echo $f; done',
|
||||
'RUN mkdir -p /x && echo hi > /x/a',
|
||||
].join('\n')
|
||||
expect((await readLines(source)).slice(3)).toEqual([
|
||||
' run 2: uv 按 lock 同步依赖(缓存目录 /root/.cache/uv 跨构建保留、临时挂上密钥 npmrc、不联网)',
|
||||
' run 3: 更新软件包列表',
|
||||
' run 4: 执行',
|
||||
' options 5',
|
||||
' 出错就停: 是 -e',
|
||||
' command 6: 更新软件包列表',
|
||||
' run 8: 遍历 a、b 中的每个 f',
|
||||
' command 8: 打印 f',
|
||||
' run 9: 执行',
|
||||
' command 9: 建目录 /x(连同上级目录)',
|
||||
' command 9: 打印 "hi"(写到 /x/a)',
|
||||
])
|
||||
})
|
||||
|
||||
it('没有 shell 的语言插件时 RUN 照抄命令、没有子块', async () => {
|
||||
expect((await read('FROM x\nRUN a && b\n', false))[0].children.map(block => viewLines([block]))).toEqual([['run 2: 执行 a && b']])
|
||||
})
|
||||
|
||||
it('复制:heredoc 写入、ADD 下载网址 / 解压压缩包、选项的意思', async () => {
|
||||
const source = 'FROM x\nCOPY <<EOF /etc/app.conf\nkey=value\nEOF\nADD https://example.com/a.tar.gz /tmp/\nADD --checksum=sha256:abc app.tar.gz /opt/\nCOPY --chmod=755 run.sh /usr/local/bin/\n'
|
||||
expect((await readLines(source)).slice(3)).toEqual([
|
||||
' copy 2: 写入 /etc/app.conf(内容写在下面)',
|
||||
' copy 5: 下载 https://example.com/a.tar.gz → /tmp/',
|
||||
' copy 6: 解压 app.tar.gz → /opt/(校验 sha256:abc)',
|
||||
' copy 7: 复制 run.sh → /usr/local/bin/(权限 755)',
|
||||
])
|
||||
})
|
||||
|
||||
it('设置、健康检查、启动', async () => {
|
||||
const source = [
|
||||
'FROM x',
|
||||
'EXPOSE 80/udp 443',
|
||||
'VOLUME ["/data", "/logs"]',
|
||||
'STOPSIGNAL SIGTERM',
|
||||
'SHELL ["/bin/bash", "-o", "pipefail", "-c"]',
|
||||
'USER 1000:1000',
|
||||
'MAINTAINER someone',
|
||||
'ONBUILD RUN make',
|
||||
'HEALTHCHECK NONE',
|
||||
'HEALTHCHECK --interval=1m30s --start-interval=500ms CMD ["curl", "-f", "http://x/"]',
|
||||
'ENTRYPOINT ["/entrypoint.sh"]',
|
||||
'CMD node server.js --port $PORT',
|
||||
].join('\n')
|
||||
expect((await readLines(source)).slice(3)).toEqual([
|
||||
' setting 2: 开放端口 80(udp)、443',
|
||||
' setting 3: 声明数据卷 /data、/logs',
|
||||
' setting 4: 停止容器时发 SIGTERM 信号',
|
||||
' setting 5: 之后的 RUN、CMD、ENTRYPOINT 用 /bin/bash -o pipefail -c 执行',
|
||||
' setting 6: 之后以 1000:1000 身份运行',
|
||||
' setting 7: 维护者 someone',
|
||||
' setting 8: 以它为基础镜像构建时再执行',
|
||||
' run 8: make',
|
||||
' health 9',
|
||||
' 检查: 不做健康检查(基础镜像里设的也关掉)',
|
||||
' health 10',
|
||||
' 间隔: 1 分 30 秒',
|
||||
' 宽限期里的间隔: 500 毫秒',
|
||||
' 检查命令: 请求 http://x/(失败就报错)',
|
||||
' start 11',
|
||||
' 入口命令: /entrypoint.sh',
|
||||
' 写法: 数组,不经过 shell',
|
||||
' start 12',
|
||||
' 命令: node server.js --port PORT',
|
||||
' 写法: 经过 shell',
|
||||
])
|
||||
})
|
||||
|
||||
it('能跳到定义的名字:从阶段复制的阶段名、表里的变量名带源码下标;RUN 的源码按 shell 着色', async () => {
|
||||
const source = 'FROM node AS deps\nFROM node\nCOPY --from=deps /a /a\nENV A=1\nENV B=2\nRUN apt-get update\n'
|
||||
const [, stage] = await read(source)
|
||||
const [copy, env, run] = stage.children
|
||||
const deps = 'translation' in copy && Array.isArray(copy.translation) ? flatSegments(copy.translation).find(segment => segment.text === 'deps') : undefined
|
||||
expect(deps?.offset).toBe(source.indexOf('deps', 20))
|
||||
expect('properties' in env && env.properties[0].items?.map(item => item.defines && source.slice(item.defines.startIndex, item.defines.endIndex))).toEqual(['A', 'B'])
|
||||
expect(run.codeTokens.filter(token => token.role !== 'plain').map(token => [token.text, token.role])).toEqual([['RUN', 'keyword'], ['apt-get', 'function']])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,424 @@
|
||||
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: '检查' },
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user