From f8187da611f70c7bf534c78c7bd456845befc305 Mon Sep 17 00:00:00 2001 From: lanyizi Date: Tue, 4 Aug 2026 17:53:11 +0200 Subject: [PATCH] 0.1.13 improve completion --- README.md | 4 +- docs/analysis-issues.md | 214 +++++++++++++++++++++++++++++ docs/plan.md | 49 ++++++- package.json | 2 +- src/extension.ts | 1 + src/features/completion.ts | 275 +++++++++++++++++++++++++++++++++---- src/language/context.ts | 39 +++++- src/language/xmlParser.ts | 17 ++- src/workspace.ts | 5 + test/completion.test.mjs | 224 ++++++++++++++++++++++++++++++ test/context.test.mjs | 40 ++++++ test/xmlParser.test.mjs | 10 ++ 12 files changed, 844 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 7854e94..04f2fc4 100644 --- a/README.md +++ b/README.md @@ -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` 常量; - `` 补全可解析的 `DATA:` / `ART:` / `AUDIO:` 与项目相对路径。 - **悬停提示**:元素/属性显示 XSD 文档、类型、必填/默认值;引用值显示定义位置;`$DEFINE` 显示值与定义位置;`Include source` / `xi:include href` 显示解析后的目标文件;`xi:include` 元素与属性给出 XInclude 说明。 diff --git a/docs/analysis-issues.md b/docs/analysis-issues.md index 2ad3264..c264dc6 100644 --- a/docs/analysis-issues.md +++ b/docs/analysis-issues.md @@ -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`),因此 +像用户示例这样属性逐行书写的标签,`` 多半已闭合。另外恢复分支没有把恢复出的 +元素挂到父元素下(`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 + 说明:本文档随实现演进持续同步。最近一次同步(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 子树内解析;未命中不新增诊断(保守策略,避免跨文件误报)。 顶层 `` 暂不并入逻辑树(保留为后续扩展)。 +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 已在第八 / 九轮按上述新数据回归。 diff --git a/package.json b/package.json index 2a5b070..c539c38 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.9", + "version": "0.1.13", "publisher": "ra3-mod-xml", "license": "MIT", "engines": { diff --git a/src/extension.ts b/src/extension.ts index b1dcdff..722e508 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -30,6 +30,7 @@ export function activate(context: vscode.ExtensionContext): void { ":", ".", "/", + " ", ), ); context.subscriptions.push( diff --git a/src/features/completion.ts b/src/features/completion.ts index ff74cc6..8e78ff7 100644 --- a/src/features/completion.ts +++ b/src/features/completion.ts @@ -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 = { + 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", +}; diff --git a/src/language/context.ts b/src/language/context.ts index ebe18a5..1b0c076 100644 --- a/src/language/context.ts +++ b/src/language/context.ts @@ -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) : ""; diff --git a/src/language/xmlParser.ts b/src/language/xmlParser.ts index 8d5f74f..355c672 100644 --- a/src/language/xmlParser.ts +++ b/src/language/xmlParser.ts @@ -43,6 +43,12 @@ export interface XmlElement { /** Offset of " 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 = + `\n \n \n\n`; + 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 = + `\n \n \n\n`; + 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 = + `\n \n \n\n`; + 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 = + `\n \n \n\n`; + 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 = + `\n` + + ` \n` + + ` 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 = `\n assert.ok(labels.includes("Surfaces")); }); +test("attribute completion after a closed quote inserts a space", async () => { + const text = + `\n` + + ` \n` + + ` `; + 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 = + `\n` + + ` \n` + + ` `; + 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 = + `\n` + + ` \n` + + ` 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 = + `\n` + + ` \n` + + ` 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 = + `\n` + + ` \n` + + ` `; + 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 = + `\n` + + ` \n` + + ` 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 = `\n \n \n \n`; diff --git a/test/context.test.mjs b/test/context.test.mjs index de2dc76..326cc7e 100644 --- a/test/context.test.mjs +++ b/test/context.test.mjs @@ -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 = + `\n` + + ` \n` + + ` { + const text = + `\n` + + ` \n` + + ` { + const text = `\n`; + 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 }); diff --git a/test/xmlParser.test.mjs b/test/xmlParser.test.mjs index 64450fb..7dbeaf5 100644 --- a/test/xmlParser.test.mjs +++ b/test/xmlParser.test.mjs @@ -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 = `\n e.name === "B"); + assert.equal(b.recoveredStartTag, undefined); +}); + test("reports an unterminated attribute value at EOF", () => { const text = `