✨ 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,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<Outline[]> {
|
||||
return withBlocks(adapter, 'dockerfile', source, outline)
|
||||
}
|
||||
|
||||
function facts(source: string): Promise<Record<number, unknown>> {
|
||||
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 <<EOF /f',
|
||||
'hi',
|
||||
'EOF',
|
||||
'COPY g /g',
|
||||
'LABEL a=1',
|
||||
'LABEL b=2',
|
||||
].join('\n')
|
||||
expect(await tree(source)).toEqual([
|
||||
{ at: [1, 2], kind: 'variable' },
|
||||
{ at: 3, kind: 'stage', kids: [
|
||||
{ at: 4, kind: 'variable' },
|
||||
{ at: 6, kind: 'variable' },
|
||||
{ at: [7, 8], kind: 'copy' },
|
||||
{ at: 9, kind: 'copy' },
|
||||
{ at: 10, kind: 'copy' },
|
||||
{ at: 11, kind: 'copy' },
|
||||
{ at: [12, 14], kind: 'copy' },
|
||||
{ at: 15, kind: 'copy' },
|
||||
{ at: [16, 17], kind: 'label' },
|
||||
] },
|
||||
])
|
||||
})
|
||||
|
||||
it('onbuild 是一条设置,包着的指令是它的子块;别的指令(WORKDIR、USER、EXPOSE、VOLUME、STOPSIGNAL、SHELL)各是一行设置', async () => {
|
||||
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:各个片段去掉续行的 \\ 和缩进;只写了 <<EOT 的是 heredoc 的正文', async () => {
|
||||
const source = 'RUN useradd u && \\\n mkdir -p /a\nRUN <<EOT\nset -e\napt-get update\nEOT\n'
|
||||
const parsed = await runtime.parse(adapter, 'dockerfile', source)
|
||||
try {
|
||||
const codes = adapter.embedded?.(parsed.rootNode, source, 'dockerfile') ?? []
|
||||
expect(codes.map(code => ({ 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']))
|
||||
})
|
||||
})
|
||||
@@ -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 }] : []
|
||||
})
|
||||
}
|
||||
|
||||
/** 只写了一个 `<<EOT` 的 shell 片段:脚本是下面的 heredoc。 */
|
||||
function heredocOnly(fragments: Node[]): boolean {
|
||||
return fragments.length === 1 && named(fragments[0]).some(child => child.type === 'heredoc_marker') && /^<<-?["']?\w+["']?\s*$/.test(fragments[0].text)
|
||||
}
|
||||
|
||||
/**
|
||||
* shell 写法的命令交给 shell 读的几段:各个 shell 片段(去掉续行的 `\` 和下一行的缩进);
|
||||
* 只写了 `<<EOT` 的是它下面 heredoc 的正文(从第一行到最后一行,换行照留)。
|
||||
*/
|
||||
function shellCode(command: Node): EmbeddedCode | undefined {
|
||||
const fragments = named(command).filter(child => 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 <<EOF /f`)单独成块。
|
||||
*/
|
||||
function mergeable(previous: Node, next: Node): boolean {
|
||||
const heredoc = (node: Node): boolean => 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<string>, 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<string>()
|
||||
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<string, LocalSymbol>()
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user