From 0665f358211b96cd1c3c78344b7bf3388fb90028 Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 28 Sep 2026 11:53:54 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E8=A7=84=E5=88=99=E5=8A=A0=E8=A3=85=E8=BD=AF=E4=BB=B6=E5=8C=85?= =?UTF-8?q?=E3=80=81=E8=A3=85=E4=BE=9D=E8=B5=96=E3=80=81=E8=B7=91=E7=A8=8B?= =?UTF-8?q?=E5=BA=8F=EF=BC=88apt-get=E3=80=81apk=E3=80=81pip=E3=80=81npm?= =?UTF-8?q?=E3=80=81pnpm=E3=80=81yarn=E3=80=81corepack=E3=80=81uv=E3=80=81?= =?UTF-8?q?uvicorn=EF=BC=89=E5=92=8C=E5=BB=BA=E7=94=A8=E6=88=B7=E3=80=81?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E3=80=81=E8=A3=85=E6=96=87=E4=BB=B6=E3=80=81?= =?UTF-8?q?=E6=94=B9=E5=B1=9E=E4=B8=BB=EF=BC=88useradd=E3=80=81adduser?= =?UTF-8?q?=E3=80=81addgroup=E3=80=81gpg=E3=80=81install=E3=80=81chown?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E9=80=89=E9=A1=B9=E5=86=99=E5=88=B0=204=20?= =?UTF-8?q?=E4=B8=AA=E4=BB=A5=E4=B8=8A=E6=88=96=E5=86=99=E4=BA=86=E5=87=A0?= =?UTF-8?q?=E9=81=8D=E6=97=B6=E6=8E=92=E6=88=90=E3=80=8C=E9=80=89=E9=A1=B9?= =?UTF-8?q?=20|=20=E6=84=8F=E6=80=9D=E3=80=8D=E8=A1=A8=EF=BC=9B=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E8=87=AA=E5=B7=B1=E8=A7=84=E5=88=99=E7=9A=84=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E4=BA=A4=E7=BB=99=E7=AE=A1=20*=20=E7=9A=84=E8=A7=84?= =?UTF-8?q?=E5=88=99=EF=BC=88=E5=8F=AA=E9=97=AE=20--version=20=E7=9A=84?= =?UTF-8?q?=E8=AF=BB=E6=88=90=E6=89=93=E5=8D=B0=E7=89=88=E6=9C=AC=EF=BC=89?= =?UTF-8?q?=EF=BC=9Bcurl=20-sS=20=E5=8F=AA=E5=86=99=E4=B8=8D=E6=89=93?= =?UTF-8?q?=E5=8D=B0=E8=BF=9B=E5=BA=A6=EF=BC=8Csource=20=E8=AF=BB=E6=88=90?= =?UTF-8?q?=E8=AF=BB=E5=85=A5=E9=87=8C=E9=9D=A2=E7=9A=84=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E5=92=8C=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/codeReader/commandRules.ts | 7 +- .../codeReader/commands/buildCommands.test.ts | 105 +++++ .../codeReader/commands/packageCommands.ts | 430 ++++++++++++++++++ vscode/src/codeReader/commands/reading.ts | 69 ++- .../src/codeReader/commands/shellCommands.ts | 7 +- .../src/codeReader/commands/systemCommands.ts | 144 ++++++ .../src/codeReader/translators/bashZh.test.ts | 39 +- 7 files changed, 764 insertions(+), 37 deletions(-) create mode 100644 vscode/src/codeReader/commands/buildCommands.test.ts create mode 100644 vscode/src/codeReader/commands/packageCommands.ts create mode 100644 vscode/src/codeReader/commands/systemCommands.ts diff --git a/vscode/src/codeReader/commandRules.ts b/vscode/src/codeReader/commandRules.ts index 9021cae..0ef7613 100644 --- a/vscode/src/codeReader/commandRules.ts +++ b/vscode/src/codeReader/commandRules.ts @@ -16,7 +16,10 @@ function guarded(rule: CommandRule, report: (message: string) => void): CommandR } } -/** 命令名 → 命令规则(SourceDocument.commandRule):几条规则都管同一个名字时先注册的算。 */ +/** 管没有自己的规则的命令的规则写的命令名。 */ +const ANY_COMMAND = '*' + +/** 命令名 → 命令规则(SourceDocument.commandRule):几条规则都管同一个名字时先注册的算;没有自己的规则的交给管 '*' 的。 */ export function commandRuleLookup(rules: readonly CommandRule[], report: (message: string) => void): (name: string) => CommandRule | undefined { const byName = new Map() for (const rule of rules) { @@ -26,5 +29,5 @@ export function commandRuleLookup(rules: readonly CommandRule[], report: (messag byName.set(name, safe) } } - return name => byName.get(name) + return name => byName.get(name) ?? byName.get(ANY_COMMAND) } diff --git a/vscode/src/codeReader/commands/buildCommands.test.ts b/vscode/src/codeReader/commands/buildCommands.test.ts new file mode 100644 index 0000000..706f3c0 --- /dev/null +++ b/vscode/src/codeReader/commands/buildCommands.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { readBlockViews } from '../blockView' +import { commandRuleLookup } from '../commandRules' +import { createBashAdapter } from '../languages/bash' +import { runtime, viewLines, WASM_DIR } from '../testSupport' +import { bashZhTranslator } from '../translators/bashZh' +import { packageCommandRules } from './packageCommands' +import { shellCommandRules } from './shellCommands' +import { systemCommandRules } from './systemCommands' + +const bash = createBashAdapter(WASM_DIR) +const RULES = [...shellCommandRules, ...systemCommandRules, ...packageCommandRules] + +/** 一条 shell 命令读成的样子(viewLines 的写法,行号是 1)。 */ +async function readLines(source: string): Promise { + const { blocks } = await readBlockViews(runtime, bash, bashZhTranslator, 'shellscript', `${source}\n`, { commandRule: commandRuleLookup(RULES, () => {}) }) + return viewLines(blocks) +} + +describe('装软件包、装依赖、跑程序的命令规则', () => { + it.each([ + ['apt-get update', '更新软件包列表'], + ['apt-get install -y --no-install-recommends curl git', '安装 curl、git(不装推荐的包、不用确认)'], + ['apt-get -y purge x', '卸载 x(连配置一起删、不用确认)'], + ['apt-get autoremove -y', '卸载不再需要的依赖(不用确认)'], + ['apt-get clean', '清掉下载的安装包'], + ['apt dist-upgrade -y', '升级已装的软件包(可以增删依赖、不用确认)'], + ['apk add --no-cache --virtual .deps gcc', '安装 gcc(不留索引缓存、记成一组 .deps)'], + ['apk del .deps', '卸载 .deps'], + ['pip install --no-cache-dir -r requirements.txt', 'pip 安装 requirements.txt 里列的包(不留缓存)'], + ['pip3 install -U pip -e .', 'pip 安装 pip、可编辑的 .(升级到最新)'], + ['pip uninstall -y x', 'pip 卸载 x(不用确认)'], + ['npm ci --omit=dev', 'npm 按 lock 文件装依赖(不装开发依赖)'], + ['npm install -g pnpm', 'npm 安装 pnpm(装到全局)'], + ['npm run build', 'npm 构建'], + ['npm run migrate', 'npm 运行脚本 migrate'], + ['pnpm install --frozen-lockfile --ignore-scripts', 'pnpm 装依赖(严格按 lock 文件、不跑安装脚本)'], + ['pnpm build', 'pnpm 构建'], + ['pnpm add -D typescript', 'pnpm 安装 typescript(记成开发依赖)'], + ['yarn install --immutable', 'yarn 装依赖(严格按 lock 文件)'], + ['yarn test', 'yarn 跑测试'], + ['corepack enable', '开启 corepack'], + ['corepack prepare pnpm@latest --activate', '准备 pnpm latest(并设为当前用的版本)'], + ['uv sync --frozen --no-dev', 'uv 按 lock 同步依赖(严格按 lock 文件,不改它、不装开发依赖)'], + ['uv pip install --system -r req.txt', 'uv 安装 req.txt 里列的包(装进系统的 Python)'], + ['uv run --no-sync uvicorn app:app --host 0.0.0.0 --port 8000 --reload', '用 uv 运行(不先同步依赖) uvicorn app:app,监听 0.0.0.0:8000(改了代码自动重启)'], + ['uv run python -m app', '用 uv 运行 python -m app'], + ])('%s', async (source, expected) => { + expect(await readLines(source)).toEqual([`command 1: ${expected}`]) + }) + + it('选项写到 4 个以上、或同一个选项写了几遍时排成「选项 | 意思」表,写了几遍的并成一行', async () => { + expect(await readLines('uv sync --frozen --no-dev --no-install-project --no-install-package a --no-install-package b')).toEqual([ + 'command 1: uv 按 lock 同步依赖 · 4 个选项', + ' --frozen | 严格按 lock 文件,不改它', + ' --no-dev | 不装开发依赖', + ' --no-install-project | 不装本项目', + ' --no-install-package × 2 | 不装这些包:a、b', + ]) + expect(await readLines('uv sync --no-install-package a --no-install-package b')).toEqual([ + 'command 1: uv 按 lock 同步依赖 · 1 个选项', + ' --no-install-package × 2 | 不装这些包:a、b', + ]) + }) +}) + +describe('装系统时常用的命令规则', () => { + it.each([ + ['useradd -m -s /bin/false appuser', '新建用户 appuser(建主目录、不能登录)'], + ['adduser --system --uid 1001 nextjs', '新建系统用户 nextjs(uid 1001)'], + ['adduser web www', '把 web 加进组 www'], + ['addgroup --system --gid 1001 nodejs', '新建系统用户组 nodejs(gid 1001)'], + ['groupadd -r -g 99 svc', '新建系统用户组 svc(gid 99)'], + ['gpg --dearmor -o /k.gpg', '转成二进制密钥 写到 /k.gpg'], + ['install -d /usr/share/keyrings', '建目录 /usr/share/keyrings'], + ['install -m 644 a.conf /etc/a.conf', '安装 a.conf 到 /etc/a.conf(权限 644)'], + ['chown -R app:app /x /y', '把 /x、/y 的属主改成 app:app(连同里面的东西)'], + ['pg_repack --version', '打印 pg_repack 的版本'], + ['pg_repack --dry-run', 'pg_repack --dry-run'], + ])('%s', async (source, expected) => { + expect(await readLines(source)).toEqual([`command 1: ${expected}`]) + }) +}) + +describe('命令名 → 命令规则', () => { + const rule = (id: string, commands: string[], read: () => { text: [] } | undefined = () => ({ text: [] })) => ({ id, label: id, languageIds: ['shellscript'], commands, read }) + + it('几条规则管同一个名字时先注册的算;没有自己的规则的交给管 * 的', () => { + const lookup = commandRuleLookup([rule('a', ['rm']), rule('b', ['rm', 'ls']), rule('any', ['*'])], () => {}) + expect(lookup('rm')?.id).toBe('a') + expect(lookup('ls')?.id).toBe('b') + expect(lookup('foo')?.id).toBe('any') + expect(commandRuleLookup([rule('a', ['rm'])], () => {})('foo')).toBeUndefined() + }) + + it('规则出错时记下来、当它没读', () => { + const reports: string[] = [] + const broken = rule('broken', ['rm'], () => { + throw new Error('坏了') + }) + const read = commandRuleLookup([broken], text => reports.push(text))('rm')?.read + expect(read?.({ name: { node: {} as never, literal: 'rm' }, arguments: [], redirects: [] }, { displayLanguage: 'zh', translate: () => [], command: () => undefined })).toBeUndefined() + expect(reports).toEqual([expect.stringContaining('坏了')]) + }) +}) diff --git a/vscode/src/codeReader/commands/packageCommands.ts b/vscode/src/codeReader/commands/packageCommands.ts new file mode 100644 index 0000000..0a5b961 --- /dev/null +++ b/vscode/src/codeReader/commands/packageCommands.ts @@ -0,0 +1,430 @@ +import type { CommandContext, CommandReading, CommandRule, CommandWord, Inline } from '../contract' +import type { OptionMeaning, OptionNote, Options, OptionSpec, OptionValue } from './reading' +import { explained, hint, literalText, notes, operandsText, optionNotes, parseOptions, plain, shellCommand, space, valueText, verb, withUnknown } from './reading' + +/** 装软件包、装依赖、跑程序的命令(apt-get、apk、pip、npm、pnpm、yarn、corepack、uv、uvicorn),Dockerfile 的 RUN、CMD 里常见。 */ + +/** 第一个不是选项的词是子命令(`apt-get -y install x` 的 install);剩下的词(选项和别的)照原样交给规则认。 */ +function subcommand(args: readonly CommandWord[]): { name?: string, rest: CommandWord[] } { + const at = args.findIndex(arg => arg.literal !== undefined && !arg.literal.startsWith('-')) + return at < 0 ? { rest: [...args] } : { name: args[at].literal, rest: args.filter((_, i) => i !== at) } +} + +/** 选项的值读成的意思:「前缀 值、值」。 */ +function withValue(before: string, context: CommandContext, after = ''): (values: OptionValue[]) => Inline[] { + return values => [hint(before), ...values.flatMap((value, i) => [...(i > 0 ? [hint('、')] : []), ...valueText(value, context)]), ...(after ? [hint(after)] : [])] +} + +/** 「工具 动作 东西」:工具名按函数着色(pip、npm……),东西照常翻。 */ +function toolHead(tool: string, action: string, targets: readonly CommandWord[], context: CommandContext): Inline[] { + return [verb(`${tool} ${action}`), ...(targets.length > 0 ? [space(), ...operandsText(targets, context)] : [])] +} + +/** 读出来的一行连着选项的意思;extra 是子命令本身带的意思(`apt-get purge` 连配置一起删),排在选项前面。 */ +function read(head: Inline[], options: Options, order: readonly OptionMeaning[], extra?: OptionNote): CommandReading { + return explained(head, [...(extra ? [extra] : []), ...optionNotes(options, order)], options) +} + +/** 子命令本身带的意思,写法是子命令。 */ +function implied(subcommand: string, meaning: string): OptionNote { + return { flag: subcommand, times: 1, meaning: [hint(meaning)] } +} + +// ─── 系统软件包 ────────────────────────────────────────────────────────────── + +const APT_SPEC: OptionSpec = { + flags: 'yqf', + long: { 'yes': 'y', 'assume-yes': 'y', 'quiet': 'q', 'fix-broken': 'f', 'no-install-recommends': 'no-install-recommends', 'no-install-suggests': 'no-install-suggests', 'reinstall': 'reinstall', 'purge': 'purge' }, +} +const APT_MEANINGS: OptionMeaning[] = [ + ['no-install-recommends', '不装推荐的包'], + ['no-install-suggests', '不装建议的包'], + ['reinstall', '重新安装'], + ['purge', '连配置一起删'], + ['f', '修好坏掉的依赖'], + ['y', '不用确认'], + ['q', '少打印'], +] + +/** apt-get / apt:更新软件包列表、安装、卸载、升级、清缓存。 */ +const apt = shellCommand('apt-get', ['apt-get', 'apt'], (command, context) => { + const { name, rest } = subcommand(command.arguments) + const options = parseOptions(rest, APT_SPEC) + const packages = options.operands + switch (name) { + case 'update': + return read([verb('更新软件包列表')], options, APT_MEANINGS) + case 'install': + return read([verb('安装'), space(), ...operandsText(packages, context)], options, APT_MEANINGS) + case 'remove': + return read([verb('卸载'), space(), ...operandsText(packages, context)], options, APT_MEANINGS) + case 'purge': + return read([verb('卸载'), space(), ...operandsText(packages, context)], options, APT_MEANINGS, implied(name, '连配置一起删')) + case 'autoremove': + return read([verb('卸载不再需要的依赖')], options, APT_MEANINGS) + case 'upgrade': + return read([verb('升级已装的软件包')], options, APT_MEANINGS) + case 'dist-upgrade': + case 'full-upgrade': + return read([verb('升级已装的软件包')], options, APT_MEANINGS, implied(name, '可以增删依赖')) + case 'clean': + return read([verb('清掉下载的安装包')], options, APT_MEANINGS) + case 'autoclean': + return read([verb('清掉过时的安装包')], options, APT_MEANINGS) + default: + return undefined + } +}) + +/** apk(Alpine):安装、卸载、更新索引、升级。 */ +const apk = shellCommand('apk', ['apk'], (command, context) => { + const { name, rest } = subcommand(command.arguments) + const options = parseOptions(rest, { flags: 'Uq', valued: 't', long: { 'no-cache': 'no-cache', 'virtual': 't', 'update': 'U', 'upgrade': 'upgrade', 'quiet': 'q' } }) + const order: OptionMeaning[] = [ + ['no-cache', '不留索引缓存'], + ['U', '先更新索引'], + ['upgrade', '已装的顺便升级'], + ['t', withValue('记成一组 ', context)], + ['q', '少打印'], + ] + switch (name) { + case 'add': + return read([verb('安装'), space(), ...operandsText(options.operands, context)], options, order) + case 'del': + return read([verb('卸载'), space(), ...operandsText(options.operands, context)], options, order) + case 'update': + return read([verb('更新软件包索引')], options, order) + case 'upgrade': + return read([verb('升级已装的软件包')], options, order) + default: + return undefined + } +}) + +// ─── 语言的包管理 ──────────────────────────────────────────────────────────── + +/** pip / pip3:安装(包、-r 列表里的、-e 可编辑的)、卸载。 */ +const pip = shellCommand('pip', ['pip', 'pip3'], (command, context) => { + const { name, rest } = subcommand(command.arguments) + const options = parseOptions(rest, { + flags: 'Uqy', + valued: 'rei', + long: { 'requirement': 'r', 'editable': 'e', 'index-url': 'i', 'extra-index-url': 'extra-index-url', 'upgrade': 'U', 'quiet': 'q', 'yes': 'y', 'no-cache-dir': 'no-cache-dir', 'user': 'user', 'no-deps': 'no-deps', 'pre': 'pre', 'break-system-packages': 'break-system-packages' }, + longValued: ['extra-index-url'], + }) + const order: OptionMeaning[] = [ + ['no-cache-dir', '不留缓存'], + ['U', '升级到最新'], + ['user', '装到用户目录'], + ['no-deps', '不装依赖'], + ['pre', '也要预发布版'], + ['break-system-packages', '可以装进系统的 Python'], + ['i', withValue('从 ', context, ' 下载')], + ['extra-index-url', withValue('另外从 ', context, ' 找')], + ['y', '不用确认'], + ['q', '少打印'], + ] + if (name === 'uninstall') + return read(toolHead('pip', '卸载', options.operands, context), options, order) + if (name !== 'install') + return undefined + const lists = options.lists.get('r') ?? [] + const editable = options.lists.get('e') ?? [] + const parts: Inline[][] = [ + ...(options.operands.length > 0 ? [operandsText(options.operands, context)] : []), + ...lists.map(list => [...valueText(list, context), space(), hint('里列的包')]), + ...editable.map(path => [hint('可编辑的 '), ...valueText(path, context)]), + ] + const targets = parts.flatMap((part, i) => [...(i > 0 ? [plain('、')] : []), ...part]) + return read([verb('pip 安装'), ...(targets.length > 0 ? [space(), ...targets] : [])], options, order.filter(([key]) => key !== 'r' && key !== 'e')) +}) + +/** 常见的脚本名读成要做的事(pnpm build → pnpm 构建),别的写「运行脚本 X」。 */ +const SCRIPTS: Record = { build: '构建', test: '跑测试', dev: '启动开发服务', start: '启动', lint: '检查代码', format: '格式化代码', typecheck: '检查类型' } + +function scriptHead(tool: string, script: CommandWord, context: CommandContext): Inline[] { + const known = script.literal !== undefined ? SCRIPTS[script.literal] : undefined + return known ? [verb(`${tool} ${known}`)] : [verb(`${tool} 运行脚本`), space(), ...context.translate(script.node)] +} + +/** npm / pnpm / yarn 装依赖时的选项。 */ +const NODE_INSTALL_SPEC: OptionSpec = { + flags: 'gDPr', + long: { + 'frozen-lockfile': 'frozen-lockfile', + 'immutable': 'frozen-lockfile', + 'prod': 'P', + 'production': 'P', + 'omit': 'omit', + 'ignore-scripts': 'ignore-scripts', + 'no-audit': 'no-audit', + 'no-fund': 'no-fund', + 'prefer-offline': 'prefer-offline', + 'offline': 'offline', + 'global': 'g', + 'save-dev': 'D', + 'recursive': 'r', + }, + longValued: ['omit'], +} + +function nodeInstallMeanings(context: CommandContext): OptionMeaning[] { + return [ + ['frozen-lockfile', '严格按 lock 文件'], + ['P', '不装开发依赖'], + ['omit', values => values.some(value => value.text === 'dev') ? [hint('不装开发依赖')] : withValue('不装 ', context, ' 依赖')(values)], + ['ignore-scripts', '不跑安装脚本'], + ['prefer-offline', '有缓存就不联网'], + ['offline', '不联网'], + ['no-audit', '不做安全检查'], + ['no-fund', '不打印募捐信息'], + ['g', '装到全局'], + ['D', '记成开发依赖'], + ['r', '所有子项目'], + ] +} + +/** npm:装依赖(ci 按 lock 文件)、安装包、运行脚本。 */ +const npm = shellCommand('npm', ['npm'], (command, context) => { + const { name, rest } = subcommand(command.arguments) + const options = parseOptions(rest, NODE_INSTALL_SPEC) + const order = nodeInstallMeanings(context) + const [first] = options.operands + switch (name) { + case 'ci': + return read([verb('npm 按 lock 文件装依赖')], options, order) + case 'install': + case 'i': + return read(options.operands.length > 0 ? toolHead('npm', '安装', options.operands, context) : [verb('npm 装依赖')], options, order) + case 'run': + case 'run-script': + return first ? read(scriptHead('npm', first, context), options, []) : undefined + case 'test': + case 'start': + return [verb(`npm ${SCRIPTS[name]}`)] + case 'prune': + return read([verb('npm 删掉用不上的依赖')], options, order) + default: + return undefined + } +}) + +/** pnpm 自己的子命令;别的都是 package.json 里的脚本(pnpm build = pnpm run build)。 */ +const PNPM_COMMANDS = new Set(['install', 'i', 'add', 'run', 'fetch', 'dlx', 'exec', 'prune', 'store', 'remove', 'rm', 'update', 'up', 'deploy']) + +/** pnpm:装依赖、安装包、运行脚本、先下载依赖、临时运行。 */ +const pnpm = shellCommand('pnpm', ['pnpm'], (command, context) => { + const { name, rest } = subcommand(command.arguments) + const options = parseOptions(rest, NODE_INSTALL_SPEC) + const order = nodeInstallMeanings(context) + const [first] = options.operands + if (name && !PNPM_COMMANDS.has(name)) { + const script = command.arguments.find(arg => arg.literal === name) + return script ? read(scriptHead('pnpm', script, context), options, []) : undefined + } + switch (name) { + case 'install': + case 'i': + return read([verb('pnpm 装依赖')], options, order) + case 'add': + return read(toolHead('pnpm', '安装', options.operands, context), options, order) + case 'run': + return first ? read(scriptHead('pnpm', first, context), options, []) : undefined + case 'fetch': + return read([verb('pnpm 先把依赖下载到仓库')], options, order) + case 'dlx': + return first ? [verb('pnpm 临时下载运行'), space(), ...operandsText(options.operands, context)] : undefined + case 'exec': + return first ? [verb('pnpm 运行'), space(), ...operandsText(options.operands, context)] : undefined + default: + return undefined + } +}) + +/** yarn:装依赖(光写 yarn 也是)、安装包、运行脚本、清缓存。 */ +const yarn = shellCommand('yarn', ['yarn'], (command, context) => { + const { name, rest } = subcommand(command.arguments) + const options = parseOptions(rest, NODE_INSTALL_SPEC) + const order = nodeInstallMeanings(context) + const [first] = options.operands + switch (name) { + case undefined: + case 'install': + return read([verb('yarn 装依赖')], options, order) + case 'add': + return read(toolHead('yarn', '安装', options.operands, context), options, order) + case 'run': + return first ? read(scriptHead('yarn', first, context), options, []) : undefined + case 'cache': + return first?.literal === 'clean' ? [verb('清掉 yarn 缓存')] : undefined + default: { + const script = command.arguments.find(arg => arg.literal === name) + return script ? read(scriptHead('yarn', script, context), options, []) : undefined + } + } +}) + +/** `pnpm@latest`、`yarn@4.1.0`:工具名按函数着色,版本照写。 */ +function toolVersion(word: CommandWord, context: CommandContext): Inline[] { + const at = word.literal?.indexOf('@') ?? -1 + if (!word.literal || at <= 0) + return context.translate(word.node) + return [{ text: word.literal.slice(0, at), role: 'function' }, space(), literalText(word.literal.slice(at + 1))] +} + +/** corepack:开启 / 关掉、准备某个版本的包管理器、改用某个版本。 */ +const corepack = shellCommand('corepack', ['corepack'], (command, context) => { + const { name, rest } = subcommand(command.arguments) + const options = parseOptions(rest, { long: { activate: 'activate', global: 'g' }, flags: 'g' }) + const [tool] = options.operands + switch (name) { + case 'enable': + return [verb('开启 corepack')] + case 'disable': + return [verb('关掉 corepack')] + case 'prepare': + return tool ? read([verb('准备'), space(), ...toolVersion(tool, context)], options, [['activate', '并设为当前用的版本']]) : undefined + case 'use': + return tool ? [verb('改用'), space(), ...toolVersion(tool, context)] : undefined + case 'install': + return tool ? [verb('装好'), space(), ...toolVersion(tool, context)] : undefined + default: + return undefined + } +}) + +// ─── uv ────────────────────────────────────────────────────────────────────── + +const UV_SPEC: OptionSpec = { + flags: 'q', + valued: 'r', + long: { + 'frozen': 'frozen', + 'locked': 'locked', + 'no-dev': 'no-dev', + 'no-install-project': 'no-install-project', + 'no-install-workspace': 'no-install-workspace', + 'no-install-package': 'no-install-package', + 'all-extras': 'all-extras', + 'extra': 'extra', + 'group': 'group', + 'no-editable': 'no-editable', + 'compile-bytecode': 'compile-bytecode', + 'python': 'python', + 'no-cache': 'no-cache', + 'system': 'system', + 'no-sync': 'no-sync', + 'requirement': 'r', + 'quiet': 'q', + }, + longValued: ['no-install-package', 'extra', 'group', 'python'], +} + +function uvMeanings(context: CommandContext): OptionMeaning[] { + return [ + ['frozen', '严格按 lock 文件,不改它'], + ['locked', '检查 lock 文件是最新的'], + ['no-dev', '不装开发依赖'], + ['no-install-project', '不装本项目'], + ['no-install-workspace', '不装工作区里的包'], + ['no-install-package', values => [hint(values.length > 1 ? '不装这些包:' : '不装 '), ...values.flatMap((value, i) => [...(i > 0 ? [plain('、')] : []), ...valueText(value, context)])]], + ['all-extras', '装上全部可选依赖'], + ['extra', withValue('带上可选依赖 ', context)], + ['group', withValue('带上依赖组 ', context)], + ['no-editable', '不用可编辑方式装'], + ['compile-bytecode', '预先编译字节码'], + ['python', withValue('用 Python ', context)], + ['system', '装进系统的 Python'], + ['no-sync', '不先同步依赖'], + ['no-cache', '不用缓存'], + ['q', '少打印'], + ] +} + +/** uv run 自己的带值选项:跳过它们找到要运行的命令。 */ +const UV_RUN_VALUED = new Set(['--with', '--python', '--env-file', '--extra', '--group', '--package', '--directory', '--project']) + +/** `uv run [选项] 命令 …`:用 uv 运行后面那条命令(它有规则就照规则读)。 */ +function uvRun(args: readonly CommandWord[], context: CommandContext): CommandReading | undefined { + let at = 0 + while (args[at]?.literal?.startsWith('-')) { + const literal = args[at].literal ?? '' + at += UV_RUN_VALUED.has(literal) ? 2 : 1 + } + const inner = args[at] + if (!inner) + return undefined + const options = parseOptions(args.slice(0, at), UV_SPEC) + const innerArgs = args.slice(at + 1) + const reading = context.command(inner, innerArgs) + const text = reading?.text ?? [{ text: inner.literal ?? inner.node.text, role: 'function' }, ...innerArgs.flatMap(arg => [space(), ...context.translate(arg.node)])] + // uv run 自己的选项紧跟在「用 uv 运行」后面,后面那条命令的意思照旧写在它后面 + const own = notes(optionNotes(options, uvMeanings(context)).map(note => note.meaning)) + return { text: withUnknown([verb('用 uv 运行'), ...own, space(), ...text], options), ...(reading?.table && { table: reading.table }) } +} + +/** uv:同步依赖、pip 装包、运行命令、更新 lock、建虚拟环境、添加依赖、装工具、装 Python。 */ +const uv = shellCommand('uv', ['uv'], (command, context) => { + const [first, ...after] = command.arguments + if (first?.literal === 'run') + return uvRun(after, context) + const { name, rest } = subcommand(command.arguments) + const { name: second, rest: tail } = subcommand(rest) + const options = parseOptions(rest, UV_SPEC) + const order = uvMeanings(context) + switch (name) { + case 'sync': + return read([verb('uv 按 lock 同步依赖')], options, order) + case 'lock': + return read([verb('uv 更新 lock 文件')], options, order) + case 'venv': + return read(toolHead('uv', '建虚拟环境', options.operands, context), options, order) + case 'add': + return read(toolHead('uv', '添加依赖', options.operands, context), options, order) + case 'pip': { + const pipOptions = parseOptions(tail, UV_SPEC) + const lists = pipOptions.lists.get('r') ?? [] + if (second === 'install') { + const targets = [...(pipOptions.operands.length > 0 ? [operandsText(pipOptions.operands, context)] : []), ...lists.map(list => [...valueText(list, context), space(), hint('里列的包')])] + return read([verb('uv 安装'), space(), ...targets.flatMap((part, i) => [...(i > 0 ? [plain('、')] : []), ...part])], pipOptions, order) + } + if (second === 'sync') + return read(toolHead('uv', '按列表同步环境', pipOptions.operands, context), pipOptions, order) + return undefined + } + case 'tool': + return second === 'install' ? read(toolHead('uv', '安装工具', parseOptions(tail, UV_SPEC).operands, context), options, order) : undefined + case 'python': + return second === 'install' ? read(toolHead('uv', '安装 Python', parseOptions(tail, UV_SPEC).operands, context), options, order) : undefined + default: + return undefined + } +}) + +/** uvicorn:跑一个 ASGI 应用,写监听的地址、端口,别的选项写成意思。 */ +const uvicorn = shellCommand('uvicorn', ['uvicorn'], (command, context) => { + const options = parseOptions(command.arguments, { + long: { 'host': 'host', 'port': 'port', 'reload': 'reload', 'workers': 'workers', 'proxy-headers': 'proxy-headers', 'log-level': 'log-level', 'factory': 'factory', 'root-path': 'root-path' }, + longValued: ['host', 'port', 'workers', 'log-level', 'root-path'], + }) + const [app] = options.operands + if (!app) + return undefined + const host = options.values.get('host') + const port = options.values.get('port') + const portText = port ? [{ text: port.text, role: 'number' as const }] : [] + const listen: Inline[] = host + ? [hint(',监听 '), ...valueText(host, context), ...(port ? [hint(':'), ...portText] : [])] + : port ? [hint(',监听端口 '), ...portText] : [] + return read([{ text: 'uvicorn', role: 'function' }, space(), ...context.translate(app.node), ...listen], options, [ + ['reload', '改了代码自动重启'], + ['workers', withValue('', context, ' 个进程')], + ['proxy-headers', '认代理转来的请求头'], + ['log-level', withValue('日志级别 ', context)], + ['factory', '应用由工厂函数建'], + ['root-path', withValue('挂在 ', context, ' 下面')], + ]) +}) + +/** 装软件包、装依赖、跑程序的命令。 */ +export const packageCommandRules: CommandRule[] = [apt, apk, pip, npm, pnpm, yarn, corepack, uv, uvicorn] diff --git a/vscode/src/codeReader/commands/reading.ts b/vscode/src/codeReader/commands/reading.ts index 6265b17..ce1a58d 100644 --- a/vscode/src/codeReader/commands/reading.ts +++ b/vscode/src/codeReader/commands/reading.ts @@ -1,7 +1,10 @@ -import type { CommandContext, CommandFacts, CommandReading, CommandRule, CommandWord, Inline, Segment } from '../contract' +import type { CommandContext, CommandFacts, CommandReading, CommandRule, CommandWord, Inline, PropertyItem, Segment } from '../contract' -/** 内置命令规则管的语言:VS Code 里 bash / sh / zsh 都是 shellscript。 */ -const SHELL_LANGUAGES = ['shellscript'] +/** + * 内置命令规则管的语言:VS Code 里 bash / sh / zsh 都是 shellscript;Dockerfile 数组写法的命令(`CMD ["node", "server.js"]`) + * 不经过 shell,执行的还是同一些程序。 + */ +const SHELL_LANGUAGES = ['shellscript', 'dockerfile'] /** 动作(删除、下载……),按函数着色。 */ export function verb(text: string): Segment { @@ -47,7 +50,12 @@ export interface OptionValue { export interface Options { flags: Set + /** 带值的选项的值;写了几遍的是最后一个,全部在 lists 里。 */ values: Map + lists: Map + /** 认出的选项在源码里怎么写的(第一次的写法:`-y`、`--no-dev`),写了几遍。 */ + spelled: Map + counts: Map /** 不是选项的词(要处理的东西),照源码顺序。 */ operands: CommandWord[] /** 规则不认识的选项:照抄在译文后面。 */ @@ -68,7 +76,16 @@ export interface OptionSpec { /** 按 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 options: Options = { flags: new Set(), values: new Map(), lists: new Map(), spelled: new Map(), counts: new Map(), operands: [], unknown: [] } + const seen = (key: string, spelling: string): void => { + if (!options.spelled.has(key)) + options.spelled.set(key, spelling) + options.counts.set(key, (options.counts.get(key) ?? 0) + 1) + } + const setValue = (key: string, value: OptionValue): void => { + options.values.set(key, value) + options.lists.set(key, [...options.lists.get(key) ?? [], value]) + } const flags = spec.flags ?? '' const valued = spec.valued ?? '' let ended = false @@ -94,10 +111,11 @@ export function parseOptions(args: readonly CommandWord[], spec: OptionSpec): Op options.unknown.push(arg) continue } + seen(key, `--${name}`) if (spec.longValued?.includes(key) || valued.includes(key)) { const value = inline !== undefined ? { text: inline } : next() if (value) - options.values.set(key, value) + setValue(key, value) } else { options.flags.add(key) @@ -117,6 +135,7 @@ export function parseOptions(args: readonly CommandWord[], spec: OptionSpec): Op } for (let at = 0; at < cluster.length; at++) { const letter = cluster[at] + seen(letter, `${text.startsWith('-') ? '-' : ''}${letter}`) if (!valued.includes(letter)) { options.flags.add(letter) continue @@ -124,7 +143,7 @@ export function parseOptions(args: readonly CommandWord[], spec: OptionSpec): Op const rest = cluster.slice(at + 1) const value = rest ? { text: rest } : next() if (value) - options.values.set(letter, value) + setValue(letter, value) break } } @@ -141,6 +160,44 @@ export function operandsText(operands: readonly CommandWord[], context: CommandC return joined(operands.map(operand => context.translate(operand.node)), plain('、')) } +/** 一个认出的选项的意思:源码里怎么写的、写了几遍,和它的意思(带值的连着值)。 */ +export interface OptionNote { + flag: string + times: number + meaning: Inline[] +} + +/** 意思:选项的 key → 意思(固定的一句,或按这个选项写的各个值拼出来的)。 */ +export type OptionMeaning = [key: string, meaning: string | ((values: OptionValue[]) => Inline[])] + +/** 按 order 的顺序给写了的选项配上意思。 */ +export function optionNotes(options: Options, order: readonly OptionMeaning[]): OptionNote[] { + return order.flatMap(([key, meaning]): OptionNote[] => { + const flag = options.spelled.get(key) + if (flag === undefined || !(options.flags.has(key) || options.values.has(key))) + return [] + const text = typeof meaning === 'string' ? [hint(meaning)] : meaning(options.lists.get(key) ?? []) + return [{ flag, times: options.counts.get(key) ?? 1, meaning: text }] + }) +} + +/** 选项写到这么多个(同一个写几遍算一个)以上就排成表。 */ +const TABLE_OPTIONS = 4 + +/** + * 读出来的一行,连着选项的意思:选项少时括号里写意思(「(不装推荐的包、不用确认)」);有 4 个以上、或同一个选项写了几遍时 + * 这一行后面写「· N 个选项」,意思排成表(选项 | 意思),写了几遍的并成一行、选项后面写「× 几」。不认识的选项照抄在最后。 + */ +export function explained(head: Inline[], items: readonly OptionNote[], options?: Options): CommandReading { + if (items.length < TABLE_OPTIONS && items.every(item => item.times === 1)) + return { text: options ? withUnknown([...head, ...notes(items.map(item => item.meaning))], options) : [...head, ...notes(items.map(item => item.meaning))] } + const rows: PropertyItem[] = items.map(item => ({ + cells: [[literalText(item.flag), ...(item.times > 1 ? [hint(` × ${item.times}`)] : [])], item.meaning], + })) + const text = [...head, space(), hint(`· ${items.length} 个选项`)] + return { text: options ? withUnknown(text, options) : text, table: { columns: ['选项', '意思'], items: rows } } +} + /** 规则不认识的选项照抄在后面。 */ export function withUnknown(text: Inline[], options: Options): Inline[] { return options.unknown.length > 0 ? [...text, ...options.unknown.flatMap(arg => [space(), plain(arg.node.text)])] : text diff --git a/vscode/src/codeReader/commands/shellCommands.ts b/vscode/src/codeReader/commands/shellCommands.ts index bda4a1e..779be1b 100644 --- a/vscode/src/codeReader/commands/shellCommands.ts +++ b/vscode/src/codeReader/commands/shellCommands.ts @@ -50,10 +50,13 @@ const curl = shellCommand('curl', ['curl'], (command, context) => { const value = options.values.get(key) return value ? [[hint(before), ...valueText(value, context), ...(after ? [hint(after)] : [])]] : [] } + // -sS 一起写是「安静但出错照样说」,只写「不打印进度」 + const silent = options.flags.has('s') return withUnknown([...head, ...notes([ + ...(silent ? [[hint('不打印进度')]] : []), ...meanings(options, [['f', '失败就报错'], ['L', '跟着跳转走']]), ...valued('retry', '最多重试 ', ' 次'), - ...meanings(options, [['O', '存成网址里的文件名'], ['s', '不显示进度'], ['S', '出错时显示错误'], ['k', '不校验证书'], ['I', '只取响应头'], ['v', '打印通信过程'], ['i', '连响应头一起打印']]), + ...meanings(options, [['O', '存成网址里的文件名'], ...(silent ? [] : [['S', '出错时显示错误'] as [string, string]]), ['k', '不校验证书'], ['I', '只取响应头'], ['v', '打印通信过程'], ['i', '连响应头一起打印']]), ...valued('X', '用 ', ' 方法'), ...valued('H', '带请求头 '), ...valued('d', '发送 '), @@ -519,7 +522,7 @@ 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)] : [])] + return [verb('读入'), space(), ...context.translate(file.node), space(), hint('里的变量和函数'), ...(args.length > 0 ? [word(',参数'), space(), ...operandsText(args, context)] : [])] }) const read = shellCommand('read', ['read'], (command, context) => { diff --git a/vscode/src/codeReader/commands/systemCommands.ts b/vscode/src/codeReader/commands/systemCommands.ts new file mode 100644 index 0000000..f9415a8 --- /dev/null +++ b/vscode/src/codeReader/commands/systemCommands.ts @@ -0,0 +1,144 @@ +import type { CommandContext, CommandRule, Inline } from '../contract' +import type { OptionMeaning, OptionValue } from './reading' +import { explained, hint, operandsText, optionNotes, parseOptions, shellCommand, space, valueText, verb, word } from './reading' + +/** 用户、用户组、权限、密钥这些装系统时常用的命令(Dockerfile 里建运行用户、加软件源时常见)。 */ + +/** 登录 shell 是这几个的用户不能登录。 */ +const NO_LOGIN = new Set(['/bin/false', '/usr/sbin/nologin', '/sbin/nologin', '/usr/bin/false']) + +/** 选项的值读成的意思:「前缀 值」。 */ +function withValue(before: string, context: CommandContext, after = ''): (values: OptionValue[]) => Inline[] { + return values => [hint(before), ...values.flatMap((value, i) => [...(i > 0 ? [hint('、')] : []), ...valueText(value, context)]), ...(after ? [hint(after)] : [])] +} + +/** 登录 shell:不能登录的写「不能登录」,别的写「登录 shell 是 X」。 */ +function loginShell(context: CommandContext): (values: OptionValue[]) => Inline[] { + return (values) => { + const shell = values.at(-1) + return shell && NO_LOGIN.has(shell.text) ? [hint('不能登录')] : withValue('登录 shell 是 ', context)(values) + } +} + +/** useradd:新建用户(-r 是系统用户),选项写成意思。 */ +const useradd = shellCommand('useradd', ['useradd'], (command, context) => { + const options = parseOptions(command.arguments, { + flags: 'mMrUN', + valued: 'sugGdcke', + long: { 'create-home': 'm', 'no-create-home': 'M', 'system': 'r', 'shell': 's', 'uid': 'u', 'gid': 'g', 'groups': 'G', 'home-dir': 'd', 'comment': 'c', 'user-group': 'U', 'no-user-group': 'N' }, + }) + const [name] = options.operands + if (!name) + return undefined + const order: OptionMeaning[] = [ + ['u', withValue('uid ', context)], + ['g', withValue('主组 ', context)], + ['G', withValue('加进组 ', context)], + ['m', '建主目录'], + ['M', '不建主目录'], + ['d', withValue('主目录 ', context)], + ['s', loginShell(context)], + ['c', withValue('备注 ', context)], + ] + return explained([verb(options.flags.has('r') ? '新建系统用户' : '新建用户'), space(), ...context.translate(name.node)], optionNotes(options, order), options) +}) + +/** + * adduser:Debian 的(`--system --uid 1001 name`)和 Alpine / BusyBox 的(`-S -u 1001 -G group name`)都认; + * 写了两个名字(`adduser user group`)是把已有的用户加进组。 + */ +const adduser = shellCommand('adduser', ['adduser'], (command, context) => { + const options = parseOptions(command.arguments, { + flags: 'SDH', + valued: 'uGhsgk', + long: { 'system': 'S', 'disabled-password': 'D', 'no-create-home': 'H', 'uid': 'u', 'gid': 'gid', 'ingroup': 'G', 'home': 'h', 'shell': 's', 'gecos': 'g' }, + longValued: ['gid'], + }) + const [name, group] = options.operands + if (!name) + return undefined + if (group) + return [verb('把'), space(), ...context.translate(name.node), space(), verb('加进组'), space(), ...context.translate(group.node)] + const order: OptionMeaning[] = [ + ['u', withValue('uid ', context)], + ['gid', withValue('gid ', context)], + ['G', withValue('主组 ', context)], + ['D', '不设密码'], + ['H', '不建主目录'], + ['h', withValue('主目录 ', context)], + ['s', loginShell(context)], + ] + return explained([verb(options.flags.has('S') ? '新建系统用户' : '新建用户'), space(), ...context.translate(name.node)], optionNotes(options, order), options) +}) + +/** addgroup / groupadd:新建用户组(系统组写「系统用户组」);BusyBox 的 `addgroup user group` 是把用户加进组。 */ +const addgroup = shellCommand('addgroup', ['addgroup', 'groupadd'], (command, context) => { + const options = parseOptions(command.arguments, { flags: 'Sr', valued: 'g', long: { system: 'S', gid: 'g' } }) + const [name, group] = options.operands + if (!name) + return undefined + if (group) + return [verb('把'), space(), ...context.translate(name.node), space(), verb('加进组'), space(), ...context.translate(group.node)] + const system = options.flags.has('S') || options.flags.has('r') + return explained([verb(system ? '新建系统用户组' : '新建用户组'), space(), ...context.translate(name.node)], optionNotes(options, [['g', withValue('gid ', context)]]), options) +}) + +/** gpg:转成二进制密钥(--dearmor)、导入密钥、校验签名;别的写法照抄。 */ +const gpg = shellCommand('gpg', ['gpg'], (command, context) => { + const options = parseOptions(command.arguments, { + flags: 'q', + valued: 'o', + long: { 'dearmor': 'dearmor', 'import': 'import', 'verify': 'verify', 'batch': 'batch', 'yes': 'yes', 'output': 'o', 'recv-keys': 'recv-keys', 'keyserver': 'keyserver', 'quiet': 'q' }, + longValued: ['keyserver'], + }) + const output = options.values.get('o') + const to = output ? [space(), word('写到'), space(), ...valueText(output, context)] : [] + const from = options.operands.length > 0 ? [space(), ...operandsText(options.operands, context)] : [] + if (options.flags.has('dearmor')) + return [verb('转成二进制密钥'), ...from, ...to] + if (options.flags.has('import')) + return [verb('导入密钥'), ...from] + if (options.flags.has('recv-keys')) + return [verb('从密钥服务器取密钥'), ...from] + if (options.flags.has('verify')) + return [verb('校验签名'), ...from] + return undefined +}) + +/** install:-d 是建目录;别的是把文件装到目标位置(带权限、属主)。 */ +const install = shellCommand('install', ['install'], (command, context) => { + const options = parseOptions(command.arguments, { flags: 'dDvps', valued: 'mog', long: { directory: 'd', mode: 'm', owner: 'o', group: 'g', verbose: 'v' } }) + const order: OptionMeaning[] = [['m', withValue('权限 ', context)], ['o', withValue('属主 ', context)], ['g', withValue('属组 ', context)]] + if (options.flags.has('d')) + return explained([verb('建目录'), space(), ...operandsText(options.operands, context)], optionNotes(options, order), options) + const target = options.operands.at(-1) + const sources = options.operands.slice(0, -1) + if (!target || sources.length === 0) + return undefined + return explained([verb('安装'), space(), ...operandsText(sources, context), space(), word('到'), space(), ...context.translate(target.node)], optionNotes(options, order), options) +}) + +/** chown:把 X 的属主改成 u:g(-R 连同里面的东西)。 */ +const chown = shellCommand('chown', ['chown'], (command, context) => { + const options = parseOptions(command.arguments, { flags: 'Rhv', long: { recursive: 'R', verbose: 'v' } }) + const [owner, ...paths] = options.operands + if (!owner || paths.length === 0) + return undefined + return explained( + [verb('把'), space(), ...operandsText(paths, context), space(), verb('的属主改成'), space(), ...context.translate(owner.node)], + optionNotes(options, [['R', '连同里面的东西'], ['h', '改符号链接本身']]), + options, + ) +}) + +/** 只写了一个 `--version`(`-V`、`version`)的命令:打印它的版本;别的写法不读(照抄)。 */ +const version = shellCommand('version', ['*'], (command, context) => { + const [only, ...more] = command.arguments + if (!only || more.length > 0 || !['--version', '-V', 'version'].includes(only.literal ?? '')) + return undefined + const name = context.translate(command.name.node).map(piece => 'role' in piece ? { ...piece, role: 'function' as const } : piece) + return [verb('打印'), space(), ...name, space(), hint('的版本')] +}) + +/** 装系统时常用的命令:新建用户和用户组、处理密钥、装文件、改属主,和只问版本的命令。 */ +export const systemCommandRules: CommandRule[] = [useradd, adduser, addgroup, gpg, install, chown, version] diff --git a/vscode/src/codeReader/translators/bashZh.test.ts b/vscode/src/codeReader/translators/bashZh.test.ts index ba35d13..4cbe40b 100644 --- a/vscode/src/codeReader/translators/bashZh.test.ts +++ b/vscode/src/codeReader/translators/bashZh.test.ts @@ -1,44 +1,25 @@ 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 { packageCommandRules } from '../commands/packageCommands' import { shellCommandRules } from '../commands/shellCommands' +import { systemCommandRules } from '../commands/systemCommands' import { createBashAdapter } from '../languages/bash' -import { flatSegments, readFixture, runtime, segmentText, WASM_DIR } from '../testSupport' +import { flatSegments, readFixture, runtime, viewLines, 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} `)] - }) -} +/** 内置的全部命令规则,和插件里注册的一样。 */ +const RULES = [...shellCommandRules, ...systemCommandRules, ...packageCommandRules] async function read(source: string, reports: string[] = []): Promise { - const { blocks } = await readBlockViews(runtime, bash, bashZhTranslator, 'shellscript', source, { commandRule: commandRuleLookup(shellCommandRules, text => reports.push(text)) }) + const { blocks } = await readBlockViews(runtime, bash, bashZhTranslator, 'shellscript', source, { commandRule: commandRuleLookup(RULES, text => reports.push(text)) }) return blocks } async function readLines(source: string): Promise { - return lines(await read(source)) + return viewLines(await read(source)) } /** luffy 桌面端 fetch-runtime.sh 读出来的样子(和原型 SHELL_BLOCKS 对得上,原型略去的 95-100、116-121 行也在)。 */ @@ -200,6 +181,10 @@ describe('shell 中文直译', () => { ['rm -rf --one-file-system x', '删除 x(连同里面的东西、不提示) --one-file-system'], ['foo -x "$y"', 'foo -x y'], ['mkdir -p d && cd d', '建目录 d(连同上级目录) 然后 进入 d'], + ['mkdir -p d && echo hi > d/a', '建目录 d(连同上级目录) 然后 打印 "hi"(写到 d/a)'], + ['curl -sSL -o f https://x', '下载 https://x 到 f(不打印进度、跟着跳转走)'], + ['. /etc/os-release', '读入 /etc/os-release 里的变量和函数'], + ['pg_repack --version', '打印 pg_repack 的版本'], ['cmd || exit 1', 'cmd,失败就 退出,退出码 1'], ])('命令:%s(规则不认识的选项照抄,没有规则的命令照抄命令名)', async (source, expected) => { expect(await readLines(`${source}\n`)).toEqual([`command 1: ${expected}`]) @@ -249,7 +234,7 @@ describe('shell 中文直译', () => { 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(viewLines(blocks)).toEqual(['command 1: rm -rf x', 'command 2: 建目录 d']) expect(reports).toEqual([expect.stringContaining('坏了')]) }) })