From 04a11eb0b24cd50940b2e3950ad933713e6f9bd9 Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 5 Oct 2026 20:18:12 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20Rust=20=E7=9A=84=20?= =?UTF-8?q?#![=E2=80=A6]=20=E5=86=85=E9=83=A8=E5=B1=9E=E6=80=A7=E5=81=9A?= =?UTF-8?q?=E6=88=90=E3=80=8C=E7=BC=96=E8=AF=91=E5=B1=9E=E6=80=A7=E3=80=8D?= =?UTF-8?q?=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 22 +++- vscode/src/codeReader/blockView.ts | 4 +- vscode/src/codeReader/contract.ts | 40 ++++++- .../src/codeReader/editor/documentMessage.ts | 3 + vscode/src/codeReader/editor/previewEditor.ts | 4 +- vscode/src/codeReader/languages/rust.test.ts | 70 ++++++++++- vscode/src/codeReader/languages/rust.ts | 109 +++++++++++++++++- vscode/src/codeReader/rules/rustRows.ts | 101 +++++++++++++++- vscode/src/codeReader/rules/rustTree.ts | 15 +++ vscode/src/codeReader/translators/rustZh.ts | 30 ++++- .../src/codeReader/translators/zhTemplate.ts | 4 + .../webview-ui/src/codeReader/codeReader.css | 1 + .../src/codeReader/kindIcons.test.tsx | 1 + vscode/webview-ui/src/codeReader/types.ts | 2 +- 14 files changed, 386 insertions(+), 20 deletions(-) diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 474c4df..74059ff 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -166,11 +166,11 @@ | directive 解析指令 | `terminal` | stage 阶段 | `layers` | run 执行 | `square-terminal` | | copy 复制 | `copy` | setting 设置 | `sliders-horizontal` | label 标签 | `tag` | | health 健康检查 | `activity` | start 启动 | `play` | trait 特质 | `network` | - | impl 实现 | `square-function` | module 模块 | `layers` | | | + | impl 实现 | `square-function` | module 模块 | `layers` | attribute 编译属性 | `sliders-horizontal` | 库规则的种类用规则给的图标(`KindBadge.icon`),跟在语言的种类图标后面:SQLAlchemy 数据表 `database`、数据类 `package`、Pydantic 数据模型 `braces`(和类型块同一个图标,颜色不同)、Celery 应用 `server-cog`、定时任务 `calendar-clock`、FastAPI 接口 `globe`、FastAPI 路由器 `network`、React 组件 `atom`、状态 `toggle-right`、引用 `anchor`、数据源 `download`、派生值 `sigma`、副作用 `zap`、事件处理函数 `mouse-pointer-click`、渲染输出 `layout-template`、serde 可序列化 `braces`、thiserror 错误 `triangle-alert`、axum 接口 `globe`、sqlx 查询 `database`、tauri 桌面命令 `square-function`。数据类写了 `frozen=True` 再加一把锁(`lock`,悬停「不可修改」)。实现块再由规则 `rust.impl` 换成卡片,左边因此有两个「实现」图标。 - 这组图标由插件自己定义,是一个封闭的集合:契约里的 `KIND_ICON_NAMES`(上表 26 个——命令、管道、执行共用一个,解释器、解析指令共用一个,选项、设置共用一个,字段、标签共用一个——加库规则用的 14 个(React 的组件、状态、引用、数据源、派生值、副作用、事件处理函数、渲染输出的 8 个也在里面),lucide 的 kebab-case 名字),类型 `KindIconName` 是它们的联合,概念的 `icon` 只能写其中一个,写别的是类型错误。内置种类 → 图标名在 `contract.ts` 的 `KIND_ICONS`(宿主写进概念列表)。webview 给每个名字配一个逐个 import 的 lucide 图标组件(`kindIcons.tsx` 的 `KIND_ICON_COMPONENTS`,打包只带这些,预览脚本约 157 kB);webview 的 `KindIconName` 照契约手抄,`kindIconNames.test.ts` 核对两边一样。规则要新图标就把名字加进这组、在 webview 配上图标,没有按任意名字找图标的路。 + 这组图标由插件自己定义,是一个封闭的集合:契约里的 `KIND_ICON_NAMES`(上表 26 个——命令、管道、执行共用一个,解释器、解析指令共用一个,选项、设置、编译属性共用一个,字段、标签共用一个——加库规则用的 14 个(React 的组件、状态、引用、数据源、派生值、副作用、事件处理函数、渲染输出的 8 个也在里面),lucide 的 kebab-case 名字),类型 `KindIconName` 是它们的联合,概念的 `icon` 只能写其中一个,写别的是类型错误。内置种类 → 图标名在 `contract.ts` 的 `KIND_ICONS`(宿主写进概念列表)。webview 给每个名字配一个逐个 import 的 lucide 图标组件(`kindIcons.tsx` 的 `KIND_ICON_COMPONENTS`,打包只带这些,预览脚本约 157 kB);webview 的 `KindIconName` 照契约手抄,`kindIconNames.test.ts` 核对两边一样。规则要新图标就把名字加进这组、在 webview 配上图标,没有按任意名字找图标的路。 - 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示和 `aria-label` 里;整排是 `role="group"`(`aria-label="块操作"`)。先是「问 AI」(sparkles 图标,点开 / 收起块下面的问答区,展开时带按下的底色、`aria-expanded`;文件不在工作区文件夹里时没有,见「问 AI」),再是「编辑 / 预览」切换:预览时铅笔「编辑」,编辑时眼睛「预览」、带按下的底色(`--vscode-toolbar-activeBackground`)。按钮宽 24px、高 20px(和种类图标同高),图标 14px、`descriptionForeground` 色;悬停 `--vscode-toolbar-hoverBackground`,键盘聚焦描 1px `--vscode-focusBorder`;Tab 能走到,回车 / 空格切换。 - 悬停提示(整个插件的网页只走这一条路,预览和看板共用同一个组件):带提示的元素只写 `data-tooltip="提示"`(名字照旧写在 `aria-label` 里,只有图标的按钮必须有 `aria-label`),页面根上挂一个 `TooltipHost`(`webview-ui/src/components/ui/tooltip.tsx`)统一弹,不用原生 `title`;提示里的换行照样换行(看板摘修复弹框里「迁移」列出的几个文件一行一个);注释、回答里 Markdown 链接的标题(`[文字](网址 "标题")`)也写成 `data-tooltip`。网页的 TSX 里写原生 `title` 属性是 lint 错误(`vscode/eslint.config.mjs`),只有 `iframe` 这种必须带标题的元素例外,组件自己叫 `title` 的属性不算。原因:macOS 上 VS Code 带的 Electron(1.139 是 Electron 43)只在鼠标压着带 `title` 的元素自身边缘时才弹原生提示,停在它里面的图标、文字上不弹(electron/electron#49843,Electron 38 起),种类图标 20px 的框几乎被 16px 的图形占满,基本弹不出来;样子也是系统的。样子照 VS Code 的悬停框:`--vscode-editorHoverWidget-background` 底、`--vscode-editorHoverWidget-border` 边框、`--vscode-editorHoverWidget-foreground` 字、`0 2px 8px --vscode-widget-shadow` 阴影,11px 字,圆角 4px,最宽 22rem;优先在元素上方,上方放不下翻到下方,水平对着元素中间,贴边留 8px,不接鼠标。鼠标停 500ms 才弹,在同一个元素里移动不重新计时,已经弹着时移到另一个带提示的元素上立刻换;移开、移出页面、按下鼠标、按任意键(含 Esc)、滚动就收,收了之后鼠标不离开这个元素不再弹。键盘把焦点移到带提示的元素上(`:focus-visible`)立刻弹,焦点离开就收;提示不是元素自己的名字(`aria-label` 或它的字)时用 `aria-describedby` 挂到元素上,读屏当描述念。 - 种类图标和底色按种类区分(颜色见「颜色」);注释块虚线框。 @@ -592,7 +592,7 @@ ```ts export type BlockKind = | 'import' | 'function' | 'class' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable' - | 'trait' | 'impl' | 'module' // Rust:特质、实现、模块(属性表) + | 'trait' | 'impl' | 'module' | 'attribute' // Rust:特质、实现、模块、编译属性(属性表) | 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment' | 'interpreter' | 'options' | 'case' | 'command' | 'pipeline' // shell:解释器、选项(属性表),分情况、命令、管道(一行译文) | 'directive' | 'stage' | 'run' | 'copy' | 'setting' | 'label' | 'health' | 'start' // Dockerfile:解析指令、阶段、健康检查、启动(属性表),执行、复制、设置、标签(一行译文) @@ -805,8 +805,10 @@ export interface EnumBlock extends BlockShape { kind: 'enum', facts: EnumFacts } export interface ExceptionBlock extends BlockShape { kind: 'exception', facts: ExceptionFacts } // 头部是整个类,留下的方法(含构造函数)等是子块 export interface ImportBlock extends BlockShape { kind: 'import', facts: ImportFacts } export interface VariableBlock extends BlockShape { kind: 'const' | 'variable', facts: VariableFacts } +/** Rust 的内部属性 `#![…]`:生效的各个属性(cfg_attr 拆开)、cfg_attr 的生效条件(CfgPredicate:not / all / any / 配置项 / 照原文)、写在哪一层(文件顶层、mod、impl)。 */ +export interface AttributeBlock extends BlockShape { kind: 'attribute', facts: AttributeFacts } export type FactBlock - = | FunctionBlock | ClassBlock | EnumBlock | ExceptionBlock | ImportBlock | VariableBlock | InterpreterBlock | OptionsBlock + = | FunctionBlock | ClassBlock | EnumBlock | ExceptionBlock | ImportBlock | VariableBlock | AttributeBlock | InterpreterBlock | OptionsBlock | DirectiveBlock | StageBlock | HealthBlock | StartBlock // 各带自己的 facts:InterpreterFacts、OptionsFacts、DirectiveFacts、StageFacts、HealthFacts、StartFacts /** 带说明(facts.doc)的块。 */ export type DocumentedBlock = FunctionBlock | ClassBlock | EnumBlock | ExceptionBlock | VariableBlock @@ -943,6 +945,8 @@ export interface DisplayNameReader { export interface SourceDocument { source: string language: LanguageAdapter + /** 文件路径(TextDocument.fileName);宿主没给(嵌着的代码、只读一段源码)时没有,按文件名定的说法写成判断不了的那种(Rust 编译属性的作用范围)。 */ + fileName?: string markers: SourceRange[] /** * 调用的名字(CallFacts.name)的定义是哪种块:宿主问语言扩展定义在哪,用那个文件的语言插件分块认出来。 @@ -1816,6 +1820,12 @@ shell 脚本大半是一条条命令,一条命令读成什么取决于命令 - `mod 名字 { … }`(`mod_item` 有 `body`)→ module(中文「模块」,图标 `layers`,颜色同阶段)。卡片行:名称、说明(有文档注释才有)、可见性(没写也是本模块,跟结构体一样总有这一行)。身体用和文件顶层同一套规则拆成子块,排在这张卡片下面,不另做一张表把子块塞进某一行。模块自己的文档注释留在模块卡片上,身体里每项自己的文档注释留在那一项上。`mod 名字;` 没有身体,不拆,仍是一条 statement。 - `const`(`const_item`)→ const,`static`(`static_item`)→ variable。属性表不另写一套译文,走中文模板里常量 / 变量那一套:名称、说明、访问级别(公开才有,模板里叫「公共」)、类型、值(`static` 的值叫「初始值」)。文件顶层、模块身体、impl 身体同一条路。 - `extern "…" { … }`(`foreign_mod_item`)不单独成一块源码。里面的函数签名(`function_signature_item`,没有函数体)是函数卡片,跟普通函数一样有名称、说明、可见性、参数、返回,作为所在这一层(模块、文件顶层或 impl)的子块。 +- 内部属性 `#![…]`(`inner_attribute_item`)→ attribute(中文「编译属性」,图标 `sliders-horizontal`,颜色同常量词性的蓝 `--cr-constant`:暗 #4fc1ff / 亮 #0070c1,和特质的声明关键字蓝、选项的黄分开)。它写给所在的 crate 或模块,例如 Tauri `main.rs` 开头的 `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]`。语言插件把 `cfg_attr(条件, a, b)` 拆成生效条件和 a、b 两个属性(`AttributeFacts`),别的属性就是它自己一个;`cfg_attr` 只写了条件、没写属性的照一个属性算。卡片行(有才显示): + - 属性:属性名连参数照原文着色(`windows_subsystem = "windows"`、`allow(dead_code, unused_imports)`)。`cfg_attr` 里写了几个属性时值是「N 个」,下面一张「属性 | 说明」表,每个属性一行(认不得的说明写「—」),不再另起「说明」一行。 + - 生效条件:只有 `cfg_attr` 有。`debug_assertions` 是「调试构建」,`test` 是「测试构建」,`target_os = "windows"` 是「目标系统 windows」,`feature = "x"` 是「启用特性 x」,`not(x)` 是「非 x」,`all(…)` 用「并且」、`any(…)` 用「或者」连起来,套在别的条件里、有两项以上的加括号(「unix 并且 非 (测试构建 或者 启用特性 mock)」);别的配置项、认不得的写法照原文。「非」「并且」「或者」按 operator 着色,特性名、系统名按 string。 + - 作用范围(按 keyword 着色):写在文件顶层时,crate 根文件是「整个 crate」,别的文件是「本模块」,宿主没给文件名(`SourceDocument.fileName`)时是「所在模块」;写在 `mod` 身体里是「本模块」,`impl` 身体里是「本 impl 块」。crate 根文件按 Cargo 默认的认法(`rules/rustTree.ts` 的 `isCrateRoot`):`main.rs`、`lib.rs`,`src/bin/` 下的 `.rs`,包根下 `examples/`、`tests/`、`benches/` 里直接放的 `.rs`(`src/` 下同名目录里的是模块)。 + - 说明(按 comment 着色,列出的名字按 variable):认得的属性才有。`windows_subsystem = "windows"`「在 Windows 上编译为图形界面程序(windows 子系统),启动时不弹出控制台窗口」,`= "console"`「在 Windows 上编译为控制台程序(console 子系统),启动时打开控制台窗口」;`allow` / `warn` / `deny` / `forbid`「编译检查(lint)级别设为放行 / 警告 / 报错 / 强制报错(内层不能再放宽):」后面列括号里的各项;`feature`「启用实验特性(需 nightly 编译器):」后面列名字;`no_std`「不链接标准库 std,只使用核心库 core」;`no_main`「不使用标准的 main 入口函数,程序入口另行提供」;`recursion_limit = "N"`「宏展开等编译期递归的深度上限设为 N(默认 128)」。 + - 挂在函数、结构体上的外部属性 `#[…]` 不变:算进那一项的头部,`derive`、库规则照旧。 - 其余(`let`、宏调用)→ statement。不拆函数体。模块身体里宏调用后面多出来的那个空 `;`(`empty_statement`)不成块。 ### 属性表信息(facts)从哪来 @@ -1832,7 +1842,7 @@ shell 脚本大半是一条条命令,一条命令读成什么取决于命令 - Shell:解释器取 `#!` 后面的整句,第一个词是 `env` 的是按 PATH 找、程序名是它后面那个词,否则是写死的路径、程序名是路径最后一节。选项:每个开关一项,单字母换成 bash 的长名字(e → errexit、u → nounset、x → xtrace……),`-o 名字` 照写,`-` 开、`+` 关。变量:`variable_assignment` 的 name / value,声明里没给值的只有 name。函数:name 是函数名,doc 是紧贴在上面的注释;`positional` 是主体里(不进嵌套的函数)用到的 `$N` / `${N}` / `${N:-…}`,照位置排,`local x="$N"`、`x=$N` 给它起名字;`prints` 是没重定向到别处的 `echo` / `printf`(进 if、循环、分情况,不进 `$(…)`、管道、嵌套的函数);`captured` 看同一文件里有没有 `$(函数名 …)`;`returns` 是主体里的 `return` 命令。 - Dockerfile:解析指令是每行注释 `# 名字=值` 的名字(小写)和值;阶段取 `image_spec` 的 name / tag / digest 字段、`as` 字段(image_alias),stage 看镜像名是不是前面某个阶段的名字(不分大小写),选项是 `param`、`mount_param` 节点的原文 `--名字=值`(值在语法树里没有单独的节点,按原文拆,记下值从哪开始);变量是 `env_pair` 的 name / value、`arg_instruction` 的 name / default;健康检查的 none 看有没有 NONE,检查命令是里面的 `cmd_instruction`;数组写法取 `json_string_array`,shell 写法取 `shell_command` 里各个 `shell_fragment`(`line_continuation` 不算),只有一个 `<() const embedded = (node: Node): EmbeddedReading | undefined => { const part = parts.get(node.startIndex) @@ -481,6 +482,7 @@ export async function readBlockViews( source, language: adapter, markers, + ...(fileName && { fileName }), ...(calleeKind && { calleeKind }), ...(commandRule && { commandRule }), ...(parts.size > 0 && { embedded }), diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 834b85d..c92056e 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -12,7 +12,7 @@ export type { Node } */ export type BlockKind = | 'import' | 'function' | 'class' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable' - | 'trait' | 'impl' | 'module' + | 'trait' | 'impl' | 'module' | 'attribute' | 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment' | 'interpreter' | 'options' | 'case' | 'command' | 'pipeline' | 'directive' | 'stage' | 'run' | 'copy' | 'setting' | 'label' | 'health' | 'start' @@ -214,6 +214,35 @@ export interface ClassFacts { implements: Node[] } +/** Rust 的内部属性 `#![…]`:`cfg_attr(条件, a, b)` 拆成生效条件和 a、b 两个属性,别的属性就是它自己一个。 */ +export interface AttributeFacts { + /** 生效的各个属性,照写的顺序。 */ + attributes: AttributeItem[] + /** `cfg_attr` 的生效条件;别的属性没有。 */ + condition?: CfgPredicate + /** 写在哪一层:文件顶层、`mod` 身体、`impl` 身体;别处(`extern` 块里)没有。作用范围按它定,文件顶层还要看是不是 crate 根文件。 */ + placement?: 'file' | 'module' | 'impl' +} + +/** 一个属性:`名字`、`名字 = 值` 或 `名字(各项)`。 */ +export interface AttributeItem { + /** 整个属性(名字连参数)在源码里的范围,预览照原文着色。 */ + span: SourceSpan + /** 名字,路径照写(`rustfmt::skip`)。 */ + name: string + /** `名字 = 值` 的值:字符串去掉引号,别的照原文。 */ + value?: string + /** `名字(…)` 括号里逗号隔开的各项,照原文(`clippy::too_many_arguments` 是一项);没写括号是空的。 */ + arguments: string[] +} + +/** `cfg` 条件:`not(…)`、`all(…)`、`any(…)`,一个配置项(`test`、`target_os = "windows"`),认不出的写法照原文。 */ +export type CfgPredicate + = | { kind: 'not', operand: CfgPredicate } + | { kind: 'all' | 'any', operands: CfgPredicate[] } + | { kind: 'option', name: string, value?: string } + | { kind: 'other', text: string } + /** 枚举的一个成员;节点都指向源码。 */ export interface EnumMemberFacts { name: Node @@ -480,6 +509,8 @@ export interface TraitBlock extends BlockShape { kind: 'trait', facts: ClassFact /** Rust 的 impl:标题是「某类型 的方法」,方法是子块,放进方法表的某一行。 */ export interface ImplBlock extends BlockShape { kind: 'impl', facts: ClassFacts } export interface ModuleBlock extends BlockShape { kind: 'module', facts: ClassFacts } +/** Rust 的内部属性 `#![…]`:写给所在 crate 或模块的编译属性。 */ +export interface AttributeBlock extends BlockShape { kind: 'attribute', facts: AttributeFacts } /** 枚举块的头部是整个枚举(成员都在属性表里讲),没有子块;库规则认出来的枚举(Python)是整个类,留下的方法等是子块。 */ export interface EnumBlock extends BlockShape { kind: 'enum', facts: EnumFacts } /** 异常块的头部是整个类(类属性都在属性表里讲),留下的方法(含构造函数)等是子块。 */ @@ -497,7 +528,7 @@ export interface StageBlock extends BlockShape { kind: 'stage', facts: StageFact export interface HealthBlock extends BlockShape { kind: 'health', facts: HealthFacts } export interface StartBlock extends BlockShape { kind: 'start', facts: StartFacts } export type FactBlock - = | FunctionBlock | ClassBlock | TraitBlock | ImplBlock | ModuleBlock | EnumBlock | ExceptionBlock | ImportBlock | VariableBlock | InterpreterBlock | OptionsBlock + = | FunctionBlock | ClassBlock | TraitBlock | ImplBlock | ModuleBlock | AttributeBlock | EnumBlock | ExceptionBlock | ImportBlock | VariableBlock | InterpreterBlock | OptionsBlock | DirectiveBlock | StageBlock | HealthBlock | StartBlock /** 带说明(facts.doc)的块。 */ @@ -695,6 +726,8 @@ export interface DisplayNameReader { export interface SourceDocument { source: string language: LanguageAdapter + /** 文件路径(VS Code 的 TextDocument.fileName);宿主没给(嵌着的代码、只读一段源码)时没有,按文件名定的说法写成判断不了的那种。 */ + fileName?: string /** 写在注释里给代码阅读看的记号(中文名记号,见 DisplayNameFacts.markers):不是说明文字,翻注释、说明时去掉 */ markers: SourceRange[] /** @@ -743,7 +776,7 @@ export interface ThemeColor { dark: string, light: string } /** * 种类图标:插件自己定义的一组 lucide 图标(https://lucide.dev/icons 上的 kebab-case 名字)。前 26 个给内置种类(几种共用一个: - * 命令、管道、执行共用 square-terminal,解释器、解析指令共用 terminal,选项、设置共用 sliders-horizontal,字段、标签共用 tag), + * 命令、管道、执行共用 square-terminal,解释器、解析指令共用 terminal,选项、设置、编译属性共用 sliders-horizontal,字段、标签共用 tag), * 宿主标的入口块也用这组里的 play(entries/entryMarks.ts 的 ENTRY_CONCEPT), * 后面的给库规则的种类(React 的组件、状态、引用、数据源、派生值、副作用、事件处理函数、渲染输出也在里面);库规则的种类(KindBadge.icon)只能从这组里挑,要新图标就加进这组(webview 的 kindIcons.tsx 同时给它配上图标), * 没有按任意名字找图标的路。 @@ -804,6 +837,7 @@ export const KIND_ICONS: Record = { trait: 'network', impl: 'square-function', module: 'layers', + attribute: 'sliders-horizontal', type: 'braces', enum: 'list-ordered', exception: 'triangle-alert', diff --git a/vscode/src/codeReader/editor/documentMessage.ts b/vscode/src/codeReader/editor/documentMessage.ts index 938ce75..6406294 100644 --- a/vscode/src/codeReader/editor/documentMessage.ts +++ b/vscode/src/codeReader/editor/documentMessage.ts @@ -13,6 +13,8 @@ export interface DocumentSnapshot { languageId: string version: number getText: () => string + /** 文件路径,交给翻译插件(SourceDocument.fileName) */ + fileName?: string /** 这份文件是项目写明的入口时,预览里名字对上的块标成入口(见 entries/entryPoints.ts) */ entryNames?: readonly string[] } @@ -57,6 +59,7 @@ export async function documentMessage( try { const rules = registry.rulesFor(languageId) const { blocks, displayNames, symbols } = await readBlockViews(runtime, adapter, translator, languageId, source, { + ...(document.fileName && { fileName: document.fileName }), rules: { rules, report, lookup }, calleeKind, commandRule: commandRuleLookup(registry.commandRulesFor(languageId), report), diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 8aab798..01f4d89 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -328,7 +328,7 @@ class Preview { const source = this.document.getText() const session = this.lookups.session() const callees = this.callees.session() - const { message, displayNames, symbols } = await documentMessage({ languageId, version, getText: () => source, entryNames: this.owner.entries.namesOf(this.document.uri) }, this.registry, this.runtime, (text) => { + const { message, displayNames, symbols } = await documentMessage({ languageId, version, getText: () => source, fileName: this.document.fileName, entryNames: this.owner.entries.namesOf(this.document.uri) }, this.registry, this.runtime, (text) => { logger.add({ level: 'warn', source: 'codeReader', message: `${this.document.uri.fsPath}:${text}` }) }, session.lookup, callees.calleeKind) if (request !== this.latest || !this.ready) @@ -356,7 +356,7 @@ class Preview { */ private async readOld(source: string): Promise { const { message } = await documentMessage( - { languageId: this.document.languageId, version: this.document.version, getText: () => source, entryNames: this.owner.entries.namesOf(this.document.uri) }, + { languageId: this.document.languageId, version: this.document.version, getText: () => source, fileName: this.document.fileName, entryNames: this.owner.entries.namesOf(this.document.uri) }, this.registry, this.runtime, () => {}, diff --git a/vscode/src/codeReader/languages/rust.test.ts b/vscode/src/codeReader/languages/rust.test.ts index 46dcdb7..a4b58f6 100644 --- a/vscode/src/codeReader/languages/rust.test.ts +++ b/vscode/src/codeReader/languages/rust.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { toBlockViews } from '../blockView' import { blockMarks } from '../diff/viewDiff' import { rustImplRule } from '../rules/rustImpl' +import { isCrateRoot } from '../rules/rustTree' import { readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport' import { rustZhTranslator } from '../translators/rustZh' import { createRustAdapter } from './rust' @@ -10,9 +11,9 @@ import { createRustAdapter } from './rust' const adapter = createRustAdapter(WASM_DIR) const sample = readFixture('rustLang.rs') -function views(source: string): Promise { +function views(source: string, fileName?: string): Promise { return withBlocks(adapter, 'rust', source, blocks => - toBlockViews(blocks, { source, language: adapter, markers: [] }, rustZhTranslator, { + toBlockViews(blocks, { source, language: adapter, markers: [], ...(fileName && { fileName }) }, rustZhTranslator, { rules: [rustImplRule], report: () => {}, })) @@ -270,3 +271,68 @@ describe('rust 注释', () => { expect(markdown(impl?.children.find(block => block.kind === 'comment'))).toBe('三\n\n四') }) }) + +describe('rust 编译属性', () => { + it('tauri 的 main.rs 开头的 cfg_attr:属性、生效条件、作用范围、说明', async () => { + const source = [ + '// Prevents additional console window on Windows in release, DO NOT REMOVE!!', + '#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]', + '', + 'fn main() {}', + '', + ].join('\n') + const list = await views(source, '/repo/apps/desktop/src-tauri/src/main.rs') + expect(list.map(block => block.kind)).toEqual(['comment', 'attribute', 'function']) + expect(viewLines([card(list, 'attribute')])).toEqual([ + 'attribute 2', + ' 属性: windows_subsystem = "windows"', + ' 生效条件: 非 调试构建', + ' 作用范围: 整个 crate', + ' 说明: 在 Windows 上编译为图形界面程序(windows 子系统),启动时不弹出控制台窗口', + ]) + const attribute = card(list, 'attribute') + const value = 'properties' in attribute ? attribute.properties[0]?.value : undefined + expect(Array.isArray(value) && value.map(piece => 'role' in piece && piece.role)).toEqual(['variable', 'plain', 'plain', 'plain', 'string', 'string', 'string']) + }) + + it('allow 列出各个 lint,别的文件顶层是本模块', async () => { + const list = await views('#![allow(dead_code, unused_imports)]\n', '/repo/src/credentials.rs') + expect(viewLines(list)).toEqual([ + 'attribute 1', + ' 属性: allow(dead_code, unused_imports)', + ' 作用范围: 本模块', + ' 说明: 编译检查(lint)级别设为放行:dead_code、unused_imports', + ]) + }) + + it('认不得的属性没有说明,不知道文件名时作用范围是所在模块', async () => { + expect(viewLines(await views('#![rustfmt::skip]\n'))).toEqual([ + 'attribute 1', + ' 属性: rustfmt::skip', + ' 作用范围: 所在模块', + ]) + }) + + it('cfg_attr 写了几个属性是一张表;all、any 套着的条件加括号;mod 身体里是本模块', async () => { + const source = [ + 'mod platform {', + ' #![cfg_attr(all(unix, not(any(test, feature = "mock"))), allow(clippy::foo), deny(warnings))]', + '}', + '', + ].join('\n') + const mod = card(await views(source, '/repo/src/main.rs'), 'module') + expect(viewLines(mod.children)).toEqual([ + 'attribute 2', + ' 属性: 2 个', + ' allow(clippy::foo) | 编译检查(lint)级别设为放行:clippy::foo', + ' deny(warnings) | 编译检查(lint)级别设为报错:warnings', + ' 生效条件: unix 并且 非 (测试构建 或者 启用特性 mock)', + ' 作用范围: 本模块', + ]) + }) + + it('crate 根文件:main.rs、lib.rs、src/bin 下的、包根 tests / examples / benches 里直接放的', () => { + expect(['/p/src/main.rs', '/p/src/lib.rs', '/p/src/bin/tool.rs', '/p/tests/api.rs', 'C:\\p\\examples\\demo.rs'].map(isCrateRoot)).toEqual([true, true, true, true, true]) + expect(['/p/src/credentials.rs', '/p/src/tests/helpers.rs', '/p/src/bin/tool/args.rs'].map(isCrateRoot)).toEqual([false, false, false]) + }) +}) diff --git a/vscode/src/codeReader/languages/rust.ts b/vscode/src/codeReader/languages/rust.ts index bbf1342..695008e 100644 --- a/vscode/src/codeReader/languages/rust.ts +++ b/vscode/src/codeReader/languages/rust.ts @@ -1,8 +1,8 @@ -import type { Access, ClassFacts, CodeBlock, EnumBlock, FunctionBlock, ImplBlock, ImportBlock, ImportItem, LanguageAdapter, Node, ParameterFacts, TokenRole, TraitBlock } from '../contract' +import type { Access, AttributeFacts, AttributeItem, CfgPredicate, ClassFacts, CodeBlock, EnumBlock, FunctionBlock, ImplBlock, ImportBlock, ImportItem, LanguageAdapter, Node, ParameterFacts, SourceSpan, TokenRole, TraitBlock } from '../contract' import type { CommentRules } from './shared' import * as path from 'node:path' import { leadingAttributes, visibilityFacts } from '../rules/rustTree' -import { compoundShape, extendHeader, headerSpan, leafBlock, leafShape, listBlocks, named } from './shared' +import { children, compoundShape, extendHeader, headerSpan, isCommentNode, leafBlock, leafShape, listBlocks, named } from './shared' const STORAGE = new Set(['pub', 'async', 'const', 'static', 'mut', 'let']) @@ -69,6 +69,8 @@ function itemBlock(item: Node): CodeBlock { case 'function_item': case 'function_signature_item': return functionBlock(item) + case 'inner_attribute_item': + return innerAttributeBlock(item) default: return leafBlock('statement', item) } @@ -104,6 +106,109 @@ function valueBlock(node: Node, kind: 'const' | 'variable'): CodeBlock { } } +const PLACEMENTS: Record = { source_file: 'file', mod_item: 'module', impl_item: 'impl' } + +/** `#![…]`:写给所在 crate 或模块的编译属性。`cfg_attr(条件, a, b)` 拆成生效条件和 a、b。 */ +function innerAttributeBlock(node: Node): CodeBlock { + const attribute = named(node).find(child => child.type === 'attribute') + if (!attribute) + return leafBlock('statement', node) + const meta = metaItem(tokens(attribute)) + const [condition, ...applied] = meta.name === 'cfg_attr' ? meta.list ?? [] : [] + const conditional = condition !== undefined && applied.length > 0 + const container = node.parent?.type === 'source_file' ? node.parent : node.parent?.parent + const placement = container ? PLACEMENTS[container.type] : undefined + return { + kind: 'attribute', + ...leafShape(node), + facts: { + attributes: conditional ? applied.map(item => attributeItem(metaItem(item))) : [attributeItem(meta)], + ...(conditional && { condition: cfgPredicate(condition) }), + ...(placement && { placement }), + }, + } +} + +/** 属性的写法:名字(路径),后面 `= 值` 或括号里逗号隔开的各段。tokens 是这一段的各个词。 */ +interface MetaItem { + tokens: Node[] + name: string + value?: Node + list?: Node[][] +} + +function metaItem(words: Node[]): MetaItem { + const at = words.findIndex(word => word.type === '=' || word.type === 'token_tree') + const path = at < 0 ? words : words.slice(0, at) + const mark = words[at] + return { + tokens: words, + name: textOf(path), + ...(mark?.type === '=' && words[at + 1] && { value: words[at + 1] }), + ...(mark?.type === 'token_tree' && { list: listItems(mark) }), + } +} + +/** 括号里逗号隔开的各段,每段是它的各个词。 */ +function listItems(tree: Node): Node[][] { + const items: Node[][] = [[]] + for (const word of tokens(tree).slice(1, -1)) { + if (word.type === ',') + items.push([]) + else + items[items.length - 1].push(word) + } + return items.filter(item => item.length > 0) +} + +function attributeItem(meta: MetaItem): AttributeItem { + return { + span: spanOf(meta.tokens), + name: meta.name, + ...(meta.value && { value: literalText(meta.value) }), + arguments: (meta.list ?? []).map(textOf), + } +} + +function cfgPredicate(words: Node[]): CfgPredicate { + const meta = metaItem(words) + if (meta.list && (meta.name === 'all' || meta.name === 'any')) + return { kind: meta.name, operands: meta.list.map(cfgPredicate) } + if (meta.list?.length === 1 && meta.name === 'not') + return { kind: 'not', operand: cfgPredicate(meta.list[0]) } + if (!meta.list && words[0]?.type === 'identifier' && (words.length === 1 || meta.value)) + return { kind: 'option', name: meta.name, ...(meta.value && { value: literalText(meta.value) }) } + return { kind: 'other', text: textOf(words) } +} + +/** 节点的各个词(含标点),注释不算。 */ +function tokens(node: Node): Node[] { + return children(node).filter(child => !isCommentNode(child)) +} + +/** 连着的几个词在源码里的原文(它们是同一个节点的子节点)。 */ +function textOf(words: Node[]): string { + const [first] = words + const last = words[words.length - 1] + const parent = first?.parent + if (!first || !last || !parent) + return '' + return parent.text.slice(first.startIndex - parent.startIndex, last.endIndex - parent.startIndex) +} + +function spanOf(words: Node[]): SourceSpan { + const first = words[0] + const last = words[words.length - 1] + return { startIndex: first.startIndex, endIndex: last.endIndex, startRow: first.startPosition.row, endRow: last.endPosition.row } +} + +/** 字面量的值:字符串去掉引号,别的照原文。 */ +function literalText(node: Node): string { + if (node.type !== 'string_literal') + return node.text + return named(node).find(child => child.type === 'string_content')?.text ?? '' +} + /** 写在前面的 `#[…]` 算进头部。 */ function withAttributes(block: CodeBlock): CodeBlock { const [attribute] = leadingAttributes(block.node) diff --git a/vscode/src/codeReader/rules/rustRows.ts b/vscode/src/codeReader/rules/rustRows.ts index ef2b990..796a55e 100644 --- a/vscode/src/codeReader/rules/rustRows.ts +++ b/vscode/src/codeReader/rules/rustRows.ts @@ -1,4 +1,4 @@ -import type { ClassBlock, CodeBlock, EnumBlock, FunctionBlock, ImplBlock, ImportBlock, Inline, ModuleBlock, PropertyItem, PropertyRow, TokenRole, TraitBlock } from '../contract' +import type { AttributeBlock, AttributeFacts, AttributeItem, CfgPredicate, ClassBlock, CodeBlock, EnumBlock, FunctionBlock, ImplBlock, ImportBlock, Inline, ModuleBlock, PropertyItem, PropertyRow, SourceSpan, TokenRole, TraitBlock } from '../contract' import type { RustVisibility } from './rustTree' import { bodyFunctions, docSentence, isMethod, matchArms, oneLine, returnText, signatureParams, structFields, variantPayload } from './rustTree' @@ -72,6 +72,97 @@ export function moduleRows(block: ModuleBlock, say: RustSay): PropertyRow[] { ] } +const LINT_LEVELS: Record = { allow: '放行', warn: '警告', deny: '报错', forbid: '强制报错(内层不能再放宽)' } + +/** + * 编译属性 `#![…]`:属性(照原文着色)/ 生效条件(只有 cfg_attr)/ 作用范围 / 说明(认得的属性才有)。 + * cfg_attr 里写了几个属性时「属性」是一张「属性 | 说明」表,不再另起「说明」一行。 + * original 把源码的一段照原文着色;crateRoot 是这份文件是不是 crate 根文件,不知道文件名时没有。 + */ +export function attributeRows(block: AttributeBlock, original: (span: SourceSpan) => Inline[], crateRoot: boolean | undefined): PropertyRow[] { + const { attributes, condition, placement } = block.facts + const single = attributes.length === 1 ? attributes[0] : undefined + const description = single && attributeNote(single) + return [ + single + ? { label: '属性', value: original(single.span) } + : { + label: '属性', + value: [hint(`${attributes.length} 个`)], + columns: ['属性', '说明'], + items: attributes.map(item => ({ cells: [original(item.span), attributeNote(item) ?? [hint('—')]] })), + }, + ...(condition ? [{ label: '生效条件', value: cfgText(condition, false) }] : []), + { label: '作用范围', value: [keyword(attributeScope(placement, crateRoot))] }, + ...(description ? [{ label: '说明', value: description }] : []), + ] +} + +function attributeScope(placement: AttributeFacts['placement'], crateRoot: boolean | undefined): string { + switch (placement) { + case 'file': + return crateRoot === undefined ? '所在模块' : crateRoot ? '整个 crate' : '本模块' + case 'module': + return '本模块' + case 'impl': + return '本 impl 块' + default: + return '所在模块' + } +} + +/** 认得的属性的书面说明;认不得的没有。 */ +function attributeNote(item: AttributeItem): Inline[] | undefined { + const names = item.arguments.flatMap((name, i) => [...(i > 0 ? [note('、')] : []), { text: name, role: 'variable' as const }]) + const level = LINT_LEVELS[item.name] + if (level) + return names.length > 0 ? [note(`编译检查(lint)级别设为${level}:`), ...names] : undefined + switch (item.name) { + case 'windows_subsystem': + if (item.value === 'windows') + return [note('在 Windows 上编译为图形界面程序(windows 子系统),启动时不弹出控制台窗口')] + return item.value === 'console' ? [note('在 Windows 上编译为控制台程序(console 子系统),启动时打开控制台窗口')] : undefined + case 'feature': + return names.length > 0 ? [note('启用实验特性(需 nightly 编译器):'), ...names] : undefined + case 'no_std': + return [note('不链接标准库 std,只使用核心库 core')] + case 'no_main': + return [note('不使用标准的 main 入口函数,程序入口另行提供')] + case 'recursion_limit': + return item.value ? [note(`宏展开等编译期递归的深度上限设为 ${item.value}(默认 128)`)] : undefined + default: + return undefined + } +} + +const CFG_OPTIONS: Record = { debug_assertions: '调试构建', test: '测试构建' } +const CFG_VALUES: Record = { target_os: '目标系统', feature: '启用特性' } + +/** cfg 条件读成中文:not 是「非」,all 用「并且」、any 用「或者」连起来,套在别的条件里的加括号;认不得的照原文。 */ +function cfgText(predicate: CfgPredicate, nested: boolean): Inline[] { + switch (predicate.kind) { + case 'not': + return [operator('非'), plain(' '), ...cfgText(predicate.operand, true)] + case 'all': + case 'any': { + if (predicate.operands.length === 0) + return [hint(predicate.kind === 'all' ? '总是成立' : '从不成立')] + const joiner = operator(predicate.kind === 'all' ? '并且' : '或者') + const parts = predicate.operands.flatMap((operand, i) => [...(i > 0 ? [plain(' '), joiner, plain(' ')] : []), ...cfgText(operand, true)]) + return nested && predicate.operands.length > 1 ? [plain('('), ...parts, plain(')')] : parts + } + case 'option': { + const { name, value } = predicate + if (value === undefined) + return [plain(CFG_OPTIONS[name] ?? name)] + const label = CFG_VALUES[name] + return label ? [plain(`${label} `), { text: value, role: 'string' }] : [plain(`${name} = `), { text: `"${value}"`, role: 'string' }] + } + case 'other': + return [plain(predicate.text)] + } +} + export function enumRows(block: EnumBlock, say: RustSay): PropertyRow[] { return [ nameRow(block.facts.name, say, 'type'), @@ -178,6 +269,14 @@ function keyword(text: string): Inline { return { text, role: 'keyword' } } +function operator(text: string): Inline { + return { text, role: 'operator' } +} + +function note(text: string): Inline { + return { text, role: 'comment' } +} + function range(node: import('../contract').Node): { startIndex: number, endIndex: number } { return { startIndex: node.startIndex, endIndex: node.endIndex } } diff --git a/vscode/src/codeReader/rules/rustTree.ts b/vscode/src/codeReader/rules/rustTree.ts index eb110cc..93e47eb 100644 --- a/vscode/src/codeReader/rules/rustTree.ts +++ b/vscode/src/codeReader/rules/rustTree.ts @@ -32,6 +32,21 @@ export function oneLine(text: string): string { return text.replace(/\s+/g, ' ').trim() } +const TARGET_DIRS = new Set(['examples', 'tests', 'benches']) + +/** + * Cargo 默认认作 crate 根的文件:`main.rs`、`lib.rs`,`src/bin/` 下的 `.rs`, + * 包根下 `examples/`、`tests/`、`benches/` 里直接放的 `.rs`(`src/` 下同名目录里的是模块)。 + */ +export function isCrateRoot(fileName: string): boolean { + const [base, parent, grandparent] = fileName.split(/[\\/]/).reverse() + if (base === 'main.rs' || base === 'lib.rs') + return true + if (!base?.endsWith('.rs') || parent === undefined) + return false + return parent === 'bin' ? grandparent === 'src' : TARGET_DIRS.has(parent) && grandparent !== 'src' +} + /** 节点上的可见性。pub(in …) 不在这四档里,按本 crate 算(只本包能用)。 */ export function rustVisibility(node: Node): RustVisibility { const vis = named(node).find(child => child.type === 'visibility_modifier') diff --git a/vscode/src/codeReader/translators/rustZh.ts b/vscode/src/codeReader/translators/rustZh.ts index b73462c..4e0facd 100644 --- a/vscode/src/codeReader/translators/rustZh.ts +++ b/vscode/src/codeReader/translators/rustZh.ts @@ -1,5 +1,6 @@ -import type { FactBlock, Node, PropertyRow, SourceDocument, Translator } from '../contract' -import { enumRows, functionRows, implRows, importRows, moduleRows, plainSay, structRows, traitRows } from '../rules/rustRows' +import type { FactBlock, Inline, Node, PropertyRow, SourceDocument, SourceSpan, Translator } from '../contract' +import { attributeRows, enumRows, functionRows, implRows, importRows, moduleRows, plainSay, structRows, traitRows } from '../rules/rustRows' +import { isCrateRoot } from '../rules/rustTree' import { createZhTemplateTranslator, KIND_ZH, ZH_ATTRIBUTE_NAMES } from './zhTemplate' const say = plainSay() @@ -96,6 +97,8 @@ function describe(block: FactBlock, document: SourceDocument): PropertyRow[] { return implRows(block, say) case 'module': return moduleRows(block, say) + case 'attribute': + return attributeRows(block, span => original(block.node, span, document), document.fileName === undefined ? undefined : isCrateRoot(document.fileName)) case 'const': case 'variable': return template.describe(block, document) @@ -104,6 +107,29 @@ function describe(block: FactBlock, document: SourceDocument): PropertyRow[] { } } +/** node 里 span 那一段照原文:叶子按源码着色,叶子之间的空白照抄。 */ +function original(node: Node, span: SourceSpan, { source, language }: SourceDocument): Inline[] { + const out: Inline[] = [] + let cursor = span.startIndex + const visit = (current: Node): void => { + if (current.endIndex <= span.startIndex || current.startIndex >= span.endIndex || current.startIndex === current.endIndex) + return + if (current.childCount === 0) { + if (current.startIndex > cursor) + out.push({ text: source.slice(cursor, current.startIndex), role: 'plain' }) + out.push({ text: current.text, role: language.tokenRole(current) }) + cursor = current.endIndex + return + } + for (const child of current.children) { + if (child) + visit(child) + } + } + visit(node) + return out +} + /** 行注释去掉 `//`(文档注释连 `/`、`!`)和后面一个空格,缩进留给 Markdown;块注释去掉开头结尾的注释符号。 */ function commentText(node: Node, text: string): string { if (node.type === 'line_comment') diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index 23cded6..c366d05 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -207,6 +207,7 @@ export const KIND_ZH: Record = { trait: '特质', impl: '实现', module: '模块', + attribute: '编译属性', enum: '枚举', exception: '异常', type: '类型', @@ -722,6 +723,9 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return healthRows(block.facts, phrases) case 'start': return startRows(block.facts, phrases) + // Rust 的编译属性由 Rust 的直译排(translators/rustZh.ts) + case 'attribute': + return [] default: return block.facts.scope ? scopedVariableRows(block.facts.declarators, block.facts.scope, phrases) : variableRows(block, phrases) } diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 4631b59..16b1430 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -69,6 +69,7 @@ body.vscode-high-contrast-light { [data-kind="function"] { --kind: var(--cr-kind-function); } :is([data-kind="class"], [data-kind="type"]) { --kind: var(--cr-kind-type); } [data-kind="trait"] { --kind: var(--cr-storage); } +[data-kind="attribute"] { --kind: var(--cr-constant); } [data-kind="impl"] { --kind: var(--cr-kind-function); } [data-kind="enum"] { --kind: var(--cr-kind-enum); } [data-kind="exception"] { --kind: var(--cr-kind-exception); } diff --git a/vscode/webview-ui/src/codeReader/kindIcons.test.tsx b/vscode/webview-ui/src/codeReader/kindIcons.test.tsx index 81ab434..13ba191 100644 --- a/vscode/webview-ui/src/codeReader/kindIcons.test.tsx +++ b/vscode/webview-ui/src/codeReader/kindIcons.test.tsx @@ -24,6 +24,7 @@ describe('块的种类图标', () => { trait: 'network', impl: 'square-function', module: 'layers', + attribute: 'sliders-horizontal', type: 'braces', enum: 'list-ordered', exception: 'triangle-alert', diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 54ad3e5..be39f38 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -4,7 +4,7 @@ export type BlockKind = | 'import' | 'function' | 'class' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable' - | 'trait' | 'impl' | 'module' + | 'trait' | 'impl' | 'module' | 'attribute' | 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment' | 'interpreter' | 'options' | 'case' | 'command' | 'pipeline' | 'directive' | 'stage' | 'run' | 'copy' | 'setting' | 'label' | 'health' | 'start'