From d4ec0e7de4b18264ba74a5b364dc18f7627d6bf1 Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 28 Sep 2026 11:54:01 +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=E6=94=AF=E6=8C=81=20Dockerfile=EF=BC=9A?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E6=8C=87=E4=BB=A4=E3=80=81=E6=AF=8F=E4=B8=AA?= =?UTF-8?q?=20FROM=20=E4=B8=80=E4=B8=AA=E9=98=B6=E6=AE=B5=EF=BC=88?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E5=90=8D=E8=83=BD=E8=B7=B3=E8=BF=87=E5=8E=BB?= =?UTF-8?q?=EF=BC=89=E3=80=81RUN=20=E4=BA=A4=E7=BB=99=20shell=20=E6=8C=89?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E6=8B=86=E3=80=81=E5=A4=8D=E5=88=B6=E3=80=81?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E5=8F=82=E6=95=B0=20/=20=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E3=80=81=E8=AE=BE=E7=BD=AE=E3=80=81=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E3=80=81=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E3=80=81?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=EF=BC=8C=E8=BF=9E=E7=9D=80=E5=86=99=E7=9A=84?= =?UTF-8?q?=20ENV=20/=20ARG=20/=20COPY=20/=20LABEL=20=E5=B9=B6=E6=88=90?= =?UTF-8?q?=E4=B8=80=E5=9D=97=E5=8A=A0=E4=B8=80=E5=BC=A0=E8=A1=A8=EF=BC=9B?= =?UTF-8?q?=E8=AF=AD=E6=B3=95=E5=8C=85=E5=8F=96=E8=87=AA=20npm=20=E5=8C=85?= =?UTF-8?q?=20tree-sitter-wasm=EF=BC=8C=E6=89=93=E5=8C=85=E6=97=B6?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E8=BF=9B=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/esbuild.mjs | 6 + vscode/package.json | 5 +- vscode/pnpm-lock.yaml | 8 + .../codeReader/editor/documentMessage.test.ts | 23 +- .../codeReader/fixtures/backend.Dockerfile | 110 +++++ .../fixtures/clientNextjs.Dockerfile | 41 ++ vscode/src/codeReader/index.ts | 8 +- .../codeReader/languages/dockerfile.test.ts | 240 ++++++++++ vscode/src/codeReader/languages/dockerfile.ts | 435 ++++++++++++++++++ .../translators/dockerfileZh.test.ts | 323 +++++++++++++ .../codeReader/translators/dockerfileZh.ts | 424 +++++++++++++++++ 11 files changed, 1619 insertions(+), 4 deletions(-) create mode 100644 vscode/src/codeReader/fixtures/backend.Dockerfile create mode 100644 vscode/src/codeReader/fixtures/clientNextjs.Dockerfile create mode 100644 vscode/src/codeReader/languages/dockerfile.test.ts create mode 100644 vscode/src/codeReader/languages/dockerfile.ts create mode 100644 vscode/src/codeReader/translators/dockerfileZh.test.ts create mode 100644 vscode/src/codeReader/translators/dockerfileZh.ts diff --git a/vscode/esbuild.mjs b/vscode/esbuild.mjs index f450862..cdc189b 100644 --- a/vscode/esbuild.mjs +++ b/vscode/esbuild.mjs @@ -4,6 +4,7 @@ import { createRequire } from 'node:module' import path from 'node:path' import process from 'node:process' import * as esbuild from 'esbuild' +import { getWasmPath } from 'tree-sitter-wasm' const production = process.argv.includes('--production') const watch = process.argv.includes('--watch') @@ -33,12 +34,17 @@ const TREE_SITTER_WASM = [ 'tree-sitter-bash.wasm', ] +// @vscode/tree-sitter-wasm 没有的语法包,从 tree-sitter-wasm(预编译、签过名的各家语法包)拿:语言名 → 它的 wasm +const EXTRA_GRAMMARS = ['dockerfile'] + function copyTreeSitterWasm() { const from = path.dirname(createRequire(import.meta.url).resolve('@vscode/tree-sitter-wasm')) const to = 'dist/tree-sitter' mkdirSync(to, { recursive: true }) for (const file of TREE_SITTER_WASM) copyFileSync(path.join(from, file), path.join(to, file)) + for (const language of EXTRA_GRAMMARS) + copyFileSync(getWasmPath(language), path.join(to, `tree-sitter-${language}.wasm`)) } async function main() { diff --git a/vscode/package.json b/vscode/package.json index 2843e5e..752b494 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -320,7 +320,7 @@ "editor/title": [ { "command": "superpowers.codeReader.showPreview", - "when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python|shellscript)$/ || resourceLangId in superpowers.codeReader.languageIds)", + "when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python|shellscript|dockerfile)$/ || resourceLangId in superpowers.codeReader.languageIds)", "group": "navigation" }, { @@ -340,7 +340,7 @@ }, { "command": "superpowers.codeReader.showPreview", - "when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python|shellscript)$/ || resourceLangId in superpowers.codeReader.languageIds)" + "when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python|shellscript|dockerfile)$/ || resourceLangId in superpowers.codeReader.languageIds)" }, { "command": "superpowers.codeReader.showSource", @@ -414,6 +414,7 @@ "esbuild": "^0.25.0", "eslint": "^9.39.2", "ovsx": "^0.10.7", + "tree-sitter-wasm": "^2.0.2", "typescript": "^5.9.3", "vitest": "^4.0.16" } diff --git a/vscode/pnpm-lock.yaml b/vscode/pnpm-lock.yaml index 0d95c6c..497ce63 100644 --- a/vscode/pnpm-lock.yaml +++ b/vscode/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: ovsx: specifier: ^0.10.7 version: 0.10.7 + tree-sitter-wasm: + specifier: ^2.0.2 + version: 2.0.2 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -3394,6 +3397,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + tree-sitter-wasm@2.0.2: + resolution: {integrity: sha512-zxC/EcugWmdytml6hZoU7vn1olencvNDSluO3BOyYsPWPXkBfbjmvNriJCHght+vXMYsLJZhK9v9e+YBggV4kQ==} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -7205,6 +7211,8 @@ snapshots: tree-kill@1.2.2: {} + tree-sitter-wasm@2.0.2: {} + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 diff --git a/vscode/src/codeReader/editor/documentMessage.test.ts b/vscode/src/codeReader/editor/documentMessage.test.ts index 727a42c..cf1ffe2 100644 --- a/vscode/src/codeReader/editor/documentMessage.test.ts +++ b/vscode/src/codeReader/editor/documentMessage.test.ts @@ -1,15 +1,18 @@ import type { Memento } from 'vscode' import type { LibraryRule } from '../contract' import { describe, expect, it } from 'vitest' +import { packageCommandRules } from '../commands/packageCommands' import { shellCommandRules } from '../commands/shellCommands' import { createBashAdapter } from '../languages/bash' +import { createDockerfileAdapter } from '../languages/dockerfile' import { createPythonAdapter } from '../languages/python' import { createTypeScriptAdapter } from '../languages/typescript' import { CodeReaderRegistry } from '../registry' import { fastapiRule } from '../rules/fastapi' import { sqlalchemyRule } from '../rules/sqlalchemy' -import { runtime, WASM_DIR } from '../testSupport' +import { DOCKERFILE_WASM_DIR, runtime, WASM_DIR } from '../testSupport' import { bashZhTranslator } from '../translators/bashZh' +import { dockerfileZhTranslator } from '../translators/dockerfileZh' import { pythonZhTranslator } from '../translators/pythonZh' import { typescriptZhTranslator } from '../translators/typescriptZh' import { documentMessage } from './documentMessage' @@ -168,6 +171,24 @@ describe('预览的 document 消息', () => { expect(symbols.map(symbol => symbol.occurrences.map(range => source.slice(range.startIndex, range.endIndex)))).toEqual([['dest', 'dest']]) }) + it('dockerfile:RUN 里的 shell 交给注册的 shell 语言插件、翻译插件和命令规则读,名字连同 shell 里的一起交给宿主跳转', async () => { + const registry = new CodeReaderRegistry({ get: () => undefined, update: async () => {} } as Pick) + registry.registerLanguage(createBashAdapter(WASM_DIR)) + registry.registerLanguage(createDockerfileAdapter(DOCKERFILE_WASM_DIR)) + registry.registerTranslator(bashZhTranslator) + registry.registerTranslator(dockerfileZhTranslator) + for (const rule of [...shellCommandRules, ...packageCommandRules]) + registry.registerCommandRule(rule) + const source = 'FROM node:22 AS deps\nRUN x=1 && echo "$x" && apt-get update\nCMD ["node", "server.js"]\n' + const { message, symbols } = await documentMessage(doc('dockerfile', source), registry, runtime, ignore) + const [stage] = message.blocks + expect(stage.kind).toBe('stage') + expect(stage.children.map(child => [child.kind, child.children.map(step => step.kind)])).toEqual([['run', ['variable', 'command', 'command']], ['start', []]]) + expect(stage.children[0].children[2]).toMatchObject({ translation: [{ text: '更新软件包列表' }] }) + expect(message.attributes.find(attribute => attribute.id === 'kind')?.values.map(value => value.name)).toEqual(['阶段', '执行', '变量', '命令', '启动']) + expect(symbols.map(symbol => symbol.occurrences.map(range => source.slice(range.startIndex, range.endIndex)))).toEqual([['deps'], ['x', 'x']]) + }) + it('解析失败:把原因放进 error', async () => { const failing = { parse: async () => Promise.reject(new Error('语法包加载失败')) } const { message } = await documentMessage(doc('typescript', 'const a = 1'), registryWithTypeScript(), failing, ignore) diff --git a/vscode/src/codeReader/fixtures/backend.Dockerfile b/vscode/src/codeReader/fixtures/backend.Dockerfile new file mode 100644 index 0000000..6392ebe --- /dev/null +++ b/vscode/src/codeReader/fixtures/backend.Dockerfile @@ -0,0 +1,110 @@ +# ── 统一 Backend 镜像 ───────────────────────────────────────────────── +# 一个镜像同时承载 api / worker / beat / flower 四种角色; +# 由 docker-compose 传入不同 command 区分角色,避免重复构建/传输。 +FROM python:3.12-slim AS base + +LABEL maintainer="Cruldra" \ + description="luffy-agent-platform - 统一 Backend 镜像 (API/Worker/Beat/Flower)" + +# git: deployment 模块创建 bare repo;postgresql-client: alembic / psql 运维;curl: healthcheck +# postgresql-16-repack: lap checkpoint repack 的 CLI (与线上 PG16 大版本对齐). +# 扩展本体在目标库执行 CREATE EXTENSION pg_repack (运维一次性, 不在镜像构建时做). +# 官方 slim 默认源只有当前大版本 repack, 故加 PGDG 装 16 客户端. +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates curl gnupg postgresql-client git && \ + install -d /usr/share/keyrings && \ + curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | gpg --dearmor -o /usr/share/keyrings/postgresql.gpg && \ + . /etc/os-release && \ + echo "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 && \ + apt-get update && \ + apt-get install -y --no-install-recommends postgresql-16-repack && \ + pg_repack --version && \ + rm -rf /var/lib/apt/lists/* + +# Node.js:agent MCP 节点 (如 context7) 用 stdio + npx 拉子进程, api 容器必须有 npx。 +# 不走 apt install nodejs (拉 ~250 MB Recommends), 从官方 node:22-slim 拷贝必要 +# 文件; node:22-slim 同基于 debian:bookworm-slim, glibc 兼容, 二进制可直接跑。 +# npm / npx 在官方镜像里是指向 node_modules/npm/bin/{npm-cli,npx-cli}.js 的 symlink, +# 拷贝 node_modules 后手建 symlink (COPY 单文件会 dereference, 不能保留 symlink). +COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node +COPY --from=node:22-slim /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ + ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +# appuser 先建、/app /lib 先归属:.venv 由 appuser 直接创建,末尾无需 chown -R +# (chown -R 会在 overlayfs 里把 ~2GB venv 完整复制进新层:镜像翻倍, +# 且该层排在源码层后,每次代码变更都重建,打爆 registry 增量分发) +RUN useradd -m -s /bin/false appuser && \ + mkdir -p /app /lib && \ + chown appuser:appuser /app /lib +USER appuser +ENV UV_CACHE_DIR=/home/appuser/.cache/uv + +WORKDIR /app + +# ── 依赖层(仅随 pyproject/uv.lock 变化重建)─────────────────────────── +# 只放元数据:backend 的 pyproject+lock,以及 ../lib/* 路径依赖的 pyproject +# (uv 解析 [tool.uv.sources] 需要路径存在)。源码后置,改代码不打爆本层, +# registry 分发时该 ~2GB 层可被远端复用,日常部署只传源码层。 +COPY --chown=appuser:appuser backend/pyproject.toml backend/uv.lock ./ +COPY --chown=appuser:appuser lib/agent-dsl/pyproject.toml /lib/agent-dsl/ +COPY --chown=appuser:appuser lib/agent-compiler-deepagents/pyproject.toml /lib/agent-compiler-deepagents/ +COPY --chown=appuser:appuser lib/vendor-tools/pyproject.toml /lib/vendor-tools/ +COPY --chown=appuser:appuser lib/code-sandbox-core/pyproject.toml /lib/code-sandbox-core/ +COPY --chown=appuser:appuser lib/code-sandbox-daytona/pyproject.toml /lib/code-sandbox-daytona/ +COPY --chown=appuser:appuser lib/code-sandbox-e2b/pyproject.toml /lib/code-sandbox-e2b/ +COPY --chown=appuser:appuser lib/code-sandbox-local/pyproject.toml /lib/code-sandbox-local/ +COPY --chown=appuser:appuser lib/code-publish-core/pyproject.toml /lib/code-publish-core/ +COPY --chown=appuser:appuser lib/code-publish-compose-runner/pyproject.toml /lib/code-publish-compose-runner/ +COPY --chown=appuser:appuser lib/luffy-billing-sdk/pyproject.toml /lib/luffy-billing-sdk/ +COPY --chown=appuser:appuser lib/luffy-observability/pyproject.toml /lib/luffy-observability/ + +# 三方依赖先装;本项目与 lib editable 包此时无源码,跳过安装留给第二次 sync +RUN uv sync --frozen --no-dev --no-install-project \ + --no-install-package agent-dsl \ + --no-install-package agent-compiler-deepagents \ + --no-install-package vendor-tools \ + --no-install-package code-sandbox-core \ + --no-install-package code-sandbox-daytona \ + --no-install-package code-sandbox-e2b \ + --no-install-package code-sandbox-local \ + --no-install-package code-publish-core \ + --no-install-package code-publish-compose-runner \ + --no-install-package luffy-billing-sdk \ + --no-install-package luffy-observability + +# ── 源码层(每次代码变更只重建这里往下)──────────────────────────────── +COPY --chown=appuser:appuser lib /lib +# agent 定义 (vendor/agents/*/agent.yaml + nodes/*.py) 与代码是同一个版本, 必须随镜像原子切换; +# 路径与 settings 默认值 <_BASE_DIR>/../vendor/agents 对齐, 容器内即 /vendor/agents. +COPY --chown=appuser:appuser vendor /vendor +COPY --chown=appuser:appuser backend/src/ ./src/ +COPY --chown=appuser:appuser backend/alembic/ ./alembic/ +COPY --chown=appuser:appuser backend/alembic.ini ./ +COPY --chown=appuser:appuser backend/scripts/ ./scripts/ + +# 补装本项目 + lib editable 包(三方依赖已就位,秒级) +RUN uv sync --frozen --no-dev + +# 远程调试支持:装入 uv 创建的 venv,按需通过 DEBUGPY_ENABLE=1 开启。 +# 必须在最后一次 uv sync 之后:sync 会精确对齐 lock,把 lock 外的包卸掉。 +RUN uv pip install debugpy + +ENV UV_NO_SYNC=1 + +# 部署 ID (Jenkins IMAGE_TAG): /health 回显、日志定位后端出自哪次构建. 选择性发布时 api 与 client 镜像 tag 可不同, 客户端是否过期以 client 镜像 (浏览器包 vs BFF) 为准. +ARG DEPLOYMENT_ID +ENV DEPLOYMENT_ID=$DEPLOYMENT_ID + +EXPOSE 8000 + +# 默认 CMD 走 api,celery worker/beat/flower 由 compose 覆盖 command +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +CMD ["uv", "run", "uvicorn", "luffy_agent.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/vscode/src/codeReader/fixtures/clientNextjs.Dockerfile b/vscode/src/codeReader/fixtures/clientNextjs.Dockerfile new file mode 100644 index 0000000..65f5f5a --- /dev/null +++ b/vscode/src/codeReader/fixtures/clientNextjs.Dockerfile @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:22-alpine AS deps +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@latest --activate +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --ignore-scripts + +FROM node:22-alpine AS builder +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@latest --activate +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +# 部署 ID 烙进本次构建: Next 据此给静态资源加 ?dpl=、页内跳转时比对版本; +# 浏览器包与服务端包都能读 process.env.NEXT_DEPLOYMENT_ID (见 src/lib/version-skew). +ARG DEPLOYMENT_ID +ENV NEXT_DEPLOYMENT_ID=$DEPLOYMENT_ID +RUN pnpm build + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +ARG DEPLOYMENT_ID +ENV NEXT_DEPLOYMENT_ID=$DEPLOYMENT_ID + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +# standalone 输出不含 public/, 必须显式拷贝 (字体等静态资源) +COPY --from=builder --chown=nextjs:nodejs /app/public ./public + +USER nextjs +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 403db26..1a9eb5b 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -3,7 +3,9 @@ import type { CodeReaderApi, RuleLocation, RuleReference } from './contract' import type { IndexedName } from './nameResolution' import { commands, window, workspace } from 'vscode' import { logger } from '../logging/logger' +import { packageCommandRules } from './commands/packageCommands' import { shellCommandRules } from './commands/shellCommands' +import { systemCommandRules } from './commands/systemCommands' import { BlockFilter } from './editor/blockFilter' import { NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigation' import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor' @@ -11,6 +13,7 @@ import { showPreview, showSource } from './editor/switchEditor' import { registerCopyCommands } from './explorer/copyCommands' import { FileTreeProvider } from './explorer/fileTree' import { createBashAdapter } from './languages/bash' +import { createDockerfileAdapter } from './languages/dockerfile' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' import { resolveReference } from './libraryRules' @@ -27,6 +30,7 @@ import { fastapiRule } from './rules/fastapi' import { sqlalchemyRule } from './rules/sqlalchemy' import { bundledWasmDir, TreeSitterRuntime } from './runtime' import { bashZhTranslator } from './translators/bashZh' +import { dockerfileZhTranslator } from './translators/dockerfileZh' import { pythonZhTranslator } from './translators/pythonZh' import { typescriptZhTranslator } from './translators/typescriptZh' import { workspaceIndexHost } from './workspaceFiles' @@ -115,10 +119,12 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { api.registerLanguage(createTypeScriptAdapter(wasmDir)), api.registerLanguage(createPythonAdapter(wasmDir)), api.registerLanguage(createBashAdapter(wasmDir)), + api.registerLanguage(createDockerfileAdapter(wasmDir)), api.registerTranslator(typescriptZhTranslator), api.registerTranslator(pythonZhTranslator), api.registerTranslator(bashZhTranslator), - ...shellCommandRules.map(rule => api.registerCommandRule(rule)), + api.registerTranslator(dockerfileZhTranslator), + ...[...shellCommandRules, ...systemCommandRules, ...packageCommandRules].map(rule => api.registerCommandRule(rule)), api.registerLibraryRule(sqlalchemyRule), api.registerLibraryRule(celeryRule), api.registerLibraryRule(enumRule), diff --git a/vscode/src/codeReader/languages/dockerfile.test.ts b/vscode/src/codeReader/languages/dockerfile.test.ts new file mode 100644 index 0000000..36199d0 --- /dev/null +++ b/vscode/src/codeReader/languages/dockerfile.test.ts @@ -0,0 +1,240 @@ +import type { Outline } from '../testSupport' +import { describe, expect, it } from 'vitest' +import { DOCKERFILE_WASM_DIR, factsByLine, nodeFacts, outline, readFixture, runtime, withBlocks } from '../testSupport' +import { createDockerfileAdapter } from './dockerfile' + +const adapter = createDockerfileAdapter(DOCKERFILE_WASM_DIR) +const NEXT = readFixture('clientNextjs.Dockerfile') +const BACKEND = readFixture('backend.Dockerfile') + +function tree(source: string): Promise { + return withBlocks(adapter, 'dockerfile', source, outline) +} + +function facts(source: string): Promise> { + return withBlocks(adapter, 'dockerfile', source, factsByLine) +} + +/** luffy 前端 frontend/client-nextjs/Dockerfile 的代码块树(同原型 NEXT_DOCKERFILE)。 */ +const NEXT_TREE: Outline[] = [ + { at: 1, kind: 'directive' }, + { at: 3, kind: 'stage', kids: [ + { at: 4, kind: 'setting' }, + { at: 5, kind: 'run' }, + { at: 6, kind: 'copy' }, + { at: 7, kind: 'run' }, + ] }, + { at: 9, kind: 'stage', kids: [ + { at: 10, kind: 'setting' }, + { at: 11, kind: 'run' }, + { at: 12, kind: 'copy' }, + { at: 13, kind: 'copy' }, + { at: 14, kind: 'variable' }, + { at: [15, 16], kind: 'comment' }, + { at: 17, kind: 'variable' }, + { at: 18, kind: 'variable' }, + { at: 19, kind: 'run' }, + ] }, + { at: 21, kind: 'stage', kids: [ + { at: 22, kind: 'setting' }, + { at: [23, 26], kind: 'variable' }, + { at: 27, kind: 'variable' }, + { at: 28, kind: 'variable' }, + { at: [30, 31], kind: 'run' }, + { at: [33, 34], kind: 'copy' }, + { at: 35, kind: 'comment' }, + { at: 36, kind: 'copy' }, + { at: 38, kind: 'setting' }, + { at: 39, kind: 'setting' }, + { at: 41, kind: 'start' }, + ] }, +] + +/** luffy 后端 backend/docker/Dockerfile.backend 的代码块树:FROM 上面紧贴的三行注释是阶段的说明,算在阶段的头部里。 */ +const BACKEND_TREE: Outline[] = [ + { at: [1, 4], kind: 'stage', kids: [ + { at: [6, 7], kind: 'label' }, + { at: [9, 12], kind: 'comment' }, + { at: [13, 25], kind: 'run' }, + { at: [27, 31], kind: 'comment' }, + { at: [32, 33], kind: 'copy' }, + { at: [34, 35], kind: 'run' }, + { at: 37, kind: 'copy' }, + { at: [39, 41], kind: 'comment' }, + { at: [42, 44], kind: 'run' }, + { at: 45, kind: 'setting' }, + { at: 46, kind: 'variable' }, + { at: 48, kind: 'setting' }, + { at: [50, 53], kind: 'comment' }, + { at: [54, 65], kind: 'copy' }, + { at: 67, kind: 'comment' }, + { at: [68, 79], kind: 'run' }, + { at: 81, kind: 'comment' }, + { at: 82, kind: 'copy' }, + { at: [83, 84], kind: 'comment' }, + { at: [85, 89], kind: 'copy' }, + { at: 91, kind: 'comment' }, + { at: 92, kind: 'run' }, + { at: [94, 95], kind: 'comment' }, + { at: 96, kind: 'run' }, + { at: 98, kind: 'variable' }, + { at: 100, kind: 'comment' }, + { at: 101, kind: 'variable' }, + { at: 102, kind: 'variable' }, + { at: 104, kind: 'setting' }, + { at: 106, kind: 'comment' }, + { at: [107, 108], kind: 'health' }, + { at: 110, kind: 'start' }, + ] }, +] + +describe('dockerfile 代码块树', () => { + it('两份 luffy 的 Dockerfile:解析指令、每个 FROM 一个阶段(后面的指令是子块)、连着写的 ENV / COPY / LABEL 并成一块', async () => { + expect(await tree(NEXT)).toEqual(NEXT_TREE) + expect(await tree(BACKEND)).toEqual(BACKEND_TREE) + }) + + it('并块:同种指令紧挨着才并,空行、注释、别的指令隔开;COPY 要从同一个地方复制;ADD、带 heredoc 的 COPY 不并;第一个 FROM 之前的照常是顶层的块', async () => { + const source = [ + 'ARG A=1', + 'ARG B', + 'FROM x', + 'ENV C=1', + '', + 'ENV D=2', + 'COPY --from=a /x /x', + 'COPY --from=a /y /y', + 'COPY /z /z', + 'ADD a.tar.gz /a/', + 'ADD b.tar.gz /b/', + 'COPY < { + const source = 'FROM x\nONBUILD RUN make\nWORKDIR /a\nUSER u\nEXPOSE 80\nVOLUME /v\nSTOPSIGNAL SIGTERM\nSHELL ["/bin/bash", "-c"]\n' + expect(await tree(source)).toEqual([{ at: 1, kind: 'stage', kids: [ + { at: 2, kind: 'setting', kids: [{ at: 2, kind: 'run' }] }, + ...[3, 4, 5, 6, 7, 8].map(at => ({ at, kind: 'setting' })), + ] }]) + }) +}) + +describe('dockerfile 属性表信息(facts)', () => { + it('解析指令、阶段(名称、第几个、说明、基础镜像、标签 / 摘要、接着哪个阶段、选项)', async () => { + expect((await facts(NEXT))[1]).toEqual({ directives: [{ name: 'syntax', value: 'docker/dockerfile:1.7' }] }) + expect((await facts(NEXT))[9]).toEqual({ name: 'builder', index: 1, image: 'node', stage: false, tag: ':22-alpine', flags: [] }) + expect((await facts(BACKEND))[1]).toMatchObject({ name: 'base', index: 0, doc: ['# ── 统一 Backend 镜像 ─────────────────────────────────────────────────', expect.any(String), expect.any(String)] }) + const byLine = await facts('FROM --platform=$BUILDPLATFORM golang@sha256:abc AS build\nFROM build\n') + expect(byLine[1]).toMatchObject({ image: 'golang', digest: '@sha256:abc', flags: [{ node: '--platform=$BUILDPLATFORM', name: 'platform', value: '$BUILDPLATFORM', valueStart: 16 }] }) + expect(byLine[2]).toEqual({ index: 1, image: 'build', stage: true, flags: [] }) + }) + + it('变量:ARG 是构建参数、ENV 是环境变量,连着写的并成一块(各条的变量都在里面)', async () => { + const byLine = await facts(NEXT) + expect(byLine[17]).toEqual({ declarators: [{ name: 'DEPLOYMENT_ID' }], scope: 'build' }) + expect(byLine[23]).toEqual({ scope: 'environment', declarators: [ + { name: 'NODE_ENV', value: 'production' }, + { name: 'NEXT_TELEMETRY_DISABLED', value: '1' }, + { name: 'PORT', value: '3000' }, + { name: 'HOSTNAME', value: '0.0.0.0' }, + ] }) + }) + + it('健康检查(选项、检查命令两种写法、NONE),启动(CMD / ENTRYPOINT,数组写法和交给 shell 的一段)', async () => { + const byLine = await facts(BACKEND) + expect(byLine[107]).toMatchObject({ + none: false, + flags: [{ name: 'interval', value: '30s' }, { name: 'timeout', value: '5s' }, { name: 'start-period', value: '10s' }, { name: 'retries', value: '3' }], + command: { form: 'shell', code: { languageId: 'shellscript' } }, + }) + expect(byLine[110]).toMatchObject({ instruction: 'cmd', command: { form: 'exec', node: '["uv", "run", "uvicorn", "luffy_agent.main:app", "--host", "0.0.0.0", "--port", "8000"]' } }) + const other = await facts('FROM x\nHEALTHCHECK NONE\nENTRYPOINT node a.js\n') + expect(other[2]).toEqual({ none: true, flags: [] }) + expect(other[3]).toMatchObject({ instruction: 'entrypoint', command: { form: 'shell' } }) + }) +}) + +describe('dockerfile 嵌着的 shell、命令、名字', () => { + it('嵌着的 shell:各个片段去掉续行的 \\ 和缩进;只写了 < { + const source = 'RUN useradd u && \\\n mkdir -p /a\nRUN < ({ node: code.node.type, languageId: code.languageId, text: code.ranges.map(range => source.slice(range.startIndex, range.endIndex)) }))).toEqual([ + { node: 'shell_command', languageId: 'shellscript', text: ['useradd u && ', 'mkdir -p /a'] }, + { node: 'shell_command', languageId: 'shellscript', text: ['set -e\napt-get update'] }, + ]) + } + finally { + parsed.delete() + } + }) + + it('数组写法的命令(RUN、CMD、ENTRYPOINT、HEALTHCHECK CMD)交给命令规则:第一项是命令名,各项去掉引号;VOLUME、SHELL 的数组不是命令', async () => { + const commands = await nodeFacts(adapter, 'dockerfile', 'CMD ["node", "server.js"]\nVOLUME ["/data"]\nHEALTHCHECK CMD ["curl", "-f", "http://x/"]\n', node => adapter.command?.(node)) + expect(commands).toEqual([ + { name: { node: '"node"', literal: 'node' }, arguments: [{ node: '"server.js"', literal: 'server.js' }], redirects: [] }, + { name: { node: '"curl"', literal: 'curl' }, arguments: [{ node: '"-f"', literal: '-f' }, { node: '"http://x/"', literal: 'http://x/' }], redirects: [] }, + ]) + }) + + it('同一文件里的名字:阶段(AS 后面的名字、COPY --from=、--mount=…,from=、FROM 阶段名),变量(ARG、ENV 的名字,$X、{X} 这种展开)', async () => { + const source = `FROM node AS deps\nARG V\nFROM deps AS build\nENV W=$V\nCOPY --from=deps /a /a\nRUN --mount=type=bind,from=deps,target=/x ls\nWORKDIR \${W}\n` + const parsed = await runtime.parse(adapter, 'dockerfile', source) + try { + const symbols = adapter.symbols?.(parsed.rootNode, source, 'dockerfile') ?? [] + const texts = symbols.map(symbol => ({ + definitions: symbol.definitions.map(range => `${source.slice(range.startIndex, range.endIndex)}@${range.startIndex}`), + occurrences: symbol.occurrences.map(range => source.slice(range.startIndex, range.endIndex)), + })) + expect(texts).toEqual([ + { definitions: [`deps@${source.indexOf('deps')}`], occurrences: ['deps', 'deps', 'deps', 'deps'] }, + { definitions: [`V@${source.indexOf('V\n')}`], occurrences: ['V', 'V'] }, + { definitions: [`build@${source.indexOf('build')}`], occurrences: ['build'] }, + { definitions: [`W@${source.indexOf('W=')}`], occurrences: ['W', 'W'] }, + ]) + } + finally { + parsed.delete() + } + }) + + it('中文名:紧挨在 ARG、ENV(一个变量的)、FROM … AS 上面、只写了 # @Comment 的注释;这行注释算在那条指令的块里', async () => { + const source = '# @Comment 部署 ID\nARG DEPLOYMENT_ID\n# @Comment 依赖阶段\nFROM node AS deps\n' + const parsed = await runtime.parse(adapter, 'dockerfile', source) + try { + const found = adapter.displayNames?.find(parsed.rootNode, source, 'dockerfile') + expect(found?.names.map(({ name, text }) => [source.slice(name.startIndex, name.endIndex), text])).toEqual([['DEPLOYMENT_ID', '部署 ID'], ['deps', '依赖阶段']]) + expect(found?.markers.map(range => source.slice(range.startIndex, range.endIndex))).toEqual(['@Comment 部署 ID', '@Comment 依赖阶段']) + } + finally { + parsed.delete() + } + expect(await tree(source)).toEqual([{ at: [1, 2], kind: 'variable' }, { at: [3, 4], kind: 'stage' }]) + }) + + it('着色:指令关键字、镜像名、标签、阶段名、路径、变量', async () => { + const roles = await nodeFacts(adapter, 'dockerfile', 'FROM node:22 AS deps\nCOPY a /b\nENV PORT=$X\n', node => node.childCount === 0 ? `${node.text}:${adapter.tokenRole(node)}` : undefined) + expect(roles).toEqual(expect.arrayContaining(['FROM:keyword', 'node:type', 'AS:keyword', 'deps:variable', 'COPY:keyword', 'a:string', '/b:string', 'PORT:constant', 'X:constant'])) + }) +}) diff --git a/vscode/src/codeReader/languages/dockerfile.ts b/vscode/src/codeReader/languages/dockerfile.ts new file mode 100644 index 0000000..18c4daf --- /dev/null +++ b/vscode/src/codeReader/languages/dockerfile.ts @@ -0,0 +1,435 @@ +import type { CodeBlock, CommandFacts, CommandForm, CommandWord, DeclaratorFacts, DirectiveFacts, DisplayName, DisplayNameReader, EmbeddedCode, InstructionFlag, LanguageAdapter, LocalSymbol, Node, ParserDirective, SourceRange, SourceSpan, StageFacts, TextBlock, TokenRole } from '../contract' +import * as path from 'node:path' +import { COMMENT_MARK, commentTag } from './displayNames' +import { children, leafBlock, leafShape, named } from './shared' + +/** RUN、shell 写法的 CMD / ENTRYPOINT、HEALTHCHECK 的检查命令交给 shell 的语言插件读。 */ +const SHELL = 'shellscript' +/** BuildKit 认的解析指令,只在文件最开头连着写的几行注释里算。 */ +const DIRECTIVE = /^#\s*(syntax|escape|check)\s*=(.*)$/i +/** 一条指令一行「设置」。 */ +const SETTINGS = new Set(['workdir_instruction', 'user_instruction', 'expose_instruction', 'volume_instruction', 'stopsignal_instruction', 'shell_instruction', 'maintainer_instruction']) +/** 连着写(中间没空行、没注释)就并成一块的指令;ADD 各条做的事不一样(下载、解压、复制),不并。 */ +const MERGED = new Set(['env_instruction', 'arg_instruction', 'copy_instruction', 'label_instruction']) +/** 执行命令的指令:数组写法的命令交给命令规则读。 */ +const COMMAND_INSTRUCTIONS = new Set(['run_instruction', 'cmd_instruction', 'entrypoint_instruction']) +/** 按习惯当常量用的名字:全大写(可带数字、下划线)。 */ +const CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/ +const NAME = /^[A-Z_]\w*/i + +function spanOf(first: Node, last: Node): SourceSpan { + return { startIndex: first.startIndex, endIndex: last.endIndex, startRow: first.startPosition.row, endRow: last.endPosition.row } +} + +/** 下一个节点紧接在上一个节点的下一行开始(中间没空行)。 */ +function adjacent(previous: Node, next: Node): boolean { + return next.startPosition.row === previous.endPosition.row + 1 +} + +// ─── 选项、命令 ───────────────────────────────────────────────────────────── + +/** 指令后面的 `--名字=值`(`--mount=type=cache,…` 的值是逗号后面那一整串)。 */ +export function instructionFlags(instruction: Node): InstructionFlag[] { + return named(instruction).flatMap((node): InstructionFlag[] => { + if (node.type !== 'param' && node.type !== 'mount_param') + return [] + const match = /^--([a-z][-a-z]*)=(.*)$/s.exec(node.text) + return match ? [{ node, name: match[1], value: match[2], valueStart: node.startIndex + match[1].length + 3 }] : [] + }) +} + +/** 只写了一个 `< child.type === 'heredoc_marker') && /^<<-?["']?\w+["']?\s*$/.test(fragments[0].text) +} + +/** + * shell 写法的命令交给 shell 读的几段:各个 shell 片段(去掉续行的 `\` 和下一行的缩进); + * 只写了 `< child.type === 'shell_fragment') + if (fragments.length === 0) + return undefined + if (heredocOnly(fragments)) { + const lines = named(named(command.parent).find(child => child.type === 'heredoc_block')).filter(child => child.type === 'heredoc_line') + const [first, last] = [lines[0], lines.at(-1)] + return first && last ? { node: command, languageId: SHELL, ranges: [{ startIndex: first.startIndex, endIndex: last.endIndex }] } : undefined + } + return { node: command, languageId: SHELL, ranges: fragments.map(({ startIndex, endIndex }) => ({ startIndex, endIndex })) } +} + +/** 执行命令的两种写法:数组(不经过 shell)和交给 shell 的一段。 */ +function commandForm(instruction: Node): CommandForm | undefined { + const array = named(instruction).find(child => child.type === 'json_string_array') + if (array) + return { form: 'exec', node: array } + const shell = named(instruction).find(child => child.type === 'shell_command') + const code = shell && shellCode(shell) + return code && { form: 'shell', code } +} + +/** JSON 字符串里写的文字(去掉引号、转义)。 */ +function jsonText(node: Node): string | undefined { + try { + const value: unknown = JSON.parse(node.text) + return typeof value === 'string' ? value : undefined + } + catch { + return undefined + } +} + +function jsonWord(node: Node): CommandWord { + const literal = jsonText(node) + return { node, ...(literal !== undefined && { literal }) } +} + +/** 数组写法的命令(RUN、CMD、ENTRYPOINT、HEALTHCHECK CMD 后面的 `["node", "server.js"]`):第一项是命令名,后面是各个词。 */ +function dockerCommand(node: Node): CommandFacts | undefined { + if (node.type !== 'json_string_array' || !COMMAND_INSTRUCTIONS.has(node.parent?.type ?? '')) + return undefined + const [name, ...args] = named(node).filter(child => child.type === 'json_string') + return name ? { name: jsonWord(name), arguments: args.map(jsonWord), redirects: [] } : undefined +} + +// ─── 块 ────────────────────────────────────────────────────────────────────── + +/** 文件最开头连着的几行 `# syntax=…`:解析指令块;别的注释、空行、指令之后不再算。 */ +function directiveBlock(items: Node[]): { block?: CodeBlock, rest: Node[] } { + const directives: ParserDirective[] = [] + let count = 0 + for (const item of items) { + const match = item.type === 'comment' && item.startPosition.row === count ? DIRECTIVE.exec(item.text) : null + if (!match) + break + directives.push({ name: match[1].toLowerCase(), value: match[2].trim() }) + count++ + } + if (count === 0) + return { rest: items } + const facts: DirectiveFacts = { directives } + return { block: { kind: 'directive', node: items[0], header: spanOf(items[0], items[count - 1]), children: [], facts }, rest: items.slice(count) } +} + +/** 只写了中文名记号的注释(`# @Comment 部署 ID`):它下面紧挨着的那条指令的一部分,不单独成块。 */ +function markerOnly(comment: Node): boolean { + const tag = commentTag(comment.text) + return !!tag && comment.text.slice(0, tag.start).replace(/^#/, '').trim() === '' +} + +function envDeclarators(instruction: Node): DeclaratorFacts[] { + return named(instruction).filter(child => child.type === 'env_pair').flatMap((pair): DeclaratorFacts[] => { + const name = pair.childForFieldName('name') + const value = pair.childForFieldName('value') + return name ? [{ name, ...(value && { value }) }] : [] + }) +} + +function argDeclarators(instruction: Node): DeclaratorFacts[] { + const name = instruction.childForFieldName('name') + const value = instruction.childForFieldName('default') + return name ? [{ name, ...(value && { value }) }] : [] +} + +/** 连着写的同种指令并成的一块:ENV / ARG 是一个变量块(各条的变量都在里面),COPY / ADD / LABEL 是一行字加一张表。 */ +function mergedBlock(run: Node[]): CodeBlock { + const [first] = run + const last = run.at(-1) ?? first + const shape = { node: first, header: spanOf(first, last), children: [] } + switch (first.type) { + case 'env_instruction': + return { kind: 'variable', ...shape, facts: { declarators: run.flatMap(envDeclarators), scope: 'environment' } } + case 'arg_instruction': + return { kind: 'variable', ...shape, facts: { declarators: run.flatMap(argDeclarators), scope: 'build' } } + default: { + const block: TextBlock = { kind: first.type === 'label_instruction' ? 'label' : 'copy', ...shape } + return run.length > 1 ? { ...block, group: run } : block + } + } +} + +/** + * 能并进同一块:同一种指令、紧挨着下一行;COPY / ADD 还要从同一个地方复制(都从构建目录,或 `--from` 一样)。 + * 带 heredoc 的 COPY(`COPY < named(node).some(child => child.type === 'heredoc_block') + const from = (node: Node): string | undefined => instructionFlags(node).find(flag => flag.name === 'from')?.value + return next.type === previous.type && adjacent(previous, next) && !heredoc(previous) && !heredoc(next) && from(previous) === from(next) +} + +function instructionBlock(item: Node): CodeBlock { + if (SETTINGS.has(item.type)) + return leafBlock('setting', item) + switch (item.type) { + case 'run_instruction': { + const shell = named(item).find(child => child.type === 'shell_command') + const code = shell && shellCode(shell) + return { kind: 'run', ...leafShape(item), ...(code && { embedded: code }) } + } + case 'healthcheck_instruction': { + const cmd = named(item).find(child => child.type === 'cmd_instruction') + const command = cmd && commandForm(cmd) + const none = children(item).some(child => child.type === 'NONE') + return { kind: 'health', ...leafShape(item), facts: { none, flags: instructionFlags(item), ...(command && { command }) } } + } + case 'cmd_instruction': + case 'entrypoint_instruction': { + const command = commandForm(item) + return command + ? { kind: 'start', ...leafShape(item), facts: { instruction: item.type === 'cmd_instruction' ? 'cmd' : 'entrypoint', command } } + : leafBlock('statement', item) + } + case 'onbuild_instruction': { + const [inner] = named(item) + return { kind: 'setting', ...leafShape(item), children: inner ? [instructionBlock(inner)] : [] } + } + default: + return MERGED.has(item.type) || item.type === 'add_instruction' ? mergedBlock([item]) : leafBlock('statement', item) + } +} + +/** 阶段里(或第一个 FROM 之前)的一串指令、注释:相邻行的注释合成一块,连着写的同种指令并成一块。 */ +function instructionBlocks(items: Node[]): CodeBlock[] { + const out: CodeBlock[] = [] + let comments: CodeBlock | undefined + let marker: Node | undefined + for (let i = 0; i < items.length; i++) { + const item = items[i] + if (item.type === 'comment') { + const next = items[i + 1] + if (markerOnly(item) && next && next.type !== 'comment' && adjacent(item, next)) { + marker = item + comments = undefined + continue + } + if (comments && adjacent(items[i - 1], item)) { + comments.header = { ...comments.header, endIndex: item.endIndex, endRow: item.endPosition.row } + } + else { + comments = leafBlock('comment', item) + out.push(comments) + } + continue + } + comments = undefined + const run = [item] + while (MERGED.has(item.type) && items[i + 1] && mergeable(run[run.length - 1], items[i + 1])) + run.push(items[++i]) + const block = run.length > 1 ? mergedBlock(run) : instructionBlock(item) + out.push(marker ? { ...block, header: { ...block.header, startIndex: marker.startIndex, startRow: marker.startPosition.row } } : block) + marker = undefined + } + return out +} + +/** 紧贴在 FROM 上面、连着的几行注释(最后一行就在 FROM 上一行):阶段的说明。 */ +function stageDoc(items: Node[], at: number): Node[] { + const doc: Node[] = [] + for (let i = at - 1; i >= 0 && items[i].type === 'comment' && adjacent(items[i], doc[0] ?? items[at]); i--) + doc.unshift(items[i]) + return doc +} + +function stageBlock(from: Node, index: number, doc: Node[], aliases: Set, children: CodeBlock[]): CodeBlock { + const spec = named(from).find(child => child.type === 'image_spec') + const image = spec?.childForFieldName('name') ?? from + const name = from.childForFieldName('as') + const tag = spec?.childForFieldName('tag') + const digest = spec?.childForFieldName('digest') + const facts: StageFacts = { + ...(name && { name }), + index, + ...(doc.length > 0 && { doc }), + image, + stage: aliases.has(image.text.toLowerCase()), + ...(tag && { tag }), + ...(digest && { digest }), + flags: instructionFlags(from), + } + return { kind: 'stage', node: from, header: spanOf(doc[0] ?? from, from), children, facts } +} + +/** 每个 FROM 开一个阶段,到下一个阶段(连同它上面的说明)之前的指令、注释是它的子块;第一个 FROM 之前的照常是顶层的块。 */ +function dockerBlocks(root: Node): CodeBlock[] { + const { block: directives, rest: items } = directiveBlock(named(root)) + const froms = items.flatMap((item, i) => item.type === 'from_instruction' ? [i] : []) + const docs = froms.map(at => stageDoc(items, at)) + const starts = froms.map((at, k) => at - docs[k].length) + const out: CodeBlock[] = [...(directives ? [directives] : []), ...instructionBlocks(items.slice(0, starts[0] ?? items.length))] + const aliases = new Set() + froms.forEach((at, k) => { + const from = items[at] + const stage = stageBlock(from, k, docs[k], aliases, instructionBlocks(items.slice(at + 1, starts[k + 1] ?? items.length))) + const alias = from.childForFieldName('as') + if (alias) + aliases.add(alias.text.toLowerCase()) + out.push(stage) + }) + return out +} + +// ─── 着色、名字 ────────────────────────────────────────────────────────────── + +function nameRole(text: string): TokenRole { + return CONSTANT_NAME.test(text) ? 'constant' : 'variable' +} + +function dockerTokenRole(leaf: Node): TokenRole { + const parent = leaf.parent + if (leaf.type === 'comment') + return 'comment' + if (!leaf.isNamed) + return /^[A-Z]+$/.test(leaf.type) ? 'keyword' : 'plain' + switch (leaf.type) { + case 'image_name': + return 'type' + case 'image_tag': + case 'image_digest': + case 'path': + case 'json_string': + return 'string' + case 'image_alias': + return 'variable' + case 'variable': + return nameRole(leaf.text) + case 'expose_port': + return 'number' + case 'heredoc_line': + return parent?.parent?.type === 'run_instruction' ? 'plain' : 'string' + case 'unquoted_string': { + const field = parent && fieldOf(leaf, parent) + if ((parent?.type === 'env_pair' && field === 'name') || (parent?.type === 'arg_instruction' && field === 'name')) + return nameRole(leaf.text) + if (parent?.type === 'label_pair' && field === 'key') + return 'variable' + return parent?.type === 'user_instruction' ? 'variable' : 'string' + } + default: + return 'plain' + } +} + +function fieldOf(node: Node, parent: Node): string | null { + for (let i = 0; i < parent.childCount; i++) { + if (parent.child(i)?.id === node.id) + return parent.fieldNameForChild(i) + } + return null +} + +function rangeOf(node: Node): SourceRange { + return { startIndex: node.startIndex, endIndex: node.endIndex } +} + +/** 选项值里写的阶段名(`--from=deps`、`--mount=…,from=deps`)在源码里的范围。 */ +function fromRanges(instruction: Node): { name: string, range: SourceRange }[] { + return instructionFlags(instruction).flatMap((flag): { name: string, range: SourceRange }[] => { + if (flag.name === 'from') + return [{ name: flag.value, range: { startIndex: flag.valueStart, endIndex: flag.valueStart + flag.value.length } }] + const match = flag.name === 'mount' ? /(?:^|,)from=([^,]+)/.exec(flag.value) : null + if (!match) + return [] + const start = flag.valueStart + match.index + match[0].indexOf('=') + 1 + return [{ name: match[1], range: { startIndex: start, endIndex: start + match[1].length } }] + }) +} + +/** + * 同一文件里认得出的名字:阶段(定义处是 `AS` 后面的名字,用到的是 `COPY --from=`、`--mount=…,from=`、`FROM 阶段名`), + * 变量(定义处是 ARG、ENV 的名字,用到的是 `$X`、`${X}`)。 + */ +function dockerSymbols(root: Node): LocalSymbol[] { + const symbols = new Map() + const entry = (key: string): LocalSymbol => { + let symbol = symbols.get(key) + if (!symbol) { + symbol = { definitions: [], occurrences: [] } + symbols.set(key, symbol) + } + return symbol + } + const aliases = new Set(root.descendantsOfType('image_alias').flatMap(alias => alias ? [alias.text.toLowerCase()] : [])) + const visit = (node: Node): void => { + if (node.type === 'image_alias') { + const symbol = entry(`stage:${node.text.toLowerCase()}`) + symbol.definitions.push(rangeOf(node)) + symbol.occurrences.push(rangeOf(node)) + } + else if (node.type === 'image_name' && aliases.has(node.text.toLowerCase())) { + entry(`stage:${node.text.toLowerCase()}`).occurrences.push(rangeOf(node)) + } + else if ((node.type === 'env_pair' || node.type === 'arg_instruction') && node.childForFieldName('name')) { + const name = node.childForFieldName('name') as Node + const symbol = entry(`variable:${name.text}`) + symbol.definitions.push(rangeOf(name)) + symbol.occurrences.push(rangeOf(name)) + } + else if (node.type === 'variable') { + const name = NAME.exec(node.text)?.[0] + if (name) + entry(`variable:${name}`).occurrences.push({ startIndex: node.startIndex, endIndex: node.startIndex + name.length }) + } + for (const { name, range } of fromRanges(node)) { + if (aliases.has(name.toLowerCase())) + entry(`stage:${name.toLowerCase()}`).occurrences.push(range) + } + for (const child of children(node)) + visit(child) + } + visit(root) + for (const symbol of symbols.values()) + symbol.occurrences.sort((a, b) => a.startIndex - b.startIndex) + return [...symbols.values()].filter(symbol => symbol.definitions.length > 0) +} + +/** + * Dockerfile 里的中文名:紧挨在 ARG、ENV(只有一个变量的)、`FROM … AS 名字` 上面一行、只写了 `# @Comment 名字` 的注释。 + * Dockerfile 的注释只能单独成行,没有行尾记号的写法。 + */ +const dockerNameReader: DisplayNameReader = { + files: { dockerfile: '**/{Dockerfile,Dockerfile.*,*.dockerfile,Containerfile,Containerfile.*}' }, + mentions: source => source.includes(COMMENT_MARK), + find: (root) => { + const names: DisplayName[] = [] + const markers: SourceRange[] = [] + for (const comment of named(root).filter(item => item.type === 'comment' && markerOnly(item))) { + const next = comment.nextNamedSibling + const tag = commentTag(comment.text) + if (!next || !tag || !adjacent(comment, next)) + continue + const pairs = next.type === 'env_instruction' ? named(next).filter(child => child.type === 'env_pair') : [] + const name = next.type === 'from_instruction' + ? next.childForFieldName('as') + : next.type === 'arg_instruction' ? next.childForFieldName('name') : pairs.length === 1 ? pairs[0].childForFieldName('name') : null + if (!name) + continue + names.push({ name: rangeOf(name), text: tag.text }) + markers.push({ startIndex: comment.startIndex + tag.start, endIndex: comment.endIndex }) + } + return { names, markers } + }, +} + +/** + * Dockerfile(VS Code 的 languageId 是 dockerfile:Dockerfile、Dockerfile.*、*.dockerfile、Containerfile)的语言插件。 + * 语法包是 tree-sitter-dockerfile(npm 包 tree-sitter-wasm 预编译的),RUN 这些 shell 代码交给 shell 的语言插件(见 EmbeddedCode)。 + * @param wasmDir 放 tree-sitter-dockerfile.wasm 的目录 + */ +export function createDockerfileAdapter(wasmDir: string): LanguageAdapter { + return { + id: 'dockerfile', + languageIds: ['dockerfile'], + grammarPath: () => path.join(wasmDir, 'tree-sitter-dockerfile.wasm'), + toBlocks: root => dockerBlocks(root), + tokenRole: dockerTokenRole, + command: dockerCommand, + symbols: root => dockerSymbols(root), + embedded: root => root.descendantsOfType('shell_command').flatMap((node) => { + const code = node && shellCode(node) + return code ? [code] : [] + }), + displayNames: dockerNameReader, + } +} diff --git a/vscode/src/codeReader/translators/dockerfileZh.test.ts b/vscode/src/codeReader/translators/dockerfileZh.test.ts new file mode 100644 index 0000000..b9dcd3a --- /dev/null +++ b/vscode/src/codeReader/translators/dockerfileZh.test.ts @@ -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 { + 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 { + 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 < /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 < { + 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']]) + }) +}) diff --git a/vscode/src/codeReader/translators/dockerfileZh.ts b/vscode/src/codeReader/translators/dockerfileZh.ts new file mode 100644 index 0000000..c5c636e --- /dev/null +++ b/vscode/src/codeReader/translators/dockerfileZh.ts @@ -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 = { + 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: '检查' }, + }, +})