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` 检测光标前是否
+ 已有 `<`(可带半截名字),有则把替换范围起点设为该 `<`,接受完整
+ `` 片段时是“替换”而非“追加”,从根上消灭 `<<`;`` 输入不触发子元素
+ 补全。
+2. **simple type 元素片段**:`elementSnippet` 对 simple type 返回
+ `$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${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 {
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) === "") {
+ 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 {
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);
+});