This commit is contained in:
2026-08-11 02:20:06 +02:00
parent 36eaaafa01
commit dbc2c99d8d
45 changed files with 3904 additions and 661 deletions
+60
View File
@@ -0,0 +1,60 @@
# RA3 Mod XML License
## Original code — MIT
Copyright (c) 2026 The RA3 Mod XML contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## EA Mod SDK-derived data
The following file is generated from materials distributed with the
Command & Conquer: Red Alert 3 Mod SDK, which is owned by Electronic Arts Inc.
and its licensors:
- `src/model/schema-model.json` — generated by `tools/xsd-to-model.mjs` from
the SDK's XSD schemas.
This file is not covered by the MIT license above. It is included only for
modding interoperability and remains subject to the EA Tools & Materials
End User License. "Command & Conquer" and "Red Alert 3" are trademarks of
their respective owners.
This extension does not bundle the RA3 Mod SDK; the SDK must be installed
separately by the user.
## OpenSAGE-derived files — LGPL-3.0
The following files are derived from OpenSAGE
(<https://github.com/OpenSAGE/OpenSAGE>), which is licensed under the GNU
Lesser General Public License version 3:
- `src/indexer/manifestParser.ts` — ported from
`src/OpenSage.Game/Data/StreamFS/ManifestFile.cs`.
- `src/model/asset-types.json` — extracted from
`src/OpenSage.Game/Data/StreamFS/AssetType.cs`.
These files are licensed under LGPL-3.0. The full license text is available at
<https://www.gnu.org/licenses/lgpl-3.0.html> and in the vendored OpenSAGE copy
at `OpenSAGE/LICENSE.md`.
## Other files
Unless stated otherwise above, all other files in this repository are
licensed under the MIT license above.
+203 -120
View File
@@ -1,144 +1,227 @@
# RA3 Mod XMLVS Code 扩展) > This site is not endorsed by or affiliated with Electronic Arts, or its licensors. Trademarks are the property of their respective owners. Game content and materials copyright Electronic Arts Inc. and its licensors. All Rights Reserved.
>
> RA3 Mod XML is an unofficial fan-made tool. It requires a separately installed RA3 Mod SDK.
面向《命令与征服:红色警戒 3》Mod XMLSAGE / BinaryAssetBuilder 格式)的 VS Code 工具扩展。 [**English**](README.md) | [中文](README.zh-CN.md)
## 功能 # RA3 Mod XML
- **语法高亮**:在普通 XML 高亮之上叠加领域标记(`$DEFINE` 常量、`inheritFrom``xai:joinAction`、结构标签);XML 语法异常(如未闭合引号)期间由语义 token 兜底,标签/属性/值着色不中断。 A VS Code extension that brings **IntelliSense, navigation, reference tracking, and diagnostics** to XML-based mods for **Command & Conquer: Red Alert 3**.
- **自动补全**
- 元素名:按当前父元素的 XSD 模型补全子元素;顶层资产(`AssetDeclaration` 内)补全 `GameObject``WeaponTemplate` 等 295 种类型。已输入 `<` 时补全保留该 `<`、只替换名称区(不会出现 `<<`);需要填文本的 simple-content 元素(如 `<CreateObject>`)补全为 `<CreateObject>$1</CreateObject>` 并自动弹出值补全,而不是无法填值的自闭合标签。
- 属性名:必填属性优先,附带类型/文档/默认值;自动提示 `xai:joinAction``xmlns:xai`。接受补全时自动避免与上一个属性贴在一起,并按文件已有的排版补空格或换行(换行的基础缩进由编辑器提供,插件不再内嵌缩进以免叠加);数字/角度/时间等标量属性直接填入 XSD 默认值或类型示例(如 `0d``0s`),引用/枚举/布尔等保留真正的 `$1` 占位符并弹出值补全。
- 属性值:
- 引用型属性(如 `CommandSet``Weapon`)按 `xas:refType` 补全对应类型的资产 ID(**同名 ID 只补全匹配类型**);
- `inheritFrom` 补全可继承的资产 ID
- 枚举与位标志列表(如 `Include type``LocomotorTemplate@Surfaces``KindOf`;列表值在空格后自动继续补全下一项,已使用的 flag 不再重复推荐,闭合值末尾可直接追加新 flag);
- 布尔值、`$DEFINE` 常量;
- `<Include source>` 补全可解析的 `DATA:` / `ART:` / `AUDIO:` 与项目相对路径。
- 元素文本内容:simple-content 引用元素(如 `<CreateObject>``<RequiredUpgrade>``<SpawnTemplate>`)直接在标签间补全对应类型的资产 ID(`GameObjectWeakRef` → GameObject)、枚举或 `$DEFINE`
接受片段后的 `$1` 光标位置会立即弹出值补全,而不是属性名;引用列表超过
400 条时标记为不完整,继续输入会重新请求,因此 `CrateDebris_01` 这类排在
列表后部的 id 不会因首屏截断而消失。
- **悬停提示**:元素/属性显示 XSD 文档、类型、必填/默认值;引用值显示定义位置;`$DEFINE` 显示值与定义位置;`Include source` / `xi:include href` 显示解析后的目标文件;`xi:include` 元素与属性给出 XInclude 说明。
- **引用导航**:从引用值(`CommandSet="..."``Weapon="..."``inheritFrom``<CreateObject>ID</CreateObject>` 等元素文本)跳转到定义(严格按引用类型过滤,候选由 `ra3modxml.definitionMode` 控制:`all` 列出 mod + 原版、`project-only` 优先项目内定义);`Ctrl+点击` Include / `xi:include href` 打开目标文件;Find All References 基于**语义引用索引**(属性引用 + simple-content 文本 + `inheritFrom`,排除 id 定义点 / Poid / `$DEFINE`,不再全文搜索);文档大纲列出顶层资产与 `$DEFINE`
- **引用计数(CodeLens)**:在“设计上应被引用”的顶部资产类型上显示
`0 references` / `1 reference` / `N references`0 也显示),点击直接打开
references peek;设置类、地图元数据、w3x 子结构等自动注册类型不显示,
避免满屏 0manifest 资产有对应 SageXml 源码时,引用按源码定义归并计数
(打开 SageXml 源码同样能看到引用数)。
- **未引用资产**`RA3 Mod XML: Find unreferenced assets…` 命令按类型列出
所有零引用的项目资产并跳转;编辑器右键菜单
`Find unreferenced assets of this type` 可直接使用光标所在资产类型。
- **当前文档局部作用域(T1)**:即使一个文件不在任何全局流里(没有从
`Data/Mod.xml` / `additionalmaps` 可达),插件也会按当前文件自身的资产、
`$DEFINE` 及其 include 链建立局部索引。`xi:include` 会在逻辑树中展开,
使 include 进来的内容获得正确的父上下文;`AttachModuleId` / `ModuleId` /
`AutoResolveBody` 等管线局部(Poid)引用可以补全、悬停与跳转到同一
GameObject 内的模块(含通过 `xi:include` 拼入的兄弟模块)。
- **错误检查**:XML 格式错误、未知元素/属性(`xi:` 等外来命名空间不误报)、顶层资产缺 `id`、重复 ID、未解析引用(含属性值与元素文本内容、类型不匹配)、Include / 嵌套 `xi:include` 目标找不到、`$DEFINE` 未定义。
- **manifest 支持**`<Include type="reference">` 指向的 `static/global/audio.manifest`SDK `builtmods`)会被解析,manifest 中的原版资产 ID 可用于补全/悬停/导航/诊断。
- **美术资产(`.w3x`**`W3X.xml` / `ART:` include 链中的 `.w3x` 模型文件会被
索引(`W3DContainer` / `W3DMesh` / `W3DHierarchy` 等顶层资产),因此
`Model@Name``Hierarchy``Mesh` 等引用可以解析、悬停与跳转。超大模型
(几十 MB 的顶点/三角形数据)采用浅扫描——只提取顶层资产记录、不建 DOM 树,
结果在 workspace 级缓存并跨重建复用,保存文件触发的重建不会重读未变化的模型文件。
- **索引分阶段与部分可用性**:先建立 XML + manifest 索引(首建早期即可用),
w3x 美术资产随后台扫描补齐。索引完成前,语法/模型诊断、枚举与子元素补全、
Include 跳转/悬停照常工作;引用类诊断会“显示但标注”
`unresolved-reference-indexing` + `(index incomplete)` 说明),不会把
未完成的索引误当成最终结论。
- **大项目性能**:索引记录(资产 / Define / Include / 引用 / 行号)与 include
解析结果跨重建缓存,保存触发的重建零 stat、零重读(Corona 实测约 2 秒);
DOM 树只按需保留并设元素预算,避免内存膨胀。编辑器外的文件改动(git pull、
导出工具)会触发防抖重建;构建期间文件再次被修改时,已发布索引会标记
`(stale)` 并自动重跑。include 路径解析使用目录枚举建立的文件集快照(无
statSync 风暴);records 缓存会持久化到磁盘(gzip + 多信号 stat 校验 +
内容哈希 + 原子写),重启 VS Code 后冷启动只需秒级校验,Corona 实测约 11 秒
(首次全量约 2 分钟)。引用索引只从构建期实际消费的 records 构建;打开文档
时若发现当前文本的 records 与快照不一致(如外置盘重连后缓存过时),会自动
定向重建自愈;`Re-index workspace` 会对 stat 匹配的 XML 也做内容校验。
## 使用 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.
1. 用 VS Code 打开 RA3 Mod 项目文件夹(含 `Data/Mod.xml``mod.babproj`)。 <table>
2. 插件自动激活并开始后台索引(状态栏显示阶段与资产数量;扩展也会在打开 <tr>
任意 XML 文件时激活,非 RA3 工作区不会显示 RA3 专属功能)。 <td align="center">
3. 编辑任意 `*.xml` 即可获得补全、跳转与诊断。 <strong>Intelligent Completion</strong><br>
<a href="https://ra3modxml-images.ratotal.workers.dev/enum_completion.gif">
<img src="https://ra3modxml-images.ratotal.workers.dev/enum_completion.gif" alt="RA3 XML intelligent completion" width="100%">
</a>
</td>
<td align="center">
<strong>Go to Definition</strong><br>
<a href="https://ra3modxml-images.ratotal.workers.dev/navigation.gif">
<img src="https://ra3modxml-images.ratotal.workers.dev/navigation.gif" alt="RA3 XML navigation" width="100%">
</a>
</td>
</tr>
</table>
### 设置(`settings.json` <p align="center">
<strong>Reference-aware completion</strong><br>
<a href="https://ra3modxml-images.ratotal.workers.dev/ref_completion.gif">
<img src="https://ra3modxml-images.ratotal.workers.dev/ref_completion.gif" alt="RA3 XML reference completion" width="80%">
</a>
</p>
| 设置 | 默认值 | 说明 | ## Features
|---|---|---|
| `ra3modxml.sdkPath` | `C:\Apps\RA3-MODSDK-X` | Mod SDK 根目录 |
| `ra3modxml.indexSageXml` | `true` | 是否索引 SDK 的 `SageXml` 原版源码 |
| `ra3modxml.reportUnresolvedReferences` | `warning` | 未解析引用诊断级别(`warning`/`information`/`none` |
| `ra3modxml.diagnoseUnknownElements` | `true` | 是否报告未知元素/属性(自定义 XSD 项目可关闭) |
| `ra3modxml.definitionMode` | `all` | 跳转候选:`all` 列出 mod 定义与原版定义(mod 优先);`project-only` 仅在项目内已有定义时直接跳转 mod 定义 |
| `ra3modxml.additionalDataSearchPaths` | `[]` | 追加的 `DATA:` 搜索目录 |
### 命令 ### Intelligent Completion
- `RA3 Mod XML: Re-index workspace`:手动重建索引。 Get context-aware completion based on the RA3 XML schema and project data.
- `RA3 Mod XML: Show index report`:查看索引统计。
- `RA3 Mod XML: Clear caches and rebuild`:清空内存/磁盘缓存并强制全量重建。
- `RA3 Mod XML: Show cache report`:查看磁盘缓存路径、大小、校验统计与命中数。
- `RA3 Mod XML: Find unreferenced assets…`:按类型查找零引用的项目资产。
- `RA3 Mod XML: Find unreferenced assets of this type`:右键菜单入口,
直接查找光标所在顶部资产类型的未引用资产。
## 开发 * Elements and attributes based on the RA3 XSD
* Required attributes, types, documentation, and default values
* Asset references such as `Weapon`, `CommandSet`, and `inheritFrom`
* Enum values and flag lists such as `KindOf` and `Surfaces`
* Asset IDs in text-content elements such as `<CreateObject>` and `<RequiredUpgrade>`
* `DATA:`, `ART:`, and `AUDIO:` paths
* Automatic continuation when editing flag lists
Reference completion is type-aware, so an asset ID is only suggested where its asset type is valid.
### Syntax Highlighting
RA3-specific constructs are highlighted on top of the built-in XML grammar.
### Navigation & References
Navigate through a mod's asset graph directly from the editor.
* **Go to Definition** (Ctrl+Click) for asset references
* **Find All References** using semantic reference information
* **Reference CodeLens** showing how many times an asset is referenced
* Hover information for elements, attributes, references, and `$DEFINE`s
* Ctrl+Click navigation for `Include` and `xi:include`
* Document outline for top-level assets and `$DEFINE`s
### Diagnostics
Catch common modding mistakes while you edit.
* XML syntax errors
* Unknown elements and attributes
* Missing or duplicate asset IDs
* Unresolved asset references
* References to the wrong asset type
* Undefined `$DEFINE`s
### Project Analysis
The extension can analyze the entire workspace rather than only the file currently open.
**Find unreferenced assets** lists project assets that are not referenced anywhere in the workspace, helping identify obsolete or accidentally unused definitions.
Run:
`RA3 Mod XML: Find unreferenced assets…`
You can also use the editor context menu to find unreferenced assets of the current asset type.
### Vanilla SDK Integration
The extension can use asset definitions from the **RA3 Mod SDK**, allowing vanilla game assets to participate in completion, hover information, navigation, and diagnostics.
`<Include type="reference">` manifests such as `static.manifest`, `global.manifest`, and `audio.manifest` (from the SDK's `builtmods` directory) are supported when the corresponding SDK data is available.
### Large Mod Support
Workspace indexing runs in the background and uses persistent caches to avoid rebuilding everything on every VS Code launch.
The extension has been tested on Corona Mod, a large size RA3 mod:
- 32000+ assets
- 8000+ XML files
- 3000+ W3X files
- **Full index:** ~3 minutes
- **Cached startup:** ~40 seconds to validate cached data and rebuild the in-memory index
Measurements were taken on a mechanical hard drive. Actual performance depends on hardware and project structure.
## Getting Started
1. Install the extension from the VS Code Marketplace.
2. Open your RA3 Mod project folder in VS Code.
3. Make sure the workspace contains `Data/Mod.xml`, `Data/additionalmaps/mapmetadata_*.xml`, or a `*.babproj` file.
4. Set the RA3 Mod SDK path if necessary — the extension can auto-detect an
installed SDK from the Windows registry, or you can pick the folder
manually. Leaving it empty runs the extension in project-only mode.
5. Open any `*.xml` file and start editing.
The extension automatically detects RA3 Mod workspaces and starts indexing in
the background. When the SDK is missing it shows a status-bar hint and offers
to configure the path (once per session).
## Configuration
| Setting | Default | Description |
| -------------------------------------- | ---------------------- | ------------------------------------------------------------------------------- |
| `ra3modxml.sdkPath` | *(empty)* | Path to the RA3 Mod SDK; empty disables vanilla SDK features (project-only mode) |
| `ra3modxml.indexSageXml` | `true` | Index vanilla XML definitions from the SDK's `SageXml` directory |
| `ra3modxml.reportUnresolvedReferences` | `warning` | Diagnostic level for unresolved references: `warning`, `information`, or `none` |
| `ra3modxml.diagnoseUnknownElements` | `true` | Report unknown XML elements and attributes |
| `ra3modxml.definitionMode` | `all` | Choose between project and vanilla definitions when navigating to references |
| `ra3modxml.additionalDataSearchPaths` | `[]` | Additional directories searched for `DATA:` paths |
If the SDK is installed, the extension detects it from the registry and offers
it with one click; otherwise you can set `ra3modxml.sdkPath` manually or use
the `RA3 Mod XML: Configure SDK path…` command.
## Commands
* `RA3 Mod XML: Re-index workspace`
* `RA3 Mod XML: Show index report`
* `RA3 Mod XML: Clear caches and rebuild`
* `RA3 Mod XML: Configure SDK path…`
* `RA3 Mod XML: Show cache report`
* `RA3 Mod XML: Find unreferenced assets…`
* `RA3 Mod XML: Find unreferenced assets of this type`
## Requirements
* Visual Studio Code
* A Red Alert 3 Mod SDK installation for full schema and vanilla asset support
* A RA3 Mod project containing `Data/Mod.xml`, `Data/additionalmaps/mapmetadata_*.xml`, or a `*.babproj` file
## Development
```powershell ```powershell
npm install npm install
npm run generate-model # 从 SDK XSD 重新生成 src/model/schema-model.json
npm test # 单元测试(tsc + node --test npm run generate-model # Generate the runtime schema model from the SDK XSD
npm run build # esbuild 打包到 dist/ npm test # Run unit tests
npm run package # 生成可安装的 .vsix npm run build # Build the extension
npm run package # Create a .vsix package
``` ```
测试夹具:`test/fixtures/minimod`(含 include、重复 ID、同名不同类型 ID、manifest 回退等场景)。 Test fixtures are located in `test/fixtures/minimod` and cover scenarios including includes, duplicate IDs, same-name/different-type IDs, and manifest fallback.
## 架构 ## Architecture
``` The extension is organized around a VS Code-independent parsing and indexing core:
```text
src/ src/
extension.ts 激活入口与 provider 注册 extension.ts
workspace.ts 项目检测、索引生命周期、状态栏 projectRoot.ts
settings.ts 配置读取(sdkPath、definitionMode 等) workspace.ts
settings.ts
language/ language/
xmlParser.ts 带源码偏移的轻量 XML 解析器(容错、行尾恢复) xmlParser.ts
context.ts 补全上下文分析 context.ts
typeContext.ts 上下文感知元素类型解析 typeContext.ts
semanticTokens.ts 语义 token 兜底高亮(纯 TS) semanticTokens.ts
model/ model/
schemaModel.ts XSD 模型运行时(schema-model.json / asset-types.json 由 tools 生成) schemaModel.ts
schema-model.json # Generated XSD model, bundled with the extension
asset-types.json # Generated AssetType hash table, bundled with the extension
indexer/ indexer/
includeResolver.ts Include 路径解析(纯 TS,移植 check_duplicate_ids.py includeResolver.ts
existence.ts 文件集存在性快照(目录枚举 Set,替代逐路径 statSync existence.ts
manifestParser.ts .manifest 二进制解析(移植 OpenSAGE ManifestFile.cs manifestParser.ts
fileScanner.ts 目录扫描与 Include source 候选 fileScanner.ts
refs.ts 引用目标解析(按引用类型过滤)+ “设计上可被引用类型”判定 refs.ts
referenceIndex.ts 引用记录 → 反向引用索引(定义 → 引用位置)+ 未引用报告 referenceIndex.ts
xpointer.ts xi:include xpointer 子集解析(纯 TS xpointer.ts
logicalTree.ts 当前文档逻辑树(xi:include 拼接、局部作用域) logicalTree.ts
localScope.ts 文档局部索引 overlay(自身链 + include 链) localScope.ts
shallowScan.ts .w3x 等大体积美术资产顶层浅扫描(纯 TS,不建 DOM) shallowScan.ts
records.ts 每文件紧凑索引记录(资产/Define/Include/xi/引用 + 行号偏移) records.ts
caches.ts 跨重建持久缓存(DocumentCache / IndexRecordsCache / caches.ts
IncludeResolveCache+ 失效纪元 InvalidationsEpoch diskCache.ts
diskCache.ts 跨会话磁盘缓存(gzip JSON、原子写、多信号 stat 校验) indexer.ts
indexer.ts 工作区索引器(后台、缓存、记录驱动重建、分阶段发布) types.ts
features/ completion / hover / navigation / references / codeLens /
unreferenced / diagnostics / semanticTokens features/
syntaxes/ TextMate 注入语法 completion.ts
tools/ XSD → 模型、AssetType 枚举提取 hover.ts
navigation.ts
references.ts
codeLens.ts
unreferenced.ts
diagnostics.ts
semanticTokens.ts
syntaxes/
ra3modxml.tmLanguage.json # Injected domain grammar (keeps the built-in XML grammar)
tools/
xsd-to-model.mjs # Generates schema-model.json from the SDK XSD
extract-asset-types.mjs # Extracts AssetType hashes from OpenSAGE
``` ```
解析/索引核心不依赖 VS Code API,可被其他工具复用(见 `docs/plan.md` 的远期目标:搜索与索引复用)。 ## References
## 参考 * OpenSAGE `ManifestFile.cs` — manifest format reference
- 领域说明与需求:`docs/requirements.md`
- 调研与设计决策:`docs/plan.md`
- 问题分析与修复记录:`docs/analysis-issues.md`
- 引用计数 / 语义 FAR / 未引用资产功能设计:`docs/features-reference-counts.md`
- Manifest 格式参考:OpenSAGE `src/OpenSage.Game/Data/StreamFS/ManifestFile.cs`(本仓库 `OpenSAGE/` 子目录,commit `d45d361`
+225
View File
@@ -0,0 +1,225 @@
> This site is not endorsed by or affiliated with Electronic Arts, or its licensors. Trademarks are the property of their respective owners. Game content and materials copyright Electronic Arts Inc. and its licensors. All Rights Reserved.
>
> RA3 Mod XML is an unofficial fan-made tool. It requires a separately installed RA3 Mod SDK.
[English](README.md) | [**中文**](README.zh-CN.md)
# RA3 Mod XML
一款面向 **《命令与征服:红色警戒 3》** XML 模组的 VS Code 扩展,提供 **IntelliSense、导航、引用追踪与诊断** 能力。
它理解 RA3 Mod SDK 的 XML schema、资产类型、引用、include 以及原版游戏数据——编辑大型模组的体验会更接近真正的编程语言。
<table>
<tr>
<td align="center">
<strong>智能补全</strong><br>
<a href="https://ra3modxml-images.ratotal.workers.dev/enum_completion.gif">
<img src="https://ra3modxml-images.ratotal.workers.dev/enum_completion.gif" alt="RA3 XML 智能补全" width="100%">
</a>
</td>
<td align="center">
<strong>转到定义</strong><br>
<a href="https://ra3modxml-images.ratotal.workers.dev/navigation.gif">
<img src="https://ra3modxml-images.ratotal.workers.dev/navigation.gif" alt="RA3 XML 导航" width="100%">
</a>
</td>
</tr>
</table>
<p align="center">
<strong>引用感知补全</strong><br>
<a href="https://ra3modxml-images.ratotal.workers.dev/ref_completion.gif">
<img src="https://ra3modxml-images.ratotal.workers.dev/ref_completion.gif" alt="RA3 XML 引用补全" width="80%">
</a>
</p>
## 功能特性
### 智能补全
基于 RA3 XML schema 与项目数据,提供上下文感知的补全。
* 基于 RA3 XSD 的元素与属性
* 必填属性、类型、文档与默认值
* 资产引用,如 `Weapon``CommandSet``inheritFrom`
* 枚举值与标志位列表,如 `KindOf``Surfaces`
* 文本内容元素中的资产 ID,如 `<CreateObject>``<RequiredUpgrade>`
* `DATA:``ART:``AUDIO:` 路径
* 编辑标志位列表时的自动续写
引用补全是类型感知的,因此资产 ID 只会在其类型有效的位置被提示。
### 语法高亮
在保留内置 XML 语法的基础上,额外高亮 RA3 特有的结构。
### 导航与引用
直接在编辑器中浏览模组的资产关系图。
* 资产引用的**转到定义**Ctrl+Click
* 基于语义引用信息的**查找所有引用**
* **引用 CodeLens**:显示资产被引用了多少次
* 元素、属性、引用与 `$DEFINE` 的悬停信息
* `Include``xi:include` 的 Ctrl+Click 导航
* 顶层资产与 `$DEFINE` 的文档大纲
### 诊断
在编辑时发现常见的模组编写错误。
* XML 语法错误
* 未知元素与属性
* 缺失或重复的资产 ID
* 无法解析的资产引用
* 引用了错误的资产类型
* 未定义的 `$DEFINE`
### 项目分析
扩展可以分析整个工作区,而不仅仅是当前打开的文件。
**查找未引用的资产** 会列出工作区中任何地方都未被引用的项目资产,帮助识别过时或意外未使用的定义。
运行:
`RA3 Mod XML: Find unreferenced assets…`
你也可以使用编辑器右键菜单查找当前资产类型的未引用资产。
### 原版 SDK 集成
扩展可以使用 **RA3 Mod SDK** 中的资产定义,让原版游戏资产参与补全、悬停、导航与诊断。
当对应的 SDK 数据可用时,支持 `<Include type="reference">` 引用的 manifest,例如 SDK `builtmods` 目录下的 `static.manifest``global.manifest``audio.manifest`
### 大型模组支持
工作区索引在后台运行,并使用持久化缓存,避免每次启动 VS Code 时都重建全部数据。
扩展已在大型 RA3 模组日冕 Mod 上测试:
- 32000+ 资产
- 8000+ XML 文件
- 3000+ W3X 文件
- **完整索引:** 约 3 分钟
- **缓存启动:** 约 40 秒校验缓存数据并重建内存索引
以上数据在机械硬盘上测得。实际性能取决于硬件与项目结构。
## 开始使用
1. 从 VS Code Marketplace 安装扩展。
2. 在 VS Code 中打开 RA3 Mod 项目文件夹。
3. 确保工作区包含 `Data/Mod.xml``Data/additionalmaps/mapmetadata_*.xml``*.babproj` 文件。
4. 如有必要,配置 RA3 Mod SDK 路径——扩展可以从 Windows 注册表自动检测已安装的
SDK,也可以手动选择文件夹;留空则进入仅项目模式。
5. 打开任意 `*.xml` 文件开始编辑。
扩展会自动检测 RA3 Mod 工作区并在后台开始索引。当缺少 SDK 时,状态栏会给出提示,
并在每个会话中提供一次一键设置入口。
## 配置
| 设置 | 默认值 | 说明 |
| ----------------------------------- | ----------------------- | -------------------------------------------------------------------------------- |
| `ra3modxml.sdkPath` | *(空)* | RA3 Mod SDK 的路径;留空则禁用原版 SDK 功能(仅项目模式) |
| `ra3modxml.indexSageXml` | `true` | 索引 SDK `SageXml` 目录中的原版 XML 定义 |
| `ra3modxml.reportUnresolvedReferences` | `warning` | 无法解析引用的诊断级别:`warning``information``none` |
| `ra3modxml.diagnoseUnknownElements` | `true` | 报告未知的 XML 元素与属性 |
| `ra3modxml.definitionMode` | `all` | 导航引用时选择项目定义或原版定义 |
| `ra3modxml.additionalDataSearchPaths` | `[]` | 额外的 `DATA:` 路径搜索目录 |
如果已安装 SDK,扩展会从注册表检测到它并提供一键设置;也可以手动设置
`ra3modxml.sdkPath`,或使用 `RA3 Mod XML: Configure SDK path…` 命令。
## 命令
* `RA3 Mod XML: Re-index workspace`(重新索引工作区)
* `RA3 Mod XML: Show index report`(显示索引报告)
* `RA3 Mod XML: Clear caches and rebuild`(清除缓存并重建)
* `RA3 Mod XML: Configure SDK path…`(配置 SDK 路径)
* `RA3 Mod XML: Show cache report`(显示缓存报告)
* `RA3 Mod XML: Find unreferenced assets…`(查找未引用的资产…)
* `RA3 Mod XML: Find unreferenced assets of this type`(查找此类型的未引用资产)
## 环境要求
* Visual Studio Code
* 安装 Red Alert 3 Mod SDK,以获得完整的 schema 与原版资产支持
* 包含 `Data/Mod.xml``Data/additionalmaps/mapmetadata_*.xml``*.babproj` 文件的 RA3 Mod 项目
## 开发
```powershell
npm install
npm run generate-model # 从 SDK XSD 生成运行时 schema 模型
npm test # 运行单元测试
npm run build # 构建扩展
npm run package # 打包 .vsix
```
测试夹具位于 `test/fixtures/minimod`,覆盖 include、重复 ID、同名不同类型 ID 以及 manifest 回退等场景。
## 架构
扩展围绕一个与 VS Code 无关的解析与索引核心组织:
```text
src/
extension.ts
projectRoot.ts
workspace.ts
settings.ts
language/
xmlParser.ts
context.ts
typeContext.ts
semanticTokens.ts
model/
schemaModel.ts
schema-model.json # 生成的 XSD 模型,随扩展打包
asset-types.json # 生成的 AssetType 哈希表,随扩展打包
indexer/
includeResolver.ts
existence.ts
manifestParser.ts
fileScanner.ts
refs.ts
referenceIndex.ts
xpointer.ts
logicalTree.ts
localScope.ts
shallowScan.ts
records.ts
caches.ts
diskCache.ts
indexer.ts
types.ts
features/
completion.ts
hover.ts
navigation.ts
references.ts
codeLens.ts
unreferenced.ts
diagnostics.ts
semanticTokens.ts
syntaxes/
ra3modxml.tmLanguage.json # 注入式领域语法(保留内置 XML 语法)
tools/
xsd-to-model.mjs # 从 SDK XSD 生成 schema-model.json
extract-asset-types.mjs # 从 OpenSAGE 提取 AssetType 哈希
```
## 参考
* OpenSAGE `ManifestFile.cs` — manifest 格式参考
+252 -63
View File
@@ -325,69 +325,6 @@ hover 同时显示 `No matching definition of the expected declared type...`。
--- ---
## 十、问题分析(第五轮,2026-08-01):`xi:include` 的 `href`/`xpointer` 误报未知属性
### 问题
AttachTest `Allied Vehicle\Guardian Tank\GameObject.xml` 第 249 行附近:
```xml
<xi:include
href="DATA:Includes/HeadlightDraw2.xml"
xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:HeadlightDraw2/child::*)"/>
```
报两条 `Unknown attribute "href" / "xpointer" for <include>``unknown-attribute`),
hover 同时显示 `Unknown attribute for this element.`。
这个元素属于 **W3C XInclude 命名空间**`xmlns:xi="http://www.w3.org/2001/XInclude"`),
并不是 EA `uri:ea.com:eala:asset` XSD 的一部分。同一行在第二轮“问题 C”处理过
(嵌套 `xi:include` 的索引与导航),但那轮没有覆盖 unknown-attribute 诊断,属于遗留缺口。
### 根因
诊断的属性校验没有像元素校验那样排除外来命名空间:
- 元素校验已有 `!el.name.startsWith("xi:")` 守卫(所以 `<include>` 本身不报 unknown element);
- 属性校验只跳过 `xmlns*` / `xai:` / `xi:` 前缀的属性名,而 `href`、`xpointer` 是不带
前缀的普通属性名;
- `<xi:include>` 解析类型为 null(XSD 模型不含该元素),knownAttrs 为空 → 任何属性
都被判为 unknown。
### 修复
1. `schemaModel` 新增两个纯函数:
- `isXsdElementName``xi:` 前缀元素不属于 EA XSD 模型;
- `isXsdAttributeName`:EA XSD 属性不带命名空间前缀,带前缀(`xai:`、`xi:`、
`xlink:`、`xml:`、`xsi:`、`xmlns:*`)的都是命名空间机制,不做 schema 校验。
2. `diagnostics``xi:` 前缀元素整体跳过 schema 校验(元素与属性都不再误报);
前缀属性名统一跳过。
3. `hover``xi:include` 元素/属性给出 XInclude 说明;`href` 值悬停像
`<Include source>` 一样解析目标文件(Ctrl+点击跳转此前已可用)。
### 验证
- 真实文件全量扫描:0 未知元素、0 未知属性(修复前 `href`/`xpointer` 两条必现);
- 新增测试:`isXsdElementName` / `isXsdAttributeName` 断言;`xi:include` 解析类型为
null 且不参与校验;全量 39/39 通过。
### 后续(架构方向,待确认)
用户提出“先展开 `xi:include`(类比 C++ 宏展开),再处理 mod XML 解析”。该方向与第二轮
遗留的“虚拟合并”开放项一致,设计要点:
- 构建**逻辑树**而非文本拼接:把目标文件选中内容(`xpointer` 子集)作为子节点拼入父
元素,节点保留源文件与原始偏移,避免文本级拼接导致的偏移断裂;
- 展开范围:`xi:include` 与 EA `<Include type="all">`(内容合并);`instance` /
`reference` 是可见性 / 编译产物语义,不拼树;`inheritFrom` + `joinAction` 是属性级
继承合并,不是宏展开;
- 收益:跨 include 的上下文类型解析、包含内容的结构校验、以及后续“GameObject 内模块
id 局部作用域”(HeadlightDraw2 的模块也是该 GameObject 的模块);
- 风险:include 环 / 深度限制、大文件性能、`xpointer` 仅支持现有子集形式
`/n:Name/child::*`)。
---
## 十、问题分析(第五轮,2026-08-01):`xi:include` 的 `href` / `xpointer` 被误报为未知属性 ## 十、问题分析(第五轮,2026-08-01):`xi:include` 的 `href` / `xpointer` 被误报为未知属性
### 问题 ### 问题
@@ -1624,3 +1561,255 @@ WarheadTemplate="..."` 引用。该问题在移动硬盘重连 + 重新打开工
只要文件被打开并触发 CodeLens / FAR,不一致就会被检测并定向修复。 只要文件被打开并触发 CodeLens / FAR,不一致就会被检测并定向修复。
版本 **0.1.17 → 0.1.18**。 版本 **0.1.17 → 0.1.18**。
---
## 二十五、问题分析(2026-08-07):属性补全换行误判与补全项重复
### 现象
1. 属性名补全的“自动换行”在属性已经位于自己单独一行时仍会再插一个换行:
例如在 one-per-line 标签中间插入新属性(光标行已有半截属性名,后面还有
其他属性)时,接受补全会多出一个空行。正确规则是:只有“同一行上的第二个
属性”才换行;光标已经在自己单独一行时不应换行。
2. 属性值补全出现两条完全相同的值,例如 `ProjectileNugget@WarheadTemplate`
中 `AlliedCommandoDesertEaglesWarhead` 出现两遍。
### 根因
1. `attributeInsertLayout` 判断“是否已在新行”时用的是标签内**最后一个完整
属性**的结束位置,而没考虑它是否位于光标之前。在 one-per-line 标签中间
插入时,光标后面的属性会让 `alreadyOnNewLine` 误判为 false,于是再次插入
`\n`。同理,在第一个属性之前的新行上补全也会因“面前没有完整属性”而误换行。
2. `assetIdItems` 只按 `(类型, id, 文件, 行)` 去重。同一个 ID 可以同时出现在
当前文档局部 overlay(未保存文本的行号与磁盘不同)与全局索引中,也可以
同时出现在项目 XML 与编译 manifest 中——不同文件/行号不会被去重,于是同一
个值出现两条。
### 修复
1. **换行判定只看光标之前的属性**:`attributeInsertLayout` 先过滤出结束位置
在光标之前的完整属性,再判断光标是否与它们同处一行;没有前置属性时,用
元素名与光标之间是否有换行判断是否已在自己一行。已在新行时不再插入换行,
one-per-line 风格下仍用规范缩进替换当前行空白;同一行第二个属性仍按原有
规则换行;元素名同一行补首个属性时(文件为 one-per-line)仍保留换行行为。
2. **值补全按 id 去重**`assetIdItems` 改为先按 `(类型, id, 文件, 行)` 去掉
同一份定义,再按 id(大小写不敏感)合并为一个补全项,保留分数最高的定义
local > project > sdk/manifest),其余定义在文档说明中列出
(“Also defined as …”)。`defineItems` 同步改为按 define 名去重,局部定义
优先。
### 验证(169 → 173 全绿)
- one-per-line 标签中间插入:不再插入换行,range 覆盖当前行空白与半截属性名;
- 第一个属性之前的新行补全:不换行,按规范缩进对齐;
- 元素名同一行补首个属性(one-per-line 文件):仍换行;
- 同一 ID 同时存在于局部 overlay / 全局索引 / manifest:只出现一个补全项,
文档中列出其它定义位置。
版本 **0.1.19 → 0.1.20**。
---
## 二十六、问题分析(2026-08-08):磁盘缓存校验阻塞与日志可观测性
### 现象
清缓存 / 冷启动时“validating cache”耗时很长,但输出通道只有构建开始和结束
两行,看不到校验花了多久;构建日志显示 `done in 1.6s`、索引已完整,但 VS Code
状态栏仍停留在“indexing”。
### 根因
1. `seedRecordsFromDisk` 在构建前 `await loadValidated()`,对全部 8,976 条缓存
记录逐文件 stat(机械盘可达数十秒),且这段耗时没有任何日志;构建计时从
`runBuild` 才开始,日志里自然看不到。
2. `publishIndex` 发布最终快照时 `state.building` 仍为 true`updateStatusBar()`
只会显示“indexing…”;`finally` 把 `building` 置 false 后没有再次刷新状态栏,
于是状态栏一直停在 indexing。
### 修复
1. **校验仍是快速构建的前置条件**:`DiskRecordsCache` 拆为 `load()`(读 +
gunzip + JSON,快)与 `validate()`(逐文件 stat)。冷启动**先校验后构建**:
只有 stat 与当前磁盘一致的记录才播种进共享 recordsCache,过期条目在构建时
重新读取。曾尝试“先快速构建、构建后后台校验”,但快速路径
`trustUnchanged=true`)会直接信任未校验的 recordsCache 条目,可能发布
过期 index;后台校验只能事后发现 stat 可见的变化,stat 不可见变化(同
size/mtime/ctime 的重写)无法事后发现。同时并发校验会与索引器抢机械盘
I/O,实测把信任构建从 1.6s 拖到 25swalk 17s / candidates 6.3s),因此
该方案已放弃,恢复“校验通过才允许快速构建”的不变量。
进一步优化为**分阶段校验**:full XML 记录先校验(约一半,~15s),构建随即
开始并发布 phase Ashallow 美术记录先以 `validated:false` 预播种,phase A
的 `readDocument` 只把它当“待扫描登记”用(不消费记录、不 stat 2.6GB 模型),
在 phase A 发布回调里校验并标记 `validated:true`phase B 直接命中缓存。
结果:phase A 可用时间从 ~34s 降到 ~16s,最终完成时间基本不变,正确性不变。
2. **校验进度可视化**`validate()` 增加 `onProgress` 回调,状态栏显示
`validating cache N/M…` / `validating art cache N/M…`,输出通道每校验
1000 条输出一行进度,避免长时间无反馈。
3. **日志补齐计时**:新增 `[disk-cache] loaded … in Xs`、
`[disk-cache] validated … in Xs (dropped=N)`、`[disk-cache] saved … in Xs`
以及 `[build] wall time …`(含缓存加载的总耗时);`DiskCacheLoadStats`
增加 `loadMs` / `validateMs`cacheReport 与状态栏 tooltip 一并展示;
输出通道所有日志行统一自动加本地 `HH:mm:ss.mmm` 时间戳,方便对照
watcher / build / disk-cache 事件的先后顺序。
4. **状态栏修复**`finally` 中 `building = false` 后调用 `updateStatusBar()`
构建完成后不再卡在 indexing;校验阶段状态栏直接显示
`validating cache…` / `validating art cache…` 及计数。
### 验证(173 → 176 全绿)
- `load()` 不做 stat 校验,`validate()` 返回 `kept` / `invalidKeys` 与
validated / dropped / validateMs 统计,`onProgress` 单调递增到总数;
- 变更 / 缺失条目被报告并触发重建,有效条目保留;
- 未校验的 shallow 条目在 phase A 只登记不消费,phase B stat 校验后命中缓存
不再重扫;
- 全量 176 个测试通过,esbuild 产物已更新。
---
## 二十七、问题分析(2026-08-10):CodeLens 与 FAR 定义合并路径不一致
### 现象
Corona 项目中 `WeaponTemplate` 不再显示 CodeLens 引用计数(或显示 0),但
右键菜单 Find All References 仍能查到引用。
### 根因
CodeLens 只查“全局 index 中当前文件这一条定义”的引用桶
`referenceSitesForDefinition`),而 FAR 会把**当前文档的 local overlay** 与
全局同名定义合并后再收集引用(`definitionsForReference` +
`collectReferenceSites`)。当文件未进全局 include 流(standalone / 片段文件)
或定义只存在于 local overlay 时,FAR 能通过全局同名定义找到引用,CodeLens
却按本地文件 key 精确查表得到 0/空,表现就是“FAR 可用、CodeLens 消失”。
### 修复
1. `Ra3CodeLensProvider` 改为 async,通过 `ws.getCodeLensScope(document)` 取
merged index(当前文档 local overlay + 全局 index),并用与 FAR 相同的
`definitionsForReference` + `collectReferenceSites` 计算计数;
2. `showReferencesForDef`(点击 lens 打开的 references peek)同步改为同一
逻辑,保证显示的数字与打开的 peek 严格一致;
3. CodeLens 的 records-desync 自愈改用 `recordsSyncSurfaceFor(document)`
与 FAR 一样按文档所属项目定向修复,而不是活动项目。
### 刷新体验与 0 显示
- CodeLens 使用轻量 `getCodeLensScope`(只解析当前文档 + 挂全局 index,不
展开 include 链),快照发布后的 `editor.action.codeLens.refresh` 即时返回
新计数;
- Provider 实现 `onDidChangeCodeLenses` 事件,`onIndexUpdate` 在每次快照
发布时主动 fire,VS Code 立即重新查询(不依赖 refresh 命令是否生效);
- **全局重试定时器**`onBuildStart` 启动一个 2s 间隔的定时器,只要
`ws.isBuilding()` 为 true 就重新 fire CodeLens 刷新;构建结束即停止。
用于兜底 VS Code 对单次 refresh 事件的合并/延迟,保证 phase A 的计数
不会等到 final 才上屏。定时器为全局单实例、仅构建期存在,不随文档数
放大;
- 日志:每次快照发布记录 `[codelens] refresh (project/phase/assets/...)`
首个快照前每个文档只记一次 `[codelens] suppressed`scope 异常和超过
250ms 的慢 provider 调用也会记录。refresh 事件频率等于快照发布次数
(低频),不会按每次 VS Code 查询记录,避免大项目刷屏;
- 在**第一个全局快照发布前**`stats.indexedFiles === 0` 的本地-only index
不渲染任何 CodeLens,避免冷启动期间满屏误导性的 0 references
- 一旦存在真实快照,“0 references”仍按设计显示(参考目标类型 0 也显示,
点击可打开空 peek 作为“未引用”信号)。
### 重新评估(2026-08-10
- **与 FAR 的一致性**:CodeLens 只渲染当前文档的顶层资产,cheap scope 的
overlay 已覆盖这些资产;反向索引的引用站点挂在**每个匹配定义**上,因此
当前文档定义 + 全局同名定义的并集与 FAR 的 full-scope 并集在计数上一致。
仅存在于 include 链、且不在全局 index 中的定义没有反向引用桶,full scope
也不会多出站点,故不构成计数差异。
- **0 显示语义**:按文档需求“参考目标类型 0 也显示”,隐藏逻辑收窄为
“尚无全局快照”,避免小项目 phase A 后仍被隐藏。
- **已知边界**CodeLens 计数与“从该 id 发起 FAR”完全一致,因此同名 id
跨类型时会把各类型定义的反向站点合并计数——这是 FAR 既有语义,CodeLens
与其保持一致,不再按类型收窄。
### 验证(175 → 178 全绿)
- CodeLens 与 FAR 共享定义合并路径后,standalone 文件中 WeaponTemplate
的计数与 FAR 结果一致;
- 点击 lens 打开的 peek 与计数一致;
- 尚无全局快照时不渲染 CodeLens,快照存在后 0 references 仍显示;
- `onDidChangeCodeLenses` 在 refresh 时触发;测试 shim 不再提供
`indexForDocument`/`activeIndex`,若实现回退到旧的 index 查找方式会直接
测试失败;
- 原有 manifest 源引用归并、0 引用显示、desync 自愈测试全部保持。
---
## 二十八、问题分析(2026-08-10):manifest 源地址被 mod 同名 DATA 路径遮蔽
### 现象
Corona `Data\Allied\Units\AlliedCommandoTech1.xml` 中
`Template="AlliedCommandoDesertEagles"` 的 Ctrl+点击有两个候选:
- mod 定义:正常;
- 原版 manifest 定义:`manifestSource` 是 `DATA:globaldata/weapon.xml`
但它没有跳到 `SageXml\globaldata\weapon.xml`,而是打开 mod 自己的
`Data\globaldata\weapon.xml`915 字节的 Include 汇总文件,不含该 id)。
### 根因
`manifestSource` 记录的是**原版 manifest 编译时该资产的源地址**,不是
“当前 mod 按 BAB Include 规则会命中哪个文件”。旧代码在
`src/features/navigation.ts` 的 `assetDefLocation()` 里用
`resolveSource(src, null, searchPathsFor(idx))` 解析它,而
`searchPathsFor(idx)` 是当前项目的 BAB 搜索顺序——项目 `Data` 在
`SageXml` 之前。于是只要 mod 同名遮蔽了 `DATA:globaldata/weapon.xml`
manifest 候选就会被劫持到 mod 文件。
同一语义混淆也存在于 `src/indexer/referenceIndex.ts` 的
`referenceSitesForDefinition()`:它用当前项目搜索路径判断 manifest 定义
是否对应某个源码文件,同样会被遮蔽路径带偏。
### 实测证据
- `static.manifest` 中确有
`WeaponTemplate:AlliedCommandoDesertEagles`
`sourceFileName = "DATA:globaldata/weapon.xml"`
- `Data\globaldata\weapon.xml`mod)与
`SageXml\globaldata\weapon.xml`(原版)都存在,后者 277 KB,
该 id 在第 1093 行;
- 当前 BAB 顺序解析返回 mod 文件;只按 `[SDK根, SDK\SageXml]` 解析则返回
`SageXml\globaldata\weapon.xml`
- 对 `static.manifest` 全部 DATA 源扫描:1874 个都存在于 `SageXml`
其中 172 个被 Corona `Data` 同名遮蔽。说明这是普遍现象,不是个别文件。
### 修复
1. `src/indexer/includeResolver.ts` 新增 `buildVanillaSearchPaths(sdkDir)`
DATA 只搜 `[SDK根, SDK\SageXml]`ART / AUDIO 同理只搜 SDK 目录
(当前 SDK 基本没有 art/audio 源码,保持“找不到就 manifest-only”)。
2. `src/features/navigation.ts` 的 manifest 定义跳转改用 vanilla-only 路径;
普通 `<Include>` / `xi:include` 仍使用当前项目 BAB 顺序,不受影响。
3. `src/indexer/referenceIndex.ts` 的 manifest 源归并同步改用 vanilla-only
路径,避免把 manifest 引用错误归并到 mod 同名文件。
4. 边界处理:
- `SageXml` 源文件缺失(用户删除/改名):manifest 候选保持
manifest-only,不跳到 mod 遮蔽文件;
- 源文件存在但 id 已被移除(用户修改 SageXml):跳到该文件顶部,
不做虚假的精确定位;
- 只要 `SageXml` 中仍有该 id,就精确跳转(与既有问题 C 的行为一致)。
- 当前实现不读取 `ra3modxml.indexSageXml`manifest 导航始终尝试解析
SageXml 源码(这只影响“跳到哪里”,不影响是否把 SageXml 纳入索引);
如需让导航也跟随该设置,可后续加开关。
### 测试(178 → 184 全绿)
- `includeResolver.test.mjs``buildVanillaSearchPaths` 结构断言;mod 同名
遮蔽时普通 BAB 解析命中 mod、vanilla-only 命中 SageXmlvanilla 源缺失时
即使 mod 遮蔽也返回 null;
- `referenceIndex.test.mjs`manifest 源归并只命中 SageXml 文件,同名 mod
文件不继承引用站点;
- `contentFeatures.test.mjs`Ctrl+点击 manifest 定义命中 SageXml 而非 mod
遮蔽文件;SageXml 源缺失时不跳 mod;文件存在但 id 被删时降到文件顶部。
> 备注:ART/AUDIO 源映射按用户意见不作为本轮目标;`buildVanillaSearchPaths`
> 已包含对应 SDK 目录,将来若有源码可直接复用。
+4 -3
View File
@@ -86,9 +86,10 @@ ModIndex.references Map<定义 key, ReferenceSite[]>
- 点击执行 `ra3modxml.showReferences``editor.action.showReferences` - 点击执行 `ra3modxml.showReferences``editor.action.showReferences`
打开 references peek,结果与计数完全一致(不含定义本身); 打开 references peek,结果与计数完全一致(不含定义本身);
- 计数除了当前定义自己的反向索引桶,还并入“manifestSource 可解析到当前 - 计数除了当前定义自己的反向索引桶,还并入“manifestSource 可解析到当前
文件”的 manifest 定义桶:manifest 资产有对应 SageXml 源码时,引用直接 文件”的 manifest 定义桶:`manifestSource` 按 vanilla-only 搜索路径
视作 SageXml 源码对该 asset 的引用(Go to Definition 同样把 manifest SDK 根 + `SageXml`)解析,mod 同名 DATA 路径不会被视为源码;manifest
定义映射到 SageXml 源码); 资产有对应 SageXml 源码时,引用直接视作 SageXml 源码对该 asset 的引用
Go to Definition 同样把 manifest 定义映射到 SageXml 源码);
- 索引重建完成后自动 `editor.action.codeLens.refresh`,计数不会停留在旧值。 - 索引重建完成后自动 `editor.action.codeLens.refresh`,计数不会停留在旧值。
## 五、未引用资产 ## 五、未引用资产
+78 -7
View File
@@ -1,6 +1,6 @@
# 调研结论与实施计划(已按最新代码同步更新) # 调研结论与实施计划(已按最新代码同步更新)
> 说明:本文档随实现演进持续同步。最近一次同步(2026-08-05)对齐了实现过程中新增的模块与设计变更:BAB 精确搜索路径、manifest 类型/ID 推导、上下文感知元素类型、属性级 refType / Poid 局部引用(`id` 定义点)、精确跳转范围、嵌套 `xi:include`、注入式语法高亮、bit-flag 列表补全(空格触发 / 排除已用 / 追加模式)、simple-content 元素文本引用(补全 / hover / 跳转 / 诊断 / Find All References)、语义引用索引 / CodeLens 引用计数 / 未引用资产命令等。 > 说明:本文档随实现演进持续同步。最近一次同步(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 路径遮蔽)等。
## 一、调研结论(带证据) ## 一、调研结论(带证据)
@@ -69,7 +69,10 @@
``` ```
src/ src/
extension.ts 激活入口(provider 注册、索引调度、诊断调度) extension.ts 激活入口(provider 注册、索引调度、诊断调度)
workspace.ts 项目检测(Data/Mod.xml / mod.babproj)、索引生命周期、状态栏、重建防抖 projectRoot.ts 项目根发现(向上 / 容器向下 / 单文件,Data/Mod.xml、
mapmetadata_*.xml、*.babproj 标记,纯 TS 可单测)
workspace.ts 多项目状态(按文档就近选项目、惰性索引、全局串行构建队列、
共享缓存、按项目磁盘缓存、watchers、状态栏、重建防抖)
settings.ts 配置读取(sdkPath、indexSageXml、definitionMode 等) settings.ts 配置读取(sdkPath、indexSageXml、definitionMode 等)
language/ language/
xmlParser.ts 带源码偏移的轻量 XML 解析器(格式错误定位、容错) xmlParser.ts 带源码偏移的轻量 XML 解析器(格式错误定位、容错)
@@ -81,7 +84,8 @@ src/
schema-model.json 由 tools/xsd-to-model.mjs 生成 schema-model.json 由 tools/xsd-to-model.mjs 生成
asset-types.json 由 tools/extract-asset-types.mjs 生成(TypeId 哈希→类型名) asset-types.json 由 tools/extract-asset-types.mjs 生成(TypeId 哈希→类型名)
indexer/ indexer/
includeResolver.ts Include 路径解析(纯 TSBAB /data /art /audio 顺序) includeResolver.ts Include 路径解析(纯 TSBAB /data /art /audio 顺序)+
manifest 源 vanilla-only 解析(SDK 根 + SageXml
existence.ts 文件集存在性快照(目录枚举 Set,替代逐路径 statSync existence.ts 文件集存在性快照(目录枚举 Set,替代逐路径 statSync
manifestParser.ts .manifest 二进制解析 + 类型/ID 推导(纯 TS) manifestParser.ts .manifest 二进制解析 + 类型/ID 推导(纯 TS)
fileScanner.ts 目录遍历缓存 + Include source 候选收集 fileScanner.ts 目录遍历缓存 + Include source 候选收集
@@ -112,15 +116,24 @@ tools/
extract-asset-types.mjs OpenSAGE AssetType.cs → asset-types.json extract-asset-types.mjs OpenSAGE AssetType.cs → asset-types.json
test/ test/
fixtures/minimod 样例 Modinclude 各种情形、同名 ID、嵌套 xi:include、manifest 回退) fixtures/minimod 样例 Modinclude 各种情形、同名 ID、嵌套 xi:include、manifest 回退)
*.test.mjs 14 个测试文件(xmlParser / context / completion / semanticTokens / *.test.mjs 16 个测试文件(xmlParser / context / completion / semanticTokens /
includeResolver / manifestParser / indexer / schemaModel / refs / includeResolver / manifestParser / indexer / schemaModel / refs /
typeContext / manifestTypes / referenceIndex / codeLens / typeContext / manifestTypes / referenceIndex / codeLens /
referenceProvider referenceProvider / projectRoot / workspaceMulti
``` ```
### 关键设计决策 ### 关键设计决策
1. **语言激活范围**:不劫持 `*.xml`通过 `workspaceContains:**/Data/Mod.xml``**/*.babproj` 激活;语法高亮为**纯注入** grammar(不声明 `language`,避免覆盖内置 XML 语法)。 1. **语言激活范围与项目检测**:不劫持 `*.xml`激活条件含 `onLanguage:xml`
`workspaceContains:Mod.xml``additionalmaps/mapmetadata_*.xml`
`**/Data/Mod.xml``**/Data/additionalmaps/mapmetadata_*.xml``**/*.babproj`
语法高亮为**纯注入** grammar(不声明 `language`,避免覆盖内置 XML 语法)。
项目根通过 `src/projectRoot.ts` 发现:工作区文件夹向上最多 12 层、容器文件夹
向下浅扫最多 3 层(跳过 Data/Art/builtmods/.git 等)、打开的 XML 文件向上,
任一 `Data/Mod.xml``Data/additionalmaps/mapmetadata_*.xml``*.babproj`
标记命中即算项目根(大小写不敏感、最近命中者优先)。多项目按文档就近选择:
单个项目打开时立即建索引;容器/多项目时惰性建索引(活动文档所属项目先建,
其他在文档打开/首次请求时建),构建经全局串行队列避免并发写共享缓存。
2. **索引范围与默认值**:索引“项目 Data + additionalmaps + 沿 include 可达的 SageXml 原版源码”;SDK 路径默认 `C:\Apps\RA3-MODSDK-X`(可配置)。`reference` include 解析为 `builtmods` 下对应 manifest(惰性解析、按文件缓存),manifest 缺失/无效时回退到占位 XML。 2. **索引范围与默认值**:索引“项目 Data + additionalmaps + 沿 include 可达的 SageXml 原版源码”;SDK 路径默认 `C:\Apps\RA3-MODSDK-X`(可配置)。`reference` include 解析为 `builtmods` 下对应 manifest(惰性解析、按文件缓存),manifest 缺失/无效时回退到占位 XML。
**美术资产(.w3x**`<Include type="all">` / `ART:` 指向的 `.w3x`(及内容嗅探为 **美术资产(.w3x**`<Include type="all">` / `ART:` 指向的 `.w3x`(及内容嗅探为
XML 的未知扩展名文件)按其顶层资产入库(`W3DContainer` / `W3DMesh` / XML 的未知扩展名文件)按其顶层资产入库(`W3DContainer` / `W3DMesh` /
@@ -352,6 +365,62 @@ test/
对 stat 匹配的 full XML 做内容哈希校验;打开文档时比较 records 哈希, 对 stat 匹配的 full XML 做内容哈希校验;打开文档时比较 records 哈希,
不一致则定向 invalidate + `records-desync` 重建自愈;磁盘缓存 v2 → v3; 不一致则定向 invalidate + `records-desync` 重建自愈;磁盘缓存 v2 → v3;
测试 147 → 151;分析见 `docs/analysis-issues.md` 二十四。 测试 147 → 151;分析见 `docs/analysis-issues.md` 二十四。
24. [x] 多项目支持(2026-08-07):新增纯模块 `src/projectRoot.ts`(向上 12 层 /
容器向下 3 层 / 单文件向上,`Data/Mod.xml``mapmetadata_*.xml``*.babproj`
标记,大小写不敏感、最近优先、跳过 Data/Art/builtmods/.git 等目录);
`ModWorkspace` 改为多项目状态——按文档就近选项目、单项目立即索引 /
多项目惰性索引(活动文档所属项目先建)、全局串行构建队列保护共享缓存、
磁盘缓存按项目分文件、watcher 事件按路径归属调度、workspace 文件夹变化
重检、激活事件补 mapmetadata 与打开 Data 文件夹场景;测试 151 → 168。
25. [x] 属性补全换行判定与值补全去重(2026-08-07):`attributeInsertLayout`
只按光标之前的完整属性判断“是否已在新行”,one-per-line 标签中间插入或
首属性新行补全不再多插换行,同一行第二个属性仍按原规则换行;
`assetIdItems` 按 id 去重(局部 overlay / 全局索引 / manifest 同一 ID
只给一项,其余定义列入文档说明),`defineItems` 同步按名去重;
测试 168 → 173;版本 0.1.20;分析见 `docs/analysis-issues.md` 二十五。
26. [x] 磁盘缓存可观测性、分阶段校验与进度显示(2026-08-08):
`DiskRecordsCache` 拆为 `load()`(读 + gunzip + JSON)与 `validate()`(逐文件
stat,带进度回调);冷启动**先校验 XML/full 记录再构建**,美术/shallow
记录先以 `validated:false` 预播种(phase A 只登记不消费,避免 stat 2.6GB
模型),在 phase A 发布后的回调里校验并进入 phase B——phase A 可用时间
从 ~34s 提前到 ~16s,且不牺牲“未校验缓存不可信”的正确性(曾尝试构建后
后台校验,既有 I/O 争用又无法事后发现 stat 不可见变化,已放弃);
校验进度写入状态栏(`validating cache N/M…`)并每 1000 条输出一行日志;
`DiskCacheLoadStats` 增加 `loadMs` / `validateMs`,输出通道新增
`[disk-cache] loaded / validated / saved` 计时与 `[build] wall time`
(含缓存加载的总耗时),cacheReport 与状态栏 tooltip 展示校验耗时;
输出通道所有日志行自动加本地 `HH:mm:ss.mmm` 时间戳(`ModWorkspace.log`);
修复构建完成后状态栏仍显示 indexing(`building` 置 false 后补一次
`updateStatusBar()`);测试 173 → 175;分析见
`docs/analysis-issues.md` 二十六。
27. [x] CodeLens 与 FAR 使用同一套定义合并路径(2026-08-10):CodeLens
改为通过 `getScope(document)` 取 merged index,并用
`definitionsForReference`(文档 local overlay + 全局同名定义)+
`collectReferenceSites` 计算计数,与 Find All References 严格一致;
点击 lens 打开的 references peek`showReferencesForDef`)同步改为
同一逻辑,修复“FAR 有引用但 WeaponTemplate 等 CodeLens 不显示/为 0”
的 standalone / 未进全局流文件场景;`scheduleRebuildIfRecordsDesync`
在 CodeLens 中也改用 `recordsSyncSurfaceFor(document)`(按文档所属
项目自愈)。补充:CodeLens 改用轻量 `getCodeLensScope`(只解析当前
文档 + 挂全局索引,不展开 include 链),快照发布后计数即时刷新;
仅在尚无全局快照(`stats.indexedFiles === 0`)时不渲染 CodeLens
快照存在后“0 references”仍按设计显示;新增 `onDidChangeCodeLenses`
事件在每次快照发布时主动通知 VS Code 重新查询(不再只依赖 refresh
命令);输出通道增加 `[codelens] refresh`(快照发布时低频记录)、
`[codelens] suppressed`(首个快照前每个文档只记一次)、scope 异常与
超过 250ms 的慢调用记录;另加**全局重试定时器**:构建期间每 2s 重新
fire 一次 CodeLens 刷新(`onBuildStart` 启动、`!isBuilding` 停止),
避免 VS Code 合并/漏掉单次 refresh 事件导致 phase A 计数迟迟不出现;
定时器只在构建期存在,构建结束即清除;测试 175 → 178;分析见
`docs/analysis-issues.md` 二十七。
28. [x] manifest 源地址按 vanilla-only 解析(2026-08-10):新增
`buildVanillaSearchPaths(sdkDir)``manifestSource` 只按
`[SDK根, SDK\SageXml]`(ART/AUDIO 同理)解析,不再使用当前项目 BAB
顺序;修复 mod 同名 `DATA:globaldata/weapon.xml` 遮蔽导致 manifest
定义跳不到 SageXml 的问题;`referenceIndex` 的 manifest 源归并同步
修正;SageXml 源缺失时保持 manifest-only,文件存在但 id 被删时降级
到文件顶部;测试 178 → 184;分析见 `docs/analysis-issues.md`
二十八。
## 四、验证结果(实测) ## 四、验证结果(实测)
@@ -369,7 +438,9 @@ test/
前缀保护、多行未闭合 `Disposition` 完整链路、闭合引号后补空格、一行一个属性 前缀保护、多行未闭合 `Disposition` 完整链路、闭合引号后补空格、一行一个属性
换行缩进、新行缩进对齐、标量类型化默认值)、语义 token(标签/属性/值范围、 换行缩进、新行缩进对齐、标量类型化默认值)、语义 token(标签/属性/值范围、
合法文档返回空、malformed 返回兜底 token)、include 解析(BAB 顺序、SDK 根 合法文档返回空、malformed 返回兜底 token)、include 解析(BAB 顺序、SDK 根
优先于 SageXml)、manifest 二进制解析(合成 v5 样本、类型/ID 推导)、索引器 优先于 SageXmlmanifest 源 vanilla-onlymod 同名遮蔽仍命中 SageXml、
源缺失保持 manifest-only、id 被删降级文件顶部)、manifest 二进制解析
(合成 v5 样本、类型/ID 推导)、索引器
(资产/Define/流/缺失 include/嵌套 xi:include)、XSD 模型(上下文类型、 (资产/Define/流/缺失 include/嵌套 xi:include)、XSD 模型(上下文类型、
`childTypeOf`、大小写规范化、属性级 refType、外来命名空间判定、`xs:list` `childTypeOf`、大小写规范化、属性级 refType、外来命名空间判定、`xs:list`
枚举继承与 `isList` 标记)、引用过滤(`Weapon="X"` 只跳 `WeaponTemplate` 枚举继承与 `isList` 标记)、引用过滤(`Weapon="X"` 只跳 `WeaponTemplate`
+16
View File
@@ -64,6 +64,14 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
- `<Include>` 目标文件找不到、Include 循环; - `<Include>` 目标文件找不到、Include 循环;
- `$DEFINE` 未定义。 - `$DEFINE` 未定义。
**补充(工作区/项目检测,2026-08-07**
- 项目根不要求工作区精确匹配 `Data/Mod.xml`:从工作区文件夹向上最多 12 层、
从打开的 XML 文件向上、以及从“包含多个 mod 的容器文件夹”向下浅扫最多 3 层
均可发现项目根(`Data/Mod.xml``Data/additionalmaps/mapmetadata_*.xml`
`*.babproj` 任一标记命中即可,大小写不敏感、最近命中优先)。
- 多项目同时打开时按文档就近选择项目;单项目打开立即建索引,容器/多项目采用
惰性索引(活动文档所属项目先建,其他在文档打开或首次请求时建),构建串行执行。
**补充(manifest 解析,支持 include reference 后的补全/导航/诊断)** **补充(manifest 解析,支持 include reference 后的补全/导航/诊断)**
-`Mod.xml`(或其他文件)用 `<Include type="reference" source="DATA:static.xml" />` 引用占位文件时,实际内容来自 SDK `builtmods` 下对应的已编译二进制 manifest(`static.manifest` / `global.manifest` / `audio.manifest`)。 -`Mod.xml`(或其他文件)用 `<Include type="reference" source="DATA:static.xml" />` 引用占位文件时,实际内容来自 SDK `builtmods` 下对应的已编译二进制 manifest(`static.manifest` / `global.manifest` / `audio.manifest`)。
@@ -71,6 +79,11 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
- **代码补全**:例如 reference 了 `audio.xml` 后,所有音频资产 ID 都能出现在引用型属性(如 `AudioEventRef`)的补全里; - **代码补全**:例如 reference 了 `audio.xml` 后,所有音频资产 ID 都能出现在引用型属性(如 `AudioEventRef`)的补全里;
- **引用导航/悬停**:能定位资产来自哪个 manifest、哪个源文件; - **引用导航/悬停**:能定位资产来自哪个 manifest、哪个源文件;
- **诊断**:能把“引用了 manifest 中的 ID”识别为已解析,而不是误报未解析引用。 - **诊断**:能把“引用了 manifest 中的 ID”识别为已解析,而不是误报未解析引用。
- manifest 的 `sourceFileName`(如 `DATA:globaldata/weapon.xml`)是**原版编译
时的源地址**,按 vanilla-only 搜索路径(SDK 根 + `SageXml`)解析,不能用当前
mod 的 BAB 顺序解析——否则 mod 同名 DATA 路径会遮蔽 SageXml 源码。ART/AUDIO
源码默认不映射(SDK 基本不提供);SageXml 源缺失时保持 manifest-only
文件存在但 id 被删时降级到文件顶部。
- manifest 为二进制格式,解析逻辑参考 OpenSAGE `ManifestFile.cs`(用户已在本工作区 `OpenSAGE/` 克隆并切到指定 commit)。关键格式要点: - manifest 为二进制格式,解析逻辑参考 OpenSAGE `ManifestFile.cs`(用户已在本工作区 `OpenSAGE/` 克隆并切到指定 commit)。关键格式要点:
- 头部含版本(5/6/7)、端序标志、各缓冲区大小、资产数量; - 头部含版本(5/6/7)、端序标志、各缓冲区大小、资产数量;
- 每个资产条目含 `TypeId`(哈希)、`NameOffset``SourceFileNameOffset` 等; - 每个资产条目含 `TypeId`(哈希)、`NameOffset``SourceFileNameOffset` 等;
@@ -116,6 +129,9 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
## 四、验收标准 ## 四、验收标准
- 在 AttachTest / GenEvoTest 上开箱即用(高亮、补全、跳转、诊断)。 - 在 AttachTest / GenEvoTest 上开箱即用(高亮、补全、跳转、诊断)。
- 打开 mod 的 `Data` 文件夹、`Data` 子文件夹、仅含 mapmetadata 的项目、
单个 XML 文件、以及“内部包含多个 mod”的容器文件夹时均能正确发现项目根;
多项目打开时各自索引与功能互不串扰。
- 在 Corona 规模的目录上不卡 UI:索引在后台执行、保存文件后增量更新。 - 在 Corona 规模的目录上不卡 UI:索引在后台执行、保存文件后增量更新。
- 纯解析/索引核心不依赖 VS Code API,可被其他工具复用。 - 纯解析/索引核心不依赖 VS Code API,可被其他工具复用。
- 可用 `vsce package` 打出可安装的 `.vsix` - 可用 `vsce package` 打出可安装的 `.vsix`
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+13 -5
View File
@@ -2,9 +2,9 @@
"name": "ra3-mod-xml", "name": "ra3-mod-xml",
"displayName": "RA3 Mod XML", "displayName": "RA3 Mod XML",
"description": "Red Alert 3 Mod XML tooling: syntax highlighting, completions, reference navigation and diagnostics for SAGE/BinaryAssetBuilder XML.", "description": "Red Alert 3 Mod XML tooling: syntax highlighting, completions, reference navigation and diagnostics for SAGE/BinaryAssetBuilder XML.",
"version": "0.1.18", "version": "0.1.20",
"publisher": "ra3-mod-xml", "publisher": "ra3-mod-xml",
"license": "MIT", "license": "SEE LICENSE IN LICENSE",
"engines": { "engines": {
"vscode": "^1.85.0" "vscode": "^1.85.0"
}, },
@@ -23,9 +23,13 @@
"main": "./dist/extension.js", "main": "./dist/extension.js",
"activationEvents": [ "activationEvents": [
"onLanguage:xml", "onLanguage:xml",
"workspaceContains:Mod.xml",
"workspaceContains:additionalmaps/mapmetadata_*.xml",
"workspaceContains:**/Data/Mod.xml", "workspaceContains:**/Data/Mod.xml",
"workspaceContains:**/Data/additionalmaps/mapmetadata_*.xml",
"workspaceContains:**/mod.babproj", "workspaceContains:**/mod.babproj",
"workspaceContains:**/*.babproj" "workspaceContains:**/*.babproj",
"onCommand:ra3modxml.configureSdkPath"
], ],
"contributes": { "contributes": {
"grammars": [ "grammars": [
@@ -42,8 +46,8 @@
"properties": { "properties": {
"ra3modxml.sdkPath": { "ra3modxml.sdkPath": {
"type": "string", "type": "string",
"default": "C:\\Apps\\RA3-MODSDK-X", "default": "",
"description": "Path to the RA3 Mod SDK root. Used to resolve DATA:/ART:/AUDIO: includes and to index vanilla SageXml sources." "description": "Path to the RA3 Mod SDK root. Used to resolve DATA:/ART:/AUDIO: includes and to index vanilla SageXml sources. Leave empty to disable vanilla SDK features (project-only mode)."
}, },
"ra3modxml.indexSageXml": { "ra3modxml.indexSageXml": {
"type": "boolean", "type": "boolean",
@@ -97,6 +101,10 @@
"command": "ra3modxml.clearCache", "command": "ra3modxml.clearCache",
"title": "RA3 Mod XML: Clear caches and rebuild" "title": "RA3 Mod XML: Clear caches and rebuild"
}, },
{
"command": "ra3modxml.configureSdkPath",
"title": "RA3 Mod XML: Configure SDK path…"
},
{ {
"command": "ra3modxml.showCacheReport", "command": "ra3modxml.showCacheReport",
"title": "RA3 Mod XML: Show cache report" "title": "RA3 Mod XML: Show cache report"
+64 -10
View File
@@ -1,5 +1,6 @@
import * as vscode from "vscode"; import * as vscode from "vscode";
import { ModWorkspace } from "./workspace"; import { ModWorkspace } from "./workspace";
import { SdkSetup } from "./sdkSetup";
import { Ra3CompletionProvider } from "./features/completion"; import { Ra3CompletionProvider } from "./features/completion";
import { Ra3HoverProvider } from "./features/hover"; import { Ra3HoverProvider } from "./features/hover";
import { import {
@@ -21,9 +22,12 @@ import {
} from "./features/semanticTokens"; } from "./features/semanticTokens";
const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }]; const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }];
/** Safety-net refresh interval while a rebuild is running. */
const CODELENS_RETRY_INTERVAL_MS = 2000;
export function activate(context: vscode.ExtensionContext): void { export function activate(context: vscode.ExtensionContext): void {
const ws = new ModWorkspace(context); const ws = new ModWorkspace(context);
const sdkSetup = new SdkSetup(context, () => ws);
context.subscriptions.push(ws); context.subscriptions.push(ws);
context.subscriptions.push( context.subscriptions.push(
@@ -66,12 +70,36 @@ export function activate(context: vscode.ExtensionContext): void {
new Ra3DocumentSymbolProvider(ws), new Ra3DocumentSymbolProvider(ws),
), ),
); );
const codeLensProvider = new Ra3CodeLensProvider(ws);
context.subscriptions.push( context.subscriptions.push(
vscode.languages.registerCodeLensProvider( vscode.languages.registerCodeLensProvider(XML_SELECTOR, codeLensProvider),
XML_SELECTOR,
new Ra3CodeLensProvider(ws),
),
); );
// Safety net: while a rebuild is running, re-fire the CodeLens refresh
// every 2s. VS Code sometimes coalesces/skips a single refresh event, so
// the phase-A snapshot may not repaint until the final one; periodic
// refreshes (bounded by the build duration) make the early counts appear.
let codeLensRetryTimer: ReturnType<typeof setInterval> | null = null;
const startCodeLensRetry = (): void => {
if (codeLensRetryTimer) return;
codeLensRetryTimer = setInterval(() => {
if (!ws.isBuilding) {
if (codeLensRetryTimer) {
clearInterval(codeLensRetryTimer);
codeLensRetryTimer = null;
ws.log("[codelens] retry stopped (build finished)");
}
return;
}
codeLensProvider.refresh();
}, CODELENS_RETRY_INTERVAL_MS);
ws.log("[codelens] retry started");
};
ws.onBuildStart = startCodeLensRetry;
context.subscriptions.push({
dispose: () => {
if (codeLensRetryTimer) clearInterval(codeLensRetryTimer);
},
});
context.subscriptions.push( context.subscriptions.push(
vscode.languages.registerDocumentSemanticTokensProvider( vscode.languages.registerDocumentSemanticTokensProvider(
XML_SELECTOR, XML_SELECTOR,
@@ -85,7 +113,15 @@ export function activate(context: vscode.ExtensionContext): void {
// Refresh diagnostics for every open XML document whenever a new index // Refresh diagnostics for every open XML document whenever a new index
// snapshot is published (XML phase, art phase, stale/final rebuild). // snapshot is published (XML phase, art phase, stale/final rebuild).
ws.onIndexUpdate = () => { ws.onIndexUpdate = () => {
codeLensProvider.resetSuppressionLog();
codeLensProvider.refresh();
void vscode.commands.executeCommand("editor.action.codeLens.refresh"); void vscode.commands.executeCommand("editor.action.codeLens.refresh");
const idx = ws.activeIndex();
if (idx) {
ws.log(
`[codelens] refresh (project=${idx.stats.projectDir}, phase=${idx.phase}, assets=${idx.stats.assetCount}, complete=${idx.complete}, stale=${idx.stale === true})`,
);
}
for (const doc of vscode.workspace.textDocuments) { for (const doc of vscode.workspace.textDocuments) {
if (doc.languageId === "xml") void diagnostics.update(doc); if (doc.languageId === "xml") void diagnostics.update(doc);
} }
@@ -113,7 +149,17 @@ export function activate(context: vscode.ExtensionContext): void {
); );
context.subscriptions.push( context.subscriptions.push(
vscode.workspace.onDidOpenTextDocument((doc) => { vscode.workspace.onDidOpenTextDocument((doc) => {
if (doc.languageId === "xml") void diagnostics.update(doc); if (doc.languageId === "xml") {
ws.onDocumentOpened(doc);
void sdkSetup.evaluate(ws);
void diagnostics.update(doc);
}
}),
);
context.subscriptions.push(
vscode.workspace.onDidChangeWorkspaceFolders(() => {
ws.onWorkspaceFoldersChanged();
void sdkSetup.evaluate(ws);
}), }),
); );
context.subscriptions.push( context.subscriptions.push(
@@ -131,7 +177,7 @@ export function activate(context: vscode.ExtensionContext): void {
vscode.workspace.onDidSaveTextDocument((doc) => { vscode.workspace.onDidSaveTextDocument((doc) => {
if (doc.languageId !== "xml") return; if (doc.languageId !== "xml") return;
ws.invalidate(doc.uri.fsPath); ws.invalidate(doc.uri.fsPath);
ws.scheduleRebuild("save"); ws.scheduleRebuild("save", doc);
void diagnostics.update(doc); void diagnostics.update(doc);
}), }),
); );
@@ -141,7 +187,8 @@ export function activate(context: vscode.ExtensionContext): void {
// Search paths / builtmods locations may have changed: cached include // Search paths / builtmods locations may have changed: cached include
// resolutions and manifest lookups are no longer valid. // resolutions and manifest lookups are no longer valid.
ws.invalidateExistence(); ws.invalidateExistence();
ws.scheduleRebuild("config"); ws.scheduleRebuildAll("config");
void sdkSetup.evaluate(ws);
} }
}), }),
); );
@@ -169,7 +216,7 @@ export function activate(context: vscode.ExtensionContext): void {
); );
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.openIndexReport", () => { vscode.commands.registerCommand("ra3modxml.openIndexReport", () => {
const idx = ws.index; const idx = ws.activeIndex();
if (!idx) { if (!idx) {
if (ws.isBuilding) { if (ws.isBuilding) {
void vscode.window.showInformationMessage( void vscode.window.showInformationMessage(
@@ -178,8 +225,14 @@ export function activate(context: vscode.ExtensionContext): void {
); );
return; return;
} }
if (ws.getProjectRoots().length) {
void vscode.window.showInformationMessage( void vscode.window.showInformationMessage(
"RA3 Mod XML: no index available. Open a workspace that contains Data/Mod.xml.", "RA3 Mod XML: no index for the active project yet — open a mod XML document to start indexing.",
);
return;
}
void vscode.window.showInformationMessage(
"RA3 Mod XML: no index available. Open a workspace that contains Data/Mod.xml, Data/additionalmaps/mapmetadata_*.xml or a mod folder.",
); );
return; return;
} }
@@ -220,7 +273,8 @@ export function activate(context: vscode.ExtensionContext): void {
), ),
); );
void ws.initialize(); void sdkSetup.evaluate(ws);
void ws.initialize().then(() => void sdkSetup.evaluate(ws));
} }
export function deactivate(): void { export function deactivate(): void {
+73 -14
View File
@@ -2,11 +2,13 @@ import * as vscode from "vscode";
import { LineMap, parseXml } from "../language/xmlParser"; import { LineMap, parseXml } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext"; import { resolveElementType } from "../language/typeContext";
import { isReferenceTargetType } from "../indexer/refs"; import { isReferenceTargetType } from "../indexer/refs";
import { scheduleRebuildIfRecordsDesync } from "../indexer/referenceIndex";
import type { ModIndex } from "../indexer/types";
import { import {
referenceSitesForDefinition, collectReferenceSites,
scheduleRebuildIfRecordsDesync, definitionsForReference,
} from "../indexer/referenceIndex"; type ShowReferencesArgs,
import type { ShowReferencesArgs } from "./references"; } from "./references";
import type { ModWorkspace } from "../workspace"; import type { ModWorkspace } from "../workspace";
/** Never build a DOM for huge files just to show counts (w3x safety). */ /** Never build a DOM for huge files just to show counts (w3x safety). */
@@ -21,18 +23,65 @@ const MAX_CODELENS_TEXT = 4 * 1024 * 1024;
* signal users can click to inspect an unused asset. * signal users can click to inspect an unused asset.
*/ */
export class Ra3CodeLensProvider implements vscode.CodeLensProvider { export class Ra3CodeLensProvider implements vscode.CodeLensProvider {
private changeEmitter = new vscode.EventEmitter<void>();
readonly onDidChangeCodeLenses = this.changeEmitter.event;
/** URIs for which "no global snapshot yet" has already been logged. */
private suppressedLogged = new Set<string>();
constructor(private ws: ModWorkspace) {} constructor(private ws: ModWorkspace) {}
provideCodeLenses( /** Tells VS Code to re-query lenses (used after index snapshots). */
refresh(): void {
this.changeEmitter.fire();
}
/** Called when a new snapshot is published; allows re-logging suppression. */
resetSuppressionLog(): void {
this.suppressedLogged.clear();
}
async provideCodeLenses(
document: vscode.TextDocument, document: vscode.TextDocument,
_token: vscode.CancellationToken, _token: vscode.CancellationToken,
): vscode.CodeLens[] { ): Promise<vscode.CodeLens[]> {
if (!this.ws.isRa3Workspace()) return []; if (!this.ws.isRa3Workspace()) return [];
const idx = this.ws.index; const startedAt = Date.now();
const uri = document.uri.toString();
let idx: ModIndex | null = null;
try {
idx = (await this.ws.getCodeLensScope(document)).merged;
} catch (err) {
this.ws.log(
`[codelens] scope error for ${uri}: ${err instanceof Error ? err.message : String(err)}`,
);
return [];
}
if (!idx) return []; if (!idx) return [];
// Before the first global snapshot exists the merged index is a
// local-only index (stats.indexedFiles === 0) with no real references.
// Rendering "0 references" then would be misleading, so wait until a
// snapshot is published. Once a snapshot exists, "0" is meaningful and
// must still be displayed for reference-target types.
if (!idx.complete && idx.stats.indexedFiles === 0) {
if (!this.suppressedLogged.has(uri)) {
this.suppressedLogged.add(uri);
this.ws.log(
`[codelens] suppressed for ${uri} (no global snapshot yet)`,
);
}
return [];
}
const text = document.getText(); const text = document.getText();
if (text.length > MAX_CODELENS_TEXT) return []; if (text.length > MAX_CODELENS_TEXT) {
scheduleRebuildIfRecordsDesync(this.ws, document); this.ws.log(
`[codelens] skipped for ${uri} (${text.length} bytes > ${MAX_CODELENS_TEXT})`,
);
return [];
}
scheduleRebuildIfRecordsDesync(
this.ws.recordsSyncSurfaceFor(document),
document,
);
const doc = parseXml(text); const doc = parseXml(text);
const root = doc.root; const root = doc.root;
if (!root) return []; if (!root) return [];
@@ -49,12 +98,16 @@ export class Ra3CodeLensProvider implements vscode.CodeLensProvider {
const id = idAttr.value; const id = idAttr.value;
const line = lineMap.positionAt(idAttr.valueStart).line + 1; const line = lineMap.positionAt(idAttr.valueStart).line + 1;
const count = referenceSitesForDefinition(idx, { // Same definition union as Find All References: document-local
type: local, // overlay + every same-id definition in the global index. This keeps
// the lens count and the references peek consistent even when the
// file itself is not part of the global include graph.
const defs = definitionsForReference(idx, {
id, id,
file: document.uri.fsPath, refType: null,
line, selfType: null,
}).length; });
const count = collectReferenceSites(idx, defs).length;
const range = new vscode.Range( const range = new vscode.Range(
document.positionAt(child.start), document.positionAt(child.start),
document.positionAt(child.startTagEnd), document.positionAt(child.startTagEnd),
@@ -80,6 +133,12 @@ export class Ra3CodeLensProvider implements vscode.CodeLensProvider {
}), }),
); );
} }
const elapsed = Date.now() - startedAt;
if (elapsed > 250) {
this.ws.log(
`[codelens] slow provider for ${uri}: ${lenses.length} lenses in ${elapsed}ms`,
);
}
return lenses; return lenses;
} }
} }
+69 -23
View File
@@ -454,20 +454,41 @@ 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();
const scored: { def: AssetDef; score: number }[] = []; // Deduplicate by id: the same asset can be defined in several places at
// once (current file's local overlay + global index, project XML +
// compiled manifest, or an override). Showing one completion entry per
// id is enough; the other definitions are listed in the documentation.
// Definitions are still de-duplicated by (type, id, file, line) so the
// same record found through both local and global maps is not repeated
// inside a single entry either.
const seen = new Set<string>(); const seen = new Set<string>();
const byId = new Map<
string,
{ best: { def: AssetDef; score: number }; extras: AssetDef[] }
>();
const consider = (def: AssetDef) => { const consider = (def: AssetDef) => {
const key = `${def.type}:${def.id.toLowerCase()}:${def.file}:${def.line}`; const defKey = `${def.type}:${def.id.toLowerCase()}:${def.file}:${def.line}`;
if (seen.has(key)) return; if (seen.has(defKey)) return;
seen.add(key); seen.add(defKey);
if (!def.id.toLowerCase().startsWith(lower)) return; const idKey = def.id.toLowerCase();
if (!idKey.startsWith(lower)) 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;
if (def.origin === "project") score -= 0.2; if (def.origin === "project") score -= 0.2;
if (def.stream === "local") score -= 0.4; if (def.stream === "local") score -= 0.4;
scored.push({ def, score }); const entry = byId.get(idKey);
if (!entry) {
byId.set(idKey, { best: { def, score }, extras: [] });
return;
}
if (score < entry.best.score) {
entry.extras.push(entry.best.def);
entry.best = { def, score };
} else {
entry.extras.push(def);
}
}; };
const targetType = selfType ?? refType; const targetType = selfType ?? refType;
@@ -492,17 +513,28 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
} }
} }
const top = topScoredDefs(scored, MAX_VALUE_ITEMS); const entries = [...byId.values()];
const top = topScoredDefs(
entries.map((e) => e.best),
MAX_VALUE_ITEMS,
);
const items = top.map(({ def }) => { const items = top.map(({ def }) => {
const origin = def.origin === "manifest" ? `manifest (${def.manifestSource ?? ""})` : def.origin; const originLabel = (d: AssetDef) =>
d.origin === "manifest" ? `manifest (${d.manifestSource ?? ""})` : d.origin;
const origin = originLabel(def);
const doc = new vscode.MarkdownString(); const doc = new vscode.MarkdownString();
doc.appendCodeblock(def.id); doc.appendCodeblock(def.id);
doc.appendMarkdown(`**Type**: ${def.type} \n`); doc.appendMarkdown(`**Type**: ${def.type} \n`);
if (def.manifestSource) doc.appendMarkdown(`**Source**: ${def.manifestSource} \n`); if (def.manifestSource) doc.appendMarkdown(`**Source**: ${def.manifestSource} \n`);
doc.appendMarkdown(`**Origin**: ${origin}`); doc.appendMarkdown(`**Origin**: ${origin}`);
for (const extra of byId.get(def.id.toLowerCase())?.extras ?? []) {
doc.appendMarkdown(
`\n\nAlso defined as **${extra.type}** · ${originLabel(extra)}`,
);
}
return make(def.id, vscode.CompletionItemKind.Value, `${def.type} · ${origin}`, doc.value); return make(def.id, vscode.CompletionItemKind.Value, `${def.type} · ${origin}`, doc.value);
}); });
return this.limitItems(items, scored.length); return this.limitItems(items, byId.size);
} }
private defineItems( private defineItems(
@@ -512,13 +544,16 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> { ): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const lower = prefix.replace(/^[=$]*/, "").toLowerCase(); const lower = prefix.replace(/^[=$]*/, "").toLowerCase();
const items: vscode.CompletionItem[] = []; const items: vscode.CompletionItem[] = [];
// The same define can be visible through both the local overlay and the
// global index; show one entry per name (local definitions win because
// they are iterated first).
const seen = new Set<string>(); const seen = new Set<string>();
for (const defines of [idx.local?.defines, idx.defines]) { for (const defines of [idx.local?.defines, idx.defines]) {
if (!defines) continue; if (!defines) continue;
for (const [key, defs] of defines) { for (const [key, defs] of defines) {
if (!key.includes(lower)) continue; if (!key.includes(lower)) continue;
const def = defs[0]; const def = defs[0];
const dedupe = `${def.name.toLowerCase()}:${def.file}:${def.line}`; const dedupe = def.name.toLowerCase();
if (seen.has(dedupe)) continue; if (seen.has(dedupe)) continue;
seen.add(dedupe); seen.add(dedupe);
const label = `$${def.name}`; const label = `$${def.name}`;
@@ -816,9 +851,17 @@ function attributeInsertLayout(
const wordStart = findAttributeWordStart(text, offset, el.start); const wordStart = findAttributeWordStart(text, offset, el.start);
const attrs = el.attrs; const attrs = el.attrs;
const complete = attrs.filter((a) => a.hasValue); const complete = attrs.filter((a) => a.hasValue);
const last = complete.length ? complete[complete.length - 1] : null; // Only attributes that end before the cursor decide whether the completed
// attribute is already on its own line. The tag's last complete attribute
// may still be AFTER the cursor when the user inserts a new attribute in
// the middle of a one-per-line tag; using it here would wrongly re-wrap.
const beforeCursor = complete.filter((a) => attributeEndOffset(a) <= offset);
const last = beforeCursor.length ? beforeCursor[beforeCursor.length - 1] : null;
const lastEnd = last ? attributeEndOffset(last) : -1; const lastEnd = last ? attributeEndOffset(last) : -1;
const alreadyOnNewLine = lastEnd >= 0 && text.slice(lastEnd, offset).includes("\n"); const alreadyOnNewLine =
lastEnd >= 0
? text.slice(lastEnd, offset).includes("\n")
: text.slice(el.start + 1 + el.name.length, offset).includes("\n");
// Canonical indent anchor: the first complete attribute that starts on its // Canonical indent anchor: the first complete attribute that starts on its
// own line. Fall back to the last complete attribute for inline elements. // own line. Fall back to the last complete attribute for inline elements.
@@ -838,21 +881,21 @@ function attributeInsertLayout(
? text.slice(0, anchor.nameStart).match(/[ \t]*$/)?.[0] ?? "" ? text.slice(0, anchor.nameStart).match(/[ \t]*$/)?.[0] ?? ""
: ""; : "";
if (!onePerLine) {
if (alreadyOnNewLine) {
// Inline-style file, but the user started a new line: keep whatever
// indentation they already typed.
return { rangeStart: wordStart, prefix: "" };
}
const needsSpace = wordStart > el.start + 1 && !/\s/.test(text[wordStart - 1]);
return { rangeStart: wordStart, prefix: needsSpace ? " " : "" };
}
if (alreadyOnNewLine) { if (alreadyOnNewLine) {
// The attribute being completed is already on its own line: never insert
// another newline. In one-per-line files align with the canonical indent;
// in inline files keep whatever indentation the user already typed.
if (onePerLine) {
const lineStart = text.lastIndexOf("\n", offset - 1) + 1; const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
return { rangeStart: lineStart, prefix: indent }; return { rangeStart: lineStart, prefix: indent };
} }
// Insert on a new line. The editor adds the current line's indentation to return { rangeStart: wordStart, prefix: "" };
// the new line, so we must NOT embed our own indent here (it would }
// The cursor sits on the same line as the element name or a complete
// attribute: the completed attribute would be the second one on that line.
if (onePerLine) {
// Insert on a new line. The editor adds the current line's indentation
// to the new line, so we must NOT embed our own indent here (it would
// compound). If whitespace was typed between the previous attribute and // compound). If whitespace was typed between the previous attribute and
// the cursor (e.g. a space used to trigger the suggestion popup), consume // the cursor (e.g. a space used to trigger the suggestion popup), consume
// it so it does not linger as a trailing space. // it so it does not linger as a trailing space.
@@ -863,6 +906,9 @@ function attributeInsertLayout(
? lastEnd ? lastEnd
: wordStart; : wordStart;
return { rangeStart: wsStart, prefix: "\n" }; return { rangeStart: wsStart, prefix: "\n" };
}
const needsSpace = wordStart > el.start + 1 && !/\s/.test(text[wordStart - 1]);
return { rangeStart: wordStart, prefix: needsSpace ? " " : "" };
} }
function attributeEndOffset(attr: XmlAttribute): number { function attributeEndOffset(attr: XmlAttribute): number {
+18 -1
View File
@@ -3,6 +3,7 @@ import { dirname } from "node:path";
import { LineMap, type XmlElement } from "../language/xmlParser"; import { LineMap, type XmlElement } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext"; import { resolveElementType } from "../language/typeContext";
import { resolveSource, buildSearchPaths } from "../indexer/includeResolver"; import { resolveSource, buildSearchPaths } from "../indexer/includeResolver";
import { validateSdkPath } from "../sdk";
import * as model from "../model/schemaModel"; import * as model from "../model/schemaModel";
import type { ModWorkspace } from "../workspace"; import type { ModWorkspace } from "../workspace";
import type { ModIndex } from "../indexer/types"; import type { ModIndex } from "../indexer/types";
@@ -18,11 +19,22 @@ import { scopePathKey } from "../indexer/localScope";
export class Ra3Diagnostics { export class Ra3Diagnostics {
private collection: vscode.DiagnosticCollection; private collection: vscode.DiagnosticCollection;
private sdkCache: { path: string; unusable: boolean } | null = null;
constructor(private ws: ModWorkspace) { constructor(private ws: ModWorkspace) {
this.collection = vscode.languages.createDiagnosticCollection("ra3modxml"); this.collection = vscode.languages.createDiagnosticCollection("ra3modxml");
} }
/** True when the SDK is missing or not an SDK root (project-only mode). */
private sdkUnusable(): boolean {
const path = this.ws.settings.sdkPath;
if (this.sdkCache?.path === path) return this.sdkCache.unusable;
const status = validateSdkPath(path).status;
const unusable = status === "missing" || status === "not-sdk";
this.sdkCache = { path, unusable };
return unusable;
}
async update(document: vscode.TextDocument): Promise<void> { async update(document: vscode.TextDocument): Promise<void> {
if (!this.ws.isRa3Workspace()) { if (!this.ws.isRa3Workspace()) {
this.collection.set(document.uri, []); this.collection.set(document.uri, []);
@@ -429,7 +441,7 @@ export class Ra3Diagnostics {
if (!sourceAttr?.hasValue) return; if (!sourceAttr?.hasValue) return;
const searchPaths = idx const searchPaths = idx
? buildSearchPaths(idx.sdkDir, idx.projectDir) ? buildSearchPaths(idx.sdkDir, idx.projectDir)
: this.ws.searchPaths(); : this.ws.searchPaths(document);
if (!searchPaths) return; if (!searchPaths) return;
const resolved = resolveSource( const resolved = resolveSource(
sourceAttr.value, sourceAttr.value,
@@ -439,6 +451,11 @@ export class Ra3Diagnostics {
const candidateHit = const candidateHit =
idx?.sourceCandidates.some((c) => c.source === sourceAttr.value) ?? false; idx?.sourceCandidates.some((c) => c.source === sourceAttr.value) ?? false;
if (!resolved.path && !candidateHit) { if (!resolved.path && !candidateHit) {
// Without a usable SDK, prefixed includes are expected to be missing;
// report one project-level hint instead of warning on every line.
if (this.sdkUnusable() && /^(DATA|ART|AUDIO):/i.test(sourceAttr.value.trim())) {
return;
}
diags.push( diags.push(
this.diag( this.diag(
new vscode.Range( new vscode.Range(
+1 -1
View File
@@ -155,7 +155,7 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
) { ) {
const searchPaths = idx const searchPaths = idx
? buildSearchPaths(idx.sdkDir, idx.projectDir) ? buildSearchPaths(idx.sdkDir, idx.projectDir)
: this.ws.searchPaths(); : this.ws.searchPaths(document);
const resolved = searchPaths const resolved = searchPaths
? resolveSource( ? resolveSource(
value, value,
+23 -6
View File
@@ -4,6 +4,7 @@ import { findElementAt, parseXml, textContentTokenAt } from "../language/xmlPars
import { resolveElementType } from "../language/typeContext"; import { resolveElementType } from "../language/typeContext";
import { import {
buildSearchPaths, buildSearchPaths,
buildVanillaSearchPaths,
resolveSource, resolveSource,
type SearchPaths, type SearchPaths,
} from "../indexer/includeResolver"; } from "../indexer/includeResolver";
@@ -71,7 +72,9 @@ export class Ra3DefinitionProvider implements vscode.DefinitionProvider {
(el.name === "Include" && nameLower === "source") || (el.name === "Include" && nameLower === "source") ||
(el.name === "include" && nameLower === "href") (el.name === "include" && nameLower === "href")
) { ) {
const searchPaths = idx ? searchPathsFor(idx) : this.ws.searchPaths(); const searchPaths = idx
? searchPathsFor(idx)
: this.ws.searchPaths(document);
const resolved = searchPaths const resolved = searchPaths
? resolveSource(value, dirname(document.uri.fsPath), searchPaths).path ? resolveSource(value, dirname(document.uri.fsPath), searchPaths).path
: null; : null;
@@ -178,11 +181,23 @@ async function assetDefLocation(
} }
if (def.origin === "manifest") { if (def.origin === "manifest") {
const src = def.manifestSource; const src = def.manifestSource;
if (src?.toUpperCase().startsWith("DATA:")) { if (src) {
const resolved = resolveSource(src, null, searchPathsFor(idx)).path; // manifestSource is a path recorded by the vanilla build, not an
// Include path in the current mod. Resolve it with SDK-only search
// paths so a mod file shadowing the same DATA: path cannot hijack the
// jump (e.g. mod Data/globaldata/weapon.xml vs SageXml/...). If the SDK
// source is missing (user removed/renamed a SageXml file), keep the
// definition manifest-only instead of opening the wrong file.
const resolved = resolveSource(
src,
null,
buildVanillaSearchPaths(idx.sdkDir),
).path;
if (resolved) { if (resolved) {
// The recorded source file is XML (e.g. SageXml) when available: // The recorded source file is XML (e.g. SageXml) when available:
// jump to the precise definition inside it, not just the file. // jump to the precise definition inside it. If the file was modified
// and no longer contains the id, fall back to opening the file at the
// top rather than inventing a precise location.
const precise = await locationInDocument(ws, resolved, def.id); const precise = await locationInDocument(ws, resolved, def.id);
return precise ?? new vscode.Location(vscode.Uri.file(resolved), new vscode.Position(0, 0)); return precise ?? new vscode.Location(vscode.Uri.file(resolved), new vscode.Position(0, 0));
} }
@@ -305,8 +320,10 @@ export class Ra3DocumentLinkProvider implements vscode.DocumentLinkProvider {
_token: vscode.CancellationToken, _token: vscode.CancellationToken,
): Promise<vscode.DocumentLink[]> { ): Promise<vscode.DocumentLink[]> {
if (!this.ws.isRa3Workspace()) return []; if (!this.ws.isRa3Workspace()) return [];
const idx = this.ws.index; const idx = this.ws.indexForDocument(document) ?? this.ws.activeIndex();
const searchPaths = idx ? searchPathsFor(idx) : this.ws.searchPaths(); const searchPaths = idx
? searchPathsFor(idx)
: this.ws.searchPaths(document);
if (!searchPaths) return []; if (!searchPaths) return [];
const text = document.getText(); const text = document.getText();
const doc = parseXml(text); const doc = parseXml(text);
+20 -10
View File
@@ -134,7 +134,9 @@ export async function sitesToLocations(
const locations: vscode.Location[] = []; const locations: vscode.Location[] = [];
for (const [file, fileSites] of byFile) { for (const [file, fileSites] of byFile) {
const parsed = await ws.indexer?.readDom(file); const parsed = await (ws.indexerForFile(file) ?? ws.activeIndexer())?.readDom(
file,
);
const lineMap = parsed?.lineMap ?? null; const lineMap = parsed?.lineMap ?? null;
for (const site of fileSites) { for (const site of fileSites) {
if (lineMap) { if (lineMap) {
@@ -179,7 +181,7 @@ export async function findReferenceLocations(
position: vscode.Position, position: vscode.Position,
): Promise<vscode.Location[] | null> { ): Promise<vscode.Location[] | null> {
if (!ws.isRa3Workspace()) return null; if (!ws.isRa3Workspace()) return null;
scheduleRebuildIfRecordsDesync(ws, document); scheduleRebuildIfRecordsDesync(ws.recordsSyncSurfaceFor(document), document);
const scope = await ws.getScope(document); const scope = await ws.getScope(document);
const idx = scope.merged; const idx = scope.merged;
if (!idx) return null; if (!idx) return null;
@@ -207,16 +209,24 @@ export async function showReferencesForDef(
ws: ModWorkspace, ws: ModWorkspace,
args: ShowReferencesArgs, args: ShowReferencesArgs,
): Promise<void> { ): Promise<void> {
const idx = ws.index; const doc = vscode.workspace.textDocuments.find(
(d) => d.uri.toString() === args.uri.toString(),
);
if (!doc) return;
let idx: ModIndex | null = null;
try {
idx = (await ws.getCodeLensScope(doc)).merged;
} catch {
return;
}
if (!idx) return; if (!idx) return;
const def: AssetDef = { // Same definition union as the lens count / Find All References.
type: args.type, const defs = definitionsForReference(idx, {
id: args.id, id: args.id,
file: args.file, refType: null,
line: args.line, selfType: null,
origin: "project", });
}; const sites = collectReferenceSites(idx, defs);
const sites = referenceSitesForDef(idx, def);
const locations = await sitesToLocations(ws, sites); const locations = await sitesToLocations(ws, sites);
await vscode.commands.executeCommand( await vscode.commands.executeCommand(
"editor.action.showReferences", "editor.action.showReferences",
+2 -2
View File
@@ -23,13 +23,13 @@ export async function findUnreferencedAssets(
ws: ModWorkspace, ws: ModWorkspace,
args?: { type?: string }, args?: { type?: string },
): Promise<void> { ): Promise<void> {
if (!ws.isRa3Workspace() || !ws.index) { const idx = ws.activeIndex();
if (!ws.isRa3Workspace() || !idx) {
void vscode.window.showInformationMessage( void vscode.window.showInformationMessage(
"RA3 Mod XML: no index available yet.", "RA3 Mod XML: no index available yet.",
); );
return; return;
} }
const idx = ws.index;
const byType = unreferencedByType(idx); const byType = unreferencedByType(idx);
let type = args?.type; let type = args?.type;
+8
View File
@@ -148,6 +148,14 @@ export interface IndexRecordsCacheEntry {
* produced before this field existed. * produced before this field existed.
*/ */
contentHash?: string; contentHash?: string;
/**
* False when the entry was seeded from disk but its stat has not been
* checked against the current disk yet. Such entries may only be used
* for deferred art registration during phase A; the indexer must not
* consume their records until `validated` is true (set by the stat pass
* or by a build that re-read the file).
*/
validated?: boolean;
} }
/** /**
+85 -22
View File
@@ -11,8 +11,10 @@
* Correctness model (layered): * Correctness model (layered):
* - every cached record stores a multi-signal stamp * - every cached record stores a multi-signal stamp
* `{ size, mtimeMs, birthtimeMs, ctimeMs }`; * `{ size, mtimeMs, birthtimeMs, ctimeMs }`;
* - on load, each file is stat-validated (no content reads); mismatches and * - a cold start seeds the in-memory cache immediately (`load`) and runs the
* missing files are dropped and re-read during the build; * stat pass in the background (`validate`, no content reads); mismatches
* and missing files are invalidated and re-read by a follow-up rebuild
* (the workspace's stale/dirty mechanism converges);
* - during a session the file watcher invalidates entries precisely; * - during a session the file watcher invalidates entries precisely;
* - `ra3modxml.reindex` / `ra3modxml.clearCache` remain the final authority. * - `ra3modxml.reindex` / `ra3modxml.clearCache` remain the final authority.
* *
@@ -80,6 +82,22 @@ export interface DiskCacheLoadStats {
validated: number; validated: number;
/** Records dropped because the file changed, moved or was deleted. */ /** Records dropped because the file changed, moved or was deleted. */
dropped: number; dropped: number;
/** Milliseconds spent reading / decompressing / parsing the cache file. */
loadMs: number;
/** Milliseconds spent stat-validating cached entries. */
validateMs: number;
}
function emptyLoadStats(): DiskCacheLoadStats {
return {
fileExists: false,
keyMatched: false,
loaded: 0,
validated: 0,
dropped: 0,
loadMs: 0,
validateMs: 0,
};
} }
export function diskCacheKey(identity: DiskCacheIdentity): string { export function diskCacheKey(identity: DiskCacheIdentity): string {
@@ -100,21 +118,18 @@ export class DiskRecordsCache {
} }
/** /**
* Loads and stat-validates the cache. Returns the kept records plus load * Loads the cache file without validating entries. This is fast (read +
* statistics; missing/corrupt/key-mismatched caches yield an empty result * gunzip + JSON parse) so a cold start can seed the in-memory records
* instead of an error. * cache immediately and let stat validation run in the background.
* Missing/corrupt/key-mismatched caches yield an empty result instead of
* an error.
*/ */
async loadValidated(): Promise<{ async load(): Promise<{
records: DiskCacheRecord[]; records: DiskCacheRecord[];
stats: DiskCacheLoadStats; stats: DiskCacheLoadStats;
}> { }> {
const stats: DiskCacheLoadStats = { const start = Date.now();
fileExists: false, const stats = emptyLoadStats();
keyMatched: false,
loaded: 0,
validated: 0,
dropped: 0,
};
let raw: DiskCacheFile | null = null; let raw: DiskCacheFile | null = null;
try { try {
const buf = await readFile(this.filePath); const buf = await readFile(this.filePath);
@@ -132,15 +147,37 @@ export class DiskRecordsCache {
} catch { } catch {
// Missing or corrupt cache: fall through with an empty result. // Missing or corrupt cache: fall through with an empty result.
} }
stats.loadMs = Date.now() - start;
if (!raw) return { records: [], stats }; if (!raw) return { records: [], stats };
stats.keyMatched = true; stats.keyMatched = true;
stats.loaded = raw.records.length; stats.loaded = raw.records.length;
return { records: raw.records, stats };
}
/**
* Stat-validates cached records. Returns the entries that still match
* plus the keys that must be re-read (missing / changed / moved).
*/
async validate(
records: DiskCacheRecord[],
onProgress?: (validatedCount: number, total: number) => void,
): Promise<{
stats: DiskCacheLoadStats;
kept: DiskCacheRecord[];
invalidKeys: string[];
}> {
const start = Date.now();
const stats = emptyLoadStats();
stats.fileExists = true;
stats.keyMatched = true;
stats.loaded = records.length;
const kept: DiskCacheRecord[] = []; const kept: DiskCacheRecord[] = [];
for (let i = 0; i < raw.records.length; i += VALIDATE_CONCURRENCY) { const invalidKeys: string[] = [];
const chunk = raw.records.slice(i, i + VALIDATE_CONCURRENCY); for (let i = 0; i < records.length; i += VALIDATE_CONCURRENCY) {
const chunk = records.slice(i, i + VALIDATE_CONCURRENCY);
const results = await Promise.all( const results = await Promise.all(
chunk.map(async (rec): Promise<DiskCacheRecord | null> => { chunk.map(async (rec, index): Promise<{ rec: DiskCacheRecord | null; index: number }> => {
try { try {
const s = await stat(rec.key); const s = await stat(rec.key);
if ( if (
@@ -150,24 +187,50 @@ export class DiskRecordsCache {
s.birthtimeMs === rec.stat.birthtimeMs && s.birthtimeMs === rec.stat.birthtimeMs &&
s.ctimeMs === rec.stat.ctimeMs s.ctimeMs === rec.stat.ctimeMs
) { ) {
return rec; return { rec, index };
} }
} catch { } catch {
// File missing or inaccessible. // File missing or inaccessible.
} }
return null; return { rec: null, index };
}), }),
); );
for (const r of results) { for (const { rec, index } of results) {
if (r) { if (rec) {
kept.push(r); kept.push(rec);
stats.validated++; stats.validated++;
} else { } else {
stats.dropped++; stats.dropped++;
invalidKeys.push(chunk[index].key);
} }
} }
onProgress?.(stats.validated, records.length);
} }
return { records: kept, stats }; stats.validateMs = Date.now() - start;
return { stats, kept, invalidKeys };
}
/**
* Loads and stat-validates the cache (blocking validation). Used by
* tests and kept as a convenience; the workspace normally prefers
* `load()` + background `validate()`.
*/
async loadValidated(): Promise<{
records: DiskCacheRecord[];
stats: DiskCacheLoadStats;
}> {
const { records, stats } = await this.load();
if (!records.length) return { records, stats };
const validation = await this.validate(records);
return {
records: validation.kept,
stats: {
...stats,
validated: validation.stats.validated,
dropped: validation.stats.dropped,
validateMs: validation.stats.validateMs,
},
};
} }
/** Writes the current records cache atomically (temp file + rename). */ /** Writes the current records cache atomically (temp file + rename). */
+36 -9
View File
@@ -44,39 +44,66 @@ export function buildSearchPaths(
): SearchPaths { ): SearchPaths {
const modParentPath = resolve(projectDir, ".."); const modParentPath = resolve(projectDir, "..");
const modGranParent = resolve(modParentPath, ".."); const modGranParent = resolve(modParentPath, "..");
const sdk = sdkDir && sdkDir.trim() ? resolve(sdkDir) : "";
const sdkItems = (items: string[]): string[] => (sdk ? items : []);
return { return {
DATA: [ DATA: [
sdkDir, ...sdkItems([sdk]),
modGranParent, modGranParent,
join(projectDir, "Data"), join(projectDir, "Data"),
join(sdkDir, "Mods"), ...sdkItems([join(sdk, "Mods")]),
modParentPath, modParentPath,
join(sdkDir, "SageXml"), ...sdkItems([join(sdk, "SageXml")]),
...(extra?.DATA ?? []), ...(extra?.DATA ?? []),
], ],
ART: [ ART: [
sdkDir, ...sdkItems([sdk]),
modGranParent, modGranParent,
join(projectDir, "Art1"), join(projectDir, "Art1"),
join(projectDir, "Art"), join(projectDir, "Art"),
join(sdkDir, "Mods"), ...sdkItems([join(sdk, "Mods")]),
modParentPath, modParentPath,
join(sdkDir, "Art"), ...sdkItems([join(sdk, "Art")]),
...(extra?.ART ?? []), ...(extra?.ART ?? []),
], ],
AUDIO: [ AUDIO: [
sdkDir, ...sdkItems([sdk]),
modGranParent, modGranParent,
join(projectDir, "Audio1"), join(projectDir, "Audio1"),
join(projectDir, "Audio"), join(projectDir, "Audio"),
join(sdkDir, "Mods"), ...sdkItems([join(sdk, "Mods")]),
modParentPath, modParentPath,
join(sdkDir, "Audio"), ...sdkItems([join(sdk, "Audio")]),
...(extra?.AUDIO ?? []), ...(extra?.AUDIO ?? []),
], ],
}; };
} }
/**
* Search paths used to resolve a `manifestSource` back to the original
* vanilla SDK source file.
*
* `manifestSource` records where the asset came from when the vanilla
* manifest was compiled; it is not an Include path that should be resolved
* with the current mod's BAB search order. If a mod shadows the same DATA:
* path (for example `Data/globaldata/weapon.xml` exists in both the mod and
* `SageXml`), the manifest definition must still point at the SageXml file.
*
* DATA/ART/AUDIO are resolved against the SDK root first (matching the
* vanilla BAB `/data "/art" /audio` order), then against the corresponding
* SDK source folder. ART/AUDIO source files are not shipped for most assets,
* so those resolutions usually return null and callers fall back to
* manifest-only behavior.
*/
export function buildVanillaSearchPaths(sdkDir: string): SearchPaths {
const sdk = sdkDir && sdkDir.trim() ? resolve(sdkDir) : "";
return {
DATA: sdk ? [sdk, join(sdk, "SageXml")] : [],
ART: sdk ? [sdk, join(sdk, "Art")] : [],
AUDIO: sdk ? [sdk, join(sdk, "Audio")] : [],
};
}
function splitPrefix(source: string): { prefix: SourcePrefix; rest: string } { function splitPrefix(source: string): { prefix: SourcePrefix; rest: string } {
for (const prefix of PREFIXES) { for (const prefix of PREFIXES) {
if (source.toUpperCase().startsWith(`${prefix}:`)) { if (source.toUpperCase().startsWith(`${prefix}:`)) {
+70 -3
View File
@@ -27,6 +27,7 @@ import {
type ResolveResult, type ResolveResult,
type SearchPaths, type SearchPaths,
} from "./includeResolver"; } from "./includeResolver";
import { validateSdkPath } from "../sdk";
import { import {
buildExistenceSnapshot, buildExistenceSnapshot,
type ExistenceSnapshot, type ExistenceSnapshot,
@@ -140,11 +141,17 @@ export class ModIndexer {
private visitedAll = new Set<string>(); private visitedAll = new Set<string>();
private visitedInstance = new Set<string>(); private visitedInstance = new Set<string>();
private manifestAssetKeys = new Set<string>(); private manifestAssetKeys = new Set<string>();
/** True when the SDK is missing/not an SDK: SDK-only includes are suppressed. */
private sdkUnusable: boolean;
private suppressedSdkIncludeCount = 0;
constructor(private opts: IndexOptions) { constructor(private opts: IndexOptions) {
this.searchPaths = buildSearchPaths(opts.sdkDir, opts.projectDir, { this.searchPaths = buildSearchPaths(opts.sdkDir, opts.projectDir, {
DATA: opts.additionalDataSearchPaths, DATA: opts.additionalDataSearchPaths,
}); });
const sdkStatus = validateSdkPath(opts.sdkDir);
this.sdkUnusable =
sdkStatus.status === "missing" || sdkStatus.status === "not-sdk";
// Caches may be owned by the workspace so they survive rebuilds. // Caches may be owned by the workspace so they survive rebuilds.
this.docs = opts.documentCache ?? new DocumentCache(); this.docs = opts.documentCache ?? new DocumentCache();
this.recordsCache = opts.recordsCache ?? new IndexRecordsCache(); this.recordsCache = opts.recordsCache ?? new IndexRecordsCache();
@@ -175,7 +182,27 @@ export class ModIndexer {
// ~2.6 GB of art assets on a mechanical drive). // ~2.6 GB of art assets on a mechanical drive).
if (trust) { if (trust) {
const rec = this.recordsCache.get(key); const rec = this.recordsCache.get(key);
if (rec) return this.recordsParsed(path, rec); if (rec) {
if (rec.validated === false) {
// Seeded from disk but not stat-validated yet. During phase A an
// art file only needs registration (no content), so reuse the
// cached stamp; its records are consumed only after validation.
if (opts?.deferArt && rec.kind === "shallow" && rec.stat) {
const file: IndexedFile = { path: resolve(path), stat: rec.stat };
this.files.set(key, file);
return {
file,
parse: null,
records: null,
lineMap: null,
deferredArt: true,
};
}
// Fall through: the stat-verifying path below checks this entry.
} else {
return this.recordsParsed(path, rec);
}
}
const cached = this.docs.get(key); const cached = this.docs.get(key);
if (cached) { if (cached) {
this.files.set(key, cached.file); this.files.set(key, cached.file);
@@ -194,6 +221,7 @@ export class ModIndexer {
rec.stat.birthtimeMs === st.birthtimeMs && rec.stat.birthtimeMs === st.birthtimeMs &&
rec.stat.ctimeMs === st.ctimeMs rec.stat.ctimeMs === st.ctimeMs
) { ) {
rec.validated = true;
// Force rebuilds (Re-index workspace) verify full-XML content even // Force rebuilds (Re-index workspace) verify full-XML content even
// when every stat signal matches: external drives (FAT32/exFAT) can // when every stat signal matches: external drives (FAT32/exFAT) can
// rewrite a file with the same size and coarse timestamps. // rewrite a file with the same size and coarse timestamps.
@@ -456,6 +484,7 @@ export class ModIndexer {
async build(onPhase?: (index: ModIndex) => void | Promise<void>): Promise<ModIndex> { async build(onPhase?: (index: ModIndex) => void | Promise<void>): Promise<ModIndex> {
const start = Date.now(); const start = Date.now();
this.buildRecords.clear(); this.buildRecords.clear();
this.suppressedSdkIncludeCount = 0;
// Root list only; directories are listed lazily on first query, so the // Root list only; directories are listed lazily on first query, so the
// XML phase does not pay an upfront recursive enumeration of the SDK. // XML phase does not pay an upfront recursive enumeration of the SDK.
this.existence = buildExistenceSnapshot(this.searchPaths); this.existence = buildExistenceSnapshot(this.searchPaths);
@@ -499,6 +528,17 @@ export class ModIndexer {
} }
} }
this.timings.walkMs = Date.now() - walkStart; this.timings.walkMs = Date.now() - walkStart;
if (this.suppressedSdkIncludeCount > 0) {
this.diagnostics.push({
file:
staticEntry ?? join(this.opts.projectDir, "Data"),
line: 0,
message:
"SDK path is not configured or invalid; DATA:/ART:/AUDIO: includes are not resolved (set ra3modxml.sdkPath).",
severity: "information",
code: "sdk-not-configured",
});
}
// ── Source completion candidates ── // ── Source completion candidates ──
const candidatesStart = Date.now(); const candidatesStart = Date.now();
@@ -533,9 +573,17 @@ export class ModIndexer {
// global.xml, audio.xml placeholders) but only its shallow XML files are // global.xml, audio.xml placeholders) but only its shallow XML files are
// relevant. These candidates take precedence over same-named files found // relevant. These candidates take precedence over same-named files found
// deeper in the search paths (e.g. SageXml/Static.xml). // deeper in the search paths (e.g. SageXml/Static.xml).
const sdkRootXml = (await readdir(this.opts.sdkDir)).filter( let sdkRootXml: string[] = [];
if (this.opts.sdkDir) {
try {
sdkRootXml = (await readdir(this.opts.sdkDir)).filter(
(f) => f.toLowerCase().endsWith(".xml"), (f) => f.toLowerCase().endsWith(".xml"),
); );
} catch {
// Missing/inaccessible SDK root: run in project-only mode. All other
// SDK search roots already degrade to empty lists.
}
}
const sdkRootCandidates: SourceCandidate[] = sdkRootXml.map((f) => ({ const sdkRootCandidates: SourceCandidate[] = sdkRootXml.map((f) => ({
source: `DATA:${f}`, source: `DATA:${f}`,
path: resolve(this.opts.sdkDir, f), path: resolve(this.opts.sdkDir, f),
@@ -778,6 +826,10 @@ export class ModIndexer {
for (const inc of records.includes) { for (const inc of records.includes) {
const resolved = this.resolveCached(inc.source, dirname(file)); const resolved = this.resolveCached(inc.source, dirname(file));
if (!resolved.path) { if (!resolved.path) {
if (this.shouldSuppressMissingInclude(inc.source)) {
this.suppressedSdkIncludeCount++;
continue;
}
this.diagnostics.push({ this.diagnostics.push({
file, file,
line: inc.line, line: inc.line,
@@ -808,6 +860,10 @@ export class ModIndexer {
for (const xi of records.nestedXiIncludes) { for (const xi of records.nestedXiIncludes) {
const resolved = this.resolveCached(xi.href, dirname(file)); const resolved = this.resolveCached(xi.href, dirname(file));
if (!resolved.path) { if (!resolved.path) {
if (this.shouldSuppressMissingInclude(xi.href)) {
this.suppressedSdkIncludeCount++;
continue;
}
this.diagnostics.push({ this.diagnostics.push({
file, file,
line: xi.line, line: xi.line,
@@ -839,6 +895,10 @@ export class ModIndexer {
): Promise<void> { ): Promise<void> {
const resolved = this.resolveCached(xi.href, dirname(parentFile)); const resolved = this.resolveCached(xi.href, dirname(parentFile));
if (!resolved.path) { if (!resolved.path) {
if (this.shouldSuppressMissingInclude(xi.href)) {
this.suppressedSdkIncludeCount++;
return;
}
this.diagnostics.push({ this.diagnostics.push({
file: parentFile, file: parentFile,
line: xi.line, line: xi.line,
@@ -930,12 +990,19 @@ export class ModIndexer {
private originOf(path: string): "project" | "sdk" { private originOf(path: string): "project" | "sdk" {
const p = resolve(path).toLowerCase(); const p = resolve(path).toLowerCase();
const project = resolve(this.opts.projectDir).toLowerCase(); const project = resolve(this.opts.projectDir).toLowerCase();
const sdk = resolve(this.opts.sdkDir).toLowerCase(); const sdk = this.opts.sdkDir
? resolve(this.opts.sdkDir).toLowerCase()
: "";
if (p.startsWith(project + "\\")) return "project"; if (p.startsWith(project + "\\")) return "project";
if (sdk && p.startsWith(sdk + "\\")) return "sdk"; if (sdk && p.startsWith(sdk + "\\")) return "sdk";
return "project"; return "project";
} }
/** DATA:/ART:/AUDIO: misses are expected when no usable SDK is configured. */
private shouldSuppressMissingInclude(source: string): boolean {
return this.sdkUnusable && /^(DATA|ART|AUDIO):/i.test(source.trim());
}
private addAsset(def: AssetDef): void { private addAsset(def: AssetDef): void {
// Keep the original case: type names are matched against the XSD model. // Keep the original case: type names are matched against the XSD model.
const typeKey = def.type; const typeKey = def.type;
+1 -1
View File
@@ -261,7 +261,7 @@ class OverlayBuilder {
private originOf(path: string): "project" | "sdk" | "manifest" { private originOf(path: string): "project" | "sdk" | "manifest" {
const p = resolve(path).toLowerCase(); const p = resolve(path).toLowerCase();
const project = resolve(this.ctx.projectDir).toLowerCase(); const project = resolve(this.ctx.projectDir).toLowerCase();
const sdk = resolve(this.ctx.sdkDir).toLowerCase(); const sdk = this.ctx.sdkDir ? resolve(this.ctx.sdkDir).toLowerCase() : "";
if (p.startsWith(project + "\\")) return "project"; if (p.startsWith(project + "\\")) return "project";
if (sdk && p.startsWith(sdk + "\\")) return "sdk"; if (sdk && p.startsWith(sdk + "\\")) return "sdk";
return "project"; return "project";
+2
View File
@@ -2,6 +2,8 @@
* Parser for SAGE `.manifest` files, ported from OpenSAGE * Parser for SAGE `.manifest` files, ported from OpenSAGE
* (src/OpenSage.Game/Data/StreamFS/ManifestFile.cs, commit d45d361). * (src/OpenSage.Game/Data/StreamFS/ManifestFile.cs, commit d45d361).
* *
* Licensed under LGPL-3.0 (derived from OpenSAGE); see LICENSE.
*
* The manifest is a binary index produced by BinaryAssetBuilder: every asset * The manifest is a binary index produced by BinaryAssetBuilder: every asset
* compiled into a stream is listed with hashed type/instance ids, an offset * compiled into a stream is listed with hashed type/instance ids, an offset
* into the asset-name string buffer, and an optional source file name. * into the asset-name string buffer, and an optional source file name.
+10 -7
View File
@@ -18,7 +18,7 @@ import {
isReferenceTargetType, isReferenceTargetType,
type ReferenceLookup, type ReferenceLookup,
} from "./refs"; } from "./refs";
import { buildSearchPaths, resolveSource } from "./includeResolver"; import { buildVanillaSearchPaths, resolveSource } from "./includeResolver";
import { normKey, recordsHash } from "./caches"; import { normKey, recordsHash } from "./caches";
import { LineMap, parseXml } from "../language/xmlParser"; import { LineMap, parseXml } from "../language/xmlParser";
import type { AssetDef, ModIndex, ReferenceSite } from "./types"; import type { AssetDef, ModIndex, ReferenceSite } from "./types";
@@ -90,10 +90,13 @@ function normFileKey(path: string): string {
* *
* Besides the definition's own reverse-index bucket, this unions the sites * Besides the definition's own reverse-index bucket, this unions the sites
* of manifest definitions that map back to the same XML source file via * of manifest definitions that map back to the same XML source file via
* `manifestSource`. A manifest asset with a resolvable SageXml source is * `manifestSource`. `manifestSource` is resolved with the SDK-only search
* semantically the same asset as that XML definition, so references to it * paths (not the current mod's BAB order), so a mod file shadowing the same
* should show up on the source file's CodeLens too (Find All References * DATA: path is never mistaken for the vanilla source. A manifest asset with
* already sees them because it unions every same-id/type definition). * a resolvable SageXml source is semantically the same asset as that XML
* definition, so references to it should show up on the source file's
* CodeLens too (Find All References already sees them because it unions
* every same-id/type definition).
*/ */
export function referenceSitesForDefinition( export function referenceSitesForDefinition(
idx: ModIndex, idx: ModIndex,
@@ -104,13 +107,13 @@ export function referenceSitesForDefinition(
if (!byId?.length) return sites; if (!byId?.length) return sites;
const defFile = normFileKey(def.file); const defFile = normFileKey(def.file);
const searchPaths = buildSearchPaths(idx.sdkDir, idx.projectDir); const vanillaPaths = buildVanillaSearchPaths(idx.sdkDir);
const seen = new Set( const seen = new Set(
sites.map((s) => `${s.file}\u0000${s.start}\u0000${s.end}\u0000${s.kind}`), sites.map((s) => `${s.file}\u0000${s.start}\u0000${s.end}\u0000${s.kind}`),
); );
for (const other of byId) { for (const other of byId) {
if (other.origin !== "manifest" || !other.manifestSource) continue; if (other.origin !== "manifest" || !other.manifestSource) continue;
const resolved = resolveSource(other.manifestSource, null, searchPaths).path; const resolved = resolveSource(other.manifestSource, null, vanillaPaths).path;
if (!resolved || normFileKey(resolved) !== defFile) continue; if (!resolved || normFileKey(resolved) !== defFile) continue;
for (const site of referenceSitesForDef(idx, other)) { for (const site of referenceSitesForDef(idx, other)) {
const key = `${site.file}\u0000${site.start}\u0000${site.end}\u0000${site.kind}`; const key = `${site.file}\u0000${site.start}\u0000${site.end}\u0000${site.kind}`;
+6 -1
View File
@@ -20,7 +20,12 @@ export interface AssetDef {
viaInstance?: boolean; viaInstance?: boolean;
/** Manifest path for origin === "manifest". */ /** Manifest path for origin === "manifest". */
manifest?: string; manifest?: string;
/** Source file recorded inside a manifest (e.g. "DATA:globaldata/armor.xml"). */ /**
* Source file recorded inside a manifest (e.g. "DATA:globaldata/armor.xml").
* This is a path from the vanilla build, so callers resolve it with the
* SDK-only search paths (`buildVanillaSearchPaths`), never with the current
* mod's BAB include order.
*/
manifestSource?: string; manifestSource?: string;
} }
+177
View File
@@ -0,0 +1,177 @@
/**
* Mod project root discovery for RA3 Mod XML.
*
* Pure TypeScript (no vscode dependency) so the detection rules can be unit
* tested and reused by other tools.
*
* A project root is any directory containing one of the markers the mod
* compiler (defaultscript.cs) actually consumes:
* - `Data/Mod.xml` (static data entry)
* - `Data/additionalmaps/mapmetadata_*.xml` (global data entries)
* - `*.babproj` (mod SDK project file)
*
* Discovery works in three directions:
* - upward from a folder (the workspace folder may be `Data` or a deep
* subfolder of a mod);
* - upward from a file (single-file opens without a workspace folder);
* - shallow downward from a container folder (a folder that contains
* several sibling mods).
*/
import { dirname, join, resolve } from "node:path";
import { readdirSync } from "node:fs";
export const DEFAULT_MAX_UPWARD_DEPTH = 12;
export const DEFAULT_MAX_DOWNWARD_DEPTH = 3;
export type ProjectMarkerKind = "mod" | "babproj" | "mapmetadata";
/** Directories that never contain a mod root themselves. */
const SKIP_DIRECTORY_NAMES = new Set([
"data",
"art",
"art1",
"audio",
"audio1",
"builtmods",
"builtmods-quantum",
"sageml",
"schemas",
"xsd",
"hlsl",
"node_modules",
"packages",
"dist",
"out",
"bin",
"obj",
".git",
".vs",
".vscode",
]);
/**
* Returns the marker kind found directly under `dir`, or null when `dir` is
* not a mod project root. `Data`/`mapmetadata` lookups are case-insensitive.
*/
export function projectMarkerKind(dir: string): ProjectMarkerKind | null {
const data = findCaseInsensitiveDir(dir, "Data");
if (data && hasFileIgnoreCase(data, ["Mod.xml"])) return "mod";
const entries = readDirNames(dir);
if (entries?.some((e) => e.toLowerCase().endsWith(".babproj"))) {
return "babproj";
}
if (data && hasMapMetadata(data)) return "mapmetadata";
return null;
}
/** True when `dir` is a mod project root (any marker). */
export function isProjectRoot(dir: string): boolean {
return projectMarkerKind(dir) != null;
}
/**
* Walks upward from `startDir` (up to `maxDepth` ancestors) and returns the
* nearest directory that carries a project marker, or null.
*/
export function findProjectRootUpward(
startDir: string,
maxDepth = DEFAULT_MAX_UPWARD_DEPTH,
): string | null {
let dir = resolve(startDir);
for (let i = 0; i < maxDepth; i++) {
if (isProjectRoot(dir)) return dir;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
/** Upward discovery starting from a file's directory (single-file opens). */
export function findProjectRootForFile(
file: string,
maxDepth = DEFAULT_MAX_UPWARD_DEPTH,
): string | null {
return findProjectRootUpward(dirname(resolve(file)), maxDepth);
}
/**
* Shallow downward discovery for a workspace folder that contains one or
* more mods (e.g. the SDK `Mods` folder or a personal mods container).
*
* Descends at most `maxDepth` levels, never descends into known non-mod
* directories, and stops descending once a directory is itself a project
* root (a root's own `Data`/`Art` subtrees are never project containers).
* Results are de-duplicated by normalized path.
*/
export function discoverProjects(
folder: string,
maxDepth = DEFAULT_MAX_DOWNWARD_DEPTH,
): string[] {
const out: string[] = [];
const seen = new Set<string>();
const visit = (dir: string, depth: number): void => {
if (depth > maxDepth) return;
if (isProjectRoot(dir)) {
const key = normKey(dir);
if (!seen.has(key)) {
seen.add(key);
out.push(resolve(dir));
}
return;
}
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (SKIP_DIRECTORY_NAMES.has(entry.name.toLowerCase())) continue;
visit(join(dir, entry.name), depth + 1);
}
};
visit(resolve(folder), 0);
return out;
}
function normKey(path: string): string {
return resolve(path).toLowerCase();
}
function readDirNames(dir: string): string[] | null {
try {
return readdirSync(dir);
} catch {
return null;
}
}
/** Case-insensitive child directory lookup under `parent`. */
function findCaseInsensitiveDir(parent: string, wanted: string): string | null {
const entries = readDirNames(parent);
if (!entries) return null;
const hit = entries.find(
(e) => e.toLowerCase() === wanted.toLowerCase(),
);
return hit ? join(parent, hit) : null;
}
/** True when `dir` contains any of `names` (case-insensitive file names). */
function hasFileIgnoreCase(dir: string, names: string[]): boolean {
const entries = readDirNames(dir);
if (!entries) return false;
const lower = new Set(entries.map((e) => e.toLowerCase()));
return names.some((n) => lower.has(n.toLowerCase()));
}
/** True when `dataDir/additionalmaps` contains a mapmetadata_*.xml file. */
function hasMapMetadata(dataDir: string): boolean {
const maps = findCaseInsensitiveDir(dataDir, "additionalmaps");
if (!maps) return false;
const entries = readDirNames(maps);
if (!entries) return false;
return entries.some((e) => /^mapmetadata_.*\.xml$/i.test(e));
}
+179
View File
@@ -0,0 +1,179 @@
/**
* SDK path normalization, validation and registry-based detection.
*
* Pure TypeScript (no vscode dependency) so the rules can be unit tested and
* reused by the indexer.
*
* The registry keys mirror what the SDK's own build script
* (`defaultscript.cs` initialize()) reads: the uninstall entry's
* InstallLocation, first in the 64-bit view and then under Wow6432Node.
* The installer path is only a hint - every candidate is validated against
* the actual SDK layout before being offered to the user.
*/
import { execFile } from "node:child_process";
import { readdirSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
export type SdkValidationStatus = "ok" | "partial" | "not-sdk" | "missing";
export interface SdkValidation {
/** Resolved absolute path, or "" when nothing was configured. */
path: string;
status: SdkValidationStatus;
/** Human-readable relative paths that failed validation. */
missing: string[];
}
/** Uninstall entries queried by the SDK installer (same GUIDs as defaultscript.cs). */
export const SDK_REGISTRY_KEYS = [
"HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{F6A3F605-7B10-4939-8D3D-4594332C1649}",
"HKEY_LOCAL_MACHINE\\Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{F6A3F605-7B10-4939-8D3D-4594332C1649}",
] as const;
/**
* The one required marker that identifies an RA3 Mod SDK root. The extension
* bundles its own schema model, but this file is the most distinctive SDK
* layout item (and is what `npm run generate-model` consumes).
*/
const SDK_ROOT_MARKER = ["Schemas", "xsd", "CnC3Types.xsd"] as const;
/**
* Functional items used by the extension. Missing ones degrade specific
* features (manifests, vanilla sources, SDK-side search paths), so they are
* reported as "partial" instead of rejecting the root outright.
*/
const SDK_FUNCTIONAL_ITEMS: { rel: readonly string[] }[] = [
{ rel: ["builtmods"] },
{ rel: ["SageXml"] },
{ rel: ["Mods"] },
{ rel: ["Static.xml"] },
{ rel: ["Global.xml"] },
{ rel: ["Audio.xml"] },
];
/**
* Trims quotes/whitespace and resolves to an absolute path. Returns "" for
* an empty value so callers can treat it as "no SDK configured".
*/
export function normalizeSdkPath(raw: string): string {
if (!raw) return "";
let p = String(raw).trim();
if (
p.length >= 2 &&
((p.startsWith('"') && p.endsWith('"')) ||
(p.startsWith("'") && p.endsWith("'")))
) {
p = p.slice(1, -1).trim();
}
return p ? resolve(p) : "";
}
/**
* Validates a configured/offered SDK path.
*
* - `missing`: nothing configured, or the path does not exist.
* - `not-sdk`: exists, but lacks the SDK root marker.
* - `partial`: is an SDK root, but some extension-relevant items are absent.
* - `ok`: every checked item exists.
*/
export function validateSdkPath(raw: string): SdkValidation {
const path = normalizeSdkPath(raw);
if (!path) return { path: "", status: "missing", missing: [] };
if (!isDirectory(path)) return { path, status: "missing", missing: [] };
if (!hasNestedIgnoreCase(path, SDK_ROOT_MARKER)) {
return {
path,
status: "not-sdk",
missing: [SDK_ROOT_MARKER.join("/")],
};
}
const missing: string[] = [];
for (const item of SDK_FUNCTIONAL_ITEMS) {
if (!hasNestedIgnoreCase(path, item.rel)) {
missing.push(item.rel.join("/"));
}
}
return {
path,
status: missing.length ? "partial" : "ok",
missing,
};
}
/**
* Reads InstallLocation from one registry key via `reg.exe` (Windows only).
* Returns null when the key/value is absent or the query fails.
*/
export async function readRegistryValue(
key: string,
valueName = "InstallLocation",
timeoutMs = 3000,
): Promise<string | null> {
if (process.platform !== "win32") return null;
try {
const stdout = await new Promise<string>((resolveValue, reject) => {
execFile(
"reg",
["query", key, "/v", valueName],
{ timeout: timeoutMs, windowsHide: true },
(err, stdout, _stderr) => {
if (err) reject(err);
else resolveValue(stdout);
},
);
});
return parseRegistryInstallLocation(stdout);
} catch {
return null;
}
}
/** Extracts the InstallLocation value from `reg.exe query` output. */
export function parseRegistryInstallLocation(stdout: string): string | null {
for (const line of stdout.split(/\r?\n/)) {
const m = line.match(/^\s*InstallLocation\s+REG_[A-Z_]+\s+(.+?)\s*$/i);
if (m?.[1]) return m[1].trim();
}
return null;
}
/** Queries both registry views in the same order defaultscript.cs uses. */
export async function detectSdkPathFromRegistry(): Promise<string | null> {
for (const key of SDK_REGISTRY_KEYS) {
const value = await readRegistryValue(key);
if (value?.trim()) return value.trim();
}
return null;
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function readDirNames(dir: string): string[] | null {
try {
return readdirSync(dir);
} catch {
return null;
}
}
function hasNestedIgnoreCase(root: string, rel: readonly string[]): boolean {
let dir = root;
for (let i = 0; i < rel.length - 1; i++) {
const names = readDirNames(dir);
if (!names) return false;
const hit = names.find((n) => n.toLowerCase() === rel[i].toLowerCase());
if (!hit) return false;
dir = join(dir, hit);
}
const names = readDirNames(dir);
if (!names) return false;
const wanted = rel[rel.length - 1].toLowerCase();
return names.some((n) => n.toLowerCase() === wanted);
}
+168
View File
@@ -0,0 +1,168 @@
import * as vscode from "vscode";
import type { ModWorkspace } from "./workspace";
import {
detectSdkPathFromRegistry,
validateSdkPath,
type SdkValidation,
} from "./sdk";
/**
* Non-intrusive SDK path guidance: a status-bar hint plus a one-time prompt
* (per session). The prompt prefers a validated registry-detected path, then
* falls back to a folder picker. Clearing `ra3modxml.sdkPath` explicitly is
* treated as "intentionally disabled" and never re-prompts.
*/
export class SdkSetup {
private readonly statusBar: vscode.StatusBarItem;
private promptAttempted = false;
constructor(
context: vscode.ExtensionContext,
private readonly getWs: () => ModWorkspace | null,
) {
this.statusBar = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
99,
);
this.statusBar.name = "RA3 Mod XML SDK";
this.statusBar.command = "ra3modxml.configureSdkPath";
context.subscriptions.push(this.statusBar);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.configureSdkPath", () => {
const ws = this.getWs();
if (!ws) {
void vscode.window.showInformationMessage(
"RA3 Mod XML: 打开 RA3 Mod 项目后即可配置 SDK 路径。",
);
return;
}
void this.runSetup();
}),
);
}
async evaluate(ws: ModWorkspace): Promise<void> {
if (!ws.isRa3Workspace()) {
this.statusBar.hide();
return;
}
const config = vscode.workspace.getConfiguration("ra3modxml");
const raw = config.get<string>("sdkPath", "");
const explicit = isExplicitlyConfigured(config);
const validation = validateSdkPath(raw);
if (validation.status === "ok") {
this.statusBar.hide();
return;
}
// An explicit empty value means "no SDK, project-only mode" - never nag.
if (!raw && explicit) {
this.statusBar.hide();
return;
}
this.statusBar.text = statusBarText(validation);
this.statusBar.tooltip = describeSdkValidation(validation);
this.statusBar.show();
if (!this.promptAttempted) {
this.promptAttempted = true;
await this.runSetup();
}
}
private async runSetup(): Promise<void> {
const detected = await detectSdkPathFromRegistry();
const detectedValidation = detected ? validateSdkPath(detected) : null;
if (
detectedValidation &&
(detectedValidation.status === "ok" ||
detectedValidation.status === "partial")
) {
const pick = await vscode.window.showWarningMessage(
`RA3 Mod XML 未找到有效的 SDK 路径。检测到已安装的 SDK:${detectedValidation.path}`,
"使用检测到的路径",
"手动选择…",
"暂时不用",
);
if (pick === "使用检测到的路径") {
await applySdkPath(detectedValidation.path);
} else if (pick === "手动选择…") {
await this.chooseAndApply();
}
return;
}
const pick = await vscode.window.showWarningMessage(
"RA3 Mod XML 需要 RA3 Mod SDK 路径才能启用原版数据、manifest 与跨文件补全/跳转功能。未设置时插件将以项目模式运行。",
"选择 SDK 文件夹…",
"暂时不用",
);
if (pick === "选择 SDK 文件夹…") await this.chooseAndApply();
}
private async chooseAndApply(): Promise<void> {
const picked = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: "选择 SDK 根目录",
title: "选择 RA3 Mod SDK 根目录(应包含 Schemas/xsd/CnC3Types.xsd",
});
const dir = picked?.[0]?.fsPath;
if (!dir) return;
const validation = validateSdkPath(dir);
if (validation.status === "missing" || validation.status === "not-sdk") {
void vscode.window.showErrorMessage(
`所选目录不是可用的 RA3 Mod SDK(缺少 ${
validation.missing.join("、") || "该目录"
})。请重新选择。`,
);
return;
}
await applySdkPath(validation.path);
}
}
function statusBarText(validation: SdkValidation): string {
if (validation.status === "missing") {
return validation.path
? "$(warning) RA3 XML: SDK 路径不存在"
: "$(warning) RA3 XML: 未设置 SDK";
}
if (validation.status === "not-sdk") return "$(warning) RA3 XML: SDK 路径无效";
return "$(warning) RA3 XML: SDK 不完整";
}
function describeSdkValidation(validation: SdkValidation): string {
if (validation.status === "missing") {
return validation.path
? `ra3modxml.sdkPath 指向的目录不存在:${validation.path}。点击重新设置;或将 ra3modxml.sdkPath 清空以禁用原版数据功能。`
: "未配置 RA3 Mod SDK 路径。点击设置;或将 ra3modxml.sdkPath 清空以禁用原版数据功能。";
}
if (validation.status === "not-sdk") {
return "ra3modxml.sdkPath 指向的目录不是 RA3 Mod SDK 根目录(缺少 Schemas/xsd/CnC3Types.xsd)。点击重新设置。";
}
return `RA3 Mod SDK 缺少:${validation.missing.join("、")}。manifest / 原版源码 / SDK 搜索路径等功能不可用。`;
}
function isExplicitlyConfigured(
config: vscode.WorkspaceConfiguration,
): boolean {
const info = config.inspect<string>("sdkPath");
return !!(
info &&
(info.globalValue !== undefined ||
info.workspaceValue !== undefined ||
info.workspaceFolderValue !== undefined)
);
}
async function applySdkPath(path: string): Promise<void> {
await vscode.workspace
.getConfiguration("ra3modxml")
.update("sdkPath", path, vscode.ConfigurationTarget.Global);
void vscode.window.showInformationMessage(
`RA3 Mod XML: SDK 路径已设置为 ${path},正在重建索引…`,
);
}
+5 -2
View File
@@ -1,5 +1,6 @@
import * as vscode from "vscode"; import * as vscode from "vscode";
import { join } from "node:path"; import { join } from "node:path";
import { normalizeSdkPath } from "./sdk";
export interface ExtensionSettings { export interface ExtensionSettings {
sdkPath: string; sdkPath: string;
@@ -13,7 +14,7 @@ export interface ExtensionSettings {
export function readSettings(): ExtensionSettings { export function readSettings(): ExtensionSettings {
const cfg = vscode.workspace.getConfiguration("ra3modxml"); const cfg = vscode.workspace.getConfiguration("ra3modxml");
const sdkPath = cfg.get<string>("sdkPath", "C:\\Apps\\RA3-MODSDK-X"); const sdkPath = normalizeSdkPath(cfg.get<string>("sdkPath", ""));
return { return {
sdkPath, sdkPath,
indexSageXml: cfg.get<boolean>("indexSageXml", true), indexSageXml: cfg.get<boolean>("indexSageXml", true),
@@ -27,6 +28,8 @@ export function readSettings(): ExtensionSettings {
"all", "all",
) as ExtensionSettings["definitionMode"], ) as ExtensionSettings["definitionMode"],
additionalDataSearchPaths: cfg.get<string[]>("additionalDataSearchPaths", []), additionalDataSearchPaths: cfg.get<string[]>("additionalDataSearchPaths", []),
builtmodsDirs: [join(sdkPath, "builtmods"), join(sdkPath, "builtmods-quantum")], builtmodsDirs: sdkPath
? [join(sdkPath, "builtmods"), join(sdkPath, "builtmods-quantum")]
: [],
}; };
} }
+741 -249
View File
File diff suppressed because it is too large Load Diff
+107 -27
View File
@@ -17,6 +17,18 @@ class CodeLens {
this.command = command; this.command = command;
} }
} }
class EventEmitter {
constructor() {
this.listeners = [];
this.event = (listener) => {
this.listeners.push(listener);
return { dispose: () => {} };
};
}
fire() {
for (const listener of this.listeners) listener();
}
}
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const Module = require("module"); const Module = require("module");
@@ -32,6 +44,7 @@ require.cache["vscode-stub"] = {
exports: { exports: {
Range, Range,
CodeLens, CodeLens,
EventEmitter,
}, },
}; };
@@ -72,6 +85,20 @@ function makeDocument(text = TEXT) {
} }
function makeIndex() { function makeIndex() {
const tankDef = {
type: "GameObject",
id: "TestTank",
file: FILE,
line: 2,
origin: "project",
};
const baseDef = {
type: "GameObject",
id: "BaseVehicle",
file: FILE,
line: 3,
origin: "project",
};
const tankSite = { const tankSite = {
file: "C:/mod/Data/Other.xml", file: "C:/mod/Data/Other.xml",
line: 7, line: 7,
@@ -88,37 +115,33 @@ function makeIndex() {
}; };
const references = new Map(); const references = new Map();
references.set( references.set(
assetDefKey({ assetDefKey(tankDef),
type: "GameObject",
id: "TestTank",
file: FILE,
line: 2,
}),
[tankSite, secondSite], [tankSite, secondSite],
); );
references.set( references.set(assetDefKey(baseDef), []);
assetDefKey({
type: "GameObject",
id: "BaseVehicle",
file: FILE,
line: 3,
}),
[],
);
return { return {
references, references,
assets: new Map(), assets: new Map(),
assetsById: new Map([
["testtank", [tankDef]],
["basevehicle", [baseDef]],
]),
stats: { indexedFiles: 10 },
sdkDir: SDK, sdkDir: SDK,
projectDir: PROJECT, projectDir: PROJECT,
}; };
} }
test("CodeLens shows counts on reference-target types only, including zero", () => { test("CodeLens shows counts on reference-target types only, including zero", async () => {
const idx = makeIndex();
const provider = new Ra3CodeLensProvider({ const provider = new Ra3CodeLensProvider({
isRa3Workspace: () => true, isRa3Workspace: () => true,
index: makeIndex(), index: idx,
getCodeLensScope: async () => ({ merged: idx }),
recordsSyncSurfaceFor: () => ({}),
log: () => {},
}); });
const lenses = provider.provideCodeLenses(makeDocument(), {}); const lenses = await provider.provideCodeLenses(makeDocument(), {});
assert.equal(lenses.length, 2, "no lens for auto-registered CameraSettings"); assert.equal(lenses.length, 2, "no lens for auto-registered CameraSettings");
const tank = lenses.find((l) => l.command.arguments[0].id === "TestTank"); const tank = lenses.find((l) => l.command.arguments[0].id === "TestTank");
@@ -138,21 +161,69 @@ test("CodeLens shows counts on reference-target types only, including zero", ()
assert.ok(tank.range.start.character < tank.range.end.character); assert.ok(tank.range.start.character < tank.range.end.character);
}); });
test("CodeLens returns nothing without a workspace or index", () => { test("CodeLens returns nothing without a workspace or index", async () => {
const noWorkspace = new Ra3CodeLensProvider({ const noWorkspace = new Ra3CodeLensProvider({
isRa3Workspace: () => false, isRa3Workspace: () => false,
index: makeIndex(), index: makeIndex(),
}); });
assert.deepEqual(noWorkspace.provideCodeLenses(makeDocument(), {}), []); assert.deepEqual(await noWorkspace.provideCodeLenses(makeDocument(), {}), []);
const noIndex = new Ra3CodeLensProvider({ const noIndex = new Ra3CodeLensProvider({
isRa3Workspace: () => true, isRa3Workspace: () => true,
index: null, index: null,
getCodeLensScope: async () => ({ merged: null }),
recordsSyncSurfaceFor: () => ({}),
log: () => {},
}); });
assert.deepEqual(noIndex.provideCodeLenses(makeDocument(), {}), []); assert.deepEqual(await noIndex.provideCodeLenses(makeDocument(), {}), []);
}); });
test("CodeLens counts references attached to a manifest definition with the same SageXml source", () => { test("CodeLens hides lenses before the first global snapshot", async () => {
const localOnly = {
complete: false,
stats: { indexedFiles: 0 },
references: new Map(),
};
const logs = [];
const provider = new Ra3CodeLensProvider({
isRa3Workspace: () => true,
getCodeLensScope: async () => ({ merged: localOnly }),
recordsSyncSurfaceFor: () => ({}),
log: (m) => logs.push(m),
});
assert.deepEqual(await provider.provideCodeLenses(makeDocument(), {}), []);
assert.deepEqual(await provider.provideCodeLenses(makeDocument(), {}), []);
assert.equal(
logs.filter((m) => m.includes("suppressed")).length,
1,
"suppression is logged once per document",
);
provider.resetSuppressionLog();
await provider.provideCodeLenses(makeDocument(), {});
assert.equal(
logs.filter((m) => m.includes("suppressed")).length,
2,
"reset allows re-logging after a new snapshot",
);
});
test("CodeLens refresh fires onDidChangeCodeLenses", () => {
const provider = new Ra3CodeLensProvider({
isRa3Workspace: () => true,
getCodeLensScope: async () => ({ merged: null }),
recordsSyncSurfaceFor: () => ({}),
log: () => {},
});
let fired = 0;
const subscription = provider.onDidChangeCodeLenses(() => {
fired++;
});
provider.refresh();
assert.equal(fired, 1);
subscription.dispose();
});
test("CodeLens counts references attached to a manifest definition with the same SageXml source", async () => {
const manifestDef = { const manifestDef = {
type: "GameObject", type: "GameObject",
id: "TestTank", id: "TestTank",
@@ -175,30 +246,39 @@ test("CodeLens counts references attached to a manifest definition with the same
assets: new Map([ assets: new Map([
["GameObject", new Map([["testtank", [manifestDef]]])], ["GameObject", new Map([["testtank", [manifestDef]]])],
]), ]),
assetsById: new Map([["testtank", [manifestDef]]]),
stats: { indexedFiles: 10 },
sdkDir: SDK, sdkDir: SDK,
projectDir: PROJECT, projectDir: PROJECT,
}; };
const provider = new Ra3CodeLensProvider({ const provider = new Ra3CodeLensProvider({
isRa3Workspace: () => true, isRa3Workspace: () => true,
index: idx, index: idx,
getCodeLensScope: async () => ({ merged: idx }),
recordsSyncSurfaceFor: () => ({}),
log: () => {},
}); });
const lenses = provider.provideCodeLenses(makeDocument(), {}); const lenses = await provider.provideCodeLenses(makeDocument(), {});
const tank = lenses.find((l) => l.command.arguments[0].id === "TestTank"); const tank = lenses.find((l) => l.command.arguments[0].id === "TestTank");
assert.ok(tank, "lens is shown for the SageXml-backed definition"); assert.ok(tank, "lens is shown for the SageXml-backed definition");
assert.equal(tank.command.title, "1 reference"); assert.equal(tank.command.title, "1 reference");
}); });
test("CodeLens schedules a targeted rebuild when the open document desyncs from the snapshot", () => { test("CodeLens schedules a targeted rebuild when the open document desyncs from the snapshot", async () => {
const idx = makeIndex(); const idx = makeIndex();
idx.recordsHashes = new Map([[normKey(FILE), "stale-hash"]]); idx.recordsHashes = new Map([[normKey(FILE), "stale-hash"]]);
const calls = []; const calls = [];
const provider = new Ra3CodeLensProvider({ const ws = {
isRa3Workspace: () => true, isRa3Workspace: () => true,
index: idx, index: idx,
invalidate: (p) => calls.push(["invalidate", p]), invalidate: (p) => calls.push(["invalidate", p]),
scheduleRebuild: (r) => calls.push(["schedule", r]), scheduleRebuild: (r) => calls.push(["schedule", r]),
}); getCodeLensScope: async () => ({ merged: idx }),
provider.provideCodeLenses(makeDocument(), {}); recordsSyncSurfaceFor: () => ws,
log: () => {},
};
const provider = new Ra3CodeLensProvider(ws);
await provider.provideCodeLenses(makeDocument(), {});
assert.ok( assert.ok(
calls.some(([kind]) => kind === "invalidate"), calls.some(([kind]) => kind === "invalidate"),
"the stale file is invalidated", "the stale file is invalidated",
+116
View File
@@ -416,6 +416,64 @@ test("whitespace used to trigger the popup is consumed on newline insert", async
assert.equal(count.range.end.character, pos.character); assert.equal(count.range.end.character, pos.character);
}); });
test("attribute completion in the middle of a one-per-line start tag does not add a newline", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject\n` +
` Options="IGNORE_ALL_OBJECTS"\n` +
` C\n` +
` Disposition="RANDOM_FORCE RELATIVE_ANGLE">`;
const pos = new Position(4, 7);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const count = items.find((i) => i.label === "Count");
assert.ok(count);
// The attribute is already on its own line; inserting another newline
// would leave a blank line. Only the partial name is replaced.
assert.equal(count.insertText.value, ' Count="1"');
assert.equal(count.range.start.character, 0);
assert.equal(count.range.end.character, 7);
});
test("attribute completion before the first attribute on a new line does not add a newline", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject\n` +
` C\n` +
` Options="IGNORE_ALL_OBJECTS"\n` +
` Disposition="RANDOM_FORCE RELATIVE_ANGLE">`;
const pos = new Position(3, 7);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const count = items.find((i) => i.label === "Count");
assert.ok(count);
assert.equal(count.insertText.value, ' Count="1"');
assert.equal(count.range.start.character, 0);
assert.equal(count.range.end.character, 7);
});
test("attribute completion right after the element name still wraps in one-per-line files", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject \n` +
` Options="IGNORE_ALL_OBJECTS"\n` +
` Disposition="RANDOM_FORCE RELATIVE_ANGLE">`;
const line3 = text.split("\n")[3];
const pos = new Position(3, line3.length);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const count = items.find((i) => i.label === "Count");
assert.ok(count);
// The new attribute would be the first one on the element-name line, so a
// one-per-line file still wraps it onto its own line.
assert.equal(count.insertText.value, '\nCount="1"');
assert.equal(count.range.start.character, pos.character);
assert.equal(count.range.end.character, pos.character);
});
test("scalar attributes get typed default values, suggestion attributes keep $1", async () => { test("scalar attributes get typed default values, suggestion attributes keep $1", async () => {
const text = const text =
`<AssetDeclaration>\n` + `<AssetDeclaration>\n` +
@@ -859,3 +917,61 @@ test("current-file local overlay assets survive the global 400 cap", async () =>
assert.equal(result.isIncomplete, true); assert.equal(result.isIncomplete, true);
assert.ok(result.items.some((i) => i.label === "CrateDebris_01")); assert.ok(result.items.some((i) => i.label === "CrateDebris_01"));
}); });
test("asset-id completion shows one entry per id across local/global/manifest definitions", async () => {
const projectDef = {
type: "WeaponTemplate",
id: "AlliedCommandoDesertEaglesWarhead",
file: "C:/mod/Data/GlobalData/Weapon/Weapon_Allied.xml",
line: 10,
origin: "project",
};
const unsavedLocalDef = {
type: "WeaponTemplate",
id: "AlliedCommandoDesertEaglesWarhead",
file: "C:/mod/Data/GlobalData/Weapon/Weapon_Allied.xml",
line: 14,
origin: "project",
stream: "local",
};
const manifestDef = {
type: "WeaponTemplate",
id: "AlliedCommandoDesertEaglesWarhead",
file: "C:/sdk/builtmods/static.manifest",
line: 0,
origin: "manifest",
manifestSource: "DATA:static.xml",
};
const idKey = "alliedcommandodeserteagleswarhead";
const idx = {
assets: new Map([
["WeaponTemplate", new Map([[idKey, [projectDef, manifestDef]]])],
]),
assetsById: new Map([[idKey, [projectDef, manifestDef]]]),
local: {
assets: new Map([["WeaponTemplate", new Map([[idKey, [unsavedLocalDef]]])]]),
assetsById: new Map([[idKey, [unsavedLocalDef]]]),
defines: new Map(),
},
};
const text =
`<AssetDeclaration>\n` +
` <ProjectileNugget WarheadTemplate="A">\n` +
` </ProjectileNugget>\n` +
`</AssetDeclaration>`;
const line = text.split("\n")[1];
const pos = new Position(1, line.indexOf('"A') + 2);
const result = await makeProvider(idx).provideCompletionItems(
makeDocument(text),
pos,
token,
);
const items = listItems(result);
const matches = items.filter((i) => i.label === "AlliedCommandoDesertEaglesWarhead");
assert.equal(matches.length, 1, "same id from local/global/manifest is offered once");
assert.ok(
matches[0].documentation.value.includes("Also defined"),
"additional definitions are listed in the item documentation",
);
});
+174 -1
View File
@@ -1,6 +1,9 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
// Minimal vscode shim for hover / definition / diagnostics providers. // Minimal vscode shim for hover / definition / diagnostics providers.
const CompletionItemKind = {}; const CompletionItemKind = {};
@@ -37,7 +40,7 @@ class Hover {
class Location { class Location {
constructor(uri, range) { constructor(uri, range) {
this.uri = uri; this.uri = uri;
this.range = range; this.range = range instanceof Position ? new Range(range, range) : range;
} }
} }
class Diagnostic { class Diagnostic {
@@ -236,6 +239,176 @@ test("Ctrl+click on simple-content text jumps to the definition", 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-"));
try {
const sdkDir = join(tmp, "sdk");
const projectDir = join(tmp, "project");
const sageFile = join(sdkDir, "SageXml", "globaldata", "weapon.xml");
const modFile = join(projectDir, "Data", "globaldata", "weapon.xml");
mkdirSync(dirname(sageFile), { recursive: true });
mkdirSync(dirname(modFile), { recursive: true });
writeFileSync(
sageFile,
'<AssetDeclaration xmlns="uri:ea.com:eala:asset"><GameObject id="AlliedCommandoDesertEagles"/></AssetDeclaration>',
"utf8",
);
writeFileSync(
modFile,
'<AssetDeclaration xmlns="uri:ea.com:eala:asset"><GameObject id="ModOnly"/></AssetDeclaration>',
"utf8",
);
const manifestDef = {
type: "GameObject",
id: "AlliedCommandoDesertEagles",
file: join(sdkDir, "builtmods", "static.manifest"),
line: 0,
origin: "manifest",
manifestSource: "DATA:globaldata/weapon.xml",
};
const idx = makeIdx([manifestDef]);
idx.projectDir = projectDir;
idx.sdkDir = sdkDir;
const text =
'<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n' +
' <GameObject id="MyUnit" inheritFrom="AlliedCommandoDesertEagles"/>\n' +
"</AssetDeclaration>";
const scope = await makeScope(text, idx);
const provider = new Ra3DefinitionProvider({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: { definitionMode: "all" },
indexer: { readDom: async () => null },
});
const line = text.split("\n")[1];
const pos = new Position(
1,
line.indexOf("AlliedCommandoDesertEagles") + 3,
);
const locations = await provider.provideDefinition(makeDocument(text), pos, {});
assert.ok(locations && locations.length === 1, "manifest definition resolves");
assert.equal(
locations[0].uri.fsPath,
sageFile,
"manifest source must resolve to SageXml, not the mod shadow file",
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("manifest definition stays manifest-only when the SageXml source is missing", async () => {
const tmp = mkdtempSync(join(tmpdir(), "ra3-nav-manifest-missing-"));
try {
const sdkDir = join(tmp, "sdk");
const projectDir = join(tmp, "project");
const modFile = join(projectDir, "Data", "globaldata", "weapon.xml");
mkdirSync(dirname(modFile), { recursive: true });
writeFileSync(
modFile,
'<AssetDeclaration xmlns="uri:ea.com:eala:asset"><GameObject id="AlliedCommandoDesertEagles"/></AssetDeclaration>',
"utf8",
);
const manifestDef = {
type: "GameObject",
id: "AlliedCommandoDesertEagles",
file: join(sdkDir, "builtmods", "static.manifest"),
line: 0,
origin: "manifest",
manifestSource: "DATA:globaldata/weapon.xml",
};
const idx = makeIdx([manifestDef]);
idx.projectDir = projectDir;
idx.sdkDir = sdkDir;
const text =
'<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n' +
' <GameObject id="MyUnit" inheritFrom="AlliedCommandoDesertEagles"/>\n' +
"</AssetDeclaration>";
const scope = await makeScope(text, idx);
const provider = new Ra3DefinitionProvider({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: { definitionMode: "all" },
indexer: { readDom: async () => null },
});
const line = text.split("\n")[1];
const pos = new Position(
1,
line.indexOf("AlliedCommandoDesertEagles") + 3,
);
const locations = await provider.provideDefinition(makeDocument(text), pos, {});
assert.equal(
locations,
null,
"missing vanilla source must not fall back to the mod shadow file",
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("manifest definition opens the SageXml file at the top when the id is no longer there", async () => {
const tmp = mkdtempSync(join(tmpdir(), "ra3-nav-manifest-stale-"));
try {
const sdkDir = join(tmp, "sdk");
const projectDir = join(tmp, "project");
const sageFile = join(sdkDir, "SageXml", "globaldata", "weapon.xml");
const modFile = join(projectDir, "Data", "globaldata", "weapon.xml");
mkdirSync(dirname(sageFile), { recursive: true });
mkdirSync(dirname(modFile), { recursive: true });
writeFileSync(
sageFile,
'<AssetDeclaration xmlns="uri:ea.com:eala:asset"/>',
"utf8",
);
writeFileSync(
modFile,
'<AssetDeclaration xmlns="uri:ea.com:eala:asset"><GameObject id="AlliedCommandoDesertEagles"/></AssetDeclaration>',
"utf8",
);
const manifestDef = {
type: "GameObject",
id: "AlliedCommandoDesertEagles",
file: join(sdkDir, "builtmods", "static.manifest"),
line: 0,
origin: "manifest",
manifestSource: "DATA:globaldata/weapon.xml",
};
const idx = makeIdx([manifestDef]);
idx.projectDir = projectDir;
idx.sdkDir = sdkDir;
const text =
'<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n' +
' <GameObject id="MyUnit" inheritFrom="AlliedCommandoDesertEagles"/>\n' +
"</AssetDeclaration>";
const scope = await makeScope(text, idx);
const provider = new Ra3DefinitionProvider({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: { definitionMode: "all" },
indexer: { readDom: async () => null },
});
const line = text.split("\n")[1];
const pos = new Position(
1,
line.indexOf("AlliedCommandoDesertEagles") + 3,
);
const locations = await provider.provideDefinition(makeDocument(text), pos, {});
assert.ok(locations && locations.length === 1);
assert.equal(locations[0].uri.fsPath, sageFile);
assert.equal(locations[0].range.start.line, 0);
assert.equal(locations[0].range.start.character, 0);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("diagnostics report unresolved typed content references only", async () => { test("diagnostics report unresolved typed content references only", async () => {
const text = const text =
`<AssetDeclaration>\n` + `<AssetDeclaration>\n` +
+53
View File
@@ -137,3 +137,56 @@ test("diskCacheKey differs when the identity changes", () => {
assert.notEqual(a, b); assert.notEqual(a, b);
assert.equal(a, diskCacheKey(identity)); assert.equal(a, diskCacheKey(identity));
}); });
test("load returns records without stat validation", async (t) => {
const tmp = makeTmp(t);
const file = join(tmp, "a.xml");
fs.writeFileSync(file, "0123456789");
const filePath = join(tmp, "index-records.json.gz");
const cache = new DiskRecordsCache(filePath, identity);
await cache.save([
[
file.toLowerCase(),
{ stat: stampOf(file), records: sampleRecords, kind: "full" },
],
]);
const { records, stats } = await cache.load();
assert.equal(records.length, 1);
assert.equal(stats.fileExists, true);
assert.equal(stats.keyMatched, true);
assert.equal(stats.loaded, 1);
assert.equal(stats.validated, 0);
assert.equal(stats.dropped, 0);
assert.ok(stats.loadMs >= 0);
});
test("validate reports changed/missing entries and keeps valid ones", async (t) => {
const tmp = makeTmp(t);
const a = join(tmp, "a.xml");
const b = join(tmp, "b.xml");
fs.writeFileSync(a, "0123456789");
fs.writeFileSync(b, "0123456789");
const filePath = join(tmp, "index-records.json.gz");
const cache = new DiskRecordsCache(filePath, identity);
await cache.save([
[a.toLowerCase(), { stat: stampOf(a), records: sampleRecords, kind: "full" }],
[b.toLowerCase(), { stat: stampOf(b), records: sampleRecords, kind: "full" }],
]);
const { records } = await cache.load();
const past = new Date(Date.now() - 60000);
fs.utimesSync(b, past, past);
const progress = [];
const { stats, kept, invalidKeys } = await cache.validate(records, (done, total) => {
progress.push([done, total]);
});
assert.equal(stats.validated, 1);
assert.equal(stats.dropped, 1);
assert.equal(kept.length, 1);
assert.equal(kept[0].key, a.toLowerCase());
assert.deepEqual(invalidKeys, [b.toLowerCase()]);
assert.ok(stats.validateMs >= 0);
assert.deepEqual(progress[progress.length - 1], [1, 2]);
assert.ok(progress.every(([done], i) => i === 0 || done >= progress[i - 1][0]));
});
+1
View File
@@ -0,0 +1 @@
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" />
+91
View File
@@ -2,8 +2,11 @@ import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { import {
buildSearchPaths, buildSearchPaths,
buildVanillaSearchPaths,
resolveSource, resolveSource,
manifestPathForReference, manifestPathForReference,
} from "../out/indexer/includeResolver.js"; } from "../out/indexer/includeResolver.js";
@@ -81,6 +84,94 @@ test("DATA:Static.xml prefers the SDK root over SageXml", () => {
assert.equal(r.path, join(sdk, "Static.xml")); assert.equal(r.path, join(sdk, "Static.xml"));
}); });
test("vanilla search paths stay inside the SDK", () => {
const vanilla = buildVanillaSearchPaths(sdk);
assert.deepEqual(vanilla.DATA, [sdk, join(sdk, "SageXml")]);
assert.deepEqual(vanilla.ART, [sdk, join(sdk, "Art")]);
assert.deepEqual(vanilla.AUDIO, [sdk, join(sdk, "Audio")]);
});
test("empty SDK path produces project-only search paths", () => {
const modParent = dirname(project);
const granParent = dirname(modParent);
const paths = buildSearchPaths("", project);
assert.deepEqual(paths.DATA, [
granParent,
join(project, "Data"),
modParent,
]);
assert.deepEqual(paths.ART, [
granParent,
join(project, "Art1"),
join(project, "Art"),
modParent,
]);
assert.deepEqual(paths.AUDIO, [
granParent,
join(project, "Audio1"),
join(project, "Audio"),
modParent,
]);
const r = resolveSource("DATA:static.xml", null, paths);
assert.equal(r.path, null, "DATA: include never falls back to the cwd");
const vanilla = buildVanillaSearchPaths("");
assert.deepEqual(vanilla.DATA, []);
assert.deepEqual(vanilla.ART, []);
assert.deepEqual(vanilla.AUDIO, []);
});
test("manifest sources resolve with vanilla-only paths (mod shadow ignored)", () => {
const tmp = mkdtempSync(join(tmpdir(), "ra3-vanilla-"));
try {
const sdkDir = join(tmp, "sdk");
const projectDir = join(tmp, "project");
const rel = "globaldata/weapon.xml";
const sageFile = join(sdkDir, "SageXml", rel);
const modFile = join(projectDir, "Data", rel);
mkdirSync(dirname(sageFile), { recursive: true });
mkdirSync(dirname(modFile), { recursive: true });
writeFileSync(sageFile, "<AssetDeclaration/>", "utf8");
writeFileSync(modFile, "<AssetDeclaration/>", "utf8");
const normal = resolveSource(
"DATA:globaldata/weapon.xml",
null,
buildSearchPaths(sdkDir, projectDir),
);
const vanilla = resolveSource(
"DATA:globaldata/weapon.xml",
null,
buildVanillaSearchPaths(sdkDir),
);
assert.equal(normal.path, modFile, "normal BAB order picks the mod file");
assert.equal(vanilla.path, sageFile, "manifest source stays on SageXml");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("missing vanilla source returns null even when the project shadows the path", () => {
const tmp = mkdtempSync(join(tmpdir(), "ra3-vanilla-missing-"));
try {
const sdkDir = join(tmp, "sdk");
const projectDir = join(tmp, "project");
const modFile = join(projectDir, "Data", "globaldata", "weapon.xml");
mkdirSync(dirname(modFile), { recursive: true });
writeFileSync(modFile, "<AssetDeclaration/>", "utf8");
const vanilla = resolveSource(
"DATA:globaldata/weapon.xml",
null,
buildVanillaSearchPaths(sdkDir),
);
assert.equal(vanilla.path, null);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("manifest mapping strips the prefix", () => { test("manifest mapping strips the prefix", () => {
const dirs = [join(sdk, "builtmods")]; const dirs = [join(sdk, "builtmods")];
assert.equal(manifestPathForReference("DATA:static.xml", dirs), join(dirs[0], "static.manifest")); assert.equal(manifestPathForReference("DATA:static.xml", dirs), join(dirs[0], "static.manifest"));
+81
View File
@@ -315,6 +315,47 @@ test("trusted rebuilds skip unchanged files; invalidation forces re-reads", asyn
assert.equal(forced.stats.shallowCacheHits, 2); assert.equal(forced.stats.shallowCacheHits, 2);
}); });
test("unvalidated shallow entries are deferred in phase A and stat-verified before phase B", async () => {
const documentCache = new DocumentCache();
const recordsCache = new IndexRecordsCache();
const resolveCache = new IncludeResolveCache();
const opts = () => ({
projectDir: project,
sdkDir: sdk,
builtmodsDirs: [join(sdk, "builtmods")],
indexSageXml: true,
additionalDataSearchPaths: [],
walker: new CachedDirectoryWalker(),
documentCache,
recordsCache,
resolveCache,
trustUnchanged: true,
});
const first = await new ModIndexer(opts()).build();
// Simulate the workspace pre-seeding a disk cache: shallow records are
// present but not stat-validated yet.
for (const [, entry] of recordsCache.entries()) {
if (entry.kind === "shallow") entry.validated = false;
}
let phaseA = null;
const second = await new ModIndexer(opts()).build((p) => {
phaseA = p;
});
assert.equal(phaseA.stats.deferredArtFiles, 2, "art files registered, not consumed, in phase A");
assert.equal(
phaseA.assetsById.has("tank_skn"),
false,
"art assets are deferred until phase B",
);
assert.equal(second.stats.shallowScannedFiles, 0, "validated records are not re-scanned");
assert.ok(
second.assetsById.get("tank_skn")?.some((d) => d.type === "W3DContainer"),
"art asset present after phase B",
);
});
test("index stats include candidate/walk phase timings", async () => { test("index stats include candidate/walk phase timings", async () => {
const idx = await buildIndex(); const idx = await buildIndex();
assert.equal(typeof idx.stats.candidatesMs, "number"); assert.equal(typeof idx.stats.candidatesMs, "number");
@@ -360,3 +401,43 @@ test("w3x with a UTF-8 BOM is indexed with correct offsets", async (t) => {
assert.ok(def, "BOM-prefixed w3x asset indexed"); assert.ok(def, "BOM-prefixed w3x asset indexed");
assert.equal(def.line, 3, "id line is correct despite the BOM"); assert.equal(def.line, 3, "id line is correct despite the BOM");
}); });
test("indexes the project without an SDK path (project-only mode)", async () => {
const idx = await new ModIndexer({
projectDir: project,
sdkDir: "",
builtmodsDirs: [],
indexSageXml: true,
additionalDataSearchPaths: [],
walker: new CachedDirectoryWalker(),
}).build();
assert.ok(idx.complete, "build completes without an SDK");
assert.ok(idx.assetsById.has("testtank"), "project assets are still indexed");
assert.equal(
idx.diagnostics.some(
(d) => d.code === "include-not-found" && /DATA:/.test(d.message),
),
false,
"SDK-only include misses are suppressed in project-only mode",
);
assert.ok(
idx.diagnostics.some((d) => d.code === "sdk-not-configured"),
"one summary SDK diagnostic is reported",
);
});
test("missing SDK path does not abort the build", async () => {
const missing = join(os.tmpdir(), "ra3modxml-no-such-sdk");
const idx = await new ModIndexer({
projectDir: project,
sdkDir: missing,
builtmodsDirs: [],
indexSageXml: true,
additionalDataSearchPaths: [],
walker: new CachedDirectoryWalker(),
}).build();
assert.ok(idx.complete);
assert.ok(idx.assetsById.has("testtank"));
});
+161
View File
@@ -0,0 +1,161 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const {
findProjectRootUpward,
findProjectRootForFile,
discoverProjects,
isProjectRoot,
} = require("../out/projectRoot.js");
let fixtureRoot;
let counter = 0;
function scratch(rel = "") {
if (!fixtureRoot) {
fixtureRoot = mkdtempSync(join(tmpdir(), "ra3-projectroot-"));
}
const dir = join(fixtureRoot, String(counter++), rel);
mkdirSync(dir, { recursive: true });
return dir;
}
function write(dir, rel, content = "") {
const file = join(dir, rel);
mkdirSync(join(file, ".."), { recursive: true });
writeFileSync(file, content);
return file;
}
test.after(() => {
if (fixtureRoot) rmSync(fixtureRoot, { recursive: true, force: true });
});
test("upward discovery: Data folder, subfolders and additionalmaps", () => {
const root = scratch();
write(root, "Data/Mod.xml", "<AssetDeclaration/>");
assert.equal(findProjectRootUpward(join(root, "Data")), resolve(root));
assert.equal(
findProjectRootUpward(join(root, "Data", "GlobalData", "Units")),
resolve(root),
);
assert.equal(
findProjectRootUpward(join(root, "Data", "additionalmaps", "nested")),
resolve(root),
);
});
test("upward discovery: mapmetadata-only mod", () => {
const root = scratch();
write(root, "Data/additionalmaps/mapmetadata_Global.xml", "<MapMetadata/>");
assert.equal(
findProjectRootUpward(join(root, "Data", "additionalmaps")),
resolve(root),
);
assert.equal(findProjectRootUpward(join(root, "Data")), resolve(root));
assert.equal(isProjectRoot(root), true);
});
test("upward discovery: case-insensitive Data and Mod.xml", () => {
const root = scratch();
write(root, "data/mod.xml", "<AssetDeclaration/>");
assert.equal(findProjectRootUpward(join(root, "Data")), resolve(root));
assert.equal(findProjectRootUpward(root), resolve(root));
});
test("upward discovery: babproj markers", () => {
const root = scratch();
write(root, "mod.babproj", "");
assert.equal(findProjectRootUpward(root), resolve(root));
const root2 = scratch();
write(root2, "SomeProject.babproj", "");
assert.equal(findProjectRootUpward(root2), resolve(root2));
});
test("upward discovery: no marker returns null", () => {
const root = scratch();
write(root, "random/file.txt", "x");
assert.equal(findProjectRootUpward(root), null);
assert.equal(findProjectRootUpward(join(root, "random")), null);
});
test("upward discovery: max depth respected", () => {
const root = scratch();
write(root, "Data/Mod.xml", "<AssetDeclaration/>");
let deep = root;
for (let i = 0; i < 14; i++) {
deep = join(deep, `level${i}`);
mkdirSync(deep);
}
assert.equal(findProjectRootUpward(deep, 12), null);
assert.equal(findProjectRootUpward(deep, 20), resolve(root));
});
test("upward discovery from a single file", () => {
const root = scratch();
write(root, "Data/additionalmaps/mapmetadata_Maps.xml", "<MapMetadata/>");
const file = write(root, "Data/Units/Unit.xml", "<AssetDeclaration/>");
assert.equal(findProjectRootForFile(file), resolve(root));
});
test("discoverProjects: sibling mods in a container", () => {
const container = scratch();
write(container, "ModA/Data/Mod.xml", "<AssetDeclaration/>");
write(
container,
"ModB/Data/additionalmaps/mapmetadata_B.xml",
"<MapMetadata/>",
);
const found = discoverProjects(container).map((p) => resolve(p));
assert.equal(found.length, 2);
assert.ok(found.includes(resolve(join(container, "ModA"))));
assert.ok(found.includes(resolve(join(container, "ModB"))));
});
test("discoverProjects: SDK-style deep layout (mods/mods/corona)", () => {
const container = scratch();
write(
container,
"mods/mods/corona/Data/Mod.xml",
"<AssetDeclaration/>",
);
const found = discoverProjects(container).map((p) => resolve(p));
assert.deepEqual(found, [resolve(join(container, "mods", "mods", "corona"))]);
});
test("discoverProjects: skips known non-mod directories", () => {
const container = scratch();
write(
container,
"node_modules/FakeMod/Data/Mod.xml",
"<AssetDeclaration/>",
);
write(container, ".git/Data/Mod.xml", "<AssetDeclaration/>");
assert.deepEqual(discoverProjects(container), []);
});
test("discoverProjects: de-duplicates and stops at a root", () => {
const container = scratch();
write(container, "ModA/Data/Mod.xml", "<AssetDeclaration/>");
write(container, "ModA/Inner/Data/Mod.xml", "<AssetDeclaration/>");
const first = discoverProjects(container);
const second = discoverProjects(container);
assert.deepEqual(second, first);
assert.equal(first.length, 1);
assert.equal(resolve(first[0]), resolve(join(container, "ModA")));
});
test("nested roots: nearest ancestor wins upward", () => {
const outer = scratch();
write(outer, "Data/Mod.xml", "<AssetDeclaration/>");
const inner = join(outer, "Inner");
write(inner, "Data/Mod.xml", "<AssetDeclaration/>");
const file = write(inner, "Data/Units/Unit.xml", "<AssetDeclaration/>");
assert.equal(findProjectRootForFile(file), resolve(inner));
});
+27 -7
View File
@@ -2,7 +2,13 @@ import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; import {
mkdirSync,
mkdtempSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { ModIndexer } from "../out/indexer/indexer.js"; import { ModIndexer } from "../out/indexer/indexer.js";
import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js"; import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js";
@@ -152,11 +158,21 @@ test("records extracted from XML resolve through the reference index", () => {
}); });
test("referenceSitesForDefinition unions manifest-source sites onto the SageXml source file", () => { test("referenceSitesForDefinition unions manifest-source sites onto the SageXml source file", () => {
const sourceFile = join(project, "Data", "Includes", "Units.xml"); const tmp = mkdtempSync(join(tmpdir(), "ra3-refindex-"));
try {
const sdkDir = join(tmp, "sdk");
const projectDir = join(tmp, "project");
const sourceFile = join(sdkDir, "SageXml", "Includes", "Units.xml");
const shadowFile = join(projectDir, "Data", "Includes", "Units.xml");
mkdirSync(dirname(sourceFile), { recursive: true });
mkdirSync(dirname(shadowFile), { recursive: true });
writeFileSync(sourceFile, "<AssetDeclaration/>", "utf8");
writeFileSync(shadowFile, "<AssetDeclaration/>", "utf8");
const manifestDef = { const manifestDef = {
type: "GameObject", type: "GameObject",
id: "Tank", id: "Tank",
file: join(sdk, "builtmods", "static.manifest"), file: join(sdkDir, "builtmods", "static.manifest"),
line: 0, line: 0,
origin: "manifest", origin: "manifest",
manifestSource: "DATA:Includes/Units.xml", manifestSource: "DATA:Includes/Units.xml",
@@ -172,8 +188,8 @@ test("referenceSitesForDefinition unions manifest-source sites onto the SageXml
assets: new Map([["GameObject", new Map([["tank", [manifestDef]]])]]), assets: new Map([["GameObject", new Map([["tank", [manifestDef]]])]]),
assetsById: new Map([["tank", [manifestDef]]]), assetsById: new Map([["tank", [manifestDef]]]),
references: new Map([[assetDefKey(manifestDef), [site]]]), references: new Map([[assetDefKey(manifestDef), [site]]]),
projectDir: project, projectDir,
sdkDir: sdk, sdkDir,
}; };
const sites = referenceSitesForDefinition(idx, { const sites = referenceSitesForDefinition(idx, {
@@ -185,14 +201,18 @@ test("referenceSitesForDefinition unions manifest-source sites onto the SageXml
assert.equal(sites.length, 1); assert.equal(sites.length, 1);
assert.equal(sites[0].file, "C:/mod/ref.xml"); assert.equal(sites[0].file, "C:/mod/ref.xml");
// A different file does not inherit the manifest definition's sites. // The mod file shadowing the same DATA: path must NOT inherit the
// manifest definition's sites; manifestSource maps to SageXml only.
const other = referenceSitesForDefinition(idx, { const other = referenceSitesForDefinition(idx, {
type: "GameObject", type: "GameObject",
id: "Tank", id: "Tank",
file: "C:/mod/elsewhere.xml", file: shadowFile,
line: 4, line: 4,
}); });
assert.equal(other.length, 0); assert.equal(other.length, 0);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}); });
test("the minimod indexer publishes a semantic reverse reference index", async () => { test("the minimod indexer publishes a semantic reverse reference index", async () => {
+14 -4
View File
@@ -122,15 +122,25 @@ function makeScope() {
function makeWs(scope) { function makeWs(scope) {
const parse = parseXml(TEXT); const parse = parseXml(TEXT);
const lineMap = new LineMap(TEXT); const lineMap = new LineMap(TEXT);
return { const indexer = {
isRa3Workspace: () => true,
getScope: async () => scope,
indexer: {
readDom: async (path) => readDom: async (path) =>
path === FILE path === FILE
? { file: { path: FILE }, parse, lineMap, records: null } ? { file: { path: FILE }, parse, lineMap, records: null }
: null, : null,
};
return {
isRa3Workspace: () => true,
getScope: async () => scope,
indexer,
indexerForFile: () => indexer,
activeIndexer: () => indexer,
recordsSyncSurfaceFor: () => ({
get index() {
return scope.merged;
}, },
invalidate: () => {},
scheduleRebuild: () => {},
}),
}; };
} }
+124
View File
@@ -0,0 +1,124 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import {
normalizeSdkPath,
parseRegistryInstallLocation,
validateSdkPath,
} from "../out/sdk.js";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
function makeSdk(extra = {}) {
const dir = mkdtempSync(join(tmpdir(), "ra3-sdk-"));
const rel = (p) => join(dir, ...p.split("/"));
mkdirSync(rel("Schemas/xsd"), { recursive: true });
writeFileSync(
join(rel("Schemas/xsd"), "CnC3Types.xsd"),
"<xs:schema/>",
"utf8",
);
for (const d of extra.dirs ?? []) mkdirSync(rel(d), { recursive: true });
for (const f of extra.files ?? []) {
mkdirSync(dirname(rel(f)), { recursive: true });
writeFileSync(rel(f), "x", "utf8");
}
return dir;
}
function withTemp(fn) {
const dir = mkdtempSync(join(tmpdir(), "ra3-sdk-case-"));
try {
return fn(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
test("normalizeSdkPath trims quotes and resolves to an absolute path", () => {
const raw = ` "${join(root, "test", "fixtures", "fakesdk")}" `;
assert.equal(normalizeSdkPath(raw), resolve(join(root, "test", "fixtures", "fakesdk")));
assert.equal(normalizeSdkPath(" "), "");
assert.equal(normalizeSdkPath(""), "");
});
test("validateSdkPath: empty value is missing", () => {
const v = validateSdkPath("");
assert.equal(v.status, "missing");
assert.equal(v.path, "");
});
test("validateSdkPath: nonexistent path is missing", () => {
const v = validateSdkPath(join(tmpdir(), "ra3-no-such-sdk"));
assert.equal(v.status, "missing");
assert.ok(v.path);
});
test("validateSdkPath: directory without the SDK marker is not an SDK", () => {
withTemp((dir) => {
const v = validateSdkPath(dir);
assert.equal(v.status, "not-sdk");
assert.deepEqual(v.missing, ["Schemas/xsd/CnC3Types.xsd"]);
});
});
test("validateSdkPath: partial lists the missing functional items", () => {
const dir = makeSdk({
dirs: ["builtmods"],
files: ["Static.xml"],
});
try {
const v = validateSdkPath(dir);
assert.equal(v.status, "partial");
assert.deepEqual(v.missing, [
"SageXml",
"Mods",
"Global.xml",
"Audio.xml",
]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("validateSdkPath: complete SDK is ok", () => {
const dir = makeSdk({
dirs: ["builtmods", "SageXml", "Mods"],
files: ["Static.xml", "Global.xml", "Audio.xml"],
});
try {
const v = validateSdkPath(dir);
assert.equal(v.status, "ok");
assert.deepEqual(v.missing, []);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("validateSdkPath: marker matching is case-insensitive on Windows", (t) => {
if (process.platform !== "win32") return t.skip("case-insensitive fs is Windows-only");
withTemp((dir) => {
mkdirSync(join(dir, "schemas", "xsd"), { recursive: true });
writeFileSync(join(dir, "schemas", "xsd", "cnc3types.xsd"), "x", "utf8");
const v = validateSdkPath(dir);
assert.notEqual(v.status, "not-sdk", "lower-case marker still identifies the SDK");
});
});
test("parseRegistryInstallLocation extracts the value from reg.exe output", () => {
const out = [
"",
"HKEY_LOCAL_MACHINE\\Software\\Wow6432Node\\...",
" InstallLocation REG_SZ C:\\Apps\\RA3-MODSDK-X",
"",
].join("\r\n");
assert.equal(parseRegistryInstallLocation(out), "C:\\Apps\\RA3-MODSDK-X");
assert.equal(parseRegistryInstallLocation("no such key"), null);
assert.equal(
parseRegistryInstallLocation(" DisplayName REG_SZ SDK"),
null,
);
});
+281
View File
@@ -0,0 +1,281 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
mkdtempSync,
mkdirSync,
writeFileSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
// ── Minimal vscode shim for ModWorkspace (multi-project behavior) ──────
const stubState = {
workspaceFolders: [],
textDocuments: [],
activeEditor: null,
config: {
sdkPath: "",
indexSageXml: true,
reportUnresolvedReferences: "warning",
diagnoseUnknownElements: true,
definitionMode: "all",
additionalDataSearchPaths: [],
},
};
class RelativePattern {
constructor(base, pattern) {
this.base = base;
this.pattern = pattern;
}
}
class OutputChannel {
appendLine() {}
dispose() {}
}
class StatusBarItem {
constructor() {
this.name = "";
this.command = "";
this.text = "";
this.tooltip = "";
}
show() {
this.visible = true;
}
hide() {
this.visible = false;
}
dispose() {}
}
function makeWatcher() {
return {
onDidCreate: () => ({ dispose() {} }),
onDidChange: () => ({ dispose() {} }),
onDidDelete: () => ({ dispose() {} }),
dispose() {},
};
}
const require = createRequire(import.meta.url);
const Module = require("module");
const origResolve = Module._resolveFilename;
Module._resolveFilename = function (request, ...args) {
if (request === "vscode") return "vscode-stub";
return origResolve.call(this, request, ...args);
};
require.cache["vscode-stub"] = {
id: "vscode-stub",
filename: "vscode-stub",
loaded: true,
exports: {
RelativePattern,
StatusBarAlignment: { Left: 1 },
workspace: {
getConfiguration: () => ({
get: (key, def) => stubState.config[key] ?? def,
}),
get workspaceFolders() {
return stubState.workspaceFolders;
},
get textDocuments() {
return stubState.textDocuments;
},
createFileSystemWatcher: () => makeWatcher(),
onDidCloseTextDocument: () => ({ dispose() {} }),
},
window: {
createOutputChannel: () => new OutputChannel(),
createStatusBarItem: () => new StatusBarItem(),
get activeTextEditor() {
return stubState.activeEditor;
},
},
commands: {
executeCommand: async () => undefined,
},
},
};
const { ModWorkspace } = require("../out/workspace.js");
// ── Fixtures ────────────────────────────────────────────────────────────
const FAKE_SDK = fileURLToPath(new URL("./fixtures/fakesdk", import.meta.url));
let tmpRoot;
let modA;
let modB;
let container;
let storageDir;
const MOD_A_TEXT = "<AssetDeclaration><GameObject id=\"UnitA\"/></AssetDeclaration>";
const MOD_B_TEXT = "<AssetDeclaration><GameObject id=\"UnitB\"/></AssetDeclaration>";
test.before(() => {
stubState.config.sdkPath = FAKE_SDK;
tmpRoot = mkdtempSync(join(tmpdir(), "ra3-multimod-"));
modA = join(tmpRoot, "container", "ModA");
modB = join(tmpRoot, "container", "ModB");
container = join(tmpRoot, "container");
storageDir = join(tmpRoot, "storage");
mkdirSync(join(modA, "Data"), { recursive: true });
mkdirSync(join(modB, "Data"), { recursive: true });
writeFileSync(join(modA, "Data", "Mod.xml"), MOD_A_TEXT);
writeFileSync(join(modB, "Data", "Mod.xml"), MOD_B_TEXT);
});
test.after(() => {
if (tmpRoot) rmSync(tmpRoot, { recursive: true, force: true });
});
function makeDoc(fsPath, text = "<AssetDeclaration/>") {
return {
uri: {
fsPath,
scheme: "file",
toString: () => `file://${fsPath}`,
},
languageId: "xml",
isDirty: false,
version: 1,
getText: () => text,
};
}
function makeWorkspace(folders) {
stubState.workspaceFolders = folders;
stubState.textDocuments = [];
stubState.activeEditor = null;
const context = {
storageUri: { fsPath: storageDir },
globalStorageUri: null,
subscriptions: [],
};
return new ModWorkspace(context);
}
async function waitForIndex(ws, doc) {
for (let i = 0; i < 500; i++) {
const idx = await ws.getIndex(doc);
if (idx?.complete && idx.stats.assetCount > 0) return idx;
await new Promise((r) => setTimeout(r, 20));
}
throw new Error(`timed out waiting for index of ${doc.uri.fsPath}`);
}
function stateForRoot(ws, root) {
const wanted = resolve(root).toLowerCase();
return [...ws.states.values()].find(
(s) => resolve(s.root).toLowerCase() === wanted,
);
}
test("single project folder is indexed immediately on initialize", async () => {
const ws = makeWorkspace([{ uri: { fsPath: modA } }]);
await ws.initialize();
assert.equal(ws.getProjectRoots().length, 1);
const idx = ws.activeIndex();
assert.ok(idx);
assert.equal(resolve(idx.stats.projectDir), resolve(modA));
assert.ok(idx.assetsById.has("unita"));
ws.dispose();
});
test("container folder discovers two projects and indexes lazily", async () => {
const ws = makeWorkspace([{ uri: { fsPath: container } }]);
await ws.initialize();
assert.equal(ws.getProjectRoots().length, 2);
assert.equal(ws.activeIndex(), null);
const docA = makeDoc(join(modA, "Data", "Mod.xml"), MOD_A_TEXT);
const docB = makeDoc(join(modB, "Data", "Mod.xml"), MOD_B_TEXT);
assert.equal(ws.getProjectRootFor(docA), resolve(modA));
assert.equal(ws.getProjectRootFor(docB), resolve(modB));
assert.ok(
ws.searchPaths(docA).DATA.some((d) => resolve(d) === resolve(join(modA, "Data"))),
);
// Opening ModA's document builds only ModA.
ws.onDocumentOpened(docA);
const idxA = await waitForIndex(ws, docA);
assert.equal(resolve(idxA.stats.projectDir), resolve(modA));
assert.ok(idxA.assetsById.has("unita"));
const stateB = stateForRoot(ws, modB);
assert.ok(stateB);
assert.equal(stateB.index, null);
assert.equal(stateB.buildCount, 0);
// Opening ModB's document builds ModB.
ws.onDocumentOpened(docB);
const idxB = await waitForIndex(ws, docB);
assert.equal(resolve(idxB.stats.projectDir), resolve(modB));
assert.ok(idxB.assetsById.has("unitb"));
ws.dispose();
});
test("with multiple projects the active editor's project builds on initialize", async () => {
const ws = makeWorkspace([{ uri: { fsPath: container } }]);
stubState.activeEditor = {
document: makeDoc(join(modB, "Data", "Mod.xml"), MOD_B_TEXT),
};
await ws.initialize();
const idx = ws.activeIndex();
assert.ok(idx);
assert.equal(resolve(idx.stats.projectDir), resolve(modB));
ws.dispose();
});
test("workspace folder changes add and remove projects", async () => {
const ws = makeWorkspace([{ uri: { fsPath: container } }]);
await ws.initialize();
assert.equal(ws.getProjectRoots().length, 2);
stubState.workspaceFolders = [{ uri: { fsPath: modA } }];
ws.onWorkspaceFoldersChanged();
assert.equal(ws.getProjectRoots().length, 1);
assert.equal(resolve(ws.getProjectRoots()[0]), resolve(modA));
stubState.workspaceFolders = [{ uri: { fsPath: container } }];
ws.onWorkspaceFoldersChanged();
assert.equal(ws.getProjectRoots().length, 2);
ws.dispose();
});
test("an unrelated active XML document falls back without recursion", async () => {
const ws = makeWorkspace([{ uri: { fsPath: container } }]);
const outside = join(tmpRoot, "outside.xml");
writeFileSync(outside, "<AssetDeclaration/>");
stubState.activeEditor = { document: makeDoc(outside) };
await ws.initialize();
// No project contains the active document, so nothing builds eagerly and
// activeIndex resolves to the first project (or null) without recursing.
assert.equal(ws.getProjectRoots().length, 2);
const idx = ws.activeIndex();
assert.equal(idx, null);
ws.dispose();
});
test("rebuilds for both projects complete through the serialized queue", async () => {
const ws = makeWorkspace([{ uri: { fsPath: container } }]);
await ws.initialize();
const docA = makeDoc(join(modA, "Data", "Mod.xml"), MOD_A_TEXT);
const docB = makeDoc(join(modB, "Data", "Mod.xml"), MOD_B_TEXT);
const p1 = ws.rebuild(false, "test-a", docA);
const p2 = ws.rebuild(false, "test-b", docB);
await Promise.all([p1, p2]);
const idxA = await waitForIndex(ws, docA);
const idxB = await waitForIndex(ws, docB);
assert.equal(resolve(idxA.stats.projectDir), resolve(modA));
assert.equal(resolve(idxB.stats.projectDir), resolve(modB));
assert.ok(idxA.assetsById.has("unita"));
assert.ok(idxB.assetsById.has("unitb"));
ws.dispose();
});