0.1.13 improve completion

This commit is contained in:
2026-08-04 17:53:11 +02:00
parent 6794896ac7
commit f8187da611
12 changed files with 844 additions and 36 deletions
+2 -2
View File
@@ -7,11 +7,11 @@
- **语法高亮**:在普通 XML 高亮之上叠加领域标记(`$DEFINE` 常量、`inheritFrom``xai:joinAction`、结构标签);XML 语法异常(如未闭合引号)期间由语义 token 兜底,标签/属性/值着色不中断。
- **自动补全**
- 元素名:按当前父元素的 XSD 模型补全子元素;顶层资产(`AssetDeclaration` 内)补全 `GameObject``WeaponTemplate` 等 295 种类型。
- 属性名:必填属性优先,附带类型/文档/默认值;自动提示 `xai:joinAction``xmlns:xai`
- 属性名:必填属性优先,附带类型/文档/默认值;自动提示 `xai:joinAction``xmlns:xai`接受补全时自动避免与上一个属性贴在一起,并按文件已有的排版补空格或换行(换行的基础缩进由编辑器提供,插件不再内嵌缩进以免叠加);数字/角度/时间等标量属性直接填入 XSD 默认值或类型示例(如 `0d``0s`),引用/枚举/布尔等保留真正的 `$1` 占位符并弹出值补全。
- 属性值:
- 引用型属性(如 `CommandSet``Weapon`)按 `xas:refType` 补全对应类型的资产 ID(**同名 ID 只补全匹配类型**);
- `inheritFrom` 补全可继承的资产 ID
- 枚举与位标志列表(如 `Include type``LocomotorTemplate@Surfaces``KindOf`;列表值支持空格后继续补全下一项);
- 枚举与位标志列表(如 `Include type``LocomotorTemplate@Surfaces``KindOf`;列表值空格后自动继续补全下一项,已使用的 flag 不再重复推荐,闭合值末尾可直接追加新 flag);
- 布尔值、`$DEFINE` 常量;
- `<Include source>` 补全可解析的 `DATA:` / `ART:` / `AUDIO:` 与项目相对路径。
- **悬停提示**:元素/属性显示 XSD 文档、类型、必填/默认值;引用值显示定义位置;`$DEFINE` 显示值与定义位置;`Include source` / `xi:include href` 显示解析后的目标文件;`xi:include` 元素与属性给出 XInclude 说明。
+214
View File
@@ -1064,3 +1064,217 @@ stale=true 并触发 follow-up。处理(按用户建议的扩展名白名单
输出通道同步增加 `walk / candidates / art` 耗时分解,便于下次直接定位慢在哪一段。
版本 **0.1.8 → 0.1.9**
---
## 十八、问题分析(第十三轮,2026-08-04):bit-flag 列表补全的三层问题
### 现象
`xs:list` 枚举(bit flag,如 `CreateObject@Disposition`
`LocomotorTemplate@Surfaces`):
1. 刚打开引号时(`Disposition="`)能补全;
2. 输入第一项后再输入空格,**不触发**补全;
3. 已经闭合的 `Disposition="RANDOM_FORCE RELATIVE_ANGLE"` 想在中间插入或末尾
追加 flag,**不触发**补全。
### 根因(三个独立问题)
**1. 空格没有注册为补全触发字符**
`extension.ts` 注册 provider 时只传了 `< " = : . /`。VS Code 只在输入 word
字符或已注册触发字符时自动弹出补全;空格两者都不是,所以“打 flag → 空格”
永远不会自动弹出。刚打开引号能补全正是因为 `"` 已注册。
**2. 多行未闭合引号下,解析恢复丢失后续行属性**
解析器对未闭合开始标签的恢复策略是“截到第一个换行”(`xmlParser.ts`),因此
像用户示例这样属性逐行书写的标签,`<CreateObject` 之后各行的 `Options` /
`Disposition` 全部丢失,`startTagEnd` 停在 `<CreateObject` 行尾。光标在后续
行时 `analyzeContext``content` 分支——实测 `kind: content, attrs: []`
也就是说,**按用户贴出的原文状态,当前代码其实并不会补全 Disposition**
观察到“能补全”的编辑状态里引号/`>` 多半已闭合。另外恢复分支没有把恢复出的
元素挂到父元素下(`parent = null`),即使补上上下文分析,类型解析也会退回
全局映射(`CreateObject``GameObjectWeakRef`)而找不到 `Disposition`
**3. 闭合引号内“插入/追加 flag”的体验与范围问题**
- 光标在完整值末尾(`...RELATIVE_ANGLE|"`)时,当前 token 恰好等于完整枚举
值,`startsWith` 过滤只剩它自己 → 看起来“没有补全”;
- 替换范围 bug:引号闭合时 `endOffset` 固定取整个值的末尾而不是光标位置。
实测光标在 `RANDOM_FORCE | RELATIVE_ANGLE` 中间时 range 为 `(28..42)`
选中任意 flag 会删掉 `RELATIVE_ANGLE` 及之后的内容;
- 光标恰好贴在闭合引号后面(`"|`)时,`offset <= quoteEnd` 把引号算进
prefix`RELATIVE_ANGLE"`),返回 0 项。
### 修复
1. **触发字符**`registerCompletionItemProvider` 增加 `" "`(空格)。副作用:
属性之间按空格会弹属性名补全(加分项),文本内容按空格会弹子元素补全。
2. **多行未闭合标签的补全**
- 解析器给恢复出的元素打 `recoveredStartTag` 标记,并**补挂父链**
(与正常分支一致,`parent.children.push` + `el.parent = parent`);
- `analyzeContext` 在光标越过 `startTagEnd` 且元素带标记时,把
`text.slice(tagStart, cursor)` 作为部分标签重新 `parseTag` 一次,
再走同一套 start-tag 分类。全局解析恢复策略不变,后续文档解析/诊断
不受影响。
- 引号判定从 `offset <= quoteEnd` 改为 `offset < quoteEnd`:光标在闭合
引号之后进入 attribute-name 上下文。
3. **list 补全范围与过滤**`completion.ts`):
- list 值替换范围改为 `min(cursor, valueEnd)`,只覆盖当前段;非 list
保持整值替换;
- 排除列表中已出现的 flag(空格后只推荐剩余项);
- 追加模式:当前段已是完整枚举值且没有其它枚举以它为前缀时,给出零宽
range、`insertText = " FLAG"`,可直接在闭合值末尾/列表中间追加;
前缀保护是必要的——实测 820 个 list 枚举里有 10569 对严格前缀关系
(如 `CAN_ATTACK``CAN_ATTACK_WALLS`)。
### 举一反三的测试(98 → 107 全绿)
- `xmlParser.test.mjs`:恢复元素带 `recoveredStartTag` 标记、正常元素不带;
- `context.test.mjs`:用户示例的多行未闭合引号(`Disposition="`)仍为
attribute-value 且 prefix 正确;输入 flag + 空格后 prefix 含完整值;闭合
引号之后为 attribute-name
- `completion.test.mjs`
- 空格后只推荐未使用的 flag(10 项,不含 GROUND),range 零宽在光标处;
- 列表中间插入不会删掉尾部 flag(range 止于光标);
- 闭合值末尾完整 flag → 追加模式(`insertText: " WATER"`);
- `CAN_ATTACK` 有更长变体时保持前缀过滤,不进入追加模式;
- 多行未闭合 `Disposition="RANDOM_FORCE ` 经完整 provider 链路返回剩余
flag(不含 RANDOM_FORCE)。
版本 **0.1.9 → 0.1.10**
---
## 十九、问题分析(第十四轮,2026-08-04):属性补全的插入体验
### 现象
写完一个属性值并关闭引号后,会立刻触发下一个属性的补全菜单(由 `"` 触发字符
带来,方便)。但按 Enter 接受补全时不会补空格,结果属性与上一个属性的闭合
引号贴在一起:
```xml
Disposition="RANDOM_FORCE RELATIVE_ANGLE"Count="$1"
```
连续接受会变成 `...RELATIVE_ANGLE"Count="$1"CreateFX="$1"DestinationPlayer="$1"`
另外提出两个功能请求:
1. 新属性自动参考临近属性的缩进(很多 XML 的属性统一换行缩进);
2. 补全的 `$1` 占位符在允许时变成更有意义的值(数字 → 数字、角度 → 角度、
时间 → 时间),顺带提示值的格式。
### 修复
**1. 插入布局(`completion.ts` 新增 `attributeInsertLayout`**
- 光标紧贴上一个属性的闭合引号时,插入文本前补一个空格(inline 布局);
- 临近属性是“一行一个”布局(相邻属性之间的原文含换行)时,插入
`\n + 上一个属性的缩进`
- 用户已经回车换行时,用临近缩进替换当前行已有的空白(对齐);
- inline 风格的文件里用户手动换行,则保留用户自己打的缩进,不强改;
- `xai:joinAction` / `xmlns:xai` 辅助项同样享受前缀与触发。
**2. 类型化默认值(`completion.ts` 新增 `attributeValuePlaceholder`**
- 引用 / 枚举 / list / 布尔 / `inheritFrom` / `Include@source` / `id` 保留
`$1` 占位并自动触发值补全(这些值靠候选选择,不能瞎猜);
- 标量属性优先用 XSD `default`(如 `Count="1"`),没有默认值时按类型给示例:
`Angle → 0d``Time → 0s``Percentage → 100%``Velocity → 0.0`
`SageReal/float → 0.0``SageInt/unsigned → 0`
- 填了具体默认值后不再弹空的 suggest 窗口;`allowsDefine` 的数值属性现在也
直接给数值示例(`$DEFINE` 仍可在值内手动触发补全)。
### 举一反三的测试(107 → 111 全绿)
- 闭合引号后接受属性 → ` Count="1"`(空格),range 零宽在光标处;
- 一行一个属性 → `\n Count="1"`(换行 + 缩进);
- 已在新行 → range 覆盖当前行空白,插入 ` Count="1"` 对齐;
- 标量默认值:`Count="1"`XSD 默认)、`FadeTime="0s"``DispositionAngle="0d"`
- 建议类属性:`CreateFX="$1"``Options="$1"``DisabledWhileBusy="$1"` 均带
trigger 命令;具体默认值不带。
版本 **0.1.10 → 0.1.11**
---
## 二十、问题分析(第十五轮,2026-08-04):属性补全的缩进叠加与 `$1` 占位符
### 现象
连续接受属性补全时,缩进不是稳定对齐,而是逐行递增。用户分步实测(0.1.12):
- 关闭引号 → 属性候选菜单 → 直接 Enter:第一次补全 `Count="1"` 就落在
6 个 Tab`Disposition` 是 3 个 Tab);
- 按空格再次触发 → Enter`CreateFX="$1"` 落在 9 个 Tab
- 再 EnterCreateFX 带触发命令,菜单自动重开):`DestinationPlayer="$1"`
落在 12 个 Tab。
```xml
<CreateObject
Options="IGNORE_ALL_OBJECTS"
Disposition="RANDOM_FORCE RELATIVE_ANGLE ABSOLUTE_ANGLE"
Count="1"
CreateFX="$1"
DestinationPlayer="$1"
```
### 根因
VS Code 在插入**含换行的补全文本**时,会给新行套用当前行的基础缩进,并与
补全文本里嵌入的缩进**相加**(而不是替换):
```text
我们插入 \n + 3 Tab → 落盘 = 当前行 3 Tab + 我们 3 Tab = 6 Tab
下一行:当前行 6 Tab + 我们 3 Tab = 9 Tab
再下一行:9 + 3 = 12 Tab
```
与实测的 6 / 9 / 12 完全一致。关键证据是**第一次补全就已多缩进**:0.1.12
第一次只插入 `\n` + 3 个 Tab(锚点是首个独占一行的 `Options`),落盘却是
6 个——问题不在我们读取了谁的缩进,而在于补全文本自带的缩进被编辑器叠加。
排查过程中还发现一个放大因素并已修复:`attributeInsertLayout` 原先以
“最后一个被解析出的属性”为锚点,用户在自动缩进的新行上输入的半截属性名
`C``D`…,`hasValue = false`)也会被当成锚点,把编辑器自动缩进抄进补全
行;即使叠加根因修掉,这个因素也会让缩进更容易跑偏。
### 修复(0.1.13
1. **换行时只插入 `\n`,不再嵌入缩进**:编辑器自动补当前行的基础缩进
(3 Tab),叠加量为 0,后续行稳定在 3 Tab;已在新行时仍显式替换为规范
缩进(该路径不插入换行,不受叠加影响)。
2. **锚点只用完整属性**`hasValue` 为真),并优先取**第一个独占一行的完整
属性**作为规范缩进;半截属性名不参与缩进计算,整行内联时才回退到最后一个
完整属性。
3. **`$1` 改为真正的 snippet 占位符**:属性名补全统一用 `SnippetString`
文档中不再出现字面 `$1`;接受补全后光标落在引号内的占位处,弹出的也是值
补全菜单。`Count="1"` 这类具体默认值同样用 snippet(无占位符,光标落在
闭合引号后)。
4. **尾随空格**:插入换行时,若上一个属性与光标之间只有空白(例如为触发补全
按的空格),把这段空白一并纳入替换范围,不再残留尾随空格。
5. **调试日志**`ModWorkspace.log()` 输出到 “RA3 Mod XML” 输出通道;
attribute-name 补全每次记录 `existing / range / prefix`JSON 转义),用于
对比“我们插入的内容”与“落盘的内容”,定位编辑器侧改写。
### 测试环境说明
当前单测(node + vscode stub**不能复现 VS Code 的 suggest 弹窗、snippet
缩进与 auto-indent 行为**,这类问题只能靠实机 + 输出通道日志确认。后续若要
自动化,需要引入 `@vscode/test-electron` 做扩展宿主集成测试(本期未做,记录
为候选)。
### 测试(111 → 113 全绿)
- 新行上输入半截属性名(行缩进 20 个空格)→ 补全仍用规范缩进,range 覆盖
整段自动缩进与已输入字符;
- 为触发补全按的空格被新行替换吞掉,不再残留尾随空格;
- 属性名补全断言改为 `insertText.value`SnippetString),换行插入断言为
`\nCount="1"`(缩进由编辑器提供)。
版本 **0.1.11 → 0.1.13**(0.1.12 为中间版本,仅含锚点与尾随空格修复,
未解决叠加;0.1.13 为最终修复)。
+45 -4
View File
@@ -1,6 +1,6 @@
# 调研结论与实施计划(已按最新代码同步更新)
> 说明:本文档随实现演进持续同步。最近一次同步(2026-08-01)对齐了实现过程中新增的模块与设计变更:BAB 精确搜索路径、manifest 类型/ID 推导、上下文感知元素类型、属性级 refType / Poid 局部引用(`id` 定义点)、精确跳转范围、嵌套 `xi:include`、注入式语法高亮等。
> 说明:本文档随实现演进持续同步。最近一次同步(2026-08-04)对齐了实现过程中新增的模块与设计变更:BAB 精确搜索路径、manifest 类型/ID 推导、上下文感知元素类型、属性级 refType / Poid 局部引用(`id` 定义点)、精确跳转范围、嵌套 `xi:include`、注入式语法高亮、bit-flag 列表补全(空格触发 / 排除已用 / 追加模式)等。
## 一、调研结论(带证据)
@@ -132,10 +132,16 @@ test/
11. **`xs:list` 建模与多值补全**:list 简单类型继承 itemType 的枚举 / refType / isRef /
allowsDefine 并标记 `isList``LocomotorSurfaceBitFlags``KindOfBitFlags` 等 79 个
类型、317 处属性声明受益);补全只对“最后一个空格段”过滤,替换范围只覆盖当前段,
支持 `Surfaces="GROUND ` 之后继续输入 `W` 提示 `WATER`
支持 `Surfaces="GROUND ` 之后继续输入 `W` 提示 `WATER`第十三轮(2026-08-04
补全触发与编辑体验:空格注册为触发字符;列表过滤排除已出现的 flag;当前段已是
完整枚举值且没有更长变体时进入“追加模式”(零宽 range + `insertText=" FLAG"`
可直接在闭合值末尾/中间追加);替换范围止于光标,中间插入不会删除尾部 flag。
12. **未闭合引号的行尾恢复**:起始标签扫描到 EOF 且引号未闭合时,在第一个换行处截断
标签并继续解析,未闭合只影响当前行(仍上报 `Unterminated start tag`),后续元素
的补全 / hover / 诊断不中断。
的补全 / hover / 诊断不中断。第十三轮补充:恢复出的元素带 `recoveredStartTag`
标记并补挂父链;补全上下文对“光标在恢复元素内但越过 `startTagEnd`”的情况按
`text.slice(tagStart, cursor)` 重新解析部分标签,使多行书写的未闭合属性
(如 `Disposition="`)仍可获得 attribute-value 补全,且不影响全局解析。
13. **语义 token 兜底高亮**:TextMate 对未闭合引号会把后续内容当字符串吞掉(任何
XML 编辑器皆然);扩展注册 `DocumentSemanticTokensProvider`,仅当解析报错时用
语义 token 覆盖标签名 / 属性名 / 属性值(标准 token 类型 `type` / `property` /
@@ -214,6 +220,19 @@ test/
`AttachModuleId` / `ModuleId` / `AutoResolveBody` 等)在最近 GameObject
子树内解析;未命中不新增诊断(保守策略,避免跨文件误报)。
顶层 `<Include type="all">` 暂不并入逻辑树(保留为后续扩展)。
26. **属性补全的插入布局与类型化默认值(第十四轮,2026-08-04)**
`attributeInsertLayout` 按临近属性的排版决定插入方式——贴引号时补空格、
一行一个属性时补换行 + 缩进、已在新行时用临近缩进替换当前行空白、
inline 风格的手动换行保留用户缩进;`attributeValuePlaceholder` 对引用/
枚举/list/布尔等建议类属性保留 `$1` + 自动触发,对标量属性填 XSD 默认值
或类型示例(`0d` / `0s` / `100%` / `0.0` / `0`),具体默认值不再弹空
suggest。
第十五轮(2026-08-04)最终结论:VS Code 插入含换行的补全文本时会把当前
行基础缩进与文本内嵌缩进相加(3+3=6、6+3=9…),因此换行前缀只插入 `\n`
缩进交给编辑器;同时半截属性名(`hasValue=false`)不再作为缩进锚点,改用
第一个独占一行的完整属性作为规范缩进,插入换行时顺带吞掉触发补全留下的
尾随空格;属性名补全改用 `SnippetString``$1` 成为真正占位符),并新增
输出通道调试日志。
## 三、实施步骤
@@ -250,6 +269,15 @@ test/
+ Poid 局部作用域补全/悬停/跳转;测试 92 → 98。2026-08-04 补充构建期
闸门:`getScope` 在重建进行中只返回 parse-only scope,避免与 indexer
抢盘(版本 0.1.9)。
16. [x] bit-flag 列表补全修复(第十三轮,2026-08-04):空格触发字符、多行
未闭合标签的部分标签重解析(`recoveredStartTag` 标记 + 恢复元素父链)、
闭合值内追加模式与中间插入范围修复;测试 98 → 107(版本 0.1.10)。
17. [x] 属性补全插入体验(第十四轮,2026-08-04):闭合引号后自动补空格、
临近属性缩进对齐、标量属性类型化默认值;测试 107 → 111(版本 0.1.11)。
18. [x] 属性补全缩进叠加修复(第十五轮,2026-08-04):换行前缀只插入 `\n`
(编辑器自动补基础缩进,避免 3+3=6 式叠加)、完整属性锚点 + 首个独占一行
属性为规范缩进、`$1` 改为 SnippetString 占位符、输出通道调试日志、尾随
空格吞除;测试 111 → 113(版本 0.1.120.1.13)。
## 四、验证结果(实测)
@@ -259,7 +287,20 @@ test/
| GenEvoTest | 66 文件 | 首次 ~2.6s / 二次 ~0.4s | 35,502manifest 35,322w3x 浅扫 38 | 2 个流、73 个 Define;二次构建 0 重扫 / 38 缓存命中 |
| Corona | 8,976 文件 | 首次 ~250s / 信任二次 ~2s / 强制 ~5-25s | 64,868manifest 35,322w3x 浅扫 4,829 | 3 个流、183 个 Define、0 诊断;二次构建 statSync 0、resolveHits 15,333 |
单元测试覆盖:XML 解析(自闭合/容错/偏移/未闭合引号行尾恢复)、补全上下文(未闭合引号仍为 attribute-value、list 多值分段)、补全集成(vscode stub 下 `LocomotorTemplate@Surfaces` 未闭合引号枚举补全、空格后第二段过滤与替换范围)、语义 token(标签/属性/值范围、合法文档返回空、malformed 返回兜底 token)、include 解析(BAB 顺序、SDK 根优先于 SageXml)、manifest 二进制解析(合成 v5 样本、类型/ID 推导)、索引器(资产/Define/流/缺失 include/嵌套 xi:include)、XSD 模型(上下文类型、`childTypeOf`、大小写规范化、属性级 refType、外来命名空间判定、`xs:list` 枚举继承与 `isList` 标记)、引用过滤(`Weapon="X"` 只跳 `WeaponTemplate`、模块 `id` 定义点、Poid 局部引用、`xi:include` 不校验、`Side="Allies"` 命中 manifest 的 `PlayerTemplate`)。
单元测试覆盖:XML 解析(自闭合/容错/偏移/未闭合引号行尾恢复 + recovered
标记)、补全上下文(未闭合引号仍为 attribute-value、多行未闭合引号、list 多值
分段、闭合引号后为 attribute-name)、补全集成(vscode stub 下
`LocomotorTemplate@Surfaces` 未闭合引号枚举补全、空格后第二段过滤与替换范围、
空格后排除已用 flag、中间插入范围止于光标、闭合值末尾追加模式、`CAN_ATTACK`
前缀保护、多行未闭合 `Disposition` 完整链路、闭合引号后补空格、一行一个属性
换行缩进、新行缩进对齐、标量类型化默认值)、语义 token(标签/属性/值范围、
合法文档返回空、malformed 返回兜底 token)、include 解析(BAB 顺序、SDK 根
优先于 SageXml)、manifest 二进制解析(合成 v5 样本、类型/ID 推导)、索引器
(资产/Define/流/缺失 include/嵌套 xi:include)、XSD 模型(上下文类型、
`childTypeOf`、大小写规范化、属性级 refType、外来命名空间判定、`xs:list`
枚举继承与 `isList` 标记)、引用过滤(`Weapon="X"` 只跳 `WeaponTemplate`
模块 `id` 定义点、Poid 局部引用、`xi:include` 不校验、`Side="Allies"` 命中
manifest 的 `PlayerTemplate`)。
> 注:D: 盘移动硬盘已恢复连接;Corona 已在第八 / 九轮按上述新数据回归。
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ra3-mod-xml",
"displayName": "RA3 Mod XML",
"description": "Red Alert 3 Mod XML tooling: syntax highlighting, completions, reference navigation and diagnostics for SAGE/BinaryAssetBuilder XML.",
"version": "0.1.9",
"version": "0.1.13",
"publisher": "ra3-mod-xml",
"license": "MIT",
"engines": {
+1
View File
@@ -30,6 +30,7 @@ export function activate(context: vscode.ExtensionContext): void {
":",
".",
"/",
" ",
),
);
context.subscriptions.push(
+249 -26
View File
@@ -1,5 +1,5 @@
import * as vscode from "vscode";
import type { XmlElement } from "../language/xmlParser";
import type { XmlAttribute, XmlElement } from "../language/xmlParser";
import {
analyzeContext,
splitListValuePrefix,
@@ -7,6 +7,7 @@ import {
} from "../language/context";
import { resolveElementType } from "../language/typeContext";
import * as model from "../model/schemaModel";
import type { AttributeInfo } from "../model/schemaModel";
import { isLocalReferenceAttribute } from "../indexer/refs";
import {
findContainingGameObject,
@@ -128,8 +129,15 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
const used = new Set(ctx.existingAttrs.map((a) => a.toLowerCase()));
const items: vscode.CompletionItem[] = [];
const wordStart = findAttributeWordStart(document, position, el);
const range = new vscode.Range(document.positionAt(wordStart), position);
const text = document.getText();
const offset = document.offsetAt(position);
const layout = attributeInsertLayout(text, el, offset);
const range = new vscode.Range(document.positionAt(layout.rangeStart), position);
this.ws.log(
`[completion] attr-name ${el.name} existing=[${ctx.existingAttrs.join(", ")}] ` +
`range=${layout.rangeStart}..${offset} prefix=${JSON.stringify(layout.prefix)}`,
);
for (const attr of attrs) {
if (used.has(attr.name.toLowerCase())) continue;
@@ -145,15 +153,16 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
if (attr.default != null) md.appendMarkdown(`Default: \`${attr.default}\` \n`);
md.appendMarkdown(`Type: \`${attr.type ?? "string"}\``);
item.documentation = md;
if (attr.required) {
item.insertText = attr.name === "id" ? 'id="$1"' : `${attr.name}="$1"`;
} else {
item.insertText = `${attr.name}="$1"`;
const value = this.attributeValuePlaceholder(attr, el);
item.insertText = new vscode.SnippetString(
layout.prefix + `${attr.name}="${value.snippet}"`,
);
if (value.trigger) {
item.command = {
command: "editor.action.triggerSuggest",
title: "Suggest attribute values",
};
}
item.command = {
command: "editor.action.triggerSuggest",
title: "Suggest attribute values",
};
items.push(item);
}
@@ -161,23 +170,67 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
if (!used.has("xai:joinaction")) {
const j = new vscode.CompletionItem("xai:joinAction", vscode.CompletionItemKind.Property);
j.range = range;
j.insertText = 'xai:joinAction="$1"';
j.insertText = new vscode.SnippetString(
layout.prefix + 'xai:joinAction="$1"',
);
j.detail = "Instance join action";
j.documentation = new vscode.MarkdownString(
"Controls how this element merges with the inherited definition: `Replace` or `Remove`.",
);
j.command = {
command: "editor.action.triggerSuggest",
title: "Suggest attribute values",
};
items.push(j);
}
if (!used.has("xmlns:xai")) {
const ns = new vscode.CompletionItem("xmlns:xai", vscode.CompletionItemKind.Property);
ns.range = range;
ns.insertText = 'xmlns:xai="uri:ea.com:eala:asset:instance"';
ns.insertText = new vscode.SnippetString(
layout.prefix + 'xmlns:xai="uri:ea.com:eala:asset:instance"',
);
ns.detail = "xai namespace";
items.push(ns);
}
return items;
}
/**
* Chooses the placeholder/value inserted for a completed attribute:
* attributes whose value is picked from suggestions (references, enums,
* lists, booleans, defines, include sources, local ids) keep a `$1`
* placeholder and re-trigger the value popup; scalar attributes get the XSD
* default (or a type-appropriate example such as `0d` for angles or `0s`
* for times) so the completed value shows the expected format immediately.
*/
private attributeValuePlaceholder(
attr: AttributeInfo,
el: XmlElement,
): { snippet: string; trigger: boolean } {
if (
attr.isBoolean ||
attr.isList ||
attr.enumValues.length > 0 ||
attr.refType != null ||
attr.isRef ||
attr.name === "inheritFrom" ||
(el.name === "Include" && attr.name === "source")
) {
return { snippet: "$1", trigger: true };
}
if (attr.name === "id") {
// `id` is the element's own definition point; nothing to suggest, but
// keep the placeholder for the user to type the id.
return { snippet: "$1", trigger: false };
}
if (attr.default != null && attr.default !== "") {
return { snippet: attr.default, trigger: false };
}
const example = DEFAULT_VALUE_BY_TYPE[(attr.type ?? "").toLowerCase()];
if (example != null) return { snippet: example, trigger: false };
return { snippet: "$1", trigger: false };
}
// ── Attribute value ───────────────────────────────────────────────
private valueItems(
@@ -201,15 +254,23 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
// whitespace-separated: only the token currently being edited is used for
// filtering, and the replacement range covers that token instead of the
// whole value.
const seg = attrInfo?.isList
const isList = attrInfo?.isList === true;
const seg = isList
? splitListValuePrefix(rawPrefix)
: { token: rawPrefix, start: 0 };
const prefix = seg.token;
const valueStartOffset =
attr.valueStart >= 0 ? attr.valueStart : document.offsetAt(position);
const endOffset =
attr.quoteEnd > attr.valueEnd ? attr.valueEnd : document.offsetAt(position);
const cursorOffset = document.offsetAt(position);
// For list values the replacement must cover only the segment being
// edited; extending to the end of the whole value would delete the flags
// after the cursor when inserting in the middle of an existing list.
const endOffset = isList
? Math.min(cursorOffset, attr.valueEnd >= 0 ? attr.valueEnd : cursorOffset)
: attr.quoteEnd > attr.valueEnd
? attr.valueEnd
: cursorOffset;
const rangeStart = valueStartOffset + seg.start;
const valueRange = new vscode.Range(
document.positionAt(rangeStart),
@@ -221,10 +282,12 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
kind: vscode.CompletionItemKind,
detail: string,
doc?: string,
range?: vscode.Range,
insertText?: string,
) => {
const item = new vscode.CompletionItem(label, kind);
item.range = valueRange;
item.insertText = label;
item.range = range ?? valueRange;
item.insertText = insertText ?? label;
item.detail = detail;
if (doc) item.documentation = new vscode.MarkdownString(doc);
return item;
@@ -266,6 +329,9 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
return this.assetIdItems(idx, null, attrInfo.refType, prefix, make);
}
if (attrInfo?.enumValues?.length) {
if (attrInfo.isList) {
return this.listEnumItems(attrInfo, rawPrefix, seg, valueRange, make);
}
return attrInfo.enumValues
.filter((v) => v.toLowerCase().startsWith(prefix.toLowerCase()))
.map((v) => make(v, vscode.CompletionItemKind.EnumMember, attrInfo.type ?? "enum"));
@@ -281,6 +347,65 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
return [];
}
/**
* Completions for xs:list enum values (whitespace-separated bit flags).
*
* Only the segment being edited is used for filtering, and values already
* present earlier in the list are excluded so adding a flag never re-offers
* an existing one. When the current segment is already a complete flag and
* no other flag extends it (e.g. "GROUND" -> "GROUND_EDGE" would disable
* this), the remaining flags are offered as insertions after the cursor
* (" FLAG") so flags can be appended to an already-closed value.
*/
private listEnumItems(
attrInfo: AttributeInfo,
rawPrefix: string,
seg: { token: string; start: number },
valueRange: vscode.Range,
make: (
label: string,
kind: vscode.CompletionItemKind,
detail: string,
doc?: string,
range?: vscode.Range,
insertText?: string,
) => vscode.CompletionItem,
): vscode.CompletionItem[] {
const used = new Set(
rawPrefix
.slice(0, seg.start)
.split(/\s+/)
.map((t) => t.toLowerCase())
.filter(Boolean),
);
const token = seg.token.toLowerCase();
const exact = token !== "" && attrInfo.enumValues.some((v) => v.toLowerCase() === token);
const extendable = attrInfo.enumValues.some(
(v) => v.toLowerCase().startsWith(token) && v.toLowerCase() !== token,
);
const append = exact && !extendable;
const range = append
? new vscode.Range(valueRange.end, valueRange.end)
: valueRange;
const filtered = attrInfo.enumValues.filter((v) => {
const lower = v.toLowerCase();
if (used.has(lower)) return false;
if (append) return lower !== token;
if (exact && lower === token) return false;
return lower.startsWith(token);
});
return filtered.map((v) =>
make(
v,
vscode.CompletionItemKind.EnumMember,
attrInfo.type ?? "enum",
undefined,
range,
append ? ` ${v}` : v,
),
);
}
private includeSourceItems(
idx: ModIndex,
prefix: string,
@@ -439,15 +564,93 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
}
}
function findAttributeWordStart(
document: vscode.TextDocument,
position: vscode.Position,
el: { start: number },
): number {
const offset = document.offsetAt(position);
const tagStart = el.start;
interface AttributeInsertLayout {
/** Offset where the completed attribute name starts replacing the text. */
rangeStart: number;
/** Text to insert before the attribute name (space / newline + indent). */
prefix: string;
}
/**
* Computes the insertion layout for an attribute-name completion:
* - a leading space when the cursor sits directly against a closing quote;
* - a plain newline when the element's attributes are laid out one per line
* (the editor supplies the new line's base indentation itself; embedding
* our own indent here would be ADDED on top of it, e.g. 3+3=6, 6+3=9);
* - replacing the current line's whitespace with that indentation when the
* user already started a new line.
*
* The indentation anchor is deliberately NOT the last parsed attribute:
* a half-typed attribute name on the cursor's new line has no value yet, and
* using its line indentation would copy the editor's own auto-indent (which
* can grow line by line) into every inserted attribute. Instead we use the
* first complete attribute that starts on its own line, which is stable and
* pre-existing, and only fall back to the last complete attribute when the
* whole element is inline.
*/
function attributeInsertLayout(
text: string,
el: XmlElement,
offset: number,
): AttributeInsertLayout {
const wordStart = findAttributeWordStart(text, offset, el.start);
const attrs = el.attrs;
const complete = attrs.filter((a) => a.hasValue);
const last = complete.length ? complete[complete.length - 1] : null;
const lastEnd = last ? attributeEndOffset(last) : -1;
const alreadyOnNewLine = lastEnd >= 0 && text.slice(lastEnd, offset).includes("\n");
// Canonical indent anchor: the first complete attribute that starts on its
// own line. Fall back to the last complete attribute for inline elements.
let anchor: XmlAttribute | null = null;
let onePerLine = false;
let prevEnd = el.start + 1 + el.name.length;
for (const a of complete) {
if (text.slice(prevEnd, a.nameStart).includes("\n")) {
anchor = a;
onePerLine = true;
break;
}
prevEnd = attributeEndOffset(a);
}
if (!anchor && complete.length) anchor = complete[complete.length - 1];
const indent = anchor
? text.slice(0, anchor.nameStart).match(/[ \t]*$/)?.[0] ?? ""
: "";
if (!onePerLine) {
if (alreadyOnNewLine) {
// Inline-style file, but the user started a new line: keep whatever
// indentation they already typed.
return { rangeStart: wordStart, prefix: "" };
}
const needsSpace = wordStart > el.start + 1 && !/\s/.test(text[wordStart - 1]);
return { rangeStart: wordStart, prefix: needsSpace ? " " : "" };
}
if (alreadyOnNewLine) {
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
return { rangeStart: lineStart, prefix: indent };
}
// Insert on a new line. The editor adds the current line's indentation to
// the new line, so we must NOT embed our own indent here (it would
// compound). If whitespace was typed between the previous attribute and
// the cursor (e.g. a space used to trigger the suggestion popup), consume
// it so it does not linger as a trailing space.
const wsStart =
lastEnd >= 0 &&
wordStart > lastEnd &&
/^[ \t]*$/.test(text.slice(lastEnd, wordStart))
? lastEnd
: wordStart;
return { rangeStart: wsStart, prefix: "\n" };
}
function attributeEndOffset(attr: XmlAttribute): number {
return attr.quoteEnd >= 0 ? attr.quoteEnd : attr.nameEnd;
}
function findAttributeWordStart(text: string, offset: number, tagStart: number): number {
let i = offset;
const text = document.getText();
while (i > tagStart) {
const c = text[i - 1];
if (/[\s=<>"/]/.test(c)) break;
@@ -455,3 +658,23 @@ function findAttributeWordStart(
}
return i;
}
/** Type-appropriate example values for common RA3 XSD scalar types. */
const DEFAULT_VALUE_BY_TYPE: Record<string, string> = {
angle: "0d",
time: "0s",
velocity: "0.0",
percentage: "100%",
sagereal: "0.0",
sageint: "0",
sageunsignedint: "0",
float: "0.0",
double: "0.0",
int: "0",
unsignedint: "0",
unsignedbyte: "0",
byte: "0",
short: "0",
long: "0",
decimal: "0.0",
};
+38 -1
View File
@@ -1,4 +1,5 @@
import type { XmlAttribute, XmlDocument, XmlElement } from "./xmlParser";
import { parseTag } from "./xmlParser";
export type ContextKind =
| "element-name"
@@ -42,6 +43,15 @@ export function analyzeContext(
return analyzeStartTag(container, text, offset);
}
// A start tag that had to be recovered (e.g. an attribute quote is still
// open) is truncated at the first line break, so attributes typed on later
// lines of the same tag are not part of the parsed element. Re-parse the
// partial tag up to the cursor so value/attribute completion keeps working
// while typing in the malformed tag.
if (container.recoveredStartTag) {
return analyzeRecoveredStartTag(container, text, offset);
}
// Otherwise the cursor is in element content.
return {
kind: "content",
@@ -53,6 +63,33 @@ export function analyzeContext(
};
}
/**
* Classifies the cursor inside a recovered (truncated) start tag by re-parsing
* the raw tag content up to the cursor. The original element only kept the
* attributes that fit on its first line; re-parsing the partial text recovers
* attributes typed on later lines without affecting the rest of the document.
*/
function analyzeRecoveredStartTag(
el: XmlElement,
text: string,
offset: number,
): CompletionContext {
const raw = parseTag(text.slice(el.start + 1, offset), el.start + 1);
const partial: XmlElement = {
name: raw.name,
attrs: raw.attrs,
children: [],
parent: el.parent,
start: raw.start,
startTagEnd: offset,
end: offset,
selfClosing: raw.selfClosing,
closeTagStart: -1,
depth: el.depth,
};
return analyzeStartTag(partial, text, offset);
}
function analyzeStartTag(
el: XmlElement,
text: string,
@@ -83,7 +120,7 @@ function analyzeStartTag(
attr.hasValue &&
attr.quoteStart >= 0 &&
offset >= attr.quoteStart &&
(attr.quoteEnd < 0 || offset <= attr.quoteEnd)
(attr.quoteEnd < 0 || offset < attr.quoteEnd)
) {
const start = attr.valueStart;
const prefix = offset > start ? text.slice(start, offset) : "";
+15 -2
View File
@@ -43,6 +43,12 @@ export interface XmlElement {
/** Offset of "</" of the closing tag, or -1 when self-closing. */
closeTagStart: number;
depth: number;
/**
* True when the start tag was unterminated and recovered at a line break.
* Attributes typed on later lines of the same tag are not part of the
* parsed element; completion re-parses the partial tag up to the cursor.
*/
recoveredStartTag?: boolean;
}
export interface XmlParseError {
@@ -119,7 +125,7 @@ interface RawTag {
const NAME_RE = /[A-Za-z_][\w:.-]*/y;
function parseTag(content: string, contentStart: number): RawTag {
export function parseTag(content: string, contentStart: number): RawTag {
const base = contentStart;
let j = 0;
while (j < content.length && /\s/.test(content[j])) {
@@ -357,8 +363,15 @@ export function parseXml(text: string): XmlDocument {
const raw = parseTag(content, i + 1);
if (raw.name) {
const el = buildElement(raw, stack.length);
el.recoveredStartTag = true;
elements.push(el);
root = root ?? el;
if (stack.length === 0) {
root = root ?? el;
} else {
const parent = stack[stack.length - 1];
parent.children.push(el);
el.parent = parent;
}
stack.push(el);
}
i = recoverTo + 1;
+5
View File
@@ -113,6 +113,11 @@ export class ModWorkspace {
return this.projectRoot != null;
}
/** Appends a line to the "RA3 Mod XML" output channel (debug/troubleshooting). */
log(message: string): void {
this.output.appendLine(message);
}
/** True while a rebuild is running (before any snapshot is published). */
get isBuilding(): boolean {
return this.building;
+224
View File
@@ -117,6 +117,7 @@ const makeProvider = (idx) =>
index: idx,
isRa3Workspace: () => true,
getScope: async (document) => makeScope(document.getText(), idx),
log: () => {},
});
const provider = makeProvider({});
const providerNoIndex = makeProvider(null);
@@ -161,6 +162,95 @@ test("list values filter on the token after whitespace", async () => {
assert.equal(water.range.start.character, valueStart + "GROUND ".length - line1Start);
});
test("list completion after a space offers only unused flags", async () => {
const text =
`<AssetDeclaration>\n <LocomotorTemplate id="x" Surfaces="GROUND ">\n <Other/>\n</LocomotorTemplate>\n</AssetDeclaration>`;
const line1 = text.split("\n")[1];
const pos = new Position(1, line1.indexOf("GROUND ") + "GROUND ".length);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const labels = items.map((i) => i.label);
assert.equal(items.length, 10);
assert.ok(labels.includes("WATER"));
assert.ok(!labels.includes("GROUND"));
// The replacement range is empty at the cursor: existing flags are kept.
const water = items.find((i) => i.label === "WATER");
assert.equal(water.range.start.character, pos.character);
assert.equal(water.range.end.character, pos.character);
});
test("inserting a flag in the middle of a list does not delete trailing flags", async () => {
const text =
`<AssetDeclaration>\n <LocomotorTemplate id="x" Surfaces="GROUND WATER">\n <Other/>\n</LocomotorTemplate>\n</AssetDeclaration>`;
const line1 = text.split("\n")[1];
// Cursor right after the space between GROUND and WATER.
const pos = new Position(1, line1.indexOf("GROUND ") + "GROUND ".length);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const labels = items.map((i) => i.label);
assert.ok(labels.includes("WATER"));
assert.ok(!labels.includes("GROUND"));
const water = items.find((i) => i.label === "WATER");
assert.equal(water.insertText, "WATER");
// The range must end at the cursor, not at the end of the whole value
// (which would replace the trailing "WATER" when accepting a suggestion).
assert.equal(water.range.start.character, pos.character);
assert.equal(water.range.end.character, pos.character);
});
test("complete flag at the end of a closed value appends the remaining flags", async () => {
const text =
`<AssetDeclaration>\n <LocomotorTemplate id="x" Surfaces="GROUND">\n <Other/>\n</LocomotorTemplate>\n</AssetDeclaration>`;
const line1 = text.split("\n")[1];
const pos = new Position(1, line1.indexOf("GROUND") + "GROUND".length);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const labels = items.map((i) => i.label);
assert.equal(items.length, 10);
assert.ok(!labels.includes("GROUND"));
const water = items.find((i) => i.label === "WATER");
assert.ok(water, "remaining flags are offered at the end of a complete flag");
assert.equal(water.insertText, " WATER");
assert.equal(water.range.start.character, pos.character);
assert.equal(water.range.end.character, pos.character);
});
test("prefix-extended flags keep prefix filtering instead of append mode", async () => {
const text =
`<AssetDeclaration>\n <ObjectFilter Include="CAN_ATTACK">\n <Other/>\n</ObjectFilter>\n</AssetDeclaration>`;
const line1 = text.split("\n")[1];
const pos = new Position(1, line1.indexOf("CAN_ATTACK") + "CAN_ATTACK".length);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const labels = items.map((i) => i.label);
assert.ok(labels.includes("CAN_ATTACK_WALLS"));
assert.ok(labels.includes("CAN_ATTACK_STEALTHED"));
assert.ok(!labels.includes("WATER"));
const walls = items.find((i) => i.label === "CAN_ATTACK_WALLS");
assert.equal(walls.insertText, "CAN_ATTACK_WALLS");
// The range replaces the typed token instead of inserting after it.
assert.equal(walls.range.start.character, pos.character - "CAN_ATTACK".length);
assert.equal(walls.range.end.character, pos.character);
});
test("multi-line unterminated Disposition value offers remaining flags", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject\n` +
` Options="IGNORE_ALL_OBJECTS"\n` +
` Disposition="RANDOM_FORCE `;
const line4 = text.split("\n")[4];
const pos = new Position(4, line4.length);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const labels = items.map((i) => i.label);
assert.ok(labels.includes("DISPOSITION_NONE"));
assert.ok(labels.includes("FLOATING"));
assert.ok(!labels.includes("RANDOM_FORCE"));
});
test("empty unterminated value offers all enum values", async () => {
const text =
`<AssetDeclaration>\n <LocomotorTemplate id="x" Surfaces=">\n <Other/>\n</LocomotorTemplate>\n</AssetDeclaration>`;
@@ -206,6 +296,140 @@ test("element and attribute name completions work without an index", async () =>
assert.ok(labels.includes("Surfaces"));
});
test("attribute completion after a closed quote inserts a space", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject Options="IGNORE_ALL_OBJECTS" Disposition="RANDOM_FORCE RELATIVE_ANGLE">`;
const line2 = text.split("\n")[2];
const pos = new Position(
2,
line2.indexOf('RELATIVE_ANGLE"') + 'RELATIVE_ANGLE"'.length,
);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const count = items.find((i) => i.label === "Count");
assert.ok(count, "Count is a valid next attribute");
assert.equal(count.insertText.value, ' Count="1"');
// The replacement range is empty at the cursor; the space comes from the
// insert text so the attribute never glues to the closing quote.
assert.equal(count.range.start.character, pos.character);
assert.equal(count.range.end.character, pos.character);
});
test("attribute completion on one-per-line elements adds a newline", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject\n` +
` Options="IGNORE_ALL_OBJECTS"\n` +
` Disposition="RANDOM_FORCE RELATIVE_ANGLE">`;
const line4 = text.split("\n")[4];
const pos = new Position(
4,
line4.indexOf('RELATIVE_ANGLE"') + 'RELATIVE_ANGLE"'.length,
);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const count = items.find((i) => i.label === "Count");
assert.ok(count);
// The editor adds the new line's base indentation itself; embedding our
// own indent here would be added on top of it and compound line by line.
assert.equal(count.insertText.value, '\nCount="1"');
assert.equal(count.range.start.character, pos.character);
assert.equal(count.range.end.character, pos.character);
});
test("attribute completion on a new line aligns with the neighbor indent", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject\n` +
` Options="IGNORE_ALL_OBJECTS"\n` +
` Disposition="RANDOM_FORCE RELATIVE_ANGLE"\n` +
` `;
const pos = new Position(5, 6);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const count = items.find((i) => i.label === "Count");
assert.ok(count);
assert.equal(count.insertText.value, ' Count="1"');
// The range replaces the whitespace already typed on the new line.
assert.equal(count.range.start.character, 0);
assert.equal(count.range.end.character, 6);
});
test("a half-typed attribute on a new line does not drive the indent", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject\n` +
` Options="IGNORE_ALL_OBJECTS"\n` +
` Disposition="RANDOM_FORCE RELATIVE_ANGLE"\n` +
` Count="1"\n` +
` C`;
// The "C" line carries a large editor auto-indent (20 spaces) that must
// NOT become the anchor for the completed attribute.
const pos = new Position(6, 21);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const createFX = items.find((i) => i.label === "CreateFX");
assert.ok(createFX);
assert.equal(createFX.insertText.value, ' CreateFX="$1"');
// The range covers the auto-indented whitespace and the typed "C".
assert.equal(createFX.range.start.character, 0);
assert.equal(createFX.range.end.character, 21);
});
test("whitespace used to trigger the popup is consumed on newline insert", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject\n` +
` Options="IGNORE_ALL_OBJECTS"\n` +
` Disposition="RANDOM_FORCE RELATIVE_ANGLE" >`;
const line4 = text.split("\n")[4];
const pos = new Position(
4,
line4.indexOf('RELATIVE_ANGLE" ') + 'RELATIVE_ANGLE" '.length,
);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const count = items.find((i) => i.label === "Count");
assert.ok(count);
assert.equal(count.insertText.value, '\nCount="1"');
// The range starts at the previous attribute's closing quote, so the
// typed space is replaced by the newline instead of lingering.
assert.equal(count.range.start.character, pos.character - 1);
assert.equal(count.range.end.character, pos.character);
});
test("scalar attributes get typed default values, suggestion attributes keep $1", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject `;
const pos = new Position(2, text.split("\n")[2].length);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const item = (label) => items.find((i) => i.label === label);
// XSD default wins.
assert.equal(item("Count").insertText.value, 'Count="1"');
// Type-based examples for scalars without an XSD default.
assert.equal(item("FadeTime").insertText.value, 'FadeTime="0s"');
assert.equal(item("DispositionAngle").insertText.value, 'DispositionAngle="0d"');
// Suggestion-driven values keep the $1 placeholder and re-trigger suggest.
assert.equal(item("CreateFX").insertText.value, 'CreateFX="$1"');
assert.ok(item("CreateFX").command, "reference values re-trigger suggest");
assert.equal(item("Options").insertText.value, 'Options="$1"');
assert.ok(item("Options").command, "list values re-trigger suggest");
assert.equal(item("DisabledWhileBusy").insertText.value, 'DisabledWhileBusy="$1"');
assert.ok(item("DisabledWhileBusy").command, "boolean values re-trigger suggest");
// Concrete defaults do not pop an empty suggest widget.
assert.equal(item("Count").command, undefined);
});
test("content (child element) completions work without an index", async () => {
const text =
`<AssetDeclaration>\n <LocomotorTemplate id="x">\n \n </LocomotorTemplate>\n</AssetDeclaration>`;
+40
View File
@@ -34,6 +34,46 @@ test("closed quote still resolves value context", () => {
assert.equal(ctx.valuePrefix, "GROUND");
});
test("multi-line unterminated quote keeps attribute-value context", () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject\n` +
` Options="IGNORE_ALL_OBJECTS"\n` +
` Disposition="`;
const cursor = text.length;
const doc = parseXml(text);
const ctx = analyzeContext(doc, text, cursor);
assert.equal(ctx.kind, "attribute-value");
assert.equal(ctx.attr?.name, "Disposition");
assert.equal(ctx.valuePrefix, "");
assert.equal(ctx.element?.name, "CreateObject");
});
test("multi-line unterminated value prefix includes typed flags", () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject\n` +
` Options="IGNORE_ALL_OBJECTS"\n` +
` Disposition="RANDOM_FORCE `;
const cursor = text.length;
const doc = parseXml(text);
const ctx = analyzeContext(doc, text, cursor);
assert.equal(ctx.kind, "attribute-value");
assert.equal(ctx.attr?.name, "Disposition");
assert.equal(ctx.valuePrefix, "RANDOM_FORCE ");
});
test("cursor after a closed quote is an attribute-name context", () => {
const text = `<Locomotor id="x" Surfaces="GROUND">\n</Locomotor>`;
const cursor = text.indexOf('GROUND"') + 'GROUND"'.length;
const doc = parseXml(text);
const ctx = analyzeContext(doc, text, cursor);
assert.equal(ctx.kind, "attribute-name");
assert.equal(ctx.attr, null);
});
test("splitListValuePrefix isolates the token being edited", () => {
assert.deepEqual(splitListValuePrefix("GROUND WA"), { token: "WA", start: 7 });
assert.deepEqual(splitListValuePrefix("GROUND "), { token: "", start: 7 });
+10
View File
@@ -67,6 +67,16 @@ test("recovers from an unterminated attribute value at end of line", () => {
assert.ok(b.start > a.start);
});
test("marks recovered start tags for partial completion re-parsing", () => {
const text = `<AssetDeclaration>\n <A x="1" y="abc\n <B/>\n </A>\n</AssetDeclaration>`;
const doc = parseXml(text);
const a = doc.elements.find((e) => e.name === "A");
assert.equal(a.recoveredStartTag, true);
// Well-formed elements parsed normally do not carry the marker.
const b = doc.elements.find((e) => e.name === "B");
assert.equal(b.recoveredStartTag, undefined);
});
test("reports an unterminated attribute value at EOF", () => {
const text = `<A x="abc`;
const doc = parseXml(text);