0.1.15 继续优化补全

This commit is contained in:
2026-08-04 19:14:12 +02:00
parent f8187da611
commit 47807f9fed
17 changed files with 1762 additions and 87 deletions
+7 -3
View File
@@ -6,7 +6,7 @@
- **语法高亮**:在普通 XML 高亮之上叠加领域标记(`$DEFINE` 常量、`inheritFrom``xai:joinAction`、结构标签);XML 语法异常(如未闭合引号)期间由语义 token 兜底,标签/属性/值着色不中断。
- **自动补全**
- 元素名:按当前父元素的 XSD 模型补全子元素;顶层资产(`AssetDeclaration` 内)补全 `GameObject``WeaponTemplate` 等 295 种类型。
- 元素名:按当前父元素的 XSD 模型补全子元素;顶层资产(`AssetDeclaration` 内)补全 `GameObject``WeaponTemplate` 等 295 种类型。已输入 `<` 时补全保留该 `<`、只替换名称区(不会出现 `<<`);需要填文本的 simple-content 元素(如 `<CreateObject>`)补全为 `<CreateObject>$1</CreateObject>` 并自动弹出值补全,而不是无法填值的自闭合标签。
- 属性名:必填属性优先,附带类型/文档/默认值;自动提示 `xai:joinAction``xmlns:xai`。接受补全时自动避免与上一个属性贴在一起,并按文件已有的排版补空格或换行(换行的基础缩进由编辑器提供,插件不再内嵌缩进以免叠加);数字/角度/时间等标量属性直接填入 XSD 默认值或类型示例(如 `0d``0s`),引用/枚举/布尔等保留真正的 `$1` 占位符并弹出值补全。
- 属性值:
- 引用型属性(如 `CommandSet``Weapon`)按 `xas:refType` 补全对应类型的资产 ID(**同名 ID 只补全匹配类型**);
@@ -14,15 +14,19 @@
- 枚举与位标志列表(如 `Include type``LocomotorTemplate@Surfaces``KindOf`;列表值在空格后自动继续补全下一项,已使用的 flag 不再重复推荐,闭合值末尾可直接追加新 flag);
- 布尔值、`$DEFINE` 常量;
- `<Include source>` 补全可解析的 `DATA:` / `ART:` / `AUDIO:` 与项目相对路径。
- 元素文本内容:simple-content 引用元素(如 `<CreateObject>``<RequiredUpgrade>``<SpawnTemplate>`)直接在标签间补全对应类型的资产 ID(`GameObjectWeakRef` → GameObject)、枚举或 `$DEFINE`
接受片段后的 `$1` 光标位置会立即弹出值补全,而不是属性名;引用列表超过
400 条时标记为不完整,继续输入会重新请求,因此 `CrateDebris_01` 这类排在
列表后部的 id 不会因首屏截断而消失。
- **悬停提示**:元素/属性显示 XSD 文档、类型、必填/默认值;引用值显示定义位置;`$DEFINE` 显示值与定义位置;`Include source` / `xi:include href` 显示解析后的目标文件;`xi:include` 元素与属性给出 XInclude 说明。
- **引用导航**:从引用值(`CommandSet="..."``Weapon="..."``inheritFrom`)跳转到定义(严格按引用类型过滤,候选由 `ra3modxml.definitionMode` 控制:`all` 列出 mod + 原版、`project-only` 优先项目内定义);`Ctrl+点击` Include / `xi:include href` 打开目标文件;Find All References 搜索整个工作区;文档大纲列出顶层资产与 `$DEFINE`
- **引用导航**:从引用值(`CommandSet="..."``Weapon="..."``inheritFrom``<CreateObject>ID</CreateObject>` 等元素文本)跳转到定义(严格按引用类型过滤,候选由 `ra3modxml.definitionMode` 控制:`all` 列出 mod + 原版、`project-only` 优先项目内定义);`Ctrl+点击` Include / `xi:include href` 打开目标文件;Find All References 同时搜索属性值与元素文本内容;文档大纲列出顶层资产与 `$DEFINE`
- **当前文档局部作用域(T1)**:即使一个文件不在任何全局流里(没有从
`Data/Mod.xml` / `additionalmaps` 可达),插件也会按当前文件自身的资产、
`$DEFINE` 及其 include 链建立局部索引。`xi:include` 会在逻辑树中展开,
使 include 进来的内容获得正确的父上下文;`AttachModuleId` / `ModuleId` /
`AutoResolveBody` 等管线局部(Poid)引用可以补全、悬停与跳转到同一
GameObject 内的模块(含通过 `xi:include` 拼入的兄弟模块)。
- **错误检查**:XML 格式错误、未知元素/属性(`xi:` 等外来命名空间不误报)、顶层资产缺 `id`、重复 ID、未解析引用(含类型不匹配)、Include / 嵌套 `xi:include` 目标找不到、`$DEFINE` 未定义。
- **错误检查**:XML 格式错误、未知元素/属性(`xi:` 等外来命名空间不误报)、顶层资产缺 `id`、重复 ID、未解析引用(含属性值与元素文本内容、类型不匹配)、Include / 嵌套 `xi:include` 目标找不到、`$DEFINE` 未定义。
- **manifest 支持**`<Include type="reference">` 指向的 `static/global/audio.manifest`SDK `builtmods`)会被解析,manifest 中的原版资产 ID 可用于补全/悬停/导航/诊断。
- **美术资产(`.w3x`**`W3X.xml` / `ART:` include 链中的 `.w3x` 模型文件会被
索引(`W3DContainer` / `W3DMesh` / `W3DHierarchy` 等顶层资产),因此
+231
View File
@@ -1278,3 +1278,234 @@ VS Code 在插入**含换行的补全文本**时,会给新行套用当前行
版本 **0.1.11 → 0.1.13**(0.1.12 为中间版本,仅含锚点与尾随空格修复,
未解决叠加;0.1.13 为最终修复)。
---
## 二十一、问题分析(第十六轮,2026-08-04):元素文本内容(simple content)引用与 `<<` 补全
### 现象
用户样例(`ObjectCreationList``CreateObject` 内输入 `<`):
1. 补全菜单出现 `CreateObject`,接受后变成 `<<CreateObject />`——两个尖括号
非法 XML
2. 嵌套 `<CreateObject>` 被补全成自闭合 `<CreateObject />`,无法在标签内填
单位的 id;正确写法应是 `<CreateObject>CrateDebris_01</CreateObject>`
(原版 `SageXml\GlobalData\ObjectCreationLists.xml` 与 AttachTest
`ObjectCreationList.xml` 均为此形态);
3. 手动改成 `<CreateObject>C</CreateObject>` 后,内容区不出现 C 开头的
GameObject id 补全;
4.`<CreateObject>CrateDebris_01</CreateObject>` 的文本 hover 与
Ctrl+点击导航均无效。
### 根因(三个独立缺陷叠加)
**A. 内容区补全没有处理“用户已输入 `<`”**
`<` 是补全触发字符(`extension.ts`)。`completion.ts``contentItems`
返回子元素候选时既不设置 `range``insertText` 又是完整的 `<Name…>`VS Code
就把完整标签**追加**在已输入的 `<` 后面 → `<<Name />`。影响所有内容区子元素
补全(`Offset``RequiredUpgrade` 等同样中招),不只 `CreateObject`
**B. `elementSnippet` 不知道“simple type 元素要填文本”**
XSD 中 `CreateObjectNugget` 明确声明
`<xs:element name="CreateObject" type="GameObjectWeakRef"/>``GameObjectWeakRef`
是 simple type,标签内容是 GameObject id。`elementSnippet` 只区分顶层 / 有子
元素的复杂类型 / 其他,simple type 落入 `<Name />` 分支,于是生成了永远填不了
值的自闭合标签。
**C. 所有 feature 都只认属性值,不认元素文本内容**
- `contentItems` 对 simple type 元素查“子元素列表”得到空 → 内容区无补全;
- `hover` 只检查属性名/属性值/元素名,文本区间不返回任何结果;
- `provideDefinition` 找不到属性直接 `return null`
- `checkValueReferences` 只对属性做未解析引用检查;
- Find All References 的正则只匹配 `["']id["']`
### 类似情况盘点(模型统计)
XSD 模型中共 **371 处“子元素是 simple type”的声明(149 个不同元素名)**
| 类别 | 数量 | 例子 |
|---|---|---|
| 带 `xas:refType` 的引用内容 | 291 | `CreateObject`→GameObject、`RequiredUpgrade`/`ForbiddenUpgrade`→UpgradeTemplate、`SpawnTemplate`→GameObject、`Filename`→AudioFile、`RequiredObject`→GameObject |
| 无类型 `AssetReference`isRef 无 refType | 9 | `FXShaderConstantTexture@Value``RenderSubObjectReference@Mesh/CollisionBox` |
| 枚举内容 | 22 | `SourceMustNotHaveBeenDisabledThisFrameByType` 等 |
| 允许 `$DEFINE` 的内容 | 22 | 各类数值内容 |
| 普通字符串 | 27 | `DisplayName` 等 |
关键取证:`FXShaderConstantTexture@Value` 在真实 XML 里填的是**贴图名、数值、
布尔**(如 `AUMCV``1.000000``false`),`RenderSubObjectReference@Mesh`
在 w3x 里填的是**同一模型文件内的子对象名**——它们虽然被 XSD 标成
`AssetReference`(isRef),却不是全局资产 ID。因此**只有带 refType 的 simple
内容**才按全局引用处理;无类型 `AssetReference``Poid` 一律不参与全局
补全 / hover / 跳转 / 诊断,避免误报。
### 修复
1. **内容区 `<` 处理**`completion.ts`):`contentChildItems` 检测光标前是否
已有 `<`(可带半截名字),有则把替换范围起点设为该 `<`,接受完整
`<Name…>` 片段时是“替换”而非“追加”,从根上消灭 `<<``</` 输入不触发子元素
补全。
2. **simple type 元素片段**`elementSnippet` 对 simple type 返回
`<Name>$1</Name>`(带占位符),引用/枚举/define 内容在插入后自动弹值补全
`editor.action.triggerSuggest`)。
3. **内容值补全**`contentItems` 增加 simple-content 分支——元素自身类型是
simple 时按“值”补全:refType 资产 ID(严格按类型过滤)、枚举、`$DEFINE`
替换范围只覆盖当前文本 token(`xmlParser.textContentTokenAt`)。
4. **引用层纯函数**`refs.ts`):新增 `isReferenceContentType(typeName)`
simple + refType + 排除 Poid)与 `resolveContentReferenceTargets(...)`
复用属性引用的类型过滤/评分逻辑。
5. **hover / 定义跳转 / 诊断 / Find All References**:均增加元素文本 token
分支;Find All References 的搜索模式扩展为同时匹配属性值(`"id"`)与
内容引用(`>id<`),并把结果范围裁剪到 id 本身。
### 举一反三的测试(113 → 121 全绿)
- `xmlParser.test.mjs``textContentTokenAt` 返回内容 token、边界与空白内容
返回 null
- `refs.test.mjs``GameObjectWeakRef` 内容只解析到 GameObject(同名
WeaponTemplate 排除);`AssetReference` / `Poid` / `string` 不是全局内容
引用;
- `completion.test.mjs`:用户原始场景——`<` 后接受 CreateObject 的 range 覆盖
`<``insertText``<CreateObject>$1</CreateObject>`、应用后不含 `<<`
`<CreateObject>C</CreateObject>` 内只补 C 开头的 GameObject(类型过滤 +
token range);
- `contentFeatures.test.mjs`(新增):hover 显示内容引用定义、Ctrl+点击跳到
同文件精确定位、诊断只报“带 refType 的内容引用”且不误报 WeakReference
内容。
版本 **0.1.13 → 0.1.14**
### 补充(0.1.14 实机回归,2026-08-04):真实文件里 `<` 后仍有闭合标签
单元测试最初只覆盖了“文件在 `<` 处结束”的形态,用户实机复测时 `<` 后面还有
`</CreateObject>``<<` 依然出现(这次是 `<<Tag>(光标在中间)</Tag>`)。
**第二层根因**:解析器的 `findTagEnd` 扫描残缺开始标签时会一直找到**后面闭合
标签的 `>`**,把它当成这个开始标签的结束,于是生成一个空名/半截名的伪元素
(如 name 为 `""``"Cr"`)。光标落在该伪元素的 start tag 内 → 补全走
`element-name` 分支,而该分支的替换范围只从 `<` **之后**开始,插入完整的
`<Name>…</Name>` 片段就变成 `<<Name>`
**修复**(两层):
1. `findTagEnd` 在引号外遇到 `<`(在找到 `>` 之前)直接视为“未闭合开始标签”,
走原有的行尾恢复路径:`<` 单独出现时不再产生伪元素,内容区保持 content
上下文;`<Cr` 半截名则恢复为 `recoveredStartTag` 元素壳,仍由内容分支补全
(该分支的 range 已包含 `<`)。
2. `elementNameItems` 的替换范围改为从元素 `start`(即 `<`)开始,即使后续
解析仍产生伪元素,也不会再 `<<`
测试补 121 → 125`<` 后跟闭合标签不吞掉闭合标签、半截名恢复为元素壳、真实
文件形态(`<` 后有多行属性与闭合标签)接受 CreateObject 后不含 `<<`、半截名
`<Cr` 同样不含 `<<`
### 补充 20.1.14 实机回归):`<<` 修掉后补全菜单反而消失
`<` 纳入替换范围后,单测直接调用 provider 全部通过,但 VS Code 实机里
`<` 后**不再弹出任何补全**`<CreateObject>C`(尚未输入闭合标签)也不触发,
必须补上 `</CreateObject>` 才有候选。
**根因**:VS Code 用“替换范围内的文本”作为过滤前缀。范围包含 `<` 时,前缀
就是 `<`,所有标签名都不匹配 → 菜单为空;范围为零宽且元素未闭合时,
`textContentTokenAt``closeTagStart < 0` 返回 nullrange 落在光标处、
没有覆盖已输入的 `C`,同样过滤不到。
**修复**(回到标准补全模式):
1. **已输入的 `<` 保留,不放进替换范围**`contentChildItems` /
`elementNameItems` 的 range 从 `<` **之后**开始,插入文本**不再带开括号**
`CreateObject>$1</CreateObject>`),应用结果仍是单个 `<`;没有输入 `<`
Ctrl+Space 内容补全)时才插入完整 `<Name>…</Name>`。这样过滤前缀是
`""` / `Cr`,菜单正常显示。
2. **未闭合元素也能提取内容 token**`textContentTokenAt`
`closeTagStart < 0` 时用 `el.end` 作为内容边界,`<CreateObject>C`EOF
也能覆盖已输入的 `C` 并给出前缀过滤。
测试 125 → 128:三个真实形态(孤立 `<``<Cr` 半截名、未闭合
`<CreateObject>C`)均断言插入文本不带开括号、range 覆盖正确文本、应用结果
`<<`,并新增“未输入 `<` 时插入完整标签”用例。
---
## 二十二、问题分析(第十七轮,2026-08-04):simple-content 补全变成属性补全;大列表截断后 CrateDebris 消失
### 现象
1. 接受 `<CreateObject>$1</CreateObject>` 片段后,弹出的不是 GameObject id
补全,而是 `xai:joinAction``xmlns:xai` 两个属性名候选;
2. 不接受这两个候选,直接在标签间输入 `C`:菜单要等较久才出现,且包含大量
C 开头的候选,但 **CrateDebris_01 不在其中**;继续输入 `r` / `D` / `e` /
`b` / `r` 后候选反而越来越少直至消失;
3. 快速输入 `Cr`(不等第一次菜单)则 CrateDebris_01 正常出现。
### 根因(两个独立问题)
**A. `>` 之后的零宽光标被当成“还在 start tag 内”**
`analyzeContext` 原来用 `offset <= startTagEnd` 判定光标在开始标签内。补全
片段 `<CreateObject>$1</CreateObject>` 接受后,`$1` 恰好落在
`startTagEnd``>` 后一格)这个零宽位置,于是被分到 `attribute-name`
返回的自然是 `xai:joinAction` / `xmlns:xai`
同类边界还有:已闭合元素的 `end` 之前也用了 `<=`,导致光标刚越过
`</CreateObject>` 时仍被当作“在子元素内容里”,而不是父元素的内容区。
**B. 引用列表超过 400 条后被硬截断,且没有告诉 VS Code“列表不完整”**
`assetIdItems` 对匹配到的 id 排序后直接 `slice(0, 400)`。VS Code 收到列表后
会在客户端按已输入的字符继续过滤这 400 条,**不会再次调用 provider**。
于是:
- 输入 `C` 时 CrateDebris_01 若排在 400 名之后,它从一开始就不在列表里;
- 继续输入 `r` 只是在这 400 条里过滤,CrateDebris_01 永远不会出现;
- 快速输入 `Cr` 时第一次请求的前缀已经是 `Cr`,匹配数小于 400,所以能看到。
`includeSourceItems` 还有一个变体:先 `slice(0, 400)` 再排序,同样可能把
真正优先级高的候选切掉;`defineItems` / `localIdItems` 也有同类截断隐患。
### 修复
1. **开始标签边界语义修正**`context.ts` / `xmlParser.ts`):
- `>` 之后(`offset === startTagEnd`)一律视为内容区,只有 `>` 尚未输入
的残缺开始标签仍保持 `attribute-name`
- 新增 `elementContainsOffset`:已闭合 / 自闭合元素的 `end` 为开区间,
光标在 `</Child>` 之后回到父元素内容;未闭合元素在 EOF 仍算在元素内。
2. **大列表标记 incomplete**`completion.ts`):`assetIdItems`
`includeSourceItems``defineItems``localIdItems` 一旦超过 400 条就返回
`CompletionList(..., isIncomplete: true)`。VS Code 会在用户继续输入时重新
请求 provider,窄前缀下 CrateDebris_01 这类“前 400 之外”的 id 不再丢失。
3. **局部优先 + 避免全量排序**:当前文档局部 overlay 的资产(`stream:
"local"`)在评分中额外降 0.4,先于全局项目候选;`topScoredDefs` 用
大小堆只保留前 400,不再对全部匹配 id 做 `sort`,降低大项目首键延迟。
4. **Include source 先排序再截断**:修掉“先 slice 后 sort”的顺序问题。
### 类似情况盘点
- 任何带 refType 的属性值 / `inheritFrom` / simple-content 引用,只要匹配数
超过 400,都存在“越输越少但目标永不出现”的风险——本轮统一用 incomplete
解决;
- 光标紧贴 `>` 后的零宽位置(简单元素片段 `$1`)与紧贴 `</Child>` 后的位置
是同一类“边界误归属”,本轮一并修掉;
- `Include@source` 的“先截断后排序”是同一家族 bug,已修复。
### 举一反三的测试(128 → 136 全绿)
- `context.test.mjs``>` 后且有闭合标签 → content;`>` 后但闭合标签尚未
输入 → content`</Child>` 之后 → 父元素 content
- `xmlParser.test.mjs`:已闭合 / 自闭合元素的 `end` 为开区间;未闭合元素
EOF 仍属于该元素;
- `completion.test.mjs`simple-content 片段 `$1` 位置返回值补全而不是
`xai:joinAction` / `xmlns:xai`450+ 候选时返回 incomplete 列表,窄前缀
重请求后找到 CrateDebris_01;当前文件 local overlay 资产即使总数超过
400 也保留在前 400。
版本 **0.1.14 → 0.1.15**。
### 遗留
若 Corona 上“输入 C 后菜单出现慢”仍然明显,下一个瓶颈大概率是
`getScope()` 每次文档版本变化都重建完整局部 include 链 / 逻辑树;本轮先把
候选截断与类型过滤造成的“列表错误”修掉,局部 scope 缓存优化留作独立一轮。
+35 -3
View File
@@ -1,6 +1,6 @@
# 调研结论与实施计划(已按最新代码同步更新)
> 说明:本文档随实现演进持续同步。最近一次同步(2026-08-04)对齐了实现过程中新增的模块与设计变更:BAB 精确搜索路径、manifest 类型/ID 推导、上下文感知元素类型、属性级 refType / Poid 局部引用(`id` 定义点)、精确跳转范围、嵌套 `xi:include`、注入式语法高亮、bit-flag 列表补全(空格触发 / 排除已用 / 追加模式)等。
> 说明:本文档随实现演进持续同步。最近一次同步(2026-08-04)对齐了实现过程中新增的模块与设计变更:BAB 精确搜索路径、manifest 类型/ID 推导、上下文感知元素类型、属性级 refType / Poid 局部引用(`id` 定义点)、精确跳转范围、嵌套 `xi:include`、注入式语法高亮、bit-flag 列表补全(空格触发 / 排除已用 / 追加模式)、simple-content 元素文本引用(补全 / hover / 跳转 / 诊断 / Find All References等。
## 一、调研结论(带证据)
@@ -85,7 +85,8 @@ src/
existence.ts 文件集存在性快照(目录枚举 Set,替代逐路径 statSync
manifestParser.ts .manifest 二进制解析 + 类型/ID 推导(纯 TS)
fileScanner.ts 目录遍历缓存 + Include source 候选收集
refs.ts 引用目标解析(按 refType / isRef / inheritFrom 过滤,纯 TS
refs.ts 引用目标解析(属性 + 元素文本内容,按 refType / isRef /
inheritFrom 过滤,纯 TS
shallowScan.ts 大体积美术资产(.w3x 等)顶层浅扫描(纯 TS,不建 DOM)
records.ts 每文件紧凑索引记录(资产/Define/Include/xi + 行号)
caches.ts 跨重建持久缓存(DocumentCache / IndexRecordsCache /
@@ -233,6 +234,23 @@ test/
第一个独占一行的完整属性作为规范缩进,插入换行时顺带吞掉触发补全留下的
尾随空格;属性名补全改用 `SnippetString``$1` 成为真正占位符),并新增
输出通道调试日志。
27. **simple-content 元素文本引用(第十六轮,2026-08-04)**simple type
子元素(如 `ObjectCreationList` 内嵌套 `<CreateObject>`,类型
`GameObjectWeakRef`)的**标签间文本**就是资产引用。内容区补全现在区分
“复杂元素 → 子元素名”与“简单元素 → 值补全”;用户已输入 `<` 时替换范围从
`<` 开始,杜绝 `<<`simple type 元素片段固定为 `<Name>$1</Name>`(可填
值)并自动触发值补全。hover / Ctrl 跳转 / 诊断 / Find All References
均增加内容 token 分支。只有**带 `xas:refType`** 的 simple 内容按全局引用
处理(291 处子元素声明);无类型 `AssetReference`
`FXShaderConstantTexture@Value``RenderSubObjectReference@Mesh`
真实数据是贴图/子对象名)与 `Poid` 不参与全局解析,避免误报。
补充:真实文件中 `<` 后还有 `</…>` 时,`findTagEnd` 曾把闭合标签的 `>`
当成残缺开始标签的结束,生成空名/半截名伪元素,补全走 element-name 分支
导致 `<<`。修复为引号外遇到 `<` 即视为未闭合(行尾恢复),且
`elementNameItems` 的替换范围包含 `<`。实机再回归:范围含 `<` 会让 VS Code
`<` 做过滤前缀导致菜单为空——改为保留已输入的 `<`、range 从 `<` 之后
开始、插入文本不带开括号;`textContentTokenAt` 对未闭合元素用 `el.end`
作内容边界。
## 三、实施步骤
@@ -278,6 +296,18 @@ test/
(编辑器自动补基础缩进,避免 3+3=6 式叠加)、完整属性锚点 + 首个独占一行
属性为规范缩进、`$1` 改为 SnippetString 占位符、输出通道调试日志、尾随
空格吞除;测试 111 → 113(版本 0.1.120.1.13)。
19. [x] simple-content 文本引用修复(第十六轮,2026-08-04):`<` 后补全范围
覆盖 `<`、simple type 元素片段 `<Name>$1</Name>`、简单元素内容值补全、
`refs.ts` 新增 `isReferenceContentType` / `resolveContentReferenceTargets`
hover / 定义跳转 / 诊断 / Find All References 内容分支;
测试 113 → 121(版本 0.1.14)。实机回归补充:`findTagEnd` 引号外遇 `<`
视为未闭合 + 插入文本不带开括号(range 从 `<` 之后开始);测试 121 → 125,
再回归 125 → 128(未闭合元素内容 token)。
20. [x] simple-content 零宽边界与大列表补全(第十七轮,2026-08-04):
`>` 后光标归入内容区、已闭合元素 `end` 改为开区间;超过 400 条的
id/define/local/include 候选返回 `isIncomplete` 让 VS Code 随输入重请求;
当前文档 local 资产优先、候选 top-N 用堆避免全量排序、Include source
先排序再截断;测试 128 → 136。
## 四、验证结果(实测)
@@ -300,7 +330,9 @@ test/
`childTypeOf`、大小写规范化、属性级 refType、外来命名空间判定、`xs:list`
枚举继承与 `isList` 标记)、引用过滤(`Weapon="X"` 只跳 `WeaponTemplate`
模块 `id` 定义点、Poid 局部引用、`xi:include` 不校验、`Side="Allies"` 命中
manifest 的 `PlayerTemplate`
manifest 的 `PlayerTemplate`、simple-content 文本引用(`<` 后补全不产生
`<<``<CreateObject>$1</CreateObject>` 片段、内容值按 refType 过滤、
内容 hover / Ctrl 跳转 / 诊断)。
> 注:D: 盘移动硬盘已恢复连接;Corona 已在第八 / 九轮按上述新数据回归。
+7
View File
@@ -41,9 +41,15 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
- 枚举值(XSD `xs:enumeration`);
- `$DEFINE` 常量(如 `$CIV_HEALTH_SMALL`);
- `<Include source>` 补全可解析的文件路径(`DATA:` / `ART:` / `AUDIO:`)。
- **元素文本内容(simple content**:带 `xas:refType` 的简单内容元素
(如 `<CreateObject>ID</CreateObject>`)在标签间补全对应类型的资产 ID、
枚举或 `$DEFINE`;补全出的 simple-content 元素必须是可填值的成对标签
`<Name></Name>`),且内容区已输入 `<` 时不得产生 `<<`
3. **引用提示(Hover**:元素/属性悬停显示 XSD 文档、类型、默认值;资产 ID 悬停显示定义位置;`$DEFINE` 悬停显示值与定义位置。
- 元素文本内容(simple content 引用)悬停同样显示定义位置。
4. **引用导航**
- 从引用型属性值跳转到对应资产定义(Go to Definition);
- 从 simple-content 引用元素(`<CreateObject>ID</CreateObject>`)的文本跳转到对应资产定义;
- 查找某资产 ID 的所有引用(Find All References);
- `<Include source>` / `xi:include href` 直接打开目标文件;
- `inheritFrom` 跳转到被继承元素;
@@ -54,6 +60,7 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
- 缺失必填 `id`(顶层资产);
- 重复 ID(同类型 + 同 id,mod 文件之间;覆盖原版 SageXml 不算冲突);
- 引用未解析(引用了不存在的资产 ID,可配置是否忽略原版 manifest 中的 ID);
- simple-content 引用元素的文本未解析(同属性引用规则,仅带 refType 的类型)。
- `<Include>` 目标文件找不到、Include 循环;
- `$DEFINE` 未定义。
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ra3-mod-xml",
"displayName": "RA3 Mod XML",
"description": "Red Alert 3 Mod XML tooling: syntax highlighting, completions, reference navigation and diagnostics for SAGE/BinaryAssetBuilder XML.",
"version": "0.1.13",
"version": "0.1.15",
"publisher": "ra3-mod-xml",
"license": "MIT",
"engines": {
+250 -30
View File
@@ -1,5 +1,6 @@
import * as vscode from "vscode";
import type { XmlAttribute, XmlElement } from "../language/xmlParser";
import { textContentTokenAt } from "../language/xmlParser";
import {
analyzeContext,
splitListValuePrefix,
@@ -7,7 +8,7 @@ import {
} from "../language/context";
import { resolveElementType } from "../language/typeContext";
import * as model from "../model/schemaModel";
import type { AttributeInfo } from "../model/schemaModel";
import type { AttributeInfo, SimpleTypeInfo } from "../model/schemaModel";
import { isLocalReferenceAttribute } from "../indexer/refs";
import {
findContainingGameObject,
@@ -26,7 +27,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
document: vscode.TextDocument,
position: vscode.Position,
_token: vscode.CancellationToken,
): Promise<vscode.CompletionItem[]> {
): Promise<vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem>> {
if (!this.ws.isRa3Workspace()) return [];
const text = document.getText();
const offset = document.offsetAt(position);
@@ -61,10 +62,12 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
const names = this.childrenOf(parent);
if (!names.length) return [];
// The "<" already exists at the element's start: replace only the name
// area and insert the tag body WITHOUT a leading "<", otherwise the
// range text ("<") would also be used as the filter prefix and hide
// every suggestion.
const start = ctx.element
? document.offsetAt(
document.positionAt(ctx.element.start + (ctx.closing ? 2 : 1)),
)
? ctx.element.start + (ctx.closing ? 2 : 1)
: document.offsetAt(position);
const range = new vscode.Range(document.positionAt(start), position);
const items: vscode.CompletionItem[] = [];
@@ -79,7 +82,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
(type ? `Type: ${type}` : "");
item.documentation = docText ? new vscode.MarkdownString(docText) : undefined;
item.detail = type ? `RA3 XML · ${type}` : "RA3 XML";
item.insertText = this.elementSnippet(child.name, type);
item.insertText = this.elementSnippet(child.name, type, ctx.element == null);
items.push(item);
}
return items;
@@ -103,16 +106,27 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
return [];
}
private elementSnippet(name: string, type: string | null): vscode.SnippetString {
private elementSnippet(
name: string,
type: string | null,
includeOpenBracket = true,
): vscode.SnippetString {
const open = includeOpenBracket ? "<" : "";
if (model.isTopLevelElement(name)) {
return new vscode.SnippetString(`<${name} id="$1">\n\t$0\n</${name}>`);
return new vscode.SnippetString(`${open}${name} id="$1">\n\t$0\n</${name}>`);
}
const info = type ? model.typeInfo(type) : undefined;
// Simple types hold text content (asset id / enum / define / string), so
// they need an explicit closing tag and a value placeholder instead of a
// self-closing tag that can never contain a value.
if (info?.kind === "simple") {
return new vscode.SnippetString(`${open}${name}>$1</${name}>`);
}
const hasChildren = info?.kind === "complex" && info.children.length > 0;
if (hasChildren) {
return new vscode.SnippetString(`<${name}>\n\t$0\n</${name}>`);
return new vscode.SnippetString(`${open}${name}>\n\t$0\n</${name}>`);
}
return new vscode.SnippetString(`<${name} />`);
return new vscode.SnippetString(`${open}${name} />`);
}
// ── Attribute name ────────────────────────────────────────────────
@@ -238,7 +252,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
document: vscode.TextDocument,
position: vscode.Position,
idx: ModIndex | null,
): vscode.CompletionItem[] {
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const el = ctx.element;
const attr = ctx.attr;
if (!el || !attr) return [];
@@ -358,7 +372,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
* (" FLAG") so flags can be appended to an already-closed value.
*/
private listEnumItems(
attrInfo: AttributeInfo,
attrInfo: { enumValues: string[]; type?: string | null },
rawPrefix: string,
seg: { token: string; start: number },
valueRange: vscode.Range,
@@ -410,18 +424,18 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
idx: ModIndex,
prefix: string,
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] {
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const lower = prefix.toLowerCase();
const candidates = idx.sourceCandidates
.filter((c) => c.source.toLowerCase().includes(lower))
.slice(0, MAX_VALUE_ITEMS);
const candidates = idx.sourceCandidates.filter((c) =>
c.source.toLowerCase().includes(lower),
);
const priority: Record<string, number> = { "": 0, DATA: 1, ART: 2, AUDIO: 3 };
candidates.sort(
(a, b) =>
(priority[a.prefix ?? ""] ?? 4) - (priority[b.prefix ?? ""] ?? 4) ||
a.source.localeCompare(b.source),
);
return candidates.map((c) => {
const items = candidates.map((c) => {
const item = make(c.source, vscode.CompletionItemKind.File, "Include source");
item.detail = c.path;
item.documentation = new vscode.MarkdownString(
@@ -429,6 +443,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
);
return item;
});
return this.limitItems(items, items.length);
}
private assetIdItems(
@@ -437,7 +452,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
refType: string | null,
prefix: string,
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] {
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const lower = prefix.toLowerCase();
const scored: { def: AssetDef; score: number }[] = [];
const seen = new Set<string>();
@@ -451,6 +466,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
if (refType && model.isAssignableTo(def.type, refType)) score = 1;
if (selfType && model.isAssignableTo(def.type, selfType)) score = 0;
if (def.origin === "project") score -= 0.2;
if (def.stream === "local") score -= 0.4;
scored.push({ def, score });
};
@@ -476,8 +492,8 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
}
}
scored.sort((a, b) => a.score - b.score || a.def.id.localeCompare(b.def.id));
return scored.slice(0, MAX_VALUE_ITEMS).map(({ def }) => {
const top = topScoredDefs(scored, MAX_VALUE_ITEMS);
const items = top.map(({ def }) => {
const origin = def.origin === "manifest" ? `manifest (${def.manifestSource ?? ""})` : def.origin;
const doc = new vscode.MarkdownString();
doc.appendCodeblock(def.id);
@@ -486,13 +502,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
doc.appendMarkdown(`**Origin**: ${origin}`);
return make(def.id, vscode.CompletionItemKind.Value, `${def.type} · ${origin}`, doc.value);
});
return this.limitItems(items, scored.length);
}
private defineItems(
idx: ModIndex,
prefix: string,
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] {
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const lower = prefix.replace(/^[=$]*/, "").toLowerCase();
const items: vscode.CompletionItem[] = [];
const seen = new Set<string>();
@@ -510,14 +527,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
items.push(item);
}
}
return items.slice(0, MAX_VALUE_ITEMS);
return this.limitItems(items, items.length);
}
private localIdItems(
el: LogicalElement,
prefix: string,
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] {
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const root = findContainingGameObject(el);
if (!root) return [];
const lower = prefix.toLowerCase();
@@ -533,35 +550,238 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
),
);
}
return items;
return this.limitItems(items, items.length);
}
/**
* VS Code filters the returned items client-side while the user keeps
* typing. Once the result is capped, the list must be marked incomplete so
* the provider is asked again with the narrower prefix; otherwise a wanted
* id (e.g. CrateDebris_01) can be silently cut off behind the first 400
* alphabetically-earlier candidates and never reappear.
*/
private limitItems<T extends vscode.CompletionItem>(
items: T[],
total: number,
): T[] | vscode.CompletionList<T> {
if (total <= MAX_VALUE_ITEMS) return items;
return new vscode.CompletionList(items.slice(0, MAX_VALUE_ITEMS), true);
}
// ── Element content ───────────────────────────────────────────────
private contentItems(
ctx: CompletionContext,
_document: vscode.TextDocument,
_position: vscode.Position,
_idx: ModIndex | null,
): vscode.CompletionItem[] {
document: vscode.TextDocument,
position: vscode.Position,
idx: ModIndex | null,
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const el = ctx.element;
if (!el) return [];
// Reuse element-name suggestions with a plain replacement range.
const elType = resolveElementType(el);
const info = elType ? model.typeInfo(elType) : undefined;
// Simple-content element: the text between the tags is the value itself
// (e.g. <CreateObject>CrateDebris_01</CreateObject>), so offer value
// completions (asset ids / enums / defines) instead of child elements.
if (info?.kind === "simple") {
return this.simpleContentItems(el, elType, info, document, position, idx);
}
return this.contentChildItems(el, document, position);
}
private simpleContentItems(
el: XmlElement,
elType: string | null,
info: SimpleTypeInfo,
document: vscode.TextDocument,
position: vscode.Position,
idx: ModIndex | null,
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const text = document.getText();
const offset = document.offsetAt(position);
const token = textContentTokenAt(text, el, offset);
const prefix = token ? text.slice(token.start, Math.min(offset, token.end)) : "";
const rawPrefix = text.slice(el.startTagEnd, offset);
const isList = info.isList === true;
const seg = isList
? splitListValuePrefix(rawPrefix)
: { token: prefix, start: token ? token.start - el.startTagEnd : 0 };
const rangeStart = token ? token.start : offset;
const valueRange = new vscode.Range(
document.positionAt(rangeStart),
document.positionAt(Math.max(rangeStart, offset)),
);
const make = (
label: string,
kind: vscode.CompletionItemKind,
detail: string,
doc?: string,
range?: vscode.Range,
insertText?: string,
) => {
const item = new vscode.CompletionItem(label, kind);
item.range = range ?? valueRange;
item.insertText = insertText ?? label;
item.detail = detail;
if (doc) item.documentation = new vscode.MarkdownString(doc);
return item;
};
// Typed asset references only (isRef without refType is used by real
// data for shader constants / mesh sub-object names, not global ids).
if (info.refType) {
if (!idx) return [];
return this.assetIdItems(idx, null, info.refType, seg.token, make);
}
if (info.enumValues.length) {
if (isList) return this.listEnumItems(info, rawPrefix, seg, valueRange, make);
return info.enumValues
.filter((v) => v.toLowerCase().startsWith(seg.token.toLowerCase()))
.map((v) => make(v, vscode.CompletionItemKind.EnumMember, elType ?? "enum"));
}
if (idx && info.allowsDefine) {
return this.defineItems(idx, seg.token, make);
}
return [];
}
private contentChildItems(
el: XmlElement,
document: vscode.TextDocument,
position: vscode.Position,
): vscode.CompletionItem[] {
const elType = resolveElementType(el);
const names = elType ? model.childrenOfType(elType) : model.childrenOfElement(el.name);
const items: vscode.CompletionItem[] = [];
if (!names.length) return items;
const text = document.getText();
const offset = document.offsetAt(position);
// When the user already typed "<" (optionally followed by a partial
// name), keep that "<" and replace only the name area; the inserted
// snippet then has no leading "<" so the range text stays a valid
// filter prefix ("", "Cr", ...) instead of "<" (which would hide every
// item). Without a typed "<" the full "<Name>…</Name>" is inserted.
const { rangeStart, typedOpen } = contentElementRange(text, offset);
const range = new vscode.Range(document.positionAt(rangeStart), position);
for (const child of names) {
const item = new vscode.CompletionItem(child.name, vscode.CompletionItemKind.Field);
item.insertText = this.elementSnippet(child.name, child.type);
item.range = range;
item.insertText = this.elementSnippet(child.name, child.type, !typedOpen);
const type = child.type;
const info = type ? model.typeInfo(type) : undefined;
item.detail = type ? `RA3 XML · ${type}` : "RA3 XML";
const doc = child.doc || (info?.kind === "complex" ? info.doc : "");
if (doc) item.documentation = new vscode.MarkdownString(doc);
if (info?.kind === "simple" && this.simpleContentValueKind(info)) {
item.command = {
command: "editor.action.triggerSuggest",
title: "Suggest content value",
};
}
items.push(item);
}
return items;
}
private simpleContentValueKind(info: SimpleTypeInfo): boolean {
return (
info.refType != null ||
info.enumValues.length > 0 ||
info.allowsDefine
);
}
}
interface ScoredDef {
def: AssetDef;
score: number;
}
function compareScoredDefs(a: ScoredDef, b: ScoredDef): number {
return a.score - b.score || a.def.id.localeCompare(b.def.id);
}
/**
* Returns the best `limit` scored definitions without sorting the whole
* candidate list. A max-heap keeps the worst item of the current top set at
* the root, so every additional candidate only needs an O(log limit) check.
*/
function topScoredDefs(scored: ScoredDef[], limit: number): ScoredDef[] {
if (scored.length <= limit) {
scored.sort(compareScoredDefs);
return scored;
}
const better = (a: ScoredDef, b: ScoredDef) => compareScoredDefs(a, b) < 0;
const heap: ScoredDef[] = [];
const swap = (i: number, j: number) => {
const t = heap[i];
heap[i] = heap[j];
heap[j] = t;
};
const siftUp = (i: number) => {
while (i > 0) {
const parent = (i - 1) >> 1;
if (better(heap[i], heap[parent])) {
swap(i, parent);
i = parent;
} else {
break;
}
}
};
const siftDown = (i: number) => {
for (;;) {
const left = i * 2 + 1;
const right = left + 1;
let worst = i;
if (left < heap.length && better(heap[left], heap[worst])) worst = left;
if (right < heap.length && better(heap[right], heap[worst])) worst = right;
if (worst === i) break;
swap(i, worst);
i = worst;
}
};
for (const entry of scored) {
if (heap.length < limit) {
heap.push(entry);
siftUp(heap.length - 1);
} else if (better(entry, heap[0])) {
heap[0] = entry;
siftDown(0);
}
}
heap.sort(compareScoredDefs);
return heap;
}
/**
* Replacement range for a child-element completion in element content:
* - when the user typed "<" (optionally followed by a partial name), the
* "<" is kept and the range covers the name area after it;
* - a partial name typed without "<" is replaced as a word;
* - whitespace-only content leaves the range empty at the cursor.
* Typing a closing tag ("</" / "</Name") never suggests child elements.
*/
function contentElementRange(
text: string,
offset: number,
): { rangeStart: number; typedOpen: boolean } {
let j = offset;
while (j > 0 && /[ \t]/.test(text[j - 1])) j--;
let i = j;
while (i > 0 && /[A-Za-z0-9_:.-]/.test(text[i - 1])) i--;
if (i >= 2 && text.slice(i - 2, i) === "</") {
return { rangeStart: offset, typedOpen: false };
}
if (i > 0 && text[i - 1] === "<") {
return { rangeStart: i, typedOpen: true };
}
if (i < j) return { rangeStart: i, typedOpen: false };
return { rangeStart: offset, typedOpen: false };
}
interface AttributeInsertLayout {
+79
View File
@@ -7,8 +7,10 @@ import * as model from "../model/schemaModel";
import type { ModWorkspace } from "../workspace";
import type { ModIndex } from "../indexer/types";
import {
isReferenceContentType,
isReferenceAttributeOfType,
mergeLocalAndGlobalDefs,
resolveContentReferenceTargets,
resolveReferenceTargetsForType,
} from "../indexer/refs";
import type { LogicalElement } from "../indexer/logicalTree";
@@ -198,6 +200,7 @@ export class Ra3Diagnostics {
provisional,
);
}
this.checkContentReferences(el, elType, document, idx, diags, provisional);
}
// Include-specific checks.
@@ -326,6 +329,82 @@ export class Ra3Diagnostics {
);
}
private checkContentReferences(
el: XmlElement,
elType: string | null,
document: vscode.TextDocument,
idx: ModIndex | null,
diags: vscode.Diagnostic[],
provisional: boolean,
): void {
// Only simple-content elements carry a text value; complex elements'
// "content" is child markup and must not be scanned for value refs.
const info = elType ? model.typeInfo(elType) : undefined;
if (info?.kind !== "simple") return;
if (el.selfClosing || el.closeTagStart < 0) return;
const text = document.getText();
const raw = text.slice(el.startTagEnd, el.closeTagStart);
const value = raw.trim();
if (!value) return;
const valueStart = el.startTagEnd + raw.indexOf(value);
const range = new vscode.Range(
document.positionAt(valueStart),
document.positionAt(valueStart + value.length),
);
// Undefined $DEFINE references.
const defineRe = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
let m: RegExpExecArray | null;
while ((m = defineRe.exec(value)) !== null) {
if (
idx &&
!(
idx.local?.defines.has(m[1].toLowerCase()) ??
idx.defines.has(m[1].toLowerCase())
)
) {
const code = provisional ? "undefined-define-indexing" : "undefined-define";
diags.push(
this.diag(
range,
`Undefined define "$${m[1]}"` +
(provisional ? " (index incomplete — may be a false positive)" : ""),
vscode.DiagnosticSeverity.Warning,
code,
),
);
}
}
if (value.startsWith("$") || value.startsWith("=")) return;
const severity = this.ws.settings.reportUnresolvedReferences;
if (severity === "none" || !idx) return;
if (!isReferenceContentType(elType)) return;
const targets = resolveContentReferenceTargets(idx, elType, value);
if (targets.length) return;
const anyDef =
(idx.local?.assetsById.has(value.toLowerCase()) ?? false) ||
idx.assetsById.has(value.toLowerCase());
const refType = info.refType;
const expected = refType ? `of type \`${refType}\`` : "of the expected declared type";
const code = provisional ? "unresolved-reference-indexing" : "unresolved-reference";
const baseMessage = anyDef
? `Reference "${value}" has no definition ${expected} (ids with the same name exist for other types)`
: `Unresolved reference "${value}" (not found in the current index)`;
diags.push(
this.diag(
range,
provisional
? `${baseMessage} (index incomplete — may be a false positive)`
: baseMessage,
severity === "warning"
? vscode.DiagnosticSeverity.Warning
: vscode.DiagnosticSeverity.Information,
code,
),
);
}
private checkInclude(
el: XmlElement,
document: vscode.TextDocument,
+87 -25
View File
@@ -1,12 +1,15 @@
import * as vscode from "vscode";
import { findElementAt } from "../language/xmlParser";
import { findElementAt, textContentTokenAt } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import * as model from "../model/schemaModel";
import type { ModWorkspace } from "../workspace";
import {
isLocalReferenceAttribute,
isReferenceAttributeOfType,
isReferenceContentType,
resolveContentReferenceTargets,
resolveReferenceTargetsForType,
type ReferenceTarget,
} from "../indexer/refs";
import {
findContainingGameObject,
@@ -27,6 +30,7 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
): Promise<vscode.Hover | null> {
if (!this.ws.isRa3Workspace()) return null;
const offset = document.offsetAt(position);
const text = document.getText();
const scope = await this.ws.getScope(document);
const doc = scope.expanded;
const el = findElementAt(doc, offset);
@@ -45,6 +49,12 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
return this.valueHover(el, elType, attr.name, attr.value, document, scope);
}
}
// Element text content (e.g. <CreateObject>CrateDebris_01</CreateObject>).
const contentToken = textContentTokenAt(text, el, offset);
if (contentToken) {
const h = this.contentHover(elType, contentToken.value, document, scope);
if (h) return h;
}
// Element name.
const nameStart = el.start + 1;
if (offset >= nameStart && offset <= nameStart + el.name.length) {
@@ -134,14 +144,8 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
const defs =
idx.local?.defines.get(defineMatch[1].toLowerCase()) ??
idx.defines.get(defineMatch[1].toLowerCase());
if (defs?.length) {
const d = defs[0];
md.appendMarkdown(`**Define** \`$${d.name}\` \n`);
md.appendCodeblock(d.value);
const rel = relativePath(document, d.file);
md.appendMarkdown(`Defined in \`${rel}:${d.line}\``);
return new vscode.Hover(md);
}
const h = this.defineHover(defs, document);
if (h) return h;
}
// Include source.
@@ -196,18 +200,7 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
{
if (!isReferenceAttributeOfType(elType, attrName)) return null;
const targets = resolveReferenceTargetsForType(idx, elType, attrName, value);
if (targets.length) {
const md2 = new vscode.MarkdownString();
md2.appendMarkdown(`**${targets.length} definition${targets.length > 1 ? "s" : ""}** \n`);
for (const { def: d } of targets.slice(0, 8)) {
const loc =
d.origin === "manifest"
? `manifest \`${d.manifestSource ?? d.file}\``
: `\`${relativePath(document, d.file)}:${d.line}\``;
md2.appendMarkdown(`- \`${d.type}\` · ${loc} \n`);
}
return new vscode.Hover(md2);
}
if (targets.length) return this.definitionsHover(targets, document);
const attrRef = model
.attributesOfType(elType)
.find((a) => a.name === attrName);
@@ -216,12 +209,81 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
: attrRef?.isRef
? " of the expected declared type"
: "";
md.appendMarkdown(
`No matching definition${expected} in the current index` +
" (may exist in a compiled manifest or vanilla data).",
);
return this.noDefinitionHover(expected);
}
}
/**
* Hover for text inside a simple-content element whose type is a typed
* asset reference (e.g. <CreateObject> with GameObjectWeakRef).
*/
private contentHover(
elType: string | null,
value: string,
document: vscode.TextDocument,
scope: DocumentScope,
): vscode.Hover | null {
const idx = scope.merged;
const defineMatch = /\$([A-Za-z_][A-Za-z0-9_]*)/.exec(value);
if (defineMatch && idx) {
const defs =
idx.local?.defines.get(defineMatch[1].toLowerCase()) ??
idx.defines.get(defineMatch[1].toLowerCase());
const h = this.defineHover(defs, document);
if (h) return h;
}
if (!isReferenceContentType(elType)) return null;
if (!idx) {
const md = new vscode.MarkdownString();
md.appendMarkdown("Index is still building — references cannot be resolved yet.");
return new vscode.Hover(md);
}
const targets = resolveContentReferenceTargets(idx, elType, value);
if (targets.length) return this.definitionsHover(targets, document);
const info = elType ? model.typeInfo(elType) : undefined;
const refType = info?.kind === "simple" ? info.refType : null;
return this.noDefinitionHover(
refType ? ` of type \`${refType}\`` : " of the expected declared type",
);
}
private defineHover(
defs: { name: string; value: string; file: string; line: number }[] | undefined,
document: vscode.TextDocument,
): vscode.Hover | null {
if (!defs?.length) return null;
const d = defs[0];
const md = new vscode.MarkdownString();
md.appendMarkdown(`**Define** \`$${d.name}\` \n`);
md.appendCodeblock(d.value);
const rel = relativePath(document, d.file);
md.appendMarkdown(`Defined in \`${rel}:${d.line}\``);
return new vscode.Hover(md);
}
private definitionsHover(
targets: ReferenceTarget[],
document: vscode.TextDocument,
): vscode.Hover {
const md2 = new vscode.MarkdownString();
md2.appendMarkdown(`**${targets.length} definition${targets.length > 1 ? "s" : ""}** \n`);
for (const { def: d } of targets.slice(0, 8)) {
const loc =
d.origin === "manifest"
? `manifest \`${d.manifestSource ?? d.file}\``
: `\`${relativePath(document, d.file)}:${d.line}\``;
md2.appendMarkdown(`- \`${d.type}\` · ${loc} \n`);
}
return new vscode.Hover(md2);
}
private noDefinitionHover(expected: string): vscode.Hover {
const md = new vscode.MarkdownString();
md.appendMarkdown(
`No matching definition${expected} in the current index` +
" (may exist in a compiled manifest or vanilla data).",
);
return new vscode.Hover(md);
}
private localIdHover(
+66 -19
View File
@@ -1,6 +1,6 @@
import * as vscode from "vscode";
import { dirname } from "node:path";
import { findElementAt, parseXml } from "../language/xmlParser";
import { findElementAt, parseXml, textContentTokenAt } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import {
buildSearchPaths,
@@ -9,7 +9,10 @@ import {
} from "../indexer/includeResolver";
import {
isLocalReferenceAttribute,
isReferenceContentType,
resolveContentReferenceTargets,
resolveReferenceTargetsForType,
type ReferenceTarget,
} from "../indexer/refs";
import {
findContainingGameObject,
@@ -46,7 +49,19 @@ export class Ra3DefinitionProvider implements vscode.DefinitionProvider {
const attr = el.attrs.find(
(a) => a.hasValue && offset >= a.valueStart && offset <= a.valueEnd,
);
if (!attr) return null;
if (!attr) {
// Element text content (e.g. <CreateObject>CrateDebris_01</CreateObject>).
if (idx && isReferenceContentType(elType)) {
const token = textContentTokenAt(document.getText(), el, offset);
if (token && !token.value.startsWith("$")) {
const targets = resolveContentReferenceTargets(idx, elType, token.value);
if (targets.length) {
return this.referenceLocations(scope, idx, targets, document);
}
}
}
return null;
}
const value = attr.value;
const nameLower = attr.name.toLowerCase();
@@ -80,24 +95,34 @@ export class Ra3DefinitionProvider implements vscode.DefinitionProvider {
);
if (local) return local;
}
let targets = resolveReferenceTargetsForType(idx, elType, attr.name, value);
const targets = resolveReferenceTargetsForType(idx, elType, attr.name, value);
if (!targets.length) return null;
if (
this.ws.settings.definitionMode === "project-only" &&
targets.some((t) => t.def.origin === "project")
) {
targets = targets.filter((t) => t.def.origin === "project");
}
const locations: vscode.Location[] = [];
for (const { def } of targets.slice(0, 8)) {
const loc = await assetDefLocation(this.ws, def, idx, scope, document);
if (loc) locations.push(loc);
}
return locations.length ? locations : null;
return this.referenceLocations(scope, idx, targets, document);
}
return null;
}
private async referenceLocations(
scope: DocumentScope,
idx: ModIndex,
targets: ReferenceTarget[],
document: vscode.TextDocument,
): Promise<vscode.Location[] | null> {
let filtered = targets;
if (
this.ws.settings.definitionMode === "project-only" &&
targets.some((t) => t.def.origin === "project")
) {
filtered = targets.filter((t) => t.def.origin === "project");
}
const locations: vscode.Location[] = [];
for (const { def } of filtered.slice(0, 8)) {
const loc = await assetDefLocation(this.ws, def, idx, scope, document);
if (loc) locations.push(loc);
}
return locations.length ? locations : null;
}
private localIdLocation(
scope: DocumentScope,
el: LogicalElement,
@@ -264,19 +289,41 @@ export class Ra3ReferenceProvider implements vscode.ReferenceProvider {
(a.hasValue && offset >= a.valueStart && offset <= a.valueEnd) ||
(offset >= a.nameStart && offset <= a.nameEnd),
);
if (!attr?.hasValue) return null;
const id = attr.value;
let id: string | null = null;
if (attr?.hasValue) {
id = attr.value;
} else {
// Element text content (e.g. <CreateObject>CrateDebris_01</CreateObject>).
const elType = resolveElementType(el);
const token = textContentTokenAt(text, el, offset);
if (token && isReferenceContentType(elType) && !token.value.startsWith("$")) {
id = token.value;
}
}
if (!id || id.startsWith("$")) return null;
const locations: vscode.Location[] = [];
const pattern = `["']${escapeRegExp(id)}["']`;
// Matches both attribute values ("id" / 'id') and simple-content
// references (>id<); the outer delimiters are stripped from the result
// range below so the returned locations cover just the id.
const pattern = `(?:["']|>)[ \\t]*${escapeRegExp(id)}[ \\t]*(?:["']|<)`;
await findTextInWorkspace(
{ pattern, isRegExp: true },
{ include: "**/*.xml", maxResults: 2000 },
(result: { uri: vscode.Uri; matches: { range: vscode.Range }[] }) => {
if (!result.uri) return;
for (const m of result.matches) {
locations.push(new vscode.Location(result.uri, m.range));
const start = m.range.start;
const end = m.range.end;
locations.push(
new vscode.Location(
result.uri,
new vscode.Range(
new vscode.Position(start.line, start.character + 1),
new vscode.Position(end.line, end.character - 1),
),
),
);
}
},
);
+52
View File
@@ -2,6 +2,8 @@ import {
attributesOfType,
elementTypeName,
isAssignableTo,
typeChain,
typeInfo,
} from "../model/schemaModel";
import type { AssetDef, ModIndex } from "./types";
@@ -117,6 +119,56 @@ export function resolveReferenceTargetsForType(
refType = attr.refType;
}
return filterAndScoreDefs(defs, refType, selfType);
}
/**
* True when an element's text content is a typed reference to a global
* asset: the element's resolved XSD type is a simple type carrying an
* `xas:refType` (e.g. `<CreateObject>` with `GameObjectWeakRef`).
*
* Only *typed* refs are treated as content references. Generic untyped
* `AssetReference` content is used by real data for shader constants,
* mesh sub-object names and other values that are not global asset ids
* (`FXShaderConstantTexture@Value`, `RenderSubObjectReference@Mesh`), so
* resolving those globally would produce false hover/navigation/diagnostics.
* Poid pipeline-local ids are excluded for the same reason.
*/
export function isReferenceContentType(typeName: string | null): boolean {
if (!typeName) return false;
const info = typeInfo(typeName);
if (info?.kind !== "simple") return false;
if (typeChain(typeName).includes("Poid")) return false;
return info.refType != null;
}
/**
* Resolves the definitions an element's text content should point to,
* filtered by the element type's `xas:refType`
* (e.g. `GameObjectWeakRef` -> `GameObject`).
*/
export function resolveContentReferenceTargets(
idx: ModIndex,
typeName: string | null,
id: string,
): ReferenceTarget[] {
if (!isReferenceContentType(typeName)) return [];
if (!typeName) return [];
const defs = mergeLocalAndGlobalDefs(
idx.local?.assetsById.get(id.toLowerCase()),
idx.assetsById.get(id.toLowerCase()),
);
if (!defs.length) return [];
const info = typeInfo(typeName);
const refType = info?.kind === "simple" ? info.refType : null;
return filterAndScoreDefs(defs, refType, null);
}
function filterAndScoreDefs(
defs: readonly AssetDef[],
refType: string | null,
selfType: string | null,
): ReferenceTarget[] {
const targets: ReferenceTarget[] = [];
for (const def of defs) {
if (refType && !isAssignableTo(def.type, refType)) continue;
+12 -4
View File
@@ -1,5 +1,5 @@
import type { XmlAttribute, XmlDocument, XmlElement } from "./xmlParser";
import { parseTag } from "./xmlParser";
import { elementContainsOffset, parseTag } from "./xmlParser";
export type ContextKind =
| "element-name"
@@ -31,15 +31,23 @@ export function analyzeContext(
let container: XmlElement | null = null;
for (const el of doc.elements) {
if (el.end < 0) continue;
if (offset >= el.start && offset <= el.end) {
if (elementContainsOffset(el, offset)) {
if (!container || el.depth > container.depth) container = el;
}
}
if (!container) return empty("none");
// Inside the start tag of the element.
if (offset >= container.start && offset <= container.startTagEnd) {
// Inside the start tag of the element. The boundary right after `>` is the
// start of the content (e.g. the `$1` cursor in
// `<CreateObject>$1</CreateObject>`), not another attribute slot; only an
// unterminated start tag whose `>` has not been typed yet still belongs to
// the start tag at its recovered end.
const atTagEnd = offset === container.startTagEnd;
const tagClosed =
atTagEnd && container.startTagEnd > container.start &&
text[container.startTagEnd - 1] === ">";
if (offset >= container.start && (offset < container.startTagEnd || (atTagEnd && !tagClosed))) {
return analyzeStartTag(container, text, offset);
}
+76 -1
View File
@@ -420,6 +420,13 @@ function findTagEnd(text: string, from: number): number {
if (c === quote) quote = null;
} else if (c === '"' || c === "'") {
quote = c;
} else if (c === "<") {
// A new tag start before the current tag's ">" means the ">" we would
// find later belongs to that other tag (typically a closing tag after
// a just-typed "<" in element content). Treat the current tag as
// unterminated so the parser recovers at the line break: the context
// stays "content" and the completion range can cover the typed "<".
return -1;
} else if (c === ">") {
return i;
}
@@ -448,7 +455,7 @@ export function findElementAt(doc: XmlDocument, offset: number): XmlElement | nu
let best: XmlElement | null = null;
for (const el of doc.elements) {
if (el.end < 0) continue;
if (offset >= el.start && offset <= el.end) {
if (elementContainsOffset(el, offset)) {
if (!best || el.depth > best.depth) {
best = el;
}
@@ -457,6 +464,74 @@ export function findElementAt(doc: XmlDocument, offset: number): XmlElement | nu
return best;
}
/**
* Whether `offset` belongs to an element's span.
*
* The end offset is exclusive for a completed element (closing tag or
* self-closing tag): a cursor right after `</Name>` belongs to the parent's
* content, not the child. The one exception is an unclosed element whose
* parser-recovered `end` is the document end: a cursor at EOF is still
* inside the element being typed.
*/
export function elementContainsOffset(el: XmlElement, offset: number): boolean {
if (offset < el.start) return false;
if (offset < el.end) return true;
if (offset > el.end) return false;
return !el.selfClosing && el.closeTagStart < 0;
}
export interface TextToken {
value: string;
/** Absolute offset of the first character of the token. */
start: number;
/** Absolute offset one past the last character of the token. */
end: number;
}
/**
* Returns the whitespace-delimited text token inside an element's content
* that contains `offset`, with absolute source offsets. Used for
* simple-content elements (e.g. `<CreateObject>CrateDebris_01</CreateObject>`)
* by completion, hover, navigation and diagnostics. Returns null when the
* offset is not inside text content (start tag, closing tag, self-closing).
*/
export function textContentTokenAt(
text: string,
el: XmlElement,
offset: number,
): TextToken | null {
if (el.selfClosing) return null;
const contentEnd = el.closeTagStart >= 0 ? el.closeTagStart : el.end;
if (contentEnd <= el.startTagEnd) return null;
if (offset <= el.startTagEnd || offset > contentEnd) return null;
const contentStart = el.startTagEnd;
// A cursor right before the closing tag is still inside the content; clamp
// the relative position to the content length in that case.
const rel = Math.min(offset - contentStart, contentEnd - contentStart);
let tokenStart = rel;
while (tokenStart > 0 && !/\s/.test(text[contentStart + tokenStart - 1])) {
tokenStart--;
}
let tokenEnd = rel;
while (
tokenEnd < contentEnd - contentStart &&
!/\s/.test(text[contentStart + tokenEnd])
) {
tokenEnd++;
}
// The cursor may sit on trailing whitespace or at the closing-tag
// boundary; trim whitespace so the token is exactly the value word.
while (tokenEnd > tokenStart && /\s/.test(text[contentStart + tokenEnd - 1])) {
tokenEnd--;
}
if (tokenEnd <= tokenStart) return null;
return {
value: text.slice(contentStart + tokenStart, contentStart + tokenEnd),
start: contentStart + tokenStart,
end: contentStart + tokenEnd,
};
}
/** Finds an element by name that contains the offset (including its start tag). */
export function findOpenTagElementAt(doc: XmlDocument, offset: number): XmlElement | null {
const el = findElementAt(doc, offset);
+391
View File
@@ -20,6 +20,13 @@ class CompletionItem {
}
}
class CompletionList {
constructor(items, isIncomplete) {
this.items = items;
this.isIncomplete = isIncomplete;
}
}
class Position {
constructor(line, character) {
this.line = line;
@@ -67,6 +74,7 @@ require.cache["vscode-stub"] = {
loaded: true,
exports: {
CompletionItem,
CompletionList,
CompletionItemKind,
Position,
Range,
@@ -123,6 +131,10 @@ const provider = makeProvider({});
const providerNoIndex = makeProvider(null);
const token = { isCancellationRequested: false };
function listItems(result) {
return Array.isArray(result) ? result : result.items;
}
test("Surfaces enum completion works with an unclosed quote", async () => {
const text =
`<AssetDeclaration>\n <LocomotorTemplate id="x" Surfaces="G>\n <Other/>\n</LocomotorTemplate>\n</AssetDeclaration>`;
@@ -468,3 +480,382 @@ test("Poid attributes offer ids from the enclosing GameObject's local scope", as
"Poid completions are labelled as local-scope ids",
);
});
test("accepting a child element after a typed < replaces the < instead of doubling it", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject\n` +
` Options="IGNORE_ALL_OBJECTS"\n` +
` Disposition="RANDOM_FORCE RELATIVE_ANGLE ABSOLUTE_ANGLE"\n` +
` MinForceMagnitude="2.0"\n` +
` MaxForceMagnitude="7.0"\n` +
` DispositionIntensity="5.0"\n` +
` MinLifetime="1.0s"\n` +
` MaxLifetime="3.s"\n` +
` MinForcePitch="90d"\n` +
` MaxForcePitch="75d">\n` +
` <Offset x="26.13" y="4.87" z="15.99"></Offset>\n` +
` <`;
const lines = text.split("\n");
const last = lines.length - 1;
const pos = new Position(last, lines[last].length);
const document = makeDocument(text);
const items = await providerNoIndex.provideCompletionItems(document, pos, token);
const createObject = items.find((i) => i.label === "CreateObject");
const offset = items.find((i) => i.label === "Offset");
assert.ok(createObject);
assert.ok(offset);
// The typed "<" stays in the document; the range covers only the name
// area after it (empty here), and the snippet has no leading "<", so the
// final document never contains "<<" and the filter prefix is not "<".
const ltOffset = document.offsetAt(createObject.range.start);
assert.equal(ltOffset, document.offsetAt(pos));
assert.equal(createObject.range.end.line, last);
assert.equal(createObject.range.end.character, lines[last].length);
assert.equal(offset.range.start.character, createObject.range.start.character);
// Simple-content children must be open/close pairs with a value
// placeholder, never a self-closing tag, and re-trigger value suggest.
assert.equal(createObject.insertText.value, "CreateObject>$1</CreateObject>");
assert.ok(createObject.command, "simple-content child re-triggers suggest");
const applied =
text.slice(0, document.offsetAt(createObject.range.start)) +
createObject.insertText.value +
text.slice(document.offsetAt(createObject.range.end));
assert.ok(!applied.includes("<<"), "no doubled angle bracket after accepting");
assert.match(
applied.split("\n")[last],
/<CreateObject>\$1<\/CreateObject>/,
);
});
test("no << when a closing tag follows the typed < (real file shape)", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject\n` +
` Options="IGNORE_ALL_OBJECTS"\n` +
` Disposition="RANDOM_FORCE RELATIVE_ANGLE ABSOLUTE_ANGLE">\n` +
` <Offset x="26.13" y="4.87" z="15.99"></Offset>\n` +
` <\n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
const lines = text.split("\n");
const last = 6;
const pos = new Position(last, lines[last].length);
const document = makeDocument(text);
const items = await providerNoIndex.provideCompletionItems(document, pos, token);
const createObject = items.find((i) => i.label === "CreateObject");
assert.ok(createObject, "child element still offered after a lone <");
assert.equal(document.offsetAt(createObject.range.start), document.offsetAt(pos));
assert.equal(createObject.insertText.value, "CreateObject>$1</CreateObject>");
const applied =
text.slice(0, document.offsetAt(createObject.range.start)) +
createObject.insertText.value +
text.slice(document.offsetAt(createObject.range.end));
assert.ok(!applied.includes("<<"), "no doubled angle bracket");
const appliedLines = applied.split("\n");
assert.match(appliedLines[6], /<CreateObject>\$1<\/CreateObject>/);
});
test("no << when a partial child name was typed before the closing tag", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject>\n` +
` <Cr\n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
const lines = text.split("\n");
const last = 3;
const pos = new Position(last, lines[last].length);
const document = makeDocument(text);
const items = await providerNoIndex.provideCompletionItems(document, pos, token);
const createObject = items.find((i) => i.label === "CreateObject");
assert.ok(createObject);
// The "<" stays; the range covers only the typed partial name "Cr", and
// the snippet has no leading "<".
const startOffset = document.offsetAt(createObject.range.start);
assert.equal(text[startOffset], "C");
assert.equal(createObject.insertText.value, "CreateObject>$1</CreateObject>");
const applied =
text.slice(0, startOffset) +
createObject.insertText.value +
text.slice(document.offsetAt(createObject.range.end));
assert.ok(!applied.includes("<<"), "no doubled angle bracket for partial names");
assert.match(applied.split("\n")[last], /<CreateObject>\$1<\/CreateObject>/);
});
test("content completion without a typed < inserts the full tag", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject>\n` +
` \n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
const pos = new Position(3, 6);
const items = await providerNoIndex.provideCompletionItems(
makeDocument(text),
pos,
token,
);
const createObject = items.find((i) => i.label === "CreateObject");
assert.ok(createObject);
// No "<" was typed: the snippet includes the opening bracket and the
// replacement range is empty at the cursor.
assert.equal(createObject.insertText.value, "<CreateObject>$1</CreateObject>");
assert.equal(createObject.range.start.character, 6);
assert.equal(createObject.range.end.character, 6);
});
test("simple-content element offers typed asset ids as the text value", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject>\n` +
` <CreateObject>C</CreateObject>\n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
const line = text.split("\n")[3];
const pos = new Position(3, line.indexOf(">C") + 2);
const go = {
type: "GameObject",
id: "CrateDebris_01",
file: "Crates.xml",
line: 1,
origin: "project",
};
const weapon = {
type: "WeaponTemplate",
id: "CrateWeapon_01",
file: "Weapons.xml",
line: 1,
origin: "project",
};
const idx = {
assets: new Map([
["GameObject", new Map([["cratedebris_01", [go]]])],
["WeaponTemplate", new Map([["crateweapon_01", [weapon]]])],
]),
assetsById: new Map([
["cratedebris_01", [go]],
["crateweapon_01", [weapon]],
]),
};
const items = await makeProvider(idx).provideCompletionItems(
makeDocument(text),
pos,
token,
);
const labels = items.map((i) => i.label);
assert.ok(labels.includes("CrateDebris_01"));
assert.ok(
!labels.includes("CrateWeapon_01"),
"content refs are filtered by the element's refType (GameObject)",
);
const item = items.find((i) => i.label === "CrateDebris_01");
assert.equal(item.range.start.character, line.indexOf(">C") + 1);
assert.equal(item.range.end.character, pos.character);
assert.equal(item.insertText, "CrateDebris_01");
});
test("simple-content value completion works before the closing tag is typed", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject>\n` +
` <CreateObject>C`;
const line = text.split("\n")[3];
const pos = new Position(3, line.length);
const go = {
type: "GameObject",
id: "CrateDebris_01",
file: "Crates.xml",
line: 1,
origin: "project",
};
const idx = {
assets: new Map([["GameObject", new Map([["cratedebris_01", [go]]])]]),
assetsById: new Map([["cratedebris_01", [go]]]),
};
const items = await makeProvider(idx).provideCompletionItems(
makeDocument(text),
pos,
token,
);
const labels = items.map((i) => i.label);
assert.ok(labels.includes("CrateDebris_01"));
const item = items.find((i) => i.label === "CrateDebris_01");
// The unclosed element's end is the document end, so the typed "C" is
// still a real token and the range covers it.
assert.equal(item.range.start.character, line.indexOf(">C") + 1);
assert.equal(item.range.end.character, pos.character);
});
test("content start after accepting a simple-content snippet offers values, not attributes", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject>\n` +
` <CreateObject></CreateObject>\n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
const line = text.split("\n")[3];
const pos = new Position(3, line.indexOf(">") + 1);
const go = {
type: "GameObject",
id: "CrateDebris_01",
file: "Crates.xml",
line: 1,
origin: "project",
};
const idx = {
assets: new Map([["GameObject", new Map([["cratedebris_01", [go]]])]]),
assetsById: new Map([["cratedebris_01", [go]]]),
};
const result = await makeProvider(idx).provideCompletionItems(
makeDocument(text),
pos,
token,
);
const labels = listItems(result).map((i) => i.label);
assert.ok(labels.includes("CrateDebris_01"));
assert.ok(!labels.includes("xai:joinAction"));
assert.ok(!labels.includes("xmlns:xai"));
});
test("large asset-id lists are incomplete so narrower prefixes can re-request", async () => {
const defs = [];
for (let i = 0; i < 450; i++) {
defs.push({
type: "GameObject",
id: `C${String(i).padStart(3, "0")}`,
file: `C${i}.xml`,
line: 1,
origin: "project",
});
}
defs.push({
type: "GameObject",
id: "CrateDebris_01",
file: "Crates.xml",
line: 1,
origin: "project",
});
const byId = new Map();
const gameObjects = new Map();
const assets = new Map([["GameObject", gameObjects]]);
for (const def of defs) {
const key = def.id.toLowerCase();
byId.set(key, [def]);
gameObjects.set(key, [def]);
}
const idx = { assets, assetsById: byId };
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject>\n` +
` <CreateObject>C</CreateObject>\n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
const line = text.split("\n")[3];
const pos = new Position(3, line.indexOf(">C") + 2);
const first = await makeProvider(idx).provideCompletionItems(
makeDocument(text),
pos,
token,
);
assert.equal(Array.isArray(first), false);
assert.equal(first.isIncomplete, true, "capped list asks VS Code to recompute");
assert.equal(first.items.length, 400);
assert.ok(
!first.items.some((i) => i.label === "CrateDebris_01"),
"the target is beyond the initial 400 and must be found by a re-request",
);
// isIncomplete makes VS Code call the provider again as the prefix narrows.
const text2 = text.replace(">C<", ">Cr<");
const line2 = text2.split("\n")[3];
const second = await makeProvider(idx).provideCompletionItems(
makeDocument(text2),
new Position(3, line2.indexOf(">Cr") + 3),
token,
);
const secondItems = listItems(second);
assert.ok(
secondItems.some((i) => i.label === "CrateDebris_01"),
"narrower prefix re-request reaches the previously cut-off id",
);
});
test("current-file local overlay assets survive the global 400 cap", async () => {
const defs = [];
for (let i = 0; i < 450; i++) {
defs.push({
type: "GameObject",
id: `C${String(i).padStart(3, "0")}`,
file: `C${i}.xml`,
line: 1,
origin: "project",
});
}
const byId = new Map();
const gameObjects = new Map();
const assets = new Map([["GameObject", gameObjects]]);
for (const def of defs) {
const key = def.id.toLowerCase();
byId.set(key, [def]);
gameObjects.set(key, [def]);
}
const localGo = {
type: "GameObject",
id: "CrateDebris_01",
file: "Crates.xml",
line: 1,
origin: "project",
stream: "local",
};
const idx = {
assets,
assetsById: byId,
local: {
assets: new Map([
["GameObject", new Map([["cratedebris_01", [localGo]]])],
]),
assetsById: new Map([["cratedebris_01", [localGo]]]),
defines: new Map(),
},
};
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject>\n` +
` <CreateObject>C</CreateObject>\n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
const line = text.split("\n")[3];
const result = await makeProvider(idx).provideCompletionItems(
makeDocument(text),
new Position(3, line.indexOf(">C") + 2),
token,
);
assert.equal(Array.isArray(result), false);
assert.equal(result.isIncomplete, true);
assert.ok(result.items.some((i) => i.label === "CrateDebris_01"));
});
+281
View File
@@ -0,0 +1,281 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
// Minimal vscode shim for hover / definition / diagnostics providers.
const CompletionItemKind = {};
class Position {
constructor(line, character) {
this.line = line;
this.character = character;
}
}
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
}
class MarkdownString {
constructor(value) {
this.value = value ?? "";
}
appendMarkdown(text) {
this.value += text;
return this;
}
appendCodeblock(text) {
this.value += "\n```\n" + text + "\n```\n";
return this;
}
}
class Hover {
constructor(contents) {
this.contents = contents;
}
}
class Location {
constructor(uri, range) {
this.uri = uri;
this.range = range;
}
}
class Diagnostic {
constructor(range, message, severity) {
this.range = range;
this.message = message;
this.severity = severity;
}
}
class FakeDiagnosticCollection {
constructor() {
this.last = null;
}
set(uri, diags) {
this.last = { uri, diags };
}
delete() {}
dispose() {}
}
const Uri = {
file: (p) => ({ fsPath: p }),
};
const workspace = {
getWorkspaceFolder: (uri) => ({ uri: { fsPath: "C:/mod" } }),
};
const languages = {
createDiagnosticCollection: () => new FakeDiagnosticCollection(),
};
const require = createRequire(import.meta.url);
const Module = require("module");
const origResolve = Module._resolveFilename;
Module._resolveFilename = function (request, ...args) {
if (request === "vscode") return "vscode-stub";
return origResolve.call(this, request, ...args);
};
require.cache["vscode-stub"] = {
id: "vscode-stub",
filename: "vscode-stub",
loaded: true,
exports: {
CompletionItemKind,
Position,
Range,
MarkdownString,
Hover,
Location,
Diagnostic,
DiagnosticSeverity: { Error: 0, Warning: 1, Information: 2, Hint: 3 },
Uri,
workspace,
languages,
},
};
const { Ra3HoverProvider } = require("../out/features/hover.js");
const { Ra3DefinitionProvider } = require("../out/features/navigation.js");
const { Ra3Diagnostics } = require("../out/features/diagnostics.js");
const { parseXml, LineMap } = require("../out/language/xmlParser.js");
const { expandDocument } = require("../out/indexer/logicalTree.js");
const URI = "C:/mod/Data/Crates.xml";
function makeDocument(text, uri = URI) {
const lineStarts = [0];
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) lineStarts.push(i + 1);
}
return {
uri: { fsPath: uri },
getText: () => text,
offsetAt: (pos) => lineStarts[pos.line] + pos.character,
positionAt: (offset) => {
let lo = 0;
let hi = lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lineStarts[mid] <= offset) lo = mid;
else hi = mid - 1;
}
return new Position(lo, offset - lineStarts[lo]);
},
};
}
async function makeScope(text, idx) {
const lineMap = new LineMap(text);
const parse = parseXml(text);
const expanded = await expandDocument(URI, parse, {
resolve: () => null,
readDom: async () => null,
});
return {
uri: URI,
version: 1,
parse,
lineMap,
expanded,
lineMaps: new Map(),
overlay: {},
merged: idx,
};
}
function makeIdx(defs) {
const assets = new Map();
const assetsById = new Map();
for (const def of defs) {
const idKey = def.id.toLowerCase();
let byId = assets.get(def.type);
if (!byId) {
byId = new Map();
assets.set(def.type, byId);
}
byId.set(idKey, [def]);
assetsById.set(idKey, [def]);
}
return {
assets,
assetsById,
defines: new Map(),
projectDir: "C:/mod",
sdkDir: "C:/sdk",
files: new Map(),
streams: [],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
stats: {},
};
}
const TEXT =
`<AssetDeclaration>\n` +
` <GameObject id="CrateDebris_01"/>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject>\n` +
` <CreateObject>CrateDebris_01</CreateObject>\n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
test("hover on simple-content text shows the referenced definition", async () => {
const def = {
type: "GameObject",
id: "CrateDebris_01",
file: URI,
line: 2,
origin: "project",
};
const idx = makeIdx([def]);
const scope = await makeScope(TEXT, idx);
const provider = new Ra3HoverProvider({
isRa3Workspace: () => true,
getScope: async () => scope,
searchPaths: () => null,
});
const line = TEXT.split("\n")[4];
const pos = new Position(4, line.indexOf("CrateDebris_01") + 3);
const hover = await provider.provideHover(makeDocument(TEXT), pos, {});
assert.ok(hover, "hover is returned for typed content text");
assert.match(hover.contents.value, /1 definition/);
assert.match(hover.contents.value, /GameObject/);
});
test("Ctrl+click on simple-content text jumps to the definition", async () => {
const def = {
type: "GameObject",
id: "CrateDebris_01",
file: URI,
line: 2,
origin: "project",
};
const scope = await makeScope(TEXT, makeIdx([def]));
const provider = new Ra3DefinitionProvider({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: { definitionMode: "all" },
indexer: null,
});
const line = TEXT.split("\n")[4];
const pos = new Position(4, line.indexOf("CrateDebris_01") + 3);
const locations = await provider.provideDefinition(makeDocument(TEXT), pos, {});
assert.ok(locations && locations.length === 1, "content definition resolves");
const defStart = TEXT.indexOf('id="CrateDebris_01"') + 'id="'.length;
const defEnd = defStart + "CrateDebris_01".length;
assert.deepEqual(
{
start: locations[0].range.start,
end: locations[0].range.end,
},
{
start: makeDocument(TEXT).positionAt(defStart),
end: makeDocument(TEXT).positionAt(defEnd),
},
);
});
test("diagnostics report unresolved typed content references only", async () => {
const text =
`<AssetDeclaration>\n` +
` <GameObject id="CrateDebris_01"/>\n` +
` <ObjectFilter>\n` +
` <IncludeThing>SomeValue</IncludeThing>\n` +
` </ObjectFilter>\n` +
` <ObjectCreationList id="OCL">\n` +
` <CreateObject>\n` +
` <CreateObject>CrateDebris_01</CreateObject>\n` +
` <CreateObject>MissingThing</CreateObject>\n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
const def = {
type: "GameObject",
id: "CrateDebris_01",
file: URI,
line: 2,
origin: "project",
};
const scope = await makeScope(text, makeIdx([def]));
const collection = new FakeDiagnosticCollection();
const provider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: false,
reportUnresolvedReferences: "warning",
},
});
provider["collection"] = collection;
await provider.update(makeDocument(text));
const messages = collection.last.diags.map((d) => d.message);
assert.ok(
messages.some((m) => m.includes('Unresolved reference "MissingThing"')),
"typed content refs are diagnosed",
);
assert.ok(
!messages.some((m) => m.includes("SomeValue")),
"untyped WeakReference content is not diagnosed as a global ref",
);
});
+32
View File
@@ -74,6 +74,38 @@ test("cursor after a closed quote is an attribute-name context", () => {
assert.equal(ctx.attr, null);
});
test("cursor exactly after an opening tag with a closing tag is content", () => {
const text = `<CreateObject></CreateObject>`;
const cursor = text.indexOf(">") + 1;
const doc = parseXml(text);
const ctx = analyzeContext(doc, text, cursor);
assert.equal(ctx.kind, "content");
assert.equal(ctx.element?.name, "CreateObject");
});
test("cursor after a typed > but before the closing tag is content too", () => {
const text = `<CreateObject>`;
const cursor = text.length;
const doc = parseXml(text);
const ctx = analyzeContext(doc, text, cursor);
assert.equal(ctx.kind, "content");
assert.equal(ctx.element?.name, "CreateObject");
});
test("cursor after a closed child element belongs to the parent content", () => {
const text =
`<AssetDeclaration>` +
`<ObjectCreationList>` +
`<CreateObject>X</CreateObject>` +
`</ObjectCreationList>` +
`</AssetDeclaration>`;
const cursor = text.indexOf("</CreateObject>") + "</CreateObject>".length;
const doc = parseXml(text);
const ctx = analyzeContext(doc, text, cursor);
assert.equal(ctx.kind, "content");
assert.equal(ctx.element?.name, "ObjectCreationList");
});
test("splitListValuePrefix isolates the token being edited", () => {
assert.deepEqual(splitListValuePrefix("GROUND WA"), { token: "WA", start: 7 });
assert.deepEqual(splitListValuePrefix("GROUND "), { token: "", start: 7 });
+58
View File
@@ -8,6 +8,8 @@ import {
isLocalReferenceAttribute,
isReferenceAttribute,
isReferenceAttributeOfType,
isReferenceContentType,
resolveContentReferenceTargets,
resolveReferenceTargets,
resolveReferenceTargetsForType,
} from "../out/indexer/refs.js";
@@ -242,3 +244,59 @@ test("xi:include elements are outside the XSD model and unvalidated", () => {
assert.equal(model.isXsdAttributeName(a.name), true, a.name);
}
});
test("typed simple content resolves like a typed attribute reference", () => {
// <CreateObject>CrateDebris_01</CreateObject> uses GameObjectWeakRef:
// the content is a GameObject reference, not a child element.
assert.equal(isReferenceContentType("GameObjectWeakRef"), true);
const idx = {
assetsById: new Map([
[
"cratedebris_01",
[
{
type: "GameObject",
id: "CrateDebris_01",
file: "Crates.xml",
line: 2,
origin: "project",
},
{
type: "WeaponTemplate",
id: "CrateDebris_01",
file: "Weapons.xml",
line: 4,
origin: "project",
},
],
],
]),
assets: new Map(),
projectDir: ".",
sdkDir: ".",
defines: new Map(),
files: new Map(),
streams: [],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
stats: {},
};
const targets = resolveContentReferenceTargets(idx, "GameObjectWeakRef", "CrateDebris_01");
assert.equal(targets.length, 1);
assert.equal(targets[0].def.type, "GameObject");
});
test("untyped and pipeline-local content is not a global reference", () => {
// Generic AssetReference content is used for shader constants and model
// sub-object names, not global asset ids; Poid is pipeline-local.
assert.equal(isReferenceContentType("AssetReference"), false);
assert.equal(isReferenceContentType("Poid"), false);
assert.equal(isReferenceContentType("string"), false);
const idx = { assetsById: new Map(), assets: new Map(), defines: new Map() };
assert.equal(
resolveContentReferenceTargets(idx, "AssetReference", "Anything").length,
0,
);
assert.equal(resolveContentReferenceTargets(idx, "Poid", "Anything").length, 0);
});
+97 -1
View File
@@ -1,6 +1,11 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseXml, findElementAt, stripBom } from "../out/language/xmlParser.js";
import {
parseXml,
findElementAt,
stripBom,
textContentTokenAt,
} from "../out/language/xmlParser.js";
test("stripBom removes a leading UTF-8 byte-order mark", () => {
assert.equal(stripBom("\uFEFF<A/>"), "<A/>");
@@ -44,6 +49,27 @@ test("findElementAt returns innermost element", () => {
assert.equal(at.name, "C");
});
test("findElementAt treats a completed element's end as exclusive", () => {
const text = `<A><B>X</B></A>`;
const doc = parseXml(text);
const b = doc.elements.find((e) => e.name === "B");
const at = findElementAt(doc, b.end);
assert.equal(at?.name, "A", "cursor after </B> belongs to the parent");
const self = `<A><B/></A>`;
const selfDoc = parseXml(self);
const b2 = selfDoc.elements.find((e) => e.name === "B");
const atSelf = findElementAt(selfDoc, b2.end);
assert.equal(atSelf?.name, "A", "cursor after <B/> belongs to the parent");
});
test("findElementAt still includes EOF inside an unclosed element", () => {
const text = `<A><B>C`;
const doc = parseXml(text);
const b = doc.elements.find((e) => e.name === "B");
assert.equal(findElementAt(doc, text.length)?.name, "B");
});
test("tolerates partial input while typing", () => {
const text = `<AssetDeclaration>\n\t<GameObject id="TestTank" Com`;
const doc = parseXml(text);
@@ -86,3 +112,73 @@ test("reports an unterminated attribute value at EOF", () => {
assert.equal(a.attrs[0].value, "abc");
assert.equal(a.attrs[0].quoteEnd, -1);
});
test("textContentTokenAt returns the token inside element content", () => {
const text =
`<AssetDeclaration>` +
`<CreateObject> CrateDebris_01 </CreateObject>` +
`</AssetDeclaration>`;
const doc = parseXml(text);
const el = doc.elements.find((e) => e.name === "CreateObject");
const tokenStart = text.indexOf("Crate");
const cursor = tokenStart + 3;
const token = textContentTokenAt(text, el, cursor);
assert.deepEqual(token, {
value: "CrateDebris_01",
start: tokenStart,
end: tokenStart + "CrateDebris_01".length,
});
// The start tag, closing tag and whitespace-only content are not tokens.
assert.equal(textContentTokenAt(text, el, el.start + 1), null);
assert.equal(textContentTokenAt(text, el, text.indexOf("</CreateObject") + 1), null);
const empty = `<A><B/></A><C> </C>`;
const c = parseXml(empty).elements.find((e) => e.name === "C");
assert.equal(textContentTokenAt(empty, c, c.startTagEnd + 1), null);
});
test("textContentTokenAt works before the closing tag is typed", () => {
const text = `<A><B>C`;
const doc = parseXml(text);
const b = doc.elements.find((e) => e.name === "B");
assert.ok(b);
assert.equal(b.closeTagStart, -1);
const token = textContentTokenAt(text, b, text.length);
assert.deepEqual(token, {
value: "C",
start: text.indexOf("C"),
end: text.length,
});
});
test("a typed < in content followed by a closing tag does not swallow it", () => {
// In a real file the "<" the user just typed is followed by
// "</CreateObject>" on the next line. The parser must NOT treat that
// closing tag's ">" as the end of the malformed start tag (which would
// create a bogus empty-name element and break content completion).
const text =
`<AssetDeclaration>` +
`<ObjectCreationList>` +
`<CreateObject>\n\t<Offset/>\n\t<\n</CreateObject>` +
`</ObjectCreationList>` +
`</AssetDeclaration>`;
const doc = parseXml(text);
assert.ok(doc.errors.some((e) => /Unterminated start tag/.test(e.message)));
assert.ok(!doc.elements.some((e) => e.name === ""), "no bogus empty-name element");
const co = doc.elements.find((e) => e.name === "CreateObject");
assert.ok(co, "outer CreateObject still parsed");
assert.equal(co.end, text.indexOf("</CreateObject>") + "</CreateObject>".length);
});
test("a partial child name in content is recovered, not glued to the closing tag", () => {
const text = `<A><B>\n\t<Cr\n</B></A>`;
const doc = parseXml(text);
const cr = doc.elements.find((e) => e.name === "Cr");
assert.ok(cr, "partial name is recovered as an element shell");
assert.equal(cr.recoveredStartTag, true);
// The mismatched closing tag later closes the recovered shell (parser
// recovery), so the element stays a valid container for completion.
assert.equal(cr.closeTagStart, text.indexOf("</B>"));
assert.ok(cr.end > cr.startTagEnd);
const b = doc.elements.find((e) => e.name === "B");
assert.equal(b.end, text.length);
});