0.1.24 fix inheritFrom="type:id"
This commit is contained in:
+18
-1
@@ -1,9 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.23 — 2026-08-11
|
||||
## 0.1.24 — 2026-08-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- Manifest-style qualified reference values (`Type:Id`, e.g.
|
||||
`inheritFrom="AudioEvent:BaseSoundEffect"`,
|
||||
`Sound="AudioEvent:JAP_Refinery_Select"`, `Side="PlayerTemplate:Allies"`)
|
||||
now resolve to the plain-id definitions indexed from XML (mod or
|
||||
`SageXml`). Previously the plugin only applied the “last colon segment”
|
||||
rule to manifest asset names, so qualified XML references were reported as
|
||||
unresolved even when the definition existed (the reported
|
||||
`AudioEvent:BaseSoundEffect` case).
|
||||
- The same normalization now applies to simple-content references, the
|
||||
semantic reverse index (Find All References / CodeLens counts), and the
|
||||
reference peek path, so hover, Ctrl+click, diagnostics, reference counts
|
||||
and unreferenced reports all agree.
|
||||
- Value completion keeps a `Type:` prefix the user already typed:
|
||||
`inheritFrom="AudioEvent:Base…` completes to
|
||||
`AudioEvent:BaseSoundEffect` instead of dropping the prefix. Plain ids
|
||||
without a prefix keep the previous bare-id behavior.
|
||||
|
||||
- `inheritFrom` is now accepted on all `BaseAssetType`-derived assets (e.g. `FXList`, `AIMicroManagerData`, `ObjectCreationList`, `OnDemandTextureImage`, `AITargetingHeuristic`). The XSD only declares it on `BaseInheritableAsset`, but vanilla and Corona data use it more broadly. Attribute legality is now separate from the CodeLens / Find All References “reference target by design” filter, so the universal attribute does not widen the code-lens type list.
|
||||
- `simpleContent` complex types (`AudioFileRefWithWeight`, `MultisoundSubsoundRef`) keep their XSD attributes (`Weight`, `Volume`, `PitchShiftLow/High`, ...) and their text content (`<Sound>AudioFile</Sound>`, `<Subsound>VoiceEvent</Subsound>`) is now handled as a typed asset reference by completion, hover, navigation, diagnostics, the semantic reference index, and Find All References.
|
||||
- Fragment roots whose name also appears as a nested child type (e.g. `<EvaEvent>`, `<UpgradeTemplate>`) now resolve to the top-level `AssetDeclaration` type instead of the colliding child type.
|
||||
|
||||
@@ -2128,3 +2128,90 @@ hover / 跳转 / 诊断 / FAR)在第三十一轮补齐,见下。
|
||||
- `docs/plan.md`:simple-content 文本引用说明补充第三十一轮扩展;
|
||||
- `docs/features-reference-counts.md`:引用语义说明补充“含 simpleContent 复杂
|
||||
类型”。
|
||||
|
||||
---
|
||||
|
||||
## 三十二、问题分析(2026-08-11):限定引用值 `类型:ID` 未被归一化导致误报未解析
|
||||
|
||||
### 现象
|
||||
|
||||
Corona `Data\Allied\Units\AlliedFutureTankX-1\AudioEvent.xml`:
|
||||
|
||||
```xml
|
||||
<Includes>
|
||||
<Include type="instance" source="DATA:SageXml/Sounds/BaseSoundEffect.xml" />
|
||||
</Includes>
|
||||
|
||||
<AudioEvent
|
||||
id="ALL_FutureTank_ArmPrimaryWeapon"
|
||||
inheritFrom="AudioEvent:BaseSoundEffect"
|
||||
... />
|
||||
```
|
||||
|
||||
报 `Unresolved reference "AudioEvent:BaseSoundEffect"`,提示当前索引中未找到;
|
||||
但 `SageXml\Sounds\BaseSoundEffect.xml` 里确实存在 `<AudioEvent id="BaseSoundEffect" />`,
|
||||
且 `instance` include 会被索引器与文档局部 overlay 正常 walk。
|
||||
|
||||
### 根因
|
||||
|
||||
插件只在 **manifest 一侧**做了“资产名 `类型:ID` → 裸 ID(取最后冒号段)”
|
||||
的归一化(`manifestParser.deriveAssetId`);**XML 引用值一侧**直接用原始值查
|
||||
`assetsById`。于是 `AudioEvent:BaseSoundEffect` 被当成完整 ID 精确匹配,
|
||||
索引里只有 `BaseSoundEffect`,必然查不到。
|
||||
|
||||
实测最小复现:索引中包含 `AudioEvent@BaseSoundEffect`(origin=sdk),
|
||||
`assetsById.get("audioevent:basesoundeffect")` 返回 NOT FOUND,
|
||||
`resolveReferenceTargetsForType` 返回 0 目标。
|
||||
|
||||
### 影响面(真实数据统计)
|
||||
|
||||
这是原版数据的**普遍写法**,不是用户笔误:
|
||||
|
||||
| 属性 | SageXml | Corona Data | 典型值 |
|
||||
|---|---|---:|---|
|
||||
| `inheritFrom` | 5,483 | 3,219 | `AudioEvent:BaseSoundEffect` |
|
||||
| `Sound`(AudioEntry) | 39 | 98 | `AudioEvent:JAP_Refinery_Select` |
|
||||
| `Side` | 67 | 195 | `PlayerTemplate:Allies` |
|
||||
| `ParticleTexture` | 2 | 2 | `Texture:FXLenzFlare01` |
|
||||
|
||||
前缀全部是**定义资产的具体类型**(manifest 全名格式),而 XSD refType 可能是
|
||||
基类(如 `Sound` 的 refType 是 `BaseAudioEventInfo`,前缀是 `AudioEvent`)。
|
||||
两侧数据的 `id="类型:ID"` 出现次数均为 0,说明定义侧永远是裸 ID,取最后冒号段
|
||||
没有歧义。少数 `Sound="AudioEvent:MammothTankTurretMoveLoop"` 等引用在 SDK
|
||||
源码与三个 manifest 中都找不到定义,是原版数据自身的死引用,归一化后仍会
|
||||
(且应该)继续报未解析。
|
||||
|
||||
### 修复
|
||||
|
||||
1. `refs.ts` 新增 `normalizeReferenceId(value)`:取最后冒号段(与
|
||||
`deriveAssetId` 同一规则;冒号后为空时保留原值,避免半输入误匹配),
|
||||
应用到 `resolveReferenceTargetsForType` 与 `resolveContentReferenceTargets`。
|
||||
2. `referenceIndex.ts` 的 `buildReferenceIndex` 与 `features/references.ts`
|
||||
的 `definitionsForReference` 同样归一化,FAR / CodeLens / 引用 peek 与
|
||||
诊断、hover、跳转保持一致。
|
||||
3. `records.ts` 不修改:记录仍保存原始值与原始偏移,导航/悬停范围不受影响,
|
||||
缓存格式与版本不变。
|
||||
4. `completion.ts` 的 `assetIdItems`:当前输入段含 `:` 时按冒号后片段过滤,
|
||||
补全项 label/insertText 为“已输入前缀 + 裸 ID”(如 `AudioEvent:Base…`
|
||||
→ `AudioEvent:BaseSoundEffect`);未输入前缀时保持裸 ID 补全,不特判任何
|
||||
类型、也不改变默认补全形态。
|
||||
|
||||
### 测试(219 → 226 全绿)
|
||||
|
||||
- `refs.test.mjs`:`normalizeReferenceId` 边界;qualified `inheritFrom`
|
||||
解析、裸 ID 不变、错误类型前缀仍被 selfType 过滤;qualified 属性
|
||||
(`Sound` / `Side`)与 simple-content(`AudioFile:...`)引用解析;
|
||||
- `referenceIndex.test.mjs`:qualified 记录计入反向索引(FAR / CodeLens 桶);
|
||||
- `indexer.test.mjs`:临时项目集成——`instance` include 进 SageXml +
|
||||
`inheritFrom="AudioEvent:BaseSoundEffect"`,断言定义入库、解析命中、
|
||||
反向索引落点(即用户报告的完整场景);
|
||||
- `completion.test.mjs`:`AudioEvent:Base…` 补全为
|
||||
`AudioEvent:BaseSoundEffect` 且替换范围只覆盖当前段;无前缀仍补裸 ID;
|
||||
- `contentFeatures.test.mjs`:qualified `inheritFrom` 不产生未解析诊断,
|
||||
Ctrl+点击精确定位到裸 ID 定义。
|
||||
|
||||
### 文档同步
|
||||
|
||||
- `docs/requirements.md`:情况描述补充 `类型:ID` 引用写法与归一化规则;
|
||||
- `docs/plan.md`:设计决策 5 补充限定引用值归一化,实施记录追加第 29 轮;
|
||||
- `CHANGELOG.md`:0.1.24。
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
- `inheritFrom`(按元素自身类型过滤);
|
||||
- 无 `refType` 的 `isRef` 属性(按同名 ID 匹配任意声明类型)。
|
||||
|
||||
引用值本身支持原版/Mod 常用的 manifest 风格全名 `类型:ID`
|
||||
(`inheritFrom="AudioEvent:BaseSoundEffect"`、`Sound="AudioEvent:..."`、
|
||||
`Side="PlayerTemplate:Allies"`):解析与反向索引先按 `normalizeReferenceId`
|
||||
取最后冒号段,再执行上述类型过滤;记录里的原始值与偏移保持不变。
|
||||
|
||||
`inheritFrom` 对 `BaseAssetType` 系资产是通用合法属性(XSD 只在
|
||||
`BaseInheritableAsset` 声明,但原版数据在 `FXList` 等类型上也使用)。这里的
|
||||
“合法属性”判定与“设计上应显示引用计数”的 `referenceTargetTypes()` 是分开的:
|
||||
|
||||
+9
-1
@@ -142,7 +142,7 @@ test/
|
||||
跨重建复用(详见设计决策 14)。
|
||||
3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer` ↔ `W3DContainer`),类型匹配严格遵循 XSD 继承链。`assetsById` 按 id 汇总**全部类型**的定义,去重身份为 `(type, file, line)`,同一 manifest 中同名但不同类型的美术资产(如 `W3DHierarchy:AUMCV_HOVER` 与 `W3DContainer:AUMCV_HOVER`)必须全部保留,避免 `Model@Name` 这类 `BaseRenderAssetType` 引用因先到的非渲染类型而被误判为未解析。
|
||||
4. **上下文感知元素类型**:同名元素按父元素类型解析(`resolveElementType` 沿解析树逐层 `childTypeOf`,失败回退全局映射),保证 `<Weapon>` 等元素的属性/引用判定正确。
|
||||
5. **引用判定与解析**:`refType` 或 `isRef` 均视为引用;带 `refType` 时严格按类型过滤(同名 ID 不串类型);`inheritFrom` 按可继承类型过滤。**局部作用域例外**(`isLocalReferenceAttribute`):`id` 是元素自身的定义点——无 refType 或 refType 与自身类型兼容时不检查、不解析(`RoadObject@id→Road` 这类跨类型 id 引用保留检查);Poid 类型属性是管线局部引用,全局索引无法判定,不检查、不解析。
|
||||
5. **引用判定与解析**:`refType` 或 `isRef` 均视为引用;带 `refType` 时严格按类型过滤(同名 ID 不串类型);`inheritFrom` 按可继承类型过滤。**局部作用域例外**(`isLocalReferenceAttribute`):`id` 是元素自身的定义点——无 refType 或 refType 与自身类型兼容时不检查、不解析(`RoadObject@id→Road` 这类跨类型 id 引用保留检查);Poid 类型属性是管线局部引用,全局索引无法判定,不检查、不解析。**限定引用值**:原版/Mod 数据常用 manifest 风格全名 `类型:ID`(如 `AudioEvent:BaseSoundEffect`、`PlayerTemplate:Allies`);XML 定义侧 id 从不含冒号,所以查询统一走 `normalizeReferenceId`(取最后冒号段,与 manifest 的 `deriveAssetId` 同一规则)后再按 refType/selfType 过滤,records 仍保留原始值与偏移供导航/悬停使用。
|
||||
6. **重复 ID 诊断**:与 `check_duplicate_ids.py` 一致——SageXml 不参与冲突判定,mod 覆盖原版视为正常。
|
||||
7. **未解析引用诊断**:按设置严重级别报告(默认 warning);类型不匹配时给出明确文案("有同名 ID 但类型不匹配")。`definitionMode` 设置控制跳转候选:`all`(mod + 原版全部列出,mod 优先)或 `project-only`。
|
||||
8. **跳转精度**:XML 定义跳转到 `id` 属性值的精确 Range;manifest 定义映射到源码文件(如 SageXml)时也在文件内精确定位;找不到再回退到记录行。
|
||||
@@ -424,6 +424,14 @@ test/
|
||||
修正;SageXml 源缺失时保持 manifest-only,文件存在但 id 被删时降级
|
||||
到文件顶部;测试 178 → 184;分析见 `docs/analysis-issues.md`
|
||||
二十八。
|
||||
29. [x] 限定引用值 `类型:ID` 归一化(2026-08-11,v0.1.24):新增
|
||||
`refs.normalizeReferenceId`(取最后冒号段,与 manifest `deriveAssetId`
|
||||
同一规则),应用到属性引用、simple-content 引用、语义反向索引与
|
||||
FAR/CodeLens 的 `definitionsForReference`;records 仍保存原始值与
|
||||
偏移;补全在已输入 `类型:` 前缀时按冒号后片段过滤并保留前缀;
|
||||
实测 SageXml 5483 / Corona 3219 处 `inheritFrom="AudioEvent:..."`
|
||||
等受限引用全部修复;测试 219 → 226;分析见
|
||||
`docs/analysis-issues.md` 三十二。
|
||||
|
||||
## 四、验证结果(实测)
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
|
||||
|
||||
继承机制:`inheritFrom` 让一个元素默认获得目标元素的所有内容;具体合并行为由 `xai:joinAction`(`uri:ea.com:eala:asset:instance` 命名空间)控制,实际项目中出现的取值为 `Replace`、`Remove`。XSD 只在 `BaseInheritableAsset` 上显式声明 `inheritFrom`,但原版与 Corona 数据也在 `FXList`、`AIMicroManagerData` 等 `BaseAssetType` 系资产上使用它,插件按“所有资产类型的通用属性”处理。
|
||||
|
||||
引用值写法:原版与 Corona 数据中的引用既可以是裸 ID,也可以是 manifest 风格的全名 `类型:ID`(如 `inheritFrom="AudioEvent:BaseSoundEffect"`、`Sound="AudioEvent:JAP_Refinery_Select"`、`Side="PlayerTemplate:Allies"`、`ParticleTexture="Texture:FXLenzFlare01"`)。XML 定义侧的 `id` 从不含冒号,因此插件统一按“最后冒号段”归一化后再匹配定义,类型过滤仍按 refType / 元素自身类型执行。
|
||||
|
||||
全部 XML 语法由 XSD 定义:SDK 自带 `Schemas/xsd/CnC3Types.xsd`(及其 800+ 个子 XSD)。大型 Mod 项目(如 Corona)还会携带自己修改过的 XSD 副本。
|
||||
|
||||
## 二、需求清单
|
||||
@@ -38,6 +40,7 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
|
||||
- 属性值:
|
||||
- 引用型属性(XSD 中带 `xas:refType`)补全已定义的资产 ID;
|
||||
- `inheritFrom` 补全可继承的资产 ID;
|
||||
- 引用值支持 `类型:ID` 前缀写法:已输入 `AudioEvent:` 时按冒号后的 ID 过滤,插入时保留已输入的前缀;未输入前缀时保持裸 ID 补全;
|
||||
- 枚举值(XSD `xs:enumeration`);
|
||||
- `$DEFINE` 常量(如 `$CIV_HEALTH_SMALL`);
|
||||
- `<Include source>` 补全可解析的文件路径(`DATA:` / `ART:` / `AUDIO:`)。
|
||||
@@ -61,6 +64,7 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
|
||||
- 缺失必填 `id`(顶层资产);
|
||||
- 重复 ID(同类型 + 同 id,mod 文件之间;覆盖原版 SageXml 不算冲突);
|
||||
- 引用未解析(引用了不存在的资产 ID,可配置是否忽略原版 manifest 中的 ID);
|
||||
- 引用值带 `类型:` 前缀时先归一化为裸 ID 再判定(如 `AudioEvent:BaseSoundEffect` → `BaseSoundEffect`),前缀不影响类型过滤;
|
||||
- simple-content 引用元素的文本未解析(同属性引用规则,仅带 refType 的类型)。
|
||||
- `<Include>` 目标文件找不到、Include 循环;
|
||||
- `$DEFINE` 未定义。
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "ra3-mod-xml",
|
||||
"displayName": "%ra3modxml.displayName%",
|
||||
"description": "%ra3modxml.description%",
|
||||
"version": "0.1.23",
|
||||
"version": "0.1.24",
|
||||
"publisher": "lanyi",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"icon": "images/icon.png",
|
||||
|
||||
@@ -484,6 +484,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
||||
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
|
||||
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
|
||||
const lower = prefix.toLowerCase();
|
||||
// Manifest-style qualified values ("AudioEvent:BaseSoundEffect") are
|
||||
// common in vanilla data. When the user already typed a "Type:" prefix,
|
||||
// filter on the id part after the last colon and keep the prefix in the
|
||||
// inserted label (e.g. typing "AudioEvent:Base" completes to
|
||||
// "AudioEvent:BaseSoundEffect", never to a bare "BaseSoundEffect").
|
||||
const colon = lower.lastIndexOf(":");
|
||||
const idPrefix = colon >= 0 ? lower.slice(colon + 1) : lower;
|
||||
const typePrefix = colon >= 0 ? prefix.slice(0, colon + 1) : "";
|
||||
// Deduplicate by id: the same asset can be defined in several places at
|
||||
// once (current file's local overlay + global index, project XML +
|
||||
// compiled manifest, or an override). Showing one completion entry per
|
||||
@@ -502,7 +510,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
||||
if (seen.has(defKey)) return;
|
||||
seen.add(defKey);
|
||||
const idKey = def.id.toLowerCase();
|
||||
if (!idKey.startsWith(lower)) return;
|
||||
if (!idKey.startsWith(idPrefix)) return;
|
||||
let score = 3;
|
||||
if (refType && model.isAssignableTo(def.type, refType)) score = 1;
|
||||
if (selfType && model.isAssignableTo(def.type, selfType)) score = 0;
|
||||
@@ -569,7 +577,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
||||
);
|
||||
}
|
||||
return make(
|
||||
def.id,
|
||||
typePrefix ? `${typePrefix}${def.id}` : def.id,
|
||||
vscode.CompletionItemKind.Value,
|
||||
t("{0} · {1}", def.type, origin),
|
||||
doc.value,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
isReferenceAttributeOfType,
|
||||
isReferenceContentType,
|
||||
mergeLocalAndGlobalDefs,
|
||||
normalizeReferenceId,
|
||||
} from "../indexer/refs";
|
||||
import {
|
||||
referenceSitesForDef,
|
||||
@@ -92,9 +93,10 @@ export function definitionsForReference(
|
||||
idx: ModIndex,
|
||||
ctx: ReferenceContext,
|
||||
): AssetDef[] {
|
||||
const lookupId = normalizeReferenceId(ctx.id);
|
||||
const defs = mergeLocalAndGlobalDefs(
|
||||
idx.local?.assetsById.get(ctx.id.toLowerCase()),
|
||||
idx.assetsById.get(ctx.id.toLowerCase()),
|
||||
idx.local?.assetsById.get(lookupId.toLowerCase()),
|
||||
idx.assetsById.get(lookupId.toLowerCase()),
|
||||
);
|
||||
return filterAndScoreDefs(defs, ctx.refType, ctx.selfType).map((t) => t.def);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { extractIndexRecords, type IndexRecords } from "./records";
|
||||
import {
|
||||
filterAndScoreDefs,
|
||||
isReferenceTargetType,
|
||||
normalizeReferenceId,
|
||||
type ReferenceLookup,
|
||||
} from "./refs";
|
||||
import { buildVanillaSearchPaths, resolveSource } from "./includeResolver";
|
||||
@@ -50,7 +51,9 @@ export function buildReferenceIndex(
|
||||
const map = new Map<string, ReferenceSite[]>();
|
||||
for (const { file, records } of sources) {
|
||||
for (const ref of records.references) {
|
||||
const defs = lookup.assetsById.get(ref.value.toLowerCase());
|
||||
const defs = lookup.assetsById.get(
|
||||
normalizeReferenceId(ref.value).toLowerCase(),
|
||||
);
|
||||
if (!defs?.length) continue;
|
||||
const targets = filterAndScoreDefs(defs, ref.refType, ref.selfType);
|
||||
for (const target of targets) {
|
||||
|
||||
+23
-4
@@ -16,6 +16,23 @@ export interface ReferenceTarget {
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a reference value that may use the manifest-style qualified
|
||||
* form `Type:Id` (e.g. `inheritFrom="AudioEvent:BaseSoundEffect"`,
|
||||
* `Sound="AudioEvent:JAP_Refinery_Select"` or
|
||||
* `Side="PlayerTemplate:Allies"`). XML asset ids never contain ":" (the same
|
||||
* InstanceId rule the manifest parser relies on), so the referenceable id is
|
||||
* the last colon-separated segment, exactly like `deriveAssetId`. Plain ids
|
||||
* are returned unchanged. A trailing colon with an empty remainder is left
|
||||
* unchanged so a half-typed value cannot accidentally match an id.
|
||||
*/
|
||||
export function normalizeReferenceId(value: string): string {
|
||||
const idx = value.lastIndexOf(":");
|
||||
if (idx < 0) return value;
|
||||
const id = value.slice(idx + 1);
|
||||
return id.length > 0 ? id : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of `ModIndex` that reference resolution needs. Kept narrow so
|
||||
* the reverse reference index can resolve records against the indexer's live
|
||||
@@ -116,9 +133,10 @@ export function resolveReferenceTargetsForType(
|
||||
attrName: string,
|
||||
id: string,
|
||||
): ReferenceTarget[] {
|
||||
const lookupId = normalizeReferenceId(id);
|
||||
const defs = mergeLocalAndGlobalDefs(
|
||||
idx.local?.assetsById.get(id.toLowerCase()),
|
||||
idx.assetsById.get(id.toLowerCase()),
|
||||
idx.local?.assetsById.get(lookupId.toLowerCase()),
|
||||
idx.assetsById.get(lookupId.toLowerCase()),
|
||||
);
|
||||
if (!defs.length) return [];
|
||||
|
||||
@@ -175,9 +193,10 @@ export function resolveContentReferenceTargets(
|
||||
): ReferenceTarget[] {
|
||||
if (!isReferenceContentType(typeName)) return [];
|
||||
if (!typeName) return [];
|
||||
const lookupId = normalizeReferenceId(id);
|
||||
const defs = mergeLocalAndGlobalDefs(
|
||||
idx.local?.assetsById.get(id.toLowerCase()),
|
||||
idx.assetsById.get(id.toLowerCase()),
|
||||
idx.local?.assetsById.get(lookupId.toLowerCase()),
|
||||
idx.assetsById.get(lookupId.toLowerCase()),
|
||||
);
|
||||
if (!defs.length) return [];
|
||||
const info = contentInfoOfType(typeName);
|
||||
|
||||
@@ -777,6 +777,69 @@ test("simple-content value completion works before the closing tag is typed", as
|
||||
assert.equal(item.range.end.character, pos.character);
|
||||
});
|
||||
|
||||
test("asset value completion keeps a typed Type: prefix the user already typed", async () => {
|
||||
const def = {
|
||||
type: "AudioEvent",
|
||||
id: "BaseSoundEffect",
|
||||
file: "Sounds.xml",
|
||||
line: 1,
|
||||
origin: "sdk",
|
||||
};
|
||||
const idx = {
|
||||
assets: new Map([["AudioEvent", new Map([["basesoundeffect", [def]]])]]),
|
||||
assetsById: new Map([["basesoundeffect", [def]]]),
|
||||
};
|
||||
|
||||
// Qualified input: the typed "AudioEvent:" prefix must be kept, so the
|
||||
// completed value is "AudioEvent:BaseSoundEffect" and the replacement
|
||||
// range still covers only the current segment.
|
||||
const qualifiedText =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <AudioEvent id="X" inheritFrom="AudioEvent:Base\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const qualifiedLine = qualifiedText.split("\n")[1];
|
||||
const qualifiedPos = new Position(1, qualifiedLine.length);
|
||||
const qualifiedItems = await makeProvider(idx).provideCompletionItems(
|
||||
makeDocument(qualifiedText),
|
||||
qualifiedPos,
|
||||
token,
|
||||
);
|
||||
const qualifiedLabels = qualifiedItems.map((i) => i.label);
|
||||
assert.ok(
|
||||
qualifiedLabels.includes("AudioEvent:BaseSoundEffect"),
|
||||
"qualified label offered for AudioEvent:Base",
|
||||
);
|
||||
assert.ok(
|
||||
!qualifiedLabels.includes("BaseSoundEffect"),
|
||||
"the bare id is not offered when a type prefix was typed",
|
||||
);
|
||||
const qualifiedItem = qualifiedItems.find(
|
||||
(i) => i.label === "AudioEvent:BaseSoundEffect",
|
||||
);
|
||||
assert.equal(qualifiedItem.insertText, "AudioEvent:BaseSoundEffect");
|
||||
assert.equal(
|
||||
qualifiedItem.range.start.character,
|
||||
qualifiedLine.lastIndexOf('"') + 1,
|
||||
);
|
||||
assert.equal(qualifiedItem.range.end.character, qualifiedPos.character);
|
||||
|
||||
// Plain input stays plain: no prefix typed -> bare id, unchanged behavior.
|
||||
const plainText =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <AudioEvent id="X" inheritFrom="Base\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const plainLine = plainText.split("\n")[1];
|
||||
const plainPos = new Position(1, plainLine.length);
|
||||
const plainItems = await makeProvider(idx).provideCompletionItems(
|
||||
makeDocument(plainText),
|
||||
plainPos,
|
||||
token,
|
||||
);
|
||||
const plainItem = plainItems.find((i) => i.label === "BaseSoundEffect");
|
||||
assert.ok(plainItem, "bare id offered without a type prefix");
|
||||
assert.equal(plainItem.insertText, "BaseSoundEffect");
|
||||
});
|
||||
|
||||
test("simpleContent complex child inserts a value pair and triggers suggest", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
|
||||
@@ -239,6 +239,63 @@ test("Ctrl+click on simple-content text jumps to the definition", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("qualified Type:Id inheritFrom is diagnosed and navigated as resolved", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n` +
|
||||
` <AudioEvent id="BaseSoundEffect"/>\n` +
|
||||
` <AudioEvent id="X" inheritFrom="AudioEvent:BaseSoundEffect"/>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const def = {
|
||||
type: "AudioEvent",
|
||||
id: "BaseSoundEffect",
|
||||
file: URI,
|
||||
line: 1,
|
||||
origin: "project",
|
||||
};
|
||||
const idx = makeIdx([def]);
|
||||
const scope = await makeScope(text, idx);
|
||||
|
||||
// Diagnostics: the manifest-style qualified value must not be reported as
|
||||
// an unresolved reference (the reported FutureTank scenario).
|
||||
const collection = new FakeDiagnosticCollection();
|
||||
const diagnostics = new Ra3Diagnostics({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: {
|
||||
diagnoseUnknownElements: false,
|
||||
reportUnresolvedReferences: "warning",
|
||||
},
|
||||
});
|
||||
diagnostics["collection"] = collection;
|
||||
await diagnostics.update(makeDocument(text));
|
||||
const messages = collection.last.diags.map((d) => d.message);
|
||||
assert.ok(
|
||||
!messages.some((m) => m.includes("AudioEvent:BaseSoundEffect")),
|
||||
"qualified inheritFrom is not unresolved",
|
||||
);
|
||||
|
||||
// Ctrl+click on the qualified value jumps to the plain-id definition.
|
||||
const provider = new Ra3DefinitionProvider({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: { definitionMode: "all" },
|
||||
indexer: null,
|
||||
});
|
||||
const line = text.split("\n")[2];
|
||||
const pos = new Position(2, line.indexOf("AudioEvent:BaseSoundEffect") + 8);
|
||||
const locations = await provider.provideDefinition(makeDocument(text), pos, {});
|
||||
assert.ok(locations && locations.length === 1, "qualified reference resolves");
|
||||
const defLine = text.split("\n")[1];
|
||||
const defStartChar =
|
||||
defLine.indexOf('id="BaseSoundEffect"') + 'id="'.length;
|
||||
assert.equal(locations[0].range.start.line, 1);
|
||||
assert.equal(locations[0].range.start.character, defStartChar);
|
||||
assert.equal(
|
||||
locations[0].range.end.character,
|
||||
defStartChar + "BaseSoundEffect".length,
|
||||
);
|
||||
});
|
||||
|
||||
test("hover on simpleContent complex content shows the referenced definition", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
|
||||
@@ -299,6 +299,81 @@ test("manifest assets sharing an id keep every type in assetsById", async () =>
|
||||
assert.ok(airfield?.some((d) => d.type === "W3DContainer"), "W3DContainer retained");
|
||||
});
|
||||
|
||||
test("qualified Type:Id inheritFrom resolves against an instance-included SageXml definition", async () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ra3-qualified-ref-"));
|
||||
try {
|
||||
const projectDir = join(tmp, "project");
|
||||
const sdkDir = join(tmp, "sdk");
|
||||
fs.mkdirSync(join(projectDir, "Data"), { recursive: true });
|
||||
fs.mkdirSync(join(sdkDir, "SageXml", "Sounds"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
join(projectDir, "Data", "Mod.xml"),
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<Includes>
|
||||
<Include type="all" source="Units.xml" />
|
||||
</Includes>
|
||||
</AssetDeclaration>`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
join(projectDir, "Data", "Units.xml"),
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<Includes>
|
||||
<Include type="instance" source="DATA:SageXml/Sounds/BaseSoundEffect.xml" />
|
||||
</Includes>
|
||||
<AudioEvent id="ALL_FutureTank_ArmPrimaryWeapon" inheritFrom="AudioEvent:BaseSoundEffect" />
|
||||
</AssetDeclaration>`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
join(sdkDir, "SageXml", "Sounds", "BaseSoundEffect.xml"),
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<AudioEvent id="BaseSoundEffect" />
|
||||
</AssetDeclaration>`,
|
||||
);
|
||||
|
||||
const indexer = new ModIndexer({
|
||||
projectDir,
|
||||
sdkDir,
|
||||
builtmodsDirs: [],
|
||||
indexSageXml: false,
|
||||
additionalDataSearchPaths: [],
|
||||
walker: new CachedDirectoryWalker(),
|
||||
});
|
||||
const idx = await indexer.build();
|
||||
|
||||
assert.ok(
|
||||
!idx.diagnostics.some((d) => d.code === "include-not-found"),
|
||||
"the DATA:SageXml instance include resolves",
|
||||
);
|
||||
const defs = idx.assetsById.get("basesoundeffect");
|
||||
assert.ok(
|
||||
defs?.some((d) => d.type === "AudioEvent"),
|
||||
"the SageXml definition is indexed through the instance include",
|
||||
);
|
||||
|
||||
const targets = resolveReferenceTargetsForType(
|
||||
idx,
|
||||
"AudioEvent",
|
||||
"inheritFrom",
|
||||
"AudioEvent:BaseSoundEffect",
|
||||
);
|
||||
assert.equal(targets.length, 1);
|
||||
assert.equal(targets[0].def.id, "BaseSoundEffect");
|
||||
assert.equal(targets[0].def.type, "AudioEvent");
|
||||
|
||||
const sageDef = defs.find((d) => d.type === "AudioEvent");
|
||||
const sites = idx.references.get(assetDefKey(sageDef));
|
||||
assert.ok(
|
||||
sites?.some((s) => /Units\.xml$/.test(s.file)),
|
||||
"the qualified inheritFrom lands in the reverse index (FAR / CodeLens)",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("build publishes an immutable XML phase before art scanning", async () => {
|
||||
let phaseA;
|
||||
const indexer = new ModIndexer({
|
||||
|
||||
@@ -157,6 +157,42 @@ test("records extracted from XML resolve through the reference index", () => {
|
||||
assert.equal(csSites[0].kind, "attr");
|
||||
});
|
||||
|
||||
test("qualified Type:Id reference records resolve to plain-id definitions", () => {
|
||||
const def = makeDef("AudioEvent", "BaseSoundEffect", "C:/sdk/Sounds.xml", 2);
|
||||
const lookup = {
|
||||
assets: new Map(),
|
||||
assetsById: new Map([["basesoundeffect", [def]]]),
|
||||
};
|
||||
const records = {
|
||||
assets: [],
|
||||
defines: [],
|
||||
includes: [],
|
||||
rootXiIncludes: [],
|
||||
nestedXiIncludes: [],
|
||||
references: [
|
||||
{
|
||||
kind: "attr",
|
||||
refType: null,
|
||||
selfType: "AudioEvent",
|
||||
value: "AudioEvent:BaseSoundEffect",
|
||||
line: 3,
|
||||
start: 10,
|
||||
end: 40,
|
||||
},
|
||||
],
|
||||
};
|
||||
const map = buildReferenceIndex(
|
||||
[{ file: "C:/mod/AudioEvent.xml", records }],
|
||||
lookup,
|
||||
);
|
||||
|
||||
const sites = map.get(assetDefKey(def));
|
||||
assert.equal(sites?.length, 1);
|
||||
assert.equal(sites[0].file, "C:/mod/AudioEvent.xml");
|
||||
assert.equal(sites[0].start, 10);
|
||||
assert.equal(sites[0].end, 40);
|
||||
});
|
||||
|
||||
test("referenceSitesForDefinition unions manifest-source sites onto the SageXml source file", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "ra3-refindex-"));
|
||||
try {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isReferenceAttributeOfType,
|
||||
isReferenceContentType,
|
||||
isReferenceTargetType,
|
||||
normalizeReferenceId,
|
||||
resolveContentReferenceTargets,
|
||||
resolveReferenceTargets,
|
||||
resolveReferenceTargetsForType,
|
||||
@@ -346,6 +347,130 @@ test("simpleContent complex types resolve as typed content references", () => {
|
||||
assert.equal(subsoundTargets[0].def.type, "AudioEvent");
|
||||
});
|
||||
|
||||
test("normalizeReferenceId strips a manifest-style Type: prefix", () => {
|
||||
assert.equal(normalizeReferenceId("BaseSoundEffect"), "BaseSoundEffect");
|
||||
assert.equal(
|
||||
normalizeReferenceId("AudioEvent:BaseSoundEffect"),
|
||||
"BaseSoundEffect",
|
||||
);
|
||||
// Art-asset manifest names can carry a subtype segment; the referenceable
|
||||
// id is still the last colon segment.
|
||||
assert.equal(
|
||||
normalizeReferenceId("W3dContainer:W3DContainer:ABC_SKN"),
|
||||
"ABC_SKN",
|
||||
);
|
||||
// A trailing colon has no id yet; keep the raw value so a half-typed
|
||||
// qualified value never matches anything.
|
||||
assert.equal(normalizeReferenceId("AudioEvent:"), "AudioEvent:");
|
||||
});
|
||||
|
||||
test("qualified Type:Id inheritFrom values resolve to plain-id definitions", () => {
|
||||
const def = {
|
||||
type: "AudioEvent",
|
||||
id: "BaseSoundEffect",
|
||||
file: "Sounds.xml",
|
||||
line: 1,
|
||||
origin: "sdk",
|
||||
};
|
||||
const idx = {
|
||||
assetsById: new Map([["basesoundeffect", [def]]]),
|
||||
assets: new Map(),
|
||||
defines: new Map(),
|
||||
};
|
||||
|
||||
// The reported scenario: <AudioEvent inheritFrom="AudioEvent:BaseSoundEffect"/>.
|
||||
const qualified = resolveReferenceTargetsForType(
|
||||
idx,
|
||||
"AudioEvent",
|
||||
"inheritFrom",
|
||||
"AudioEvent:BaseSoundEffect",
|
||||
);
|
||||
assert.equal(qualified.length, 1);
|
||||
assert.equal(qualified[0].def.id, "BaseSoundEffect");
|
||||
|
||||
// Plain ids keep working unchanged.
|
||||
assert.equal(
|
||||
resolveReferenceTargetsForType(idx, "AudioEvent", "inheritFrom", "BaseSoundEffect")
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
|
||||
// A wrong type prefix is still filtered by selfType: the AudioEvent def
|
||||
// must never satisfy a GameObject inheritFrom.
|
||||
assert.equal(
|
||||
resolveReferenceTargetsForType(
|
||||
idx,
|
||||
"GameObject",
|
||||
"inheritFrom",
|
||||
"GameObject:BaseSoundEffect",
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("qualified Type:Id values resolve for typed attributes and content refs", () => {
|
||||
const audioEvent = {
|
||||
type: "AudioEvent",
|
||||
id: "JAP_Refinery_Select",
|
||||
file: "SoundEffects.xml",
|
||||
line: 1,
|
||||
origin: "sdk",
|
||||
};
|
||||
const playerTemplate = {
|
||||
type: "PlayerTemplate",
|
||||
id: "Allies",
|
||||
file: "PlayerTemplates.xml",
|
||||
line: 1,
|
||||
origin: "manifest",
|
||||
};
|
||||
const audioFile = {
|
||||
type: "AudioFile",
|
||||
id: "Shared",
|
||||
file: "Audio.xml",
|
||||
line: 1,
|
||||
origin: "project",
|
||||
};
|
||||
const idx = {
|
||||
assetsById: new Map([
|
||||
["jap_refinery_select", [audioEvent]],
|
||||
["allies", [playerTemplate]],
|
||||
["shared", [audioFile]],
|
||||
]),
|
||||
assets: new Map(),
|
||||
defines: new Map(),
|
||||
};
|
||||
|
||||
// SoundOrEvaEvent@Sound refType is BaseAudioEventInfo; the concrete
|
||||
// "AudioEvent:" prefix must survive normalization and the type filter.
|
||||
const soundTargets = resolveReferenceTargetsForType(
|
||||
idx,
|
||||
"SoundOrEvaEvent",
|
||||
"Sound",
|
||||
"AudioEvent:JAP_Refinery_Select",
|
||||
);
|
||||
assert.equal(soundTargets.length, 1);
|
||||
assert.equal(soundTargets[0].def.type, "AudioEvent");
|
||||
|
||||
// Side="PlayerTemplate:Allies" style attribute.
|
||||
const sideTargets = resolveReferenceTargetsForType(
|
||||
idx,
|
||||
"SideSound",
|
||||
"Side",
|
||||
"PlayerTemplate:Allies",
|
||||
);
|
||||
assert.equal(sideTargets.length, 1);
|
||||
assert.equal(sideTargets[0].def.type, "PlayerTemplate");
|
||||
|
||||
// Simple-content references use the same convention.
|
||||
const contentTargets = resolveContentReferenceTargets(
|
||||
idx,
|
||||
"AudioFileRefWithWeight",
|
||||
"AudioFile:Shared",
|
||||
);
|
||||
assert.equal(contentTargets.length, 1);
|
||||
assert.equal(contentTargets[0].def.type, "AudioFile");
|
||||
});
|
||||
|
||||
test("untyped and pipeline-local content is not a global reference", () => {
|
||||
// Generic AssetReference content is used for shader constants and model
|
||||
// sub-object names, not global asset ids; Poid is pipeline-local.
|
||||
|
||||
Reference in New Issue
Block a user