✨ feat(vscode): 代码阅读支持 Shell 脚本(bash / sh / zsh):解释器、选项每个开关一行、变量展开读成中文、分情况表、函数按位置的参数 / 输出 / 返回、常用命令按命令规则读成一句、重定向、条件、遍历、管道;同一文件里的函数和变量能跳转、高亮
This commit is contained in:
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('坏了')])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
Reference in new issue
Block a user