✨ feat(vscode): 代码阅读支持 Shell 脚本(bash / sh / zsh):解释器、选项每个开关一行、变量展开读成中文、分情况表、函数按位置的参数 / 输出 / 返回、常用命令按命令规则读成一句、重定向、条件、遍历、管道;同一文件里的函数和变量能跳转、高亮

This commit is contained in:
cruldra committed 2026-09-28 00:44:53 +08:00
1 parent c577f38bee
commit e96694317d
12 files changed
+2559 -3

No files matched your search

@@ -0,0 +1,255 @@
import type { BlockView } from '../blockView'
import type { Inline, PropertyItem } from '../contract'
import { describe, expect, it } from 'vitest'
import { readBlockViews } from '../blockView'
import { commandRuleLookup } from '../commandRules'
import { shellCommandRules } from '../commands/shellCommands'
import { createBashAdapter } from '../languages/bash'
import { flatSegments, readFixture, runtime, segmentText, WASM_DIR } from '../testSupport'
import { bashZhTranslator } from './bashZh'
const bash = createBashAdapter(WASM_DIR)
function valueText(value: Inline[] | { markdown: string }): string {
return 'markdown' in value ? value.markdown.split('\n')[0] : segmentText(value)
}
/**
* 视图数据写成文字:一行译文的块是「种类 行号: 译文」,属性表块是「种类 行号」下面每行「属性: 值」;
* 表(属性行展开的、译文下面的、格子里套的)每行比上一层多缩进,格子用 | 隔开,格子里的换行写成 ⏎。
*/
function lines(blocks: BlockView[], indent = ''): string[] {
const items = (list: PropertyItem[] | undefined, pad: string): string[] => (list ?? []).flatMap(item => [
pad + item.cells.map(segmentText).join(' | ').replace(/\n/g, ' ⏎ '),
...items(item.items, `${pad} `),
...items(item.table?.items, `${pad} `),
])
return blocks.flatMap((block) => {
const head = 'properties' in block
? [`${indent}${block.kind} ${block.startLine + 1}`, ...block.properties.flatMap(row => [`${indent} ${row.label}: ${valueText(row.value)}`, ...items(row.items, `${indent} `)])]
: [`${indent}${block.kind} ${block.startLine + 1}: ${valueText(block.translation)}`, ...items(block.table?.items, `${indent} `)]
return [...head, ...lines(block.children, `${indent} `)]
})
}
async function read(source: string, reports: string[] = []): Promise<BlockView[]> {
const { blocks } = await readBlockViews(runtime, bash, bashZhTranslator, 'shellscript', source, { commandRule: commandRuleLookup(shellCommandRules, text => reports.push(text)) })
return blocks
}
async function readLines(source: string): Promise<string[]> {
return lines(await read(source))
}
/** luffy 桌面端 fetch-runtime.sh 读出来的样子(和原型 SHELL_BLOCKS 对得上,原型略去的 95-100、116-121 行也在)。 */
const FETCH_READING = [
'interpreter 1',
' 解释器: bash',
' 怎么找: 按 PATH 去找(/usr/bin/env bash)',
'comment 2: 下载执行器自带的 git 与 python, 放到 src-tauri/resources/runtime/<target>/{git,python}。',
'options 14',
' 出错就停: 是 -e',
' 用到没设的变量就报错: 是 -u',
' 管道里任一步失败都算失败: 是 -o pipefail',
'variable 16',
' 名称: target',
' 初始值: 第 1 个参数(没给就是空)',
'variable 17',
' 名称: here',
' 初始值: 执行 〔进入 脚本所在目录/.. 然后 打印当前目录〕 的输出',
'variable 18',
' 名称: dest',
' 初始值: here/src-tauri/resources/runtime/target',
'variable 19',
' 名称: cache',
' 初始值: XDG_CACHE_HOME(没设就用 HOME/.cache)/luffy-desktop-runtime',
'const 21',
' 名称: PY_RELEASE',
' 值: 20260924',
'const 22',
' 名称: PY_VERSION',
' 值: 3.13.15',
'case 24: 按 target 分情况',
' x86_64-pc-windows-msvc | GIT_URL = https://github.com/git-for-windows/git/releases/download/v2.55.0.windows.5/MinGit-2.55.0.5-64-bit.zip ⏎ GIT_SHA256 = 56d7b226b7693196cfc71fef26568f536c4a021ab6c37ff2db4287bed908e96e ⏎ PY_SHA256 = e42fa944748a50e9ff481cbb817ef8a6e3da6fbcf0cf6f29b554e1acb8c7384d',
' aarch64-apple-darwin | GIT_URL = https://github.com/desktop/dugite-native/releases/download/v2.53.0-4/dugite-native-v2.53.0-4098283-macOS-arm64.tar.gz ⏎ GIT_SHA256 = f9dc64635a5b62fbd7ad95db73268bbb8912255ac516d65d37bf7af22fcb8ffe ⏎ PY_SHA256 = 064afb7c2fc0bbf511d886288adf98696af5105e36c138cdf2c199c0146fcf68',
' 其他 | 打印 "用法: 脚本路径 <x86_64-pc-windows-msvc|aarch64-apple-darwin>"(打印到错误输出) ⏎ 退出,退出码 2',
'variable 40',
' 名称: PY_URL',
' 初始值: https://github.com/astral-sh/python-build-standalone/releases/download/PY_RELEASE/cpython-PY_VERSION%2BPY_RELEASE-target-install_only_stripped.tar.gz',
'function 42',
' 名称: sha_ok',
' 输入参数: 2 个,按位置',
' 第 1 个 | $1',
' 第 2 个 | $2',
' 返回: 最后一句成不成功',
' if 43: 成立:文件 第 1 个参数 存在 并且 〔算 第 1 个参数 的 sha256 → 按空格切,取第 1 段〕 等于 第 2 个参数',
'function 46',
' 名称: fetch',
' 说明: 下载 (已缓存且校验通过就不重下), 回显本地文件路径',
' 输入参数: 2 个,按位置',
' 第 1 个 | url',
' 第 2 个 | sha',
' 输出: 打印 out(调用处用 $(fetch …) 拿到)',
' variable 48',
' 名称: url',
' 初始值: 第 1 个参数',
' 名称: sha',
' 初始值: 第 2 个参数',
' 名称: out',
' 初始值: 无',
' variable 49',
' 名称: out',
' 初始值: cache/〔取文件名 url(其中 %2B 全换成 +)〕',
' command 50: 建目录 cache(连同上级目录)',
' if 51: 如果 不 sha_ok out sha',
' command 52: 打印 "下载 url"(打印到错误输出)',
' command 53: 下载 url 到 out.part(失败就报错、跟着跳转走、最多重试 3 次)',
' command 54: 改名 out.part → out',
' if 56: 如果 不 sha_ok out sha',
' command 57: 打印 "sha256 不符: out"(打印到错误输出)',
' command 58: 退出,退出码 1',
' command 60: 打印 out',
'variable 63',
' 名称: git_pkg',
' 初始值: 执行 〔fetch GIT_URL GIT_SHA256〕 的输出',
'variable 64',
' 名称: py_pkg',
' 初始值: 执行 〔fetch PY_URL PY_SHA256〕 的输出',
'command 66: 删除 dest(连同里面的东西、不提示)',
'command 67: 建目录 dest/git(连同上级目录)',
'case 68: 按 git_pkg 分情况',
' 以 .zip 结尾 | 解压 zip git_pkg 到 dest/git(不打印过程)',
' 其他 | 解压 tar.gz git_pkg 到 dest/git',
'comment 72: python-build-standalone 的包里顶层目录就叫 python',
'command 73: 解压 tar.gz py_pkg 到 dest',
'comment 75: ── 去掉 Git Credential Manager ──────────────────────────────────────────────',
'case 78: 按 target 分情况',
' case 79: 情况:以 -apple-darwin 结尾',
' variable 80',
' 名称: gc',
' 初始值: dest/git/libexec/git-core',
' command 81: 删除 gc/git-credential-manager*、gc/*.dll、gc/*.dylib、gc/createdump、gc/NOTICE、gc/uninstall.sh(连同里面的东西、不提示)',
' comment 83: .NET 的各语言资源目录; git 自己的子目录只有 mergetools',
' command 84: 找出 gc 下一层的目录,除了 mergetools,逐个 删除(连同里面的东西、不提示)',
' case 86: 情况:以 -windows-msvc 结尾',
' comment 87: mingw64/bin 里 git 自己依赖的 DLL 都以 lib 开头 (外加 zlib1.dll), 其余 DLL 都属于 GCM;',
' variable 89',
' 名称: mb',
' 初始值: dest/git/mingw64/bin',
' command 90: 删除 mb/git-credential-manager.exe*、mb/libHarfBuzzSharp.dll、mb/libSkiaSharp.dll(不提示)',
' command 91: 找出 mb 这一层的 *.dll,除了 lib* 和 zlib1.dll,逐个 删除',
'comment 95: Windows 版 python 只有 python.exe; 平台脚本按 python3 这个名字再调起它时 (它的目录前置在',
'if 98: 如果 target 以 -windows-msvc 结尾',
' command 99: 复制 dest/python/python.exe 到 dest/python/python3.exe',
'comment 102: ── 去掉符号链接 ─────────────────────────────────────────────────────────────',
'if 105: 如果 target 以 -apple-darwin 结尾',
' variable 106',
' 名称: gc',
' 初始值: dest/git/libexec/git-core',
' comment 107: 内建命令的短横线别名 (git-add -> git …): git 自己调内建命令不走它们。同 git 构建选项',
' loop 109: 遍历 gc/git-* 匹配到的每个文件 f',
' if 110: 如果不是 〔f 是符号链接 并且 〔读出 f 指向的路径〕 等于 git〕,跳过这一个',
' case 111: 按 〔取文件名 f〕 分情况',
' git-upload-pack 或 git-receive-pack 或 git-upload-archive | 什么也不做',
' 其他 | 删除 f',
' comment 116: python 只留 bin/python3 这一个入口 (执行器按它找), 版本号命名的本体挪过来',
' variable 117',
' 名称: pybin',
' 初始值: dest/python/bin',
' variable 118',
' 名称: real',
' 初始值: 执行 〔读出 pybin/python3 指向的路径〕 的输出',
' command 119: 删除 pybin/python3',
' command 120: 改名 pybin/real → pybin/python3',
' command 121: 找出 dest/python 下面所有符号链接,逐个 删除',
' comment 122: 剩下的 (传输程序、git-remote-https 之类) 换成实体文件',
' pipeline 123: 找出 dest 下面所有符号链接 → 逐行读进 link',
' command 124: 复制 link 指向的实体文件 到 link.tmp',
' command 125: 改名 link.tmp → link',
'command 129: 打印 "自带运行时就绪: dest"(打印到错误输出)',
'command 130: 统计占用空间 dest/git、dest/python(按人读的单位、只看总数)(打印到错误输出)',
]
describe('shell 中文直译', () => {
it('fetch-runtime.sh 整份:解释器、选项、变量、分情况、函数(参数表、输出、返回)、命令规则、条件、遍历、管道', async () => {
expect(await readLines(readFixture('fetchRuntime.sh'))).toEqual(FETCH_READING)
})
it('变量展开:长度、去头去尾、截取、没设时、替换;特殊变量', async () => {
expect(await readLines(`echo "\${#x} \${x#p} \${x%%s} \${x:1:2} $@ $? \${x:=d} \${x:+y} \${x/a/b}"\n`)).toEqual([
'command 1: 打印 x 的长度 x(去掉开头的 p) x(去掉结尾最长的 s) x(从第 1 个字符起,取 2 个) 全部参数 上一条命令的退出码 x(没设就设成 d) y(x 设了才有) x(其中第一个 a 换成 b)',
])
expect(await readLines(`echo "\${x:?no}"\n`)).toEqual(['command 1: 打印 x(没设就报错:no)'])
})
it.each([
['[ "$a" -gt 3 ]', 'if 1: 成立:a 大于 3'],
['[[ "$x" != *.sh ]]', 'if 1: 成立:x 不以 .sh 结尾'],
['[ -f x ] && rm x', 'if 1: 如果 文件 x 存在,删除 x'],
])('条件:%s', async (source, expected) => {
expect(await readLines(`${source}\n`)).toEqual([expected])
})
it.each([
['cmd 2>/dev/null', 'cmd(丢掉错误输出)'],
['cmd >> log 2>&1', 'cmd(追加到 log)(错误输出并进输出)'],
['grep -qi error log.txt', '在 log.txt 里找含 error 的行(只看找没找到、不分大小写)'],
['sed -i \'s/a/b/g\' conf', '把 conf 里的 a 全换成 b(直接改文件)'],
['chmod +x run.sh', '给 run.sh 加上可执行权限'],
['ln -sf target link', '建符号链接 link → target(已有就替换)'],
['rm -rf --one-file-system x', '删除 x(连同里面的东西、不提示) --one-file-system'],
['foo -x "$y"', 'foo -x y'],
['mkdir -p d && cd d', '建目录 d(连同上级目录) 然后 进入 d'],
['cmd || exit 1', 'cmd,失败就 退出,退出码 1'],
])('命令:%s(规则不认识的选项照抄,没有规则的命令照抄命令名)', async (source, expected) => {
expect(await readLines(`${source}\n`)).toEqual([`command 1: ${expected}`])
})
it('遍历:写出来的几项、全部参数、只要 / 直到', async () => {
expect(await readLines('for x in a b; do echo "$x"; done\n')).toEqual(['loop 1: 遍历 a、b 中的每个 x', ' command 1: 打印 x'])
expect(await readLines('for a in "$@"; do shift; done\n')).toEqual(['loop 1: 遍历 全部参数 的每个 a', ' command 1: shift'])
expect(await readLines('while [ -n "$x" ]; do sleep 1; done\n')).toEqual(['loop 1: 只要 x 不为空 就一直重复', ' command 1: 等 1 秒'])
expect(await readLines('until false; do break; done\n')).toEqual(['loop 1: 直到 失败 之前一直重复', ' command 1: 跳出循环'])
})
it('变量的值:$(dirname "$0") 是脚本所在目录,整个值是 $(…) 读成「执行〔…〕的输出」,只读的是常量', async () => {
expect(await readLines('x="$(dirname "$0")"\ny=$(pwd)\nreadonly N=3\n')).toEqual([
'variable 1',
' 名称: x',
' 初始值: 脚本所在目录',
'variable 2',
' 名称: y',
' 初始值: 执行 〔打印当前目录〕 的输出',
'const 3',
' 名称: N',
' 值: 3',
])
})
it('着色:字符串里写死的部分是字符串色,展开的变量是变量色,全大写的名字是常量色', async () => {
const [block] = await read(`PY_URL="https://x/$PY_RELEASE/c-\${PY_VERSION}-$target.tgz"\n`)
const value = 'properties' in block ? block.properties.find(row => row.label === '初始值')?.value : undefined
expect(value && !('markdown' in value) && flatSegments(value).map(({ text, role }) => [text, role])).toEqual([
['https://x/', 'string'],
['PY_RELEASE', 'constant'],
['/c-', 'string'],
['PY_VERSION', 'constant'],
['-', 'string'],
['target', 'variable'],
['.tgz', 'string'],
])
expect(block.codeTokens.filter(({ role }) => role !== 'plain').map(({ text, role }) => [text, role])).toContainEqual(['PY_URL', 'constant'])
})
it('命令规则出错时照抄命令并报告,别的命令不受影响', async () => {
const reports: string[] = []
const broken = { ...shellCommandRules.find(rule => rule.commands.includes('rm'))!, read: () => {
throw new Error('坏了')
} }
const { blocks } = await readBlockViews(runtime, bash, bashZhTranslator, 'shellscript', 'rm -rf x\nmkdir d\n', {
commandRule: commandRuleLookup([broken, ...shellCommandRules], text => reports.push(text)),
})
expect(lines(blocks)).toEqual(['command 1: rm -rf x', 'command 2: 建目录 d'])
expect(reports).toEqual([expect.stringContaining('坏了')])
})
})
+588
View File
@@ -0,0 +1,588 @@
import type { Inline, Node, PropertyItem, Segment, TokenRole, Translator } from '../contract'
import type { NodeReader, ReaderContext } from './zhTemplate'
import { createZhTemplateTranslator } from './zhTemplate'
// shell 脚本的中文直译:块头部、值、条件、命令都由这里的读法(ZhTable.readers)读,模板引擎只管拼词、着色、源码下标;
// 命令的意思交给命令规则(SourceDocument.commandRule),这里只管 shell 自己的写法:展开、字符串、条件、通配、管道、分情况。
function seg(text: string, role: TokenRole): Segment {
return { text, role }
}
const word = (text: string): Segment => seg(text, 'keyword')
const hint = (text: string): Segment => seg(text, 'hint')
const plain = (text: string): Segment => seg(text, 'plain')
const literal = (text: string): Segment => seg(text, 'string')
const space = (): Segment => plain(' ')
function named(node: Node): Node[] {
return node.namedChildren.filter((child): child is Node => child !== null && child.type !== 'comment')
}
function joined(parts: Inline[][], separator: Inline[]): Inline[] {
return parts.flatMap((part, i) => i > 0 ? [...separator, ...part] : part)
}
/** 〔…〕括起来:一段读出来的命令、条件嵌在别的话里。 */
function bracketed(parts: Inline[]): Inline[] {
return [plain('〔'), ...parts, plain('〕')]
}
// ─── 展开 ────────────────────────────────────────────────────────────────────
/** `$1`、`$0`、`$@` 这类特殊变量读成什么。 */
const SPECIAL_ZH: Record<string, string> = {
'0': '脚本路径',
'@': '全部参数',
'*': '全部参数',
'#': '参数个数',
'?': '上一条命令的退出码',
'$': '当前进程号',
'!': '最近一个后台命令的进程号',
'-': '当前开关',
}
const POSITIONAL = /^[1-9]$/
/** 展开里的名字:第几个参数、特殊变量读成中文,别的名字照抄(带源码下标)。 */
function variableName(node: Node, context: ReaderContext): Inline[] {
if (POSITIONAL.test(node.text))
return [seg(`第 ${node.text} 个参数`, 'variable')]
const special = SPECIAL_ZH[node.text]
if (special && node.type === 'special_variable_name')
return [seg(special, 'variable')]
return [context.name(node)]
}
/** 展开的值 `${x:-默认}` 里那个值:空的写「空」。 */
function operandText(nodes: Node[], context: ReaderContext): Inline[] {
return nodes.length > 0 ? nodes.flatMap(node => context.say(node)) : []
}
/**
* `${…}`:取值、没设时用默认值(`:-`,第几个参数写「没给」)、没设时设成(`:=`)、没设就报错(`:?`)、设了才有(`:+`)、
* 长度(`${#x}`)、去掉开头 / 结尾(`#` `##` `%` `%%`)、替换(`/` `//`)、截取(`:偏移:长度`)、大小写。认不出的照常翻。
*/
const expansion: NodeReader = (node, context) => {
const parts = node.children.filter((child): child is Node => child !== null)
const nameAt = parts.findIndex(child => child.type === 'variable_name' || child.type === 'special_variable_name')
if (nameAt < 0)
return undefined
const name = variableName(parts[nameAt], context)
const operators = parts.filter((child, at) => node.fieldNameForChild(at) === 'operator')
const prefix = operators.find(operator => operator.startIndex < parts[nameAt].startIndex)
if (prefix?.type === '#')
return [...name, space(), word('的长度')]
const [operator, second] = operators.filter(op => op.startIndex > parts[nameAt].startIndex)
if (!operator)
return name
const operands = parts.filter(child => child.isNamed && child.startIndex >= operator.endIndex && (!second || child.endIndex <= second.startIndex))
const rest = second ? parts.filter(child => child.isNamed && child.startIndex >= second.endIndex) : []
const value = operandText(operands, context)
const given = POSITIONAL.test(parts[nameAt].text) ? '给' : '设'
const note = (...inner: Inline[]): Inline[] => [...name, hint('('), ...inner, hint(')')]
switch (operator.type) {
case ':-':
case '-':
return value.length > 0 ? note(hint(`没${given}就用 `), ...value) : note(hint(`没${given}就是空`))
case ':=':
case '=':
return note(hint(`没${given}就设成 `), ...value)
case ':?':
case '?':
return value.length > 0 ? note(hint(`没${given}就报错:`), ...value) : note(hint(`没${given}就报错`))
case ':+':
case '+':
return [...value, hint('('), ...name, hint(` ${given}了才有)`)]
case '#':
return note(hint('去掉开头的 '), ...value)
case '##':
return note(hint('去掉开头最长的 '), ...value)
case '%':
return note(hint('去掉结尾的 '), ...value)
case '%%':
return note(hint('去掉结尾最长的 '), ...value)
case '/':
return note(hint('其中第一个 '), ...value, hint(' 换成 '), ...operandText(rest, context))
case '//':
return note(hint('其中 '), ...value, hint(' 全换成 '), ...operandText(rest, context))
case ':':
return note(hint('从第 '), ...value, hint(' 个字符起'), ...(rest.length > 0 ? [hint(',取 '), ...operandText(rest, context), hint(' 个')] : []))
case '^^':
return note(hint('全转大写'))
case ',,':
return note(hint('全转小写'))
default:
return undefined
}
}
const simpleExpansion: NodeReader = (node, context) => {
const [name] = named(node)
return name ? variableName(name, context) : undefined
}
/** 整个是赋给变量的值(`x="$(…)"`、`x=$(…)`):读成「执行〔…〕的输出」。 */
function assignedValue(substitution: Node): boolean {
const parent = substitution.parent
if (parent?.type === 'variable_assignment')
return true
return parent?.type === 'string' && named(parent).length === 1 && parent.parent?.type === 'variable_assignment'
}
/**
* `$(…)`:里面读成的话括在〔〕里;整个是赋给变量的值时写「执行〔…〕的输出」。
* 命令规则读出来的是一个东西(`$(dirname "$0")` 是脚本所在目录)时直接写它。
*/
const commandSubstitution: NodeReader = (node, context) => {
const inner = named(node)
if (inner.length === 1) {
const reading = context.command(inner[0])
if (reading?.value)
return reading.text
}
const text = joined(inner.map(statement => context.say(statement)), [plain(';')])
return assignedValue(node) ? [hint('执行'), space(), ...bracketed(text), space(), hint('的输出')] : bracketed(text)
}
/**
* 双引号字符串:去掉引号,写死的部分按字符串着色,展开照常读;空的写「空字符串」。
* 两个展开之间的空格语法树算在后一个展开里(`"$a $b"`),照源码补上。
*/
const string: NodeReader = (node, context) => {
const parts = named(node)
if (parts.length === 0)
return [hint('空字符串')]
const { source } = context.document
let cursor = node.startIndex + 1
return parts.flatMap((part) => {
const gap = source.slice(cursor, part.startIndex) + (part.type === 'string_content' ? '' : /^\s*/.exec(part.text)?.[0] ?? '')
cursor = part.endIndex
const said = part.type === 'string_content' ? [literal(part.text.replace(/\\([$`"\\])/g, '$1'))] : context.say(part)
return gap ? [literal(gap), ...said] : said
})
}
const rawString: NodeReader = (node) => {
const text = node.text.slice(1, -1)
return text ? [literal(text)] : [hint('空字符串')]
}
const concatenation: NodeReader = (node, context) => named(node).flatMap(part => context.say(part))
const wordReader: NodeReader = node => [seg(node.text.replace(/\\(.)/g, '$1'), /^\d+(?:\.\d+)*$/.test(node.text) ? 'number' : 'string')]
// ─── 通配 ────────────────────────────────────────────────────────────────────
const GLOB = /[*?[]/
/**
* 一个写死的通配:`*x` 以 x 结尾、`x*` 以 x 开头、`*x*` 含有 x,别的写「匹配 …」;`*` 在分情况里是「其他」、在条件里是「任意」。
* negate:「不以 … 结尾」这些。
*/
function globText(text: string, all: string, negate = false): Inline[] {
const not = negate ? '不' : ''
if (text === '*')
return [hint(all)]
const inner = text.slice(1, -1)
if (text.startsWith('*') && text.endsWith('*') && inner && !GLOB.test(inner))
return [word(`${negate ? '不含' : '含有'}`), space(), literal(inner)]
const tail = text.slice(1)
if (text.startsWith('*') && !GLOB.test(tail))
return [word(`${not}以`), space(), literal(tail), space(), word('结尾')]
const head = text.slice(0, -1)
if (text.endsWith('*') && !GLOB.test(head))
return [word(`${not}以`), space(), literal(head), space(), word('开头')]
if (GLOB.test(text))
return [word(`${not}匹配`), space(), literal(text)]
return negate ? [word('不是'), space(), literal(text)] : [literal(text)]
}
/** 写死的文字(没有展开):通配按 globText 读。 */
function literalOf(node: Node): string | undefined {
switch (node.type) {
case 'word':
case 'extglob_pattern':
return node.text.replace(/\\(.)/g, '$1')
case 'raw_string':
return node.text.slice(1, -1)
case 'string':
return named(node).every(part => part.type === 'string_content') ? named(node).map(part => part.text).join('') : undefined
case 'concatenation': {
const parts = named(node).map(literalOf)
return parts.every(part => part !== undefined) ? parts.join('') : undefined
}
default:
return undefined
}
}
/** 分情况的一个情况的几个模式:`a | b` 读成「a 或 b」,`*` 是「其他」。 */
function patternsText(item: Node, context: ReaderContext): Inline[] {
const patterns = item.childrenForFieldName('value').filter((pattern): pattern is Node => pattern !== null)
return joined(patterns.map((pattern) => {
const text = literalOf(pattern)
return text === undefined ? context.say(pattern) : globText(text, '其他')
}), [space(), word('或'), space()])
}
// ─── 条件 ────────────────────────────────────────────────────────────────────
/** `[ -f x ]` 这类:[前面的话, 后面的话],x 夹在中间。 */
const UNARY_TESTS: Record<string, [string, string]> = {
'-f': ['文件', '存在'],
'-d': ['目录', '存在'],
'-e': ['', '存在'],
'-a': ['', '存在'],
'-L': ['', '是符号链接'],
'-h': ['', '是符号链接'],
'-z': ['', '为空'],
'-n': ['', '不为空'],
'-r': ['', '可读'],
'-w': ['', '可写'],
'-x': ['', '可执行'],
'-s': ['', '存在且不为空'],
'-p': ['', '是管道'],
'-S': ['', '是套接字'],
'-t': ['文件描述符', '是终端'],
'-v': ['变量', '设了值'],
}
const BINARY_TESTS: Record<string, string> = {
'-eq': '等于',
'-ne': '不等于',
'-lt': '小于',
'-le': '不大于',
'-gt': '大于',
'-ge': '不小于',
'-nt': '比它新',
'-ot': '比它旧',
'=~': '匹配正则',
'<': '排在前面',
'>': '排在后面',
'&&': '并且',
'-a': '并且',
'||': '或者',
'-o': '或者',
}
const unaryExpression: NodeReader = (node, context) => {
const operator = node.childForFieldName('operator')
const [operand] = named(node).filter(child => child.startIndex >= (operator?.endIndex ?? node.startIndex))
if (!operand)
return undefined
if (node.child(0)?.type === '!')
return [word('不'), space(), ...context.say(operand)]
const test = operator && UNARY_TESTS[operator.text]
if (!test)
return undefined
const [before, after] = test
return [...(before ? [plain(before), space()] : []), ...context.say(operand), space(), word(after)]
}
/** 在 `[[ … ]]` 里(`==` 右边是通配)还是 `[ … ]` 里(`=` 是比文字)。 */
function doubleBracket(node: Node): boolean {
for (let parent = node.parent; parent; parent = parent.parent) {
if (parent.type === 'test_command')
return parent.child(0)?.type === '[['
}
return false
}
const binaryExpression: NodeReader = (node, context) => {
const left = node.childForFieldName('left')
const right = node.childForFieldName('right')
const operator = node.childForFieldName('operator')?.text
if (!left || !right || !operator)
return undefined
if (operator === '=' || operator === '==' || operator === '!=') {
const negate = operator === '!='
const text = doubleBracket(node) ? literalOf(right) : undefined
const phrase = text !== undefined && GLOB.test(text)
? globText(text, '任意', negate)
: [word(negate ? '不等于' : '等于'), space(), ...context.say(right)]
return [...context.say(left), space(), ...phrase]
}
const meaning = BINARY_TESTS[operator]
return meaning ? [...context.say(left), space(), word(meaning), space(), ...context.say(right)] : undefined
}
/** `[ … ]`:里面的条件;自己就是一句语句时前面写「成立:」(同一串全是条件的,见 list)。 */
const testCommand: NodeReader = (node, context) => {
const [inner] = named(node)
return inner ? [...(isStatement(node) ? [word('成立:')] : []), ...context.say(inner)] : undefined
}
const negatedCommand: NodeReader = (node, context) => {
const [inner] = named(node)
return inner ? [word('不'), space(), ...context.say(inner)] : undefined
}
const parenthesized: NodeReader = (node, context) => {
const [inner] = named(node)
return inner ? bracketed(context.say(inner)) : undefined
}
// ─── 一串命令、管道 ──────────────────────────────────────────────────────────
function isTest(node: Node): boolean {
return node.type === 'test_command' || (node.type === 'negated_command' && named(node).some(isTest))
}
/** `a && b || c`:几句和它们之间的 && / ||,照源码顺序。 */
function listParts(node: Node): { operands: Node[], operators: string[] } {
if (node.type !== 'list')
return { operands: [node], operators: [] }
const [left, right] = named(node)
const operator = node.children.find(child => child && !child.isNamed && (child.type === '&&' || child.type === '||'))?.type ?? '&&'
const head = left ? listParts(left) : { operands: [], operators: [] }
const tail = right ? listParts(right) : { operands: [], operators: [] }
return { operands: [...head.operands, ...tail.operands], operators: [...head.operators, operator, ...tail.operators] }
}
/** 是条件的一部分:if / elif / while 的条件,或在 `[ … ]` 里。 */
function inCondition(node: Node): boolean {
for (let child = node, parent = node.parent; parent; child = parent, parent = parent.parent) {
if (parent.type === 'test_command' || parent.type === 'command_substitution')
return parent.type === 'test_command'
if (parent.type === 'if_statement' || parent.type === 'while_statement' || parent.type === 'elif_clause') {
const then = parent.children.find(token => token?.type === 'then' || token?.type === 'do_group')
return !!then && child.endIndex <= then.startIndex
}
if (parent.type !== 'list' && parent.type !== 'negated_command')
return false
}
return false
}
/** 自己就是一句语句(不在条件、命令替换、别的一串命令里)。 */
function isStatement(node: Node): boolean {
const parent = node.parent
return !!parent && parent.type !== 'list' && parent.type !== 'command_substitution' && !inCondition(node)
}
/**
* 一串命令:
* - 全是条件:「A 并且 B」,自己就是一句语句时前面写「成立:」(这句的结果就是条件成不成立);
* - 前面全是条件、最后跟一句:「如果〔…〕,<那一句>」,用 || 接的是「如果不是〔…〕,…」(`[ … ] || continue` 跳过这一个);
* - 别的:在条件里用「并且 / 或者」,否则「然后 / ,失败就」。
*/
const list: NodeReader = (node, context) => {
const { operands, operators } = listParts(node)
const conditional = inCondition(node) || operands.every(isTest)
const chain = (count: number, forCondition: boolean): Inline[] => operands.slice(0, count).flatMap((operand, i) => {
const said = context.say(operand)
if (i === 0)
return said
const joiner = operators[i - 1] === '&&' ? (forCondition ? '并且' : '然后') : (forCondition ? '或者' : ',失败就')
return [...(joiner.startsWith(',') ? [] : [space()]), word(joiner), space(), ...said]
})
if (operands.every(isTest))
return [...(isStatement(node) ? [word('成立:')] : []), ...chain(operands.length, true)]
const last = operands[operands.length - 1]
if (!conditional && operands.slice(0, -1).every(isTest) && operands.length > 1) {
const condition = chain(operands.length - 1, true)
const negated = operators[operators.length - 1] === '||'
return [word(negated ? '如果不是' : '如果'), space(), ...(operands.length > 2 ? bracketed(condition) : condition), word(','), ...context.say(last)]
}
return chain(operands.length, conditional)
}
/** 管道:每一段读出来,用「→」连起来;最后一段是循环时只写它的头(逐行读进 x)。 */
const pipeline: NodeReader = (node, context) => joined(named(node).map(part => context.say(part)), [space(), word('→'), space()])
const redirectedStatement: NodeReader = (node, context) => {
const body = node.childForFieldName('body')
return body && body.type !== 'command' ? context.say(body) : undefined
}
// ─── 赋值 ────────────────────────────────────────────────────────────────────
/** 写在表格里、条件后面的一句赋值:「名字 = 值」。 */
const variableAssignment: NodeReader = (node, context) => {
const name = node.childForFieldName('name')
const value = node.childForFieldName('value')
if (!name)
return undefined
return [context.name(name), plain(' = '), ...(value ? context.say(value) : [hint('空')])]
}
const DECLARATIONS_ZH: Record<string, string> = { local: '局部', export: '导出', readonly: '只读', declare: '声明', typeset: '声明' }
const declarationCommand: NodeReader = (node, context) => {
const keyword = node.child(0)?.type ?? ''
const declared = named(node).filter(child => child.type === 'variable_assignment' || child.type === 'variable_name')
return [seg(DECLARATIONS_ZH[keyword] ?? keyword, 'storage'), space(), ...joined(declared.map(item => context.say(item)), [plain(',')])]
}
const variableNameReader: NodeReader = (node, context) => variableName(node, context)
// ─── 复合语句的头部 ──────────────────────────────────────────────────────────
/** if / elif / while 的条件:then / do 之前的各句(去掉分号)。 */
function conditions(node: Node): Node[] {
const stop = node.children.find(child => child?.type === 'then' || child?.type === 'do_group')
return named(node).filter(child => !stop || child.endIndex <= stop.startIndex)
}
function conditionText(node: Node, context: ReaderContext): Inline[] {
return joined(conditions(node).map(condition => context.say(condition)), [plain(';')])
}
const ifStatement: NodeReader = (node, context) => [word('如果'), space(), ...conditionText(node, context)]
const elifClause: NodeReader = (node, context) => [word('否则如果'), space(), ...conditionText(node, context)]
const elseClause: NodeReader = () => [word('否则')]
/** for 的来源里有通配(`"$gc"/git-*`):逐个取匹配到的文件。 */
function hasGlob(node: Node): boolean {
if (node.type === 'word')
return GLOB.test(node.text)
if (node.type === 'string' || node.type === 'raw_string' || node.type === 'simple_expansion' || node.type === 'expansion')
return false
return named(node).some(hasGlob)
}
/**
* for:「遍历 <通配> 匹配到的每个文件 f」「遍历 a、b 中的每个 x」,没写 in 的是「遍历 全部参数 的每个 x」。
*/
const forStatement: NodeReader = (node, context) => {
const variable = node.childForFieldName('variable')
const values = node.childrenForFieldName('value').filter((value): value is Node => value !== null)
if (!variable)
return undefined
const name = context.name(variable)
if (values.length === 0)
return [word('遍历'), space(), hint('全部参数'), space(), word('的每个'), space(), name]
if (values.length === 1 && hasGlob(values[0]))
return [word('遍历'), space(), ...context.say(values[0]), space(), hint('匹配到的每个文件'), space(), name]
const source = joined(values.map(value => context.say(value)), [plain('、')])
return [word('遍历'), space(), ...source, space(), word(values.length > 1 ? '中的每个' : '的每个'), space(), name]
}
/** while:条件是 `read x` 时「逐行读进 x」;until 是「直到 … 之前一直重复」,别的「只要 … 就一直重复」。 */
const whileStatement: NodeReader = (node, context) => {
const [condition] = conditions(node)
if (!condition)
return undefined
const until = node.child(0)?.type === 'until'
if (!until && condition.type === 'command' && condition.childForFieldName('name')?.text === 'read') {
const targets = condition.childrenForFieldName('argument').filter((arg): arg is Node => arg !== null && arg.type === 'word' && !arg.text.startsWith('-'))
return [word('逐行读进'), space(), ...joined(targets.map(target => [context.name(target, 'variable')]), [plain('、')])]
}
const text = conditionText(node, context)
return until ? [word('直到'), space(), ...text, space(), word('之前一直重复')] : [word('只要'), space(), ...text, space(), word('就一直重复')]
}
// ─── 分情况 ──────────────────────────────────────────────────────────────────
function caseItems(node: Node): Node[] {
return named(node).filter(child => child.type === 'case_item')
}
/** 一个情况要做的各句:`)` 后面的。 */
function caseBody(item: Node): Node[] {
const close = item.children.find(child => child?.type === ')')
return named(item).filter(child => !close || child.startIndex >= close.endIndex)
}
/**
* case:「按 x 分情况」;整条语句都在这一块里时(几个情况都短),各个情况排成一张表「情况 | 做什么」,
* 做什么是那几句,一句一行,什么都不做的写「什么也不做」。
*/
const caseStatement: NodeReader = (node, context) => {
const subject = node.childForFieldName('value')
if (!subject)
return undefined
const text = [word('按'), space(), ...context.say(subject), space(), word('分情况')]
if (!context.whole(node))
return text
const items = caseItems(node).map((item): PropertyItem => {
const body = caseBody(item)
return {
cells: [
patternsText(item, context),
body.length > 0 ? joined(body.map(statement => context.say(statement)), [plain('\n')]) : [hint('什么也不做')],
],
}
})
return { text, table: { columns: ['情况', '做什么'], items } }
}
/** 分情况拆成子块时的一个情况:「情况:以 -apple-darwin 结尾」。 */
const caseItem: NodeReader = (node, context) => [word('情况:'), ...patternsText(node, context)]
/** bash 自己的开关(set -e / set -o …)的意思。 */
const SWITCHES_ZH: Record<string, string> = {
errexit: '出错就停',
nounset: '用到没设的变量就报错',
pipefail: '管道里任一步失败都算失败',
xtrace: '打印执行的每条命令',
verbose: '打印读到的每一行',
noglob: '不展开通配',
noexec: '只检查不执行',
allexport: '设的变量都导出',
noclobber: '不覆盖已有文件',
errtrace: '函数里出错也触发 ERR',
functrace: '函数里也触发 DEBUG',
monitor: '打开作业控制',
notify: '后台任务结束马上通知',
hashall: '记住命令的位置',
braceexpand: '展开花括号',
histexpand: '展开历史',
physical: 'cd 走真实路径',
keyword: '赋值写在哪都算',
onecmd: '执行一条就退出',
posix: '按 POSIX 行事',
}
/** `#` 注释去掉井号。 */
function commentText(_node: Node, text: string): string {
return text.replace(/^#+\s?/, '')
}
export const bashZhTranslator: Translator = createZhTemplateTranslator({
id: 'shellscript.zh-template',
label: '中文直译',
languageIds: ['shellscript'],
keywords: {},
literals: {},
builtinTypes: {},
isTypePosition: () => false,
operators: {},
operatorParents: [],
emptyLiterals: {},
mapLiterals: {},
stringNodes: [],
commentText,
nodeTemplates: {},
readers: {
string,
raw_string: rawString,
concatenation,
word: wordReader,
extglob_pattern: wordReader,
simple_expansion: simpleExpansion,
expansion,
command_substitution: commandSubstitution,
variable_name: variableNameReader,
special_variable_name: variableNameReader,
variable_assignment: variableAssignment,
declaration_command: declarationCommand,
test_command: testCommand,
unary_expression: unaryExpression,
binary_expression: binaryExpression,
parenthesized_expression: parenthesized,
negated_command: negatedCommand,
list,
pipeline,
redirected_statement: redirectedStatement,
if_statement: ifStatement,
elif_clause: elifClause,
else_clause: elseClause,
for_statement: forStatement,
while_statement: whileStatement,
case_statement: caseStatement,
case_item: caseItem,
},
switches: SWITCHES_ZH,
})