From 19dbfe0f34fb6e6c6773e8b35e997b0d4148c63a Mon Sep 17 00:00:00 2001 From: lanyizi Date: Tue, 11 Aug 2026 20:01:33 +0200 Subject: [PATCH] 0.1.24 fix inheritFrom="type:id" --- CHANGELOG.md | 19 ++++- docs/analysis-issues.md | 87 +++++++++++++++++++++ docs/features-reference-counts.md | 5 ++ docs/plan.md | 10 ++- docs/requirements.md | 4 + package.json | 2 +- src/features/completion.ts | 12 ++- src/features/references.ts | 6 +- src/indexer/referenceIndex.ts | 5 +- src/indexer/refs.ts | 27 ++++++- test/completion.test.mjs | 63 +++++++++++++++ test/contentFeatures.test.mjs | 57 ++++++++++++++ test/indexer.test.mjs | 75 ++++++++++++++++++ test/referenceIndex.test.mjs | 36 +++++++++ test/refs.test.mjs | 125 ++++++++++++++++++++++++++++++ 15 files changed, 521 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f921eca..a0584db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 (`AudioFile`, `VoiceEvent`) 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. ``, ``) now resolve to the top-level `AssetDeclaration` type instead of the colliding child type. diff --git a/docs/analysis-issues.md b/docs/analysis-issues.md index 573093c..13da7b3 100644 --- a/docs/analysis-issues.md +++ b/docs/analysis-issues.md @@ -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 + + + + + +``` + +报 `Unresolved reference "AudioEvent:BaseSoundEffect"`,提示当前索引中未找到; +但 `SageXml\Sounds\BaseSoundEffect.xml` 里确实存在 ``, +且 `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。 diff --git a/docs/features-reference-counts.md b/docs/features-reference-counts.md index b792551..84aa5ea 100644 --- a/docs/features-reference-counts.md +++ b/docs/features-reference-counts.md @@ -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()` 是分开的: diff --git a/docs/plan.md b/docs/plan.md index 1afd487..d00c39a 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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`,失败回退全局映射),保证 `` 等元素的属性/引用判定正确。 -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` 三十二。 ## 四、验证结果(实测) diff --git a/docs/requirements.md b/docs/requirements.md index 3368d74..3e2aed0 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -25,6 +25,8 @@ XML 之间的组织靠 `` 标签,共有三种语义: 继承机制:`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 之间的组织靠 `` 标签,共有三种语义: - 属性值: - 引用型属性(XSD 中带 `xas:refType`)补全已定义的资产 ID; - `inheritFrom` 补全可继承的资产 ID; + - 引用值支持 `类型:ID` 前缀写法:已输入 `AudioEvent:` 时按冒号后的 ID 过滤,插入时保留已输入的前缀;未输入前缀时保持裸 ID 补全; - 枚举值(XSD `xs:enumeration`); - `$DEFINE` 常量(如 `$CIV_HEALTH_SMALL`); - `` 补全可解析的文件路径(`DATA:` / `ART:` / `AUDIO:`)。 @@ -61,6 +64,7 @@ XML 之间的组织靠 `` 标签,共有三种语义: - 缺失必填 `id`(顶层资产); - 重复 ID(同类型 + 同 id,mod 文件之间;覆盖原版 SageXml 不算冲突); - 引用未解析(引用了不存在的资产 ID,可配置是否忽略原版 manifest 中的 ID); + - 引用值带 `类型:` 前缀时先归一化为裸 ID 再判定(如 `AudioEvent:BaseSoundEffect` → `BaseSoundEffect`),前缀不影响类型过滤; - simple-content 引用元素的文本未解析(同属性引用规则,仅带 refType 的类型)。 - `` 目标文件找不到、Include 循环; - `$DEFINE` 未定义。 diff --git a/package.json b/package.json index fab0b82..2f89552 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/features/completion.ts b/src/features/completion.ts index d41e58b..3acefe3 100644 --- a/src/features/completion.ts +++ b/src/features/completion.ts @@ -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 { 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, diff --git a/src/features/references.ts b/src/features/references.ts index 1b5bda9..87407fb 100644 --- a/src/features/references.ts +++ b/src/features/references.ts @@ -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); } diff --git a/src/indexer/referenceIndex.ts b/src/indexer/referenceIndex.ts index 09d9721..7d512aa 100644 --- a/src/indexer/referenceIndex.ts +++ b/src/indexer/referenceIndex.ts @@ -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(); 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) { diff --git a/src/indexer/refs.ts b/src/indexer/refs.ts index 1ffe27c..14d9554 100644 --- a/src/indexer/refs.ts +++ b/src/indexer/refs.ts @@ -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); diff --git a/test/completion.test.mjs b/test/completion.test.mjs index d0f9276..dd148ab 100644 --- a/test/completion.test.mjs +++ b/test/completion.test.mjs @@ -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 = + `\n` + + ` 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 = + `\n` + + ` 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 = `\n` + diff --git a/test/contentFeatures.test.mjs b/test/contentFeatures.test.mjs index 38d9633..6b975de 100644 --- a/test/contentFeatures.test.mjs +++ b/test/contentFeatures.test.mjs @@ -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 = + `\n` + + ` \n` + + ` \n` + + ``; + 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 = `\n` + diff --git a/test/indexer.test.mjs b/test/indexer.test.mjs index 8188c48..3f6d2c0 100644 --- a/test/indexer.test.mjs +++ b/test/indexer.test.mjs @@ -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"), + ` + + + + +`, + ); + fs.writeFileSync( + join(projectDir, "Data", "Units.xml"), + ` + + + + + +`, + ); + fs.writeFileSync( + join(sdkDir, "SageXml", "Sounds", "BaseSoundEffect.xml"), + ` + + +`, + ); + + 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({ diff --git a/test/referenceIndex.test.mjs b/test/referenceIndex.test.mjs index a7dafac..a695a68 100644 --- a/test/referenceIndex.test.mjs +++ b/test/referenceIndex.test.mjs @@ -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 { diff --git a/test/refs.test.mjs b/test/refs.test.mjs index ad256ba..f948b17 100644 --- a/test/refs.test.mjs +++ b/test/refs.test.mjs @@ -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: . + 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.