✨ 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

+1
View File
@@ -30,6 +30,7 @@ const TREE_SITTER_WASM = [
'tree-sitter-tsx.wasm',
'tree-sitter-javascript.wasm',
'tree-sitter-python.wasm',
'tree-sitter-bash.wasm',
]
function copyTreeSitterWasm() {
+2 -2
View File
@@ -320,7 +320,7 @@
"editor/title": [
{
"command": "superpowers.codeReader.showPreview",
"when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python)$/ || resourceLangId in superpowers.codeReader.languageIds)",
"when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python|shellscript)$/ || resourceLangId in superpowers.codeReader.languageIds)",
"group": "navigation"
},
{
@@ -340,7 +340,7 @@
},
{
"command": "superpowers.codeReader.showPreview",
"when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python)$/ || resourceLangId in superpowers.codeReader.languageIds)"
"when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python|shellscript)$/ || resourceLangId in superpowers.codeReader.languageIds)"
},
{
"command": "superpowers.codeReader.showSource",
+167
View File
@@ -0,0 +1,167 @@
import type { CommandContext, CommandFacts, CommandReading, CommandRule, CommandWord, Inline, Segment } from '../contract'
/** 内置命令规则管的语言:VS Code 里 bash / sh / zsh 都是 shellscript。 */
const SHELL_LANGUAGES = ['shellscript']
/** 动作(删除、下载……),按函数着色。 */
export function verb(text: string): Segment {
return { text, role: 'function' }
}
/** 连接词(到、→、,逐个),按关键字着色。 */
export function word(text: string): Segment {
return { text, role: 'keyword' }
}
/** 说明(选项的意思、范围),淡色。 */
export function hint(text: string): Segment {
return { text, role: 'hint' }
}
export function plain(text: string): Segment {
return { text, role: 'plain' }
}
export function literalText(text: string): Segment {
return { text, role: 'string' }
}
export function space(): Segment {
return plain(' ')
}
export function joined(parts: Inline[][], separator: Inline): Inline[] {
return parts.flatMap((part, i) => i > 0 ? [separator, ...part] : part)
}
/** 选项的意思写在括号里,几项用顿号隔开;一项都没有时什么都不写。 */
export function notes(items: Inline[][]): Inline[] {
return items.length > 0 ? [hint('('), ...joined(items, hint('、')), hint(')')] : []
}
/** 选项的值:写死的照抄(字符串色),带展开的照常翻。 */
export interface OptionValue {
word?: CommandWord
text: string
}
export interface Options {
flags: Set<string>
values: Map<string, OptionValue>
/** 不是选项的词(要处理的东西),照源码顺序。 */
operands: CommandWord[]
/** 规则不认识的选项:照抄在译文后面。 */
unknown: CommandWord[]
}
/**
* 规则认哪些选项:单字母开关、带值的单字母选项,长选项换成的 key(单字母或长名字本身),带值的长选项(换过以后的 key)。
* bare:第一个词可以不带 -(`tar xzf f`)。
*/
export interface OptionSpec {
flags?: string
valued?: string
long?: Record<string, string>
longValued?: string[]
bare?: boolean
}
/** 按 spec 认选项:-rf 这种连写的拆开,-o 值 / -o值 / --retry 3 / --retry=3 都认,-- 之后都不是选项。 */
export function parseOptions(args: readonly CommandWord[], spec: OptionSpec): Options {
const options: Options = { flags: new Set(), values: new Map(), operands: [], unknown: [] }
const flags = spec.flags ?? ''
const valued = spec.valued ?? ''
let ended = false
for (let i = 0; i < args.length; i++) {
const arg = args[i]
const text = arg.literal
const next = (): OptionValue | undefined => {
const value = args[++i]
return value && { word: value, text: value.literal ?? value.node.text }
}
if (ended || text === undefined || text === '-' || !(text.startsWith('-') || (spec.bare && i === 0 && /^[a-z]+$/i.test(text)))) {
options.operands.push(arg)
continue
}
if (text === '--') {
ended = true
continue
}
if (text.startsWith('--')) {
const [name, inline] = text.slice(2).split('=', 2)
const key = spec.long?.[name]
if (key === undefined) {
options.unknown.push(arg)
continue
}
if (spec.longValued?.includes(key) || valued.includes(key)) {
const value = inline !== undefined ? { text: inline } : next()
if (value)
options.values.set(key, value)
}
else {
options.flags.add(key)
}
continue
}
const cluster = text.startsWith('-') ? text.slice(1) : text
if (/^\d+$/.test(cluster)) {
options.operands.push(arg)
continue
}
// 带值的字母后面剩下的都是它的值(-d' '、-f1),只检查它之前的字母认不认识
const valueAt = [...cluster].findIndex(letter => valued.includes(letter))
if ([...(valueAt < 0 ? cluster : cluster.slice(0, valueAt))].some(letter => !flags.includes(letter))) {
options.unknown.push(arg)
continue
}
for (let at = 0; at < cluster.length; at++) {
const letter = cluster[at]
if (!valued.includes(letter)) {
options.flags.add(letter)
continue
}
const rest = cluster.slice(at + 1)
const value = rest ? { text: rest } : next()
if (value)
options.values.set(letter, value)
break
}
}
return options
}
/** 选项的值读成译文。 */
export function valueText(value: OptionValue, context: CommandContext): Inline[] {
return value.word ? context.translate(value.word.node) : [literalText(value.text)]
}
/** 几个要处理的东西,顿号隔开。 */
export function operandsText(operands: readonly CommandWord[], context: CommandContext): Inline[] {
return joined(operands.map(operand => context.translate(operand.node)), plain('、'))
}
/** 规则不认识的选项照抄在后面。 */
export function withUnknown(text: Inline[], options: Options): Inline[] {
return options.unknown.length > 0 ? [...text, ...options.unknown.flatMap(arg => [space(), plain(arg.node.text)])] : text
}
/** 一条内置命令规则:只有中文词表,别的译文语言不读(照抄源码)。 */
export function shellCommand(
id: string,
commands: string[],
read: (command: CommandFacts, context: CommandContext) => CommandReading | Inline[] | undefined,
): CommandRule {
return {
id: `shell.${id}`,
label: commands.join(' / '),
languageIds: SHELL_LANGUAGES,
commands,
read: (command, context) => {
if (context.displayLanguage !== 'zh')
return undefined
const reading = read(command, context)
return Array.isArray(reading) ? { text: reading } : reading
},
}
}
@@ -0,0 +1,584 @@
import type { CommandContext, CommandRule, CommandWord, Inline } from '../contract'
import type { Options } from './reading'
import { hint, joined, literalText, notes, operandsText, parseOptions, plain, shellCommand, space, valueText, verb, withUnknown, word } from './reading'
/** 「动作 东西(选项的意思)」这一种最常见的读法。 */
function action(name: string, options: Options, context: CommandContext, meanings: Inline[][]): Inline[] {
const target = options.operands.length > 0 ? [space(), ...operandsText(options.operands, context)] : []
return withUnknown([verb(name), ...target, ...notes(meanings)], options)
}
/** 按 order 的顺序写出开了的开关的意思。 */
function meanings(options: Options, order: [string, string][]): Inline[][] {
return order.filter(([flag]) => options.flags.has(flag)).map(([, meaning]) => [hint(meaning)])
}
/** 「A 到 B」:几个来源、一个去处。 */
function toTarget(sources: readonly CommandWord[], target: CommandWord | undefined, context: CommandContext): Inline[] {
const from = operandsText(sources, context)
return target ? [...from, space(), word('到'), space(), ...context.translate(target.node)] : from
}
const rm = shellCommand('rm', ['rm'], (command, context) => {
const options = parseOptions(command.arguments, { flags: 'rRfivd', long: { recursive: 'r', force: 'f', interactive: 'i', verbose: 'v', dir: 'd' } })
return action('删除', options, context, meanings(options, [['r', '连同里面的东西'], ['R', '连同里面的东西'], ['d', '也删空目录'], ['f', '不提示'], ['i', '逐个确认'], ['v', '列出删了什么']]))
})
const mkdir = shellCommand('mkdir', ['mkdir'], (command, context) => {
const options = parseOptions(command.arguments, { flags: 'pv', valued: 'm', long: { parents: 'p', verbose: 'v', mode: 'm' } })
const mode = options.values.get('m')
return action('建目录', options, context, [
...meanings(options, [['p', '连同上级目录']]),
...(mode ? [[hint('权限 '), ...valueText(mode, context)]] : []),
...meanings(options, [['v', '列出建了什么']]),
])
})
const curl = shellCommand('curl', ['curl'], (command, context) => {
const options = parseOptions(command.arguments, {
flags: 'fLsSOkIvi',
valued: 'oXHdumA',
long: { 'fail': 'f', 'location': 'L', 'silent': 's', 'show-error': 'S', 'remote-name': 'O', 'insecure': 'k', 'head': 'I', 'verbose': 'v', 'output': 'o', 'request': 'X', 'header': 'H', 'data': 'd', 'user': 'u', 'max-time': 'm', 'user-agent': 'A', 'retry': 'retry' },
longValued: ['retry'],
})
const output = options.values.get('o')
const urls = operandsText(options.operands, context)
const head: Inline[] = output
? [verb('下载'), space(), ...urls, space(), word('到'), space(), ...valueText(output, context)]
: [verb(options.flags.has('O') ? '下载' : '请求'), space(), ...urls]
const valued = (key: string, before: string, after = ''): Inline[][] => {
const value = options.values.get(key)
return value ? [[hint(before), ...valueText(value, context), ...(after ? [hint(after)] : [])]] : []
}
return withUnknown([...head, ...notes([
...meanings(options, [['f', '失败就报错'], ['L', '跟着跳转走']]),
...valued('retry', '最多重试 ', ' 次'),
...meanings(options, [['O', '存成网址里的文件名'], ['s', '不显示进度'], ['S', '出错时显示错误'], ['k', '不校验证书'], ['I', '只取响应头'], ['v', '打印通信过程'], ['i', '连响应头一起打印']]),
...valued('X', '用 ', ' 方法'),
...valued('H', '带请求头 '),
...valued('d', '发送 '),
...valued('u', '用户 '),
...valued('A', '标成 '),
...valued('m', '最多等 ', ' 秒'),
])], options)
})
/** tar 的压缩格式:z → tar.gz,j → tar.bz2,J → tar.xz,a 按文件名定。 */
function tarFormat(options: Options): string {
if (options.flags.has('z'))
return 'tar.gz'
if (options.flags.has('j'))
return 'tar.bz2'
return options.flags.has('J') ? 'tar.xz' : 'tar'
}
const tar = shellCommand('tar', ['tar'], (command, context) => {
const options = parseOptions(command.arguments, {
flags: 'xctzjJvap',
valued: 'fC',
long: { 'extract': 'x', 'create': 'c', 'list': 't', 'gzip': 'z', 'bzip2': 'j', 'xz': 'J', 'verbose': 'v', 'file': 'f', 'directory': 'C', 'auto-compress': 'a' },
bare: true,
})
const file = options.values.get('f')
const dir = options.values.get('C')
const archive = [literalText(tarFormat(options)), ...(file ? [space(), ...valueText(file, context)] : [])]
const into = dir ? [space(), word('到'), space(), ...valueText(dir, context)] : []
const extra = notes(meanings(options, [['v', '列出每个文件'], ['p', '保留权限']]))
if (options.flags.has('x'))
return withUnknown([verb('解压'), space(), ...archive, ...into, ...operandsTail(options, context, '只要 '), ...extra], options)
if (options.flags.has('c'))
return withUnknown([verb('打包'), space(), ...operandsText(options.operands, context), space(), word('成'), space(), ...archive, ...extra], options)
if (options.flags.has('t'))
return withUnknown([verb('列出'), space(), ...archive, space(), word('里的文件'), ...extra], options)
return undefined
})
/** 解压时写在后面的几个成员:只解出它们。 */
function operandsTail(options: Options, context: CommandContext, lead: string): Inline[] {
return options.operands.length > 0 ? [hint(`(${lead}`), ...operandsText(options.operands, context), hint(')')] : []
}
const unzip = shellCommand('unzip', ['unzip'], (command, context) => {
const options = parseOptions(command.arguments, { flags: 'qonl', valued: 'd' })
const [archive, ...members] = options.operands
if (!archive)
return undefined
const dir = options.values.get('d')
const zip = [literalText('zip'), space(), ...context.translate(archive.node)]
if (options.flags.has('l'))
return withUnknown([verb('列出'), space(), ...zip, space(), word('里的文件')], options)
return withUnknown([
verb('解压'),
space(),
...zip,
...(dir ? [space(), word('到'), space(), ...valueText(dir, context)] : []),
...(members.length > 0 ? [hint('(只要 '), ...operandsText(members, context), hint(')')] : []),
...notes(meanings(options, [['q', '不打印过程'], ['o', '覆盖已有的不问'], ['n', '已有的不覆盖']])),
], options)
})
const FIND_TYPES: Record<string, string> = { f: '文件', d: '目录', l: '符号链接' }
/**
* find 路径 [条件…] [动作]:找出 路径 下一层的 / 这一层的 / 下面所有 东西,名字按 -name 写,`! -name x` 写「除了 x」,
* -delete / -exec 命令 {} + 写「,逐个 <命令>」。认不出的条件照抄。
*/
const find = shellCommand('find', ['find'], (command, context) => {
const args = command.arguments
const isPredicate = (arg: CommandWord): boolean => arg.literal !== undefined && /^[-!(]/.test(arg.literal)
const paths: CommandWord[] = []
let i = 0
while (i < args.length && !isPredicate(args[i])) {
paths.push(args[i])
i++
}
let minDepth: string | undefined
let maxDepth: string | undefined
let type: string | undefined
const names: Inline[][] = []
const excluded: Inline[][] = []
const unknown: CommandWord[] = []
let act: Inline[] | undefined
let negate = false
for (; i < args.length; i++) {
const text = args[i].literal
const value = args[i + 1]
switch (text) {
case '!':
case '-not':
negate = true
continue
case '-mindepth':
minDepth = value?.literal
i++
break
case '-maxdepth':
maxDepth = value?.literal
i++
break
case '-type':
type = value?.literal
i++
break
case '-name':
case '-iname':
if (value)
(negate ? excluded : names).push(context.translate(value.node))
i++
break
case '-delete':
act = [verb('删除')]
break
case '-exec':
case '-execdir': {
const end = args.findIndex((arg, at) => at > i && (arg.literal === '+' || arg.literal === ';'))
const words = args.slice(i + 1, end < 0 ? args.length : end).filter(arg => arg.literal !== '{}')
const [name, ...rest] = words
const reading = name ? context.command(name, rest) : undefined
act = reading ? reading.text : words.flatMap((arg, at) => [...(at > 0 ? [space()] : []), plain(arg.node.text)])
i = end < 0 ? args.length : end
break
}
case '-print':
break
default:
unknown.push(args[i])
}
negate = false
}
const scope = minDepth === '1' && maxDepth === '1' ? '下一层的' : maxDepth === '1' ? '这一层的' : maxDepth ? `往下 ${maxDepth} 层以内的` : '下面所有'
const what: Inline[] = names.length > 0 ? joined(names, word('或')) : [hint(type ? FIND_TYPES[type] ?? type : '东西')]
const typed = names.length > 0 && type ? [hint(FIND_TYPES[type] ?? type), space()] : []
const found: Inline[] = [
verb('找出'),
space(),
...operandsText(paths, context),
space(),
hint(scope),
...(names.length > 0 ? [space()] : []),
...typed,
...what,
...(excluded.length > 0 ? [word(',除了'), space(), ...excluded.flatMap((part, at) => at > 0 ? [space(), word('和'), space(), ...part] : part)] : []),
...(act ? [word(',逐个'), space(), ...act] : []),
]
return unknown.length > 0 ? [...found, ...unknown.flatMap(arg => [space(), plain(arg.node.text)])] : found
})
const cp = shellCommand('cp', ['cp'], (command, context) => {
const options = parseOptions(command.arguments, { flags: 'rRafpLPvni', long: { 'recursive': 'r', 'archive': 'a', 'force': 'f', 'dereference': 'L', 'no-dereference': 'P', 'verbose': 'v', 'no-clobber': 'n', 'interactive': 'i' } })
const sources = options.operands.slice(0, -1)
const target = options.operands.at(-1)
if (!target || sources.length === 0)
return undefined
const from = options.flags.has('L')
? joined(sources.map(source => [...context.translate(source.node), space(), word('指向的实体文件')]), plain('、'))
: operandsText(sources, context)
return withUnknown([verb('复制'), space(), ...from, space(), word('到'), space(), ...context.translate(target.node), ...notes(meanings(options, [
['r', '连同里面的东西'],
['R', '连同里面的东西'],
['a', '原样保留,连同里面的东西'],
['p', '保留权限和时间'],
['f', '不提示'],
['n', '已有的不覆盖'],
['i', '逐个确认'],
['v', '列出复制了什么'],
]))], options)
})
const mv = shellCommand('mv', ['mv'], (command, context) => {
const options = parseOptions(command.arguments, { flags: 'finv', long: { 'force': 'f', 'interactive': 'i', 'no-clobber': 'n', 'verbose': 'v' } })
const sources = options.operands.slice(0, -1)
const target = options.operands.at(-1)
if (!target || sources.length === 0)
return undefined
const moved = sources.length === 1
? [verb('改名'), space(), ...context.translate(sources[0].node), space(), word('→'), space(), ...context.translate(target.node)]
: [verb('移动'), space(), ...toTarget(sources, target, context)]
return withUnknown([...moved, ...notes(meanings(options, [['f', '不提示'], ['n', '已有的不覆盖'], ['i', '逐个确认'], ['v', '列出移动了什么']]))], options)
})
/** 要打印的东西:有写死的文字时连引号照抄(打印 "下载 url"),只是一个变量时不带引号(打印 out)。 */
function printed(operands: readonly CommandWord[], context: CommandContext): Inline[] {
const parts = joined(operands.map(operand => context.translate(operand.node)), literalText(' '))
const quoted = operands.some(operand => operand.literal !== undefined || (operand.node.type === 'string' && operand.node.namedChildren.some(child => child?.type === 'string_content')))
return quoted ? [literalText('"'), ...parts, literalText('"')] : parts
}
const echo = shellCommand('echo', ['echo'], (command, context) => {
const args = [...command.arguments]
const flags = new Set<string>()
while (args[0]?.literal && /^-[neE]+$/.test(args[0].literal)) {
for (const flag of args[0].literal.slice(1))
flags.add(flag)
args.shift()
}
return [verb('打印'), ...(args.length > 0 ? [space(), ...printed(args, context)] : [hint('空行')]), ...notes([
...(flags.has('n') ? [[hint('不换行')]] : []),
...(flags.has('e') ? [[hint('认转义')]] : []),
])]
})
const printf = shellCommand('printf', ['printf'], (command, context) => {
const [format, ...args] = command.arguments
if (!format || format.literal?.startsWith('-'))
return undefined
return [verb('按格式打印'), space(), ...printed([format], context), ...(args.length > 0 ? [word(',填入'), space(), ...operandsText(args, context)] : [])]
})
const exit = shellCommand('exit', ['exit'], (command, context) => {
const [code] = command.arguments
return [verb('退出'), ...(code ? [hint(',退出码 '), ...context.translate(code.node)] : [])]
})
const returns = shellCommand('return', ['return'], (command, context) => {
const [code] = command.arguments
return [verb('返回'), ...(code ? [space(), ...context.translate(code.node)] : [])]
})
const cd = shellCommand('cd', ['cd'], (command, context) => {
const options = parseOptions(command.arguments, { flags: 'LP' })
const [target] = options.operands
if (!target)
return [verb('进入主目录')]
if (target.literal === '-')
return [verb('回到上一个目录')]
return withUnknown([verb('进入'), space(), ...context.translate(target.node)], options)
})
const pwd = shellCommand('pwd', ['pwd'], () => [verb('打印当前目录')])
const basename = shellCommand('basename', ['basename'], (command, context) => {
const options = parseOptions(command.arguments, { flags: 'a', valued: 's', long: { suffix: 's', multiple: 'a' } })
const [path, suffix] = options.operands
if (!path)
return undefined
const cut = options.values.get('s') ?? (suffix && !options.flags.has('a') ? { word: suffix, text: suffix.literal ?? suffix.node.text } : undefined)
const paths = options.flags.has('a') ? options.operands : [path]
return withUnknown([verb('取文件名'), space(), ...operandsText(paths, context), ...(cut ? [hint('(去掉 '), ...valueText(cut, context), hint(')')] : [])], options)
})
/** `$0`、`"$0"`:脚本自己的路径。 */
function scriptPath(arg: CommandWord): boolean {
return arg.node.text === '$0' || arg.node.text === '"$0"'
}
const dirname = shellCommand('dirname', ['dirname'], (command, context) => {
const options = parseOptions(command.arguments, {})
const [path] = options.operands
if (!path)
return undefined
if (options.operands.length === 1 && scriptPath(path))
return { text: [plain('脚本所在目录')], value: true }
return withUnknown([verb('取所在目录'), space(), ...operandsText(options.operands, context)], options)
})
const readlink = shellCommand('readlink', ['readlink'], (command, context) => {
const options = parseOptions(command.arguments, { flags: 'femnq', long: { 'canonicalize': 'f', 'canonicalize-existing': 'e', 'canonicalize-missing': 'm', 'no-newline': 'n', 'quiet': 'q' } })
const [path] = options.operands
if (!path)
return undefined
if (['f', 'e', 'm'].some(flag => options.flags.has(flag)))
return withUnknown([verb('取'), space(), ...context.translate(path.node), space(), word('的真实路径')], options)
return withUnknown([verb('读出'), space(), ...context.translate(path.node), space(), word('指向的路径')], options)
})
/** shasum -a 256、sha256sum 这类:算 x 的 sha256;-c 是按清单校验。 */
function digest(algorithm: (options: Options) => string) {
return (command: { arguments: CommandWord[] }, context: CommandContext): Inline[] | undefined => {
const options = parseOptions(command.arguments, { flags: 'cbt', valued: 'a', long: { check: 'c', algorithm: 'a', binary: 'b', text: 't' } })
const name = algorithm(options)
if (options.flags.has('c'))
return withUnknown([verb('按清单校验'), space(), ...operandsText(options.operands, context), hint(`(${name})`)], options)
return withUnknown([verb('算'), space(), ...operandsText(options.operands, context), space(), word(`的 ${name}`)], options)
}
}
const shasum = shellCommand('shasum', ['shasum'], digest(options => `sha${options.values.get('a')?.text ?? '1'}`))
const sums = [
shellCommand('sha256sum', ['sha256sum'], digest(() => 'sha256')),
shellCommand('sha1sum', ['sha1sum'], digest(() => 'sha1')),
shellCommand('sha512sum', ['sha512sum'], digest(() => 'sha512')),
shellCommand('md5sum', ['md5sum'], digest(() => 'md5')),
]
/** cut -d 的分隔符叫什么:空格、制表符,别的照抄。 */
function delimiterName(text: string): Inline {
if (text === ' ')
return hint('空格')
return text === '\t' ? hint('制表符') : literalText(text)
}
/** cut -f / -c 的范围:1 → 第 1,1,3 → 第 1、3,2- → 第 2 往后,1-3 → 第 1 到 3。 */
function rangeText(text: string, unit: string): string {
const parts = text.split(',').map((part) => {
const [from, to] = part.split('-')
if (!part.includes('-'))
return from
if (!to)
return `${from} ${unit}往后`
return from ? `${from} 到 ${to}` : `开头到 ${to}`
})
const last = parts.at(-1) ?? ''
return `第 ${parts.join('、')}${last.endsWith('往后') ? '' : ` ${unit}`}`
}
const cut = shellCommand('cut', ['cut'], (command, context) => {
const options = parseOptions(command.arguments, { flags: 's', valued: 'dfcb', long: { 'delimiter': 'd', 'fields': 'f', 'characters': 'c', 'bytes': 'b', 'only-delimited': 's' } })
const delimiter = options.values.get('d')
const fields = options.values.get('f')
const characters = options.values.get('c') ?? options.values.get('b')
const files = options.operands.length > 0 ? operandsText(options.operands, context) : undefined
if (fields) {
const by = delimiter ? delimiterName(delimiter.text) : hint('制表符')
const lines = files ? [space(), ...files, space(), word('的每一行')] : []
return withUnknown([verb('按'), by, verb('切'), ...lines, word(','), verb(`取${rangeText(fields.text, '段')}`)], options)
}
if (characters) {
const range = rangeText(characters.text, '个字符')
return withUnknown(files ? [verb('取'), space(), ...files, space(), word('每一行的'), verb(range)] : [verb(`取${range}`)], options)
}
return undefined
})
const du = shellCommand('du', ['du'], (command, context) => {
const options = parseOptions(command.arguments, { flags: 'shackm', valued: 'd', long: { 'summarize': 's', 'human-readable': 'h', 'all': 'a', 'total': 'c', 'max-depth': 'd' } })
const depth = options.values.get('d')
return action('统计占用空间', options, context, [
...meanings(options, [['h', '按人读的单位'], ['s', '只看总数'], ['a', '连文件也列'], ['c', '再算合计']]),
...(depth ? [[hint('往下看 '), ...valueText(depth, context), hint(' 层')]] : []),
])
})
const cat = shellCommand('cat', ['cat'], (command, context) => {
const options = parseOptions(command.arguments, { flags: 'nbsvAE', long: { number: 'n' } })
const note = notes(meanings(options, [['n', '带行号'], ['b', '非空行带行号'], ['s', '连续空行只留一行']]))
if (options.operands.length === 0)
return withUnknown([verb('打印输入'), ...note], options)
if (options.operands.length === 1)
return withUnknown([verb('打印'), space(), ...operandsText(options.operands, context), space(), word('的内容'), ...note], options)
return withUnknown([verb('连起来打印'), space(), ...operandsText(options.operands, context), ...note], options)
})
const grep = shellCommand('grep', ['grep', 'egrep', 'fgrep'], (command, context) => {
const options = parseOptions(command.arguments, {
flags: 'qivrREFnoclwxsHh',
valued: 'ef',
long: { 'quiet': 'q', 'ignore-case': 'i', 'invert-match': 'v', 'recursive': 'r', 'extended-regexp': 'E', 'fixed-strings': 'F', 'line-number': 'n', 'only-matching': 'o', 'count': 'c', 'files-with-matches': 'l', 'word-regexp': 'w', 'line-regexp': 'x', 'regexp': 'e', 'file': 'f' },
})
const explicit = options.values.get('e')
const operands = [...options.operands]
const pattern: Inline[] | undefined = explicit ? valueText(explicit, context) : operands.length > 0 ? context.translate((operands.shift() as CommandWord).node) : undefined
if (!pattern)
return undefined
const where = operands.length > 0 ? operandsText(operands, context) : [hint('输入')]
return withUnknown([
verb('在'),
space(),
...where,
space(),
verb(options.flags.has('v') ? '里找不含' : '里找含'),
space(),
...pattern,
space(),
word('的行'),
...notes(meanings(options, [
['q', '只看找没找到'],
['i', '不分大小写'],
['w', '整词'],
['x', '整行'],
['E', '按扩展正则'],
['F', '按原文'],
['r', '连同子目录'],
['R', '连同子目录'],
['n', '带行号'],
['o', '只打印匹配到的部分'],
['c', '只数有几行'],
['l', '只列文件名'],
['s', '不报读不了的文件'],
])),
], options)
})
/** sed 的 s 命令:s/旧/新/g(分隔符可以换)。 */
function substitution(script: string): { from: string, to: string, all: boolean } | undefined {
if (!script.startsWith('s') || script.length < 4)
return undefined
const separator = script[1]
const parts = script.slice(2).split(separator)
if (parts.length !== 3)
return undefined
const [from, to, flags] = parts
return /^[gI]*$/.test(flags) ? { from, to, all: flags.includes('g') } : undefined
}
const sed = shellCommand('sed', ['sed'], (command, context) => {
const options = parseOptions(command.arguments, { flags: 'nEri', valued: 'ef', long: { 'quiet': 'n', 'silent': 'n', 'regexp-extended': 'E', 'in-place': 'i', 'expression': 'e', 'file': 'f' } })
const operands = [...options.operands]
const script = options.values.get('e') ?? (operands.length > 0 ? { word: operands[0], text: operands.shift()?.literal ?? '' } : undefined)
if (!script)
return undefined
const files = operands.length > 0 ? operandsText(operands, context) : [hint('输入')]
const note = notes(meanings(options, [['i', '直接改文件'], ['n', '不自动打印'], ['E', '按扩展正则'], ['r', '按扩展正则']]))
const replaced = substitution(script.text)
if (replaced) {
return withUnknown([
verb('把'),
space(),
...files,
space(),
word('里的'),
space(),
literalText(replaced.from),
space(),
verb(replaced.all ? '全换成' : '换成'),
space(),
literalText(replaced.to),
...note,
], options)
}
return withUnknown([verb('按 sed 脚本'), space(), ...valueText(script, context), space(), verb('改'), space(), ...files, ...note], options)
})
const chmod = shellCommand('chmod', ['chmod'], (command, context) => {
const options = parseOptions(command.arguments, { flags: 'Rv', long: { recursive: 'R', verbose: 'v' } })
const [mode, ...files] = options.operands
if (!mode || files.length === 0)
return undefined
const note = notes(meanings(options, [['R', '连同里面的东西']]))
const targets = operandsText(files, context)
if (mode.literal === '+x' || mode.literal === 'a+x')
return withUnknown([verb('给'), space(), ...targets, space(), verb('加上可执行权限'), ...note], options)
if (mode.literal && /^[0-7]{3,4}$/.test(mode.literal))
return withUnknown([verb('把'), space(), ...targets, space(), verb('的权限改成'), space(), literalText(mode.literal), ...note], options)
return withUnknown([verb('改'), space(), ...targets, space(), verb('的权限:'), ...context.translate(mode.node), ...note], options)
})
const ln = shellCommand('ln', ['ln'], (command, context) => {
const options = parseOptions(command.arguments, { flags: 'sfnvr', long: { 'symbolic': 's', 'force': 'f', 'no-dereference': 'n', 'verbose': 'v', 'relative': 'r' } })
const [target, link] = options.operands
if (!target)
return undefined
const made = link ? [...context.translate(link.node), space()] : []
return withUnknown([
verb(options.flags.has('s') ? '建符号链接' : '建硬链接'),
space(),
...made,
word('→'),
space(),
...context.translate(target.node),
...notes([...(link ? [] : [[hint('放在当前目录')]]), ...meanings(options, [['f', '已有就替换'], ['r', '按相对路径']])]),
], options)
})
const touch = shellCommand('touch', ['touch'], (command, context) => {
const options = parseOptions(command.arguments, { flags: 'acm', long: { 'no-create': 'c' } })
return withUnknown([verb('更新修改时间'), space(), ...operandsText(options.operands, context), ...notes(options.flags.has('c') ? [[hint('没有的不建')]] : [[hint('没有就建空文件')]])], options)
})
const source = shellCommand('source', ['source', '.'], (command, context) => {
const [file, ...args] = command.arguments
if (!file)
return undefined
return [verb('载入并执行'), space(), ...context.translate(file.node), ...(args.length > 0 ? [word(',参数'), space(), ...operandsText(args, context)] : [])]
})
const read = shellCommand('read', ['read'], (command, context) => {
const options = parseOptions(command.arguments, { flags: 'rse', valued: 'ptdna' })
const prompt = options.values.get('p')
return withUnknown([
verb('读一行进'),
space(),
...operandsText(options.operands, context),
...notes([
...(prompt ? [[hint('提示 '), ...valueText(prompt, context)]] : []),
...meanings(options, [['r', '不认转义'], ['s', '不回显']]),
]),
], options)
})
const sleep = shellCommand('sleep', ['sleep'], (command, context) => {
const [time] = command.arguments
return time ? [verb('等'), space(), ...context.translate(time.node), space(), word('秒')] : undefined
})
const flow = [
shellCommand('continue', ['continue'], () => [verb('跳过这一个')]),
shellCommand('break', ['break'], () => [verb('跳出循环')]),
shellCommand('true', ['true', ':'], () => [verb('什么也不做')]),
shellCommand('false', ['false'], () => [verb('失败')]),
]
/** 内置的命令规则:常用的文件、下载、打包、文本处理命令和 shell 自带的几个命令。 */
export const shellCommandRules: CommandRule[] = [
rm,
mkdir,
curl,
tar,
unzip,
find,
cp,
mv,
echo,
printf,
exit,
returns,
cd,
pwd,
basename,
dirname,
readlink,
shasum,
...sums,
cut,
du,
cat,
grep,
sed,
chmod,
ln,
touch,
source,
read,
sleep,
...flow,
]
@@ -1,12 +1,15 @@
import type { Memento } from 'vscode'
import type { LibraryRule } from '../contract'
import { describe, expect, it } from 'vitest'
import { shellCommandRules } from '../commands/shellCommands'
import { createBashAdapter } from '../languages/bash'
import { createPythonAdapter } from '../languages/python'
import { createTypeScriptAdapter } from '../languages/typescript'
import { CodeReaderRegistry } from '../registry'
import { fastapiRule } from '../rules/fastapi'
import { sqlalchemyRule } from '../rules/sqlalchemy'
import { runtime, WASM_DIR } from '../testSupport'
import { bashZhTranslator } from '../translators/bashZh'
import { pythonZhTranslator } from '../translators/pythonZh'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { documentMessage } from './documentMessage'
@@ -151,6 +154,20 @@ describe('预览的 document 消息', () => {
`库规则 broken 看第 ${line} 行的 ${['import', 'class', 'variable', 'field'][i]} 块时出错,这块照常显示:读不懂`))
})
it('shell:注册的命令规则读命令,筛选栏有 shell 的种类,同一文件里的名字交给宿主跳转', async () => {
const registry = new CodeReaderRegistry({ get: () => undefined, update: async () => {} } as Pick<Memento, 'get' | 'update'>)
registry.registerLanguage(createBashAdapter(WASM_DIR))
registry.registerTranslator(bashZhTranslator)
for (const rule of shellCommandRules)
registry.registerCommandRule(rule)
const source = '#!/usr/bin/env bash\nset -eu\ndest="$1"\nrm -rf "$dest"\n'
const { message, symbols } = await documentMessage(doc('shellscript', source), registry, runtime, ignore)
expect(message.blocks.map(b => b.kind)).toEqual(['interpreter', 'options', 'variable', 'command'])
expect(message.blocks[3]).toMatchObject({ translation: [{ text: '删除' }, { text: ' ' }, { text: 'dest' }, { text: '(' }, { text: '连同里面的东西' }, { text: '、' }, { text: '不提示' }, { text: ')' }] })
expect(message.attributes.find(attribute => attribute.id === 'kind')?.values.map(value => value.name)).toEqual(['解释器', '选项', '变量', '命令'])
expect(symbols.map(symbol => symbol.occurrences.map(range => source.slice(range.startIndex, range.endIndex)))).toEqual([['dest', 'dest']])
})
it('解析失败:把原因放进 error', async () => {
const failing = { parse: async () => Promise.reject(new Error('语法包加载失败')) }
const { message } = await documentMessage(doc('typescript', 'const a = 1'), registryWithTypeScript(), failing, ignore)
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# 下载执行器自带的 git 与 python, 放到 src-tauri/resources/runtime/<target>/{git,python}。
#
# scripts/fetch-runtime.sh <target>
#
# 支持的 target:
# x86_64-pc-windows-msvc MinGit 64-bit (git-for-windows) + python-build-standalone
# aarch64-apple-darwin dugite-native macOS arm64 (GitHub Desktop 的可搬移 git) + python-build-standalone
#
# 构建 (含 cargo test / tauri dev) 前先跑一次: tauri 按目标平台的配置把这个目录打进安装包
# (见 src-tauri/tauri.<os>.conf.json), 目录缺了构建直接失败。
# 下载的包按版本缓存在 ~/.cache/luffy-desktop-runtime, 校验 sha256; 国内访问 GitHub 需要
# 先 export https_proxy。版本升级时同时改下面的地址与 sha256。
set -euo pipefail
target="${1:-}"
here="$(cd "$(dirname "$0")/.." && pwd)"
dest="$here/src-tauri/resources/runtime/$target"
cache="${XDG_CACHE_HOME:-$HOME/.cache}/luffy-desktop-runtime"
PY_RELEASE=20260924
PY_VERSION=3.13.15
case "$target" in
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
;;
*)
echo "用法: $0 <x86_64-pc-windows-msvc|aarch64-apple-darwin>" >&2
exit 2
;;
esac
PY_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PY_RELEASE}/cpython-${PY_VERSION}%2B${PY_RELEASE}-${target}-install_only_stripped.tar.gz"
sha_ok() {
[ -f "$1" ] && [ "$(shasum -a 256 "$1" | cut -d' ' -f1)" = "$2" ]
}
# 下载 (已缓存且校验通过就不重下), 回显本地文件路径
fetch() {
local url="$1" sha="$2" out
out="$cache/$(basename "${url//%2B/+}")"
mkdir -p "$cache"
if ! sha_ok "$out" "$sha"; then
echo "下载 $url" >&2
curl -fL --retry 3 -o "$out.part" "$url"
mv "$out.part" "$out"
fi
if ! sha_ok "$out" "$sha"; then
echo "sha256 不符: $out" >&2
exit 1
fi
echo "$out"
}
git_pkg="$(fetch "$GIT_URL" "$GIT_SHA256")"
py_pkg="$(fetch "$PY_URL" "$PY_SHA256")"
rm -rf "$dest"
mkdir -p "$dest/git"
case "$git_pkg" in
*.zip) unzip -q "$git_pkg" -d "$dest/git" ;;
*) tar -xzf "$git_pkg" -C "$dest/git" ;;
esac
# python-build-standalone 的包里顶层目录就叫 python
tar -xzf "$py_pkg" -C "$dest"
# ── 去掉 Git Credential Manager ──────────────────────────────────────────────
# 两个 git 包都带了 GCM (.NET 写的图形界面凭证助手, 占包体大头: macOS 约 120MB、Windows 约 60MB)。
# 平台的 git 在后台跑, 凭证由平台写进仓库配置, 不能弹窗, 用不上它。
case "$target" in
*-apple-darwin)
gc="$dest/git/libexec/git-core"
rm -rf "$gc"/git-credential-manager* "$gc"/*.dll "$gc"/*.dylib \
"$gc"/createdump "$gc"/NOTICE "$gc"/uninstall.sh
# .NET 的各语言资源目录; git 自己的子目录只有 mergetools
find "$gc" -mindepth 1 -maxdepth 1 -type d ! -name mergetools -exec rm -rf {} +
;;
*-windows-msvc)
# mingw64/bin 里 git 自己依赖的 DLL 都以 lib 开头 (外加 zlib1.dll), 其余 DLL 都属于 GCM;
# lib 开头的只有 HarfBuzzSharp / SkiaSharp 两个属于 GCM
mb="$dest/git/mingw64/bin"
rm -f "$mb"/git-credential-manager.exe* "$mb"/libHarfBuzzSharp.dll "$mb"/libSkiaSharp.dll
find "$mb" -maxdepth 1 -name '*.dll' ! -name 'lib*' ! -name zlib1.dll -delete
;;
esac
# Windows 版 python 只有 python.exe; 平台脚本按 python3 这个名字再调起它时 (它的目录前置在
# PATH 上), 要在这里先找到, 而不是落到系统自带的「去应用商店安装」占位程序上。
# python.exe 只是 90KB 的启动器, 本体在同目录的 python3xx.dll。
if [[ "$target" == *-windows-msvc ]]; then
cp "$dest/python/python.exe" "$dest/python/python3.exe"
fi
# ── 去掉符号链接 ─────────────────────────────────────────────────────────────
# tauri 打包不保留符号链接, 会把每个都展开成一整份拷贝 (git 的 140 多个内建命令别名全指向
# 3MB 的 git)。只有 macOS 的包里有符号链接。
if [[ "$target" == *-apple-darwin ]]; then
gc="$dest/git/libexec/git-core"
# 内建命令的短横线别名 (git-add -> git …): git 自己调内建命令不走它们。同 git 构建选项
# SKIP_DASHED_BUILT_INS, 只留远端/本地传输要按名字调起的三个
for f in "$gc"/git-*; do
[ -L "$f" ] && [ "$(readlink "$f")" = git ] || continue
case "$(basename "$f")" in
git-upload-pack | git-receive-pack | git-upload-archive) ;;
*) rm "$f" ;;
esac
done
# python 只留 bin/python3 这一个入口 (执行器按它找), 版本号命名的本体挪过来
pybin="$dest/python/bin"
real="$(readlink "$pybin/python3")"
rm "$pybin/python3"
mv "$pybin/$real" "$pybin/python3"
find "$dest/python" -type l -delete
# 剩下的 (传输程序、git-remote-https 之类) 换成实体文件
find "$dest" -type l | while read -r link; do
cp -L "$link" "$link.tmp"
mv "$link.tmp" "$link"
done
fi
echo "自带运行时就绪: $dest" >&2
du -sh "$dest/git" "$dest/python" >&2
+7 -1
View File
@@ -3,12 +3,14 @@ import type { CodeReaderApi, RuleLocation, RuleReference } from './contract'
import type { IndexedName } from './nameResolution'
import { commands, window, workspace } from 'vscode'
import { logger } from '../logging/logger'
import { shellCommandRules } from './commands/shellCommands'
import { BlockFilter } from './editor/blockFilter'
import { NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigation'
import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor'
import { showPreview, showSource } from './editor/switchEditor'
import { registerCopyCommands } from './explorer/copyCommands'
import { FileTreeProvider } from './explorer/fileTree'
import { createBashAdapter } from './languages/bash'
import { createPythonAdapter } from './languages/python'
import { createTypeScriptAdapter } from './languages/typescript'
import { resolveReference } from './libraryRules'
@@ -24,6 +26,7 @@ import { enumRule } from './rules/enum'
import { fastapiRule } from './rules/fastapi'
import { sqlalchemyRule } from './rules/sqlalchemy'
import { bundledWasmDir, TreeSitterRuntime } from './runtime'
import { bashZhTranslator } from './translators/bashZh'
import { pythonZhTranslator } from './translators/pythonZh'
import { typescriptZhTranslator } from './translators/typescriptZh'
import { workspaceIndexHost } from './workspaceFiles'
@@ -56,7 +59,7 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise<void> {
/**
* 接入代码阅读:注册表、侧栏文件树(复制、文件注释、按审查工单只看 PR 改过的文件)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。
* 内置的语言插件、翻译插件和库规则与其他扩展一样通过返回的 API 注册。
* 内置的语言插件、翻译插件、库规则和命令规则与其他扩展一样通过返回的 API 注册。
*/
export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
const registry = new CodeReaderRegistry(context.globalState)
@@ -111,8 +114,11 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
context.subscriptions.push(
api.registerLanguage(createTypeScriptAdapter(wasmDir)),
api.registerLanguage(createPythonAdapter(wasmDir)),
api.registerLanguage(createBashAdapter(wasmDir)),
api.registerTranslator(typescriptZhTranslator),
api.registerTranslator(pythonZhTranslator),
api.registerTranslator(bashZhTranslator),
...shellCommandRules.map(rule => api.registerCommandRule(rule)),
api.registerLibraryRule(sqlalchemyRule),
api.registerLibraryRule(celeryRule),
api.registerLibraryRule(enumRule),
@@ -0,0 +1,193 @@
import type { Outline } from '../testSupport'
import { describe, expect, it } from 'vitest'
import { factsByLine, nodeFacts, nodesToText, outline, readFixture, runtime, WASM_DIR, withBlocks } from '../testSupport'
import { createBashAdapter } from './bash'
const adapter = createBashAdapter(WASM_DIR)
const FETCH = readFixture('fetchRuntime.sh')
function tree(source: string): Promise<Outline[]> {
return withBlocks(adapter, 'shellscript', source, outline)
}
function facts(source: string): Promise<Record<number, unknown>> {
return withBlocks(adapter, 'shellscript', source, factsByLine)
}
/** luffy 桌面端 fetch-runtime.sh 的代码块树(同原型 SHELL_BLOCKS,原型略去的 95-100、116-121 行也在)。 */
const FETCH_TREE: Outline[] = [
{ at: 1, kind: 'interpreter' },
{ at: [2, 13], kind: 'comment' },
{ at: 14, kind: 'options' },
{ at: 16, kind: 'variable' },
{ at: 17, kind: 'variable' },
{ at: 18, kind: 'variable' },
{ at: 19, kind: 'variable' },
{ at: 21, kind: 'const' },
{ at: 22, kind: 'const' },
{ at: [24, 39], kind: 'case' },
{ at: 40, kind: 'variable' },
{ at: 42, kind: 'function', kids: [{ at: 43, kind: 'if' }] },
{ at: [46, 47], kind: 'function', kids: [
{ at: 48, kind: 'variable' },
{ at: 49, kind: 'variable' },
{ at: 50, kind: 'command' },
{ at: 51, kind: 'if', kids: [{ at: 52, kind: 'command' }, { at: 53, kind: 'command' }, { at: 54, kind: 'command' }] },
{ at: 56, kind: 'if', kids: [{ at: 57, kind: 'command' }, { at: 58, kind: 'command' }] },
{ at: 60, kind: 'command' },
] },
{ at: 63, kind: 'variable' },
{ at: 64, kind: 'variable' },
{ at: 66, kind: 'command' },
{ at: 67, kind: 'command' },
{ at: [68, 71], kind: 'case' },
{ at: 72, kind: 'comment' },
{ at: 73, kind: 'command' },
{ at: [75, 77], kind: 'comment' },
{ at: 78, kind: 'case', kids: [
{ at: 79, kind: 'case', kids: [{ at: 80, kind: 'variable' }, { at: [81, 82], kind: 'command' }, { at: 83, kind: 'comment' }, { at: 84, kind: 'command' }] },
{ at: 86, kind: 'case', kids: [{ at: [87, 88], kind: 'comment' }, { at: 89, kind: 'variable' }, { at: 90, kind: 'command' }, { at: 91, kind: 'command' }] },
] },
{ at: [95, 97], kind: 'comment' },
{ at: 98, kind: 'if', kids: [{ at: 99, kind: 'command' }] },
{ at: [102, 104], kind: 'comment' },
{ at: 105, kind: 'if', kids: [
{ at: 106, kind: 'variable' },
{ at: [107, 108], kind: 'comment' },
{ at: 109, kind: 'loop', kids: [{ at: 110, kind: 'if' }, { at: [111, 114], kind: 'case' }] },
{ at: 116, kind: 'comment' },
{ at: 117, kind: 'variable' },
{ at: 118, kind: 'variable' },
{ at: 119, kind: 'command' },
{ at: 120, kind: 'command' },
{ at: 121, kind: 'command' },
{ at: 122, kind: 'comment' },
{ at: 123, kind: 'pipeline', kids: [{ at: 124, kind: 'command' }, { at: 125, kind: 'command' }] },
] },
{ at: 129, kind: 'command' },
{ at: 130, kind: 'command' },
]
describe('shell 代码块树', () => {
it('fetch-runtime.sh:解释器、选项、变量 / 常量、分情况(短的整条一块,长的每个情况一块子块)、函数(紧贴的注释算进头部)、命令、管道后面的循环主体是子块', async () => {
expect(await tree(FETCH)).toEqual(FETCH_TREE)
})
it('elif / else 展平成紧跟 if 的兄弟块;while、for 的主体是子块;一串全是条件、条件后面跟一句的算条件,别的一串命令算命令', async () => {
const source = [
'if [ -z "$a" ]; then',
' echo a',
'elif [ -n "$b" ]; then',
' echo b',
'else',
' echo c',
'fi',
'while true; do',
' sleep 1',
'done',
'[ -d "$x" ]',
'[ -f "$x" ] && rm "$x"',
'mkdir -p "$x" && cd "$x"',
'return 1',
].join('\n')
expect(await tree(source)).toEqual([
{ at: 1, kind: 'if', kids: [{ at: 2, kind: 'command' }] },
{ at: 3, kind: 'else', kids: [{ at: 4, kind: 'command' }] },
{ at: 5, kind: 'else', kids: [{ at: 6, kind: 'command' }] },
{ at: 8, kind: 'loop', kids: [{ at: 9, kind: 'command' }] },
{ at: 11, kind: 'if' },
{ at: 12, kind: 'if' },
{ at: 13, kind: 'command' },
{ at: 14, kind: 'return' },
])
})
})
describe('shell 属性表信息(facts)', () => {
it('解释器:env 按 PATH 找,写死的路径照写;选项:连写的开关拆开,-o 跟长名字,+ 是关掉;set -- 不是选项', async () => {
expect((await facts(FETCH))[1]).toEqual({ program: 'bash', lookup: 'env', command: '/usr/bin/env bash' })
expect((await facts('#!/bin/sh -e\n'))[1]).toEqual({ program: 'sh', lookup: 'path', command: '/bin/sh -e' })
expect((await facts(FETCH))[14]).toEqual({ switches: [
{ id: 'errexit', on: true, flag: '-e' },
{ id: 'nounset', on: true, flag: '-u' },
{ id: 'pipefail', on: true, flag: '-o pipefail' },
] })
expect((await facts('set +x -o nounset\n'))[1]).toEqual({ switches: [{ id: 'xtrace', on: false, flag: '+x' }, { id: 'nounset', on: true, flag: '-o nounset' }] })
expect(await tree('set -- a b\n')).toEqual([{ at: 1, kind: 'command' }])
})
it('变量:local 一句几个是一块,没值的也算;readonly、顶层全大写写死的值是常量,值里有展开的不是', async () => {
const byLine = await facts(FETCH)
expect(byLine[48]).toEqual({ declarators: [{ name: 'url', value: '"$1"' }, { name: 'sha', value: '"$2"' }, { name: 'out' }] })
expect(byLine[21]).toEqual({ declarators: [{ name: 'PY_RELEASE', value: '20260924' }] })
expect(await tree('readonly n=1\nPY_URL="$x/y"\nx=1\n')).toEqual([{ at: 1, kind: 'const' }, { at: 2, kind: 'variable' }, { at: 3, kind: 'variable' }])
})
it('函数:按位置取的参数(local x="$1" 起的名字)、打印到输出的几句(重定向到错误输出的不算)、输出被 $(…) 拿去用、写了的 return', async () => {
const byLine = await facts(FETCH)
expect(byLine[42]).toMatchObject({ name: 'sha_ok', positional: [{ position: 1 }, { position: 2 }], prints: [], captured: false, returns: [] })
expect(byLine[46]).toMatchObject({
name: 'fetch',
doc: '# 下载 (已缓存且校验通过就不重下), 回显本地文件路径',
positional: [{ position: 1, name: 'url' }, { position: 2, name: 'sha' }],
prints: ['echo "$out"'],
captured: true,
returns: [],
})
expect((await facts('check() {\n [ -f "$1" ] || return 1\n return 0\n}\n'))[1]).toMatchObject({ returns: ['return 1', 'return 0'] })
})
})
describe('shell 命令、名字', () => {
const commands = (source: string): Promise<unknown[]> => nodeFacts(adapter, 'shellscript', source, node => adapter.command?.(node))
it('命令:各个词连写死的文字(去掉引号、转义),有展开的没有;重定向读成输出 / 错误输出写到、追加到、并进另一路、从文件读', async () => {
const [cut] = await commands('cut -d\' \' -f1 "$x" a\\ b')
expect(nodesToText(cut)).toMatchObject({
name: { node: 'cut', literal: 'cut' },
arguments: [{ node: '-d\' \'', literal: '-d ' }, { node: '-f1', literal: '-f1' }, { node: '"$x"' }, { node: 'a\\ b', literal: 'a b' }],
redirects: [],
})
// 带重定向的语句和它里面那条命令都认得出,这里只看语句的
const redirected = await nodeFacts(adapter, 'shellscript', 'echo hi >&2\ncmd 2>/dev/null >> log < in\ncmd &> out 2>&1\n', node => node.type === 'redirected_statement' ? adapter.command?.(node) : undefined)
expect(redirected.map(facts => (facts as { redirects: unknown }).redirects)).toEqual([
[{ kind: 'merge', stream: 'output', into: 'error' }],
[{ kind: 'write', stream: 'error', target: '/dev/null' }, { kind: 'append', stream: 'output', target: 'log' }, { kind: 'read', source: 'in' }],
[{ kind: 'write', stream: 'both', target: 'out' }, { kind: 'merge', stream: 'error', into: 'output' }],
])
})
it('同一文件里的名字:函数的定义和调用,变量的赋值、local、for、read 和展开;第几个参数不算', async () => {
const source = 'fetch() {\n local url="$1" out\n out="$url"\n}\nfor f in a; do read -r line; echo "$f $line"; done\nx="$(fetch "$f")"\n'
const parsed = await runtime.parse(adapter, 'shellscript', source)
const symbols = adapter.symbols?.(parsed.rootNode, source, 'shellscript') ?? []
parsed.delete()
const at = (text: string, from = 0): number => source.indexOf(text, from)
const byText = (offset: number): { definitions: string[], occurrences: string[] } | undefined => {
const symbol = symbols.find(entry => entry.occurrences.some(range => range.startIndex === offset))
return 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)}@${range.startIndex}`),
}
}
expect(byText(at('fetch'))).toEqual({ definitions: [`fetch@${at('fetch')}`], occurrences: [`fetch@${at('fetch')}`, `fetch@${at('fetch "$f"')}`] })
expect(byText(at('out'))).toEqual({
definitions: [`out@${at('out')}`, `out@${at('out=')}`],
occurrences: [`out@${at('out')}`, `out@${at('out=')}`],
})
expect(byText(at('line'))?.occurrences).toEqual([`line@${at('line')}`, `line@${at('line"')}`])
expect(symbols.some(entry => entry.occurrences.some(range => source.slice(range.startIndex, range.endIndex) === '1'))).toBe(false)
})
it('中文名:变量赋值、声明行尾的 # @Comment 名字', async () => {
const source = 'dest="$1" # @Comment 安装目录\nlocal n # @Comment 次数\n'
const parsed = await runtime.parse(adapter, 'shellscript', source)
try {
const found = adapter.displayNames?.find(parsed.rootNode, source, 'shellscript')
expect(found?.names.map(({ name, text }) => [source.slice(name.startIndex, name.endIndex), text])).toEqual([['dest', '安装目录'], ['n', '次数']])
}
finally {
parsed.delete()
}
})
})
+584
View File
@@ -0,0 +1,584 @@
import type { BlockShape, CodeBlock, CommandFacts, CommandWord, DeclaratorFacts, FunctionFacts, InterpreterFacts, LanguageAdapter, LocalSymbol, Node, OptionsFacts, PositionalParameter, Redirect, SourceRange, SwitchFacts, TextKind, TokenRole } from '../contract'
import type { CommentRules, ListBlocks } from './shared'
import * as path from 'node:path'
import { bashNameReader } from './bashNames'
import { children, compoundShape, leafBlock, leafShape, listBlocks, named } from './shared'
/** 按习惯当常量用的名字:全大写(可带数字、下划线)。 */
const CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/
const POSITIONAL = /^[1-9]$/
/** 流程关键字(按关键字着色)和声明关键字(按声明着色)。 */
const FLOW_KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'until', 'do', 'done', 'case', 'esac', 'in', 'select'])
const DECLARATION_KEYWORDS = new Set(['local', 'export', 'readonly', 'declare', 'typeset', 'function', 'unset'])
/** 写在 `set` 里的单字母开关 → bash 的长名字(`set -o` 用的那个)。 */
const SHORT_SWITCHES: Record<string, string> = {
a: 'allexport',
b: 'notify',
e: 'errexit',
f: 'noglob',
h: 'hashall',
k: 'keyword',
m: 'monitor',
n: 'noexec',
t: 'onecmd',
u: 'nounset',
v: 'verbose',
x: 'xtrace',
B: 'braceexpand',
C: 'noclobber',
E: 'errtrace',
H: 'histexpand',
P: 'physical',
T: 'functrace',
}
/** 打印到输出的内置命令:函数里没重定向的这几句是函数的输出。 */
const PRINTERS = new Set(['echo', 'printf'])
const LOOPS = new Set(['for_statement', 'c_style_for_statement', 'while_statement'])
/** 分情况里一个情况最多几句、每句一行写完,才整个排成一张表(不拆成子块)。 */
const COMPACT_CASE_STATEMENTS = 3
const SIMPLE_STATEMENTS = new Set(['command', 'redirected_statement', 'variable_assignment', 'declaration_command', 'negated_command'])
/** 不出值的展开:命令替换、变量展开、算术展开、进程替换;值里有它们就不是写死的。 */
const EXPANSIONS = new Set(['simple_expansion', 'expansion', 'command_substitution', 'arithmetic_expansion', 'process_substitution'])
const COMMENTS: CommentRules = {
isLine: () => true,
// 紧贴在函数上面一行的注释是函数的说明
isDoc: (comment) => {
const next = comment.nextSibling
return next?.type === 'function_definition' && next.startPosition.row === comment.endPosition.row + 1
},
}
function statements(items: Node[], afterRow: number, top: boolean): CodeBlock[] {
return listBlocks(items, afterRow, item => statementBlocks(item, top), COMMENTS)
}
const nested: ListBlocks = (items, afterRow) => statements(items, afterRow, false)
/** 节点里(不进嵌套的函数定义)满足 test 的节点,先序。 */
function descendants(node: Node, test: (node: Node) => boolean, out: Node[] = []): Node[] {
for (const child of children(node)) {
if (test(child))
out.push(child)
if (child.type !== 'function_definition')
descendants(child, test, out)
}
return out
}
function commandName(command: Node | null | undefined): string | undefined {
return command?.type === 'command' ? command.childForFieldName('name')?.text : undefined
}
/** 语句本身执行的那条命令:`cmd`,或带重定向的 `cmd >&2` 里的 cmd。 */
function commandOf(statement: Node): Node | undefined {
if (statement.type === 'command')
return statement
const body = statement.type === 'redirected_statement' ? statement.childForFieldName('body') : null
return body?.type === 'command' ? body : undefined
}
// ─── 块 ──────────────────────────────────────────────────────────────────────
function statementBlocks(node: Node, top: boolean): CodeBlock[] {
switch (node.type) {
case 'variable_assignment':
return [variableBlock(node, [node], top, false)]
case 'variable_assignments':
return [variableBlock(node, named(node).filter(child => child.type === 'variable_assignment'), top, false)]
case 'declaration_command':
return [declarationBlock(node, top)]
case 'function_definition':
return [functionBlock(node)]
case 'if_statement':
return ifBlocks(node)
case 'for_statement':
case 'c_style_for_statement':
case 'while_statement':
return [loopBlock('loop', node, node)]
case 'case_statement':
return [caseBlock(node)]
case 'pipeline':
return [pipelineBlock(node)]
case 'redirected_statement': {
const body = node.childForFieldName('body')
return body && body.type !== 'command' ? statementBlocks(body, top) : [commandBlock(node)]
}
case 'command':
return [commandBlock(node)]
case 'negated_command':
case 'test_command':
return [leafBlock(isTest(node) ? 'if' : 'command', node)]
case 'list':
return [leafBlock(conditionList(node) ? 'if' : 'command', node)]
default:
return [leafBlock('statement', node)]
}
}
/** 值里没有展开(变量、命令替换……),是写死的。 */
function isLiteral(value: Node): boolean {
return !EXPANSIONS.has(value.type) && named(value).every(isLiteral)
}
/** 常量:readonly 的,或脚本顶层、名字全大写、值写死的。 */
function variableBlock(node: Node, assignments: Node[], top: boolean, readonly: boolean): CodeBlock {
const declarators = assignments.flatMap((assignment): DeclaratorFacts[] => {
if (assignment.type !== 'variable_assignment')
return [{ name: assignment }]
const name = assignment.childForFieldName('name')
const value = assignment.childForFieldName('value')
return name ? [{ name, ...(value && { value }) }] : []
})
const constant = readonly || (top && declarators.length > 0 && declarators.every(({ name, value }) => CONSTANT_NAME.test(name.text) && value && isLiteral(value)))
return { kind: constant ? 'const' : 'variable', ...leafShape(node), facts: { declarators } }
}
/** local / export / readonly / declare:声明的变量是一块;什么都没声明(`export -f fn`)的当命令。 */
function declarationBlock(node: Node, top: boolean): CodeBlock {
const keyword = node.child(0)?.type
const declared = named(node).filter(child => child.type === 'variable_assignment' || child.type === 'variable_name')
if (declared.length === 0)
return leafBlock('command', node)
const readonly = keyword === 'readonly' || named(node).some(child => child.type === 'word' && /^-\w*r/.test(child.text))
return variableBlock(node, declared, top, readonly)
}
function commandBlock(node: Node): CodeBlock {
const command = commandOf(node)
const name = commandName(command)
if (name === 'set' && command && node.type === 'command') {
const switches = optionSwitches(named(command).filter(child => child.type !== 'command_name'))
if (switches)
return { kind: 'options', ...leafShape(node), facts: { switches } }
}
return leafBlock(name === 'return' ? 'return' : 'command', node)
}
/** `set -euo pipefail`、`set +x`、`set -o nounset` 的各个开关;有不是开关的词(`set -- a b`)时不是。 */
function optionSwitches(words: Node[]): OptionsFacts['switches'] | undefined {
const switches: SwitchFacts[] = []
for (let i = 0; i < words.length; i++) {
const text = words[i].text
const match = /^([-+])([a-z]+)$/i.exec(text)
if (!match)
return undefined
const on = match[1] === '-'
for (const letter of match[2]) {
if (letter === 'o') {
const name = words[++i]?.text
if (!name || !/^[a-z]+$/.test(name))
return undefined
switches.push({ id: name, on, flag: `${match[1]}o ${name}` })
continue
}
switches.push({ id: SHORT_SWITCHES[letter] ?? letter, on, flag: `${match[1]}${letter}` })
}
}
return switches.length > 0 ? switches : undefined
}
/** 条件:`[ … ]`、`[[ … ]]`,前面可以带 `!`。 */
function isTest(node: Node): boolean {
if (node.type === 'negated_command')
return named(node).some(isTest)
return node.type === 'test_command'
}
/** `a && b || c` 里用 && / || 连起来的各句,照源码顺序。 */
function listOperands(node: Node): Node[] {
return node.type === 'list' ? named(node).filter(child => child.type !== 'comment').flatMap(listOperands) : [node]
}
/**
* 当条件读的一串:全是条件(`[ -f x ] && [ … ]`,读成「成立:…」),或前面全是条件、最后跟一句(`[ … ] || continue`、
* `[ -f x ] && rm x`,读成「如果(不是)〔…〕,…」)。
*/
function conditionList(node: Node): boolean {
const operands = listOperands(node)
return operands.slice(0, -1).every(isTest) && (isTest(operands[operands.length - 1]) || operands.length > 1)
}
/** 语句块(`{ … }`、do … done、then 后面)里的各句连同注释:去掉关键字、分号。 */
function bodyItems(nodes: Node[]): Node[] {
return nodes.filter(child => child.isNamed)
}
/** 复合语句块:头部到第一句之前(没有句子时到 fromEnd 为止),子块是各句。 */
function compound(kind: TextKind, outer: Node, items: Node[], fromEnd: number): CodeBlock {
const shape: BlockShape = compoundShape(outer, items[0]?.startIndex ?? fromEnd, items, nested)
return { kind, ...shape }
}
/** 循环:头部到 do 为止,子块是 do … done 里的各句。管道后面跟的 while 也是这样拆,只是块本身是整个管道。 */
function loopBlock(kind: TextKind, outer: Node, loop: Node): CodeBlock {
const body = loop.childForFieldName('body')
const items = body ? bodyItems(children(body)) : []
const open = body?.child(0)
return compound(kind, outer, items, open?.endIndex ?? loop.endIndex)
}
/** if 一块,elif / else 展平成紧跟它的兄弟块(同 Python)。 */
function ifBlocks(node: Node): CodeBlock[] {
const clauses = named(node).filter(child => child.type === 'elif_clause' || child.type === 'else_clause')
return [clauseBlock('if', node), ...clauses.map(clause => clauseBlock('else', clause))]
}
/** if、elif 的主体是 then 后面、下一个 elif / else 之前的各句;else 的主体是 else 后面的各句。 */
function clauseBlock(kind: TextKind, node: Node): CodeBlock {
const all = children(node)
const opener = all.findIndex(child => child.type === (node.type === 'else_clause' ? 'else' : 'then'))
const rest = all.slice(opener + 1)
const end = rest.findIndex(child => child.type === 'elif_clause' || child.type === 'else_clause' || child.type === 'fi')
const items = bodyItems(end < 0 ? rest : rest.slice(0, end))
return compound(kind, node, items, all[opener]?.endIndex ?? node.endIndex)
}
function caseItems(node: Node): Node[] {
return named(node).filter(child => child.type === 'case_item')
}
/** 一个情况里要做的各句(连同注释):`)` 后面的、`;;` 之前的。 */
function caseBody(item: Node): Node[] {
const all = children(item)
const close = all.findIndex(child => child.type === ')')
return bodyItems(all.slice(close + 1))
}
/** 每个情况都只有几句、每句一行写完、没有注释和嵌套的语句块。 */
function compactCase(node: Node): boolean {
return caseItems(node).every((item) => {
const body = caseBody(item)
return body.length <= COMPACT_CASE_STATEMENTS && body.every(statement => SIMPLE_STATEMENTS.has(statement.type)
&& statement.startPosition.row === statement.endPosition.row
&& (statement.type !== 'redirected_statement' || commandOf(statement) !== undefined))
})
}
/** 分情况:几个情况都短时整条语句一块(情况排成表),否则头部到 in 为止、每个情况一块子块。 */
function caseBlock(node: Node): CodeBlock {
if (compactCase(node))
return leafBlock('case', node)
const items = caseItems(node)
const inKeyword = children(node).find(child => child.type === 'in')
const shape = compoundShape(node, items[0]?.startIndex ?? inKeyword?.endIndex ?? node.endIndex, [], nested)
return { kind: 'case', ...shape, children: [...shape.children, ...items.map(branchBlock)] }
}
/** 一个情况:头部是它的模式(到 `)` 为止),子块是要做的各句。 */
function branchBlock(item: Node): CodeBlock {
const body = caseBody(item)
const close = children(item).find(child => child.type === ')')
return compound('case', item, body, close?.endIndex ?? item.endIndex)
}
/** 管道:最后一段是循环(`find … | while read -r x; do … done`)时循环的主体是子块。 */
function pipelineBlock(node: Node): CodeBlock {
const last = named(node).at(-1)
if (last && LOOPS.has(last.type))
return loopBlock('pipeline', node, last)
return leafBlock('pipeline', node)
}
// ─── 函数 ────────────────────────────────────────────────────────────────────
/** `$1`、`"$1"`、`${1}` 这种只是取第几个参数的值:取的是第几个。 */
function positionalValue(value: Node | null): number | undefined {
if (!value)
return undefined
const inner = value.type === 'string' && named(value).length === 1 ? named(value)[0] : value
if (inner.type !== 'simple_expansion' && inner.type !== 'expansion')
return undefined
const expanded = named(inner)
const name = expanded.length === 1 ? expanded[0] : undefined
return name?.type === 'variable_name' && POSITIONAL.test(name.text) ? Number(name.text) : undefined
}
/** 函数里用到的第几个参数,和它们在函数里的名字(`local url="$1"`、`url=$1`)。 */
function positionalParameters(body: Node): PositionalParameter[] {
const used = new Set(descendants(body, node => node.type === 'variable_name' && POSITIONAL.test(node.text)
&& (node.parent?.type === 'simple_expansion' || node.parent?.type === 'expansion')).map(node => Number(node.text)))
const names = new Map<number, Node>()
for (const assignment of descendants(body, node => node.type === 'variable_assignment')) {
const position = positionalValue(assignment.childForFieldName('value'))
const name = assignment.childForFieldName('name')
if (position !== undefined && name && !names.has(position))
names.set(position, name)
}
return [...used].sort((a, b) => a - b).map(position => ({ position, ...(names.has(position) && { name: names.get(position) }) }))
}
/** 重定向了输出(`>&2`、`> file`)的语句不算打印到输出。 */
function printsToOutput(statement: Node): boolean {
const name = commandName(commandOf(statement))
if (!name || !PRINTERS.has(name))
return false
return redirectsOf(statement).every(redirect => redirect.kind === 'read' || redirect.stream === 'error')
}
/** 函数自己的各句(进 if、循环、分情况,不进命令替换、管道、嵌套的函数)里打印到输出的那几句。 */
function printedStatements(body: Node): Node[] {
const out: Node[] = []
const visit = (node: Node): void => {
for (const child of named(node)) {
if (child.type === 'command' || child.type === 'redirected_statement') {
if (printsToOutput(child))
out.push(child)
}
else if (child.type !== 'function_definition' && child.type !== 'pipeline' && child.type !== 'command_substitution') {
visit(child)
}
}
}
visit(body)
return out
}
/** 同一文件里有没有 `$(name …)`:函数的输出被拿去用了。 */
function captured(root: Node, name: string): boolean {
return descendants(root, node => node.type === 'command_substitution'
&& named(node).some(inner => commandName(inner) === name || commandName(listOperands(inner)[0]) === name)).length > 0
}
function functionBlock(node: Node): CodeBlock {
const name = node.childForFieldName('name')
const body = node.childForFieldName('body')
const items = body?.type === 'compound_statement' ? bodyItems(children(body)) : []
const open = body?.child(0)
const shape = compoundShape(node, items[0]?.startIndex ?? open?.endIndex ?? node.endIndex, items, nested)
const facts: FunctionFacts = {
...(name && { name }),
decorators: [],
parameters: [],
positional: body ? positionalParameters(body) : [],
prints: body ? printedStatements(body) : [],
captured: !!name && captured(node.tree.rootNode, name.text),
returns: body ? descendants(body, child => commandName(child) === 'return') : [],
}
return { kind: 'function', ...shape, facts }
}
// ─── 解释器 ──────────────────────────────────────────────────────────────────
/** `#!/usr/bin/env bash`、`#!/bin/sh -e`。 */
function interpreterFacts(shebang: Node): InterpreterFacts {
const command = shebang.text.slice(2).trim()
const [program = '', ...rest] = command.split(/\s+/)
if (path.posix.basename(program) === 'env') {
const named = rest.find(word => !word.startsWith('-'))
return { program: named ? path.posix.basename(named) : 'env', lookup: 'env', command }
}
return { program: path.posix.basename(program), lookup: 'path', command }
}
function programBlocks(root: Node): CodeBlock[] {
const items = named(root)
const [first] = items
if (first?.type === 'comment' && first.startPosition.row === 0 && first.text.startsWith('#!')) {
const interpreter: CodeBlock = { kind: 'interpreter', ...leafShape(first), facts: interpreterFacts(first) }
return [interpreter, ...statements(items.slice(1), 0, true)]
}
return statements(items, -1, true)
}
// ─── 命令 ────────────────────────────────────────────────────────────────────
/** 一个 `>`、`>>`、`>&2`、`2>&1`、`&>`、`<` 重定向;heredoc 这类不算。 */
function redirectOf(node: Node): Redirect | undefined {
if (node.type !== 'file_redirect')
return undefined
const descriptor = named(node).find(child => child.type === 'file_descriptor')?.text
const operator = children(node).find(child => !child.isNamed)?.type
const target = node.childForFieldName('destination')
if (!operator || !target)
return undefined
const stream = descriptor === '2' ? 'error' : 'output'
switch (operator) {
case '>&':
if (target.text === '1' || target.text === '2')
return { kind: 'merge', stream, into: target.text === '2' ? 'error' : 'output' }
return { kind: 'write', stream, target }
case '>':
case '>|':
return { kind: 'write', stream, target }
case '>>':
return { kind: 'append', stream, target }
case '&>':
return { kind: 'write', stream: 'both', target }
case '&>>':
return { kind: 'append', stream: 'both', target }
case '<':
return { kind: 'read', source: target }
default:
return undefined
}
}
function redirectsOf(statement: Node): Redirect[] {
if (statement.type !== 'redirected_statement')
return []
return statement.childrenForFieldName('redirect').flatMap((redirect) => {
const found = redirect ? redirectOf(redirect) : undefined
return found ? [found] : []
})
}
/** 一个词写死的文字:去掉引号、反斜杠转义;里面有展开的没有。 */
function literalOf(word: Node): string | undefined {
switch (word.type) {
case 'word':
case 'number':
case 'extglob_pattern':
return word.text.replace(/\\(.)/g, '$1')
case 'raw_string':
return word.text.slice(1, -1)
case 'command_name': {
const [inner] = named(word)
return inner ? literalOf(inner) : undefined
}
case 'string': {
const parts = named(word)
return parts.every(part => part.type === 'string_content') ? parts.map(part => part.text.replace(/\\([$`"\\])/g, '$1')).join('') : undefined
}
case 'concatenation': {
const parts = named(word).map(literalOf)
return parts.every(part => part !== undefined) ? parts.join('') : undefined
}
default:
return undefined
}
}
function commandWord(node: Node): CommandWord {
const literal = literalOf(node)
return literal === undefined ? { node } : { node, literal }
}
/** 一条命令(带不带重定向):命令名、后面的各个词(连写死的文字)、重定向。 */
function bashCommand(node: Node): CommandFacts | undefined {
const command = commandOf(node)
const name = command?.childForFieldName('name')
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) }
}
// ─── 名字 ────────────────────────────────────────────────────────────────────
function rangeOf(node: Node): SourceRange {
return { startIndex: node.startIndex, endIndex: node.endIndex }
}
/**
* 同一文件里认得出的名字:函数(定义处是函数名,用到的是同名的命令),变量(定义处是赋值、local / export 声明、for 的变量、
* read 读进的名字,用到的是 `$x`、`${x…}` 和这些定义处)。第几个参数、`$?` 这类不算。
*/
function bashSymbols(root: Node): LocalSymbol[] {
const symbols = new Map<string, LocalSymbol>()
const symbol = (key: string): LocalSymbol => {
let found = symbols.get(key)
if (!found) {
found = { definitions: [], occurrences: [] }
symbols.set(key, found)
}
return found
}
const define = (key: string, node: Node): void => {
const entry = symbol(key)
entry.definitions.push(rangeOf(node))
entry.occurrences.push(rangeOf(node))
}
const functions = new Set(descendants(root, node => node.type === 'function_definition').map(fn => fn.childForFieldName('name')?.text))
const visit = (node: Node): void => {
const parent = node.parent
if (node.type === 'function_definition') {
const name = node.childForFieldName('name')
if (name)
define(`function:${name.text}`, name)
}
else if (node.type === 'word' && parent?.type === 'command_name' && functions.has(node.text)) {
symbol(`function:${node.text}`).occurrences.push(rangeOf(node))
}
else if (node.type === 'variable_name' && !POSITIONAL.test(node.text)) {
const defining = parent?.type === 'variable_assignment' || parent?.type === 'declaration_command' || parent?.type === 'for_statement'
if (defining)
define(`variable:${node.text}`, node)
else if (parent?.type === 'simple_expansion' || parent?.type === 'expansion')
symbol(`variable:${node.text}`).occurrences.push(rangeOf(node))
}
else if (node.type === 'command' && commandName(node) === 'read') {
for (const arg of node.childrenForFieldName('argument')) {
if (arg?.type === 'word' && /^[a-z_]\w*$/i.test(arg.text))
define(`variable:${arg.text}`, arg)
}
}
for (const child of children(node))
visit(child)
}
visit(root)
return [...symbols.values()].filter(entry => entry.definitions.length > 0)
}
// ─── 词性 ────────────────────────────────────────────────────────────────────
function insideString(leaf: Node): boolean {
for (let node = leaf.parent; node; node = node.parent) {
if (EXPANSIONS.has(node.type))
return false
if (node.type === 'string' || node.type === 'heredoc_body')
return true
}
return false
}
function bashTokenRole(leaf: Node): TokenRole {
const parent = leaf.parent
switch (leaf.type) {
case 'comment':
return 'comment'
case 'string_content':
case 'raw_string':
case 'ansi_c_string':
case 'heredoc_content':
case 'heredoc_body':
case 'extglob_pattern':
case 'regex':
return 'string'
case 'number':
return 'number'
case 'variable_name':
return CONSTANT_NAME.test(leaf.text) ? 'constant' : 'variable'
case 'special_variable_name':
return 'variable'
case 'test_operator':
return 'operator'
case 'file_descriptor':
return 'number'
case 'word':
if (parent?.type === 'command_name' || parent?.type === 'function_definition')
return 'function'
if (insideString(leaf) || parent?.type === 'case_item')
return 'string'
return /^\d+(?:\.\d+)*$/.test(leaf.text) ? 'number' : 'plain'
case '"':
return 'string'
}
if (FLOW_KEYWORDS.has(leaf.type))
return 'keyword'
return DECLARATION_KEYWORDS.has(leaf.type) ? 'storage' : 'plain'
}
/** bash / sh / zsh 脚本(VS Code 的 languageId 是 shellscript)的语言插件。@param wasmDir 语法包 .wasm 所在目录 */
export function createBashAdapter(wasmDir: string): LanguageAdapter {
return {
id: 'bash',
languageIds: ['shellscript'],
grammarPath: () => path.join(wasmDir, 'tree-sitter-bash.wasm'),
toBlocks: root => programBlocks(root),
tokenRole: bashTokenRole,
command: bashCommand,
symbols: root => bashSymbols(root),
displayNames: bashNameReader,
}
}
@@ -0,0 +1,31 @@
import type { DisplayNameFacts, DisplayNameReader, Node } from '../contract'
import type { NameCandidate } from './displayNames'
import { COMMENT_MARK, displayNameFacts } from './displayNames'
import { children } from './shared'
/**
* shell 脚本里的中文名:变量赋值(含 local / export / readonly 声明的)行尾的 `# @Comment 名字`。
* shell 没有注解、装饰器,也没有写参数说明的习惯,只认行尾记号。
*/
function bashDisplayNames(root: Node, source: string): DisplayNameFacts {
const candidates: NameCandidate[] = []
const comments: Node[] = []
const visit = (node: Node): void => {
if (node.type === 'comment')
comments.push(node)
else if (node.type === 'variable_assignment' && node.childForFieldName('name'))
candidates.push({ name: node.childForFieldName('name') as Node, trailing: true })
else if (node.type === 'variable_name' && node.parent?.type === 'declaration_command')
candidates.push({ name: node, trailing: true })
for (const child of children(node))
visit(child)
}
visit(root)
return displayNameFacts(candidates, comments, source)
}
export const bashNameReader: DisplayNameReader = {
files: { shellscript: '**/*.{sh,bash,zsh}' },
mentions: source => source.includes(COMMENT_MARK),
find: (root, source) => bashDisplayNames(root, source),
}
@@ -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,
})