From 47807f9fed95af1bd5ac03c77eead4af3f629a63 Mon Sep 17 00:00:00 2001 From: lanyizi Date: Tue, 4 Aug 2026 19:14:12 +0200 Subject: [PATCH] =?UTF-8?q?0.1.15=20=E7=BB=A7=E7=BB=AD=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 +- docs/analysis-issues.md | 231 ++++++++++++++++++++ docs/plan.md | 38 +++- docs/requirements.md | 7 + package.json | 2 +- src/features/completion.ts | 280 +++++++++++++++++++++--- src/features/diagnostics.ts | 79 +++++++ src/features/hover.ts | 112 +++++++--- src/features/navigation.ts | 85 ++++++-- src/indexer/refs.ts | 52 +++++ src/language/context.ts | 16 +- src/language/xmlParser.ts | 77 ++++++- test/completion.test.mjs | 391 ++++++++++++++++++++++++++++++++++ test/contentFeatures.test.mjs | 281 ++++++++++++++++++++++++ test/context.test.mjs | 32 +++ test/refs.test.mjs | 58 +++++ test/xmlParser.test.mjs | 98 ++++++++- 17 files changed, 1762 insertions(+), 87 deletions(-) create mode 100644 test/contentFeatures.test.mjs diff --git a/README.md b/README.md index 04f2fc4..02d21fb 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ - **语法高亮**:在普通 XML 高亮之上叠加领域标记(`$DEFINE` 常量、`inheritFrom`、`xai:joinAction`、结构标签);XML 语法异常(如未闭合引号)期间由语义 token 兜底,标签/属性/值着色不中断。 - **自动补全**: - - 元素名:按当前父元素的 XSD 模型补全子元素;顶层资产(`AssetDeclaration` 内)补全 `GameObject`、`WeaponTemplate` 等 295 种类型。 + - 元素名:按当前父元素的 XSD 模型补全子元素;顶层资产(`AssetDeclaration` 内)补全 `GameObject`、`WeaponTemplate` 等 295 种类型。已输入 `<` 时补全保留该 `<`、只替换名称区(不会出现 `<<`);需要填文本的 simple-content 元素(如 ``)补全为 `$1` 并自动弹出值补全,而不是无法填值的自闭合标签。 - 属性名:必填属性优先,附带类型/文档/默认值;自动提示 `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` 常量; - `` 补全可解析的 `DATA:` / `ART:` / `AUDIO:` 与项目相对路径。 + - 元素文本内容:simple-content 引用元素(如 ``、``、``)直接在标签间补全对应类型的资产 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`、`ID` 等元素文本)跳转到定义(严格按引用类型过滤,候选由 `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 支持**:`` 指向的 `static/global/audio.manifest`(SDK `builtmods`)会被解析,manifest 中的原版资产 ID 可用于补全/悬停/导航/诊断。 - **美术资产(`.w3x`)**:`W3X.xml` / `ART:` include 链中的 `.w3x` 模型文件会被 索引(`W3DContainer` / `W3DMesh` / `W3DHierarchy` 等顶层资产),因此 diff --git a/docs/analysis-issues.md b/docs/analysis-issues.md index c264dc6..6dcf3c7 100644 --- a/docs/analysis-issues.md +++ b/docs/analysis-issues.md @@ -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`,接受后变成 `<`——两个尖括号 + 非法 XML; +2. 嵌套 `` 被补全成自闭合 ``,无法在标签内填 + 单位的 id;正确写法应是 `CrateDebris_01` + (原版 `SageXml\GlobalData\ObjectCreationLists.xml` 与 AttachTest + `ObjectCreationList.xml` 均为此形态); +3. 手动改成 `C` 后,内容区不出现 C 开头的 + GameObject id 补全; +4. 对 `CrateDebris_01` 的文本 hover 与 + Ctrl+点击导航均无效。 + +### 根因(三个独立缺陷叠加) + +**A. 内容区补全没有处理“用户已输入 `<`”** + +`<` 是补全触发字符(`extension.ts`)。`completion.ts` 的 `contentItems` +返回子元素候选时既不设置 `range`,`insertText` 又是完整的 ``,VS Code +就把完整标签**追加**在已输入的 `<` 后面 → `<`。影响所有内容区子元素 +补全(`Offset`、`RequiredUpgrade` 等同样中招),不只 `CreateObject`。 + +**B. `elementSnippet` 不知道“simple type 元素要填文本”** + +XSD 中 `CreateObjectNugget` 明确声明 +``;`GameObjectWeakRef` +是 simple type,标签内容是 GameObject id。`elementSnippet` 只区分顶层 / 有子 +元素的复杂类型 / 其他,simple type 落入 `` 分支,于是生成了永远填不了 +值的自闭合标签。 + +**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` 检测光标前是否 + 已有 `<`(可带半截名字),有则把替换范围起点设为该 `<`,接受完整 + `` 片段时是“替换”而非“追加”,从根上消灭 `<<`;`$1`(带占位符),引用/枚举/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` 为 `$1`、应用后不含 `<<`; + `C` 内只补 C 开头的 GameObject(类型过滤 + + token range); +- `contentFeatures.test.mjs`(新增):hover 显示内容引用定义、Ctrl+点击跳到 + 同文件精确定位、诊断只报“带 refType 的内容引用”且不误报 WeakReference + 内容。 + +版本 **0.1.13 → 0.1.14**。 + +### 补充(0.1.14 实机回归,2026-08-04):真实文件里 `<` 后仍有闭合标签 + +单元测试最初只覆盖了“文件在 `<` 处结束”的形态,用户实机复测时 `<` 后面还有 +``,`<<` 依然出现(这次是 `<(光标在中间)`)。 + +**第二层根因**:解析器的 `findTagEnd` 扫描残缺开始标签时会一直找到**后面闭合 +标签的 `>`**,把它当成这个开始标签的结束,于是生成一个空名/半截名的伪元素 +(如 name 为 `""` 或 `"Cr"`)。光标落在该伪元素的 start tag 内 → 补全走 +`element-name` 分支,而该分支的替换范围只从 `<` **之后**开始,插入完整的 +`` 片段就变成 `<`。 + +**修复**(两层): + +1. `findTagEnd` 在引号外遇到 `<`(在找到 `>` 之前)直接视为“未闭合开始标签”, + 走原有的行尾恢复路径:`<` 单独出现时不再产生伪元素,内容区保持 content + 上下文;`C`(尚未输入闭合标签)也不触发, +必须补上 `` 才有候选。 + +**根因**:VS Code 用“替换范围内的文本”作为过滤前缀。范围包含 `<` 时,前缀 +就是 `<`,所有标签名都不匹配 → 菜单为空;范围为零宽且元素未闭合时, +`textContentTokenAt` 因 `closeTagStart < 0` 返回 null,range 落在光标处、 +没有覆盖已输入的 `C`,同样过滤不到。 + +**修复**(回到标准补全模式): + +1. **已输入的 `<` 保留,不放进替换范围**:`contentChildItems` / + `elementNameItems` 的 range 从 `<` **之后**开始,插入文本**不再带开括号** + (`CreateObject>$1`),应用结果仍是单个 `<`;没有输入 `<` + (Ctrl+Space 内容补全)时才插入完整 ``。这样过滤前缀是 + `""` / `Cr`,菜单正常显示。 +2. **未闭合元素也能提取内容 token**:`textContentTokenAt` 在 + `closeTagStart < 0` 时用 `el.end` 作为内容边界,`C`(EOF) + 也能覆盖已输入的 `C` 并给出前缀过滤。 + +测试 125 → 128:三个真实形态(孤立 `<`、`C`)均断言插入文本不带开括号、range 覆盖正确文本、应用结果 +无 `<<`,并新增“未输入 `<` 时插入完整标签”用例。 + +--- + +## 二十二、问题分析(第十七轮,2026-08-04):simple-content 补全变成属性补全;大列表截断后 CrateDebris 消失 + +### 现象 + +1. 接受 `$1` 片段后,弹出的不是 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` 判定光标在开始标签内。补全 +片段 `$1` 接受后,`$1` 恰好落在 +`startTagEnd`(`>` 后一格)这个零宽位置,于是被分到 `attribute-name`, +返回的自然是 `xai:joinAction` / `xmlns:xai`。 + +同类边界还有:已闭合元素的 `end` 之前也用了 `<=`,导致光标刚越过 +`` 时仍被当作“在子元素内容里”,而不是父元素的内容区。 + +**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` 为开区间, + 光标在 `` 之后回到父元素内容;未闭合元素在 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`)与紧贴 `` 后的位置 + 是同一类“边界误归属”,本轮一并修掉; +- `Include@source` 的“先截断后排序”是同一家族 bug,已修复。 + +### 举一反三的测试(128 → 136 全绿) + +- `context.test.mjs`:`>` 后且有闭合标签 → content;`>` 后但闭合标签尚未 + 输入 → content;`` 之后 → 父元素 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 缓存优化留作独立一轮。 diff --git a/docs/plan.md b/docs/plan.md index f6140c9..ed438a5 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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` 内嵌套 ``,类型 + `GameObjectWeakRef`)的**标签间文本**就是资产引用。内容区补全现在区分 + “复杂元素 → 子元素名”与“简单元素 → 值补全”;用户已输入 `<` 时替换范围从 + `<` 开始,杜绝 `<<`;simple type 元素片段固定为 `$1`(可填 + 值)并自动触发值补全。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 元素片段 `$1`、简单元素内容值补全、 + `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 文本引用(`<` 后补全不产生 +`<<`、`$1` 片段、内容值按 refType 过滤、 +内容 hover / Ctrl 跳转 / 诊断)。 > 注:D: 盘移动硬盘已恢复连接;Corona 已在第八 / 九轮按上述新数据回归。 diff --git a/docs/requirements.md b/docs/requirements.md index 307df50..371de03 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -41,9 +41,15 @@ XML 之间的组织靠 `` 标签,共有三种语义: - 枚举值(XSD `xs:enumeration`); - `$DEFINE` 常量(如 `$CIV_HEALTH_SMALL`); - `` 补全可解析的文件路径(`DATA:` / `ART:` / `AUDIO:`)。 + - **元素文本内容(simple content)**:带 `xas:refType` 的简单内容元素 + (如 `ID`)在标签间补全对应类型的资产 ID、 + 枚举或 `$DEFINE`;补全出的 simple-content 元素必须是可填值的成对标签 + (``),且内容区已输入 `<` 时不得产生 `<<`。 3. **引用提示(Hover)**:元素/属性悬停显示 XSD 文档、类型、默认值;资产 ID 悬停显示定义位置;`$DEFINE` 悬停显示值与定义位置。 + - 元素文本内容(simple content 引用)悬停同样显示定义位置。 4. **引用导航**: - 从引用型属性值跳转到对应资产定义(Go to Definition); + - 从 simple-content 引用元素(`ID`)的文本跳转到对应资产定义; - 查找某资产 ID 的所有引用(Find All References); - `` / `xi:include href` 直接打开目标文件; - `inheritFrom` 跳转到被继承元素; @@ -54,6 +60,7 @@ XML 之间的组织靠 `` 标签,共有三种语义: - 缺失必填 `id`(顶层资产); - 重复 ID(同类型 + 同 id,mod 文件之间;覆盖原版 SageXml 不算冲突); - 引用未解析(引用了不存在的资产 ID,可配置是否忽略原版 manifest 中的 ID); + - simple-content 引用元素的文本未解析(同属性引用规则,仅带 refType 的类型)。 - `` 目标文件找不到、Include 循环; - `$DEFINE` 未定义。 diff --git a/package.json b/package.json index c539c38..f044f21 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/features/completion.ts b/src/features/completion.ts index 8e78ff7..70bed6a 100644 --- a/src/features/completion.ts +++ b/src/features/completion.ts @@ -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 { + ): Promise> { 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`); + return new vscode.SnippetString(`${open}${name} id="$1">\n\t$0\n`); } 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`); + } const hasChildren = info?.kind === "complex" && info.children.length > 0; if (hasChildren) { - return new vscode.SnippetString(`<${name}>\n\t$0\n`); + return new vscode.SnippetString(`${open}${name}>\n\t$0\n`); } - 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 { 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 { 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 = { "": 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 { const lower = prefix.toLowerCase(); const scored: { def: AssetDef; score: number }[] = []; const seen = new Set(); @@ -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 { const lower = prefix.replace(/^[=$]*/, "").toLowerCase(); const items: vscode.CompletionItem[] = []; const seen = new Set(); @@ -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 { 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( + items: T[], + total: number, + ): T[] | vscode.CompletionList { + 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 { 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. CrateDebris_01), 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 { + 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 "" 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 (" 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) === " 0 && text[i - 1] === "<") { + return { rangeStart: i, typedOpen: true }; + } + if (i < j) return { rangeStart: i, typedOpen: false }; + return { rangeStart: offset, typedOpen: false }; } interface AttributeInsertLayout { diff --git a/src/features/diagnostics.ts b/src/features/diagnostics.ts index 84cab1e..f1aebdf 100644 --- a/src/features/diagnostics.ts +++ b/src/features/diagnostics.ts @@ -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, diff --git a/src/features/hover.ts b/src/features/hover.ts index 91b853a..bdcdaeb 100644 --- a/src/features/hover.ts +++ b/src/features/hover.ts @@ -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 { 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. CrateDebris_01). + 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. 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( diff --git a/src/features/navigation.ts b/src/features/navigation.ts index a321236..ebf0f26 100644 --- a/src/features/navigation.ts +++ b/src/features/navigation.ts @@ -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. CrateDebris_01). + 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 { + 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. CrateDebris_01). + 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), + ), + ), + ); } }, ); diff --git a/src/indexer/refs.ts b/src/indexer/refs.ts index d010e0f..ed384aa 100644 --- a/src/indexer/refs.ts +++ b/src/indexer/refs.ts @@ -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. `` 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; diff --git a/src/language/context.ts b/src/language/context.ts index 1b0c076..0f61256 100644 --- a/src/language/context.ts +++ b/src/language/context.ts @@ -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 + // `$1`), 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); } diff --git a/src/language/xmlParser.ts b/src/language/xmlParser.ts index 355c672..6745ade 100644 --- a/src/language/xmlParser.ts +++ b/src/language/xmlParser.ts @@ -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 `` 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. `CrateDebris_01`) + * 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); diff --git a/test/completion.test.mjs b/test/completion.test.mjs index a1cd6df..0a232de 100644 --- a/test/completion.test.mjs +++ b/test/completion.test.mjs @@ -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 = `\n { + const text = + `\n` + + ` \n` + + ` \n` + + ` \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"); + 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], + /\$1<\/CreateObject>/, + ); +}); + +test("no << when a closing tag follows the typed < (real file shape)", async () => { + const text = + `\n` + + ` \n` + + ` \n` + + ` \n` + + ` <\n` + + ` \n` + + ` \n` + + ``; + 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"); + + 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], /\$1<\/CreateObject>/); +}); + +test("no << when a partial child name was typed before the closing tag", async () => { + const text = + `\n` + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ``; + 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"); + 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], /\$1<\/CreateObject>/); +}); + +test("content completion without a typed < inserts the full tag", async () => { + const text = + `\n` + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ``; + 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, "$1"); + 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 = + `\n` + + ` \n` + + ` \n` + + ` C\n` + + ` \n` + + ` \n` + + ``; + 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 = + `\n` + + ` \n` + + ` \n` + + ` 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 = + `\n` + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ``; + 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 = + `\n` + + ` \n` + + ` \n` + + ` C\n` + + ` \n` + + ` \n` + + ``; + 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 = + `\n` + + ` \n` + + ` \n` + + ` C\n` + + ` \n` + + ` \n` + + ``; + 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")); +}); diff --git a/test/contentFeatures.test.mjs b/test/contentFeatures.test.mjs new file mode 100644 index 0000000..49dda31 --- /dev/null +++ b/test/contentFeatures.test.mjs @@ -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 = + `\n` + + ` \n` + + ` \n` + + ` \n` + + ` CrateDebris_01\n` + + ` \n` + + ` \n` + + ``; + +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 = + `\n` + + ` \n` + + ` \n` + + ` SomeValue\n` + + ` \n` + + ` \n` + + ` \n` + + ` CrateDebris_01\n` + + ` MissingThing\n` + + ` \n` + + ` \n` + + ``; + 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", + ); +}); diff --git a/test/context.test.mjs b/test/context.test.mjs index 326cc7e..ec890a2 100644 --- a/test/context.test.mjs +++ b/test/context.test.mjs @@ -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 = ``; + 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 = ``; + 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 = + `` + + `` + + `X` + + `` + + ``; + const cursor = text.indexOf("") + "".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 }); diff --git a/test/refs.test.mjs b/test/refs.test.mjs index 2e6638f..921f0eb 100644 --- a/test/refs.test.mjs +++ b/test/refs.test.mjs @@ -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", () => { + // CrateDebris_01 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); +}); diff --git a/test/xmlParser.test.mjs b/test/xmlParser.test.mjs index 7dbeaf5..25f97c9 100644 --- a/test/xmlParser.test.mjs +++ b/test/xmlParser.test.mjs @@ -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"), ""); @@ -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 = `X`; + 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 belongs to the parent"); + + const self = ``; + 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 belongs to the parent"); +}); + +test("findElementAt still includes EOF inside an unclosed element", () => { + const text = `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 = `\n\t { assert.equal(a.attrs[0].value, "abc"); assert.equal(a.attrs[0].quoteEnd, -1); }); + +test("textContentTokenAt returns the token inside element content", () => { + const text = + `` + + ` CrateDebris_01 ` + + ``; + 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(" `; + 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 = `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 + // "" 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 = + `` + + `` + + `\n\t\n\t<\n` + + `` + + ``; + 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("") + "".length); +}); + +test("a partial child name in content is recovered, not glued to the closing tag", () => { + const text = `\n\t`; + 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("")); + assert.ok(cr.end > cr.startTagEnd); + const b = doc.elements.find((e) => e.name === "B"); + assert.equal(b.end, text.length); +});