✨ feat(vscode): 命令规则加装软件包、装依赖、跑程序(apt-get、apk、pip、npm、pnpm、yarn、corepack、uv、uvicorn)和建用户、密钥、装文件、改属主(useradd、adduser、addgroup、gpg、install、chown);选项写到 4 个以上或写了几遍时排成「选项 | 意思」表;没有自己规则的命令交给管 * 的规则(只问 --version 的读成打印版本);curl -sS 只写不打印进度,source 读成读入里面的变量和函数

This commit is contained in:
cruldra committed 2026-09-28 11:53:54 +08:00
1 parent 149cb51cc0
commit 0665f35821
7 files changed
+764 -37

No files matched your search

+5 -2
View File
@@ -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<string, CommandRule>()
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)
}
@@ -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<string[]> {
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('坏了')])
})
})
@@ -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<string, string> = { 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]
+63 -6
View File
@@ -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<string>
/** 带值的选项的值;写了几遍的是最后一个,全部在 lists 里。 */
values: Map<string, OptionValue>
lists: Map<string, OptionValue[]>
/** 认出的选项在源码里怎么写的(第一次的写法:`-y`、`--no-dev`),写了几遍。 */
spelled: Map<string, string>
counts: Map<string, number>
/** 不是选项的词(要处理的东西),照源码顺序。 */
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
@@ -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) => {
@@ -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]
@@ -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<BlockView[]> {
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<string[]> {
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('坏了')])
})
})