From 149cb51cc0b72c3fe3965bab2ba554cd0ad03be4 Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 28 Sep 2026 11:53:54 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20shell=20=E8=A2=AB?= =?UTF-8?q?=E5=BD=93=E6=88=90=E4=B8=80=E4=B8=B2=E6=AD=A5=E9=AA=A4=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=EF=BC=88Dockerfile=20=E7=9A=84=20RUN?= =?UTF-8?q?=EF=BC=89=E5=8F=AA=E7=94=A8=20&&=20=E8=BF=9E=E7=9D=80=E7=9A=84?= =?UTF-8?q?=E4=B8=80=E4=B8=B2=E6=8B=86=E6=88=90=E4=B8=80=E6=9D=A1=E4=B8=80?= =?UTF-8?q?=E6=AD=A5=EF=BC=9Btree-sitter-bash=20=E6=8A=8A=20a=20&&=20b=20>?= =?UTF-8?q?=20f=20=E8=AF=BB=E6=88=90=E6=95=B4=E4=B8=B2=E5=B8=A6=E9=87=8D?= =?UTF-8?q?=E5=AE=9A=E5=90=91=EF=BC=8C=E9=87=8D=E5=AE=9A=E5=90=91=E6=94=B9?= =?UTF-8?q?=E7=AE=97=E5=9C=A8=E6=9C=80=E5=90=8E=E9=82=A3=E6=9D=A1=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/codeReader/languages/bash.test.ts | 27 ++++++++ vscode/src/codeReader/languages/bash.ts | 65 +++++++++++++++++++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/vscode/src/codeReader/languages/bash.test.ts b/vscode/src/codeReader/languages/bash.test.ts index a63de77..9a3935b 100644 --- a/vscode/src/codeReader/languages/bash.test.ts +++ b/vscode/src/codeReader/languages/bash.test.ts @@ -138,6 +138,33 @@ describe('shell 属性表信息(facts)', () => { }) }) +describe('shell 被别的语言嵌着当步骤执行(Dockerfile 的 RUN)', () => { + const steps = async (source: string): Promise<{ blocks: Outline[], chained: boolean, headers: string[] } | undefined> => { + const parsed = await runtime.parse(adapter, 'shellscript', source) + try { + const found = adapter.steps?.(parsed.rootNode, source, 'shellscript') + return found && { blocks: outline(found.blocks), chained: found.chained, headers: found.blocks.map(block => source.slice(block.header.startIndex, block.header.endIndex)) } + } + finally { + parsed.delete() + } + } + + it('只用 && 连着的一串拆成一条一步(一步失败就停);整串带着的重定向只算在最后一条上,它的头部延到重定向结尾', async () => { + expect(await steps('apt-get update && echo hi > /f && rm -rf /x')).toEqual({ + blocks: [{ at: 1, kind: 'command' }, { at: 1, kind: 'command' }, { at: 1, kind: 'command' }], + chained: true, + headers: ['apt-get update', 'echo hi > /f', 'rm -rf /x'], + }) + }) + + it('条件串、有 || 的串、几句分开写的照 toBlocks 一句一步,不算一步失败就停', async () => { + expect(await steps('[ -f x ] && rm x')).toMatchObject({ blocks: [{ at: 1, kind: 'if' }], chained: false }) + expect(await steps('a && b || c')).toMatchObject({ blocks: [{ at: 1, kind: 'command' }], chained: false }) + expect(await steps('a\nb')).toMatchObject({ blocks: [{ at: 1, kind: 'command' }, { at: 2, kind: 'command' }], chained: false }) + }) +}) + describe('shell 命令、名字', () => { const commands = (source: string): Promise => nodeFacts(adapter, 'shellscript', source, node => adapter.command?.(node)) diff --git a/vscode/src/codeReader/languages/bash.ts b/vscode/src/codeReader/languages/bash.ts index 5757a3f..d7f2ab6 100644 --- a/vscode/src/codeReader/languages/bash.ts +++ b/vscode/src/codeReader/languages/bash.ts @@ -1,4 +1,4 @@ -import type { BlockShape, CodeBlock, CommandFacts, CommandWord, DeclaratorFacts, FunctionFacts, InterpreterFacts, LanguageAdapter, LocalSymbol, Node, OptionsFacts, PositionalParameter, Redirect, SourceRange, SwitchFacts, TextKind, TokenRole } from '../contract' +import type { BlockShape, CodeBlock, CommandFacts, CommandWord, DeclaratorFacts, FunctionFacts, InterpreterFacts, LanguageAdapter, LocalSymbol, Node, OptionsFacts, PositionalParameter, Redirect, SourceRange, StepBlocks, SwitchFacts, TextKind, TokenRole } from '../contract' import type { CommentRules, ListBlocks } from './shared' import * as path from 'node:path' import { bashNameReader } from './bashNames' @@ -461,7 +461,27 @@ function bashCommand(node: Node): CommandFacts | undefined { if (!command || !name) return undefined const args = command.childrenForFieldName('argument').filter(arg => arg !== null) - return { name: commandWord(name), arguments: args.map(commandWord), redirects: redirectsOf(node) } + const inherited = node.type === 'command' ? redirectedChain(node) : undefined + return { name: commandWord(name), arguments: args.map(commandWord), redirects: [...redirectsOf(node), ...(inherited ? redirectsOf(inherited) : [])] } +} + +/** 一串 `a && b` 里的最后一句。 */ +function lastOperand(list: Node): Node | undefined { + return named(list).filter(child => child.type !== 'comment').at(-1) +} + +/** + * tree-sitter-bash 把 `a && b > f` 读成整串带着重定向(redirected_statement 的 body 是 `a && b`),其实重定向只属于最后那条命令 b: + * command 是这样一串的最后一条时给出那个 redirected_statement,它的重定向算在 command 上。 + */ +function redirectedChain(command: Node): Node | undefined { + let child = command + let parent = command.parent + while (parent?.type === 'list' && lastOperand(parent)?.id === child.id) { + child = parent + parent = parent.parent + } + return child.id !== command.id && parent?.type === 'redirected_statement' && parent.childForFieldName('body')?.id === child.id ? parent : undefined } // ─── 名字 ──────────────────────────────────────────────────────────────────── @@ -569,6 +589,46 @@ function bashTokenRole(leaf: Node): TokenRole { return DECLARATION_KEYWORDS.has(leaf.type) ? 'storage' : 'plain' } +/** 一串 `a && b` 本身(整串带着重定向的,见 redirectedChain,是它 body 里的那串)。 */ +function chainList(node: Node): Node | undefined { + const body = node.type === 'redirected_statement' ? node.childForFieldName('body') : node + return body?.type === 'list' ? body : undefined +} + +/** 一串里的各条命令,照源码顺序;整串带着重定向的往里拆(重定向算在最后一条上)。 */ +function chainOperands(node: Node): Node[] { + const list = chainList(node) + return list ? named(list).filter(child => child.type !== 'comment').flatMap(chainOperands) : [node] +} + +/** `a && b && c`:只用 && 连着的一串(都不是条件)。 */ +function andChain(node: Node): boolean { + const operators = (list: Node): string[] => children(list).flatMap((child) => { + const inner = chainList(child) + return inner ? operators(inner) : child.isNamed ? [] : [child.type] + }) + const list = chainList(node) + return !!list && operators(list).every(operator => operator === '&&') && !chainOperands(node).some(isTest) +} + +/** 一步的块:整串带着的重定向算在最后一条上,它的头部延到重定向结尾。 */ +function stepBlocks(operand: Node): CodeBlock[] { + const redirected = operand.type === 'command' ? redirectedChain(operand) : undefined + return statementBlocks(operand, true).map(block => redirected ? { ...block, header: { ...block.header, endIndex: redirected.endIndex, endRow: redirected.endPosition.row } } : block) +} + +/** + * 被别的语言当一串步骤执行的 shell(Dockerfile 的 RUN):只有一句、是只用 && 连着的一串命令时拆开,一条命令一步(一步失败就停); + * 别的照 toBlocks 一句一步。 + */ +function bashSteps(root: Node): StepBlocks { + const items = named(root) + const [only, ...more] = items.filter(item => item.type !== 'comment') + if (only && more.length === 0 && andChain(only)) + return { blocks: chainOperands(only).flatMap(stepBlocks), chained: true } + return { blocks: statements(items, -1, true), chained: false } +} + /** bash / sh / zsh 脚本(VS Code 的 languageId 是 shellscript)的语言插件。@param wasmDir 语法包 .wasm 所在目录 */ export function createBashAdapter(wasmDir: string): LanguageAdapter { return { @@ -576,6 +636,7 @@ export function createBashAdapter(wasmDir: string): LanguageAdapter { languageIds: ['shellscript'], grammarPath: () => path.join(wasmDir, 'tree-sitter-bash.wasm'), toBlocks: root => programBlocks(root), + steps: root => bashSteps(root), tokenRole: bashTokenRole, command: bashCommand, symbols: root => bashSymbols(root),