diff --git a/vscode/esbuild.mjs b/vscode/esbuild.mjs index ec12dcb..f450862 100644 --- a/vscode/esbuild.mjs +++ b/vscode/esbuild.mjs @@ -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() { diff --git a/vscode/package.json b/vscode/package.json index e7344d9..b51178d 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -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", diff --git a/vscode/src/codeReader/commands/reading.ts b/vscode/src/codeReader/commands/reading.ts new file mode 100644 index 0000000..6265b17 --- /dev/null +++ b/vscode/src/codeReader/commands/reading.ts @@ -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 + values: Map + /** 不是选项的词(要处理的东西),照源码顺序。 */ + operands: CommandWord[] + /** 规则不认识的选项:照抄在译文后面。 */ + unknown: CommandWord[] +} + +/** + * 规则认哪些选项:单字母开关、带值的单字母选项,长选项换成的 key(单字母或长名字本身),带值的长选项(换过以后的 key)。 + * bare:第一个词可以不带 -(`tar xzf f`)。 + */ +export interface OptionSpec { + flags?: string + valued?: string + long?: Record + 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 + }, + } +} diff --git a/vscode/src/codeReader/commands/shellCommands.ts b/vscode/src/codeReader/commands/shellCommands.ts new file mode 100644 index 0000000..bda4a1e --- /dev/null +++ b/vscode/src/codeReader/commands/shellCommands.ts @@ -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 = { 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() + 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, +] diff --git a/vscode/src/codeReader/editor/documentMessage.test.ts b/vscode/src/codeReader/editor/documentMessage.test.ts index b69e405..727a42c 100644 --- a/vscode/src/codeReader/editor/documentMessage.test.ts +++ b/vscode/src/codeReader/editor/documentMessage.test.ts @@ -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) + 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) diff --git a/vscode/src/codeReader/fixtures/fetchRuntime.sh b/vscode/src/codeReader/fixtures/fetchRuntime.sh new file mode 100755 index 0000000..3dcafc1 --- /dev/null +++ b/vscode/src/codeReader/fixtures/fetchRuntime.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# 下载执行器自带的 git 与 python, 放到 src-tauri/resources/runtime//{git,python}。 +# +# scripts/fetch-runtime.sh +# +# 支持的 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..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 " >&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 diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 57b3018..403db26 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -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 { /** * 接入代码阅读:注册表、侧栏文件树(复制、文件注释、按审查工单只看 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), diff --git a/vscode/src/codeReader/languages/bash.test.ts b/vscode/src/codeReader/languages/bash.test.ts new file mode 100644 index 0000000..a63de77 --- /dev/null +++ b/vscode/src/codeReader/languages/bash.test.ts @@ -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 { + return withBlocks(adapter, 'shellscript', source, outline) +} + +function facts(source: string): Promise> { + 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 => 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() + } + }) +}) diff --git a/vscode/src/codeReader/languages/bash.ts b/vscode/src/codeReader/languages/bash.ts new file mode 100644 index 0000000..5757a3f --- /dev/null +++ b/vscode/src/codeReader/languages/bash.ts @@ -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 = { + 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() + 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() + 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, + } +} diff --git a/vscode/src/codeReader/languages/bashNames.ts b/vscode/src/codeReader/languages/bashNames.ts new file mode 100644 index 0000000..4952a01 --- /dev/null +++ b/vscode/src/codeReader/languages/bashNames.ts @@ -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), +} diff --git a/vscode/src/codeReader/translators/bashZh.test.ts b/vscode/src/codeReader/translators/bashZh.test.ts new file mode 100644 index 0000000..ba35d13 --- /dev/null +++ b/vscode/src/codeReader/translators/bashZh.test.ts @@ -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 { + const { blocks } = await readBlockViews(runtime, bash, bashZhTranslator, 'shellscript', source, { commandRule: commandRuleLookup(shellCommandRules, text => reports.push(text)) }) + return blocks +} + +async function readLines(source: string): Promise { + 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//{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', + ' 其他 | 打印 "用法: 脚本路径 "(打印到错误输出) ⏎ 退出,退出码 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('坏了')]) + }) +}) diff --git a/vscode/src/codeReader/translators/bashZh.ts b/vscode/src/codeReader/translators/bashZh.ts new file mode 100644 index 0000000..de1e33e --- /dev/null +++ b/vscode/src/codeReader/translators/bashZh.ts @@ -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 = { + '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 = { + '-f': ['文件', '存在'], + '-d': ['目录', '存在'], + '-e': ['', '存在'], + '-a': ['', '存在'], + '-L': ['', '是符号链接'], + '-h': ['', '是符号链接'], + '-z': ['', '为空'], + '-n': ['', '不为空'], + '-r': ['', '可读'], + '-w': ['', '可写'], + '-x': ['', '可执行'], + '-s': ['', '存在且不为空'], + '-p': ['', '是管道'], + '-S': ['', '是套接字'], + '-t': ['文件描述符', '是终端'], + '-v': ['变量', '设了值'], +} + +const BINARY_TESTS: Record = { + '-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 = { 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 = { + 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, +})