0.1.13 improve completion
This commit is contained in:
@@ -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 说明。
|
||||
|
||||
@@ -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;
|
||||
- 再 Enter(CreateFX 带触发命令,菜单自动重开):`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
@@ -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.12–0.1.13)。
|
||||
|
||||
## 四、验证结果(实测)
|
||||
|
||||
@@ -259,7 +287,20 @@ test/
|
||||
| GenEvoTest | 66 文件 | 首次 ~2.6s / 二次 ~0.4s | 35,502(manifest 35,322,w3x 浅扫 38) | 2 个流、73 个 Define;二次构建 0 重扫 / 38 缓存命中 |
|
||||
| Corona | 8,976 文件 | 首次 ~250s / 信任二次 ~2s / 强制 ~5-25s | 64,868(manifest 35,322,w3x 浅扫 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
@@ -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": {
|
||||
|
||||
@@ -30,6 +30,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
":",
|
||||
".",
|
||||
"/",
|
||||
" ",
|
||||
),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
|
||||
+246
-23
@@ -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",
|
||||
};
|
||||
}
|
||||
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
@@ -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) : "";
|
||||
|
||||
@@ -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);
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user