0.1.15 继续优化补全
This commit is contained in:
@@ -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` 等顶层资产),因此
|
||||
|
||||
@@ -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` 同样不含 `<<`。
|
||||
|
||||
### 补充 2(0.1.14 实机回归):`<<` 修掉后补全菜单反而消失
|
||||
|
||||
把 `<` 纳入替换范围后,单测直接调用 provider 全部通过,但 VS Code 实机里
|
||||
`<` 后**不再弹出任何补全**;`<CreateObject>C`(尚未输入闭合标签)也不触发,
|
||||
必须补上 `</CreateObject>` 才有候选。
|
||||
|
||||
**根因**:VS Code 用“替换范围内的文本”作为过滤前缀。范围包含 `<` 时,前缀
|
||||
就是 `<`,所有标签名都不匹配 → 菜单为空;范围为零宽且元素未闭合时,
|
||||
`textContentTokenAt` 因 `closeTagStart < 0` 返回 null,range 落在光标处、
|
||||
没有覆盖已输入的 `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
@@ -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.12–0.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 已在第八 / 九轮按上述新数据回归。
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user