Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90dc18a167 | ||
|
|
19dbfe0f34 | ||
|
|
84a44bedfd | ||
|
|
b5e055216c | ||
|
|
21c2275c00 |
@@ -11,6 +11,8 @@ debug.log
|
|||||||
docs/**
|
docs/**
|
||||||
OpenSAGE/**
|
OpenSAGE/**
|
||||||
test/**
|
test/**
|
||||||
|
images/**
|
||||||
|
!images/icon.png
|
||||||
**/*.map
|
**/*.map
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
esbuild.mjs
|
esbuild.mjs
|
||||||
|
|||||||
@@ -1,5 +1,54 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.25 — 2026-08-11
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Repository and homepage links now point to the GitHub mirror; README links to both GitHub and Gitea.
|
||||||
|
- Extension now activates in untrusted (Restricted Mode) workspaces; workspace-defined `ra3modxml.sdkPath` and `ra3modxml.additionalDataSearchPaths` are ignored until the workspace is trusted.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- Element-name completion for simple-content children now re-triggers value suggestions after inserting the `<Name>$1</Name>` snippet.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 128×128 PNG extension icon (converted from `images/icon.webp`). The `.vsix` no longer bundles the `images` folder, so the large GIF demos stay out of the package.
|
||||||
|
|
||||||
|
## 0.1.22 — 2026-08-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Manifest assets that share the same id under different types are now all kept in the by-id index. Previously the first same-id entry (e.g. `W3DHierarchy:AUMCV_HOVER`) could shadow later definitions (e.g. `W3DContainer:AUMCV_HOVER`), causing `Model@Name` and other `BaseRenderAssetType` references to be reported as unresolved even though the asset existed in `Static.manifest`.
|
||||||
|
- `xi:include` without an `xpointer` now splices the target document's root element itself (XInclude semantics), so fragments like `GenericCelestialBuildingSuicide.xml` keep their module wrapper (`CreateObjectDie`) instead of only inserting its children.
|
||||||
|
- Fragment files (documents whose root is not `AssetDeclaration`) no longer trigger standalone-document diagnostics: top-level `missing-id`, duplicate-id, unresolved-reference and undefined-define checks are skipped, unknown wrapper roots are not reported as unknown elements, and a known fragment root still validates its subtree's elements/attributes.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Missing `xi:include` targets now surface in the Problems panel as `include-not-found` warnings for the edited document (previously only tracked in indexer diagnostics).
|
||||||
|
|
||||||
## 0.1.21 — 2026-08-11
|
## 0.1.21 — 2026-08-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ A VS Code extension that brings **IntelliSense, navigation, reference tracking,
|
|||||||
|
|
||||||
It understands the RA3 Mod SDK's XML schema, asset types, references, includes, and vanilla game data — so editing a large mod feels much more like working with a real programming language.
|
It understands the RA3 Mod SDK's XML schema, asset types, references, includes, and vanilla game data — so editing a large mod feels much more like working with a real programming language.
|
||||||
|
|
||||||
|
Source code: [GitHub mirror](https://github.com/RA3CoronaDevelopers/Ra3ModXmlExt.git) · [Gitea](https://git.ra3battle.cn/RA3CoronaDevelopers/Ra3ModXmlExt)
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
|
|
||||||
它理解 RA3 Mod SDK 的 XML schema、资产类型、引用、include 以及原版游戏数据——编辑大型模组的体验会更接近真正的编程语言。
|
它理解 RA3 Mod SDK 的 XML schema、资产类型、引用、include 以及原版游戏数据——编辑大型模组的体验会更接近真正的编程语言。
|
||||||
|
|
||||||
|
源码:[GitHub 镜像](https://github.com/RA3CoronaDevelopers/Ra3ModXmlExt.git) · [Gitea](https://git.ra3battle.cn/RA3CoronaDevelopers/Ra3ModXmlExt)
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
|
|||||||
@@ -1813,3 +1813,405 @@ manifest 候选就会被劫持到 mod 文件。
|
|||||||
|
|
||||||
> 备注:ART/AUDIO 源映射按用户意见不作为本轮目标;`buildVanillaSearchPaths`
|
> 备注:ART/AUDIO 源映射按用户意见不作为本轮目标;`buildVanillaSearchPaths`
|
||||||
> 已包含对应 SDK 目录,将来若有源码可直接复用。
|
> 已包含对应 SDK 目录,将来若有源码可直接复用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二十九、问题分析(2026-08-11):manifest 同名不同类型资产被 `assetsById` 去重丢弃
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
|
||||||
|
Corona `Data\Allied\Units\AlliedMCV.xml` 的
|
||||||
|
`ScriptedModelDraw → ModelConditionState → Model Name="AUMCV_Hover"`
|
||||||
|
报 unresolved-reference:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ScriptedModelDraw id="ModuleTag_Draw_Hover" OkToChangeModelColor="true">
|
||||||
|
<ModelConditionState ParseCondStateType="PARSE_DEFAULT">
|
||||||
|
<Model Name="AUMCV_Hover" />
|
||||||
|
</ModelConditionState>
|
||||||
|
</ScriptedModelDraw>
|
||||||
|
```
|
||||||
|
|
||||||
|
提示为“没有类型为 `BaseRenderAssetType` 的定义(其他类型存在同名 id)”,
|
||||||
|
但 `Static.manifest` 中确实存在 `W3DContainer:AUMCV_HOVER`。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
|
||||||
|
`src/indexer/indexer.ts` 的 `addAsset()` 在维护两个索引时用了同一套去重:
|
||||||
|
|
||||||
|
- `assets`:`类型 -> id -> 定义`,按 `(file, line)` 去重;
|
||||||
|
- `assetsById`:`id -> 所有类型定义`,也按 `(file, line)` 去重。
|
||||||
|
|
||||||
|
XML 定义的行号各不相同,所以 `(file, line)` 足够;但 manifest 资产入库时
|
||||||
|
`line` 固定为 0,于是同一个 manifest 里 id 相同、类型不同的多个资产会被
|
||||||
|
当成同一条定义,只保留最先出现的类型。
|
||||||
|
|
||||||
|
`Static.manifest` 中 `AUMCV_HOVER` 的实际顺序是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
W3DHierarchy:AUMCV_HOVER
|
||||||
|
W3DAnimation:AUMCV_HOVER
|
||||||
|
W3DContainer:AUMCV_HOVER
|
||||||
|
```
|
||||||
|
|
||||||
|
`W3DContainer` 因此被 `W3DHierarchy` 挤掉。`Model@Name` 的 `refType` 是
|
||||||
|
`BaseRenderAssetType`,`W3DHierarchy` 按 XSD 继承链不是渲染资产,所以
|
||||||
|
`assetsById` 里“有同名 id”但“没有匹配类型”,正好产生上述提示。
|
||||||
|
|
||||||
|
### 为什么以前没暴露 / 不是回归
|
||||||
|
|
||||||
|
第三轮修复的 `AUAntiVehicleVehicleTech1_SKN` 在 static.manifest 里只有一个
|
||||||
|
同名定义(`W3DContainer`),没有类型竞争,因此当时测不到该分支。git blame
|
||||||
|
显示 `addAsset` 的 `(file, line)` 去重从首个提交就存在,所以这是潜在缺陷被
|
||||||
|
新数据形态首次触发,不是近期改动造成的回归。
|
||||||
|
|
||||||
|
### 影响面(真实 manifest 扫描)
|
||||||
|
|
||||||
|
对 `Static / Global / Audio` 三个 manifest 模拟当前入库逻辑:
|
||||||
|
|
||||||
|
| 指标 | 数值 |
|
||||||
|
|---|---:|
|
||||||
|
| 同名 id 跨类型的 ID | 1318 |
|
||||||
|
| 被丢弃的类型定义 | 1470 |
|
||||||
|
| `W3DContainer` / `W3DMesh` 被丢弃的 id | 412 |
|
||||||
|
|
||||||
|
`Audio.manifest` 无此类碰撞。受影响的不止诊断和 hover:
|
||||||
|
`resolveReferenceTargetsForType`、语义 FAR / CodeLens 引用计数、未类型化补全
|
||||||
|
都经 `assetsById` 查找,因此 manifest 中的模型引用普遍可能误报或漏计。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
`assetsById` 是“按 id 汇总所有类型定义”的索引,去重身份必须包含类型:
|
||||||
|
|
||||||
|
1. `src/indexer/indexer.ts` 的 `addAsset()`:`assets` 与 `assetsById` 的去重
|
||||||
|
都改为 `(type, file, line)`;
|
||||||
|
2. `src/indexer/localScope.ts` 的 `addAsset()`:同样的去重修正,避免局部
|
||||||
|
overlay 未来遇到同构数据时重复踩坑。
|
||||||
|
|
||||||
|
`mergeLocalAndGlobalDefs`、`assetDefKey` 本来已按 `(type, id, file, line)`
|
||||||
|
区分定义,修复后三处语义一致。
|
||||||
|
|
||||||
|
### 测试(新增 1 个集成测试,全量 198 个通过)
|
||||||
|
|
||||||
|
`test/indexer.test.mjs` 新增自包含用例:
|
||||||
|
|
||||||
|
- 用最小 version-5 manifest 构造 `W3DHierarchy / W3DAnimation / W3DContainer`
|
||||||
|
三个同 id 资产,顺序刻意让渲染类型排在最后;
|
||||||
|
- 再构造 `Texture:ABAirfield` 在前、`W3DContainer:ABAIRFIELD` 在后的常见形态;
|
||||||
|
- 断言 `assetsById` 保留全部类型;
|
||||||
|
- 断言 `Model@Name` 经 `resolveReferenceTargetsForType` 命中 `W3DContainer`;
|
||||||
|
- 断言反向引用索引把该引用记到 `W3DContainer` 名下。
|
||||||
|
|
||||||
|
### 文档同步
|
||||||
|
|
||||||
|
`docs/plan.md` 的 manifest 建模小节补充:`assetsById` 必须保留同 id 的不同
|
||||||
|
类型定义,去重身份为 `(type, file, line)`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二十八、问题分析(2026-08-11):xi:include 无 xpointer 语义与片段文件诊断(P0)
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
|
||||||
|
`Data/Includes/GenericCelestialBuildingSuicide.xml` 这类被 `xi:include` 引用的
|
||||||
|
片段文件在独立打开时被报一串错误:`DieMuxData` 报 `missing-id`(“顶层资产需要
|
||||||
|
id”),wrapper 根不在 XSD 里的文件报 `unknown-element`,引用在完整索引下能解析
|
||||||
|
前还会报未解析引用。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
|
||||||
|
1. **无 `xpointer` 的展开语义错误**:`expandDocument` 把目标 `root.children`
|
||||||
|
拼进父节点。按 XInclude 语义(也是 Corona 的实际用法),没有 `xpointer` 时应
|
||||||
|
整体包含目标文档的根元素。`GenericCelestialBuildingSuicide.xml` 的根
|
||||||
|
`CreateObjectDie` 本身就是要放进 GameObject 的模块;旧实现会丢掉它,只把
|
||||||
|
`DieMuxData` 拼进去。
|
||||||
|
2. **诊断层把片段当完整文档**:`isTopLevel` 假定根一定是 `AssetDeclaration`,
|
||||||
|
于是片段根的子元素被当成顶层资产要求 id;未知 wrapper 根也被当成未知元素。
|
||||||
|
3. **引用/define 与上下文耦合**:片段里的引用可能由 include 者(或 include 者
|
||||||
|
的 include 链)提供,独立打开片段时无法可靠判定。
|
||||||
|
|
||||||
|
### 修复(P0,不猜测外部上下文)
|
||||||
|
|
||||||
|
1. `logicalTree.expandDocument`:无 `xpointer` 时 `handleChild(parse.root)`,
|
||||||
|
整体包含目标根元素;有 `xpointer` 时保持 `/n:Name/child::*` 语义。
|
||||||
|
2. `diagnostics` 片段模式:根 localName 不是 `AssetDeclaration` 即为片段。
|
||||||
|
- 一律跳过顶层 `missing-id` / 跨文件重复 id、未解析引用、未定义 `$DEFINE`;
|
||||||
|
- 根是已知 XSD 元素时(如 `CreateObjectDie`),根自身提供类型上下文,整棵子树
|
||||||
|
的未知元素 / 未知属性仍正常校验;
|
||||||
|
- 根不在 XSD 中(wrapper/container,如 `CommonArmorDraws`)时,只报 XML 语法
|
||||||
|
与片段内部 `xi:include` / `<Include>` 目标缺失,其余检查延后到上下文诊断。
|
||||||
|
3. 新增 `checkXiInclude`:`xi:include` 目标缺失在 Problems 中上报
|
||||||
|
`include-not-found`(此前只在 indexer 内部诊断)。
|
||||||
|
|
||||||
|
### 测试(202 → 202 全绿)
|
||||||
|
|
||||||
|
- `localScope.test.mjs`:无 `xpointer` 的 `xi:include` 把目标根元素
|
||||||
|
`CreateObjectDie` 整体拼入 `Behaviors`,`DieMuxData` 仍挂在它下面;
|
||||||
|
- `contentFeatures.test.mjs`:片段已知根不再报 `missing-id` / 未解析引用,但子树
|
||||||
|
未知属性仍报;未知 wrapper 根不报元素/属性,片段内部缺失 `xi:include` 仍报;
|
||||||
|
完整 `AssetDeclaration` 文档的顶层 id 检查不受影响。
|
||||||
|
|
||||||
|
### 边界与后续
|
||||||
|
|
||||||
|
- **P1 上下文诊断**:indexer 增加“反向 include 表”(`xi:include` 目标 →
|
||||||
|
include 者列表),打开片段时用 include 者的逻辑树做真实上下文校验,再恢复引用 /
|
||||||
|
define / 子元素结构检查。多上下文取并集去重。
|
||||||
|
- `<Include type="all|instance|reference">` 与 `xi:include` 语义不同:前者的目标
|
||||||
|
是完整 `AssetDeclaration`,不进入片段模式;后者才允许片段文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三十、问题分析(2026-08-11):`FXList inheritFrom` 误报未知属性与模型修正
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
|
||||||
|
`GlobalData/FX_List.xml` 中:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<FXList id="FX_LargeEMCannonHitCrit" inheritFrom="FX_LargeEMCannonHit">
|
||||||
|
<NuggetList>
|
||||||
|
<ParticleSystem Particle="CritHit" OrientToObject="true" Ricochet="true"/>
|
||||||
|
</NuggetList>
|
||||||
|
</FXList>
|
||||||
|
```
|
||||||
|
|
||||||
|
报 `Unknown attribute "inheritFrom" for <FXList>`。
|
||||||
|
|
||||||
|
### 根因(三个独立问题)
|
||||||
|
|
||||||
|
**A. `inheritFrom` 的 XSD 白名单落后于 BAB 实际语义**
|
||||||
|
|
||||||
|
- SDK 与 Corona 的 `AssetTypeFXList.xsd` 都写 `FXList extends BaseAssetType`;
|
||||||
|
- `BaseAssetType` 只有 `id` / `typeHashCode` / `buildRule`,`inheritFrom` 只挂在
|
||||||
|
`BaseInheritableAsset` 上;
|
||||||
|
- 内置模型因此认为 `FXList` 没有 `inheritFrom`,diagnostics 的 unknown-attribute
|
||||||
|
直接按模型属性表判断;
|
||||||
|
- 但引用 / hover / 跳转层早就把 `inheritFrom` 当作通用引用属性处理,只有“属性名
|
||||||
|
合法性”这一层还在用 XSD 白名单,所以表现为局部不一致。
|
||||||
|
|
||||||
|
证据(用插件同一套解析器 + 模型扫描):
|
||||||
|
|
||||||
|
| 类型 | 原版 SageXml 顶层使用 `inheritFrom` | Corona 顶层使用 |
|
||||||
|
|---|---|---|
|
||||||
|
| `AIMicroManagerData` | 233 | 128 |
|
||||||
|
| `FXList` | 142 | 10 |
|
||||||
|
| `AITargetingHeuristic` | 10 | 5 |
|
||||||
|
| `ObjectCreationList` | 2 | 0 |
|
||||||
|
| `OnDemandTextureImage` | 0 | 9 |
|
||||||
|
|
||||||
|
原版 `FXListSoviet.xml` / `FXListJapan.xml` 大量使用该写法,说明这是 BAB 接受的
|
||||||
|
真实语义,不是用户笔误。
|
||||||
|
|
||||||
|
**B. `simpleContent` 复杂类型的属性被生成器丢弃**
|
||||||
|
|
||||||
|
`xsd-to-model.mjs` 的 `expandComplexType` 只读 `complexContent/extension`,没有读
|
||||||
|
`simpleContent/extension`。因此:
|
||||||
|
|
||||||
|
- `AudioFileRefWithWeight`(XSD 有 `Weight` / `Volume`)在模型里属性为空;
|
||||||
|
- `MultisoundSubsoundRef`(XSD 有 `Weight` / `PitchShiftLow/High` / `Volume` /
|
||||||
|
`PlayPercent` / `VolumeShift`)同样为空。
|
||||||
|
|
||||||
|
Corona 实测 `<Sound Weight="...">` 565 处、`<Subsound Weight="...">` 33 处会被误报。
|
||||||
|
simpleContent 复杂类型的**文本内容**引用语义(如 `<Sound>AudioFile</Sound>` 的补全 /
|
||||||
|
hover / 跳转 / 诊断 / FAR)在第三十一轮补齐,见下。
|
||||||
|
|
||||||
|
**C. 片段根元素仍受“元素名→类型”全局单映射影响**
|
||||||
|
|
||||||
|
`EvaEvent` 既是顶层资产,也是 `FXNuggetTypes` 的子元素
|
||||||
|
(`EvaEventFXNugget`)。全局 `elementTypeName("EvaEvent")` 取到的是先注册的
|
||||||
|
`EvaEventFXNugget`。完整 `AssetDeclaration` 文档有父上下文可以纠正;但
|
||||||
|
`additionalmaps/ALLC.xml` 这类根元素就是 `<EvaEvent>` 的片段没有父上下文,于是
|
||||||
|
`Priority`、`TimeBetweenEvents`、`ExpirationTime` 等合法属性被当成未知。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
1. **通用属性合法性集中到模型层**:`schemaModel.ts` 新增
|
||||||
|
`isAssetType()`(`BaseAssetType` 及其后代)与通用 `inheritFrom` 属性;
|
||||||
|
`attributesOfType()` 对资产类型统一返回它。诊断、属性补全、hover 自动一致。
|
||||||
|
2. **CodeLens / FAR 的“设计目标”判定保持窄口径**:`refs.ts` 的
|
||||||
|
`referenceTargetTypes()` 仍只看 XSD 显式声明的 `inheritFrom` 与类型化引用,
|
||||||
|
不会因为通用属性把全部 317 个资产类型变成计数目标。`isReferenceAttributeOfType`
|
||||||
|
与 `resolveReferenceTargetsForType` 同步改为只对资产类型接受 `inheritFrom`。
|
||||||
|
3. **生成器支持 `simpleContent/extension`**:`expandComplexType` 现在同时读
|
||||||
|
`complexContent` 与 `simpleContent` 的 extension,重新生成模型后
|
||||||
|
`AudioFileRefWithWeight` / `MultisoundSubsoundRef` 属性齐全。
|
||||||
|
4. **片段根优先取顶层类型**:`schemaModel.topLevelElementType()` 从
|
||||||
|
`AssetDeclaration` 的子元素声明解析类型;`resolveElementType()` 对文档根先用它,
|
||||||
|
再回退全局映射;hover 的元素名展示也使用已解析类型。
|
||||||
|
|
||||||
|
### 测试(举一反三,全量 210 通过)
|
||||||
|
|
||||||
|
- `schemaModel.test.mjs`:`FXList` / `AIMicroManagerData` / `ObjectCreationList` /
|
||||||
|
`OnDemandTextureImage` / `AITargetingHeuristic` 均接受 `inheritFrom`,
|
||||||
|
`Include` 不接受;`AudioFileRefWithWeight` / `MultisoundSubsoundRef` 属性齐全;
|
||||||
|
- `refs.test.mjs`:`FXList inheritFrom` 是引用,`Include inheritFrom` 不是;
|
||||||
|
`Credits` 接受通用 `inheritFrom` 但仍是 `isReferenceTargetType() === false`,
|
||||||
|
证明两个判定已分离;
|
||||||
|
- `typeContext.test.mjs`:片段根 `<EvaEvent>` / `<UpgradeTemplate>` 解析为顶层
|
||||||
|
类型,`<Weapon>` 仍回退到 `WeaponRef`;
|
||||||
|
- `completion.test.mjs`:`FXList` 属性补全出现 `inheritFrom`;
|
||||||
|
- `contentFeatures.test.mjs`:`FXList inheritFrom`、`<Sound Weight>`、片段根
|
||||||
|
`<EvaEvent>` 不再报 unknown-attribute,真实拼写错误仍报。
|
||||||
|
|
||||||
|
### 文档同步
|
||||||
|
|
||||||
|
- `docs/requirements.md`:继承机制补充“对资产类型通用”;
|
||||||
|
- `docs/plan.md`:XSD 结构说明补充实测差异与两个判定的分离;
|
||||||
|
- `docs/features-reference-counts.md`:说明通用 `inheritFrom` 不扩大 CodeLens
|
||||||
|
目标集合。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三十一、问题分析(2026-08-11):补齐所有“内容即引用”的语义(含 simpleContent 复杂类型)
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
上一轮只恢复了 `AudioFileRefWithWeight` / `MultisoundSubsoundRef` 的属性,但它们
|
||||||
|
的文本内容(`<Sound>AudioFile</Sound>`、`<Subsound>VoiceEvent</Subsound>`)仍然
|
||||||
|
没有按引用处理。本轮把 simple-content 的内容语义统一到一条管线:**凡是元素文本
|
||||||
|
内容带 `xas:refType` 的,无论底层是 simple type 还是 simpleContent complexType,
|
||||||
|
都参与补全 / hover / 跳转 / 诊断 / 引用索引 / FAR**。
|
||||||
|
|
||||||
|
### 真实项目验证
|
||||||
|
|
||||||
|
用插件同一套解析器 + 模型扫描 Corona `Data`(7540 个 XML,跳过 w3x):
|
||||||
|
|
||||||
|
| 类别 | 唯一元素/类型组合 | 出现次数 | 典型元素 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 带 `refType` 的内容引用 | 62 | 20,813 | `Sound`→AudioFile、`Subsound`→BaseAudioEventInfo、`CreateObject`→GameObject、`TriggeredBy`→UpgradeTemplate |
|
||||||
|
| 无 `refType` 的 `isRef` 内容 | 5 | 1,150 | `Value`/`AddEmotion`/`Compare`/`Campaign`/`Mission`→AssetReference |
|
||||||
|
| 普通标量/枚举内容 | 6 | 9,012 | `IncludeThing`/`ExcludeThing`→WeakReference、`Script`、`SpecificBarrelOverride` |
|
||||||
|
|
||||||
|
结论:
|
||||||
|
|
||||||
|
- `Sound` / `Attack` / `Decay`(`AudioFileRefWithWeight`)内容确实是 `AudioFile`
|
||||||
|
引用;`Subsound`(`MultisoundSubsoundRef`)内容确实是 `BaseAudioEventInfo`
|
||||||
|
引用,必须纳入全局引用语义。
|
||||||
|
- `AssetReference` 系的无类型内容(`Value` 等)是着色器常量、脚本参数等,
|
||||||
|
**不应**按全局资产 ID 解析;保持上一轮“只处理带 refType 的内容”的边界。
|
||||||
|
- `IncludeThing` / `ExcludeThing` 等 `WeakReference` 内容是对象过滤/局部语义,
|
||||||
|
也没有 refType,不参与全局引用。
|
||||||
|
- 内联 simpleContent(如 w3x 的 `Frame`)是 `xs:float` 标量,`contentInfoOfType`
|
||||||
|
能识别但 `refType === null`,不会误报。
|
||||||
|
|
||||||
|
### 实现
|
||||||
|
|
||||||
|
1. **模型层**:`schemaModel.ts` 新增 `SimpleContentInfo` / `ContentTypeInfo` 和
|
||||||
|
`contentInfoOfType()`;`ComplexTypeInfo` 增加可选 `content` 字段。
|
||||||
|
`xsd-to-model.mjs` 对 `simpleContent/extension` 记录 base 类型的
|
||||||
|
`refType` / `isRef` / 枚举 / list / `$DEFINE` 能力。
|
||||||
|
2. **引用判定**:`refs.ts` 的 `isReferenceContentType()` 与
|
||||||
|
`resolveContentReferenceTargets()` 改用统一内容描述;simpleContent 复杂类型
|
||||||
|
的 `refType` 也进入 `referenceTargetTypes()`,保证 CodeLens 类型过滤正确。
|
||||||
|
3. **索引**:`records.ts` 内容记录的 `refType` 从统一内容描述提取,FAR 与
|
||||||
|
引用计数不再丢 `Sound` / `Subsound`。
|
||||||
|
4. **补全**:`completion.ts` 的元素片段、内容值补全、子元素补全触发 suggest 均
|
||||||
|
统一走 `contentInfoOfType()`,`<Sound>` 会生成 `<Sound>$1</Sound>` 并弹
|
||||||
|
AudioFile 候选。
|
||||||
|
5. **hover / 导航 / 诊断**:全部改为从 `contentInfoOfType()` 取 `refType`。
|
||||||
|
|
||||||
|
### 测试(全量 219 通过)
|
||||||
|
|
||||||
|
- `schemaModel.test.mjs`:`contentInfoOfType` 对 simple 与 simpleContent 统一;
|
||||||
|
- `refs.test.mjs`:`AudioFileRefWithWeight` / `MultisoundSubsoundRef` 是内容引用,
|
||||||
|
`@inline:Frame` 不是;同名 AudioFile / AudioEvent 严格按 refType 过滤;
|
||||||
|
- `records.test.mjs`:`Sound` / `Subsound` 文本进入引用索引;
|
||||||
|
- `completion.test.mjs`:`<Sound>` 补全成值对并触发 suggest,内容值只补
|
||||||
|
AudioFile;
|
||||||
|
- `contentFeatures.test.mjs`:`<Sound>` hover / Ctrl+点击 / `<Subsound>` 未解析
|
||||||
|
诊断;
|
||||||
|
- `referenceProvider.test.mjs`:FAR 返回 `Sound` 文本引用。
|
||||||
|
|
||||||
|
### 文档同步
|
||||||
|
|
||||||
|
- `docs/requirements.md`:simple-content 元素补充 simpleContent 复杂类型示例;
|
||||||
|
- `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。
|
||||||
|
|||||||
@@ -31,10 +31,21 @@
|
|||||||
与补全 / hover / 跳转 / 诊断完全一致(`refs.ts` 是唯一判定来源):
|
与补全 / hover / 跳转 / 诊断完全一致(`refs.ts` 是唯一判定来源):
|
||||||
|
|
||||||
- 带 `xas:refType` 的属性值(如 `CommandSet` → `LogicCommandSet`);
|
- 带 `xas:refType` 的属性值(如 `CommandSet` → `LogicCommandSet`);
|
||||||
- 带 `xas:refType` 的 simple-content 文本(如 `<CreateObject>ID</CreateObject>`);
|
- 带 `xas:refType` 的 simple-content 文本(如 `<CreateObject>ID</CreateObject>`,
|
||||||
|
含 simpleContent 复杂类型 `<Sound>AudioFile</Sound>`、`<Subsound>VoiceEvent</Subsound>`);
|
||||||
- `inheritFrom`(按元素自身类型过滤);
|
- `inheritFrom`(按元素自身类型过滤);
|
||||||
- 无 `refType` 的 `isRef` 属性(按同名 ID 匹配任意声明类型)。
|
- 无 `refType` 的 `isRef` 属性(按同名 ID 匹配任意声明类型)。
|
||||||
|
|
||||||
|
引用值本身支持原版/Mod 常用的 manifest 风格全名 `类型:ID`
|
||||||
|
(`inheritFrom="AudioEvent:BaseSoundEffect"`、`Sound="AudioEvent:..."`、
|
||||||
|
`Side="PlayerTemplate:Allies"`):解析与反向索引先按 `normalizeReferenceId`
|
||||||
|
取最后冒号段,再执行上述类型过滤;记录里的原始值与偏移保持不变。
|
||||||
|
|
||||||
|
`inheritFrom` 对 `BaseAssetType` 系资产是通用合法属性(XSD 只在
|
||||||
|
`BaseInheritableAsset` 声明,但原版数据在 `FXList` 等类型上也使用)。这里的
|
||||||
|
“合法属性”判定与“设计上应显示引用计数”的 `referenceTargetTypes()` 是分开的:
|
||||||
|
通用 `inheritFrom` 不会把每个资产类型都变成 CodeLens / 未引用报告的目标。
|
||||||
|
|
||||||
不算引用:
|
不算引用:
|
||||||
|
|
||||||
- 元素自己的 `id` 定义点(除非是 `RoadObject@id→Road` 这类跨类型 id 引用);
|
- 元素自己的 `id` 定义点(除非是 `RoadObject@id→Road` 这类跨类型 id 引用);
|
||||||
|
|||||||
+34
-20
@@ -1,6 +1,6 @@
|
|||||||
# 调研结论与实施计划(已按最新代码同步更新)
|
# 调研结论与实施计划(已按最新代码同步更新)
|
||||||
|
|
||||||
> 说明:本文档随实现演进持续同步。最近一次同步(2026-08-10)对齐了实现过程中新增的模块与设计变更:BAB 精确搜索路径、manifest 类型/ID 推导、上下文感知元素类型、属性级 refType / Poid 局部引用(`id` 定义点)、精确跳转范围、嵌套 `xi:include`、注入式语法高亮、bit-flag 列表补全(空格触发 / 排除已用 / 追加模式)、simple-content 元素文本引用(补全 / hover / 跳转 / 诊断 / Find All References)、语义引用索引 / CodeLens 引用计数 / 未引用资产命令、属性补全换行判定与按 id 去重、manifest 源地址按 vanilla-only 解析(避免 mod 同名 DATA 路径遮蔽)等。
|
> 说明:本文档随实现演进持续同步。最近一次同步(2026-08-11)对齐了实现过程中新增的模块与设计变更:BAB 精确搜索路径、manifest 类型/ID 推导、上下文感知元素类型、属性级 refType / Poid 局部引用(`id` 定义点)、精确跳转范围、嵌套 `xi:include`、注入式语法高亮、bit-flag 列表补全(空格触发 / 排除已用 / 追加模式)、simple-content 元素文本引用(补全 / hover / 跳转 / 诊断 / Find All References)、语义引用索引 / CodeLens 引用计数 / 未引用资产命令、属性补全换行判定与按 id 去重、manifest 源地址按 vanilla-only 解析(避免 mod 同名 DATA 路径遮蔽)、`assetsById` 保留同 id 的不同类型 manifest 定义等。
|
||||||
|
|
||||||
## 一、调研结论(带证据)
|
## 一、调研结论(带证据)
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
- 共 **821 个 XSD / 1.5 MB**,入口 `CnC3Types.xsd`。
|
- 共 **821 个 XSD / 1.5 MB**,入口 `CnC3Types.xsd`。
|
||||||
- 根元素 `AssetDeclaration`:`Tags` / `Includes` / `Defines` + **295 个顶层资产元素**(含内联声明)。
|
- 根元素 `AssetDeclaration`:`Tags` / `Includes` / `Defines` + **295 个顶层资产元素**(含内联声明)。
|
||||||
- 每个元素名对应一个 `complexType`,子元素用 `xs:sequence` / `xs:choice` 定义,属性用 `xs:attribute` 定义;复杂类型通过 `xs:extension` 继承(如 `BaseInheritableAsset` 提供 `inheritFrom`)。
|
- 每个元素名对应一个 `complexType`,子元素用 `xs:sequence` / `xs:choice` 定义,属性用 `xs:attribute` 定义;复杂类型通过 `xs:extension` 继承(XSD 中 `BaseInheritableAsset` 提供 `inheritFrom`)。实测 BAB / 原版数据也接受 `FXList`、`AIMicroManagerData`、`AITargetingHeuristic`、`ObjectCreationList` 等 `BaseAssetType` 系资产使用 `inheritFrom`,因此插件把 `inheritFrom` 作为所有资产类型的通用属性处理;“设计上应显示引用计数”的判定仍按 XSD 显式声明的可继承类型,两者分开。
|
||||||
- `Includes/Ref.xsd` 定义了大量带 `xas:refType="<资产类型>"` 的引用类型(如 `CommandSet` 引用 `LogicCommandSet`)→ 补全/导航按引用类型过滤的依据。
|
- `Includes/Ref.xsd` 定义了大量带 `xas:refType="<资产类型>"` 的引用类型(如 `CommandSet` 引用 `LogicCommandSet`)→ 补全/导航按引用类型过滤的依据。
|
||||||
- `XmlEdit:Default` 提供默认值;`xs:enumeration` 提供枚举值。`xas:refType` 可声明在 simple type 上,也可声明在 `<xs:attribute>` 节点上(模型生成器两者都读、属性级优先)。
|
- `XmlEdit:Default` 提供默认值;`xs:enumeration` 提供枚举值。`xas:refType` 可声明在 simple type 上,也可声明在 `<xs:attribute>` 节点上(模型生成器两者都读、属性级优先)。
|
||||||
- `Poid`("Pipeline Object Id",`xas:isWeakRef="true"`)表示**管线局部标识**:`id` 属性定义元素自身(如 `ModuleData@id` → refType `ModuleData`);`ModuleId`、`AutoResolveBody`、`SoundRef` 等 Poid 属性引用同一资产/子树内的模块、子对象、材质——它们都不对全局资产索引做 resolved 判定。
|
- `Poid`("Pipeline Object Id",`xas:isWeakRef="true"`)表示**管线局部标识**:`id` 属性定义元素自身(如 `ModuleData@id` → refType `ModuleData`);`ModuleId`、`AutoResolveBody`、`SoundRef` 等 Poid 属性引用同一资产/子树内的模块、子对象、材质——它们都不对全局资产索引做 resolved 判定。
|
||||||
@@ -140,9 +140,9 @@ test/
|
|||||||
`W3DHierarchy` / `W3DCollisionBox` 等),使 `Model@Name`、`Hierarchy`、`Mesh`
|
`W3DHierarchy` / `W3DCollisionBox` 等),使 `Model@Name`、`Hierarchy`、`Mesh`
|
||||||
等引用可解析;大模型文件**浅扫描**(不建 DOM),结果缓存在 workspace 级、
|
等引用可解析;大模型文件**浅扫描**(不建 DOM),结果缓存在 workspace 级、
|
||||||
跨重建复用(详见设计决策 14)。
|
跨重建复用(详见设计决策 14)。
|
||||||
3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer` ↔ `W3DContainer`),类型匹配严格遵循 XSD 继承链。
|
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>` 等元素的属性/引用判定正确。
|
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 覆盖原版视为正常。
|
6. **重复 ID 诊断**:与 `check_duplicate_ids.py` 一致——SageXml 不参与冲突判定,mod 覆盖原版视为正常。
|
||||||
7. **未解析引用诊断**:按设置严重级别报告(默认 warning);类型不匹配时给出明确文案("有同名 ID 但类型不匹配")。`definitionMode` 设置控制跳转候选:`all`(mod + 原版全部列出,mod 优先)或 `project-only`。
|
7. **未解析引用诊断**:按设置严重级别报告(默认 warning);类型不匹配时给出明确文案("有同名 ID 但类型不匹配")。`definitionMode` 设置控制跳转候选:`all`(mod + 原版全部列出,mod 优先)或 `project-only`。
|
||||||
8. **跳转精度**:XML 定义跳转到 `id` 属性值的精确 Range;manifest 定义映射到源码文件(如 SageXml)时也在文件内精确定位;找不到再回退到记录行。
|
8. **跳转精度**:XML 定义跳转到 `id` 属性值的精确 Range;manifest 定义映射到源码文件(如 SageXml)时也在文件内精确定位;找不到再回退到记录行。
|
||||||
@@ -252,16 +252,19 @@ test/
|
|||||||
第一个独占一行的完整属性作为规范缩进,插入换行时顺带吞掉触发补全留下的
|
第一个独占一行的完整属性作为规范缩进,插入换行时顺带吞掉触发补全留下的
|
||||||
尾随空格;属性名补全改用 `SnippetString`(`$1` 成为真正占位符),并新增
|
尾随空格;属性名补全改用 `SnippetString`(`$1` 成为真正占位符),并新增
|
||||||
输出通道调试日志。
|
输出通道调试日志。
|
||||||
27. **simple-content 元素文本引用(第十六轮,2026-08-04)**:simple type
|
27. **simple-content 元素文本引用(第十六轮,2026-08-04;第三十一轮扩展)**:
|
||||||
子元素(如 `ObjectCreationList` 内嵌套 `<CreateObject>`,类型
|
simple type 子元素(如 `ObjectCreationList` 内嵌套 `<CreateObject>`,类型
|
||||||
`GameObjectWeakRef`)的**标签间文本**就是资产引用。内容区补全现在区分
|
`GameObjectWeakRef`)以及 simpleContent 复杂类型(如 `<Sound>` →
|
||||||
“复杂元素 → 子元素名”与“简单元素 → 值补全”;用户已输入 `<` 时替换范围从
|
`AudioFileRefWithWeight`、`<Subsound>` → `MultisoundSubsoundRef`)的
|
||||||
`<` 开始,杜绝 `<<`;simple type 元素片段固定为 `<Name>$1</Name>`(可填
|
**标签间文本**就是资产引用。内容区补全现在区分“复杂元素 → 子元素名”与
|
||||||
值)并自动触发值补全。hover / Ctrl 跳转 / 诊断 / Find All References
|
“内容元素 → 值补全”;用户已输入 `<` 时替换范围从 `<` 开始,杜绝 `<<`;
|
||||||
均增加内容 token 分支。只有**带 `xas:refType`** 的 simple 内容按全局引用
|
内容元素片段固定为 `<Name>$1</Name>`(可填值)并自动触发值补全。hover /
|
||||||
处理(291 处子元素声明);无类型 `AssetReference`
|
Ctrl 跳转 / 诊断 / Find All References 均增加内容 token 分支。只有**带
|
||||||
(`FXShaderConstantTexture@Value`、`RenderSubObjectReference@Mesh` 等
|
`xas:refType`** 的内容按全局引用处理(291 处 simple type 子元素声明 +
|
||||||
真实数据是贴图/子对象名)与 `Poid` 不参与全局解析,避免误报。
|
`Sound` / `Attack` / `Decay` / `Subsound` 等 simpleContent 复杂类型);
|
||||||
|
无类型 `AssetReference`(`FXShaderConstantTexture@Value`、
|
||||||
|
`RenderSubObjectReference@Mesh` 等真实数据是贴图/子对象名)与 `Poid`
|
||||||
|
不参与全局解析,避免误报。
|
||||||
补充:真实文件中 `<` 后还有 `</…>` 时,`findTagEnd` 曾把闭合标签的 `>` 误
|
补充:真实文件中 `<` 后还有 `</…>` 时,`findTagEnd` 曾把闭合标签的 `>` 误
|
||||||
当成残缺开始标签的结束,生成空名/半截名伪元素,补全走 element-name 分支
|
当成残缺开始标签的结束,生成空名/半截名伪元素,补全走 element-name 分支
|
||||||
导致 `<<`。修复为引号外遇到 `<` 即视为未闭合(行尾恢复),且
|
导致 `<<`。修复为引号外遇到 `<` 即视为未闭合(行尾恢复),且
|
||||||
@@ -421,6 +424,14 @@ test/
|
|||||||
修正;SageXml 源缺失时保持 manifest-only,文件存在但 id 被删时降级
|
修正;SageXml 源缺失时保持 manifest-only,文件存在但 id 被删时降级
|
||||||
到文件顶部;测试 178 → 184;分析见 `docs/analysis-issues.md`
|
到文件顶部;测试 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` 三十二。
|
||||||
|
|
||||||
## 四、验证结果(实测)
|
## 四、验证结果(实测)
|
||||||
|
|
||||||
@@ -464,7 +475,7 @@ test/
|
|||||||
展开(第十二轮,见第六节);顶层 `<Include type="all">` 与 `inheritFrom` +
|
展开(第十二轮,见第六节);顶层 `<Include type="all">` 与 `inheritFrom` +
|
||||||
`xai:joinAction` 的深合并仍未实现,后续如需要“当前文档视角的全量合并诊断”再继续。
|
`xai:joinAction` 的深合并仍未实现,后续如需要“当前文档视角的全量合并诊断”再继续。
|
||||||
|
|
||||||
## 六、include 展开设计备忘(2026-08-01;xi:include 部分已实施于第十二轮)
|
## 六、include 展开设计备忘(2026-08-01;xi:include 部分已实施于第十二轮,无 xpointer 语义与片段诊断见第二十八轮)
|
||||||
|
|
||||||
> 目的:集中记录 include 处理相关的现状、结论与设计,下次遇到 include 问题时从这里继续,
|
> 目的:集中记录 include 处理相关的现状、结论与设计,下次遇到 include 问题时从这里继续,
|
||||||
> 并在实施后把结果回写本节。
|
> 并在实施后把结果回写本节。
|
||||||
@@ -477,7 +488,8 @@ test/
|
|||||||
| `reference` → builtmods manifest 解析 / 缺失回退占位 XML | 已实现 |
|
| `reference` → builtmods manifest 解析 / 缺失回退占位 XML | 已实现 |
|
||||||
| 嵌套 `xi:include`(任意层级):目标可索引、缺失报 `include-not-found`、Ctrl+点击跳转、`href` hover 解析目标 | 已实现(第二轮 + 第五轮) |
|
| 嵌套 `xi:include`(任意层级):目标可索引、缺失报 `include-not-found`、Ctrl+点击跳转、`href` hover 解析目标 | 已实现(第二轮 + 第五轮) |
|
||||||
| `xi:include` 及其属性不参与 XSD 校验(外来命名空间守卫 `isXsdElementName` / `isXsdAttributeName`) | 已实现(第五轮) |
|
| `xi:include` 及其属性不参与 XSD 校验(外来命名空间守卫 `isXsdElementName` / `isXsdAttributeName`) | 已实现(第五轮) |
|
||||||
| include 目标内容“虚拟合并”进父文档的逻辑树 | 已实现 `xi:include`(第十二轮);顶层 `<Include type="all">` 仍不展开 |
|
| include 目标内容“虚拟合并”进父文档的逻辑树 | 已实现 `xi:include`(第十二轮);**无 `xpointer` 时整体包含目标根元素**(第二十八轮修正);顶层 `<Include type="all">` 仍不展开 |
|
||||||
|
| 片段文件(根非 `AssetDeclaration`)的诊断 | 已实现 P0(第二十八轮):跳过顶层 id/重复/引用/define 检查;根为已知 XSD 元素时仍校验子树;根未知时只报语法与 include 缺失 |
|
||||||
|
|
||||||
### 2. 已确认的方向
|
### 2. 已确认的方向
|
||||||
|
|
||||||
@@ -488,9 +500,11 @@ BAB(`defaultscript.cs`)编译时正是这样把整个 Mod 合并成一份大
|
|||||||
|
|
||||||
- **不要**把 include 目标展开成文本再整体重新解析:源码偏移会断裂,诊断 / 跳转 / hover /
|
- **不要**把 include 目标展开成文本再整体重新解析:源码偏移会断裂,诊断 / 跳转 / hover /
|
||||||
补全全部无法映射回原始文件。
|
补全全部无法映射回原始文件。
|
||||||
- **要做**的是:解析器逐文件解析(现状不变);展开器把目标文件选中节点按 `xpointer`
|
- **要做**的是:解析器逐文件解析(现状不变);展开器把目标文件选中节点挂进父元素——
|
||||||
子集挂进父元素,节点保留各自的源文件与原始偏移(来源追溯)。后续分析跑在逻辑树上,
|
有 `xpointer` 时取 `/n:Name/child::*` 选中 children,无 `xpointer` 时按 XInclude 语义
|
||||||
范围映射按节点 `sourceFile` 回到对应文件的 lineMap。
|
整体包含目标根元素(RA3 片段如 `CreateObjectDie` 依赖这一行为);节点保留各自的源文件
|
||||||
|
与原始偏移(来源追溯)。后续分析跑在逻辑树上,范围映射按节点 `sourceFile` 回到对应
|
||||||
|
文件的 lineMap。
|
||||||
- 现有 `parseXml` 已记录标签 / 属性 / 值的起止偏移,`XmlElement` 结构可直接复用;拼接时
|
- 现有 `parseXml` 已记录标签 / 属性 / 值的起止偏移,`XmlElement` 结构可直接复用;拼接时
|
||||||
用浅拷贝节点壳并重建 parent 链,避免破坏目标文件缓存树自身的 parent 指针。
|
用浅拷贝节点壳并重建 parent 链,避免破坏目标文件缓存树自身的 parent 指针。
|
||||||
|
|
||||||
@@ -498,7 +512,7 @@ BAB(`defaultscript.cs`)编译时正是这样把整个 Mod 合并成一份大
|
|||||||
|
|
||||||
| 构造 | 拼入逻辑树 | 理由 |
|
| 构造 | 拼入逻辑树 | 理由 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `xi:include` | ✅ | 内容并入父元素(HeadlightDraw2 场景) |
|
| `xi:include` | ✅ | 有 `xpointer`:选中容器 children;无 `xpointer`:整体包含目标根元素(CreateObjectDie / TechUpgradeReceiver 等片段场景) |
|
||||||
| EA `<Include type="all">` | ✅ | BAB“内容合并”,等价于复制进来 |
|
| EA `<Include type="all">` | ✅ | BAB“内容合并”,等价于复制进来 |
|
||||||
| `type="instance"` | ❌ | 只影响编译可见性;拼树会把 BaseVehicle 的顶层资产错误塞进当前文档 |
|
| `type="instance"` | ❌ | 只影响编译可见性;拼树会把 BaseVehicle 的顶层资产错误塞进当前文档 |
|
||||||
| `type="reference"` | ❌ | manifest 编译产物,无文本内容 |
|
| `type="reference"` | ❌ | manifest 编译产物,无文本内容 |
|
||||||
|
|||||||
+10
-5
@@ -23,7 +23,9 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
|
|||||||
- 无前缀的路径相对于当前文件所在目录;
|
- 无前缀的路径相对于当前文件所在目录;
|
||||||
- `ART:` 路径支持“文件名前两个小写字母作为子目录”的匹配(如 `JUAntiShip` → `ju/JUAntiShip`)。
|
- `ART:` 路径支持“文件名前两个小写字母作为子目录”的匹配(如 `JUAntiShip` → `ju/JUAntiShip`)。
|
||||||
|
|
||||||
继承机制:`inheritFrom` 让一个元素默认获得目标元素的所有内容;具体合并行为由 `xai:joinAction`(`uri:ea.com:eala:asset:instance` 命名空间)控制,实际项目中出现的取值为 `Replace`、`Remove`。
|
继承机制:`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 副本。
|
全部 XML 语法由 XSD 定义:SDK 自带 `Schemas/xsd/CnC3Types.xsd`(及其 800+ 个子 XSD)。大型 Mod 项目(如 Corona)还会携带自己修改过的 XSD 副本。
|
||||||
|
|
||||||
@@ -38,13 +40,15 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
|
|||||||
- 属性值:
|
- 属性值:
|
||||||
- 引用型属性(XSD 中带 `xas:refType`)补全已定义的资产 ID;
|
- 引用型属性(XSD 中带 `xas:refType`)补全已定义的资产 ID;
|
||||||
- `inheritFrom` 补全可继承的资产 ID;
|
- `inheritFrom` 补全可继承的资产 ID;
|
||||||
|
- 引用值支持 `类型:ID` 前缀写法:已输入 `AudioEvent:` 时按冒号后的 ID 过滤,插入时保留已输入的前缀;未输入前缀时保持裸 ID 补全;
|
||||||
- 枚举值(XSD `xs:enumeration`);
|
- 枚举值(XSD `xs:enumeration`);
|
||||||
- `$DEFINE` 常量(如 `$CIV_HEALTH_SMALL`);
|
- `$DEFINE` 常量(如 `$CIV_HEALTH_SMALL`);
|
||||||
- `<Include source>` 补全可解析的文件路径(`DATA:` / `ART:` / `AUDIO:`)。
|
- `<Include source>` 补全可解析的文件路径(`DATA:` / `ART:` / `AUDIO:`)。
|
||||||
- **元素文本内容(simple content)**:带 `xas:refType` 的简单内容元素
|
- **元素文本内容(simple content)**:带 `xas:refType` 的内容元素
|
||||||
(如 `<CreateObject>ID</CreateObject>`)在标签间补全对应类型的资产 ID、
|
(simple type 如 `<CreateObject>ID</CreateObject>`,simpleContent 复杂类型
|
||||||
枚举或 `$DEFINE`;补全出的 simple-content 元素必须是可填值的成对标签
|
如 `<Sound>AudioFile</Sound>`、`<Subsound>VoiceEvent</Subsound>`)在标签间
|
||||||
(`<Name></Name>`),且内容区已输入 `<` 时不得产生 `<<`。
|
补全对应类型的资产 ID、枚举或 `$DEFINE`;补全出的 simple-content 元素必须
|
||||||
|
是可填值的成对标签(`<Name></Name>`),且内容区已输入 `<` 时不得产生 `<<`。
|
||||||
3. **引用提示(Hover)**:元素/属性悬停显示 XSD 文档、类型、默认值;资产 ID 悬停显示定义位置;`$DEFINE` 悬停显示值与定义位置。
|
3. **引用提示(Hover)**:元素/属性悬停显示 XSD 文档、类型、默认值;资产 ID 悬停显示定义位置;`$DEFINE` 悬停显示值与定义位置。
|
||||||
- 元素文本内容(simple content 引用)悬停同样显示定义位置。
|
- 元素文本内容(simple content 引用)悬停同样显示定义位置。
|
||||||
4. **引用导航**:
|
4. **引用导航**:
|
||||||
@@ -60,6 +64,7 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
|
|||||||
- 缺失必填 `id`(顶层资产);
|
- 缺失必填 `id`(顶层资产);
|
||||||
- 重复 ID(同类型 + 同 id,mod 文件之间;覆盖原版 SageXml 不算冲突);
|
- 重复 ID(同类型 + 同 id,mod 文件之间;覆盖原版 SageXml 不算冲突);
|
||||||
- 引用未解析(引用了不存在的资产 ID,可配置是否忽略原版 manifest 中的 ID);
|
- 引用未解析(引用了不存在的资产 ID,可配置是否忽略原版 manifest 中的 ID);
|
||||||
|
- 引用值带 `类型:` 前缀时先归一化为裸 ID 再判定(如 `AudioEvent:BaseSoundEffect` → `BaseSoundEffect`),前缀不影响类型过滤;
|
||||||
- simple-content 引用元素的文本未解析(同属性引用规则,仅带 refType 的类型)。
|
- simple-content 引用元素的文本未解析(同属性引用规则,仅带 refType 的类型)。
|
||||||
- `<Include>` 目标文件找不到、Include 循环;
|
- `<Include>` 目标文件找不到、Include 循环;
|
||||||
- `$DEFINE` 未定义。
|
- `$DEFINE` 未定义。
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -66,6 +66,7 @@
|
|||||||
" (based on a partial index)": " (based on a partial index)",
|
" (based on a partial index)": " (based on a partial index)",
|
||||||
" (index incomplete — may be a false positive)": " (index incomplete — may be a false positive)",
|
" (index incomplete — may be a false positive)": " (index incomplete — may be a false positive)",
|
||||||
"Include target not found: {0}": "Include target not found: {0}",
|
"Include target not found: {0}": "Include target not found: {0}",
|
||||||
|
"xi:include target not found: {0}": "xi:include target not found: {0}",
|
||||||
"XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.": "XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.",
|
"XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.": "XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.",
|
||||||
"**Top-level asset element**": "**Top-level asset element**",
|
"**Top-level asset element**": "**Top-level asset element**",
|
||||||
"Attributes: {0} · Children: {1}": "Attributes: {0} · Children: {1}",
|
"Attributes: {0} · Children: {1}": "Attributes: {0} · Children: {1}",
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
" (based on a partial index)": "(基于部分索引)",
|
" (based on a partial index)": "(基于部分索引)",
|
||||||
" (index incomplete — may be a false positive)": "(索引不完整——可能是误报)",
|
" (index incomplete — may be a false positive)": "(索引不完整——可能是误报)",
|
||||||
"Include target not found: {0}": "找不到 Include 目标:{0}",
|
"Include target not found: {0}": "找不到 Include 目标:{0}",
|
||||||
|
"xi:include target not found: {0}": "找不到 xi:include 目标:{0}",
|
||||||
"XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.": "XInclude 元素(W3C XInclude 命名空间)——不属于 RA3 XSD 模型。",
|
"XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.": "XInclude 元素(W3C XInclude 命名空间)——不属于 RA3 XSD 模型。",
|
||||||
"**Top-level asset element**": "**顶层资产元素**",
|
"**Top-level asset element**": "**顶层资产元素**",
|
||||||
"Attributes: {0} · Children: {1}": "属性:{0} · 子元素:{1}",
|
"Attributes: {0} · Children: {1}": "属性:{0} · 子元素:{1}",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ra3-mod-xml",
|
"name": "ra3-mod-xml",
|
||||||
"version": "0.1.21",
|
"version": "0.1.25",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ra3-mod-xml",
|
"name": "ra3-mod-xml",
|
||||||
"version": "0.1.21",
|
"version": "0.1.25",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-xml-parser": "^4.5.0"
|
"fast-xml-parser": "^4.5.0"
|
||||||
|
|||||||
+14
-3
@@ -2,14 +2,25 @@
|
|||||||
"name": "ra3-mod-xml",
|
"name": "ra3-mod-xml",
|
||||||
"displayName": "%ra3modxml.displayName%",
|
"displayName": "%ra3modxml.displayName%",
|
||||||
"description": "%ra3modxml.description%",
|
"description": "%ra3modxml.description%",
|
||||||
"version": "0.1.21",
|
"version": "0.1.25",
|
||||||
"publisher": "lanyi",
|
"publisher": "lanyi",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
"icon": "images/icon.png",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.ra3battle.cn/RA3CoronaDevelopers/Ra3ModXmlExt.git"
|
"url": "https://github.com/RA3CoronaDevelopers/Ra3ModXmlExt.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/RA3CoronaDevelopers/Ra3ModXmlExt",
|
||||||
|
"capabilities": {
|
||||||
|
"untrustedWorkspaces": {
|
||||||
|
"supported": "limited",
|
||||||
|
"description": "The workspace-defined SDK path and additional DATA: search paths are ignored until the workspace is trusted.",
|
||||||
|
"restrictedConfigurations": [
|
||||||
|
"ra3modxml.sdkPath",
|
||||||
|
"ra3modxml.additionalDataSearchPaths"
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"homepage": "https://git.ra3battle.cn/RA3CoronaDevelopers/Ra3ModXmlExt",
|
|
||||||
"l10n": "./l10n",
|
"l10n": "./l10n",
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.85.0"
|
"vscode": "^1.85.0"
|
||||||
|
|||||||
+36
-16
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "../language/context";
|
} from "../language/context";
|
||||||
import { resolveElementType } from "../language/typeContext";
|
import { resolveElementType } from "../language/typeContext";
|
||||||
import * as model from "../model/schemaModel";
|
import * as model from "../model/schemaModel";
|
||||||
import type { AttributeInfo, SimpleTypeInfo } from "../model/schemaModel";
|
import type { AttributeInfo, ContentTypeInfo } from "../model/schemaModel";
|
||||||
import { isLocalReferenceAttribute } from "../indexer/refs";
|
import { isLocalReferenceAttribute } from "../indexer/refs";
|
||||||
import {
|
import {
|
||||||
findContainingGameObject,
|
findContainingGameObject,
|
||||||
@@ -84,6 +84,13 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
|||||||
item.documentation = docText ? new vscode.MarkdownString(docText) : undefined;
|
item.documentation = docText ? new vscode.MarkdownString(docText) : undefined;
|
||||||
item.detail = type ? t("RA3 XML · {0}", type) : t("RA3 XML");
|
item.detail = type ? t("RA3 XML · {0}", type) : t("RA3 XML");
|
||||||
item.insertText = this.elementSnippet(child.name, type, ctx.element == null);
|
item.insertText = this.elementSnippet(child.name, type, ctx.element == null);
|
||||||
|
const contentInfo = type ? model.contentInfoOfType(type) : undefined;
|
||||||
|
if (contentInfo && this.simpleContentValueKind(contentInfo)) {
|
||||||
|
item.command = {
|
||||||
|
command: "editor.action.triggerSuggest",
|
||||||
|
title: t("Suggest content value"),
|
||||||
|
};
|
||||||
|
}
|
||||||
items.push(item);
|
items.push(item);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
@@ -120,13 +127,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
|||||||
if (model.isTopLevelElement(name)) {
|
if (model.isTopLevelElement(name)) {
|
||||||
return new vscode.SnippetString(`${open}${name} id="$1">\n\t$0\n</${name}>`);
|
return new vscode.SnippetString(`${open}${name} id="$1">\n\t$0\n</${name}>`);
|
||||||
}
|
}
|
||||||
const info = type ? model.typeInfo(type) : undefined;
|
// Simple types and simpleContent complex types hold text content (asset
|
||||||
// Simple types hold text content (asset id / enum / define / string), so
|
// id / enum / define / string), so they need an explicit closing tag and
|
||||||
// they need an explicit closing tag and a value placeholder instead of a
|
// a value placeholder instead of a self-closing tag that can never
|
||||||
// self-closing tag that can never contain a value.
|
// contain a value.
|
||||||
if (info?.kind === "simple") {
|
if (type && model.contentInfoOfType(type)) {
|
||||||
return new vscode.SnippetString(`${open}${name}>$1</${name}>`);
|
return new vscode.SnippetString(`${open}${name}>$1</${name}>`);
|
||||||
}
|
}
|
||||||
|
const info = type ? model.typeInfo(type) : undefined;
|
||||||
const hasChildren = info?.kind === "complex" && info.children.length > 0;
|
const hasChildren = info?.kind === "complex" && info.children.length > 0;
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
return new vscode.SnippetString(`${open}${name}>\n\t$0\n</${name}>`);
|
return new vscode.SnippetString(`${open}${name}>\n\t$0\n</${name}>`);
|
||||||
@@ -338,6 +346,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
|||||||
|
|
||||||
// inheritFrom: same element type first, then everything.
|
// inheritFrom: same element type first, then everything.
|
||||||
if (attrName === "inheritfrom") {
|
if (attrName === "inheritfrom") {
|
||||||
|
if (!model.isAssetType(elType)) return [];
|
||||||
if (!idx) return [];
|
if (!idx) return [];
|
||||||
return this.assetIdItems(idx, el.name, null, prefix, make);
|
return this.assetIdItems(idx, el.name, null, prefix, make);
|
||||||
}
|
}
|
||||||
@@ -475,6 +484,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
|||||||
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
|
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
|
||||||
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
|
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
|
||||||
const lower = prefix.toLowerCase();
|
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
|
// Deduplicate by id: the same asset can be defined in several places at
|
||||||
// once (current file's local overlay + global index, project XML +
|
// once (current file's local overlay + global index, project XML +
|
||||||
// compiled manifest, or an override). Showing one completion entry per
|
// compiled manifest, or an override). Showing one completion entry per
|
||||||
@@ -493,7 +510,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
|||||||
if (seen.has(defKey)) return;
|
if (seen.has(defKey)) return;
|
||||||
seen.add(defKey);
|
seen.add(defKey);
|
||||||
const idKey = def.id.toLowerCase();
|
const idKey = def.id.toLowerCase();
|
||||||
if (!idKey.startsWith(lower)) return;
|
if (!idKey.startsWith(idPrefix)) return;
|
||||||
let score = 3;
|
let score = 3;
|
||||||
if (refType && model.isAssignableTo(def.type, refType)) score = 1;
|
if (refType && model.isAssignableTo(def.type, refType)) score = 1;
|
||||||
if (selfType && model.isAssignableTo(def.type, selfType)) score = 0;
|
if (selfType && model.isAssignableTo(def.type, selfType)) score = 0;
|
||||||
@@ -560,7 +577,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return make(
|
return make(
|
||||||
def.id,
|
typePrefix ? `${typePrefix}${def.id}` : def.id,
|
||||||
vscode.CompletionItemKind.Value,
|
vscode.CompletionItemKind.Value,
|
||||||
t("{0} · {1}", def.type, origin),
|
t("{0} · {1}", def.type, origin),
|
||||||
doc.value,
|
doc.value,
|
||||||
@@ -653,12 +670,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
|||||||
const el = ctx.element;
|
const el = ctx.element;
|
||||||
if (!el) return [];
|
if (!el) return [];
|
||||||
const elType = resolveElementType(el);
|
const elType = resolveElementType(el);
|
||||||
const info = elType ? model.typeInfo(elType) : undefined;
|
const info = elType ? model.contentInfoOfType(elType) : undefined;
|
||||||
|
|
||||||
// Simple-content element: the text between the tags is the value itself
|
// Simple-content element (simple type or simpleContent complex type):
|
||||||
// (e.g. <CreateObject>CrateDebris_01</CreateObject>), so offer value
|
// the text between the tags is the value itself (e.g.
|
||||||
// completions (asset ids / enums / defines) instead of child elements.
|
// <CreateObject>CrateDebris_01</CreateObject> or
|
||||||
if (info?.kind === "simple") {
|
// <Sound>AudioFile</Sound>), so offer value completions (asset ids /
|
||||||
|
// enums / defines) instead of child elements.
|
||||||
|
if (info) {
|
||||||
return this.simpleContentItems(el, elType, info, document, position, idx);
|
return this.simpleContentItems(el, elType, info, document, position, idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,7 +687,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
|||||||
private simpleContentItems(
|
private simpleContentItems(
|
||||||
el: XmlElement,
|
el: XmlElement,
|
||||||
elType: string | null,
|
elType: string | null,
|
||||||
info: SimpleTypeInfo,
|
info: ContentTypeInfo,
|
||||||
document: vscode.TextDocument,
|
document: vscode.TextDocument,
|
||||||
position: vscode.Position,
|
position: vscode.Position,
|
||||||
idx: ModIndex | null,
|
idx: ModIndex | null,
|
||||||
@@ -756,7 +775,8 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
|||||||
item.detail = type ? t("RA3 XML · {0}", type) : t("RA3 XML");
|
item.detail = type ? t("RA3 XML · {0}", type) : t("RA3 XML");
|
||||||
const doc = child.doc || (info?.kind === "complex" ? info.doc : "");
|
const doc = child.doc || (info?.kind === "complex" ? info.doc : "");
|
||||||
if (doc) item.documentation = new vscode.MarkdownString(doc);
|
if (doc) item.documentation = new vscode.MarkdownString(doc);
|
||||||
if (info?.kind === "simple" && this.simpleContentValueKind(info)) {
|
const contentInfo = type ? model.contentInfoOfType(type) : undefined;
|
||||||
|
if (contentInfo && this.simpleContentValueKind(contentInfo)) {
|
||||||
item.command = {
|
item.command = {
|
||||||
command: "editor.action.triggerSuggest",
|
command: "editor.action.triggerSuggest",
|
||||||
title: t("Suggest content value"),
|
title: t("Suggest content value"),
|
||||||
@@ -767,7 +787,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
private simpleContentValueKind(info: SimpleTypeInfo): boolean {
|
private simpleContentValueKind(info: ContentTypeInfo): boolean {
|
||||||
return (
|
return (
|
||||||
info.refType != null ||
|
info.refType != null ||
|
||||||
info.enumValues.length > 0 ||
|
info.enumValues.length > 0 ||
|
||||||
|
|||||||
+73
-18
@@ -139,6 +139,17 @@ export class Ra3Diagnostics {
|
|||||||
): void {
|
): void {
|
||||||
const settings = this.ws.settings;
|
const settings = this.ws.settings;
|
||||||
const fileDuplicates = new Map<string, { line: number }>();
|
const fileDuplicates = new Map<string, { line: number }>();
|
||||||
|
// A file whose root is not AssetDeclaration is an xi:include fragment
|
||||||
|
// (e.g. Data/Includes/GenericCelestialBuildingSuicide.xml). It is not a
|
||||||
|
// standalone RA3 document: top-level id / duplicate checks do not apply,
|
||||||
|
// and references/defines can only be resolved in the includer's context.
|
||||||
|
// When the fragment root itself is a known XSD element (e.g.
|
||||||
|
// CreateObjectDie), the root supplies the type context for its whole
|
||||||
|
// subtree, so element/attribute validation is still reliable.
|
||||||
|
const rootName = root ? localName(root.name) : "";
|
||||||
|
const isFragment = rootName !== "AssetDeclaration";
|
||||||
|
const validateTree =
|
||||||
|
!isFragment || (root !== null && model.elementTypeName(rootName) !== null);
|
||||||
|
|
||||||
for (const el of doc.elements) {
|
for (const el of doc.elements) {
|
||||||
// Only report diagnostics for nodes that belong to the document being
|
// Only report diagnostics for nodes that belong to the document being
|
||||||
@@ -155,7 +166,7 @@ export class Ra3Diagnostics {
|
|||||||
const range = tagRange(document, el);
|
const range = tagRange(document, el);
|
||||||
|
|
||||||
// Top-level assets must have an id.
|
// Top-level assets must have an id.
|
||||||
if (isTopLevel) {
|
if (!isFragment && isTopLevel) {
|
||||||
const idAttr = el.attrs.find((a) => a.name === "id");
|
const idAttr = el.attrs.find((a) => a.name === "id");
|
||||||
if (!idAttr || !idAttr.value) {
|
if (!idAttr || !idAttr.value) {
|
||||||
diags.push(
|
diags.push(
|
||||||
@@ -206,7 +217,7 @@ export class Ra3Diagnostics {
|
|||||||
const isXsdElement = model.isXsdElementName(el.name);
|
const isXsdElement = model.isXsdElementName(el.name);
|
||||||
|
|
||||||
// Unknown element.
|
// Unknown element.
|
||||||
if (settings.diagnoseUnknownElements && isXsdElement) {
|
if (settings.diagnoseUnknownElements && isXsdElement && validateTree) {
|
||||||
const knownType = model.elementTypeName(local);
|
const knownType = model.elementTypeName(local);
|
||||||
if (!knownType) {
|
if (!knownType) {
|
||||||
diags.push(
|
diags.push(
|
||||||
@@ -221,7 +232,7 @@ export class Ra3Diagnostics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Attributes.
|
// Attributes.
|
||||||
if (isXsdElement) {
|
if (isXsdElement && validateTree) {
|
||||||
const elType = resolveElementType(el);
|
const elType = resolveElementType(el);
|
||||||
const knownAttrs = model.attributesOfType(elType);
|
const knownAttrs = model.attributesOfType(elType);
|
||||||
const knownNames = new Set(knownAttrs.map((a) => a.name));
|
const knownNames = new Set(knownAttrs.map((a) => a.name));
|
||||||
@@ -247,27 +258,70 @@ export class Ra3Diagnostics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!attr.hasValue) continue;
|
if (!attr.hasValue) continue;
|
||||||
this.checkValueReferences(
|
// References and $DEFINE constants inside fragments depend on the
|
||||||
elType,
|
// includer's context; don't report them until P1 resolves the real
|
||||||
attr.name,
|
// include sites.
|
||||||
attr.value,
|
if (!isFragment) {
|
||||||
attr,
|
this.checkValueReferences(
|
||||||
document,
|
elType,
|
||||||
idx,
|
attr.name,
|
||||||
diags,
|
attr.value,
|
||||||
provisional,
|
attr,
|
||||||
);
|
document,
|
||||||
|
idx,
|
||||||
|
diags,
|
||||||
|
provisional,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isFragment) {
|
||||||
|
this.checkContentReferences(el, elType, document, idx, diags, provisional);
|
||||||
}
|
}
|
||||||
this.checkContentReferences(el, elType, document, idx, diags, provisional);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include-specific checks.
|
// Include-specific checks.
|
||||||
if (local === "Include") {
|
if (local === "Include") {
|
||||||
this.checkInclude(el, document, idx, diags);
|
this.checkInclude(el, document, idx, diags);
|
||||||
|
} else if (local === "include" && el.name.toLowerCase().startsWith("xi:")) {
|
||||||
|
this.checkXiInclude(el, document, idx, diags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private checkXiInclude(
|
||||||
|
el: XmlElement,
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
idx: ModIndex | null,
|
||||||
|
diags: vscode.Diagnostic[],
|
||||||
|
): void {
|
||||||
|
const hrefAttr = el.attrs.find((a) => a.name === "href");
|
||||||
|
if (!hrefAttr?.hasValue) return;
|
||||||
|
const searchPaths = idx
|
||||||
|
? buildSearchPaths(idx.sdkDir, idx.projectDir)
|
||||||
|
: this.ws.searchPaths(document);
|
||||||
|
if (!searchPaths) return;
|
||||||
|
const resolved = resolveSource(
|
||||||
|
hrefAttr.value,
|
||||||
|
dirname(document.uri.fsPath),
|
||||||
|
searchPaths,
|
||||||
|
);
|
||||||
|
if (resolved.path) return;
|
||||||
|
if (/^(DATA|ART|AUDIO):/i.test(hrefAttr.value.trim())) {
|
||||||
|
if (this.sdkUnusable()) return;
|
||||||
|
}
|
||||||
|
diags.push(
|
||||||
|
this.diag(
|
||||||
|
new vscode.Range(
|
||||||
|
document.positionAt(hrefAttr.valueStart),
|
||||||
|
document.positionAt(hrefAttr.valueEnd),
|
||||||
|
),
|
||||||
|
t("xi:include target not found: {0}", hrefAttr.value),
|
||||||
|
vscode.DiagnosticSeverity.Warning,
|
||||||
|
"include-not-found",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private checkCrossFileDuplicate(
|
private checkCrossFileDuplicate(
|
||||||
type: string,
|
type: string,
|
||||||
id: string,
|
id: string,
|
||||||
@@ -397,10 +451,11 @@ export class Ra3Diagnostics {
|
|||||||
diags: vscode.Diagnostic[],
|
diags: vscode.Diagnostic[],
|
||||||
provisional: boolean,
|
provisional: boolean,
|
||||||
): void {
|
): void {
|
||||||
// Only simple-content elements carry a text value; complex elements'
|
// Only simple-content elements carry a text value (simple types and
|
||||||
// "content" is child markup and must not be scanned for value refs.
|
// simpleContent complex types); ordinary complex elements' "content" is
|
||||||
const info = elType ? model.typeInfo(elType) : undefined;
|
// child markup and must not be scanned for value refs.
|
||||||
if (info?.kind !== "simple") return;
|
const info = elType ? model.contentInfoOfType(elType) : undefined;
|
||||||
|
if (!info) return;
|
||||||
if (el.selfClosing || el.closeTagStart < 0) return;
|
if (el.selfClosing || el.closeTagStart < 0) return;
|
||||||
const text = document.getText();
|
const text = document.getText();
|
||||||
const raw = text.slice(el.startTagEnd, el.closeTagStart);
|
const raw = text.slice(el.startTagEnd, el.closeTagStart);
|
||||||
|
|||||||
+11
-6
@@ -59,12 +59,15 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
|
|||||||
// Element name.
|
// Element name.
|
||||||
const nameStart = el.start + 1;
|
const nameStart = el.start + 1;
|
||||||
if (offset >= nameStart && offset <= nameStart + el.name.length) {
|
if (offset >= nameStart && offset <= nameStart + el.name.length) {
|
||||||
return this.elementHover(el.name);
|
return this.elementHover(el.name, elType);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private elementHover(name: string): vscode.Hover | null {
|
private elementHover(
|
||||||
|
name: string,
|
||||||
|
resolvedType: string | null = null,
|
||||||
|
): vscode.Hover | null {
|
||||||
if (name.startsWith("xi:")) {
|
if (name.startsWith("xi:")) {
|
||||||
const md = new vscode.MarkdownString();
|
const md = new vscode.MarkdownString();
|
||||||
md.appendCodeblock(`<${name}>`, "xml");
|
md.appendCodeblock(`<${name}>`, "xml");
|
||||||
@@ -75,7 +78,9 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
|
|||||||
);
|
);
|
||||||
return new vscode.Hover(md);
|
return new vscode.Hover(md);
|
||||||
}
|
}
|
||||||
const type = model.elementTypeName(name);
|
const type =
|
||||||
|
resolvedType ??
|
||||||
|
(model.topLevelElementType(name) ?? model.elementTypeName(name));
|
||||||
const info = type ? model.typeInfo(type) : undefined;
|
const info = type ? model.typeInfo(type) : undefined;
|
||||||
const md = new vscode.MarkdownString();
|
const md = new vscode.MarkdownString();
|
||||||
md.appendCodeblock(`<${name}>`, "xml");
|
md.appendCodeblock(`<${name}>`, "xml");
|
||||||
@@ -87,7 +92,7 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
|
|||||||
md.appendMarkdown(
|
md.appendMarkdown(
|
||||||
`${t(
|
`${t(
|
||||||
"Attributes: {0} · Children: {1}",
|
"Attributes: {0} · Children: {1}",
|
||||||
info.attributes.length,
|
model.attributesOfType(type).length,
|
||||||
info.children.length,
|
info.children.length,
|
||||||
)} \n`,
|
)} \n`,
|
||||||
);
|
);
|
||||||
@@ -267,8 +272,8 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
|
|||||||
}
|
}
|
||||||
const targets = resolveContentReferenceTargets(idx, elType, value);
|
const targets = resolveContentReferenceTargets(idx, elType, value);
|
||||||
if (targets.length) return this.definitionsHover(targets, document);
|
if (targets.length) return this.definitionsHover(targets, document);
|
||||||
const info = elType ? model.typeInfo(elType) : undefined;
|
const info = elType ? model.contentInfoOfType(elType) : undefined;
|
||||||
const refType = info?.kind === "simple" ? info.refType : null;
|
const refType = info?.refType ?? null;
|
||||||
return this.noDefinitionHover(refType ? "typed" : "untyped", refType ?? undefined);
|
return this.noDefinitionHover(refType ? "typed" : "untyped", refType ?? undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ import {
|
|||||||
textContentTokenAt,
|
textContentTokenAt,
|
||||||
} from "../language/xmlParser";
|
} from "../language/xmlParser";
|
||||||
import { resolveElementType } from "../language/typeContext";
|
import { resolveElementType } from "../language/typeContext";
|
||||||
import { attributesOfType, typeInfo } from "../model/schemaModel";
|
import { attributesOfType, contentInfoOfType } from "../model/schemaModel";
|
||||||
import {
|
import {
|
||||||
filterAndScoreDefs,
|
filterAndScoreDefs,
|
||||||
isReferenceAttributeOfType,
|
isReferenceAttributeOfType,
|
||||||
isReferenceContentType,
|
isReferenceContentType,
|
||||||
mergeLocalAndGlobalDefs,
|
mergeLocalAndGlobalDefs,
|
||||||
|
normalizeReferenceId,
|
||||||
} from "../indexer/refs";
|
} from "../indexer/refs";
|
||||||
import {
|
import {
|
||||||
referenceSitesForDef,
|
referenceSitesForDef,
|
||||||
@@ -76,10 +77,10 @@ export function referenceContextAt(
|
|||||||
if (elType && isReferenceContentType(elType)) {
|
if (elType && isReferenceContentType(elType)) {
|
||||||
const token = textContentTokenAt(text, el, offset);
|
const token = textContentTokenAt(text, el, offset);
|
||||||
if (token && !token.value.startsWith("$") && !token.value.startsWith("=")) {
|
if (token && !token.value.startsWith("$") && !token.value.startsWith("=")) {
|
||||||
const info = typeInfo(elType);
|
const info = contentInfoOfType(elType);
|
||||||
return {
|
return {
|
||||||
id: token.value,
|
id: token.value,
|
||||||
refType: info?.kind === "simple" ? info.refType : null,
|
refType: info?.refType ?? null,
|
||||||
selfType: null,
|
selfType: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -92,9 +93,10 @@ export function definitionsForReference(
|
|||||||
idx: ModIndex,
|
idx: ModIndex,
|
||||||
ctx: ReferenceContext,
|
ctx: ReferenceContext,
|
||||||
): AssetDef[] {
|
): AssetDef[] {
|
||||||
|
const lookupId = normalizeReferenceId(ctx.id);
|
||||||
const defs = mergeLocalAndGlobalDefs(
|
const defs = mergeLocalAndGlobalDefs(
|
||||||
idx.local?.assetsById.get(ctx.id.toLowerCase()),
|
idx.local?.assetsById.get(lookupId.toLowerCase()),
|
||||||
idx.assetsById.get(ctx.id.toLowerCase()),
|
idx.assetsById.get(lookupId.toLowerCase()),
|
||||||
);
|
);
|
||||||
return filterAndScoreDefs(defs, ctx.refType, ctx.selfType).map((t) => t.def);
|
return filterAndScoreDefs(defs, ctx.refType, ctx.selfType).map((t) => t.def);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-2
@@ -1014,14 +1014,32 @@ export class ModIndexer {
|
|||||||
}
|
}
|
||||||
const arr = byId.get(idKey);
|
const arr = byId.get(idKey);
|
||||||
if (arr) {
|
if (arr) {
|
||||||
if (arr.some((a) => a.file === def.file && a.line === def.line)) return;
|
if (
|
||||||
|
arr.some(
|
||||||
|
(a) =>
|
||||||
|
a.type === def.type &&
|
||||||
|
a.file === def.file &&
|
||||||
|
a.line === def.line,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
arr.push(def);
|
arr.push(def);
|
||||||
} else {
|
} else {
|
||||||
byId.set(idKey, [def]);
|
byId.set(idKey, [def]);
|
||||||
}
|
}
|
||||||
const all = this.assetsById.get(idKey);
|
const all = this.assetsById.get(idKey);
|
||||||
if (all) {
|
if (all) {
|
||||||
if (all.some((a) => a.file === def.file && a.line === def.line)) return;
|
if (
|
||||||
|
all.some(
|
||||||
|
(a) =>
|
||||||
|
a.type === def.type &&
|
||||||
|
a.file === def.file &&
|
||||||
|
a.line === def.line,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
all.push(def);
|
all.push(def);
|
||||||
} else {
|
} else {
|
||||||
this.assetsById.set(idKey, [def]);
|
this.assetsById.set(idKey, [def]);
|
||||||
|
|||||||
@@ -244,14 +244,32 @@ class OverlayBuilder {
|
|||||||
}
|
}
|
||||||
const arr = byId.get(idKey);
|
const arr = byId.get(idKey);
|
||||||
if (arr) {
|
if (arr) {
|
||||||
if (arr.some((a) => a.file === def.file && a.line === def.line)) return;
|
if (
|
||||||
|
arr.some(
|
||||||
|
(a) =>
|
||||||
|
a.type === def.type &&
|
||||||
|
a.file === def.file &&
|
||||||
|
a.line === def.line,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
arr.push(def);
|
arr.push(def);
|
||||||
} else {
|
} else {
|
||||||
byId.set(idKey, [def]);
|
byId.set(idKey, [def]);
|
||||||
}
|
}
|
||||||
const all = this.overlay.assetsById.get(idKey);
|
const all = this.overlay.assetsById.get(idKey);
|
||||||
if (all) {
|
if (all) {
|
||||||
if (all.some((a) => a.file === def.file && a.line === def.line)) return;
|
if (
|
||||||
|
all.some(
|
||||||
|
(a) =>
|
||||||
|
a.type === def.type &&
|
||||||
|
a.file === def.file &&
|
||||||
|
a.line === def.line,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
all.push(def);
|
all.push(def);
|
||||||
} else {
|
} else {
|
||||||
this.overlay.assetsById.set(idKey, [def]);
|
this.overlay.assetsById.set(idKey, [def]);
|
||||||
|
|||||||
@@ -169,12 +169,26 @@ async function expandXi(
|
|||||||
if (!target?.parse?.root) return;
|
if (!target?.parse?.root) return;
|
||||||
|
|
||||||
const xpointer = xi.attrs.find((a) => a.name === "xpointer")?.value ?? "";
|
const xpointer = xi.attrs.find((a) => a.name === "xpointer")?.value ?? "";
|
||||||
const selected = xpointer
|
if (xpointer) {
|
||||||
? findXPointerContainer(target.parse, xpointer)?.children ?? []
|
const container = findXPointerContainer(target.parse, xpointer);
|
||||||
: target.parse.root.children;
|
if (!container) return;
|
||||||
|
for (const sel of container.children) {
|
||||||
for (const sel of selected) {
|
await handleChild(sel, logicalParent, resolved, depth + 1, ctx, elements, stack);
|
||||||
await handleChild(sel, logicalParent, resolved, depth + 1, ctx, elements, stack);
|
}
|
||||||
|
} else {
|
||||||
|
// XInclude semantics: without an xpointer the whole target document is
|
||||||
|
// included, i.e. its root element replaces the <xi:include> node.
|
||||||
|
// RA3 fragments such as GenericCelestialBuildingSuicide.xml rely on this
|
||||||
|
// to splice the module element itself (CreateObjectDie) into the parent.
|
||||||
|
await handleChild(
|
||||||
|
target.parse.root,
|
||||||
|
logicalParent,
|
||||||
|
resolved,
|
||||||
|
depth + 1,
|
||||||
|
ctx,
|
||||||
|
elements,
|
||||||
|
stack,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
stack.delete(key);
|
stack.delete(key);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
import type { LineMap, XmlDocument } from "../language/xmlParser";
|
import type { LineMap, XmlDocument } from "../language/xmlParser";
|
||||||
import type { ShallowDocument } from "./shallowScan";
|
import type { ShallowDocument } from "./shallowScan";
|
||||||
import { attributesOfType, typeInfo } from "../model/schemaModel";
|
import { attributesOfType, contentInfoOfType } from "../model/schemaModel";
|
||||||
import { resolveElementType } from "../language/typeContext";
|
import { resolveElementType } from "../language/typeContext";
|
||||||
import {
|
import {
|
||||||
isReferenceAttributeOfType,
|
isReferenceAttributeOfType,
|
||||||
@@ -246,10 +246,10 @@ function collectReferenceRecords(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const start = el.startTagEnd + raw.indexOf(value);
|
const start = el.startTagEnd + raw.indexOf(value);
|
||||||
const info = typeInfo(elType);
|
const info = contentInfoOfType(elType);
|
||||||
out.push({
|
out.push({
|
||||||
kind: "content",
|
kind: "content",
|
||||||
refType: info?.kind === "simple" ? info.refType : null,
|
refType: info?.refType ?? null,
|
||||||
selfType: null,
|
selfType: null,
|
||||||
value,
|
value,
|
||||||
line: lineOf(lineMap, start),
|
line: lineOf(lineMap, start),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { extractIndexRecords, type IndexRecords } from "./records";
|
|||||||
import {
|
import {
|
||||||
filterAndScoreDefs,
|
filterAndScoreDefs,
|
||||||
isReferenceTargetType,
|
isReferenceTargetType,
|
||||||
|
normalizeReferenceId,
|
||||||
type ReferenceLookup,
|
type ReferenceLookup,
|
||||||
} from "./refs";
|
} from "./refs";
|
||||||
import { buildVanillaSearchPaths, resolveSource } from "./includeResolver";
|
import { buildVanillaSearchPaths, resolveSource } from "./includeResolver";
|
||||||
@@ -50,7 +51,9 @@ export function buildReferenceIndex(
|
|||||||
const map = new Map<string, ReferenceSite[]>();
|
const map = new Map<string, ReferenceSite[]>();
|
||||||
for (const { file, records } of sources) {
|
for (const { file, records } of sources) {
|
||||||
for (const ref of records.references) {
|
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;
|
if (!defs?.length) continue;
|
||||||
const targets = filterAndScoreDefs(defs, ref.refType, ref.selfType);
|
const targets = filterAndScoreDefs(defs, ref.refType, ref.selfType);
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
|
|||||||
+56
-15
@@ -2,7 +2,9 @@ import {
|
|||||||
allTypeNames,
|
allTypeNames,
|
||||||
attributesOfType,
|
attributesOfType,
|
||||||
canonicalTypeName,
|
canonicalTypeName,
|
||||||
|
contentInfoOfType,
|
||||||
elementTypeName,
|
elementTypeName,
|
||||||
|
isAssetType,
|
||||||
isAssignableTo,
|
isAssignableTo,
|
||||||
typeChain,
|
typeChain,
|
||||||
typeInfo,
|
typeInfo,
|
||||||
@@ -14,6 +16,23 @@ export interface ReferenceTarget {
|
|||||||
score: number;
|
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 subset of `ModIndex` that reference resolution needs. Kept narrow so
|
||||||
* the reverse reference index can resolve records against the indexer's live
|
* the reverse reference index can resolve records against the indexer's live
|
||||||
@@ -74,7 +93,7 @@ export function isReferenceAttributeOfType(
|
|||||||
typeName: string | null,
|
typeName: string | null,
|
||||||
attrName: string,
|
attrName: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (attrName.toLowerCase() === "inheritfrom") return true;
|
if (attrName.toLowerCase() === "inheritfrom") return isAssetType(typeName);
|
||||||
const attr = attributesOfType(typeName).find((a) => a.name === attrName);
|
const attr = attributesOfType(typeName).find((a) => a.name === attrName);
|
||||||
if (attr == null || !(attr.refType != null || attr.isRef)) return false;
|
if (attr == null || !(attr.refType != null || attr.isRef)) return false;
|
||||||
// Definitions (id) and pipeline-local references (Poid) are not references
|
// Definitions (id) and pipeline-local references (Poid) are not references
|
||||||
@@ -114,9 +133,10 @@ export function resolveReferenceTargetsForType(
|
|||||||
attrName: string,
|
attrName: string,
|
||||||
id: string,
|
id: string,
|
||||||
): ReferenceTarget[] {
|
): ReferenceTarget[] {
|
||||||
|
const lookupId = normalizeReferenceId(id);
|
||||||
const defs = mergeLocalAndGlobalDefs(
|
const defs = mergeLocalAndGlobalDefs(
|
||||||
idx.local?.assetsById.get(id.toLowerCase()),
|
idx.local?.assetsById.get(lookupId.toLowerCase()),
|
||||||
idx.assetsById.get(id.toLowerCase()),
|
idx.assetsById.get(lookupId.toLowerCase()),
|
||||||
);
|
);
|
||||||
if (!defs.length) return [];
|
if (!defs.length) return [];
|
||||||
|
|
||||||
@@ -125,6 +145,7 @@ export function resolveReferenceTargetsForType(
|
|||||||
let selfType: string | null = null;
|
let selfType: string | null = null;
|
||||||
|
|
||||||
if (nameLower === "inheritfrom") {
|
if (nameLower === "inheritfrom") {
|
||||||
|
if (!isAssetType(typeName)) return [];
|
||||||
selfType = typeName;
|
selfType = typeName;
|
||||||
} else {
|
} else {
|
||||||
const attr = attributesOfType(typeName).find((a) => a.name === attrName);
|
const attr = attributesOfType(typeName).find((a) => a.name === attrName);
|
||||||
@@ -141,7 +162,9 @@ export function resolveReferenceTargetsForType(
|
|||||||
/**
|
/**
|
||||||
* True when an element's text content is a typed reference to a global
|
* True when an element's text content is a typed reference to a global
|
||||||
* asset: the element's resolved XSD type is a simple type carrying an
|
* asset: the element's resolved XSD type is a simple type carrying an
|
||||||
* `xas:refType` (e.g. `<CreateObject>` with `GameObjectWeakRef`).
|
* `xas:refType`, or a simpleContent complex type carrying `xas:refType`
|
||||||
|
* (e.g. `<CreateObject>` with `GameObjectWeakRef`, `<Sound>` with
|
||||||
|
* `AudioFileRefWithWeight`).
|
||||||
*
|
*
|
||||||
* Only *typed* refs are treated as content references. Generic untyped
|
* Only *typed* refs are treated as content references. Generic untyped
|
||||||
* `AssetReference` content is used by real data for shader constants,
|
* `AssetReference` content is used by real data for shader constants,
|
||||||
@@ -152,8 +175,8 @@ export function resolveReferenceTargetsForType(
|
|||||||
*/
|
*/
|
||||||
export function isReferenceContentType(typeName: string | null): boolean {
|
export function isReferenceContentType(typeName: string | null): boolean {
|
||||||
if (!typeName) return false;
|
if (!typeName) return false;
|
||||||
const info = typeInfo(typeName);
|
const info = contentInfoOfType(typeName);
|
||||||
if (info?.kind !== "simple") return false;
|
if (!info) return false;
|
||||||
if (typeChain(typeName).includes("Poid")) return false;
|
if (typeChain(typeName).includes("Poid")) return false;
|
||||||
return info.refType != null;
|
return info.refType != null;
|
||||||
}
|
}
|
||||||
@@ -170,13 +193,14 @@ export function resolveContentReferenceTargets(
|
|||||||
): ReferenceTarget[] {
|
): ReferenceTarget[] {
|
||||||
if (!isReferenceContentType(typeName)) return [];
|
if (!isReferenceContentType(typeName)) return [];
|
||||||
if (!typeName) return [];
|
if (!typeName) return [];
|
||||||
|
const lookupId = normalizeReferenceId(id);
|
||||||
const defs = mergeLocalAndGlobalDefs(
|
const defs = mergeLocalAndGlobalDefs(
|
||||||
idx.local?.assetsById.get(id.toLowerCase()),
|
idx.local?.assetsById.get(lookupId.toLowerCase()),
|
||||||
idx.assetsById.get(id.toLowerCase()),
|
idx.assetsById.get(lookupId.toLowerCase()),
|
||||||
);
|
);
|
||||||
if (!defs.length) return [];
|
if (!defs.length) return [];
|
||||||
const info = typeInfo(typeName);
|
const info = contentInfoOfType(typeName);
|
||||||
const refType = info?.kind === "simple" ? info.refType : null;
|
const refType = info?.refType ?? null;
|
||||||
return filterAndScoreDefs(defs, refType, null);
|
return filterAndScoreDefs(defs, refType, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,13 +247,29 @@ export function mergeLocalAndGlobalDefs(
|
|||||||
|
|
||||||
let referenceTargetTypeSet: Set<string> | null = null;
|
let referenceTargetTypeSet: Set<string> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the XSD itself declares `inheritFrom` for the type. This is the
|
||||||
|
* narrower "designed reference target" signal used by CodeLens / unreferenced
|
||||||
|
* reports; the universal BAB `inheritFrom` attribute must not widen it to
|
||||||
|
* every BaseAssetType descendant.
|
||||||
|
*/
|
||||||
|
function xsdDeclaresInheritFrom(typeName: string): boolean {
|
||||||
|
const info = typeInfo(typeName);
|
||||||
|
return (
|
||||||
|
info?.kind === "complex" &&
|
||||||
|
info.attributes.some((a) => a.name.toLowerCase() === "inheritfrom")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The set of XSD types that are "reference targets by design": at least one
|
* The set of XSD types that are "reference targets by design": at least one
|
||||||
* typed reference attribute / simple-content reference points at them, or
|
* typed reference attribute / simple-content reference points at them, or
|
||||||
* they are inheritable (`inheritFrom`). Types outside this set are
|
* the XSD explicitly declares them inheritable (`inheritFrom`). The universal
|
||||||
* auto-registered / structural (settings, map metadata, w3x sub-assets...),
|
* BAB `inheritFrom` attribute on every BaseAssetType descendant is a separate
|
||||||
* so a zero reference count is their normal state and counts would only be
|
* legality concern and intentionally does NOT widen this set. Types outside
|
||||||
* noise.
|
* this set are auto-registered / structural (settings, map metadata, w3x
|
||||||
|
* sub-assets...), so a zero reference count is their normal state and counts
|
||||||
|
* would only be noise.
|
||||||
*/
|
*/
|
||||||
export function referenceTargetTypes(): ReadonlySet<string> {
|
export function referenceTargetTypes(): ReadonlySet<string> {
|
||||||
if (referenceTargetTypeSet) return referenceTargetTypeSet;
|
if (referenceTargetTypeSet) return referenceTargetTypeSet;
|
||||||
@@ -246,7 +286,8 @@ export function referenceTargetTypes(): ReadonlySet<string> {
|
|||||||
if (isLocalReferenceAttribute(typeName, attr.name)) continue;
|
if (isLocalReferenceAttribute(typeName, attr.name)) continue;
|
||||||
if (attr.refType) add(attr.refType);
|
if (attr.refType) add(attr.refType);
|
||||||
}
|
}
|
||||||
if (info.attributes.some((a) => a.name.toLowerCase() === "inheritfrom")) {
|
if (info.content?.refType) add(info.content.refType);
|
||||||
|
if (xsdDeclaresInheritFrom(typeName)) {
|
||||||
add(typeName);
|
add(typeName);
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { childTypeOf, elementTypeName } from "../model/schemaModel";
|
import { childTypeOf, elementTypeName, topLevelElementType } from "../model/schemaModel";
|
||||||
import type { XmlElement } from "./xmlParser";
|
import type { XmlElement } from "./xmlParser";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,7 +9,12 @@ import type { XmlElement } from "./xmlParser";
|
|||||||
*/
|
*/
|
||||||
export function resolveElementType(el: XmlElement): string | null {
|
export function resolveElementType(el: XmlElement): string | null {
|
||||||
if (!el.parent) {
|
if (!el.parent) {
|
||||||
return elementTypeName(el.name);
|
// A document root (fragment or full AssetDeclaration) has no parent to
|
||||||
|
// provide context. When the root is a top-level asset whose name also
|
||||||
|
// appears as a nested child type (EvaEvent, UpgradeTemplate, ...),
|
||||||
|
// prefer the AssetDeclaration declaration over the global single-map
|
||||||
|
// fallback.
|
||||||
|
return topLevelElementType(el.name) ?? elementTypeName(el.name);
|
||||||
}
|
}
|
||||||
const parentType = resolveElementType(el.parent);
|
const parentType = resolveElementType(el.parent);
|
||||||
return childTypeOf(parentType, el.name) ?? elementTypeName(el.name);
|
return childTypeOf(parentType, el.name) ?? elementTypeName(el.name);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+125
-1
@@ -33,6 +33,21 @@ export interface ComplexTypeInfo {
|
|||||||
attributes: AttributeInfo[];
|
attributes: AttributeInfo[];
|
||||||
base: string | null;
|
base: string | null;
|
||||||
doc: string;
|
doc: string;
|
||||||
|
/**
|
||||||
|
* Present only for complexType + simpleContent types (e.g.
|
||||||
|
* AudioFileRefWithWeight / MultisoundSubsoundRef). Describes the text
|
||||||
|
* between the tags just like a simple type's value semantics.
|
||||||
|
*/
|
||||||
|
content?: SimpleContentInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimpleContentInfo {
|
||||||
|
refType: string | null;
|
||||||
|
isRef: boolean;
|
||||||
|
enumValues: string[];
|
||||||
|
isList: boolean;
|
||||||
|
allowsDefine: boolean;
|
||||||
|
base: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimpleTypeInfo {
|
export interface SimpleTypeInfo {
|
||||||
@@ -48,6 +63,24 @@ export interface SimpleTypeInfo {
|
|||||||
|
|
||||||
export type TypeInfo = ComplexTypeInfo | SimpleTypeInfo;
|
export type TypeInfo = ComplexTypeInfo | SimpleTypeInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified value semantics for element text content. Both simple types
|
||||||
|
* (`<CreateObject>` -> GameObjectWeakRef) and simpleContent complex types
|
||||||
|
* (`<Sound>` -> AudioFileRefWithWeight) share this shape so the completion /
|
||||||
|
* hover / navigation / diagnostics / indexer pipelines do not have to know
|
||||||
|
* which XSD construct produced the content.
|
||||||
|
*/
|
||||||
|
export interface ContentTypeInfo {
|
||||||
|
kind: "simple" | "simpleContent";
|
||||||
|
refType: string | null;
|
||||||
|
isRef: boolean;
|
||||||
|
enumValues: string[];
|
||||||
|
isList: boolean;
|
||||||
|
allowsDefine: boolean;
|
||||||
|
base: string | null;
|
||||||
|
doc: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface RawModel {
|
interface RawModel {
|
||||||
version: number;
|
version: number;
|
||||||
rootXsd: string;
|
rootXsd: string;
|
||||||
@@ -59,6 +92,33 @@ interface RawModel {
|
|||||||
|
|
||||||
const model = schemaModel as unknown as RawModel;
|
const model = schemaModel as unknown as RawModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `inheritFrom` is accepted by BAB / real RA3 data on BaseAssetType-derived
|
||||||
|
* assets even though the XSD only declares it on BaseInheritableAsset
|
||||||
|
* (vanilla SageXml uses it on FXList, AIMicroManagerData,
|
||||||
|
* AITargetingHeuristic, ObjectCreationList, ...). It is therefore exposed as
|
||||||
|
* a universal attribute for every asset type.
|
||||||
|
*
|
||||||
|
* This is deliberately separate from `referenceTargetTypes()` in refs.ts:
|
||||||
|
* "may legally appear in the document" and "is a designed CodeLens / FAR
|
||||||
|
* reference target" are different decisions.
|
||||||
|
*/
|
||||||
|
const UNIVERSAL_INHERIT_FROM: AttributeInfo = {
|
||||||
|
name: "inheritFrom",
|
||||||
|
required: false,
|
||||||
|
default: null,
|
||||||
|
doc: "Inherits another asset of the same type.",
|
||||||
|
kind: "simple",
|
||||||
|
type: "@attr:inheritFrom",
|
||||||
|
refType: null,
|
||||||
|
enumValues: [],
|
||||||
|
isList: false,
|
||||||
|
allowsDefine: false,
|
||||||
|
isRef: false,
|
||||||
|
isBoolean: false,
|
||||||
|
base: "string",
|
||||||
|
};
|
||||||
|
|
||||||
/** Lowercase type name -> canonical (XSD) type name. */
|
/** Lowercase type name -> canonical (XSD) type name. */
|
||||||
const typeNameIndex = new Map<string, string>();
|
const typeNameIndex = new Map<string, string>();
|
||||||
for (const name of Object.keys(model.types)) {
|
for (const name of Object.keys(model.types)) {
|
||||||
@@ -109,6 +169,43 @@ export function typeInfo(name: string): TypeInfo | undefined {
|
|||||||
return model.types[name];
|
return model.types[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns content-value semantics for a type, or null when the element is a
|
||||||
|
* normal complex element (children, not text).
|
||||||
|
*/
|
||||||
|
export function contentInfoOfType(
|
||||||
|
typeName: string | null,
|
||||||
|
): ContentTypeInfo | null {
|
||||||
|
if (!typeName) return null;
|
||||||
|
const info = model.types[canonicalTypeName(typeName) ?? typeName];
|
||||||
|
if (!info) return null;
|
||||||
|
if (info.kind === "simple") {
|
||||||
|
return {
|
||||||
|
kind: "simple",
|
||||||
|
refType: info.refType,
|
||||||
|
isRef: info.isRef,
|
||||||
|
enumValues: info.enumValues,
|
||||||
|
isList: info.isList,
|
||||||
|
allowsDefine: info.allowsDefine,
|
||||||
|
base: info.base,
|
||||||
|
doc: info.doc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (info.kind === "complex" && info.content) {
|
||||||
|
return {
|
||||||
|
kind: "simpleContent",
|
||||||
|
refType: info.content.refType,
|
||||||
|
isRef: info.content.isRef,
|
||||||
|
enumValues: info.content.enumValues,
|
||||||
|
isList: info.content.isList,
|
||||||
|
allowsDefine: info.content.allowsDefine,
|
||||||
|
base: info.content.base,
|
||||||
|
doc: info.doc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function elementTypeName(name: string): string | null {
|
export function elementTypeName(name: string): string | null {
|
||||||
const t = elementToType.get(name);
|
const t = elementToType.get(name);
|
||||||
return t ? t : null;
|
return t ? t : null;
|
||||||
@@ -135,7 +232,23 @@ export function attributesOfElement(name: string): AttributeInfo[] {
|
|||||||
export function attributesOfType(typeName: string | null): AttributeInfo[] {
|
export function attributesOfType(typeName: string | null): AttributeInfo[] {
|
||||||
if (!typeName) return [];
|
if (!typeName) return [];
|
||||||
const info = model.types[canonicalTypeName(typeName) ?? typeName];
|
const info = model.types[canonicalTypeName(typeName) ?? typeName];
|
||||||
return info && info.kind === "complex" ? info.attributes : [];
|
if (!info || info.kind !== "complex") return [];
|
||||||
|
if (
|
||||||
|
isAssetType(typeName) &&
|
||||||
|
!info.attributes.some((a) => a.name === "inheritFrom")
|
||||||
|
) {
|
||||||
|
return [...info.attributes, UNIVERSAL_INHERIT_FROM];
|
||||||
|
}
|
||||||
|
return info.attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for types in the asset hierarchy (BaseAssetType and its descendants).
|
||||||
|
* These are the types on which BAB accepts the universal `inheritFrom`
|
||||||
|
* attribute even when the XSD does not declare it.
|
||||||
|
*/
|
||||||
|
export function isAssetType(typeName: string | null): boolean {
|
||||||
|
return !!typeName && typeChain(typeName).includes("BaseAssetType");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,6 +283,17 @@ export function elementTypeIn(
|
|||||||
return elementTypeName(childName);
|
return elementTypeName(childName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a top-level asset element name to the type declared inside
|
||||||
|
* AssetDeclaration. This is the type a fragment/standalone document root
|
||||||
|
* should use when its name collides with a nested child type (e.g. EvaEvent
|
||||||
|
* is both a top-level asset and an FXNugget child).
|
||||||
|
*/
|
||||||
|
export function topLevelElementType(name: string): string | null {
|
||||||
|
const declType = elementTypeName("AssetDeclaration");
|
||||||
|
return declType ? childTypeOf(declType, name) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function typeDoc(name: string): string {
|
export function typeDoc(name: string): string {
|
||||||
const info = model.types[name];
|
const info = model.types[name];
|
||||||
return info?.doc ?? "";
|
return info?.doc ?? "";
|
||||||
|
|||||||
@@ -308,6 +308,21 @@ test("element and attribute name completions work without an index", async () =>
|
|||||||
assert.ok(labels.includes("Surfaces"));
|
assert.ok(labels.includes("Surfaces"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("universal inheritFrom is offered on asset attribute completion", async () => {
|
||||||
|
const text = `<AssetDeclaration>\n <FXList `;
|
||||||
|
const line1 = text.split("\n")[1];
|
||||||
|
const pos = new Position(1, line1.length);
|
||||||
|
|
||||||
|
const items = await providerNoIndex.provideCompletionItems(
|
||||||
|
makeDocument(text),
|
||||||
|
pos,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
const labels = listItems(items).map((i) => i.label);
|
||||||
|
assert.ok(labels.includes("inheritFrom"), "FXList offers universal inheritFrom");
|
||||||
|
assert.ok(labels.includes("id"));
|
||||||
|
});
|
||||||
|
|
||||||
test("attribute completion after a closed quote inserts a space", async () => {
|
test("attribute completion after a closed quote inserts a space", async () => {
|
||||||
const text =
|
const text =
|
||||||
`<AssetDeclaration>\n` +
|
`<AssetDeclaration>\n` +
|
||||||
@@ -762,6 +777,138 @@ test("simple-content value completion works before the closing tag is typed", as
|
|||||||
assert.equal(item.range.end.character, pos.character);
|
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` +
|
||||||
|
` <AudioEvent id="A">\n` +
|
||||||
|
` <S`;
|
||||||
|
const line = text.split("\n")[2];
|
||||||
|
const pos = new Position(2, line.length);
|
||||||
|
|
||||||
|
const items = await providerNoIndex.provideCompletionItems(
|
||||||
|
makeDocument(text),
|
||||||
|
pos,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
const sound = listItems(items).find((i) => i.label === "Sound");
|
||||||
|
assert.ok(sound, "Sound child is offered under AudioEvent");
|
||||||
|
assert.equal(sound.insertText.value, "Sound>$1</Sound>");
|
||||||
|
assert.ok(sound.command, "simpleContent child re-triggers value suggest");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simpleContent complex text offers typed asset ids as the value", async () => {
|
||||||
|
const text =
|
||||||
|
`<AssetDeclaration>\n` +
|
||||||
|
` <AudioEvent id="A">\n` +
|
||||||
|
` <Sound>V</Sound>\n` +
|
||||||
|
` </AudioEvent>\n` +
|
||||||
|
`</AssetDeclaration>`;
|
||||||
|
const line = text.split("\n")[2];
|
||||||
|
const pos = new Position(2, line.indexOf(">V") + 2);
|
||||||
|
const audioFile = {
|
||||||
|
type: "AudioFile",
|
||||||
|
id: "VoiceFile",
|
||||||
|
file: "AudioFiles.xml",
|
||||||
|
line: 1,
|
||||||
|
origin: "project",
|
||||||
|
};
|
||||||
|
const audioEvent = {
|
||||||
|
type: "AudioEvent",
|
||||||
|
id: "VoiceEvent",
|
||||||
|
file: "Voice.xml",
|
||||||
|
line: 1,
|
||||||
|
origin: "project",
|
||||||
|
};
|
||||||
|
const idx = {
|
||||||
|
assets: new Map([
|
||||||
|
["AudioFile", new Map([["voicefile", [audioFile]]])],
|
||||||
|
["AudioEvent", new Map([["voiceevent", [audioEvent]]])],
|
||||||
|
]),
|
||||||
|
assetsById: new Map([
|
||||||
|
["voicefile", [audioFile]],
|
||||||
|
["voiceevent", [audioEvent]],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = await makeProvider(idx).provideCompletionItems(
|
||||||
|
makeDocument(text),
|
||||||
|
pos,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
const labels = items.map((i) => i.label);
|
||||||
|
assert.ok(labels.includes("VoiceFile"));
|
||||||
|
assert.ok(
|
||||||
|
!labels.includes("VoiceEvent"),
|
||||||
|
"Sound content is filtered by AudioFileRefWithWeight refType AudioFile",
|
||||||
|
);
|
||||||
|
const item = items.find((i) => i.label === "VoiceFile");
|
||||||
|
assert.equal(item.range.start.character, line.indexOf(">V") + 1);
|
||||||
|
assert.equal(item.range.end.character, pos.character);
|
||||||
|
});
|
||||||
|
|
||||||
test("content start after accepting a simple-content snippet offers values, not attributes", async () => {
|
test("content start after accepting a simple-content snippet offers values, not attributes", async () => {
|
||||||
const text =
|
const text =
|
||||||
`<AssetDeclaration>\n` +
|
`<AssetDeclaration>\n` +
|
||||||
|
|||||||
@@ -239,6 +239,145 @@ 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` +
|
||||||
|
` <AudioEvent id="A">\n` +
|
||||||
|
` <Sound>VoiceFile</Sound>\n` +
|
||||||
|
` </AudioEvent>\n` +
|
||||||
|
`</AssetDeclaration>`;
|
||||||
|
const def = {
|
||||||
|
type: "AudioFile",
|
||||||
|
id: "VoiceFile",
|
||||||
|
file: URI,
|
||||||
|
line: 2,
|
||||||
|
origin: "project",
|
||||||
|
};
|
||||||
|
const scope = await makeScope(text, makeIdx([def]));
|
||||||
|
const provider = new Ra3HoverProvider({
|
||||||
|
isRa3Workspace: () => true,
|
||||||
|
getScope: async () => scope,
|
||||||
|
searchPaths: () => null,
|
||||||
|
});
|
||||||
|
const line = text.split("\n")[2];
|
||||||
|
const pos = new Position(2, line.indexOf("VoiceFile") + 3);
|
||||||
|
const hover = await provider.provideHover(makeDocument(text), pos, {});
|
||||||
|
assert.ok(hover, "hover is returned for AudioFileRefWithWeight content");
|
||||||
|
assert.match(hover.contents.value, /1 definition/);
|
||||||
|
assert.match(hover.contents.value, /AudioFile/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Ctrl+click on simpleContent complex content jumps to the definition", async () => {
|
||||||
|
const text =
|
||||||
|
`<AssetDeclaration>\n` +
|
||||||
|
` <AudioEvent id="A">\n` +
|
||||||
|
` <Sound>VoiceFile</Sound>\n` +
|
||||||
|
` </AudioEvent>\n` +
|
||||||
|
`</AssetDeclaration>`;
|
||||||
|
const def = {
|
||||||
|
type: "AudioFile",
|
||||||
|
id: "VoiceFile",
|
||||||
|
file: URI,
|
||||||
|
line: 2,
|
||||||
|
origin: "project",
|
||||||
|
};
|
||||||
|
const scope = await makeScope(text, makeIdx([def]));
|
||||||
|
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("VoiceFile") + 3);
|
||||||
|
const locations = await provider.provideDefinition(makeDocument(text), pos, {});
|
||||||
|
assert.ok(locations && locations.length === 1);
|
||||||
|
assert.equal(locations[0].uri.fsPath, URI);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diagnostics report unresolved simpleContent complex content references", async () => {
|
||||||
|
const text =
|
||||||
|
`<AssetDeclaration>\n` +
|
||||||
|
` <Multisound id="M">\n` +
|
||||||
|
` <Subsound>MissingEvent</Subsound>\n` +
|
||||||
|
` </Multisound>\n` +
|
||||||
|
`</AssetDeclaration>`;
|
||||||
|
const scope = await makeScope(text, makeIdx([]));
|
||||||
|
const collection = new FakeDiagnosticCollection();
|
||||||
|
const provider = new Ra3Diagnostics({
|
||||||
|
isRa3Workspace: () => true,
|
||||||
|
getScope: async () => scope,
|
||||||
|
settings: {
|
||||||
|
diagnoseUnknownElements: false,
|
||||||
|
reportUnresolvedReferences: "warning",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
provider["collection"] = collection;
|
||||||
|
await provider.update(makeDocument(text));
|
||||||
|
const messages = collection.last.diags.map((d) => d.message);
|
||||||
|
assert.ok(
|
||||||
|
messages.some((m) => m.includes('Unresolved reference "MissingEvent"')),
|
||||||
|
"MultisoundSubsoundRef text is diagnosed as a typed content reference",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("Ctrl+click on a manifest definition maps to SageXml even when the mod shadows the DATA path", async () => {
|
test("Ctrl+click on a manifest definition maps to SageXml even when the mod shadows the DATA path", async () => {
|
||||||
const tmp = mkdtempSync(join(tmpdir(), "ra3-nav-manifest-"));
|
const tmp = mkdtempSync(join(tmpdir(), "ra3-nav-manifest-"));
|
||||||
try {
|
try {
|
||||||
@@ -452,3 +591,186 @@ test("diagnostics report unresolved typed content references only", async () =>
|
|||||||
"untyped WeakReference content is not diagnosed as a global ref",
|
"untyped WeakReference content is not diagnosed as a global ref",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fragment diagnostics skip document-level checks but keep subtree validation", async () => {
|
||||||
|
const text =
|
||||||
|
`<CreateObjectDie xmlns="uri:ea.com:eala:asset" id="ModuleTag_X" CreationList="OCL_X">\n` +
|
||||||
|
` <DieMuxData DeathTypo="SUICIDED"/>\n` +
|
||||||
|
`</CreateObjectDie>`;
|
||||||
|
const scope = await makeScope(text, makeIdx([]));
|
||||||
|
const collection = new FakeDiagnosticCollection();
|
||||||
|
const provider = new Ra3Diagnostics({
|
||||||
|
isRa3Workspace: () => true,
|
||||||
|
getScope: async () => scope,
|
||||||
|
settings: {
|
||||||
|
diagnoseUnknownElements: true,
|
||||||
|
reportUnresolvedReferences: "warning",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
provider["collection"] = collection;
|
||||||
|
await provider.update(makeDocument(text));
|
||||||
|
const codes = collection.last.diags.map((d) => d.code);
|
||||||
|
assert.ok(
|
||||||
|
!codes.includes("missing-id"),
|
||||||
|
"fragment children are not treated as top-level assets",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!codes.some((c) => c.startsWith("unresolved-reference")),
|
||||||
|
"fragment references are deferred to the includer context",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
codes.includes("unknown-attribute"),
|
||||||
|
"a known fragment root still validates its subtree attributes",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fragment diagnostics ignore unknown wrapper roots and still report missing xi:include", async () => {
|
||||||
|
const text =
|
||||||
|
`<CommonArmorDraws xmlns="uri:ea.com:eala:asset" xmlns:xi="http://www.w3.org/2001/XInclude">\n` +
|
||||||
|
` <ScriptedModelDraw id="M" Bogus="x"/>\n` +
|
||||||
|
` <xi:include href="MissingTarget.xml"/>\n` +
|
||||||
|
`</CommonArmorDraws>`;
|
||||||
|
const scope = await makeScope(text, makeIdx([]));
|
||||||
|
const collection = new FakeDiagnosticCollection();
|
||||||
|
const provider = new Ra3Diagnostics({
|
||||||
|
isRa3Workspace: () => true,
|
||||||
|
getScope: async () => scope,
|
||||||
|
settings: {
|
||||||
|
diagnoseUnknownElements: true,
|
||||||
|
reportUnresolvedReferences: "warning",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
provider["collection"] = collection;
|
||||||
|
await provider.update(makeDocument(text));
|
||||||
|
const codes = collection.last.diags.map((d) => d.code);
|
||||||
|
assert.ok(
|
||||||
|
!codes.includes("unknown-element"),
|
||||||
|
"wrapper roots are not validated as standalone documents",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!codes.includes("unknown-attribute"),
|
||||||
|
"unknown wrapper roots do not trigger subtree attribute guessing",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
codes.includes("include-not-found"),
|
||||||
|
"missing xi:include targets inside fragments are still reported",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diagnostics accept universal inheritFrom on asset types", async () => {
|
||||||
|
const text =
|
||||||
|
`<AssetDeclaration>\n` +
|
||||||
|
` <FXList id="FX_A" inheritFrom="FX_Base">\n` +
|
||||||
|
` <NuggetList/>\n` +
|
||||||
|
` </FXList>\n` +
|
||||||
|
`</AssetDeclaration>`;
|
||||||
|
const scope = await makeScope(text, makeIdx([]));
|
||||||
|
const collection = new FakeDiagnosticCollection();
|
||||||
|
const provider = new Ra3Diagnostics({
|
||||||
|
isRa3Workspace: () => true,
|
||||||
|
getScope: async () => scope,
|
||||||
|
settings: {
|
||||||
|
diagnoseUnknownElements: true,
|
||||||
|
reportUnresolvedReferences: "none",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
provider["collection"] = collection;
|
||||||
|
await provider.update(makeDocument(text));
|
||||||
|
const codes = collection.last.diags.map((d) => d.code);
|
||||||
|
assert.ok(
|
||||||
|
!codes.includes("unknown-attribute"),
|
||||||
|
"FXList inheritFrom must not be flagged as unknown",
|
||||||
|
);
|
||||||
|
|
||||||
|
const badText =
|
||||||
|
`<AssetDeclaration>\n` +
|
||||||
|
` <FXList id="FX_B" Bogus="x">\n` +
|
||||||
|
` <NuggetList/>\n` +
|
||||||
|
` </FXList>\n` +
|
||||||
|
`</AssetDeclaration>`;
|
||||||
|
const badScope = await makeScope(badText, makeIdx([]));
|
||||||
|
const badCollection = new FakeDiagnosticCollection();
|
||||||
|
const badProvider = new Ra3Diagnostics({
|
||||||
|
isRa3Workspace: () => true,
|
||||||
|
getScope: async () => badScope,
|
||||||
|
settings: {
|
||||||
|
diagnoseUnknownElements: true,
|
||||||
|
reportUnresolvedReferences: "none",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
badProvider["collection"] = badCollection;
|
||||||
|
await badProvider.update(makeDocument(badText));
|
||||||
|
assert.ok(
|
||||||
|
badCollection.last.diags.map((d) => d.code).includes("unknown-attribute"),
|
||||||
|
"a real unknown attribute is still reported",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diagnostics keep simpleContent extension attributes known", async () => {
|
||||||
|
const text =
|
||||||
|
`<AssetDeclaration>\n` +
|
||||||
|
` <AudioEvent id="A">\n` +
|
||||||
|
` <Sound Weight="100">AudioFile</Sound>\n` +
|
||||||
|
` </AudioEvent>\n` +
|
||||||
|
`</AssetDeclaration>`;
|
||||||
|
const scope = await makeScope(text, makeIdx([]));
|
||||||
|
const collection = new FakeDiagnosticCollection();
|
||||||
|
const provider = new Ra3Diagnostics({
|
||||||
|
isRa3Workspace: () => true,
|
||||||
|
getScope: async () => scope,
|
||||||
|
settings: {
|
||||||
|
diagnoseUnknownElements: true,
|
||||||
|
reportUnresolvedReferences: "none",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
provider["collection"] = collection;
|
||||||
|
await provider.update(makeDocument(text));
|
||||||
|
const codes = collection.last.diags.map((d) => d.code);
|
||||||
|
assert.ok(
|
||||||
|
!codes.includes("unknown-attribute"),
|
||||||
|
"Weight on AudioFileRefWithWeight must be known",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diagnostics use the top-level asset type for colliding fragment roots", async () => {
|
||||||
|
const text =
|
||||||
|
`<EvaEvent id="IncomingTransmission" Priority="100" TimeBetweenEvents="0ms" ExpirationTime="10000ms"/>`;
|
||||||
|
const scope = await makeScope(text, makeIdx([]));
|
||||||
|
const collection = new FakeDiagnosticCollection();
|
||||||
|
const provider = new Ra3Diagnostics({
|
||||||
|
isRa3Workspace: () => true,
|
||||||
|
getScope: async () => scope,
|
||||||
|
settings: {
|
||||||
|
diagnoseUnknownElements: true,
|
||||||
|
reportUnresolvedReferences: "none",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
provider["collection"] = collection;
|
||||||
|
await provider.update(makeDocument(text));
|
||||||
|
const codes = collection.last.diags.map((d) => d.code);
|
||||||
|
assert.ok(
|
||||||
|
!codes.includes("unknown-attribute"),
|
||||||
|
"EvaEvent fragment root attributes must resolve against the top-level asset type",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("full documents still require ids on top-level assets", async () => {
|
||||||
|
const text = `<AssetDeclaration>\n <GameObject/>\n</AssetDeclaration>`;
|
||||||
|
const scope = await makeScope(text, makeIdx([]));
|
||||||
|
const collection = new FakeDiagnosticCollection();
|
||||||
|
const provider = new Ra3Diagnostics({
|
||||||
|
isRa3Workspace: () => true,
|
||||||
|
getScope: async () => scope,
|
||||||
|
settings: {
|
||||||
|
diagnoseUnknownElements: true,
|
||||||
|
reportUnresolvedReferences: "warning",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
provider["collection"] = collection;
|
||||||
|
await provider.update(makeDocument(text));
|
||||||
|
const codes = collection.last.diags.map((d) => d.code);
|
||||||
|
assert.ok(
|
||||||
|
codes.includes("missing-id"),
|
||||||
|
"AssetDeclaration documents keep top-level id checks",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
IndexRecordsCache,
|
IndexRecordsCache,
|
||||||
} from "../out/indexer/caches.js";
|
} from "../out/indexer/caches.js";
|
||||||
import { resolveReferenceTargetsForType } from "../out/indexer/refs.js";
|
import { resolveReferenceTargetsForType } from "../out/indexer/refs.js";
|
||||||
|
import { assetDefKey } from "../out/indexer/referenceIndex.js";
|
||||||
|
|
||||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||||
const project = join(root, "test", "fixtures", "minimod");
|
const project = join(root, "test", "fixtures", "minimod");
|
||||||
@@ -29,6 +30,75 @@ async function buildIndex() {
|
|||||||
return indexer.build();
|
return indexer.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function u32(value) {
|
||||||
|
const b = Buffer.alloc(4);
|
||||||
|
b.writeUInt32LE(value >>> 0);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function u16(value) {
|
||||||
|
const b = Buffer.alloc(2);
|
||||||
|
b.writeUInt16LE(value >>> 0);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal version-5 manifest with one asset entry per supplied descriptor. */
|
||||||
|
function minimalManifestV5(assets) {
|
||||||
|
const nameParts = [];
|
||||||
|
const sourceParts = [];
|
||||||
|
let nameOffset = 0;
|
||||||
|
let sourceOffset = 0;
|
||||||
|
const entries = assets.map((asset) => {
|
||||||
|
const name = Buffer.from(`${asset.name}\0`, "ascii");
|
||||||
|
const source = Buffer.from(`${asset.source ?? ""}\0`, "ascii");
|
||||||
|
const entry = {
|
||||||
|
typeId: asset.typeId,
|
||||||
|
nameOffset,
|
||||||
|
sourceFileNameOffset: sourceOffset,
|
||||||
|
};
|
||||||
|
nameParts.push(name);
|
||||||
|
sourceParts.push(source);
|
||||||
|
nameOffset += name.length;
|
||||||
|
sourceOffset += source.length;
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
const names = Buffer.concat(nameParts);
|
||||||
|
const sources = Buffer.concat(sourceParts);
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
Buffer.from([0, 1]), // isBigEndian=false, isLinked=true
|
||||||
|
u16(5), // version
|
||||||
|
u32(0), // streamChecksum
|
||||||
|
u32(0), // allTypesHash
|
||||||
|
u32(assets.length), // assetCount
|
||||||
|
u32(0), // totalInstanceDataSize
|
||||||
|
u32(0), // maxInstanceChunkSize
|
||||||
|
u32(0), // maxRelocationChunkSize
|
||||||
|
u32(0), // maxImportsChunkSize
|
||||||
|
u32(0), // assetReferenceBufferSize
|
||||||
|
u32(0), // referencedManifestNameBufferSize
|
||||||
|
u32(names.length), // assetNameBufferSize
|
||||||
|
u32(sources.length), // sourceFileNameBufferSize
|
||||||
|
];
|
||||||
|
for (const entry of entries) {
|
||||||
|
parts.push(
|
||||||
|
u32(entry.typeId),
|
||||||
|
u32(0), // instanceId
|
||||||
|
u32(0), // typeHash
|
||||||
|
u32(0), // instanceHash
|
||||||
|
u32(0), // assetReferenceOffset
|
||||||
|
u32(0), // assetReferenceCount
|
||||||
|
u32(entry.nameOffset),
|
||||||
|
u32(entry.sourceFileNameOffset),
|
||||||
|
u32(0), // instanceDataSize
|
||||||
|
u32(0), // relocationDataSize
|
||||||
|
u32(0), // importsDataSize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parts.push(names, sources);
|
||||||
|
return Buffer.concat(parts);
|
||||||
|
}
|
||||||
|
|
||||||
test("indexes assets, defines, streams and include errors", async () => {
|
test("indexes assets, defines, streams and include errors", async () => {
|
||||||
const idx = await buildIndex();
|
const idx = await buildIndex();
|
||||||
|
|
||||||
@@ -133,6 +203,177 @@ test("w3x files appear in Include source completion candidates", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("manifest assets sharing an id keep every type in assetsById", async () => {
|
||||||
|
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ra3-manifest-multitype-"));
|
||||||
|
const projectDir = join(tmp, "project");
|
||||||
|
const sdkDir = join(tmp, "sdk");
|
||||||
|
const builtmodsDir = join(sdkDir, "builtmods");
|
||||||
|
fs.mkdirSync(join(projectDir, "Data"), { recursive: true });
|
||||||
|
fs.mkdirSync(builtmodsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(join(sdkDir, "Static.xml"), "<AssetDeclaration/>");
|
||||||
|
fs.writeFileSync(
|
||||||
|
join(projectDir, "Data", "Mod.xml"),
|
||||||
|
`<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||||
|
<Includes>
|
||||||
|
<Include type="reference" source="DATA:static.xml" />
|
||||||
|
</Includes>
|
||||||
|
<GameObject id="AlliedMCV">
|
||||||
|
<Draws>
|
||||||
|
<ScriptedModelDraw id="ModuleTag_Draw_Hover">
|
||||||
|
<ModelConditionState ParseCondStateType="PARSE_DEFAULT">
|
||||||
|
<Model Name="AUMCV_Hover" />
|
||||||
|
</ModelConditionState>
|
||||||
|
</ScriptedModelDraw>
|
||||||
|
</Draws>
|
||||||
|
</GameObject>
|
||||||
|
</AssetDeclaration>`,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
join(builtmodsDir, "static.manifest"),
|
||||||
|
minimalManifestV5([
|
||||||
|
{
|
||||||
|
typeId: 0x11111111,
|
||||||
|
name: "W3DHierarchy:AUMCV_HOVER",
|
||||||
|
source: "ART:aumcv_hover.w3x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typeId: 0x22222222,
|
||||||
|
name: "W3DAnimation:AUMCV_HOVER",
|
||||||
|
source: "ART:aumcv_hover.w3x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typeId: 0x33333333,
|
||||||
|
name: "W3DContainer:AUMCV_HOVER",
|
||||||
|
source: "ART:aumcv_hover.w3x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typeId: 0x44444444,
|
||||||
|
name: "Texture:ABAirfield",
|
||||||
|
source: "ART:abairfield.tga",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typeId: 0x55555555,
|
||||||
|
name: "W3DContainer:ABAIRFIELD",
|
||||||
|
source: "ART:abairfield.w3x",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const indexer = new ModIndexer({
|
||||||
|
projectDir,
|
||||||
|
sdkDir,
|
||||||
|
builtmodsDirs: [builtmodsDir],
|
||||||
|
indexSageXml: false,
|
||||||
|
additionalDataSearchPaths: [],
|
||||||
|
walker: new CachedDirectoryWalker(),
|
||||||
|
});
|
||||||
|
const idx = await indexer.build();
|
||||||
|
|
||||||
|
// The reported AUMCV_HOVER shape: Hierarchy/Animation precede the
|
||||||
|
// W3DContainer, so the by-id index must not drop the render asset.
|
||||||
|
const hover = idx.assetsById.get("aumcv_hover");
|
||||||
|
assert.ok(hover?.some((d) => d.type === "W3DContainer"), "W3DContainer retained");
|
||||||
|
assert.ok(hover?.some((d) => d.type === "W3DHierarchy"), "W3DHierarchy retained");
|
||||||
|
assert.ok(hover?.some((d) => d.type === "W3DAnimation"), "W3DAnimation retained");
|
||||||
|
|
||||||
|
const targets = resolveReferenceTargetsForType(
|
||||||
|
idx,
|
||||||
|
"ScriptedModelDrawModel",
|
||||||
|
"Name",
|
||||||
|
"AUMCV_Hover",
|
||||||
|
);
|
||||||
|
assert.equal(targets.length, 1);
|
||||||
|
assert.equal(targets[0].def.type, "W3DContainer");
|
||||||
|
|
||||||
|
const container = hover.find((d) => d.type === "W3DContainer");
|
||||||
|
const sites = idx.references.get(assetDefKey(container));
|
||||||
|
assert.ok(
|
||||||
|
sites?.some((s) => s.kind === "attr" && /Mod\.xml$/.test(s.file)),
|
||||||
|
"Model reference is attributed to the W3DContainer definition",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Common Texture-first shape must also keep the render definition.
|
||||||
|
const airfield = idx.assetsById.get("abairfield");
|
||||||
|
assert.ok(airfield?.some((d) => d.type === "Texture"), "Texture retained");
|
||||||
|
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 () => {
|
test("build publishes an immutable XML phase before art scanning", async () => {
|
||||||
let phaseA;
|
let phaseA;
|
||||||
const indexer = new ModIndexer({
|
const indexer = new ModIndexer({
|
||||||
|
|||||||
@@ -99,6 +99,54 @@ test("logical xi:include expansion gives included modules their Draws context",
|
|||||||
assert.ok(localIds.includes("ModuleTag_Headlight"));
|
assert.ok(localIds.includes("ModuleTag_Headlight"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("xi:include without xpointer splices the target root element itself", async (t) => {
|
||||||
|
const tmp = await mkdtemp(join(tmpdir(), "ra3-local-noxpointer-"));
|
||||||
|
t.after(() => rm(tmp, { recursive: true, force: true }));
|
||||||
|
const dataDir = join(tmp, "Data");
|
||||||
|
const includesDir = join(dataDir, "Includes");
|
||||||
|
await mkdir(includesDir, { recursive: true });
|
||||||
|
const mainPath = join(dataDir, "Main.xml");
|
||||||
|
const fragmentPath = join(includesDir, "Fragment.xml");
|
||||||
|
await writeFile(
|
||||||
|
fragmentPath,
|
||||||
|
'<CreateObjectDie xmlns="uri:ea.com:eala:asset" id="ModuleTag_X" CreationList="OCL_X"><DieMuxData DeathTypes="SUICIDED"/></CreateObjectDie>',
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
mainPath,
|
||||||
|
'<AssetDeclaration xmlns="uri:ea.com:eala:asset" xmlns:xi="http://www.w3.org/2001/XInclude">' +
|
||||||
|
'<GameObject id="G"><Behaviors><xi:include href="DATA:Includes/Fragment.xml"/></Behaviors></GameObject>' +
|
||||||
|
"</AssetDeclaration>",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const searchPaths = buildSearchPaths(sdk, tmp);
|
||||||
|
const text = await readFile(mainPath, "utf8");
|
||||||
|
const scope = await buildDocumentScope(mainPath, text, 1, {
|
||||||
|
projectDir: tmp,
|
||||||
|
sdkDir: sdk,
|
||||||
|
searchPaths,
|
||||||
|
readRecords: readParsed,
|
||||||
|
readDom: readParsed,
|
||||||
|
});
|
||||||
|
const behaviors = scope.expanded.elements.find((e) => e.name === "Behaviors");
|
||||||
|
assert.ok(behaviors, "Behaviors exists");
|
||||||
|
assert.equal(behaviors.children.length, 1);
|
||||||
|
const module = behaviors.children[0];
|
||||||
|
assert.equal(module.name, "CreateObjectDie");
|
||||||
|
assert.equal(
|
||||||
|
module.attrs.find((a) => a.name === "id")?.value,
|
||||||
|
"ModuleTag_X",
|
||||||
|
);
|
||||||
|
assert.match(module.sourceFile, /Fragment\.xml$/i);
|
||||||
|
const dieMux = scope.expanded.elements.find((e) => e.name === "DieMuxData");
|
||||||
|
assert.ok(dieMux, "DieMuxData is expanded");
|
||||||
|
assert.equal(
|
||||||
|
dieMux.parent,
|
||||||
|
module,
|
||||||
|
"DieMuxData stays inside the included CreateObjectDie module",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("local overlay wins over a global definition with the same id", async () => {
|
test("local overlay wins over a global definition with the same id", async () => {
|
||||||
const scope = await makeScope();
|
const scope = await makeScope();
|
||||||
const global = {
|
const global = {
|
||||||
|
|||||||
@@ -113,3 +113,26 @@ test("extractIndexRecords records typed references and skips non-references", ()
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("extractIndexRecords records simpleContent complex text as references", () => {
|
||||||
|
const text = `<AssetDeclaration>
|
||||||
|
<AudioEvent id="A">
|
||||||
|
<Sound Weight="100">VoiceFile</Sound>
|
||||||
|
</AudioEvent>
|
||||||
|
<Multisound id="M">
|
||||||
|
<Subsound Weight="50">VoiceEvent</Subsound>
|
||||||
|
</Multisound>
|
||||||
|
</AssetDeclaration>`;
|
||||||
|
const lineMap = new LineMap(text);
|
||||||
|
const records = extractIndexRecords(parseXml(text), lineMap, text);
|
||||||
|
const content = records.references.filter((r) => r.kind === "content");
|
||||||
|
|
||||||
|
const sound = content.find((r) => r.value === "VoiceFile");
|
||||||
|
assert.ok(sound, "Sound text is recorded as a content reference");
|
||||||
|
assert.equal(sound.refType, "AudioFile");
|
||||||
|
assert.equal(sound.selfType, null);
|
||||||
|
|
||||||
|
const subsound = content.find((r) => r.value === "VoiceEvent");
|
||||||
|
assert.ok(subsound, "Subsound text is recorded as a content reference");
|
||||||
|
assert.equal(subsound.refType, "BaseAudioEventInfo");
|
||||||
|
});
|
||||||
|
|||||||
@@ -157,6 +157,42 @@ test("records extracted from XML resolve through the reference index", () => {
|
|||||||
assert.equal(csSites[0].kind, "attr");
|
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", () => {
|
test("referenceSitesForDefinition unions manifest-source sites onto the SageXml source file", () => {
|
||||||
const tmp = mkdtempSync(join(tmpdir(), "ra3-refindex-"));
|
const tmp = mkdtempSync(join(tmpdir(), "ra3-refindex-"));
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -177,3 +177,80 @@ test("FAR from the reference site itself returns the same result", async () => {
|
|||||||
assert.equal(refs.length, 1);
|
assert.equal(refs.length, 1);
|
||||||
assert.equal(refs[0].range.start.line, 4);
|
assert.equal(refs[0].range.start.line, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("FAR includes simpleContent complex content references", async () => {
|
||||||
|
const text = `<AssetDeclaration>
|
||||||
|
<AudioFile id="VoiceFile"/>
|
||||||
|
<AudioEvent id="A">
|
||||||
|
<Sound>VoiceFile</Sound>
|
||||||
|
</AudioEvent>
|
||||||
|
</AssetDeclaration>`;
|
||||||
|
const parse = parseXml(text);
|
||||||
|
const lineMap = new LineMap(text);
|
||||||
|
const records = extractIndexRecords(parse, lineMap, text);
|
||||||
|
const def = {
|
||||||
|
type: "AudioFile",
|
||||||
|
id: "VoiceFile",
|
||||||
|
file: FILE,
|
||||||
|
line: 2,
|
||||||
|
origin: "project",
|
||||||
|
};
|
||||||
|
const lookup = {
|
||||||
|
assets: new Map([["AudioFile", new Map([["voicefile", [def]]])]]),
|
||||||
|
assetsById: new Map([["voicefile", [def]]]),
|
||||||
|
};
|
||||||
|
const references = buildReferenceIndex([{ file: FILE, records }], lookup);
|
||||||
|
const idx = {
|
||||||
|
...lookup,
|
||||||
|
references,
|
||||||
|
complete: true,
|
||||||
|
phase: "art",
|
||||||
|
projectDir: "C:/mod",
|
||||||
|
sdkDir: "C:/sdk",
|
||||||
|
defines: new Map(),
|
||||||
|
files: new Map(),
|
||||||
|
streams: [],
|
||||||
|
manifests: new Map(),
|
||||||
|
sourceCandidates: [],
|
||||||
|
diagnostics: [],
|
||||||
|
stats: {},
|
||||||
|
};
|
||||||
|
const scope = { merged: idx };
|
||||||
|
const localParse = parseXml(text);
|
||||||
|
const localLineMap = new LineMap(text);
|
||||||
|
const localIndexer = {
|
||||||
|
readDom: async (path) =>
|
||||||
|
path === FILE
|
||||||
|
? { file: { path: FILE }, parse: localParse, lineMap: localLineMap, records: null }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
const localWs = {
|
||||||
|
isRa3Workspace: () => true,
|
||||||
|
getScope: async () => scope,
|
||||||
|
indexer: localIndexer,
|
||||||
|
indexerForFile: () => localIndexer,
|
||||||
|
activeIndexer: () => localIndexer,
|
||||||
|
recordsSyncSurfaceFor: () => ({
|
||||||
|
get index() {
|
||||||
|
return scope.merged;
|
||||||
|
},
|
||||||
|
invalidate: () => {},
|
||||||
|
scheduleRebuild: () => {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const provider = new Ra3ReferenceProvider(localWs);
|
||||||
|
const document = makeDocument(text);
|
||||||
|
const defLine = text.split("\n")[1];
|
||||||
|
const defPos = new Position(1, defLine.indexOf('id="') + 4);
|
||||||
|
|
||||||
|
const refs = await provider.provideReferences(document, defPos, {
|
||||||
|
includeDeclaration: true,
|
||||||
|
}, {});
|
||||||
|
assert.ok(refs, "references are returned");
|
||||||
|
assert.equal(refs.length, 1);
|
||||||
|
assert.equal(refs[0].range.start.line, 3);
|
||||||
|
assert.equal(
|
||||||
|
text.split("\n")[3].slice(refs[0].range.start.character, refs[0].range.end.character),
|
||||||
|
"VoiceFile",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
isReferenceAttribute,
|
isReferenceAttribute,
|
||||||
isReferenceAttributeOfType,
|
isReferenceAttributeOfType,
|
||||||
isReferenceContentType,
|
isReferenceContentType,
|
||||||
|
isReferenceTargetType,
|
||||||
|
normalizeReferenceId,
|
||||||
resolveContentReferenceTargets,
|
resolveContentReferenceTargets,
|
||||||
resolveReferenceTargets,
|
resolveReferenceTargets,
|
||||||
resolveReferenceTargetsForType,
|
resolveReferenceTargetsForType,
|
||||||
@@ -70,7 +72,25 @@ test("isReferenceAttribute distinguishes references from enums/paths", () => {
|
|||||||
// Typed references and inheritFrom are references.
|
// Typed references and inheritFrom are references.
|
||||||
assert.equal(isReferenceAttribute("GameObject", "CommandSet"), true);
|
assert.equal(isReferenceAttribute("GameObject", "CommandSet"), true);
|
||||||
assert.equal(isReferenceAttribute("GameObject", "inheritFrom"), true);
|
assert.equal(isReferenceAttribute("GameObject", "inheritFrom"), true);
|
||||||
|
assert.equal(isReferenceAttribute("FXList", "inheritFrom"), true);
|
||||||
|
assert.equal(isReferenceAttribute("AIMicroManagerData", "inheritFrom"), true);
|
||||||
assert.equal(isReferenceAttribute("FireWeaponNugget", "WeaponName"), true);
|
assert.equal(isReferenceAttribute("FireWeaponNugget", "WeaponName"), true);
|
||||||
|
// inheritFrom is an asset-level attribute; non-asset elements stay non-refs.
|
||||||
|
assert.equal(isReferenceAttribute("Include", "inheritFrom"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("universal inheritFrom legality is separate from CodeLens target design", () => {
|
||||||
|
// FXList and other BaseAssetType descendants legally accept inheritFrom,
|
||||||
|
// but the XSD does not declare it there. That must not widen the designed
|
||||||
|
// reference-target set (Credits is still not a CodeLens target).
|
||||||
|
assert.ok(
|
||||||
|
model.attributesOfElement("FXList").some((a) => a.name === "inheritFrom"),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
model.attributesOfElement("Credits").some((a) => a.name === "inheritFrom"),
|
||||||
|
);
|
||||||
|
assert.equal(isReferenceTargetType("Credits"), false);
|
||||||
|
assert.equal(isReferenceTargetType("FXList"), true); // via FXListRef, not universal attr
|
||||||
});
|
});
|
||||||
|
|
||||||
test("attribute-level xas:refType is preserved in the model", () => {
|
test("attribute-level xas:refType is preserved in the model", () => {
|
||||||
@@ -287,6 +307,170 @@ test("typed simple content resolves like a typed attribute reference", () => {
|
|||||||
assert.equal(targets[0].def.type, "GameObject");
|
assert.equal(targets[0].def.type, "GameObject");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("simpleContent complex types resolve as typed content references", () => {
|
||||||
|
// <Sound>AudioFile</Sound> / <Subsound>VoiceEvent</Subsound> use
|
||||||
|
// simpleContent complex types (AudioFileRefWithWeight /
|
||||||
|
// MultisoundSubsoundRef) whose text is still a typed asset reference.
|
||||||
|
assert.equal(isReferenceContentType("AudioFileRefWithWeight"), true);
|
||||||
|
assert.equal(isReferenceContentType("MultisoundSubsoundRef"), true);
|
||||||
|
// Inline Frame's simpleContent is a scalar float, not a reference.
|
||||||
|
assert.equal(isReferenceContentType("@inline:Frame"), false);
|
||||||
|
|
||||||
|
const idx = {
|
||||||
|
assetsById: new Map([
|
||||||
|
[
|
||||||
|
"shared",
|
||||||
|
[
|
||||||
|
{ type: "AudioFile", id: "Shared", file: "Audio.xml", line: 1, origin: "project" },
|
||||||
|
{ type: "AudioEvent", id: "Shared", file: "Voice.xml", line: 2, origin: "project" },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
assets: new Map(),
|
||||||
|
defines: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const soundTargets = resolveContentReferenceTargets(
|
||||||
|
idx,
|
||||||
|
"AudioFileRefWithWeight",
|
||||||
|
"Shared",
|
||||||
|
);
|
||||||
|
assert.equal(soundTargets.length, 1);
|
||||||
|
assert.equal(soundTargets[0].def.type, "AudioFile");
|
||||||
|
|
||||||
|
const subsoundTargets = resolveContentReferenceTargets(
|
||||||
|
idx,
|
||||||
|
"MultisoundSubsoundRef",
|
||||||
|
"Shared",
|
||||||
|
);
|
||||||
|
assert.equal(subsoundTargets.length, 1);
|
||||||
|
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", () => {
|
test("untyped and pipeline-local content is not a global reference", () => {
|
||||||
// Generic AssetReference content is used for shader constants and model
|
// Generic AssetReference content is used for shader constants and model
|
||||||
// sub-object names, not global asset ids; Poid is pipeline-local.
|
// sub-object names, not global asset ids; Poid is pipeline-local.
|
||||||
|
|||||||
@@ -17,6 +17,70 @@ test("GameObject has expected attributes", () => {
|
|||||||
assert.ok(attrs.some((a) => a.name === "inheritFrom"));
|
assert.ok(attrs.some((a) => a.name === "inheritFrom"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("inheritFrom is a universal asset attribute, not only BaseInheritableAsset", () => {
|
||||||
|
// BAB / vanilla data accepts inheritFrom on FXList, AIMicroManagerData,
|
||||||
|
// ObjectCreationList, OnDemandTextureImage and AITargetingHeuristic even
|
||||||
|
// though the XSD only declares it on BaseInheritableAsset.
|
||||||
|
for (const name of [
|
||||||
|
"FXList",
|
||||||
|
"AIMicroManagerData",
|
||||||
|
"ObjectCreationList",
|
||||||
|
"OnDemandTextureImage",
|
||||||
|
"AITargetingHeuristic",
|
||||||
|
]) {
|
||||||
|
assert.ok(
|
||||||
|
model.attributesOfElement(name).some((a) => a.name === "inheritFrom"),
|
||||||
|
`${name} should accept universal inheritFrom`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Structural elements that are not assets must not get the attribute.
|
||||||
|
assert.ok(
|
||||||
|
!model.attributesOfElement("Include").some((a) => a.name === "inheritFrom"),
|
||||||
|
"Include is not an asset and must not accept inheritFrom",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simpleContent extension attributes are preserved by the model generator", () => {
|
||||||
|
const sound = model.typeInfo("AudioFileRefWithWeight");
|
||||||
|
assert.equal(sound?.kind, "complex");
|
||||||
|
assert.ok(sound.attributes.some((a) => a.name === "Weight"));
|
||||||
|
assert.ok(sound.attributes.some((a) => a.name === "Volume"));
|
||||||
|
|
||||||
|
const subsound = model.typeInfo("MultisoundSubsoundRef");
|
||||||
|
assert.equal(subsound?.kind, "complex");
|
||||||
|
assert.ok(subsound.attributes.some((a) => a.name === "Weight"));
|
||||||
|
assert.ok(subsound.attributes.some((a) => a.name === "PitchShiftLow"));
|
||||||
|
assert.ok(subsound.attributes.some((a) => a.name === "PitchShiftHigh"));
|
||||||
|
assert.ok(subsound.attributes.some((a) => a.name === "Volume"));
|
||||||
|
assert.ok(subsound.attributes.some((a) => a.name === "PlayPercent"));
|
||||||
|
assert.ok(subsound.attributes.some((a) => a.name === "VolumeShift"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contentInfoOfType unifies simple and simpleContent content semantics", () => {
|
||||||
|
const simple = model.contentInfoOfType("GameObjectWeakRef");
|
||||||
|
assert.equal(simple?.kind, "simple");
|
||||||
|
assert.equal(simple?.refType, "GameObject");
|
||||||
|
|
||||||
|
const sound = model.contentInfoOfType("AudioFileRefWithWeight");
|
||||||
|
assert.equal(sound?.kind, "simpleContent");
|
||||||
|
assert.equal(sound?.refType, "AudioFile");
|
||||||
|
assert.equal(sound?.isRef, true);
|
||||||
|
|
||||||
|
const subsound = model.contentInfoOfType("MultisoundSubsoundRef");
|
||||||
|
assert.equal(subsound?.kind, "simpleContent");
|
||||||
|
assert.equal(subsound?.refType, "BaseAudioEventInfo");
|
||||||
|
|
||||||
|
// Ordinary complex elements and structural elements have no content value.
|
||||||
|
assert.equal(model.contentInfoOfType("GameObject"), null);
|
||||||
|
assert.equal(model.contentInfoOfType("Include"), null);
|
||||||
|
|
||||||
|
// Inline simpleContent with a scalar base has content info but no refType.
|
||||||
|
const frame = model.contentInfoOfType("@inline:Frame");
|
||||||
|
assert.ok(frame, "inline simpleContent is exposed through contentInfoOfType");
|
||||||
|
assert.equal(frame?.refType, null);
|
||||||
|
assert.equal(frame?.base, "float");
|
||||||
|
});
|
||||||
|
|
||||||
test("attribute-level xas:refType is captured (module ids, map objects)", () => {
|
test("attribute-level xas:refType is captured (module ids, map objects)", () => {
|
||||||
// ModuleData@id is declared as <xs:attribute name="id" type="Poid"
|
// ModuleData@id is declared as <xs:attribute name="id" type="Poid"
|
||||||
// xas:refType="ModuleData" />; the refType must reach every module subtype.
|
// xas:refType="ModuleData" />; the refType must reach every module subtype.
|
||||||
|
|||||||
@@ -39,3 +39,24 @@ test("model childTypeOf primitives", () => {
|
|||||||
assert.equal(model.childTypeOf("WeaponSlot_WeaponData", "Weapon"), null);
|
assert.equal(model.childTypeOf("WeaponSlot_WeaponData", "Weapon"), null);
|
||||||
assert.equal(model.childTypeOf(null, "Weapon"), null);
|
assert.equal(model.childTypeOf(null, "Weapon"), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fragment roots prefer top-level AssetDeclaration types over name collisions", () => {
|
||||||
|
// <EvaEvent> is both a top-level asset and an FXNugget child. A fragment
|
||||||
|
// root has no parent context, so it must resolve to the top-level asset
|
||||||
|
// type (with Priority / TimeBetweenEvents etc.), not to EvaEventFXNugget.
|
||||||
|
const evaDoc = parseXml(
|
||||||
|
`<EvaEvent id="IncomingTransmission" Priority="100" TimeBetweenEvents="0ms" ExpirationTime="10000ms"/>`,
|
||||||
|
);
|
||||||
|
assert.equal(resolveElementType(evaDoc.root), "EvaEvent");
|
||||||
|
assert.ok(
|
||||||
|
model.attributesOfType("EvaEvent").some((a) => a.name === "Priority"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const upgradeDoc = parseXml(`<UpgradeTemplate id="Upgrade_X" inheritFrom="Base"/>`);
|
||||||
|
assert.equal(resolveElementType(upgradeDoc.root), "UpgradeTemplate");
|
||||||
|
|
||||||
|
// Non-top-level fragment roots still fall back to the contextual child
|
||||||
|
// mapping (no AssetDeclaration child exists for Weapon).
|
||||||
|
const weaponDoc = parseXml(`<Weapon Ordering="PRIMARY_WEAPON"/>`);
|
||||||
|
assert.equal(resolveElementType(weaponDoc.root), "WeaponRef");
|
||||||
|
});
|
||||||
|
|||||||
+29
-2
@@ -368,8 +368,13 @@ function expandComplexType(name, chain = []) {
|
|||||||
const node = complexTypes.get(name);
|
const node = complexTypes.get(name);
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
|
|
||||||
const extension = node?.complexContent?.extension;
|
// Both `complexContent/extension` and `simpleContent/extension` contribute
|
||||||
const baseName = normalizeTypeName(extension?.["@_base"] ?? node?.simpleContent?.extension?.["@_base"]);
|
// attributes. simpleContent types (AudioFileRefWithWeight,
|
||||||
|
// MultisoundSubsoundRef, ...) were previously losing all of theirs because
|
||||||
|
// only the complexContent branch was read.
|
||||||
|
const extension =
|
||||||
|
node?.complexContent?.extension ?? node?.simpleContent?.extension ?? null;
|
||||||
|
const baseName = normalizeTypeName(extension?.["@_base"] ?? null);
|
||||||
const base = baseName ? expandComplexType(baseName, [...chain, name]) : null;
|
const base = baseName ? expandComplexType(baseName, [...chain, name]) : null;
|
||||||
|
|
||||||
const ownChildren = collectChildren(extension ?? node);
|
const ownChildren = collectChildren(extension ?? node);
|
||||||
@@ -389,6 +394,28 @@ function expandComplexType(name, chain = []) {
|
|||||||
base: baseName,
|
base: baseName,
|
||||||
doc: docOf(node),
|
doc: docOf(node),
|
||||||
};
|
};
|
||||||
|
if (node?.simpleContent) {
|
||||||
|
const simpleBaseName = normalizeTypeName(
|
||||||
|
node.simpleContent.extension?.["@_base"] ?? null,
|
||||||
|
);
|
||||||
|
const baseDesc = simpleBaseName
|
||||||
|
? resolveTypeDescriptor(simpleBaseName)
|
||||||
|
: null;
|
||||||
|
result.content = {
|
||||||
|
refType:
|
||||||
|
normalizeTypeName(node["@_refType"]) ??
|
||||||
|
normalizeTypeName(baseDesc?.refType) ??
|
||||||
|
null,
|
||||||
|
isRef:
|
||||||
|
node["@_isRef"] === "true" ||
|
||||||
|
node["@_isWeakRef"] === "true" ||
|
||||||
|
baseDesc?.isRef === true,
|
||||||
|
enumValues: baseDesc?.enumValues ?? [],
|
||||||
|
isList: baseDesc?.isList ?? false,
|
||||||
|
allowsDefine: baseDesc?.allowsDefine ?? false,
|
||||||
|
base: simpleBaseName,
|
||||||
|
};
|
||||||
|
}
|
||||||
expandedTypes.set(name, result);
|
expandedTypes.set(name, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user