12 Commits
Author SHA1 Message Date
lanyi 4f422a748e find asset 2026-09-10 23:16:36 +02:00
lanyi a25adacaee finalize 2026-09-10 21:39:32 +02:00
lanyi 5993da4ce6 ai agent 2026-09-10 19:18:15 +02:00
lanyi 3a3d70efeb ai agent 2026-09-10 17:03:10 +02:00
lanyi 90dc18a167 0.1.25 2026-08-11 23:33:21 +02:00
lanyi 19dbfe0f34 0.1.24 fix inheritFrom="type:id" 2026-08-11 20:01:33 +02:00
lanyi 84a44bedfd fix inherit from 2026-08-11 19:42:23 +02:00
lanyi b5e055216c 0.1.22 2026-08-11 16:23:58 +02:00
lanyi 21c2275c00 fixed manifest references with same id 2026-08-11 15:17:30 +02:00
lanyi 2fd40d9d51 准备发布! 2026-08-11 12:27:37 +02:00
lanyi 2a049c7e92 0.1.21 2026-08-11 12:01:25 +02:00
lanyi dbc2c99d8d 0.1.20 2026-08-11 02:20:06 +02:00
103 changed files with 16926 additions and 949 deletions
+2
View File
@@ -11,6 +11,8 @@ debug.log
docs/**
OpenSAGE/**
test/**
images/**
!images/icon.png
**/*.map
tsconfig.json
esbuild.mjs
+121
View File
@@ -0,0 +1,121 @@
# Changelog
## 0.1.26 — 2026-09-10
### Added
- **AI Agent access (MCP).** `RA3 Mod XML: Enable AI Agent access…` exposes the
semantic index to AI agent clients through a local, read-only MCP (Model
Context Protocol) server. It exports a stable snapshot, writes a launcher
under `~/.ra3modxml/`, starts a loopback query server while VS Code is
running, and offers to install the Agent Skill and/or write the MCP client
configuration (Claude Desktop, Cursor global/project, or a copied generic
block). `Disable AI Agent access` stops the live server for the workspace;
`Uninstall AI Agent integration…` removes MCP config entries, the launcher,
and Skill copies after a confirmation prompt.
- **Agent Skill (`ra3-mod-xml`).** `RA3 Mod XML: Install Agent Skill…` installs
`SKILL.md` plus a query-tool reference to `~/.agents/skills/`, and optionally
to Claude Code or project-local skill directories. It explains when the index
applies and how to reach it with or without MCP. `Uninstall Agent Skill…`
removes only directories that still carry the extension's marker.
- **`RA3 Mod XML: Export AI Agent index snapshot`** writes the gzipped,
versioned snapshot that the MCP server and CLI fall back to when VS Code is
closed.
- **Agent CLI** (`dist/agent/cli.js`): live-index first, exported-snapshot
fallback. `outgoing` and `projects` need element context and report that
explicitly instead of returning an empty result.
- **First-run / upgrade introduction.** After the first index, the extension
offers AI Agent access once (per machine). Upgrades from a build that already
had the feature stay silent, and "Don't show again" is remembered.
- **Live discovery and multi-window safety.** Per-project endpoint files, one
`instances/` entry per VS Code window, and a merged `~/.ra3modxml/index.json`.
Queries are pinned to a project and a mismatched answer is refused. A crashed
window's instance file is pruned by the next activation.
- **No Node installation required.** The MCP launcher for AI Agent Access runs
the bundled server on VS Code's own Electron binary (`ELECTRON_RUN_AS_NODE=1`)
and falls back to `node` on `PATH` only when that binary is missing.
Launchers are refreshed on activation, so moving or updating VS Code does not
break an existing MCP configuration.
## 0.1.25 — 2026-08-11
### Changed
- Repository and homepage links now point to the GitHub mirror; README links to both GitHub and Gitea.
- Extension now activates in untrusted (Restricted Mode) workspaces; workspace-defined `ra3modxml.sdkPath` and `ra3modxml.additionalDataSearchPaths` are ignored until the workspace is trusted.
- **Find asset.** `RA3 Mod XML: Find asset (id or Type:Id)…` opens a search
picker over the whole index: type `Type:Id`, an exact id or a partial id and
the result list updates as you type (`Type:` alone lists every asset of that
type). Rows show the asset type, origin (project / SDK / manifest), reference
count and defining file; accepting one jumps to the definition. When a
`type:id` is defined in several places (mod override + vanilla source +
compiled manifest) a second picker chooses which definition to open. The
query can also be prefilled from the current selection through the editor
context menu entry.
### Fixed
- The editor context-menu entries were declared under a top-level `menus` key
instead of `contributes.menus`, so VS Code never showed them. Adding the new
search entry moved the block where it belongs; `Find unreferenced assets of
this type` now appears in the editor context menu as documented.
## 0.1.24 — 2026-08-11
### Fixed
- Manifest-style qualified reference values (`Type:Id`, e.g.
`inheritFrom="AudioEvent:BaseSoundEffect"`,
`Sound="AudioEvent:JAP_Refinery_Select"`, `Side="PlayerTemplate:Allies"`)
now resolve to the plain-id definitions indexed from XML (mod or
`SageXml`). Previously the plugin only applied the “last colon segment”
rule to manifest asset names, so qualified XML references were reported as
unresolved even when the definition existed (the reported
`AudioEvent:BaseSoundEffect` case).
- The same normalization now applies to simple-content references, the
semantic reverse index (Find All References / CodeLens counts), and the
reference peek path, so hover, Ctrl+click, diagnostics, reference counts
and unreferenced reports all agree.
- Value completion keeps a `Type:` prefix the user already typed:
`inheritFrom="AudioEvent:Base…` completes to
`AudioEvent:BaseSoundEffect` instead of dropping the prefix. Plain ids
without a prefix keep the previous bare-id behavior.
- `inheritFrom` is now accepted on all `BaseAssetType`-derived assets (e.g. `FXList`, `AIMicroManagerData`, `ObjectCreationList`, `OnDemandTextureImage`, `AITargetingHeuristic`). The XSD only declares it on `BaseInheritableAsset`, but vanilla and Corona data use it more broadly. Attribute legality is now separate from the CodeLens / Find All References “reference target by design” filter, so the universal attribute does not widen the code-lens type list.
- `simpleContent` complex types (`AudioFileRefWithWeight`, `MultisoundSubsoundRef`) keep their XSD attributes (`Weight`, `Volume`, `PitchShiftLow/High`, ...) and their text content (`<Sound>AudioFile</Sound>`, `<Subsound>VoiceEvent</Subsound>`) is now handled as a typed asset reference by completion, hover, navigation, diagnostics, the semantic reference index, and Find All References.
- Fragment roots whose name also appears as a nested child type (e.g. `<EvaEvent>`, `<UpgradeTemplate>`) now resolve to the top-level `AssetDeclaration` type instead of the colliding child type.
- Element-name completion for simple-content children now re-triggers value suggestions after inserting the `<Name>$1</Name>` snippet.
### Added
- 128×128 PNG extension icon (converted from `images/icon.webp`). The `.vsix` no longer bundles the `images` folder, so the large GIF demos stay out of the package.
## 0.1.22 — 2026-08-11
### Fixed
- Manifest assets that share the same id under different types are now all kept in the by-id index. Previously the first same-id entry (e.g. `W3DHierarchy:AUMCV_HOVER`) could shadow later definitions (e.g. `W3DContainer:AUMCV_HOVER`), causing `Model@Name` and other `BaseRenderAssetType` references to be reported as unresolved even though the asset existed in `Static.manifest`.
- `xi:include` without an `xpointer` now splices the target document's root element itself (XInclude semantics), so fragments like `GenericCelestialBuildingSuicide.xml` keep their module wrapper (`CreateObjectDie`) instead of only inserting its children.
- Fragment files (documents whose root is not `AssetDeclaration`) no longer trigger standalone-document diagnostics: top-level `missing-id`, duplicate-id, unresolved-reference and undefined-define checks are skipped, unknown wrapper roots are not reported as unknown elements, and a known fragment root still validates its subtree's elements/attributes.
### Added
- Missing `xi:include` targets now surface in the Problems panel as `include-not-found` warnings for the edited document (previously only tracked in indexer diagnostics).
## 0.1.21 — 2026-08-11
### Added
- English/Chinese localization for the extension manifest, commands, settings, status bar, notifications, hover, completions, CodeLens, and diagnostics.
- `package.nls.*` and `l10n/` bundles so future languages can be added without touching source code.
- AI-generated project disclosure in both `README.md` and `README.zh-CN.md`.
### Changed
- Packaged extension now includes localization resources.
- `npm run package` no longer fails on missing repository-link rewriting.
### Fixed
- Parser errors carry stable codes and parameters so the UI layer can localize them while the parsing core stays VS Code-independent.
+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.
+310 -120
View File
@@ -1,144 +1,334 @@
# RA3 Mod XML(VS 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 player-made tool. It requires a separately installed RA3 Mod SDK.
面向《命令与征服:红色警戒 3》Mod XML(SAGE / BinaryAssetBuilder 格式)的 VS Code 工具扩展。
[**English**](README.md) | [中文](README.zh-CN.md)
## 功能
# RA3 Mod XML
- **语法高亮**:在普通 XML 高亮之上叠加领域标记(`$DEFINE` 常量、`inheritFrom`、`xai:joinAction`、结构标签);XML 语法异常(如未闭合引号)期间由语义 token 兜底,标签/属性/值着色不中断。
- **自动补全**:
- 元素名:按当前父元素的 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 子结构等自动注册类型不显示,
避免满屏 0;manifest 资产有对应 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 也做内容校验。
> **AI-generated project**
>
> This extension was generated almost entirely by AI. As a result, there may be unexpected bugs or edge cases. Bug reports, corrections, and feedback are very welcome.
## 使用
A VS Code extension that brings **IntelliSense, navigation, reference tracking, and diagnostics** to XML-based mods for **Command & Conquer: Red Alert 3**.
1. 用 VS Code 打开 RA3 Mod 项目文件夹(含 `Data/Mod.xml` 或 `mod.babproj`)。
2. 插件自动激活并开始后台索引(状态栏显示阶段与资产数量;扩展也会在打开
任意 XML 文件时激活,非 RA3 工作区不会显示 RA3 专属功能)。
3. 编辑任意 `*.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.
### 设置(`settings.json`)
Source code: [GitHub mirror](https://github.com/RA3CoronaDevelopers/Ra3ModXmlExt.git) · [Gitea](https://git.ra3battle.cn/RA3CoronaDevelopers/Ra3ModXmlExt)
| 设置 | 默认值 | 说明 |
|---|---|---|
| `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:` 搜索目录 |
<table>
<tr>
<td align="center">
<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>
### 命令
<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>
- `RA3 Mod XML: Re-index workspace`:手动重建索引。
- `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`:右键菜单入口,
直接查找光标所在顶部资产类型的未引用资产。
## Features
## 开发
### Intelligent Completion
Get context-aware completion based on the RA3 XML schema and project data.
* 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 asset** searches the index for an asset by `Type:Id`, by exact id, or by a partial id — typed directly into the picker, with results updating as you type. The result list shows each asset's type, origin (project / SDK / manifest), reference count and defining file; accepting a result jumps to its definition. When the same `type:id` exists in several places (for example a mod override of a vanilla asset), a second picker asks which definition to open.
Run:
`RA3 Mod XML: Find asset (id or Type:Id)…`
You can also select an id in the editor and use the context menu entry to search for it.
**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.
An empty `ra3modxml.sdkPath` is also the default value. Leaving the setting
untouched does **not** suppress the SDK setup hint; only explicitly setting it
to an empty string opts out of SDK features permanently.
## 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 asset (id or Type:Id)…`
* `RA3 Mod XML: Find unreferenced assets…`
* `RA3 Mod XML: Find unreferenced assets of this type`
* `RA3 Mod XML: Enable AI Agent access…`
* `RA3 Mod XML: Disable AI Agent access`
* `RA3 Mod XML: Install Agent Skill…`
* `RA3 Mod XML: Uninstall Agent Skill…`
* `RA3 Mod XML: Uninstall AI Agent integration…`
* `RA3 Mod XML: Export AI Agent index snapshot`
## AI Agent Access
The extension can expose its semantic asset index to AI Agent clients through
a local, read-only MCP (Model Context Protocol) server. This is optional and
does not modify `PATH` or install global commands.
Run:
`RA3 Mod XML: Enable AI Agent access…`
The command:
1. Exports a stable index snapshot for the active project.
2. Creates a stable launcher under `~/.ra3modxml/`.
3. Starts a local read-only query server while VS Code is running.
4. Offers to:
* install the `ra3-mod-xml` Agent Skill to `~/.agents/skills/` (and
optionally to Claude Code or project-local skill directories),
* write the MCP client configuration for Claude Desktop or Cursor,
* copy a generic MCP configuration block.
The MCP server prefers the live in-memory index while the extension is
running and falls back to the last exported snapshot when VS Code is closed.
The exposed tools include asset lookup, incoming semantic references,
**outgoing reference edges** (`get_asset_references`: which assets an asset
uses, through which element/attribute, and where in the XML), active-file
checks, `$DEFINE` lookup, and Include source resolution.
`get_asset_references` follows `inheritFrom` ancestors and marks the
ancestor's entries with `definedIn`, so "this unit has no `WeaponSetUpdate`,
but the base unit it inherits from does" is answerable in a single call. It is
bounded by `depth` (default 1, max 3), `targetTypes` and `maxEdges`, and
reports truncation instead of silently dropping results.
Discovery is per project (`~/.ra3modxml/endpoints/<project>.json`), so several
VS Code windows can enable agent access at the same time without shadowing each
other, and a client can never be silently answered from a different project.
Each window also registers itself under `~/.ra3modxml/instances/`, and a small
merged `~/.ra3modxml/index.json` lists the live instances and their project
roots. A window that crashes is cleaned up by whichever instance starts next —
no workspace has to be reopened first.
**No Node installation is required.** The launcher runs the bundled server on
VS Code's own Electron binary (`ELECTRON_RUN_AS_NODE=1`) and only falls back to
`node` from `PATH` if that binary is missing. The launcher is rewritten on every
activation, so updating or moving VS Code does not break an existing MCP config.
The MCP server is plain JSON-RPC over stdio, so an agent can also query the
index without any MCP setup at all by piping a request into the launcher. A
matching CLI (`cli.js`, next to `mcpServer.js` in `dist/agent/`) answers from
the live index when VS Code is running and from the exported snapshot
otherwise; commands that need element context say so explicitly rather than
returning an empty result.
### Installing and removing the integration
* `RA3 Mod XML: Install Agent Skill…` works with or without an indexed project.
The default target is the cross-agent `~/.agents/skills/ra3-mod-xml/`
convention; Claude Code (`~/.claude/skills/`), project-local `.agents/skills/`
and `.claude/skills/`, and any custom folder can be selected too.
* `RA3 Mod XML: Uninstall Agent Skill…` lists the recorded copies and removes
only directories that still carry the extension's marker. A folder the user
replaced or created by hand is never deleted.
* `RA3 Mod XML: Disable AI Agent access` stops the live query server for the
current workspace (project discovery files for this window are cleaned up).
Installed skills and MCP client configurations are kept.
* `RA3 Mod XML: Uninstall AI Agent integration…` is the cleanup wizard. It can
stop live access, remove installed Agent Skills, delete the MCP entries this
extension wrote (from the recorded files and the conventional Claude
Desktop / Cursor paths, leaving other servers untouched), and delete the
stable launcher. Nothing is removed before a confirmation prompt.
After an extension upgrade, recorded Skill copies are rewritten automatically
to the new version; directories without the extension's marker are left alone.
The first time an AI Agent feature becomes available (fresh install, or an
upgrade from an older version), the extension offers to enable it after the
first index. It never asks twice: choosing "Don't show again" — or simply
ignoring the message — is remembered, and later upgrades stay silent.
See `docs/ai-agent-integration-plan.md` for the full design and progress.
## 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
npm install
npm run generate-model # 从 SDK XSD 重新生成 src/model/schema-model.json
npm test # 单元测试(tsc + node --test)
npm run build # esbuild 打包到 dist/
npm run package # 生成可安装的 .vsix
npm run generate-model # Generate the runtime schema model from the SDK XSD
npm test # Run unit tests
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/
extension.ts 激活入口与 provider 注册
workspace.ts 项目检测、索引生命周期、状态栏
settings.ts 配置读取(sdkPath、definitionMode 等)
extension.ts
projectRoot.ts
workspace.ts
settings.ts
language/
xmlParser.ts 带源码偏移的轻量 XML 解析器(容错、行尾恢复)
context.ts 补全上下文分析
typeContext.ts 上下文感知元素类型解析
semanticTokens.ts 语义 token 兜底高亮(纯 TS)
xmlParser.ts
context.ts
typeContext.ts
semanticTokens.ts
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/
includeResolver.ts Include 路径解析(纯 TS,移植 check_duplicate_ids.py)
existence.ts 文件集存在性快照(目录枚举 Set,替代逐路径 statSync)
manifestParser.ts .manifest 二进制解析(移植 OpenSAGE ManifestFile.cs)
fileScanner.ts 目录扫描与 Include source 候选
refs.ts 引用目标解析(按引用类型过滤)+ “设计上可被引用类型”判定
referenceIndex.ts 引用记录 → 反向引用索引(定义 → 引用位置)+ 未引用报告
xpointer.ts xi:include xpointer 子集解析(纯 TS)
logicalTree.ts 当前文档逻辑树(xi:include 拼接、局部作用域)
localScope.ts 文档局部索引 overlay(自身链 + include 链)
shallowScan.ts .w3x 等大体积美术资产顶层浅扫描(纯 TS,不建 DOM)
records.ts 每文件紧凑索引记录(资产/Define/Include/xi/引用 + 行号偏移)
caches.ts 跨重建持久缓存(DocumentCache / IndexRecordsCache /
IncludeResolveCache)+ 失效纪元 InvalidationsEpoch
diskCache.ts 跨会话磁盘缓存(gzip JSON、原子写、多信号 stat 校验)
indexer.ts 工作区索引器(后台、缓存、记录驱动重建、分阶段发布)
features/ completion / hover / navigation / references / codeLens /
unreferenced / diagnostics / semanticTokens
syntaxes/ TextMate 注入语法
tools/ XSD → 模型、AssetType 枚举提取
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 # 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
## 参考
- 领域说明与需求:`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`)
* OpenSAGE `ManifestFile.cs` — manifest format reference
+290
View File
@@ -0,0 +1,290 @@
> 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 player-made tool. It requires a separately installed RA3 Mod SDK.
[English](README.md) | [**中文**](README.zh-CN.md)
# RA3 Mod XML
> **AI 生成项目**
>
> 本扩展几乎完全由 AI 生成,因此可能存在意外 bug 或边界情况。欢迎提交 bug 报告、修正与反馈。
一款面向 **《命令与征服:红色警戒 3》** XML 模组的 VS Code 扩展,提供 **IntelliSense、导航、引用追踪与诊断** 能力。
它理解 RA3 Mod SDK 的 XML schema、资产类型、引用、include 以及原版游戏数据——编辑大型模组的体验会更接近真正的编程语言。
源码:[GitHub 镜像](https://github.com/RA3CoronaDevelopers/Ra3ModXmlExt.git) · [Gitea](https://git.ra3battle.cn/RA3CoronaDevelopers/Ra3ModXmlExt)
<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`
### 项目分析
扩展可以分析整个工作区,而不仅仅是当前打开的文件。
**查找资产** 可以在索引中按 `类型:ID`、精确 ID 或部分 ID 搜索资产——直接在输入框中输入,结果随输入实时更新。结果列表显示资产的类型、来源(项目 / SDK / manifest)、引用次数与所在文件;选中后会跳转到其定义。当同一个 `类型:ID` 存在多处定义时(例如 mod 覆盖了原版资产),会再弹出一次选择框让你选择打开哪一处定义。
运行:
`RA3 Mod XML: Find asset (id or Type:Id)…`
你也可以在编辑器中选中一个 ID,然后用右键菜单直接搜索它。
**查找未引用的资产** 会列出工作区中任何地方都未被引用的项目资产,帮助识别过时或意外未使用的定义。
运行:
`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…` 命令。
`ra3modxml.sdkPath` 的默认值本身就是空字符串。**不修改该设置**不会抑制 SDK
设置提示;只有显式把它设为空字符串,才表示永久禁用 SDK 功能。
## 命令
* `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 asset (id or Type:Id)…`(查找资产(ID 或 类型:ID)…)
* `RA3 Mod XML: Find unreferenced assets…`(查找未引用的资产…)
* `RA3 Mod XML: Find unreferenced assets of this type`(查找此类型的未引用资产)
* `RA3 Mod XML: Enable AI Agent access…`(启用 AI Agent 访问…)
* `RA3 Mod XML: Disable AI Agent access`(禁用 AI Agent 访问)
* `RA3 Mod XML: Install Agent Skill…`(安装 Agent Skill…)
* `RA3 Mod XML: Uninstall Agent Skill…`(卸载 Agent Skill…)
* `RA3 Mod XML: Uninstall AI Agent integration…`(卸载 AI Agent 集成…)
* `RA3 Mod XML: Export AI Agent index snapshot`(导出 AI Agent 索引快照)
## AI Agent 访问
扩展可以通过本地只读 MCP(Model Context Protocol)Server,把语义索引提供给 AI Agent 使用。该功能可选,不会修改 `PATH`,也不会注册全局命令。
运行:
`RA3 Mod XML: Enable AI Agent access…`
该命令会:
1. 为当前项目导出稳定的索引快照;
2. 在 `~/.ra3modxml/` 下创建稳定 launcher;
3. 在 VS Code 运行期间启动本地只读查询服务;
4. 让用户选择:
* 安装 `ra3-mod-xml` Agent Skill 到 `~/.agents/skills/`(也可选择 Claude Code 或项目级目录);
* 写入 Claude Desktop / Cursor 的 MCP 配置;
* 复制通用 MCP 配置。
MCP Server 在扩展运行时优先查询内存中的实时索引;扩展关闭后回退到最近一次导出的快照。暴露的工具包括资产定义查询、语义引用查询、**正向引用边**(`get_asset_references`:该资产用了哪些资产、通过哪个元素/属性、写在 XML 的哪一行)、文件是否有效 include、`$DEFINE` 查询以及 Include source 解析。
`get_asset_references` 会沿 `inheritFrom` 祖先链遍历,并用 `definedIn` 标出条目实际写在哪个祖先的文件里,因此"这个单位自己没有 `WeaponSetUpdate`,但它继承的基础单位有"可以在一次调用里回答。它受 `depth`(默认 1,上限 3)、`targetTypes` 与 `maxEdges` 三重限制,并在截断时显式报告,而不是静默丢弃结果。
Endpoint 按项目存放(`~/.ra3modxml/endpoints/<project>.json`),因此多个 VS Code 窗口可以同时启用 AI Agent 访问而不互相覆盖,客户端也不会被静默地用另一个项目的数据回答。每个窗口还会在 `~/.ra3modxml/instances/` 下登记自己,并由一份合并的只读 `~/.ra3modxml/index.json` 列出当前实例与项目根。崩溃残留的实例会被下一个启动的实例顺手清理,不需要先重新打开同一个工作区。
**不需要安装 Node。** launcher 使用 VS Code 自带的 Electron 二进制(`ELECTRON_RUN_AS_NODE=1`)运行内置的 MCP Server,只有在该二进制缺失时才回退到 `PATH` 上的 `node`。launcher 在每次激活时重写,所以升级或移动 VS Code 都不会让已有的 MCP 配置失效。
MCP Server 本质上就是 stdio 上的 JSON-RPC,因此 agent 也可以完全不做 MCP 配置,直接把请求管道给 launcher。另有一个配套 CLI(`cli.js`,与 `mcpServer.js` 同在 `dist/agent/`):VS Code 运行时走实时索引,否则读导出的快照;需要元素上下文的命令会明确说明,而不是返回空结果。
### 安装与移除
* `RA3 Mod XML: Install Agent Skill…` 不要求项目已建立索引。默认安装到跨 agent 的 `~/.agents/skills/ra3-mod-xml/`,也可选择 Claude Code(`~/.claude/skills/`)、项目级 `.agents/skills/` 与 `.claude/skills/`,或任意自定义文件夹。
* `RA3 Mod XML: Uninstall Agent Skill…` 列出已记录的安装位置,只删除仍带有本扩展标记的目录;被用户替换或手工创建的目录不会被删除。
* `RA3 Mod XML: Disable AI Agent access` 停止当前工作区的实时查询服务并清理本窗口的发现文件;已安装的 Skill 与 MCP 客户端配置会保留。
* `RA3 Mod XML: Uninstall AI Agent integration…` 是清理向导:可停止实时访问、移除已安装的 Agent Skill、删除本扩展写入的 MCP 配置项(来自记录文件与常规的 Claude Desktop / Cursor 路径,不会动其他 server),以及删除稳定 launcher。确认之前不会删除任何内容。
扩展升级后,记录中的 Skill 副本会自动重写到新版本;不带本扩展标记的目录不会被改写。
当 AI Agent 功能首次可用时(全新安装,或从旧版本升级),扩展会在第一次索引完成后询问是否启用。它不会反复打扰:选择"不再提示"——或者干脆忽略——都会被记住,之后的升级保持静默。
完整设计与进度见 `docs/ai-agent-integration-plan.md`。
## 环境要求
* 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 格式参考
File diff suppressed because it is too large Load Diff
+654 -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` 被误报为未知属性
### 问题
@@ -1624,3 +1561,657 @@ WarheadTemplate="..."` 引用。该问题在移动硬盘重连 + 重新打开工
只要文件被打开并触发 CodeLens / FAR,不一致就会被检测并定向修复。
版本 **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 拖到 25s(walk 17s / candidates 6.3s),因此
该方案已放弃,恢复“校验通过才允许快速构建”的不变量。
进一步优化为**分阶段校验**:full XML 记录先校验(约一半,~15s),构建随即
开始并发布 phase A;shallow 美术记录先以 `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 命中 SageXml;vanilla 源缺失时
即使 mod 遮蔽也返回 null;
- `referenceIndex.test.mjs`:manifest 源归并只命中 SageXml 文件,同名 mod
文件不继承引用站点;
- `contentFeatures.test.mjs`:Ctrl+点击 manifest 定义命中 SageXml 而非 mod
遮蔽文件;SageXml 源缺失时不跳 mod;文件存在但 id 被删时降到文件顶部。
> 备注:ART/AUDIO 源映射按用户意见不作为本轮目标;`buildVanillaSearchPaths`
> 已包含对应 SDK 目录,将来若有源码可直接复用。
---
## 二十九、问题分析(2026-08-11):manifest 同名不同类型资产被 `assetsById` 去重丢弃
### 现象
Corona `Data\Allied\Units\AlliedMCV.xml` 的
`ScriptedModelDraw → ModelConditionState → Model Name="AUMCV_Hover"`
报 unresolved-reference:
```xml
<ScriptedModelDraw id="ModuleTag_Draw_Hover" OkToChangeModelColor="true">
<ModelConditionState ParseCondStateType="PARSE_DEFAULT">
<Model Name="AUMCV_Hover" />
</ModelConditionState>
</ScriptedModelDraw>
```
提示为“没有类型为 `BaseRenderAssetType` 的定义(其他类型存在同名 id)”,
但 `Static.manifest` 中确实存在 `W3DContainer:AUMCV_HOVER`。
### 根因
`src/indexer/indexer.ts` 的 `addAsset()` 在维护两个索引时用了同一套去重:
- `assets`:`类型 -> id -> 定义`,按 `(file, line)` 去重;
- `assetsById`:`id -> 所有类型定义`,也按 `(file, line)` 去重。
XML 定义的行号各不相同,所以 `(file, line)` 足够;但 manifest 资产入库时
`line` 固定为 0,于是同一个 manifest 里 id 相同、类型不同的多个资产会被
当成同一条定义,只保留最先出现的类型。
`Static.manifest` 中 `AUMCV_HOVER` 的实际顺序是:
```text
W3DHierarchy:AUMCV_HOVER
W3DAnimation:AUMCV_HOVER
W3DContainer:AUMCV_HOVER
```
`W3DContainer` 因此被 `W3DHierarchy` 挤掉。`Model@Name` 的 `refType` 是
`BaseRenderAssetType`,`W3DHierarchy` 按 XSD 继承链不是渲染资产,所以
`assetsById` 里“有同名 id”但“没有匹配类型”,正好产生上述提示。
### 为什么以前没暴露 / 不是回归
第三轮修复的 `AUAntiVehicleVehicleTech1_SKN` 在 static.manifest 里只有一个
同名定义(`W3DContainer`),没有类型竞争,因此当时测不到该分支。git blame
显示 `addAsset` 的 `(file, line)` 去重从首个提交就存在,所以这是潜在缺陷被
新数据形态首次触发,不是近期改动造成的回归。
### 影响面(真实 manifest 扫描)
对 `Static / Global / Audio` 三个 manifest 模拟当前入库逻辑:
| 指标 | 数值 |
|---|---:|
| 同名 id 跨类型的 ID | 1318 |
| 被丢弃的类型定义 | 1470 |
| `W3DContainer` / `W3DMesh` 被丢弃的 id | 412 |
`Audio.manifest` 无此类碰撞。受影响的不止诊断和 hover:
`resolveReferenceTargetsForType`、语义 FAR / CodeLens 引用计数、未类型化补全
都经 `assetsById` 查找,因此 manifest 中的模型引用普遍可能误报或漏计。
### 修复
`assetsById` 是“按 id 汇总所有类型定义”的索引,去重身份必须包含类型:
1. `src/indexer/indexer.ts` 的 `addAsset()`:`assets` 与 `assetsById` 的去重
都改为 `(type, file, line)`;
2. `src/indexer/localScope.ts` 的 `addAsset()`:同样的去重修正,避免局部
overlay 未来遇到同构数据时重复踩坑。
`mergeLocalAndGlobalDefs`、`assetDefKey` 本来已按 `(type, id, file, line)`
区分定义,修复后三处语义一致。
### 测试(新增 1 个集成测试,全量 198 个通过)
`test/indexer.test.mjs` 新增自包含用例:
- 用最小 version-5 manifest 构造 `W3DHierarchy / W3DAnimation / W3DContainer`
三个同 id 资产,顺序刻意让渲染类型排在最后;
- 再构造 `Texture:ABAirfield` 在前、`W3DContainer:ABAIRFIELD` 在后的常见形态;
- 断言 `assetsById` 保留全部类型;
- 断言 `Model@Name` 经 `resolveReferenceTargetsForType` 命中 `W3DContainer`;
- 断言反向引用索引把该引用记到 `W3DContainer` 名下。
### 文档同步
`docs/plan.md` 的 manifest 建模小节补充:`assetsById` 必须保留同 id 的不同
类型定义,去重身份为 `(type, file, line)`。
---
## 二十八、问题分析(2026-08-11):xi:include 无 xpointer 语义与片段文件诊断(P0)
### 现象
`Data/Includes/GenericCelestialBuildingSuicide.xml` 这类被 `xi:include` 引用的
片段文件在独立打开时被报一串错误:`DieMuxData` 报 `missing-id`(“顶层资产需要
id”),wrapper 根不在 XSD 里的文件报 `unknown-element`,引用在完整索引下能解析
前还会报未解析引用。
### 根因
1. **无 `xpointer` 的展开语义错误**:`expandDocument` 把目标 `root.children`
拼进父节点。按 XInclude 语义(也是 Corona 的实际用法),没有 `xpointer` 时应
整体包含目标文档的根元素。`GenericCelestialBuildingSuicide.xml` 的根
`CreateObjectDie` 本身就是要放进 GameObject 的模块;旧实现会丢掉它,只把
`DieMuxData` 拼进去。
2. **诊断层把片段当完整文档**:`isTopLevel` 假定根一定是 `AssetDeclaration`,
于是片段根的子元素被当成顶层资产要求 id;未知 wrapper 根也被当成未知元素。
3. **引用/define 与上下文耦合**:片段里的引用可能由 include 者(或 include 者
的 include 链)提供,独立打开片段时无法可靠判定。
### 修复(P0,不猜测外部上下文)
1. `logicalTree.expandDocument`:无 `xpointer` 时 `handleChild(parse.root)`,
整体包含目标根元素;有 `xpointer` 时保持 `/n:Name/child::*` 语义。
2. `diagnostics` 片段模式:根 localName 不是 `AssetDeclaration` 即为片段。
- 一律跳过顶层 `missing-id` / 跨文件重复 id、未解析引用、未定义 `$DEFINE`;
- 根是已知 XSD 元素时(如 `CreateObjectDie`),根自身提供类型上下文,整棵子树
的未知元素 / 未知属性仍正常校验;
- 根不在 XSD 中(wrapper/container,如 `CommonArmorDraws`)时,只报 XML 语法
与片段内部 `xi:include` / `<Include>` 目标缺失,其余检查延后到上下文诊断。
3. 新增 `checkXiInclude`:`xi:include` 目标缺失在 Problems 中上报
`include-not-found`(此前只在 indexer 内部诊断)。
### 测试(202 → 202 全绿)
- `localScope.test.mjs`:无 `xpointer` 的 `xi:include` 把目标根元素
`CreateObjectDie` 整体拼入 `Behaviors`,`DieMuxData` 仍挂在它下面;
- `contentFeatures.test.mjs`:片段已知根不再报 `missing-id` / 未解析引用,但子树
未知属性仍报;未知 wrapper 根不报元素/属性,片段内部缺失 `xi:include` 仍报;
完整 `AssetDeclaration` 文档的顶层 id 检查不受影响。
### 边界与后续
- **P1 上下文诊断**:indexer 增加“反向 include 表”(`xi:include` 目标 →
include 者列表),打开片段时用 include 者的逻辑树做真实上下文校验,再恢复引用 /
define / 子元素结构检查。多上下文取并集去重。
- `<Include type="all|instance|reference">` 与 `xi:include` 语义不同:前者的目标
是完整 `AssetDeclaration`,不进入片段模式;后者才允许片段文件。
---
## 三十、问题分析(2026-08-11):`FXList inheritFrom` 误报未知属性与模型修正
### 现象
`GlobalData/FX_List.xml` 中:
```xml
<FXList id="FX_LargeEMCannonHitCrit" inheritFrom="FX_LargeEMCannonHit">
<NuggetList>
<ParticleSystem Particle="CritHit" OrientToObject="true" Ricochet="true"/>
</NuggetList>
</FXList>
```
报 `Unknown attribute "inheritFrom" for <FXList>`。
### 根因(三个独立问题)
**A. `inheritFrom` 的 XSD 白名单落后于 BAB 实际语义**
- SDK 与 Corona 的 `AssetTypeFXList.xsd` 都写 `FXList extends BaseAssetType`;
- `BaseAssetType` 只有 `id` / `typeHashCode` / `buildRule`,`inheritFrom` 只挂在
`BaseInheritableAsset` 上;
- 内置模型因此认为 `FXList` 没有 `inheritFrom`,diagnostics 的 unknown-attribute
直接按模型属性表判断;
- 但引用 / hover / 跳转层早就把 `inheritFrom` 当作通用引用属性处理,只有“属性名
合法性”这一层还在用 XSD 白名单,所以表现为局部不一致。
证据(用插件同一套解析器 + 模型扫描):
| 类型 | 原版 SageXml 顶层使用 `inheritFrom` | Corona 顶层使用 |
|---|---|---|
| `AIMicroManagerData` | 233 | 128 |
| `FXList` | 142 | 10 |
| `AITargetingHeuristic` | 10 | 5 |
| `ObjectCreationList` | 2 | 0 |
| `OnDemandTextureImage` | 0 | 9 |
原版 `FXListSoviet.xml` / `FXListJapan.xml` 大量使用该写法,说明这是 BAB 接受的
真实语义,不是用户笔误。
**B. `simpleContent` 复杂类型的属性被生成器丢弃**
`xsd-to-model.mjs` 的 `expandComplexType` 只读 `complexContent/extension`,没有读
`simpleContent/extension`。因此:
- `AudioFileRefWithWeight`(XSD 有 `Weight` / `Volume`)在模型里属性为空;
- `MultisoundSubsoundRef`(XSD 有 `Weight` / `PitchShiftLow/High` / `Volume` /
`PlayPercent` / `VolumeShift`)同样为空。
Corona 实测 `<Sound Weight="...">` 565 处、`<Subsound Weight="...">` 33 处会被误报。
simpleContent 复杂类型的**文本内容**引用语义(如 `<Sound>AudioFile</Sound>` 的补全 /
hover / 跳转 / 诊断 / FAR)在第三十一轮补齐,见下。
**C. 片段根元素仍受“元素名→类型”全局单映射影响**
`EvaEvent` 既是顶层资产,也是 `FXNuggetTypes` 的子元素
(`EvaEventFXNugget`)。全局 `elementTypeName("EvaEvent")` 取到的是先注册的
`EvaEventFXNugget`。完整 `AssetDeclaration` 文档有父上下文可以纠正;但
`additionalmaps/ALLC.xml` 这类根元素就是 `<EvaEvent>` 的片段没有父上下文,于是
`Priority`、`TimeBetweenEvents`、`ExpirationTime` 等合法属性被当成未知。
### 修复
1. **通用属性合法性集中到模型层**:`schemaModel.ts` 新增
`isAssetType()`(`BaseAssetType` 及其后代)与通用 `inheritFrom` 属性;
`attributesOfType()` 对资产类型统一返回它。诊断、属性补全、hover 自动一致。
2. **CodeLens / FAR 的“设计目标”判定保持窄口径**:`refs.ts` 的
`referenceTargetTypes()` 仍只看 XSD 显式声明的 `inheritFrom` 与类型化引用,
不会因为通用属性把全部 317 个资产类型变成计数目标。`isReferenceAttributeOfType`
与 `resolveReferenceTargetsForType` 同步改为只对资产类型接受 `inheritFrom`。
3. **生成器支持 `simpleContent/extension`**:`expandComplexType` 现在同时读
`complexContent` 与 `simpleContent` 的 extension,重新生成模型后
`AudioFileRefWithWeight` / `MultisoundSubsoundRef` 属性齐全。
4. **片段根优先取顶层类型**:`schemaModel.topLevelElementType()` 从
`AssetDeclaration` 的子元素声明解析类型;`resolveElementType()` 对文档根先用它,
再回退全局映射;hover 的元素名展示也使用已解析类型。
### 测试(举一反三,全量 210 通过)
- `schemaModel.test.mjs`:`FXList` / `AIMicroManagerData` / `ObjectCreationList` /
`OnDemandTextureImage` / `AITargetingHeuristic` 均接受 `inheritFrom`,
`Include` 不接受;`AudioFileRefWithWeight` / `MultisoundSubsoundRef` 属性齐全;
- `refs.test.mjs`:`FXList inheritFrom` 是引用,`Include inheritFrom` 不是;
`Credits` 接受通用 `inheritFrom` 但仍是 `isReferenceTargetType() === false`,
证明两个判定已分离;
- `typeContext.test.mjs`:片段根 `<EvaEvent>` / `<UpgradeTemplate>` 解析为顶层
类型,`<Weapon>` 仍回退到 `WeaponRef`;
- `completion.test.mjs`:`FXList` 属性补全出现 `inheritFrom`;
- `contentFeatures.test.mjs`:`FXList inheritFrom`、`<Sound Weight>`、片段根
`<EvaEvent>` 不再报 unknown-attribute,真实拼写错误仍报。
### 文档同步
- `docs/requirements.md`:继承机制补充“对资产类型通用”;
- `docs/plan.md`:XSD 结构说明补充实测差异与两个判定的分离;
- `docs/features-reference-counts.md`:说明通用 `inheritFrom` 不扩大 CodeLens
目标集合。
---
## 三十一、问题分析(2026-08-11):补齐所有“内容即引用”的语义(含 simpleContent 复杂类型)
### 目标
上一轮只恢复了 `AudioFileRefWithWeight` / `MultisoundSubsoundRef` 的属性,但它们
的文本内容(`<Sound>AudioFile</Sound>`、`<Subsound>VoiceEvent</Subsound>`)仍然
没有按引用处理。本轮把 simple-content 的内容语义统一到一条管线:**凡是元素文本
内容带 `xas:refType` 的,无论底层是 simple type 还是 simpleContent complexType,
都参与补全 / hover / 跳转 / 诊断 / 引用索引 / FAR**。
### 真实项目验证
用插件同一套解析器 + 模型扫描 Corona `Data`(7540 个 XML,跳过 w3x):
| 类别 | 唯一元素/类型组合 | 出现次数 | 典型元素 |
|---|---|---|---|
| 带 `refType` 的内容引用 | 62 | 20,813 | `Sound`→AudioFile、`Subsound`→BaseAudioEventInfo、`CreateObject`→GameObject、`TriggeredBy`→UpgradeTemplate |
| 无 `refType` 的 `isRef` 内容 | 5 | 1,150 | `Value`/`AddEmotion`/`Compare`/`Campaign`/`Mission`→AssetReference |
| 普通标量/枚举内容 | 6 | 9,012 | `IncludeThing`/`ExcludeThing`→WeakReference、`Script`、`SpecificBarrelOverride` |
结论:
- `Sound` / `Attack` / `Decay`(`AudioFileRefWithWeight`)内容确实是 `AudioFile`
引用;`Subsound`(`MultisoundSubsoundRef`)内容确实是 `BaseAudioEventInfo`
引用,必须纳入全局引用语义。
- `AssetReference` 系的无类型内容(`Value` 等)是着色器常量、脚本参数等,
**不应**按全局资产 ID 解析;保持上一轮“只处理带 refType 的内容”的边界。
- `IncludeThing` / `ExcludeThing` 等 `WeakReference` 内容是对象过滤/局部语义,
也没有 refType,不参与全局引用。
- 内联 simpleContent(如 w3x 的 `Frame`)是 `xs:float` 标量,`contentInfoOfType`
能识别但 `refType === null`,不会误报。
### 实现
1. **模型层**:`schemaModel.ts` 新增 `SimpleContentInfo` / `ContentTypeInfo` 和
`contentInfoOfType()`;`ComplexTypeInfo` 增加可选 `content` 字段。
`xsd-to-model.mjs` 对 `simpleContent/extension` 记录 base 类型的
`refType` / `isRef` / 枚举 / list / `$DEFINE` 能力。
2. **引用判定**:`refs.ts` 的 `isReferenceContentType()` 与
`resolveContentReferenceTargets()` 改用统一内容描述;simpleContent 复杂类型
的 `refType` 也进入 `referenceTargetTypes()`,保证 CodeLens 类型过滤正确。
3. **索引**:`records.ts` 内容记录的 `refType` 从统一内容描述提取,FAR 与
引用计数不再丢 `Sound` / `Subsound`。
4. **补全**:`completion.ts` 的元素片段、内容值补全、子元素补全触发 suggest 均
统一走 `contentInfoOfType()`,`<Sound>` 会生成 `<Sound>$1</Sound>` 并弹
AudioFile 候选。
5. **hover / 导航 / 诊断**:全部改为从 `contentInfoOfType()` 取 `refType`。
### 测试(全量 219 通过)
- `schemaModel.test.mjs`:`contentInfoOfType` 对 simple 与 simpleContent 统一;
- `refs.test.mjs`:`AudioFileRefWithWeight` / `MultisoundSubsoundRef` 是内容引用,
`@inline:Frame` 不是;同名 AudioFile / AudioEvent 严格按 refType 过滤;
- `records.test.mjs`:`Sound` / `Subsound` 文本进入引用索引;
- `completion.test.mjs`:`<Sound>` 补全成值对并触发 suggest,内容值只补
AudioFile;
- `contentFeatures.test.mjs`:`<Sound>` hover / Ctrl+点击 / `<Subsound>` 未解析
诊断;
- `referenceProvider.test.mjs`:FAR 返回 `Sound` 文本引用。
### 文档同步
- `docs/requirements.md`:simple-content 元素补充 simpleContent 复杂类型示例;
- `docs/plan.md`:simple-content 文本引用说明补充第三十一轮扩展;
- `docs/features-reference-counts.md`:引用语义说明补充“含 simpleContent 复杂
类型”。
---
## 三十二、问题分析(2026-08-11):限定引用值 `类型:ID` 未被归一化导致误报未解析
### 现象
Corona `Data\Allied\Units\AlliedFutureTankX-1\AudioEvent.xml`:
```xml
<Includes>
<Include type="instance" source="DATA:SageXml/Sounds/BaseSoundEffect.xml" />
</Includes>
<AudioEvent
id="ALL_FutureTank_ArmPrimaryWeapon"
inheritFrom="AudioEvent:BaseSoundEffect"
... />
```
报 `Unresolved reference "AudioEvent:BaseSoundEffect"`,提示当前索引中未找到;
但 `SageXml\Sounds\BaseSoundEffect.xml` 里确实存在 `<AudioEvent id="BaseSoundEffect" />`,
且 `instance` include 会被索引器与文档局部 overlay 正常 walk。
### 根因
插件只在 **manifest 一侧**做了“资产名 `类型:ID` → 裸 ID(取最后冒号段)”
的归一化(`manifestParser.deriveAssetId`);**XML 引用值一侧**直接用原始值查
`assetsById`。于是 `AudioEvent:BaseSoundEffect` 被当成完整 ID 精确匹配,
索引里只有 `BaseSoundEffect`,必然查不到。
实测最小复现:索引中包含 `AudioEvent@BaseSoundEffect`(origin=sdk),
`assetsById.get("audioevent:basesoundeffect")` 返回 NOT FOUND,
`resolveReferenceTargetsForType` 返回 0 目标。
### 影响面(真实数据统计)
这是原版数据的**普遍写法**,不是用户笔误:
| 属性 | SageXml | Corona Data | 典型值 |
|---|---|---:|---|
| `inheritFrom` | 5,483 | 3,219 | `AudioEvent:BaseSoundEffect` |
| `Sound`(AudioEntry) | 39 | 98 | `AudioEvent:JAP_Refinery_Select` |
| `Side` | 67 | 195 | `PlayerTemplate:Allies` |
| `ParticleTexture` | 2 | 2 | `Texture:FXLenzFlare01` |
前缀全部是**定义资产的具体类型**(manifest 全名格式),而 XSD refType 可能是
基类(如 `Sound` 的 refType 是 `BaseAudioEventInfo`,前缀是 `AudioEvent`)。
两侧数据的 `id="类型:ID"` 出现次数均为 0,说明定义侧永远是裸 ID,取最后冒号段
没有歧义。少数 `Sound="AudioEvent:MammothTankTurretMoveLoop"` 等引用在 SDK
源码与三个 manifest 中都找不到定义,是原版数据自身的死引用,归一化后仍会
(且应该)继续报未解析。
### 修复
1. `refs.ts` 新增 `normalizeReferenceId(value)`:取最后冒号段(与
`deriveAssetId` 同一规则;冒号后为空时保留原值,避免半输入误匹配),
应用到 `resolveReferenceTargetsForType` 与 `resolveContentReferenceTargets`。
2. `referenceIndex.ts` 的 `buildReferenceIndex` 与 `features/references.ts`
的 `definitionsForReference` 同样归一化,FAR / CodeLens / 引用 peek 与
诊断、hover、跳转保持一致。
3. `records.ts` 不修改:记录仍保存原始值与原始偏移,导航/悬停范围不受影响,
缓存格式与版本不变。
4. `completion.ts` 的 `assetIdItems`:当前输入段含 `:` 时按冒号后片段过滤,
补全项 label/insertText 为“已输入前缀 + 裸 ID”(如 `AudioEvent:Base…`
→ `AudioEvent:BaseSoundEffect`);未输入前缀时保持裸 ID 补全,不特判任何
类型、也不改变默认补全形态。
### 测试(219 → 226 全绿)
- `refs.test.mjs`:`normalizeReferenceId` 边界;qualified `inheritFrom`
解析、裸 ID 不变、错误类型前缀仍被 selfType 过滤;qualified 属性
(`Sound` / `Side`)与 simple-content(`AudioFile:...`)引用解析;
- `referenceIndex.test.mjs`:qualified 记录计入反向索引(FAR / CodeLens 桶);
- `indexer.test.mjs`:临时项目集成——`instance` include 进 SageXml +
`inheritFrom="AudioEvent:BaseSoundEffect"`,断言定义入库、解析命中、
反向索引落点(即用户报告的完整场景);
- `completion.test.mjs`:`AudioEvent:Base…` 补全为
`AudioEvent:BaseSoundEffect` 且替换范围只覆盖当前段;无前缀仍补裸 ID;
- `contentFeatures.test.mjs`:qualified `inheritFrom` 不产生未解析诊断,
Ctrl+点击精确定位到裸 ID 定义。
### 文档同步
- `docs/requirements.md`:情况描述补充 `类型:ID` 引用写法与归一化规则;
- `docs/plan.md`:设计决策 5 补充限定引用值归一化,实施记录追加第 29 轮;
- `CHANGELOG.md`:0.1.24。
+16 -4
View File
@@ -31,10 +31,21 @@
与补全 / hover / 跳转 / 诊断完全一致(`refs.ts` 是唯一判定来源):
- 带 `xas:refType` 的属性值(如 `CommandSet` → `LogicCommandSet`);
- 带 `xas:refType` 的 simple-content 文本(如 `<CreateObject>ID</CreateObject>`);
- 带 `xas:refType` 的 simple-content 文本(如 `<CreateObject>ID</CreateObject>`,
含 simpleContent 复杂类型 `<Sound>AudioFile</Sound>`、`<Subsound>VoiceEvent</Subsound>`);
- `inheritFrom`(按元素自身类型过滤);
- 无 `refType` 的 `isRef` 属性(按同名 ID 匹配任意声明类型)。
引用值本身支持原版/Mod 常用的 manifest 风格全名 `类型:ID`
(`inheritFrom="AudioEvent:BaseSoundEffect"`、`Sound="AudioEvent:..."`、
`Side="PlayerTemplate:Allies"`):解析与反向索引先按 `normalizeReferenceId`
取最后冒号段,再执行上述类型过滤;记录里的原始值与偏移保持不变。
`inheritFrom` 对 `BaseAssetType` 系资产是通用合法属性(XSD 只在
`BaseInheritableAsset` 声明,但原版数据在 `FXList` 等类型上也使用)。这里的
“合法属性”判定与“设计上应显示引用计数”的 `referenceTargetTypes()` 是分开的:
通用 `inheritFrom` 不会把每个资产类型都变成 CodeLens / 未引用报告的目标。
不算引用:
- 元素自己的 `id` 定义点(除非是 `RoadObject@id→Road` 这类跨类型 id 引用);
@@ -86,9 +97,10 @@ ModIndex.references Map<定义 key, ReferenceSite[]>
- 点击执行 `ra3modxml.showReferences` → `editor.action.showReferences`
打开 references peek,结果与计数完全一致(不含定义本身);
- 计数除了当前定义自己的反向索引桶,还并入“manifestSource 可解析到当前
文件”的 manifest 定义桶:manifest 资产有对应 SageXml 源码时,引用直接
视作 SageXml 源码对该 asset 的引用(Go to Definition 同样把 manifest
定义映射到 SageXml 源码);
文件”的 manifest 定义桶:`manifestSource` 按 vanilla-only 搜索路径
(SDK 根 + `SageXml`)解析,mod 同名 DATA 路径不会被视为源码;manifest
资产有对应 SageXml 源码时,引用直接视作 SageXml 源码对该 asset 的引用
(Go to Definition 同样把 manifest 定义映射到 SageXml 源码);
- 索引重建完成后自动 `editor.action.codeLens.refresh`,计数不会停留在旧值。
## 五、未引用资产
+111 -26
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-11)对齐了实现过程中新增的模块与设计变更:BAB 精确搜索路径、manifest 类型/ID 推导、上下文感知元素类型、属性级 refType / Poid 局部引用(`id` 定义点)、精确跳转范围、嵌套 `xi:include`、注入式语法高亮、bit-flag 列表补全(空格触发 / 排除已用 / 追加模式)、simple-content 元素文本引用(补全 / hover / 跳转 / 诊断 / Find All References)、语义引用索引 / CodeLens 引用计数 / 未引用资产命令、属性补全换行判定与按 id 去重、manifest 源地址按 vanilla-only 解析(避免 mod 同名 DATA 路径遮蔽)、`assetsById` 保留同 id 的不同类型 manifest 定义等。
## 一、调研结论(带证据)
@@ -24,7 +24,7 @@
- 共 **821 个 XSD / 1.5 MB**,入口 `CnC3Types.xsd`。
- 根元素 `AssetDeclaration`:`Tags` / `Includes` / `Defines` + **295 个顶层资产元素**(含内联声明)。
- 每个元素名对应一个 `complexType`,子元素用 `xs:sequence` / `xs:choice` 定义,属性用 `xs:attribute` 定义;复杂类型通过 `xs:extension` 继承(如 `BaseInheritableAsset` 提供 `inheritFrom`)。
- 每个元素名对应一个 `complexType`,子元素用 `xs:sequence` / `xs:choice` 定义,属性用 `xs:attribute` 定义;复杂类型通过 `xs:extension` 继承(XSD 中 `BaseInheritableAsset` 提供 `inheritFrom`)。实测 BAB / 原版数据也接受 `FXList`、`AIMicroManagerData`、`AITargetingHeuristic`、`ObjectCreationList` 等 `BaseAssetType` 系资产使用 `inheritFrom`,因此插件把 `inheritFrom` 作为所有资产类型的通用属性处理;“设计上应显示引用计数”的判定仍按 XSD 显式声明的可继承类型,两者分开。
- `Includes/Ref.xsd` 定义了大量带 `xas:refType="<资产类型>"` 的引用类型(如 `CommandSet` 引用 `LogicCommandSet`)→ 补全/导航按引用类型过滤的依据。
- `XmlEdit:Default` 提供默认值;`xs:enumeration` 提供枚举值。`xas:refType` 可声明在 simple type 上,也可声明在 `<xs:attribute>` 节点上(模型生成器两者都读、属性级优先)。
- `Poid`("Pipeline Object Id",`xas:isWeakRef="true"`)表示**管线局部标识**:`id` 属性定义元素自身(如 `ModuleData@id` → refType `ModuleData`);`ModuleId`、`AutoResolveBody`、`SoundRef` 等 Poid 属性引用同一资产/子树内的模块、子对象、材质——它们都不对全局资产索引做 resolved 判定。
@@ -69,7 +69,10 @@
```
src/
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 等)
language/
xmlParser.ts 带源码偏移的轻量 XML 解析器(格式错误定位、容错)
@@ -81,7 +84,8 @@ src/
schema-model.json 由 tools/xsd-to-model.mjs 生成
asset-types.json 由 tools/extract-asset-types.mjs 生成(TypeId 哈希→类型名)
indexer/
includeResolver.ts Include 路径解析(纯 TS,BAB /data /art /audio 顺序)
includeResolver.ts Include 路径解析(纯 TS,BAB /data /art /audio 顺序)+
manifest 源 vanilla-only 解析(SDK 根 + SageXml)
existence.ts 文件集存在性快照(目录枚举 Set,替代逐路径 statSync)
manifestParser.ts .manifest 二进制解析 + 类型/ID 推导(纯 TS)
fileScanner.ts 目录遍历缓存 + Include source 候选收集
@@ -112,24 +116,33 @@ tools/
extract-asset-types.mjs OpenSAGE AssetType.cs → asset-types.json
test/
fixtures/minimod 样例 Mod(include 各种情形、同名 ID、嵌套 xi:include、manifest 回退)
*.test.mjs 14 个测试文件(xmlParser / context / completion / semanticTokens /
*.test.mjs 16 个测试文件(xmlParser / context / completion / semanticTokens /
includeResolver / manifestParser / indexer / schemaModel / refs /
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。
**美术资产(.w3x)**:`<Include type="all">` / `ART:` 指向的 `.w3x`(及内容嗅探为
XML 的未知扩展名文件)按其顶层资产入库(`W3DContainer` / `W3DMesh` /
`W3DHierarchy` / `W3DCollisionBox` 等),使 `Model@Name`、`Hierarchy`、`Mesh`
等引用可解析;大模型文件**浅扫描**(不建 DOM),结果缓存在 workspace 级、
跨重建复用(详见设计决策 14)。
3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer` ↔ `W3DContainer`),类型匹配严格遵循 XSD 继承链。
3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer` ↔ `W3DContainer`),类型匹配严格遵循 XSD 继承链。`assetsById` 按 id 汇总**全部类型**的定义,去重身份为 `(type, file, line)`,同一 manifest 中同名但不同类型的美术资产(如 `W3DHierarchy:AUMCV_HOVER` 与 `W3DContainer:AUMCV_HOVER`)必须全部保留,避免 `Model@Name` 这类 `BaseRenderAssetType` 引用因先到的非渲染类型而被误判为未解析。
4. **上下文感知元素类型**:同名元素按父元素类型解析(`resolveElementType` 沿解析树逐层 `childTypeOf`,失败回退全局映射),保证 `<Weapon>` 等元素的属性/引用判定正确。
5. **引用判定与解析**:`refType` 或 `isRef` 均视为引用;带 `refType` 时严格按类型过滤(同名 ID 不串类型);`inheritFrom` 按可继承类型过滤。**局部作用域例外**(`isLocalReferenceAttribute`):`id` 是元素自身的定义点——无 refType 或 refType 与自身类型兼容时不检查、不解析(`RoadObject@id→Road` 这类跨类型 id 引用保留检查);Poid 类型属性是管线局部引用,全局索引无法判定,不检查、不解析。
5. **引用判定与解析**:`refType` 或 `isRef` 均视为引用;带 `refType` 时严格按类型过滤(同名 ID 不串类型);`inheritFrom` 按可继承类型过滤。**局部作用域例外**(`isLocalReferenceAttribute`):`id` 是元素自身的定义点——无 refType 或 refType 与自身类型兼容时不检查、不解析(`RoadObject@id→Road` 这类跨类型 id 引用保留检查);Poid 类型属性是管线局部引用,全局索引无法判定,不检查、不解析。**限定引用值**:原版/Mod 数据常用 manifest 风格全名 `类型:ID`(如 `AudioEvent:BaseSoundEffect`、`PlayerTemplate:Allies`);XML 定义侧 id 从不含冒号,所以查询统一走 `normalizeReferenceId`(取最后冒号段,与 manifest 的 `deriveAssetId` 同一规则)后再按 refType/selfType 过滤,records 仍保留原始值与偏移供导航/悬停使用。
6. **重复 ID 诊断**:与 `check_duplicate_ids.py` 一致——SageXml 不参与冲突判定,mod 覆盖原版视为正常。
7. **未解析引用诊断**:按设置严重级别报告(默认 warning);类型不匹配时给出明确文案("有同名 ID 但类型不匹配")。`definitionMode` 设置控制跳转候选:`all`(mod + 原版全部列出,mod 优先)或 `project-only`。
8. **跳转精度**:XML 定义跳转到 `id` 属性值的精确 Range;manifest 定义映射到源码文件(如 SageXml)时也在文件内精确定位;找不到再回退到记录行。
@@ -239,16 +252,19 @@ test/
第一个独占一行的完整属性作为规范缩进,插入换行时顺带吞掉触发补全留下的
尾随空格;属性名补全改用 `SnippetString`(`$1` 成为真正占位符),并新增
输出通道调试日志。
27. **simple-content 元素文本引用(第十六轮,2026-08-04)**:simple type
子元素(如 `ObjectCreationList` 内嵌套 `<CreateObject>`,类型
`GameObjectWeakRef`)的**标签间文本**就是资产引用。内容区补全现在区分
“复杂元素 → 子元素名”与“简单元素 → 值补全”;用户已输入 `<` 时替换范围从
`<` 开始,杜绝 `<<`;simple type 元素片段固定为 `<Name>$1</Name>`(可填
值)并自动触发值补全。hover / Ctrl 跳转 / 诊断 / Find All References
均增加内容 token 分支。只有**带 `xas:refType`** 的 simple 内容按全局引用
处理(291 处子元素声明);无类型 `AssetReference`
(`FXShaderConstantTexture@Value`、`RenderSubObjectReference@Mesh` 等
真实数据是贴图/子对象名)与 `Poid` 不参与全局解析,避免误报。
27. **simple-content 元素文本引用(第十六轮,2026-08-04;第三十一轮扩展)**:
simple type 子元素(如 `ObjectCreationList` 内嵌套 `<CreateObject>`,类型
`GameObjectWeakRef`)以及 simpleContent 复杂类型(如 `<Sound>` →
`AudioFileRefWithWeight`、`<Subsound>` → `MultisoundSubsoundRef`)的
**标签间文本**就是资产引用。内容区补全现在区分“复杂元素 → 子元素名”与
“内容元素 → 值补全”;用户已输入 `<` 时替换范围从 `<` 开始,杜绝 `<<`;
内容元素片段固定为 `<Name>$1</Name>`(可填值)并自动触发值补全。hover /
Ctrl 跳转 / 诊断 / Find All References 均增加内容 token 分支。只有**带
`xas:refType`** 的内容按全局引用处理(291 处 simple type 子元素声明 +
`Sound` / `Attack` / `Decay` / `Subsound` 等 simpleContent 复杂类型);
无类型 `AssetReference`(`FXShaderConstantTexture@Value`、
`RenderSubObjectReference@Mesh` 等真实数据是贴图/子对象名)与 `Poid`
不参与全局解析,避免误报。
补充:真实文件中 `<` 后还有 `</…>` 时,`findTagEnd` 曾把闭合标签的 `>` 误
当成残缺开始标签的结束,生成空名/半截名伪元素,补全走 element-name 分支
导致 `<<`。修复为引号外遇到 `<` 即视为未闭合(行尾恢复),且
@@ -352,6 +368,70 @@ test/
对 stat 匹配的 full XML 做内容哈希校验;打开文档时比较 records 哈希,
不一致则定向 invalidate + `records-desync` 重建自愈;磁盘缓存 v2 → v3;
测试 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`
二十八。
29. [x] 限定引用值 `类型:ID` 归一化(2026-08-11,v0.1.24):新增
`refs.normalizeReferenceId`(取最后冒号段,与 manifest `deriveAssetId`
同一规则),应用到属性引用、simple-content 引用、语义反向索引与
FAR/CodeLens 的 `definitionsForReference`;records 仍保存原始值与
偏移;补全在已输入 `类型:` 前缀时按冒号后片段过滤并保留前缀;
实测 SageXml 5483 / Corona 3219 处 `inheritFrom="AudioEvent:..."`
等受限引用全部修复;测试 219 → 226;分析见
`docs/analysis-issues.md` 三十二。
## 四、验证结果(实测)
@@ -369,7 +449,9 @@ test/
前缀保护、多行未闭合 `Disposition` 完整链路、闭合引号后补空格、一行一个属性
换行缩进、新行缩进对齐、标量类型化默认值)、语义 token(标签/属性/值范围、
合法文档返回空、malformed 返回兜底 token)、include 解析(BAB 顺序、SDK 根
优先于 SageXml)、manifest 二进制解析(合成 v5 样本、类型/ID 推导)、索引器
优先于 SageXml;manifest 源 vanilla-only:mod 同名遮蔽仍命中 SageXml、
源缺失保持 manifest-only、id 被删降级文件顶部)、manifest 二进制解析
(合成 v5 样本、类型/ID 推导)、索引器
(资产/Define/流/缺失 include/嵌套 xi:include)、XSD 模型(上下文类型、
`childTypeOf`、大小写规范化、属性级 refType、外来命名空间判定、`xs:list`
枚举继承与 `isList` 标记)、引用过滤(`Weapon="X"` 只跳 `WeaponTemplate`、
@@ -393,7 +475,7 @@ test/
展开(第十二轮,见第六节);顶层 `<Include type="all">` 与 `inheritFrom` +
`xai:joinAction` 的深合并仍未实现,后续如需要“当前文档视角的全量合并诊断”再继续。
## 六、include 展开设计备忘(2026-08-01;xi:include 部分已实施于第十二轮)
## 六、include 展开设计备忘(2026-08-01;xi:include 部分已实施于第十二轮,无 xpointer 语义与片段诊断见第二十八轮)
> 目的:集中记录 include 处理相关的现状、结论与设计,下次遇到 include 问题时从这里继续,
> 并在实施后把结果回写本节。
@@ -406,7 +488,8 @@ test/
| `reference` → builtmods manifest 解析 / 缺失回退占位 XML | 已实现 |
| 嵌套 `xi:include`(任意层级):目标可索引、缺失报 `include-not-found`、Ctrl+点击跳转、`href` hover 解析目标 | 已实现(第二轮 + 第五轮) |
| `xi:include` 及其属性不参与 XSD 校验(外来命名空间守卫 `isXsdElementName` / `isXsdAttributeName`) | 已实现(第五轮) |
| include 目标内容“虚拟合并”进父文档的逻辑树 | 已实现 `xi:include`(第十二轮);顶层 `<Include type="all">` 仍不展开 |
| include 目标内容“虚拟合并”进父文档的逻辑树 | 已实现 `xi:include`(第十二轮);**无 `xpointer` 时整体包含目标根元素**(第二十八轮修正);顶层 `<Include type="all">` 仍不展开 |
| 片段文件(根非 `AssetDeclaration`)的诊断 | 已实现 P0(第二十八轮):跳过顶层 id/重复/引用/define 检查;根为已知 XSD 元素时仍校验子树;根未知时只报语法与 include 缺失 |
### 2. 已确认的方向
@@ -417,9 +500,11 @@ BAB(`defaultscript.cs`)编译时正是这样把整个 Mod 合并成一份大
- **不要**把 include 目标展开成文本再整体重新解析:源码偏移会断裂,诊断 / 跳转 / hover /
补全全部无法映射回原始文件。
- **要做**的是:解析器逐文件解析(现状不变);展开器把目标文件选中节点按 `xpointer`
子集挂进父元素,节点保留各自的源文件与原始偏移(来源追溯)。后续分析跑在逻辑树上,
范围映射按节点 `sourceFile` 回到对应文件的 lineMap。
- **要做**的是:解析器逐文件解析(现状不变);展开器把目标文件选中节点挂进父元素——
有 `xpointer` 时取 `/n:Name/child::*` 选中 children,无 `xpointer` 时按 XInclude 语义
整体包含目标根元素(RA3 片段如 `CreateObjectDie` 依赖这一行为);节点保留各自的源文件
与原始偏移(来源追溯)。后续分析跑在逻辑树上,范围映射按节点 `sourceFile` 回到对应
文件的 lineMap。
- 现有 `parseXml` 已记录标签 / 属性 / 值的起止偏移,`XmlElement` 结构可直接复用;拼接时
用浅拷贝节点壳并重建 parent 链,避免破坏目标文件缓存树自身的 parent 指针。
@@ -427,7 +512,7 @@ BAB(`defaultscript.cs`)编译时正是这样把整个 Mod 合并成一份大
| 构造 | 拼入逻辑树 | 理由 |
|---|---|---|
| `xi:include` | ✅ | 内容并入父元素(HeadlightDraw2 场景) |
| `xi:include` | ✅ | 有 `xpointer`:选中容器 children;无 `xpointer`:整体包含目标根元素(CreateObjectDie / TechUpgradeReceiver 等片段场景) |
| EA `<Include type="all">` | ✅ | BAB“内容合并”,等价于复制进来 |
| `type="instance"` | ❌ | 只影响编译可见性;拼树会把 BaseVehicle 的顶层资产错误塞进当前文档 |
| `type="reference"` | ❌ | manifest 编译产物,无文本内容 |
+26 -5
View File
@@ -23,7 +23,9 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
- 无前缀的路径相对于当前文件所在目录;
- `ART:` 路径支持“文件名前两个小写字母作为子目录”的匹配(如 `JUAntiShip` → `ju/JUAntiShip`)。
继承机制:`inheritFrom` 让一个元素默认获得目标元素的所有内容;具体合并行为由 `xai:joinAction`(`uri:ea.com:eala:asset:instance` 命名空间)控制,实际项目中出现的取值为 `Replace`、`Remove`。
继承机制:`inheritFrom` 让一个元素默认获得目标元素的所有内容;具体合并行为由 `xai:joinAction`(`uri:ea.com:eala:asset:instance` 命名空间)控制,实际项目中出现的取值为 `Replace`、`Remove`。XSD 只在 `BaseInheritableAsset` 上显式声明 `inheritFrom`,但原版与 Corona 数据也在 `FXList`、`AIMicroManagerData` 等 `BaseAssetType` 系资产上使用它,插件按“所有资产类型的通用属性”处理。
引用值写法:原版与 Corona 数据中的引用既可以是裸 ID,也可以是 manifest 风格的全名 `类型:ID`(如 `inheritFrom="AudioEvent:BaseSoundEffect"`、`Sound="AudioEvent:JAP_Refinery_Select"`、`Side="PlayerTemplate:Allies"`、`ParticleTexture="Texture:FXLenzFlare01"`)。XML 定义侧的 `id` 从不含冒号,因此插件统一按“最后冒号段”归一化后再匹配定义,类型过滤仍按 refType / 元素自身类型执行。
全部 XML 语法由 XSD 定义:SDK 自带 `Schemas/xsd/CnC3Types.xsd`(及其 800+ 个子 XSD)。大型 Mod 项目(如 Corona)还会携带自己修改过的 XSD 副本。
@@ -38,13 +40,15 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
- 属性值:
- 引用型属性(XSD 中带 `xas:refType`)补全已定义的资产 ID;
- `inheritFrom` 补全可继承的资产 ID;
- 引用值支持 `类型:ID` 前缀写法:已输入 `AudioEvent:` 时按冒号后的 ID 过滤,插入时保留已输入的前缀;未输入前缀时保持裸 ID 补全;
- 枚举值(XSD `xs:enumeration`);
- `$DEFINE` 常量(如 `$CIV_HEALTH_SMALL`);
- `<Include source>` 补全可解析的文件路径(`DATA:` / `ART:` / `AUDIO:`)。
- **元素文本内容(simple content)**:带 `xas:refType` 的简单内容元素
(如 `<CreateObject>ID</CreateObject>`)在标签间补全对应类型的资产 ID、
枚举或 `$DEFINE`;补全出的 simple-content 元素必须是可填值的成对标签
(`<Name></Name>`),且内容区已输入 `<` 时不得产生 `<<`。
- **元素文本内容(simple content)**:带 `xas:refType` 的内容元素
(simple type 如 `<CreateObject>ID</CreateObject>`,simpleContent 复杂类型
如 `<Sound>AudioFile</Sound>`、`<Subsound>VoiceEvent</Subsound>`)在标签间
补全对应类型的资产 ID、枚举或 `$DEFINE`;补全出的 simple-content 元素必须
是可填值的成对标签(`<Name></Name>`),且内容区已输入 `<` 时不得产生 `<<`。
3. **引用提示(Hover)**:元素/属性悬停显示 XSD 文档、类型、默认值;资产 ID 悬停显示定义位置;`$DEFINE` 悬停显示值与定义位置。
- 元素文本内容(simple content 引用)悬停同样显示定义位置。
4. **引用导航**:
@@ -60,10 +64,19 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
- 缺失必填 `id`(顶层资产);
- 重复 ID(同类型 + 同 id,mod 文件之间;覆盖原版 SageXml 不算冲突);
- 引用未解析(引用了不存在的资产 ID,可配置是否忽略原版 manifest 中的 ID);
- 引用值带 `类型:` 前缀时先归一化为裸 ID 再判定(如 `AudioEvent:BaseSoundEffect` → `BaseSoundEffect`),前缀不影响类型过滤;
- simple-content 引用元素的文本未解析(同属性引用规则,仅带 refType 的类型)。
- `<Include>` 目标文件找不到、Include 循环;
- `$DEFINE` 未定义。
**补充(工作区/项目检测,2026-08-07)**:
- 项目根不要求工作区精确匹配 `Data/Mod.xml`:从工作区文件夹向上最多 12 层、
从打开的 XML 文件向上、以及从“包含多个 mod 的容器文件夹”向下浅扫最多 3 层
均可发现项目根(`Data/Mod.xml`、`Data/additionalmaps/mapmetadata_*.xml`、
`*.babproj` 任一标记命中即可,大小写不敏感、最近命中优先)。
- 多项目同时打开时按文档就近选择项目;单项目打开立即建索引,容器/多项目采用
惰性索引(活动文档所属项目先建,其他在文档打开或首次请求时建),构建串行执行。
**补充(manifest 解析,支持 include reference 后的补全/导航/诊断)**:
- 当 `Mod.xml`(或其他文件)用 `<Include type="reference" source="DATA:static.xml" />` 引用占位文件时,实际内容来自 SDK `builtmods` 下对应的已编译二进制 manifest(`static.manifest` / `global.manifest` / `audio.manifest`)。
@@ -71,6 +84,11 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
- **代码补全**:例如 reference 了 `audio.xml` 后,所有音频资产 ID 都能出现在引用型属性(如 `AudioEventRef`)的补全里;
- **引用导航/悬停**:能定位资产来自哪个 manifest、哪个源文件;
- **诊断**:能把“引用了 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)。关键格式要点:
- 头部含版本(5/6/7)、端序标志、各缓冲区大小、资产数量;
- 每个资产条目含 `TypeId`(哈希)、`NameOffset`、`SourceFileNameOffset` 等;
@@ -116,6 +134,9 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
## 四、验收标准
- 在 AttachTest / GenEvoTest 上开箱即用(高亮、补全、跳转、诊断)。
- 打开 mod 的 `Data` 文件夹、`Data` 子文件夹、仅含 mapmetadata 的项目、
单个 XML 文件、以及“内部包含多个 mod”的容器文件夹时均能正确发现项目根;
多项目打开时各自索引与功能互不串扰。
- 在 Corona 规模的目录上不卡 UI:索引在后台执行、保存文件后增量更新。
- 纯解析/索引核心不依赖 VS Code API,可被其他工具复用。
- 可用 `vsce package` 打出可安装的 `.vsix`。
+14 -2
View File
@@ -1,11 +1,23 @@
import * as esbuild from "esbuild";
import { rm } from "node:fs/promises";
const watch = process.argv.includes("--watch");
// Remove previous outputs first. esbuild only overwrites the files its current
// entry points produce, so without this a packaged VSIX can ship stale
// artifacts from an older layout (e.g. the pre-`outbase` dist/mcpServer.js
// next to the current dist/agent/mcpServer.js).
await rm("dist", { recursive: true, force: true });
const ctx = await esbuild.context({
entryPoints: ["./src/extension.ts"],
entryPoints: [
"./src/extension.ts",
"./src/agent/mcpServer.ts",
"./src/agent/cli.ts",
],
bundle: true,
outfile: "dist/extension.js",
outdir: "dist",
outbase: "src",
external: ["vscode"],
format: "cjs",
platform: "node",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+228
View File
@@ -0,0 +1,228 @@
{
"RA3 Mod XML: caches cleared; rebuilding from scratch…": "RA3 Mod XML: caches cleared; rebuilding from scratch…",
"RA3 Mod XML: index is still building — check the status bar. Most features become available after the XML phase.": "RA3 Mod XML: index is still building — check the status bar. Most features become available after the XML phase.",
"RA3 Mod XML: no index for the active project yet — open a mod XML document to start indexing.": "RA3 Mod XML: no index for the active project yet — open a mod XML document to start indexing.",
"RA3 Mod XML: no index available. Open a workspace that contains Data/Mod.xml, Data/additionalmaps/mapmetadata_*.xml or a mod folder.": "RA3 Mod XML: no index available. Open a workspace that contains Data/Mod.xml, Data/additionalmaps/mapmetadata_*.xml or a mod folder.",
"(stale)": "(stale)",
"RA3 Mod XML index": "RA3 Mod XML index",
"Project: {0}": "Project: {0}",
"Files: {0} ({1} parsed, {2} shallow-scanned, {3} cache hits)": "Files: {0} ({1} parsed, {2} shallow-scanned, {3} cache hits)",
"Assets: {0} ({1} from {2} manifests)": "Assets: {0} ({1} from {2} manifests)",
"References: {0}": "References: {0}",
"Defines: {0} · Streams: {1} · Candidates: {2}": "Defines: {0} · Streams: {1} · Candidates: {2}",
"Phase: {0} · Complete: {1}{2}": "Phase: {0} · Complete: {1}{2}",
"Build #{0} (trigger: {1})": "Build #{0} (trigger: {1})",
"Indexed in {0}s": "Indexed in {0}s",
"XML walk: {0}s · Candidates: {1}s · Art scan: {2}s": "XML walk: {0}s · Candidates: {1}s · Art scan: {2}s",
"0 references": "0 references",
"1 reference": "1 reference",
"{0} references": "{0} references",
"Type: {0}": "Type: {0}",
"RA3 XML · {0}": "RA3 XML · {0}",
"RA3 XML": "RA3 XML",
"Root element of every RA3 asset file": "Root element of every RA3 asset file",
"**Required**": "**Required**",
"References: `{0}`": "References: `{0}`",
"Values: {0}": "Values: {0}",
"Default: `{0}`": "Default: `{0}`",
"Type: `{0}`": "Type: `{0}`",
"Suggest attribute values": "Suggest attribute values",
"Instance join action": "Instance join action",
"Controls how this element merges with the inherited definition: `Replace` or `Remove`.": "Controls how this element merges with the inherited definition: `Replace` or `Remove`.",
"xai namespace": "xai namespace",
"Include type": "Include type",
"xai:joinAction": "xai:joinAction",
"enum": "enum",
"boolean": "boolean",
"Include source": "Include source",
"`{0}` · {1}": "`{0}` · {1}",
"relative": "relative",
"manifest ({0})": "manifest ({0})",
"manifest": "manifest",
"**Type**: {0}": "**Type**: {0}",
"**Source**: {0}": "**Source**: {0}",
"**Origin**: {0}": "**Origin**: {0}",
"Also defined as **{0}** · {1}": "Also defined as **{0}** · {1}",
"{0} · {1}": "{0} · {1}",
"Define": "Define",
"local module": "local module",
"Pipeline-local id in the enclosing GameObject (includes xi:include targets).": "Pipeline-local id in the enclosing GameObject (includes xi:include targets).",
"Suggest content value": "Suggest content value",
"project": "project",
"SDK": "SDK",
"Content is not allowed before the root element": "Content is not allowed before the root element",
"Unterminated comment": "Unterminated comment",
"Unterminated CDATA section": "Unterminated CDATA section",
"Unterminated DOCTYPE": "Unterminated DOCTYPE",
"Unterminated processing instruction": "Unterminated processing instruction",
"Unterminated closing tag": "Unterminated closing tag",
"Unterminated start tag": "Unterminated start tag",
"Malformed markup": "Malformed markup",
"Unexpected closing tag </{0}>": "Unexpected closing tag </{0}>",
"Mismatched closing tag: expected </{0}>, found </{1}>": "Mismatched closing tag: expected </{0}>, found </{1}>",
"Element <{0}> is never closed": "Element <{0}> is never closed",
"Top-level asset <{0}> requires an id attribute": "Top-level asset <{0}> requires an id attribute",
"Unknown element <{0}> (not in the RA3 XSD model)": "Unknown element <{0}> (not in the RA3 XSD model)",
" (based on a partial index)": " (based on a partial index)",
" (index incomplete — may be a false positive)": " (index incomplete — may be a false positive)",
"Include target not found: {0}": "Include target not found: {0}",
"xi:include target not found: {0}": "xi:include target not found: {0}",
"XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.": "XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.",
"**Top-level asset element**": "**Top-level asset element**",
"Attributes: {0} · Children: {1}": "Attributes: {0} · Children: {1}",
"Extends: `{0}`": "Extends: `{0}`",
"Simple type: `{0}`": "Simple type: `{0}`",
"Not found in the bundled XSD model.": "Not found in the bundled XSD model.",
"Namespace/instance attribute.": "Namespace/instance attribute.",
"XInclude attribute (W3C XInclude namespace) — not part of the RA3 XSD model.": "XInclude attribute (W3C XInclude namespace) — not part of the RA3 XSD model.",
"Unknown attribute for this element.": "Unknown attribute for this element.",
"References assets of type `{0}`": "References assets of type `{0}`",
"Values: `{0}`": "Values: `{0}`",
"May use `$DEFINE` constants": "May use `$DEFINE` constants",
"**Include source**": "**Include source**",
"Include source: `{0}` (not in candidate index)": "Include source: `{0}` (not in candidate index)",
"Index is still building — references cannot be resolved yet.": "Index is still building — references cannot be resolved yet.",
"**Define** `{0}`": "**Define** `{0}`",
"Defined in `{0}:{1}`": "Defined in `{0}:{1}`",
"**1 definition**": "**1 definition**",
"**{0} definitions**": "**{0} definitions**",
"manifest `{0}`": "manifest `{0}`",
"`{0}:{1}`": "`{0}:{1}`",
"- `{0}` · {1}": "- `{0}` · {1}",
"No matching definition of type `{0}` in the current index (may exist in a compiled manifest or vanilla data).": "No matching definition of type `{0}` in the current index (may exist in a compiled manifest or vanilla data).",
"No matching definition of the expected declared type in the current index (may exist in a compiled manifest or vanilla data).": "No matching definition of the expected declared type in the current index (may exist in a compiled manifest or vanilla data).",
"No matching definition in the current index (may exist in a compiled manifest or vanilla data).": "No matching definition in the current index (may exist in a compiled manifest or vanilla data).",
"**Local pipeline id** `{0}`": "**Local pipeline id** `{0}`",
"RA3 Mod XML: no index available yet.": "RA3 Mod XML: no index available yet.",
"RA3 Mod XML: no unreferenced assets found.": "RA3 Mod XML: no unreferenced assets found.",
"{0} unreferenced": "{0} unreferenced",
"Select an asset type": "Select an asset type",
"RA3 Mod XML: no unreferenced {0} assets found.": "RA3 Mod XML: no unreferenced {0} assets found.",
"{0}: {1} unreferenced": "{0}: {1} unreferenced",
"RA3 Mod XML: open an RA3 mod project first to configure the SDK path.": "RA3 Mod XML: open an RA3 mod project first to configure the SDK path.",
"Use detected path": "Use detected path",
"Choose manually…": "Choose manually…",
"Not now": "Not now",
"RA3 Mod XML could not find a valid SDK path. Detected installed SDK: {0}": "RA3 Mod XML could not find a valid SDK path. Detected installed SDK: {0}",
"Choose SDK folder…": "Choose SDK folder…",
"RA3 Mod XML needs the RA3 Mod SDK path to enable vanilla data, manifests and cross-file completion/navigation. Without it, the extension runs in project-only mode.": "RA3 Mod XML needs the RA3 Mod SDK path to enable vanilla data, manifests and cross-file completion/navigation. Without it, the extension runs in project-only mode.",
"Choose SDK root": "Choose SDK root",
"Choose the RA3 Mod SDK root (should contain Schemas/xsd/CnC3Types.xsd)": "Choose the RA3 Mod SDK root (should contain Schemas/xsd/CnC3Types.xsd)",
"The selected directory is not a usable RA3 Mod SDK (missing {0}). Please choose again.": "The selected directory is not a usable RA3 Mod SDK (missing {0}). Please choose again.",
"that directory": "that directory",
"$(warning) RA3 XML: SDK path does not exist": "$(warning) RA3 XML: SDK path does not exist",
"$(warning) RA3 XML: SDK not configured": "$(warning) RA3 XML: SDK not configured",
"$(warning) RA3 XML: SDK path is invalid": "$(warning) RA3 XML: SDK path is invalid",
"$(warning) RA3 XML: SDK is incomplete": "$(warning) RA3 XML: SDK is incomplete",
"The directory configured in ra3modxml.sdkPath does not exist: {0}. Click to reconfigure, or clear ra3modxml.sdkPath to disable vanilla data features.": "The directory configured in ra3modxml.sdkPath does not exist: {0}. Click to reconfigure, or clear ra3modxml.sdkPath to disable vanilla data features.",
"RA3 Mod SDK path is not configured. Click to set it, or clear ra3modxml.sdkPath to disable vanilla data features.": "RA3 Mod SDK path is not configured. Click to set it, or clear ra3modxml.sdkPath to disable vanilla data features.",
"The directory configured in ra3modxml.sdkPath is not an RA3 Mod SDK root (missing Schemas/xsd/CnC3Types.xsd). Click to reconfigure.": "The directory configured in ra3modxml.sdkPath is not an RA3 Mod SDK root (missing Schemas/xsd/CnC3Types.xsd). Click to reconfigure.",
"RA3 Mod SDK is missing: {0}. Manifest / vanilla source / SDK search path features are unavailable.": "RA3 Mod SDK is missing: {0}. Manifest / vanilla source / SDK search path features are unavailable.",
"RA3 Mod XML: SDK path set to {0}; rebuilding the index…": "RA3 Mod XML: SDK path set to {0}; rebuilding the index…",
"$(sync~spin) RA3 XML: indexing…": "$(sync~spin) RA3 XML: indexing…",
"art cache": "art cache",
"$(error) RA3 XML: indexing failed (stale index kept)": "$(error) RA3 XML: indexing failed (stale index kept)",
"$(error) RA3 XML: indexing failed": "$(error) RA3 XML: indexing failed",
"$(sync~spin) RA3 XML: validating {0} {1}/{2}…": "$(sync~spin) RA3 XML: validating {0} {1}/{2}…",
"RA3 Mod XML cache report": "RA3 Mod XML cache report",
"Projects: {0}": "Projects: {0}",
" builds: #{0} (last trigger: {1})": " builds: #{0} (last trigger: {1})",
" disk cache: {0}": " disk cache: {0}",
"not loaded": "not loaded",
" file: {0}": " file: {0}",
"{0} KB": "{0} KB",
"missing": "missing",
" last load: file={0} keyMatched={1} loaded={2} validated={3} dropped={4} (load {5}ms, validate {6}ms)": " last load: file={0} keyMatched={1} loaded={2} validated={3} dropped={4} (load {5}ms, validate {6}ms)",
" saved after last build: {0}": " saved after last build: {0}",
"yes": "yes",
"no": "no",
" last build: phase={0} assets={1} snapshotHits={2} snapshotFallbacks={3} recordsCacheHits={4} shallowCacheHits={5}": " last build: phase={0} assets={1} snapshotHits={2} snapshotFallbacks={3} recordsCacheHits={4} shallowCacheHits={5}",
"Shared in-memory: {0} record entries · {1} documents ({2} elements) · {3} include resolutions": "Shared in-memory: {0} record entries · {1} documents ({2} elements) · {3} include resolutions",
"$(symbol-misc) RA3 XML: {0} project(s) — open a mod XML to index": "$(symbol-misc) RA3 XML: {0} project(s) — open a mod XML to index",
"$(symbol-misc) RA3 XML: {0} · {1} assets{2}": "$(symbol-misc) RA3 XML: {0} · {1} assets{2}",
"{0} files indexed ({1} parsed, {2} art assets shallow-scanned, {3}s)": "{0} files indexed ({1} parsed, {2} art assets shallow-scanned, {3}s)",
"{0} assets ({1} from {2} manifests)": "{0} assets ({1} from {2} manifests)",
"{0} reference sites": "{0} reference sites",
"{0} defines, {1} streams, {2} include candidates": "{0} defines, {1} streams, {2} include candidates",
"Disk cache: load {0}s, validate {1}s ({2}/{3} ok)": "Disk cache: load {0}s, validate {1}s ({2}/{3} ok)",
"Duplicate id \"{0}\" for <{1}> (also defined on line {2})": "Duplicate id \"{0}\" for <{1}> (also defined on line {2})",
"Duplicate id \"{0}\" for <{1}> (also defined in {2})": "Duplicate id \"{0}\" for <{1}> (also defined in {2})",
"Unknown attribute \"{0}\" for <{1}>": "Unknown attribute \"{0}\" for <{1}>",
"Undefined define \"${0}\"": "Undefined define \"${0}\"",
"Invalid Include type \"{0}\" (expected reference, instance or all)": "Invalid Include type \"{0}\" (expected reference, instance or all)",
"Reference \"{0}\" has no definition of type `{1}` (ids with the same name exist for other types)": "Reference \"{0}\" has no definition of type `{1}` (ids with the same name exist for other types)",
"Reference \"{0}\" has no definition of the expected declared type (ids with the same name exist for other types)": "Reference \"{0}\" has no definition of the expected declared type (ids with the same name exist for other types)",
"Reference \"{0}\" has no matching definition (ids with the same name exist for other types)": "Reference \"{0}\" has no matching definition (ids with the same name exist for other types)",
"Unresolved reference \"{0}\" (not found in the current index)": "Unresolved reference \"{0}\" (not found in the current index)",
"RA3 Mod XML: no index available yet. Wait for indexing to finish before exporting an AI Agent snapshot.": "RA3 Mod XML: no index available yet. Wait for indexing to finish before exporting an AI Agent snapshot.",
"RA3 Mod XML: exported AI Agent index snapshot to {0}": "RA3 Mod XML: exported AI Agent index snapshot to {0}",
"Reveal in Explorer": "Reveal in Explorer",
"RA3 Mod XML: failed to export AI Agent index snapshot: {0}": "RA3 Mod XML: failed to export AI Agent index snapshot: {0}",
"RA3 Mod XML: no index available yet. Wait for indexing to finish before enabling AI Agent access.": "RA3 Mod XML: no index available yet. Wait for indexing to finish before enabling AI Agent access.",
"RA3 Mod XML: the AI Agent launcher will use Node from PATH. Install Node, or run VS Code from a normal installation so the bundled runtime can be used.": "RA3 Mod XML: the AI Agent launcher will use Node from PATH. Install Node, or run VS Code from a normal installation so the bundled runtime can be used.",
"Install Agent Skill (recommended)": "Install Agent Skill (recommended)",
"Write MCP config to Claude Desktop": "Write MCP config to Claude Desktop",
"Write MCP config to Cursor (global)": "Write MCP config to Cursor (global)",
"Write MCP config to Cursor (project)": "Write MCP config to Cursor (project)",
"Copy MCP config": "Copy MCP config",
"RA3 Mod XML AI Agent access enabled. Choose an optional next step.": "RA3 Mod XML AI Agent access enabled. Choose an optional next step.",
"RA3 Mod XML Agent Skill installed to {0}": "RA3 Mod XML Agent Skill installed to {0}",
"RA3 Mod XML: could not write the Agent Skill to {0}": "RA3 Mod XML: could not write the Agent Skill to {0}",
"RA3 Mod XML MCP config written to {0}": "RA3 Mod XML MCP config written to {0}",
"RA3 Mod XML MCP config copied to clipboard.": "RA3 Mod XML MCP config copied to clipboard.",
"RA3 Mod XML: failed to enable AI Agent access: {0}": "RA3 Mod XML: failed to enable AI Agent access: {0}",
"Default (~/.agents/skills)": "Default (~/.agents/skills)",
"Claude Code (~/.claude/skills)": "Claude Code (~/.claude/skills)",
"Current project .agents/skills": "Current project .agents/skills",
"Current project .claude/skills": "Current project .claude/skills",
"Choose a custom folder…": "Choose a custom folder…",
"The skill is installed as <folder>/{0}": "The skill is installed as <folder>/{0}",
"Select Agent Skill install locations": "Select Agent Skill install locations",
"Choose a skill folder": "Choose a skill folder",
"Choose the folder that should contain the {0} skill": "Choose the folder that should contain the {0} skill",
"RA3 Mod XML: could not write the Agent Skill (0 of {0} locations).": "RA3 Mod XML: could not write the Agent Skill (0 of {0} locations).",
"RA3 Mod XML Agent Skill installed to {0} location(s); {1} failed.": "RA3 Mod XML Agent Skill installed to {0} location(s); {1} failed.",
"RA3 Mod XML Agent Skill installed to {0} location(s).": "RA3 Mod XML Agent Skill installed to {0} location(s).",
"Show installed skills": "Show installed skills",
"RA3 Mod XML: no recorded Agent Skill installation to remove.": "RA3 Mod XML: no recorded Agent Skill installation to remove.",
"missing — will be dropped from the record": "missing — will be dropped from the record",
"installed by this extension (v{0})": "installed by this extension (v{0})",
"not managed by this extension — will be skipped": "not managed by this extension — will be skipped",
"No RA3 Mod XML skill marker found; remove it manually if you want it gone.": "No RA3 Mod XML skill marker found; remove it manually if you want it gone.",
"Select Agent Skill installations to remove": "Select Agent Skill installations to remove",
"RA3 Mod XML: removed {0} Agent Skill installation(s); {1} skipped (not managed by this extension).": "RA3 Mod XML: removed {0} Agent Skill installation(s); {1} skipped (not managed by this extension).",
"RA3 Mod XML: removed {0} Agent Skill installation(s).": "RA3 Mod XML: removed {0} Agent Skill installation(s).",
"RA3 Mod XML: AI Agent access disabled for this workspace. Installed skills and MCP client configurations were kept.": "RA3 Mod XML: AI Agent access disabled for this workspace. Installed skills and MCP client configurations were kept.",
"Uninstall AI Agent integration…": "Uninstall AI Agent integration…",
"Stop live AI Agent access in this window": "Stop live AI Agent access in this window",
"Remove installed Agent Skills": "Remove installed Agent Skills",
"{0} location(s)": "{0} location(s)",
"Remove MCP client configuration entries": "Remove MCP client configuration entries",
"Claude Desktop / Cursor and files recorded by this extension": "Claude Desktop / Cursor and files recorded by this extension",
"Remove the stable MCP launcher": "Remove the stable MCP launcher",
"Select what to remove (nothing is removed until you confirm)": "Select what to remove (nothing is removed until you confirm)",
"Remove the selected AI Agent components?": "Remove the selected AI Agent components?",
"Remove": "Remove",
"live access stopped": "live access stopped",
"{0} skill installation(s) removed": "{0} skill installation(s) removed",
"{0} unmanaged skill folder(s) skipped": "{0} unmanaged skill folder(s) skipped",
"MCP config removed from {0} file(s)": "MCP config removed from {0} file(s)",
"MCP launcher removed": "MCP launcher removed",
"RA3 Mod XML AI Agent cleanup: {0}.": "RA3 Mod XML AI Agent cleanup: {0}.",
"RA3 Mod XML: this version can expose the project's asset index to AI agents (MCP + Agent Skill). Enable it?": "RA3 Mod XML: this version can expose the project's asset index to AI agents (MCP + Agent Skill). Enable it?",
"Enable AI Agent access": "Enable AI Agent access",
"Learn more": "Learn more",
"Don't show again": "Don't show again",
"RA3 Mod XML: Find asset": "RA3 Mod XML: Find asset",
"Id, partial id or Type:Id (e.g. WeaponTemplate:AssaultRifle)": "Id, partial id or Type:Id (e.g. WeaponTemplate:AssaultRifle)",
"Type an id, a partial id or Type:Id to search.": "Type an id, a partial id or Type:Id to search.",
"No asset matches \"{0}\".": "No asset matches \"{0}\".",
"{0} of {1} matches — keep typing to narrow the list.": "{0} of {1} matches — keep typing to narrow the list.",
"1 asset found.": "1 asset found.",
"{0} assets found.": "{0} assets found.",
"{0} definitions": "{0} definitions",
"Select the definition to open": "Select the definition to open",
"RA3 Mod XML: no XML source location for {0} ({1}).": "RA3 Mod XML: no XML source location for {0} ({1}).",
"RA3 Mod XML: the index has no assets yet — wait for indexing to finish.": "RA3 Mod XML: the index has no assets yet — wait for indexing to finish.",
"local": "local"
}
+228
View File
@@ -0,0 +1,228 @@
{
"RA3 Mod XML: caches cleared; rebuilding from scratch…": "RA3 Mod XML:缓存已清空,正在从零重建…",
"RA3 Mod XML: index is still building — check the status bar. Most features become available after the XML phase.": "RA3 Mod XML:索引仍在构建中——请查看状态栏。大部分功能会在 XML 阶段完成后可用。",
"RA3 Mod XML: no index for the active project yet — open a mod XML document to start indexing.": "RA3 Mod XML:活动项目还没有索引——打开一个 Mod XML 文档即可开始建索引。",
"RA3 Mod XML: no index available. Open a workspace that contains Data/Mod.xml, Data/additionalmaps/mapmetadata_*.xml or a mod folder.": "RA3 Mod XML:当前没有可用索引。请打开包含 Data/Mod.xml、Data/additionalmaps/mapmetadata_*.xml 或 Mod 文件夹的工作区。",
"(stale)": "(已过期)",
"RA3 Mod XML index": "RA3 Mod XML 索引",
"Project: {0}": "项目:{0}",
"Files: {0} ({1} parsed, {2} shallow-scanned, {3} cache hits)": "文件:{0}(解析 {1},浅扫描 {2},缓存命中 {3})",
"Assets: {0} ({1} from {2} manifests)": "资产:{0}(其中 {1} 来自 {2} 个 manifest)",
"References: {0}": "引用:{0}",
"Defines: {0} · Streams: {1} · Candidates: {2}": "常量:{0} · 流:{1} · 候选:{2}",
"Phase: {0} · Complete: {1}{2}": "阶段:{0} · 完成:{1}{2}",
"Build #{0} (trigger: {1})": "构建 #{0}(触发原因:{1})",
"Indexed in {0}s": "索引耗时 {0} 秒",
"XML walk: {0}s · Candidates: {1}s · Art scan: {2}s": "XML 遍历:{0} 秒 · 候选扫描:{1} 秒 · 美术扫描:{2} 秒",
"0 references": "0 个引用",
"1 reference": "1 个引用",
"{0} references": "{0} 个引用",
"Type: {0}": "类型:{0}",
"RA3 XML · {0}": "RA3 XML · {0}",
"RA3 XML": "RA3 XML",
"Root element of every RA3 asset file": "每个 RA3 资产文件的根元素",
"**Required**": "**必填**",
"References: `{0}`": "引用:`{0}`",
"Values: {0}": "取值:{0}",
"Default: `{0}`": "默认值:`{0}`",
"Type: `{0}`": "类型:`{0}`",
"Suggest attribute values": "建议属性值",
"Instance join action": "实例合并动作",
"Controls how this element merges with the inherited definition: `Replace` or `Remove`.": "控制该元素如何与继承的定义合并:`Replace` 或 `Remove`。",
"xai namespace": "xai 命名空间",
"Include type": "Include 类型",
"xai:joinAction": "xai:joinAction",
"enum": "枚举",
"boolean": "布尔值",
"Include source": "Include 源",
"`{0}` · {1}": "`{0}` · {1}",
"relative": "相对路径",
"manifest ({0})": "manifest({0})",
"manifest": "manifest",
"**Type**: {0}": "**类型**:{0}",
"**Source**: {0}": "**来源**:{0}",
"**Origin**: {0}": "**归属**:{0}",
"Also defined as **{0}** · {1}": "也定义为 **{0}** · {1}",
"{0} · {1}": "{0} · {1}",
"Define": "定义",
"local module": "局部模块",
"Pipeline-local id in the enclosing GameObject (includes xi:include targets).": "所在 GameObject 中的管线局部 id(包含 xi:include 目标)。",
"Suggest content value": "建议内容值",
"project": "项目",
"SDK": "SDK",
"Content is not allowed before the root element": "根元素之前不允许出现内容",
"Unterminated comment": "注释未结束",
"Unterminated CDATA section": "CDATA 段未结束",
"Unterminated DOCTYPE": "DOCTYPE 未结束",
"Unterminated processing instruction": "处理指令未结束",
"Unterminated closing tag": "结束标签未闭合",
"Unterminated start tag": "开始标签未闭合",
"Malformed markup": "标记格式错误",
"Unexpected closing tag </{0}>": "意外的结束标签 </{0}>",
"Mismatched closing tag: expected </{0}>, found </{1}>": "结束标签不匹配:应为 </{0}>,实际为 </{1}>",
"Element <{0}> is never closed": "元素 <{0}> 从未闭合",
"Top-level asset <{0}> requires an id attribute": "顶层资产 <{0}> 需要 id 属性",
"Unknown element <{0}> (not in the RA3 XSD model)": "未知元素 <{0}>(不在 RA3 XSD 模型中)",
" (based on a partial index)": "(基于部分索引)",
" (index incomplete — may be a false positive)": "(索引不完整——可能是误报)",
"Include target not found: {0}": "找不到 Include 目标:{0}",
"xi:include target not found: {0}": "找不到 xi:include 目标:{0}",
"XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.": "XInclude 元素(W3C XInclude 命名空间)——不属于 RA3 XSD 模型。",
"**Top-level asset element**": "**顶层资产元素**",
"Attributes: {0} · Children: {1}": "属性:{0} · 子元素:{1}",
"Extends: `{0}`": "继承自:`{0}`",
"Simple type: `{0}`": "简单类型:`{0}`",
"Not found in the bundled XSD model.": "内置 XSD 模型中未找到。",
"Namespace/instance attribute.": "命名空间/实例属性。",
"XInclude attribute (W3C XInclude namespace) — not part of the RA3 XSD model.": "XInclude 属性(W3C XInclude 命名空间)——不属于 RA3 XSD 模型。",
"Unknown attribute for this element.": "该元素的未知属性。",
"References assets of type `{0}`": "引用类型为 `{0}` 的资产",
"Values: `{0}`": "取值:`{0}`",
"May use `$DEFINE` constants": "可使用 `$DEFINE` 常量",
"**Include source**": "**Include 源**",
"Include source: `{0}` (not in candidate index)": "Include 源:`{0}`(不在候选索引中)",
"Index is still building — references cannot be resolved yet.": "索引仍在构建中——暂时无法解析引用。",
"**Define** `{0}`": "**常量** `{0}`",
"Defined in `{0}:{1}`": "定义于 `{0}:{1}`",
"**1 definition**": "**1 个定义**",
"**{0} definitions**": "**{0} 个定义**",
"manifest `{0}`": "manifest `{0}`",
"`{0}:{1}`": "`{0}:{1}`",
"- `{0}` · {1}": "- `{0}` · {1}",
"No matching definition of type `{0}` in the current index (may exist in a compiled manifest or vanilla data).": "当前索引中没有类型为 `{0}` 的匹配定义(可能存在于编译后的 manifest 或原版数据中)。",
"No matching definition of the expected declared type in the current index (may exist in a compiled manifest or vanilla data).": "当前索引中没有符合声明类型的匹配定义(可能存在于编译后的 manifest 或原版数据中)。",
"No matching definition in the current index (may exist in a compiled manifest or vanilla data).": "当前索引中没有匹配的定义(可能存在于编译后的 manifest 或原版数据中)。",
"**Local pipeline id** `{0}`": "**局部管线 id** `{0}`",
"RA3 Mod XML: no index available yet.": "RA3 Mod XML:尚无可用索引。",
"RA3 Mod XML: no unreferenced assets found.": "RA3 Mod XML:没有找到未引用的资产。",
"{0} unreferenced": "{0} 个未引用",
"Select an asset type": "选择资产类型",
"RA3 Mod XML: no unreferenced {0} assets found.": "RA3 Mod XML:没有找到未引用的 {0} 类型资产。",
"{0}: {1} unreferenced": "{0}:{1} 个未引用",
"RA3 Mod XML: open an RA3 mod project first to configure the SDK path.": "RA3 Mod XML:请先打开 RA3 Mod 项目再配置 SDK 路径。",
"Use detected path": "使用检测到的路径",
"Choose manually…": "手动选择…",
"Not now": "暂时不用",
"RA3 Mod XML could not find a valid SDK path. Detected installed SDK: {0}": "RA3 Mod XML 未找到有效的 SDK 路径。检测到已安装的 SDK:{0}",
"Choose SDK folder…": "选择 SDK 文件夹…",
"RA3 Mod XML needs the RA3 Mod SDK path to enable vanilla data, manifests and cross-file completion/navigation. Without it, the extension runs in project-only mode.": "RA3 Mod XML 需要 RA3 Mod SDK 路径才能启用原版数据、manifest 与跨文件补全/跳转功能。未设置时插件将以项目模式运行。",
"Choose SDK root": "选择 SDK 根目录",
"Choose the RA3 Mod SDK root (should contain Schemas/xsd/CnC3Types.xsd)": "选择 RA3 Mod SDK 根目录(应包含 Schemas/xsd/CnC3Types.xsd)",
"The selected directory is not a usable RA3 Mod SDK (missing {0}). Please choose again.": "所选目录不是可用的 RA3 Mod SDK(缺少 {0})。请重新选择。",
"that directory": "该目录",
"$(warning) RA3 XML: SDK path does not exist": "$(warning) RA3 XML:SDK 路径不存在",
"$(warning) RA3 XML: SDK not configured": "$(warning) RA3 XML:未设置 SDK",
"$(warning) RA3 XML: SDK path is invalid": "$(warning) RA3 XML:SDK 路径无效",
"$(warning) RA3 XML: SDK is incomplete": "$(warning) RA3 XML:SDK 不完整",
"The directory configured in ra3modxml.sdkPath does not exist: {0}. Click to reconfigure, or clear ra3modxml.sdkPath to disable vanilla data features.": "ra3modxml.sdkPath 指向的目录不存在:{0}。点击重新设置;或将 ra3modxml.sdkPath 清空以禁用原版数据功能。",
"RA3 Mod SDK path is not configured. Click to set it, or clear ra3modxml.sdkPath to disable vanilla data features.": "未配置 RA3 Mod SDK 路径。点击设置;或将 ra3modxml.sdkPath 清空以禁用原版数据功能。",
"The directory configured in ra3modxml.sdkPath is not an RA3 Mod SDK root (missing Schemas/xsd/CnC3Types.xsd). Click to reconfigure.": "ra3modxml.sdkPath 指向的目录不是 RA3 Mod SDK 根目录(缺少 Schemas/xsd/CnC3Types.xsd)。点击重新设置。",
"RA3 Mod SDK is missing: {0}. Manifest / vanilla source / SDK search path features are unavailable.": "RA3 Mod SDK 缺少:{0}。manifest / 原版源码 / SDK 搜索路径等功能不可用。",
"RA3 Mod XML: SDK path set to {0}; rebuilding the index…": "RA3 Mod XML:SDK 路径已设置为 {0},正在重建索引…",
"$(sync~spin) RA3 XML: indexing…": "$(sync~spin) RA3 XML:正在建索引…",
"art cache": "美术缓存",
"$(error) RA3 XML: indexing failed (stale index kept)": "$(error) RA3 XML:索引失败(保留旧索引)",
"$(error) RA3 XML: indexing failed": "$(error) RA3 XML:索引失败",
"$(sync~spin) RA3 XML: validating {0} {1}/{2}…": "$(sync~spin) RA3 XML:正在校验 {0} {1}/{2}…",
"RA3 Mod XML cache report": "RA3 Mod XML 缓存报告",
"Projects: {0}": "项目数:{0}",
" builds: #{0} (last trigger: {1})": " 构建次数:#{0}(上次触发:{1})",
" disk cache: {0}": " 磁盘缓存:{0}",
"not loaded": "未加载",
" file: {0}": " 文件:{0}",
"{0} KB": "{0} KB",
"missing": "缺失",
" last load: file={0} keyMatched={1} loaded={2} validated={3} dropped={4} (load {5}ms, validate {6}ms)": " 最近加载:file={0} keyMatched={1} loaded={2} validated={3} dropped={4}(加载 {5}ms,校验 {6}ms)",
" saved after last build: {0}": " 上次构建后已保存:{0}",
"yes": "是",
"no": "否",
" last build: phase={0} assets={1} snapshotHits={2} snapshotFallbacks={3} recordsCacheHits={4} shallowCacheHits={5}": " 最近构建:阶段={0} 资产={1} snapshotHits={2} snapshotFallbacks={3} recordsCacheHits={4} shallowCacheHits={5}",
"Shared in-memory: {0} record entries · {1} documents ({2} elements) · {3} include resolutions": "共享内存:{0} 条记录 · {1} 个文档({2} 个元素)· {3} 条 include 解析",
"$(symbol-misc) RA3 XML: {0} project(s) — open a mod XML to index": "$(symbol-misc) RA3 XML:{0} 个项目——打开一个 Mod XML 开始建索引",
"$(symbol-misc) RA3 XML: {0} · {1} assets{2}": "$(symbol-misc) RA3 XML:{0} · {1} 个资产{2}",
"{0} files indexed ({1} parsed, {2} art assets shallow-scanned, {3}s)": "{0} 个文件已索引(解析 {1},浅扫描美术资产 {2},耗时 {3} 秒)",
"{0} assets ({1} from {2} manifests)": "{0} 个资产(其中 {1} 来自 {2} 个 manifest)",
"{0} reference sites": "{0} 个引用位置",
"{0} defines, {1} streams, {2} include candidates": "{0} 个常量,{1} 条流,{2} 个 include 候选",
"Disk cache: load {0}s, validate {1}s ({2}/{3} ok)": "磁盘缓存:加载 {0} 秒,校验 {1} 秒({2}/{3} 正常)",
"Duplicate id \"{0}\" for <{1}> (also defined on line {2})": "重复 id \"{0}\":<{1}>(也定义在第 {2} 行)",
"Duplicate id \"{0}\" for <{1}> (also defined in {2})": "重复 id \"{0}\":<{1}>(也定义在 {2})",
"Unknown attribute \"{0}\" for <{1}>": "未知属性 \"{0}\":<{1}>",
"Undefined define \"${0}\"": "未定义常量 \"${0}\"",
"Invalid Include type \"{0}\" (expected reference, instance or all)": "无效的 Include 类型 \"{0}\"(应为 reference、instance 或 all)",
"Reference \"{0}\" has no definition of type `{1}` (ids with the same name exist for other types)": "引用 \"{0}\" 没有类型为 `{1}` 的定义(其他类型存在同名 id)",
"Reference \"{0}\" has no definition of the expected declared type (ids with the same name exist for other types)": "引用 \"{0}\" 没有符合声明类型的定义(其他类型存在同名 id)",
"Reference \"{0}\" has no matching definition (ids with the same name exist for other types)": "引用 \"{0}\" 没有匹配的定义(其他类型存在同名 id)",
"Unresolved reference \"{0}\" (not found in the current index)": "无法解析的引用 \"{0}\"(当前索引中未找到)",
"RA3 Mod XML: no index available yet. Wait for indexing to finish before exporting an AI Agent snapshot.": "RA3 Mod XML:暂无可用索引。请等待索引完成后再导出 AI Agent 快照。",
"RA3 Mod XML: exported AI Agent index snapshot to {0}": "RA3 Mod XML:已导出 AI Agent 索引快照到 {0}",
"Reveal in Explorer": "在资源管理器中显示",
"RA3 Mod XML: failed to export AI Agent index snapshot: {0}": "RA3 Mod XML:导出 AI Agent 索引快照失败:{0}",
"RA3 Mod XML: no index available yet. Wait for indexing to finish before enabling AI Agent access.": "RA3 Mod XML:暂无可用索引。请等待索引完成后再启用 AI Agent 访问。",
"RA3 Mod XML: the AI Agent launcher will use Node from PATH. Install Node, or run VS Code from a normal installation so the bundled runtime can be used.": "RA3 Mod XML:AI Agent 启动器将使用 PATH 中的 Node。请安装 Node,或从正常安装的 VS Code 启动,以使用内置运行时。",
"Install Agent Skill (recommended)": "安装 Agent Skill(推荐)",
"Write MCP config to Claude Desktop": "将 MCP 配置写入 Claude Desktop",
"Write MCP config to Cursor (global)": "将 MCP 配置写入 Cursor(全局)",
"Write MCP config to Cursor (project)": "将 MCP 配置写入 Cursor(项目)",
"Copy MCP config": "复制 MCP 配置",
"RA3 Mod XML AI Agent access enabled. Choose an optional next step.": "RA3 Mod XML AI Agent 访问已启用。请选择可选的后续操作。",
"RA3 Mod XML Agent Skill installed to {0}": "RA3 Mod XML Agent Skill 已安装到 {0}",
"RA3 Mod XML: could not write the Agent Skill to {0}": "RA3 Mod XML:无法将 Agent Skill 写入 {0}",
"RA3 Mod XML MCP config written to {0}": "RA3 Mod XML MCP 配置已写入 {0}",
"RA3 Mod XML MCP config copied to clipboard.": "RA3 Mod XML MCP 配置已复制到剪贴板。",
"RA3 Mod XML: failed to enable AI Agent access: {0}": "RA3 Mod XML:启用 AI Agent 访问失败:{0}",
"Default (~/.agents/skills)": "默认(~/.agents/skills)",
"Claude Code (~/.claude/skills)": "Claude Code(~/.claude/skills)",
"Current project .agents/skills": "当前项目 .agents/skills",
"Current project .claude/skills": "当前项目 .claude/skills",
"Choose a custom folder…": "选择自定义文件夹…",
"The skill is installed as <folder>/{0}": "Skill 将安装到 <文件夹>/{0}",
"Select Agent Skill install locations": "选择 Agent Skill 安装位置",
"Choose a skill folder": "选择 Skill 文件夹",
"Choose the folder that should contain the {0} skill": "选择用于存放 {0} Skill 的文件夹",
"RA3 Mod XML: could not write the Agent Skill (0 of {0} locations).": "RA3 Mod XML:无法写入 Agent Skill({0} 个位置全部失败)。",
"RA3 Mod XML Agent Skill installed to {0} location(s); {1} failed.": "RA3 Mod XML Agent Skill 已安装到 {0} 个位置;{1} 个失败。",
"RA3 Mod XML Agent Skill installed to {0} location(s).": "RA3 Mod XML Agent Skill 已安装到 {0} 个位置。",
"Show installed skills": "查看已安装的 Skill",
"RA3 Mod XML: no recorded Agent Skill installation to remove.": "RA3 Mod XML:没有可移除的 Agent Skill 安装记录。",
"missing — will be dropped from the record": "已缺失——将从记录中删除",
"installed by this extension (v{0})": "由本扩展安装(v{0})",
"not managed by this extension — will be skipped": "不由本扩展管理——将跳过",
"No RA3 Mod XML skill marker found; remove it manually if you want it gone.": "未找到 RA3 Mod XML Skill 标记;如需删除请手动处理。",
"Select Agent Skill installations to remove": "选择要移除的 Agent Skill 安装",
"RA3 Mod XML: removed {0} Agent Skill installation(s); {1} skipped (not managed by this extension).": "RA3 Mod XML:已移除 {0} 个 Agent Skill 安装;跳过 {1} 个(不由本扩展管理)。",
"RA3 Mod XML: removed {0} Agent Skill installation(s).": "RA3 Mod XML:已移除 {0} 个 Agent Skill 安装。",
"RA3 Mod XML: AI Agent access disabled for this workspace. Installed skills and MCP client configurations were kept.": "RA3 Mod XML:已禁用此工作区的 AI Agent 访问。已安装的 Skill 和 MCP 客户端配置已保留。",
"Uninstall AI Agent integration…": "卸载 AI Agent 集成…",
"Stop live AI Agent access in this window": "在此窗口中停止实时 AI Agent 访问",
"Remove installed Agent Skills": "移除已安装的 Agent Skill",
"{0} location(s)": "{0} 个位置",
"Remove MCP client configuration entries": "移除 MCP 客户端配置项",
"Claude Desktop / Cursor and files recorded by this extension": "Claude Desktop / Cursor 以及本扩展记录过的配置文件",
"Remove the stable MCP launcher": "移除稳定的 MCP 启动器",
"Select what to remove (nothing is removed until you confirm)": "选择要移除的内容(确认前不会删除任何内容)",
"Remove the selected AI Agent components?": "要移除选中的 AI Agent 组件吗?",
"Remove": "移除",
"live access stopped": "已停止实时访问",
"{0} skill installation(s) removed": "已移除 {0} 个 Skill 安装",
"{0} unmanaged skill folder(s) skipped": "已跳过 {0} 个非本扩展管理的 Skill 目录",
"MCP config removed from {0} file(s)": "已从 {0} 个配置文件中移除 MCP 配置",
"MCP launcher removed": "已移除 MCP 启动器",
"RA3 Mod XML AI Agent cleanup: {0}.": "RA3 Mod XML AI Agent 清理完成:{0}。",
"RA3 Mod XML: this version can expose the project's asset index to AI agents (MCP + Agent Skill). Enable it?": "RA3 Mod XML:此版本可以把项目资产索引提供给 AI Agent(MCP + Agent Skill)。要启用吗?",
"Enable AI Agent access": "启用 AI Agent 访问",
"Learn more": "了解更多",
"Don't show again": "不再提示",
"RA3 Mod XML: Find asset": "RA3 Mod XML:查找资产",
"Id, partial id or Type:Id (e.g. WeaponTemplate:AssaultRifle)": "ID、部分 ID 或 类型:ID(例如 WeaponTemplate:AssaultRifle)",
"Type an id, a partial id or Type:Id to search.": "输入 ID、部分 ID 或 类型:ID 进行搜索。",
"No asset matches \"{0}\".": "没有匹配 \"{0}\" 的资产。",
"{0} of {1} matches — keep typing to narrow the list.": "{0}/{1} 个匹配——继续输入可缩小范围。",
"1 asset found.": "找到 1 个资产。",
"{0} assets found.": "找到 {0} 个资产。",
"{0} definitions": "{0} 处定义",
"Select the definition to open": "选择要打开的定义",
"RA3 Mod XML: no XML source location for {0} ({1}).": "RA3 Mod XML:{0}({1})没有可打开的 XML 源位置。",
"RA3 Mod XML: the index has no assets yet — wait for indexing to finish.": "RA3 Mod XML:索引中还没有资产——请等待索引完成。",
"local": "本地"
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ra3-mod-xml",
"version": "0.1.18",
"version": "0.1.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ra3-mod-xml",
"version": "0.1.18",
"version": "0.1.25",
"license": "MIT",
"dependencies": {
"fast-xml-parser": "^4.5.0"
+90 -31
View File
@@ -1,10 +1,27 @@
{
"name": "ra3-mod-xml",
"displayName": "RA3 Mod XML",
"description": "Red Alert 3 Mod XML tooling: syntax highlighting, completions, reference navigation and diagnostics for SAGE/BinaryAssetBuilder XML.",
"version": "0.1.18",
"publisher": "ra3-mod-xml",
"license": "MIT",
"displayName": "%ra3modxml.displayName%",
"description": "%ra3modxml.description%",
"version": "0.1.26",
"publisher": "lanyi",
"license": "SEE LICENSE IN LICENSE",
"icon": "images/icon.png",
"repository": {
"type": "git",
"url": "https://github.com/RA3CoronaDevelopers/Ra3ModXmlExt.git"
},
"homepage": "https://github.com/RA3CoronaDevelopers/Ra3ModXmlExt",
"capabilities": {
"untrustedWorkspaces": {
"supported": "limited",
"description": "The workspace-defined SDK path and additional DATA: search paths are ignored until the workspace is trusted.",
"restrictedConfigurations": [
"ra3modxml.sdkPath",
"ra3modxml.additionalDataSearchPaths"
]
}
},
"l10n": "./l10n",
"engines": {
"vscode": "^1.85.0"
},
@@ -23,9 +40,13 @@
"main": "./dist/extension.js",
"activationEvents": [
"onLanguage:xml",
"workspaceContains:Mod.xml",
"workspaceContains:additionalmaps/mapmetadata_*.xml",
"workspaceContains:**/Data/Mod.xml",
"workspaceContains:**/Data/additionalmaps/mapmetadata_*.xml",
"workspaceContains:**/mod.babproj",
"workspaceContains:**/*.babproj"
"workspaceContains:**/*.babproj",
"onCommand:ra3modxml.configureSdkPath"
],
"contributes": {
"grammars": [
@@ -38,17 +59,17 @@
}
],
"configuration": {
"title": "RA3 Mod XML",
"title": "%ra3modxml.configuration.title%",
"properties": {
"ra3modxml.sdkPath": {
"type": "string",
"default": "C:\\Apps\\RA3-MODSDK-X",
"description": "Path to the RA3 Mod SDK root. Used to resolve DATA:/ART:/AUDIO: includes and to index vanilla SageXml sources."
"default": "",
"description": "%ra3modxml.sdkPath.description%"
},
"ra3modxml.indexSageXml": {
"type": "boolean",
"default": true,
"description": "Index vanilla game XML sources shipped with the SDK (SageXml) so completions/references can resolve vanilla asset ids."
"description": "%ra3modxml.indexSageXml.description%"
},
"ra3modxml.reportUnresolvedReferences": {
"type": "string",
@@ -58,12 +79,12 @@
"none"
],
"default": "warning",
"description": "Severity for attribute references (CommandSet=..., inheritFrom=...) that cannot be resolved in the index. The index covers mod XML sources, SDK SageXml sources and compiled manifests."
"description": "%ra3modxml.reportUnresolvedReferences.description%"
},
"ra3modxml.diagnoseUnknownElements": {
"type": "boolean",
"default": true,
"description": "Report element/attribute names that are not known from the bundled XSD model."
"description": "%ra3modxml.diagnoseUnknownElements.description%"
},
"ra3modxml.definitionMode": {
"type": "string",
@@ -72,7 +93,7 @@
"project-only"
],
"default": "all",
"description": "Go-to-definition candidates: 'all' lists the mod definition together with any vanilla (SDK/manifest) definitions (mod first); 'project-only' jumps directly to the mod definition and skips vanilla candidates when the id is defined in the project."
"description": "%ra3modxml.definitionMode.description%"
},
"ra3modxml.additionalDataSearchPaths": {
"type": "array",
@@ -80,45 +101,82 @@
"type": "string"
},
"default": [],
"description": "Extra absolute directories appended to the DATA: search path (checked after the project folders)."
"description": "%ra3modxml.additionalDataSearchPaths.description%"
}
}
},
"commands": [
{
"command": "ra3modxml.reindex",
"title": "RA3 Mod XML: Re-index workspace"
"title": "%ra3modxml.command.reindex.title%"
},
{
"command": "ra3modxml.openIndexReport",
"title": "RA3 Mod XML: Show index report"
"title": "%ra3modxml.command.openIndexReport.title%"
},
{
"command": "ra3modxml.clearCache",
"title": "RA3 Mod XML: Clear caches and rebuild"
"title": "%ra3modxml.command.clearCache.title%"
},
{
"command": "ra3modxml.configureSdkPath",
"title": "%ra3modxml.command.configureSdkPath.title%"
},
{
"command": "ra3modxml.showCacheReport",
"title": "RA3 Mod XML: Show cache report"
"title": "%ra3modxml.command.showCacheReport.title%"
},
{
"command": "ra3modxml.enableAgentAccess",
"title": "%ra3modxml.command.enableAgentAccess.title%"
},
{
"command": "ra3modxml.disableAgentAccess",
"title": "%ra3modxml.command.disableAgentAccess.title%"
},
{
"command": "ra3modxml.installAgentSkill",
"title": "%ra3modxml.command.installAgentSkill.title%"
},
{
"command": "ra3modxml.uninstallAgentSkill",
"title": "%ra3modxml.command.uninstallAgentSkill.title%"
},
{
"command": "ra3modxml.uninstallAgentIntegration",
"title": "%ra3modxml.command.uninstallAgentIntegration.title%"
},
{
"command": "ra3modxml.exportIndexSnapshot",
"title": "%ra3modxml.command.exportIndexSnapshot.title%"
},
{
"command": "ra3modxml.findAsset",
"title": "%ra3modxml.command.findAsset.title%"
},
{
"command": "ra3modxml.findUnreferencedAssets",
"title": "RA3 Mod XML: Find unreferenced assets…"
"title": "%ra3modxml.command.findUnreferencedAssets.title%"
},
{
"command": "ra3modxml.findUnreferencedAssetsOfType",
"title": "RA3 Mod XML: Find unreferenced assets of this type"
"title": "%ra3modxml.command.findUnreferencedAssetsOfType.title%"
}
]
},
"menus": {
"editor/context": [
{
"command": "ra3modxml.findUnreferencedAssetsOfType",
"when": "editorLangId == xml && ra3modxml.active",
"group": "navigation@50"
}
]
],
"menus": {
"editor/context": [
{
"command": "ra3modxml.findAsset",
"when": "editorLangId == xml && ra3modxml.active",
"group": "navigation@45"
},
{
"command": "ra3modxml.findUnreferencedAssetsOfType",
"when": "editorLangId == xml && ra3modxml.active",
"group": "navigation@50"
}
]
}
},
"scripts": {
"build": "node esbuild.mjs",
@@ -126,7 +184,8 @@
"generate-model": "node tools/xsd-to-model.mjs",
"pretest": "tsc",
"test": "node --test \"test/*.test.mjs\"",
"package": "npm run build && vsce package"
"package": "npm run build && vsce package --no-rewrite-relative-links",
"publish": "npm run build && vsce publish --no-rewrite-relative-links"
},
"dependencies": {
"fast-xml-parser": "^4.5.0"
+25
View File
@@ -0,0 +1,25 @@
{
"ra3modxml.displayName": "RA3 Mod XML",
"ra3modxml.description": "Red Alert 3 Mod XML tooling: syntax highlighting, completions, reference navigation and diagnostics for SAGE/BinaryAssetBuilder XML.",
"ra3modxml.configuration.title": "RA3 Mod XML",
"ra3modxml.sdkPath.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.description": "Index vanilla game XML sources shipped with the SDK (SageXml) so completions/references can resolve vanilla asset ids.",
"ra3modxml.reportUnresolvedReferences.description": "Severity for attribute references (CommandSet=..., inheritFrom=...) that cannot be resolved in the index. The index covers mod XML sources, SDK SageXml sources and compiled manifests.",
"ra3modxml.diagnoseUnknownElements.description": "Report element/attribute names that are not known from the bundled XSD model.",
"ra3modxml.definitionMode.description": "Go-to-definition candidates: 'all' lists the mod definition together with any vanilla (SDK/manifest) definitions (mod first); 'project-only' jumps directly to the mod definition and skips vanilla candidates when the id is defined in the project.",
"ra3modxml.additionalDataSearchPaths.description": "Extra absolute directories appended to the DATA: search path (checked after the project folders).",
"ra3modxml.command.reindex.title": "RA3 Mod XML: Re-index workspace",
"ra3modxml.command.openIndexReport.title": "RA3 Mod XML: Show index report",
"ra3modxml.command.clearCache.title": "RA3 Mod XML: Clear caches and rebuild",
"ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: Configure SDK path…",
"ra3modxml.command.showCacheReport.title": "RA3 Mod XML: Show cache report",
"ra3modxml.command.enableAgentAccess.title": "RA3 Mod XML: Enable AI Agent access…",
"ra3modxml.command.disableAgentAccess.title": "RA3 Mod XML: Disable AI Agent access",
"ra3modxml.command.installAgentSkill.title": "RA3 Mod XML: Install Agent Skill…",
"ra3modxml.command.uninstallAgentSkill.title": "RA3 Mod XML: Uninstall Agent Skill…",
"ra3modxml.command.uninstallAgentIntegration.title": "RA3 Mod XML: Uninstall AI Agent integration…",
"ra3modxml.command.exportIndexSnapshot.title": "RA3 Mod XML: Export AI Agent index snapshot",
"ra3modxml.command.findAsset.title": "RA3 Mod XML: Find asset (id or Type:Id)…",
"ra3modxml.command.findUnreferencedAssets.title": "RA3 Mod XML: Find unreferenced assets…",
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: Find unreferenced assets of this type"
}
+25
View File
@@ -0,0 +1,25 @@
{
"ra3modxml.displayName": "RA3 Mod XML",
"ra3modxml.description": "红警 3 Mod XML 工具:为 SAGE/BinaryAssetBuilder XML 提供语法高亮、自动补全、引用跳转与诊断。",
"ra3modxml.configuration.title": "RA3 Mod XML",
"ra3modxml.sdkPath.description": "RA3 Mod SDK 根目录路径。用于解析 DATA:/ART:/AUDIO: include,并索引原版 SageXml 源码。留空可禁用 SDK 原版功能(仅项目模式)。",
"ra3modxml.indexSageXml.description": "索引 SDK 附带的原版游戏 XML 源码(SageXml),使补全/引用可以解析原版资产 id。",
"ra3modxml.reportUnresolvedReferences.description": "索引中无法解析的属性引用(CommandSet=...、inheritFrom=...)的诊断级别。索引覆盖 mod XML 源码、SDK SageXml 源码和编译后的 manifest。",
"ra3modxml.diagnoseUnknownElements.description": "报告不在内置 XSD 模型中的元素/属性名。",
"ra3modxml.definitionMode.description": "跳转定义候选项:'all' 同时列出 mod 定义与原版(SDK/manifest)定义(mod 优先);'project-only' 在项目内已定义该 id 时直接跳转 mod 定义并跳过原版候选。",
"ra3modxml.additionalDataSearchPaths.description": "追加到 DATA: 搜索路径末尾的额外绝对目录(在项目文件夹之后检查)。",
"ra3modxml.command.reindex.title": "RA3 Mod XML: 重建索引",
"ra3modxml.command.openIndexReport.title": "RA3 Mod XML: 显示索引报告",
"ra3modxml.command.clearCache.title": "RA3 Mod XML: 清空缓存并重建",
"ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: 配置 SDK 路径…",
"ra3modxml.command.showCacheReport.title": "RA3 Mod XML: 显示缓存报告",
"ra3modxml.command.enableAgentAccess.title": "RA3 Mod XML: 启用 AI Agent 访问…",
"ra3modxml.command.disableAgentAccess.title": "RA3 Mod XML: 禁用 AI Agent 访问",
"ra3modxml.command.installAgentSkill.title": "RA3 Mod XML: 安装 Agent Skill…",
"ra3modxml.command.uninstallAgentSkill.title": "RA3 Mod XML: 卸载 Agent Skill…",
"ra3modxml.command.uninstallAgentIntegration.title": "RA3 Mod XML: 卸载 AI Agent 集成…",
"ra3modxml.command.exportIndexSnapshot.title": "RA3 Mod XML: 导出 AI Agent 索引快照",
"ra3modxml.command.findAsset.title": "RA3 Mod XML: 查找资产(ID 或 类型:ID)…",
"ra3modxml.command.findUnreferencedAssets.title": "RA3 Mod XML: 查找未引用的资产…",
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: 查找该类型的未引用资产"
}
+323
View File
@@ -0,0 +1,323 @@
/**
* Thin CLI for RA3 Mod XML index queries.
*
* Deliberately thin: it does **not** reimplement the query surface. It does
* exactly two things.
*
* 1. Forward the call to the live VS Code extension through the shared
* `LiveClient` (same transport, project pinning and validation as the MCP
* server, so the two entry points cannot drift apart).
* 2. Fall back to the on-disk snapshot exported by the extension when no live
* instance is reachable.
*
* Features that need the DOM (element-level provenance) are live-only; the
* CLI reports that clearly instead of returning an empty result that would
* look like "no references exist".
*
* The CLI is not installed into PATH. It is reached through an absolute path,
* normally by an agent following the skill's discovery instructions.
*
* Usage:
* node dist/agent/cli.js status
* node dist/agent/cli.js find <id> [type]
* node dist/agent/cli.js refs <id> [type]
* node dist/agent/cli.js outgoing <id> [type] [--depth N] [--target-types A,B]
* node dist/agent/cli.js list <type> [prefix]
* node dist/agent/cli.js active <file>
* node dist/agent/cli.js define <name>
* node dist/agent/cli.js resolve <source>
* node dist/agent/cli.js projects
*/
import { resolve } from "node:path";
import { findProjectRootUpward } from "../projectRoot";
import { LiveClient, type LiveClientOptions } from "./liveClient";
import {
findAssets,
findDefine,
findReferenceGroups,
isFileActive,
listAssetsByType,
resolveIncludeSource,
statusFromSnapshot,
} from "./query";
import { readSnapshotFile, snapshotPathForProject } from "./snapshot";
import type { AgentIndexSnapshot } from "./types";
const EXIT_OK = 0;
const EXIT_USAGE = 2;
const EXIT_UNAVAILABLE = 3;
export interface CliOptions {
projectDir: string | null;
snapshotPath: string | null;
agentHome: string | undefined;
command: string;
args: string[];
depth: number | undefined;
targetTypes: string[];
maxEdges: number | undefined;
includeUnresolved: boolean;
}
export function parseArgs(argv: string[]): CliOptions {
const options: CliOptions = {
projectDir: null,
snapshotPath: null,
agentHome: undefined,
command: "status",
args: [],
depth: undefined,
targetTypes: [],
maxEdges: undefined,
includeUnresolved: false,
};
const positional: string[] = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--project" || arg === "-p") {
options.projectDir = argv[++i] ?? null;
} else if (arg === "--snapshot" || arg === "-s") {
options.snapshotPath = argv[++i] ?? null;
} else if (arg === "--agent-home") {
options.agentHome = argv[++i] ?? undefined;
} else if (arg === "--depth" || arg === "-d") {
const raw = argv[++i];
options.depth = raw != null ? Number(raw) : undefined;
} else if (arg === "--target-types" || arg === "-t") {
options.targetTypes = (argv[++i] ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else if (arg === "--max-edges") {
const raw = argv[++i];
options.maxEdges = raw != null ? Number(raw) : undefined;
} else if (arg === "--include-unresolved") {
options.includeUnresolved = true;
} else if (arg === "--help" || arg === "-h") {
options.command = "help";
} else if (arg.startsWith("-")) {
// Ignore unknown flags rather than failing an agent's probing call.
} else {
positional.push(arg);
}
}
if (positional.length > 0) {
options.command = positional[0];
options.args = positional.slice(1);
}
return options;
}
function printHelp(): void {
process.stdout.write(`RA3 Mod XML agent CLI
Usage:
node cli.js [--project <dir>] <command> [args...]
node cli.js [--snapshot <file>] <command> [args...]
Project resolution order:
1. --project
2. --snapshot
3. nearest mod project root above the current directory
Commands:
status index state (+ source: live or snapshot)
find <id> [type] asset definitions
refs <id> [type] incoming semantic references
outgoing <id> [type] outgoing reference edges (live only)
--depth N --target-types A,B --max-edges N --include-unresolved
list <type> [prefix] assets of one type
active <file> whether a file is in an indexed stream
define <name> $DEFINE constants
resolve <source> Include source candidate
projects indexed project roots (live only)
help
`);
}
/** Resolves the project directory, inferring it from cwd when not given. */
export function resolveProjectDir(options: CliOptions): string | null {
if (options.projectDir) return resolve(options.projectDir);
const inferred = findProjectRootUpward(process.cwd());
return inferred;
}
/** Writes JSON to stdout and returns the exit code. */
function emit(value: unknown): number {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
return EXIT_OK;
}
function fail(message: string, code = EXIT_USAGE): number {
process.stderr.write(`${message}\n`);
return code;
}
/** Live-only commands, reported explicitly when live is unreachable. */
export const LIVE_ONLY = new Set(["outgoing", "projects"]);
export function liveArgsFor(options: CliOptions): Record<string, unknown> {
const [a, b] = options.args;
switch (options.command) {
case "find":
return { id: a, type: b };
case "refs":
return { id: a, type: b };
case "outgoing":
return {
id: a,
type: b,
depth: options.depth,
targetTypes: options.targetTypes?.length ? options.targetTypes : undefined,
maxEdges: options.maxEdges,
includeUnresolved: options.includeUnresolved ? true : undefined,
};
case "list":
return { type: a, prefix: b };
case "active":
return { path: a };
case "define":
return { name: a };
case "resolve":
return { source: a };
default:
return {};
}
}
/** Maps a CLI command to the live/MCP tool name. */
export function toolNameFor(command: string): string {
switch (command) {
case "find":
return "find_asset";
case "refs":
return "find_references";
case "outgoing":
return "get_asset_references";
case "list":
return "list_assets_by_type";
case "active":
return "is_file_active";
case "define":
return "find_define";
case "resolve":
return "resolve_include";
case "projects":
return "list_projects";
default:
return "get_status";
}
}
/** Runs the command against the on-disk snapshot. */
function runSnapshotCommand(
options: CliOptions,
snapshot: AgentIndexSnapshot,
): number {
const [a, b] = options.args;
switch (options.command) {
case "status":
return emit({ source: "snapshot", index: statusFromSnapshot(snapshot) });
case "find":
if (!a) return fail("find requires an id.");
return emit(findAssets(snapshot, a, b));
case "refs":
if (!a) return fail("refs requires an id.");
return emit(findReferenceGroups(snapshot, a, b));
case "list":
if (!a) return fail("list requires a type.");
return emit(listAssetsByType(snapshot, a, b ?? ""));
case "active":
if (!a) return fail("active requires a file path.");
return emit({ active: isFileActive(snapshot, a) });
case "define":
if (!a) return fail("define requires a name.");
return emit(findDefine(snapshot, a.replace(/^\$/, "")));
case "resolve":
if (!a) return fail("resolve requires a source.");
return emit(resolveIncludeSource(snapshot, a));
default:
return fail(`Unknown command: ${options.command}`);
}
}
/** Explains why a live-only command could not run. */
function liveOnlyUnavailable(options: CliOptions): number {
return emit({
index: { state: "no_index" },
source: "unavailable",
error:
options.command === "outgoing"
? "get_asset_references requires a live index: the element context it reports is not stored in the on-disk snapshot. Open the project in VS Code with AI Agent access enabled, then retry."
: "list_projects requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.",
});
}
async function main(): Promise<number> {
const options = parseArgs(process.argv.slice(2));
if (options.command === "help") {
printHelp();
return EXIT_OK;
}
const projectDir = resolveProjectDir(options);
// Live first: the extension's in-memory index is the most complete source.
if (!options.snapshotPath) {
const liveOptions: LiveClientOptions = {
projectDir,
agentHome: options.agentHome,
};
const client = new LiveClient(liveOptions);
const result = await client.query(
toolNameFor(options.command),
liveArgsFor(options),
);
if (result?.mismatched) {
return emit({
index: { state: "error" },
source: "live",
error: `The live server answered for a different project than "${projectDir}"; refusing the result.`,
});
}
if (result) {
return emit({ source: "live", ...(result.payload as object) });
}
// No live index: fall through to the snapshot when the command allows it.
if (LIVE_ONLY.has(options.command)) return liveOnlyUnavailable(options);
}
// Snapshot fallback.
const snapshotPath =
options.snapshotPath ??
(projectDir ? snapshotPathForProject(projectDir, options.agentHome) : null);
if (!snapshotPath) {
return fail(
"No project found. Pass --project <dir>, --snapshot <file>, or run from inside a mod project.",
EXIT_UNAVAILABLE,
);
}
const snapshot = await readSnapshotFile(snapshotPath);
if (!snapshot) {
return fail(
`No live VS Code instance and no readable snapshot at ${snapshotPath}. Open the project in VS Code with AI Agent access enabled, or run the "Export AI Agent index snapshot" command.`,
EXIT_UNAVAILABLE,
);
}
return runSnapshotCommand(options, snapshot);
}
// Only run the CLI when executed directly, so the module stays importable by
// tests (same guard as the MCP server).
if (typeof require !== "undefined" && require.main === module) {
void main().then(
(code) => {
process.exitCode = code;
},
(err) => {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = EXIT_UNAVAILABLE;
},
);
}
+153
View File
@@ -0,0 +1,153 @@
/**
* Read/write helpers for the local endpoint files used by MCP/CLI tools to
* find a live RA3 Mod XML query server.
*
* Discovery is **per project** (`endpoints/<slug>-<hash>.json`) because a
* single global `endpoint.json` breaks as soon as more than one VS Code
* window (or more than one project) has AI Agent access enabled: the last
* window to start would overwrite the file, and a client started for project
* A could silently receive project B's index.
*
* A global `endpoint.json` is still written as a legacy/"most recent" pointer
* so tooling that does not know the project can find something, but readers
* that do know the project must prefer the per-project file.
*
* Pure TypeScript: no VS Code dependency.
*/
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { defaultAgentHome, snapshotBaseName } from "./snapshot";
export interface AgentEndpoint {
/** Base URL of the local server, e.g. http://127.0.0.1:54321 */
url: string;
token: string;
/** Absolute project root this endpoint serves (may serve several). */
projectDir?: string;
/** Every project root the server can answer for. */
projects?: string[];
/** PID of the VS Code extension host, used to detect stale files. */
processId?: number;
/** ISO timestamp of the last write, useful for diagnostics. */
updatedAt?: string;
}
/** Path to the legacy global endpoint file. */
export function endpointPath(agentHome = defaultAgentHome()): string {
return join(agentHome, "endpoint.json");
}
/** Directory holding one endpoint file per project. */
export function endpointDir(agentHome = defaultAgentHome()): string {
return join(agentHome, "endpoints");
}
/** Path to the per-project endpoint file. */
export function endpointPathForProject(
projectDir: string,
agentHome = defaultAgentHome(),
): string {
return join(endpointDir(agentHome), `${snapshotBaseName(projectDir)}.json`);
}
async function writeJson(file: string, value: unknown): Promise<void> {
await mkdir(dirname(file), { recursive: true });
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
async function readJson<T>(file: string): Promise<T | null> {
try {
return JSON.parse(await readFile(file, "utf8")) as T;
} catch {
return null;
}
}
/** Writes the legacy global endpoint file. */
export async function writeEndpoint(
endpoint: AgentEndpoint,
agentHome = defaultAgentHome(),
): Promise<string> {
const file = endpointPath(agentHome);
await writeJson(file, endpoint);
return file;
}
/** Writes one project's endpoint file (does not touch the global pointer). */
export async function writeEndpointForProject(
projectDir: string,
endpoint: AgentEndpoint,
agentHome = defaultAgentHome(),
): Promise<string> {
const file = endpointPathForProject(projectDir, agentHome);
await writeJson(file, { ...endpoint, projectDir: resolve(projectDir) });
return file;
}
/** Reads the legacy global endpoint file. */
export async function readEndpoint(
agentHome = defaultAgentHome(),
): Promise<AgentEndpoint | null> {
const parsed = await readJson<AgentEndpoint>(endpointPath(agentHome));
if (!parsed?.url || !parsed.token) return null;
return parsed;
}
/**
* Reads one project's endpoint. Returns null when the file is missing or the
* recorded project does not match the requested one (defence in depth: the
* file name already encodes the project, but a stale/hand-edited file must
* never be able to redirect a client to another project).
*/
export async function readEndpointForProject(
projectDir: string,
agentHome = defaultAgentHome(),
): Promise<AgentEndpoint | null> {
const parsed = await readJson<AgentEndpoint>(
endpointPathForProject(projectDir, agentHome),
);
if (!parsed?.url || !parsed.token) return null;
if (parsed.projectDir && !sameProject(parsed.projectDir, projectDir)) {
return null;
}
return parsed;
}
/** Removes the legacy global endpoint file. */
export async function clearEndpoint(
agentHome = defaultAgentHome(),
): Promise<void> {
await rm(endpointPath(agentHome), { force: true });
}
/** Removes one project's endpoint file. */
export async function clearEndpointForProject(
projectDir: string,
agentHome = defaultAgentHome(),
): Promise<void> {
await rm(endpointPathForProject(projectDir, agentHome), { force: true });
}
/** True when both paths resolve to the same project root. */
export function sameProject(a: string, b: string): boolean {
return resolve(a).toLowerCase() === resolve(b).toLowerCase();
}
/**
* True when a recorded extension-host PID is still running.
*
* `process.kill(pid, 0)` only probes existence: EPERM means the process
* exists but we may not signal it, which still counts as alive. Unknown PIDs
* are treated as alive so that a missing/older endpoint file does not disable
* the live path.
*/
export function isProcessAlive(pid: number | undefined | null): boolean {
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return true;
try {
process.kill(pid, 0);
return true;
} catch (err) {
return (err as NodeJS.ErrnoException).code === "EPERM";
}
}
+503
View File
@@ -0,0 +1,503 @@
/**
* Bounded, provenance-carrying forward-reference (outgoing edge) queries.
*
* The reverse index answers "who references this asset". This module answers
* the opposite, which the index does not store: "which assets does this asset
* reference, and through which XML element/attribute?".
*
* Design decisions (see docs/ai-agent-integration-plan.md §9):
*
* - It returns **edges**, not a flat node set. Each edge carries the element
* name, parent element name and attribute that produced it, plus the exact
* file/line of the XML text. A node list without that context forces the
* agent back into reading source, which is exactly what this should avoid.
* - It is **bounded three ways**: `depth` (1 default, 3 max), `targetTypes`
* (assignability filter) and `maxEdges`. Truncation is reported together
* with a per-type summary of what was dropped, so the caller can narrow the
* query instead of silently receiving a partial answer.
* - `inheritFrom` does **not** consume depth. Inheritance is part of an
* asset's own effective definition, so the ancestor's XML is walked at the
* same level and tagged with `definedIn`. This is what makes "AthenaCannon
* has no WeaponSetUpdate, but BaseCannon does" answerable in one call.
* - It is DOM-based and therefore **live-only**: resolving element context
* needs the parse tree, and storing element names on all ~98k reference
* records would measurably inflate the persistent cache.
* - It deliberately does **not** compute merged/inherited effective values:
* `xai:joinAction` Replace/Remove semantics are too easy to get wrong.
*
* Pure TypeScript: no VS Code dependency.
*/
import type { AssetDef, ModIndex } from "../indexer/types";
import { localName } from "../indexer/xpointer";
import {
isReferenceAttributeOfType,
isReferenceContentType,
resolveContentReferenceTargets,
resolveReferenceTargetsForType,
} from "../indexer/refs";
import {
LineMap,
parseXml,
type XmlDocument,
type XmlElement,
} from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import { isAssignableTo } from "../model/schemaModel";
export interface ForwardRefVia {
kind: "attribute" | "content" | "inheritFrom";
/** Element carrying the reference (e.g. `Weapon`). */
element: string;
/** Parent element giving the element its context (e.g. `WeaponSlotHardpoint`). */
parent: string | null;
/** Attribute name for `attribute` kind; null for `content`/`inheritFrom`. */
attribute: string | null;
}
/** A resolved definition location. */
export interface ForwardRefTarget {
type: string;
id: string;
file: string;
line: number;
}
export interface ForwardRefEdge {
/** 1-based hop number from the queried asset. */
depth: number;
/** The asset the edge is attributed to (the queried asset for inherited XML). */
from: { type: string; id: string };
/** Resolved definition, or null for an unresolved reference. */
to: ForwardRefTarget | null;
via: ForwardRefVia;
/**
* Present only when the XML text lives in an `inheritFrom` ancestor rather
* than in `from` itself.
*/
definedIn?: { type: string; id: string };
/** Exact source position of the reference value. */
source: { file: string; line: number; character: number };
/** Raw value, present for unresolved references and `inheritFrom` edges. */
value?: string;
}
export interface ForwardRefNode extends ForwardRefTarget {
/** Shallowest depth at which this node was reached. */
depth: number;
}
export interface ForwardRefOptions {
/** Levels of assets to expand. 1 = only the queried asset (default). Max 3. */
depth?: number;
/** Only keep edges/nodes whose target is assignable to one of these types. */
targetTypes?: string[];
/** Hard cap on returned edges (default 200). */
maxEdges?: number;
/** Include edges whose reference value could not be resolved. */
includeUnresolved?: boolean;
/** Walk `inheritFrom` ancestors (default true). */
includeInheritance?: boolean;
}
export interface ForwardRefResult {
roots: AssetDef[];
edges: ForwardRefEdge[];
nodes: ForwardRefNode[];
truncated: boolean;
/** Target types dropped because `maxEdges` was reached. */
omittedByTargetType: Record<string, number>;
/** Non-fatal warnings (missing files, missing definitions, capped chains). */
warnings: string[];
}
/** A parsed XML file plus the raw text, as needed by the DOM walk. */
export interface LoadedXmlFile {
parse: XmlDocument;
lineMap: LineMap;
text: string;
}
export type XmlFileLoader = (file: string) => Promise<LoadedXmlFile | null>;
const DEFAULT_MAX_EDGES = 200;
const MAX_DEPTH_LIMIT = 3;
/**
* Inheritance is walked at the same depth, so the chain length is capped
* separately to keep pathological hierarchies from expanding without bound.
*/
const MAX_INHERIT_CHAIN = 8;
/** Parses a file's text into the shape this module needs. */
export function parseLoadedXml(text: string): LoadedXmlFile {
return { parse: parseXml(text), lineMap: new LineMap(text), text };
}
/**
* Resolves the definitions to start from, then walks their outgoing
* references. Returns an empty result (with a warning) when nothing matches.
*/
export async function collectAssetReferences(
index: ModIndex,
id: string,
type: string | null,
loadFile: XmlFileLoader,
options: ForwardRefOptions = {},
): Promise<ForwardRefResult> {
const wanted = id.toLowerCase();
const wantedType = type?.toLowerCase() ?? null;
const roots = (index.assetsById.get(wanted) ?? []).filter(
(d) => !wantedType || d.type.toLowerCase() === wantedType,
);
if (!roots.length) {
return {
roots: [],
edges: [],
nodes: [],
truncated: false,
omittedByTargetType: {},
warnings: [
`No definition found for id "${id}"${type ? ` of type "${type}"` : ""}.`,
],
};
}
const result = await collectForwardReferences(index, roots, loadFile, options);
return { ...result, roots };
}
/**
* Walks the outgoing references of `roots` and returns bounded edges.
*/
export async function collectForwardReferences(
index: ModIndex,
roots: readonly AssetDef[],
loadFile: XmlFileLoader,
options: ForwardRefOptions = {},
): Promise<ForwardRefResult> {
const depthLimit = clampDepth(options.depth);
const maxEdges = Math.max(1, Math.floor(options.maxEdges ?? DEFAULT_MAX_EDGES));
const targetTypes = (options.targetTypes ?? []).filter(Boolean);
const includeUnresolved = options.includeUnresolved ?? false;
const includeInheritance = options.includeInheritance ?? true;
const edges: ForwardRefEdge[] = [];
const nodes = new Map<string, ForwardRefNode>();
const omitted = new Map<string, number>();
const warnings: string[] = [];
let truncated = false;
let edgeCount = 0;
const fileCache = new Map<string, LoadedXmlFile | null>();
const load = async (file: string): Promise<LoadedXmlFile | null> => {
const key = file.toLowerCase();
if (!fileCache.has(key)) {
try {
fileCache.set(key, await loadFile(file));
} catch {
fileCache.set(key, null);
}
}
return fileCache.get(key) ?? null;
};
interface WorkItem {
/** Asset whose XML is walked. */
def: AssetDef;
/** 0-based expansion level. */
level: number;
/** Asset the edges are attributed to (the queried asset). */
effective: { type: string; id: string };
/** Chain from `effective` (exclusive) down to `def` (inclusive). */
trail: { type: string; id: string }[];
}
const queue: WorkItem[] = roots.map((def) => ({
def,
level: 0,
effective: { type: def.type, id: def.id },
trail: [],
}));
const visited = new Set<string>();
const matchesTargetType = (typeName: string): boolean =>
targetTypes.length === 0 ||
targetTypes.some((wanted) => isAssignableTo(typeName, wanted));
const addEdge = (
level: number,
effective: WorkItem["effective"],
trail: WorkItem["trail"],
via: ForwardRefVia,
target: ForwardRefTarget | null,
source: ForwardRefEdge["source"],
value?: string,
): void => {
// `targetTypes` drops edges the caller did not ask for. `inheritFrom`
// edges always survive: they explain where the remaining edges came from
// (an inherited weapon lives in the ancestor's file, and editing the
// derived asset would be wrong). This filter is applied before the edge
// cap so `omittedByTargetType` only reports cap-driven truncation.
if (target && via.kind !== "inheritFrom" && !matchesTargetType(target.type)) {
return;
}
if (edgeCount >= maxEdges) {
truncated = true;
const bucket = target?.type ?? "#unresolved";
omitted.set(bucket, (omitted.get(bucket) ?? 0) + 1);
return;
}
edgeCount++;
const edge: ForwardRefEdge = {
depth: level + 1,
from: { type: effective.type, id: effective.id },
to: target,
via,
source,
};
if (trail.length) {
const owner = trail[trail.length - 1];
edge.definedIn = { type: owner.type, id: owner.id };
}
if (value !== undefined) edge.value = value;
edges.push(edge);
if (target) {
const key = nodeKey(target);
const existing = nodes.get(key);
if (!existing || level + 1 < existing.depth) {
nodes.set(key, { ...target, depth: level + 1 });
}
}
};
while (queue.length) {
const item = queue.shift()!;
const { def, level, effective, trail } = item;
const visitKey = [
effective.type,
effective.id.toLowerCase(),
def.type,
def.id.toLowerCase(),
def.file.toLowerCase(),
def.line,
].join("\u0000");
if (visited.has(visitKey)) continue;
visited.add(visitKey);
const loaded = await load(def.file);
if (!loaded) {
warnings.push(`Could not read ${def.file} (definition of ${def.type}:${def.id}).`);
continue;
}
const ownerEl = findDefinitionElement(loaded, def);
if (!ownerEl) {
warnings.push(
`Could not locate <${def.type} id="${def.id}"> inside ${def.file}; the index line may be stale.`,
);
continue;
}
const sourceAt = (offset: number): ForwardRefEdge["source"] => {
const pos = loaded.lineMap.positionAt(offset);
return { file: def.file, line: pos.line + 1, character: pos.character };
};
for (const el of subtreeInDocumentOrder(ownerEl)) {
const elType = resolveElementType(el);
const elLocal = localName(el.name);
const parentLocal = el.parent ? localName(el.parent.name) : null;
for (const attr of el.attrs) {
if (!attr.hasValue) continue;
const nameLower = attr.name.toLowerCase();
// inheritFrom belongs to the asset element itself and is handled
// separately below so it can also expand the ancestor chain.
if (nameLower === "inheritfrom") continue;
const value = attr.value;
if (!value || value.startsWith("$") || value.startsWith("=")) continue;
if (!isReferenceAttributeOfType(elType, attr.name)) continue;
const via: ForwardRefVia = {
kind: "attribute",
element: elLocal,
parent: parentLocal,
attribute: attr.name,
};
const source = sourceAt(attr.valueStart);
const targets = resolveReferenceTargetsForType(index, elType, attr.name, value);
if (!targets.length) {
if (includeUnresolved) addEdge(level, effective, trail, via, null, source, value);
continue;
}
for (const target of targets) {
const to = targetOf(target.def);
addEdge(level, effective, trail, via, to, source);
if (level + 1 < depthLimit) {
queue.push({
def: target.def,
level: level + 1,
effective: { type: target.def.type, id: target.def.id },
trail: [],
});
}
}
}
// Simple-content references (e.g. <CreateObject>temp_id</CreateObject>).
if (elType && isReferenceContentType(elType) && !el.selfClosing && el.closeTagStart >= 0) {
const raw = loaded.text.slice(el.startTagEnd, el.closeTagStart);
const value = raw.trim();
if (
!value ||
value.startsWith("$") ||
value.startsWith("=") ||
value.includes("<")
) {
continue;
}
const start = el.startTagEnd + raw.indexOf(value);
const via: ForwardRefVia = {
kind: "content",
element: elLocal,
parent: parentLocal,
attribute: null,
};
const source = sourceAt(start);
const targets = resolveContentReferenceTargets(index, elType, value);
if (!targets.length) {
if (includeUnresolved) addEdge(level, effective, trail, via, null, source, value);
continue;
}
for (const target of targets) {
const to = targetOf(target.def);
addEdge(level, effective, trail, via, to, source);
if (level + 1 < depthLimit) {
queue.push({
def: target.def,
level: level + 1,
effective: { type: target.def.type, id: target.def.id },
trail: [],
});
}
}
}
}
// inheritFrom on the definition element: walk the ancestor at the SAME
// level so its XML contributes to the queried asset's effective definition.
if (includeInheritance) {
const inheritAttr = ownerEl.attrs.find(
(a) => a.name.toLowerCase() === "inheritfrom",
);
const value = inheritAttr?.value;
if (inheritAttr?.hasValue && value && !value.startsWith("$") && !value.startsWith("=")) {
const via: ForwardRefVia = {
kind: "inheritFrom",
element: localName(ownerEl.name),
parent: ownerEl.parent ? localName(ownerEl.parent.name) : null,
attribute: inheritAttr.name,
};
const source = sourceAt(inheritAttr.valueStart);
const targets = resolveReferenceTargetsForType(
index,
def.type,
"inheritFrom",
value,
);
if (!targets.length) {
if (includeUnresolved) addEdge(level, effective, trail, via, null, source, value);
} else {
for (const target of targets) {
const to = targetOf(target.def);
addEdge(level, effective, trail, via, to, source, value);
// The trail records the asset whose XML we are about to walk, so
// edges found there report the correct `definedIn`.
const nextTrail = [
...trail,
{ type: target.def.type, id: target.def.id },
];
if (nextTrail.length < MAX_INHERIT_CHAIN) {
queue.push({ def: target.def, level, effective, trail: nextTrail });
} else {
warnings.push(
`Inheritance chain for ${effective.type}:${effective.id} exceeded ${MAX_INHERIT_CHAIN} levels; deeper ancestors were not walked.`,
);
}
}
}
}
}
}
return {
roots: [...roots],
edges,
nodes: [...nodes.values()].sort(
(a, b) => a.depth - b.depth || a.type.localeCompare(b.type) || a.id.localeCompare(b.id),
),
truncated,
omittedByTargetType: Object.fromEntries(omitted),
warnings,
};
}
function clampDepth(depth: number | undefined): number {
if (depth == null || !Number.isFinite(depth)) return 1;
return Math.max(1, Math.min(MAX_DEPTH_LIMIT, Math.floor(depth)));
}
function targetOf(def: AssetDef): ForwardRefTarget {
return { type: def.type, id: def.id, file: def.file, line: def.line };
}
function nodeKey(target: ForwardRefTarget): string {
return `${target.type}\u0000${target.id.toLowerCase()}\u0000${target.file.toLowerCase()}\u0000${target.line}`;
}
/**
* Finds the element defining `def` inside a parsed file.
*
* The index stores a line number for the `id` attribute, so an exact line
* match is the strongest signal; top-level placement is the next best. This
* matters when a nested element reuses the same id.
*/
export function findDefinitionElement(
loaded: LoadedXmlFile,
def: Pick<AssetDef, "id" | "line">,
): XmlElement | null {
const wanted = def.id.toLowerCase();
const root = loaded.parse.root;
let best: XmlElement | null = null;
let bestScore = -1;
for (const el of loaded.parse.elements) {
const idAttr = el.attrs.find((a) => a.name.toLowerCase() === "id");
if (!idAttr?.hasValue) continue;
if (idAttr.value.toLowerCase() !== wanted) continue;
let score = 0;
const isTopLevel = el.parent === root || (root == null && el.parent == null);
if (isTopLevel) score += 2;
if (def.line > 0) {
const line = loaded.lineMap.positionAt(idAttr.valueStart).line + 1;
if (line === def.line) score += 4;
}
if (score > bestScore) {
bestScore = score;
best = el;
}
}
return best;
}
/** Depth-first, document-order element list for a subtree (no recursion). */
export function subtreeInDocumentOrder(root: XmlElement): XmlElement[] {
const out: XmlElement[] = [];
const stack: XmlElement[] = [root];
while (stack.length) {
const el = stack.pop()!;
out.push(el);
for (let i = el.children.length - 1; i >= 0; i--) {
stack.push(el.children[i]);
}
}
return out;
}
+268
View File
@@ -0,0 +1,268 @@
/**
* Live-instance registry.
*
* Each VS Code window that enables AI Agent access writes **its own** file
* under `instances/`. Two properties follow from that:
*
* - **No locking and no merging.** Writers never touch each other's files, so
* there is no read-modify-write race to guard. An earlier design that
* shared a single JSON file would have needed a lock file (itself another
* thing a crash can leave behind) plus a merge step.
* - **Crash recovery does not wait for the same workspace.** Any instance can
* prune entries whose recorded PID is dead, so a crashed window is cleaned
* up the next time *any* VS Code window with the extension activates.
*
* A merged, read-only `index.json` is derived from the instance files purely
* for discovery (agents/humans looking at the directory), so the machine
* coordination files and the human-readable view stay separate.
*
* Pure TypeScript: no VS Code dependency.
*/
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { clearEndpoint, isProcessAlive, writeEndpoint, type AgentEndpoint } from "./endpoint";
import { defaultAgentHome, snapshotBaseName } from "./snapshot";
/** One live extension-host instance. */
export interface AgentInstance extends AgentEndpoint {
/** Unique per window; also the file name stem. */
instanceId: string;
}
/** Discovery manifest derived from all live instances. */
export interface AgentIndexManifest {
schemaVersion: number;
generatedAt: string;
instances: Array<{
instanceId: string;
processId?: number;
url: string;
/** Tokens are intentionally omitted: the manifest is for discovery. */
projects: string[];
}>;
projects: string[];
}
export const INSTANCE_SCHEMA_VERSION = 1;
/** Directory holding one file per live extension host. */
export function instancesDir(agentHome = defaultAgentHome()): string {
return join(agentHome, "instances");
}
/** Path to the merged discovery manifest. */
export function manifestPath(agentHome = defaultAgentHome()): string {
return join(agentHome, "index.json");
}
/** File name for one instance. */
export function instanceFileName(instanceId: string): string {
return `vscode-${instanceId}.json`;
}
/**
* Builds a process-unique instance id. Combining the PID with a random suffix
* keeps two windows of the same process id from colliding across restarts.
*/
export function makeInstanceId(pid = process.pid): string {
const rand = Math.random().toString(36).slice(2, 8);
return `${pid}-${rand}`;
}
/** Writes this instance's own file. */
export async function writeInstance(
instance: AgentInstance,
agentHome = defaultAgentHome(),
): Promise<string> {
const file = join(instancesDir(agentHome), instanceFileName(instance.instanceId));
await mkdir(dirname(file), { recursive: true });
await writeFile(file, `${JSON.stringify(instance, null, 2)}\n`, "utf8");
return file;
}
/** Reads every instance file, skipping malformed ones. */
export async function readInstances(
agentHome = defaultAgentHome(),
): Promise<AgentInstance[]> {
const dir = instancesDir(agentHome);
let names: string[];
try {
names = await readdir(dir);
} catch {
return [];
}
const out: AgentInstance[] = [];
for (const name of names) {
if (!name.endsWith(".json")) continue;
try {
const parsed = JSON.parse(
await readFile(join(dir, name), "utf8"),
) as AgentInstance;
if (parsed?.url && parsed?.token) {
// Fall back to the file name when an older file lacks the field.
parsed.instanceId ??= name.replace(/^vscode-/, "").replace(/\.json$/, "");
out.push(parsed);
}
} catch {
// Skip unreadable/corrupt entries.
}
}
return out;
}
/** Removes this instance's own file. */
export async function clearInstance(
instanceId: string,
agentHome = defaultAgentHome(),
): Promise<void> {
await rm(join(instancesDir(agentHome), instanceFileName(instanceId)), {
force: true,
});
}
export interface PruneResult {
removed: string[];
kept: AgentInstance[];
}
/**
* Removes instance files whose recorded PID is no longer alive.
*
* This is how crashes are cleaned up without waiting for the same workspace to
* be reopened. Only clearly-dead PIDs are pruned: `isProcessAlive` treats an
* unknown or unparseable PID as alive, so an older file that predates the
* `processId` field is never deleted by mistake.
*/
export async function pruneInstances(
agentHome = defaultAgentHome(),
): Promise<PruneResult> {
const instances = await readInstances(agentHome);
const removed: string[] = [];
const kept: AgentInstance[] = [];
for (const instance of instances) {
if (isProcessAlive(instance.processId)) {
kept.push(instance);
} else {
removed.push(instance.instanceId);
await clearInstance(instance.instanceId, agentHome).catch(() => undefined);
}
}
return { removed, kept };
}
/** Collects every project root across live instances. */
export function projectsOf(instances: readonly AgentInstance[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const instance of instances) {
const candidates = [
...(instance.projects ?? []),
...(instance.projectDir ? [instance.projectDir] : []),
];
for (const project of candidates) {
const key = project.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(project);
}
}
return out;
}
/**
* Regenerates the merged read-only manifest from the live instances.
* Best-effort: failures are swallowed because the manifest is a convenience,
* not a correctness requirement (readers can always scan `instances/`).
*/
export async function writeManifest(
instances: readonly AgentInstance[],
agentHome = defaultAgentHome(),
): Promise<AgentIndexManifest> {
const manifest: AgentIndexManifest = {
schemaVersion: INSTANCE_SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
instances: instances.map((instance) => ({
instanceId: instance.instanceId,
processId: instance.processId,
url: instance.url,
projects: [
...(instance.projects ?? []),
...(instance.projectDir ? [instance.projectDir] : []),
],
})),
projects: projectsOf(instances),
};
try {
const file = manifestPath(agentHome);
await mkdir(dirname(file), { recursive: true });
await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
} catch {
// Discovery only: never fail the caller over this.
}
return manifest;
}
/** Reads the discovery manifest, or null when absent/malformed. */
export async function readManifest(
agentHome = defaultAgentHome(),
): Promise<AgentIndexManifest | null> {
try {
return JSON.parse(
await readFile(manifestPath(agentHome), "utf8"),
) as AgentIndexManifest;
} catch {
return null;
}
}
/**
* Re-derives the legacy global endpoint pointer and the merged discovery
* manifest from the instance files that still exist and whose recorded
* process is alive.
*
* Called after an instance file is removed (window closed, or pruned as
* crashed). Closing one window must never make another still-running window
* undiscoverable: whichever writer changed the instance set has to rebuild
* both derived files from the survivors instead of clearing them.
*
* Best-effort: the pointer/manifest are discovery conveniences, and readers
* can always scan `instances/`, so failures never propagate to the caller.
*/
export async function refreshDiscovery(
agentHome = defaultAgentHome(),
): Promise<AgentIndexManifest> {
const { kept } = await pruneInstances(agentHome);
const first = kept[0];
try {
if (first) {
await writeEndpoint(endpointOf(first), agentHome);
} else {
await clearEndpoint(agentHome);
}
} catch {
// Discovery pointer only; the manifest below is still worth writing.
}
return writeManifest(kept, agentHome);
}
/**
* The endpoint fields of an instance file, without the registry-only
* `instanceId` (endpoint.json predates the instance registry and readers do
* not expect that field).
*/
function endpointOf(instance: AgentInstance): AgentEndpoint {
return {
url: instance.url,
token: instance.token,
projectDir: instance.projectDir,
projects: instance.projects,
processId: instance.processId,
updatedAt: instance.updatedAt,
};
}
/** Snapshot path for a project, re-exported for discovery convenience. */
export function projectKey(projectDir: string): string {
return snapshotBaseName(projectDir);
}
+272
View File
@@ -0,0 +1,272 @@
/**
* Shared live-index client used by both the MCP server and the CLI.
*
* Keeping this in one place matters because the forwarding path carries safety
* logic that must not drift between entry points:
*
* - the requested project is always pinned on the request, so the server
* cannot answer from whichever project its active editor points at;
* - a response reporting a different `projectDir` is refused rather than
* shown, because a plausible wrong answer is worse than no answer;
* - a dead extension-host PID marks the instance file stale;
* - failed attempts are negatively cached so every tool call does not pay a
* connection timeout.
*
* Transport is currently loopback HTTP. Swapping it for a named pipe / Unix
* domain socket only requires changing this file.
*
* Pure TypeScript: no VS Code dependency.
*/
import {
isProcessAlive,
readEndpoint,
readEndpointForProject,
sameProject,
type AgentEndpoint,
} from "./endpoint";
import { readInstances } from "./instances";
/** Cooldown after a failed live attempt, to avoid a probe per tool call. */
const LIVE_RETRY_COOLDOWN_MS = 5000;
/** Per-request timeout for a live query. */
const LIVE_TIMEOUT_MS = 1500;
export interface LiveQueryResult {
payload: unknown;
/** True when the live server answered but for a different project. */
mismatched: boolean;
/** Which endpoint produced the answer (for diagnostics). */
endpoint: AgentEndpoint;
}
/** Normalizes a path for case/separator-insensitive comparison. */
export function normalizePath(p: string): string {
return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
}
/**
* Rejects a live response that belongs to a different project than the one
* the client was started for. See docs/ai-agent-integration-plan.md §10 for
* why this guard exists: in a multi-window setup a client configured for
* project A could otherwise silently receive project B's index.
*/
export function responseProjectMismatch(
payload: unknown,
projectDir: string | null,
): boolean {
if (!projectDir) return false;
const index = (payload as { index?: { projectDir?: string } } | null)?.index;
const reported = index?.projectDir;
if (!reported) return false;
return normalizePath(reported) !== normalizePath(projectDir);
}
/** Tools that can be answered by the live index and their HTTP paths. */
const TOOL_PATHS: Record<string, string> = {
get_status: "/status",
find_asset: "/find_asset",
find_references: "/find_references",
get_asset_references: "/get_asset_references",
list_assets_by_type: "/list_assets",
is_file_active: "/is_file_active",
find_define: "/find_define",
resolve_include: "/resolve_include",
list_projects: "/projects",
};
/**
* Builds the live request URL for a tool call, always pinning the project.
* Returns null for tools the live server does not serve.
*/
export function liveUrlForTool(
endpointUrl: string,
projectDir: string | null,
toolName: string,
args: Record<string, unknown>,
): string | null {
const path = TOOL_PATHS[toolName];
if (!path) return null;
const base = endpointUrl.replace(/\/$/, "");
const q = new URLSearchParams();
// Always pin the requested project. Without this the server would silently
// answer from whatever project its active editor points at.
if (projectDir) q.set("project", projectDir);
switch (toolName) {
case "get_status":
case "list_projects":
break;
case "find_asset":
case "find_references":
case "get_asset_references":
q.set("id", String(args.id ?? ""));
if (args.type != null) q.set("type", String(args.type));
if (toolName === "get_asset_references") {
if (args.depth != null) q.set("depth", String(args.depth));
if (Array.isArray(args.targetTypes)) {
q.set("targetTypes", (args.targetTypes as unknown[]).map(String).join(","));
}
if (args.maxEdges != null) q.set("maxEdges", String(args.maxEdges));
if (args.includeUnresolved != null) {
q.set("includeUnresolved", String(args.includeUnresolved));
}
}
break;
case "list_assets_by_type":
q.set("type", String(args.type ?? ""));
if (args.prefix != null) q.set("prefix", String(args.prefix));
if (args.limit != null) q.set("limit", String(args.limit));
break;
case "is_file_active":
q.set("path", String(args.path ?? ""));
break;
case "find_define":
q.set("name", String(args.name ?? ""));
break;
case "resolve_include":
q.set("source", String(args.source ?? ""));
break;
default:
return null;
}
const query = q.toString();
return query ? `${base}${path}?${query}` : `${base}${path}`;
}
export interface LiveClientOptions {
/** Project the client was started for (pins every request). */
projectDir?: string | null;
/** Override the agent home directory (used by tests). */
agentHome?: string;
/** Clock injection for deterministic negative-cache tests. */
now?: () => number;
}
/**
* Finds a usable endpoint for this client's project.
*
* Order: per-project file, then any live instance whose `projects` list
* contains this project, then the legacy global pointer (only when its
* recorded project matches). Endpoints with a dead PID are skipped.
*/
export async function findEndpoint(
options: LiveClientOptions = {},
): Promise<AgentEndpoint | null> {
const projectDir = options.projectDir ?? null;
const agentHome = options.agentHome;
if (projectDir) {
const direct = await readEndpointForProject(projectDir, agentHome);
if (direct && isProcessAlive(direct.processId)) return direct;
}
// Scan live instances: this is what makes a freshly started window work
// even if its per-project file has not been written yet.
try {
const instances = await readInstances(agentHome);
for (const instance of instances) {
if (!isProcessAlive(instance.processId)) continue;
if (projectDir && instance.projectDir && !sameProject(instance.projectDir, projectDir)) {
continue;
}
if (
projectDir &&
instance.projects?.length &&
!instance.projects.some((p) => sameProject(p, projectDir))
) {
continue;
}
return instance;
}
} catch {
// Instance scanning is best-effort.
}
const fallback = await readEndpoint(agentHome);
if (fallback && isProcessAlive(fallback.processId)) {
if (
!projectDir ||
!fallback.projectDir ||
sameProject(fallback.projectDir, projectDir)
) {
return fallback;
}
}
return null;
}
/**
* Queries the live extension server. Returns null when no live index is
* reachable, so callers can fall back to the on-disk snapshot.
*/
export class LiveClient {
private unavailableUntil = 0;
private readonly now: () => number;
constructor(private readonly options: LiveClientOptions = {}) {
this.now = options.now ?? Date.now;
}
/** True while the negative cache is suppressing attempts. */
get suppressed(): boolean {
return this.now() < this.unavailableUntil;
}
/** Clears the negative cache (e.g. after the user re-enables access). */
reset(): void {
this.unavailableUntil = 0;
}
/** Marks the live path unavailable for the cooldown period. */
markUnavailable(): void {
this.unavailableUntil = this.now() + LIVE_RETRY_COOLDOWN_MS;
}
async query(
toolName: string,
args: Record<string, unknown> = {},
): Promise<LiveQueryResult | null> {
if (this.suppressed) return null;
if (typeof fetch !== "function") return null;
const projectDir = this.options.projectDir ?? null;
const endpoint = await findEndpoint(this.options);
if (!endpoint) return null;
const url = liveUrlForTool(endpoint.url, projectDir, toolName, args);
if (!url) return null;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), LIVE_TIMEOUT_MS);
try {
const res = await fetch(url, {
headers: { authorization: `Bearer ${endpoint.token}` },
signal: controller.signal,
});
if (!res.ok) {
// 401/404 mean the endpoint is stale rather than merely slow.
if (res.status === 401 || res.status === 404) this.markUnavailable();
return null;
}
const payload: unknown = await res.json();
return {
payload,
mismatched: responseProjectMismatch(payload, projectDir),
endpoint,
};
} catch {
this.markUnavailable();
return null;
} finally {
clearTimeout(timeout);
}
}
}
/** Convenience one-shot query using a fresh client. */
export async function queryLive(
toolName: string,
args: Record<string, unknown> = {},
options: LiveClientOptions = {},
): Promise<LiveQueryResult | null> {
return new LiveClient(options).query(toolName, args);
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Query helpers that operate directly on the live in-memory ModIndex.
*
* These are useful for local HTTP/live services where converting the full
* index to an external snapshot on every query would be wasteful.
*
* Pure TypeScript: no VS Code dependency.
*/
import { resolve } from "node:path";
import type { AssetDef, DefineDef, ModIndex, ReferenceSite } from "../indexer/types";
import { statusFromIndex } from "./snapshot";
import type { AgentIndexStatus, AgentReferenceGroup } from "./types";
function normalizePath(p: string): string {
return resolve(p).replace(/\\/g, "/").toLowerCase();
}
/** Finds asset definitions by id in the live index. */
export function findAssetsLive(
index: ModIndex,
id: string,
type?: string | null,
): AssetDef[] {
const wanted = id.toLowerCase();
const wantedType = type?.toLowerCase();
const candidates = index.assetsById.get(wanted) ?? [];
if (!wantedType) return candidates;
return candidates.filter((a) => a.type.toLowerCase() === wantedType);
}
/** Lists assets of one type in the live index. */
export function listAssetsByTypeLive(
index: ModIndex,
type: string,
idPrefix = "",
limit?: number,
): AssetDef[] {
const wantedType = type.toLowerCase();
const wantedPrefix = idPrefix.toLowerCase();
const byType = index.assets.get(type);
if (!byType) return [];
const out: AssetDef[] = [];
for (const [id, defs] of byType) {
if (!id.startsWith(wantedPrefix)) continue;
for (const def of defs) {
if (def.type.toLowerCase() !== wantedType) continue;
out.push(def);
if (limit != null && out.length >= limit) return out;
}
}
return out;
}
/**
* Converts the reverse reference map into groups for one asset id.
* Reference map keys are `type\0id\0file\0line`.
*/
export function findReferenceGroupsLive(
index: ModIndex,
id: string,
type?: string | null,
): AgentReferenceGroup[] {
const wanted = id.toLowerCase();
const wantedType = type?.toLowerCase();
const groups: AgentReferenceGroup[] = [];
for (const [key, sites] of index.references) {
const parts = key.split("\u0000");
if (parts.length !== 4) continue;
const [typeName, defId, file, lineText] = parts;
if (defId.toLowerCase() !== wanted) continue;
if (wantedType && typeName.toLowerCase() !== wantedType) continue;
groups.push({
type: typeName,
id: defId,
file,
line: Number(lineText) || 0,
sites,
});
}
return groups;
}
/** Flattens live reference groups into sites. */
export function findReferenceSitesLive(
index: ModIndex,
id: string,
type?: string | null,
): ReferenceSite[] {
return findReferenceGroupsLive(index, id, type).flatMap((g) => g.sites);
}
/** Returns streams containing a file in the live index. */
export function streamsForFileLive(
index: ModIndex,
file: string,
): ModIndex["streams"] {
const key = normalizePath(file);
return index.streams.filter((s) =>
[...s.files].some((candidate) => normalizePath(candidate) === key),
);
}
/** True when a file belongs to a live stream. */
export function isFileActiveLive(index: ModIndex, file: string): boolean {
return streamsForFileLive(index, file).length > 0;
}
/** Finds defines by name in the live index. */
export function findDefineLive(
index: ModIndex,
name: string,
): DefineDef[] {
const wanted = name.toLowerCase();
return index.defines.get(wanted) ?? [];
}
/** Resolves Include source using the live source-candidate list. */
export function resolveIncludeLive(
index: ModIndex,
source: string,
): { source: string; path: string } | null {
const wanted = source.toLowerCase();
const hit = index.sourceCandidates.find(
(c) => c.source.toLowerCase() === wanted,
);
return hit ? { source: hit.source, path: hit.path } : null;
}
/**
* Returns a status object for the live index.
*
* `requestedProjectDir` is only echoed when no index is available, so callers
* can still verify which server they reached. It is never used to fake a
* `projectDir` when an index exists — the index is the source of truth.
*/
export function liveStatus(
index: ModIndex | null | undefined,
requestedProjectDir?: string,
): AgentIndexStatus {
return statusFromIndex(index, requestedProjectDir);
}
/** Every project root this index belongs to (for `/projects` listing). */
export function knownProjectDirs(index: ModIndex | null | undefined): string[] {
return index ? [index.projectDir] : [];
}
+268
View File
@@ -0,0 +1,268 @@
/**
* Local read-only HTTP server for live RA3 Mod XML index queries.
*
* The server runs inside the VS Code extension host when the user has enabled
* AI Agent access. It listens only on 127.0.0.1 and requires a bearer token so
* unrelated local processes cannot query it by accident.
*
* Requests may carry `?project=<dir>` to select which project's index answers
* the query. When omitted, the server falls back to the active project. Every
* response echoes `index.projectDir` so the caller can verify it reached the
* server/project it asked for — see docs/ai-agent-integration-plan.md §10 for
* why that check is required in multi-window setups.
*
* Pure TypeScript: no VS Code dependency.
*/
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import type { ModIndex } from "../indexer/types";
import {
collectAssetReferences,
type ForwardRefOptions,
type XmlFileLoader,
} from "./forwardRefs";
import {
findAssetsLive,
findDefineLive,
findReferenceGroupsLive,
isFileActiveLive,
listAssetsByTypeLive,
liveStatus,
resolveIncludeLive,
} from "./liveQuery";
const USAGE_GUIDE = `RA3 Mod XML live index query API
Endpoints (all require Authorization: Bearer <token>):
GET /status
GET /projects
GET /find_asset?id=...&type=...
GET /find_references?id=...&type=...
GET /get_asset_references?id=...&type=...&depth=1&targetTypes=A,B&maxEdges=200
GET /list_assets?type=...&prefix=...&limit=...
GET /is_file_active?path=...
GET /find_define?name=...
GET /resolve_include?source=...
GET /get_usage_guide
All endpoints accept an optional ?project=<absolute dir> selector. Responses
echo index.projectDir; treat the result as belonging to a different project
when it does not match what you asked for.
`;
export interface LocalServerHandle {
port: number;
token: string;
close(): Promise<void>;
}
export interface LocalServerOptions {
/** Returns the current in-memory index for a project (null when unknown). */
getIndex: (projectDir?: string) => ModIndex | null;
/** Every project root the live workspace currently knows about. */
listProjects?: () => string[];
/**
* Reads + parses one XML file. Required by /get_asset_references, which
* needs element context that the index does not store.
*/
loadFile?: XmlFileLoader;
token?: string;
/** Defaults to an OS-assigned port on 127.0.0.1. */
port?: number;
}
function sendJson(res: ServerResponse, status: number, value: unknown): void {
const body = JSON.stringify(value);
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(body),
});
res.end(body);
}
function sendText(res: ServerResponse, status: number, text: string): void {
res.writeHead(status, {
"content-type": "text/plain; charset=utf-8",
"content-length": Buffer.byteLength(text),
});
res.end(text);
}
function isAuthorized(req: IncomingMessage, token: string): boolean {
const header = req.headers.authorization ?? "";
return header === `Bearer ${token}`;
}
/** Parses a comma-separated `targetTypes` parameter. */
function parseList(raw: string | null): string[] {
if (!raw) return [];
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
function parseNumber(raw: string | null): number | undefined {
if (raw == null || raw === "") return undefined;
const value = Number(raw);
return Number.isFinite(value) ? value : undefined;
}
function forwardRefOptionsFrom(q: URLSearchParams): ForwardRefOptions {
return {
depth: parseNumber(q.get("depth")),
targetTypes: parseList(q.get("targetTypes")),
maxEdges: parseNumber(q.get("maxEdges")),
includeUnresolved: q.get("includeUnresolved") === "true",
};
}
async function handle(
options: LocalServerOptions,
token: string,
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
if (!isAuthorized(req, token)) {
sendJson(res, 401, { error: "Unauthorized" });
return;
}
const url = new URL(req.url ?? "/", "http://127.0.0.1");
const q = url.searchParams;
const projectDir = q.get("project") ?? undefined;
const index = options.getIndex(projectDir);
// When an index exists its own projectDir is authoritative; the selector is
// only echoed for no-index responses so callers can still verify the server.
const status = liveStatus(index, projectDir);
switch (url.pathname) {
case "/status":
sendJson(res, 200, status);
return;
case "/projects":
sendJson(res, 200, {
index: status,
data: options.listProjects?.() ?? [],
});
return;
case "/find_asset":
sendJson(res, 200, {
index: status,
data: index ? findAssetsLive(index, q.get("id") ?? "", q.get("type")) : [],
});
return;
case "/find_references":
sendJson(res, 200, {
index: status,
data: index
? findReferenceGroupsLive(index, q.get("id") ?? "", q.get("type"))
: [],
});
return;
case "/list_assets": {
const limit = parseNumber(q.get("limit"));
sendJson(res, 200, {
index: status,
data: index
? listAssetsByTypeLive(index, q.get("type") ?? "", q.get("prefix") ?? "", limit)
: [],
});
return;
}
case "/is_file_active":
sendJson(res, 200, {
index: status,
data: { active: index ? isFileActiveLive(index, q.get("path") ?? "") : false },
});
return;
case "/find_define":
sendJson(res, 200, {
index: status,
data: index
? findDefineLive(index, (q.get("name") ?? "").replace(/^\$/, ""))
: [],
});
return;
case "/resolve_include":
sendJson(res, 200, {
index: status,
data: index ? resolveIncludeLive(index, q.get("source") ?? "") : null,
});
return;
case "/get_asset_references": {
if (!index) {
sendJson(res, 200, {
index: status,
data: null,
error:
"get_asset_references requires a live index (VS Code must be open with the project indexed).",
});
return;
}
if (!options.loadFile) {
sendJson(res, 200, {
index: status,
data: null,
error: "The live server was started without XML file access.",
});
return;
}
const result = await collectAssetReferences(
index,
q.get("id") ?? "",
q.get("type"),
options.loadFile,
forwardRefOptionsFrom(q),
);
sendJson(res, 200, { index: status, data: result });
return;
}
case "/get_usage_guide":
sendText(res, 200, USAGE_GUIDE);
return;
default:
sendJson(res, 404, { error: `Not found: ${url.pathname}` });
}
}
/** Starts a local HTTP server; resolves once it is listening. */
export async function startLocalServer(
options: LocalServerOptions,
): Promise<LocalServerHandle> {
const token = options.token ?? randomToken();
const server = createServer((req, res) => {
void handle(options, token, req, res).catch((err) => {
sendJson(res, 500, {
error: err instanceof Error ? err.message : String(err),
});
});
});
await new Promise<void>((resolveListen, reject) => {
server.once("error", reject);
server.listen(options.port ?? 0, "127.0.0.1", () => resolveListen());
});
const address = server.address() as AddressInfo;
return {
port: address.port,
token,
close: () =>
new Promise<void>((resolveClose, rejectClose) => {
server.close((err) => (err ? rejectClose(err) : resolveClose()));
}),
};
}
function randomToken(): string {
return `ra3-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
+379
View File
@@ -0,0 +1,379 @@
/**
* Minimal MCP (Model Context Protocol) stdio server exposing the RA3 Mod XML
* agent snapshot query API.
*
* This is intentionally dependency-free. It speaks the JSON-RPC-over-stdio
* subset used by MCP clients:
*
* initialize
* notifications/initialized
* ping
* tools/list
* tools/call
*
* Usage:
* node out/agent/mcpServer.js --project D:/Mods/Example
* node out/agent/mcpServer.js --snapshot /path/to/snapshot.json.gz
*/
import { createInterface } from "node:readline";
import { LiveClient } from "./liveClient";
import { pruneInstances } from "./instances";
import { readSnapshotFile, snapshotPathForProject } from "./snapshot";
import {
findAssets,
findDefine,
findReferenceGroups,
isFileActive,
listAssetsByType,
resolveIncludeSource,
statusFromSnapshot,
} from "./query";
import type { AgentIndexSnapshot } from "./types";
interface McpTool {
name: string;
description: string;
inputSchema: Record<string, unknown>;
handler: (args: Record<string, unknown>, snapshot: AgentIndexSnapshot | null) => unknown;
}
const USAGE_GUIDE = `RA3 Mod XML index query tools
This MCP server exposes the semantic index built by the RA3 Mod XML VS Code extension.
Use these tools instead of full-text grepping the XML tree when you need exact facts:
- find_asset(id, type?) -> definition sites (file/line/origin/stream)
- find_references(id, type?) -> semantic reference sites
- get_asset_references(id, type?, depth?, targetTypes?) -> outgoing reference EDGES with element context
- list_assets_by_type(type, prefix?, limit?) -> assets of a type
- is_file_active(path) -> whether a file is part of an indexed include stream
- find_define(name) -> $DEFINE definitions
- resolve_include(source) -> candidate source file
- list_projects() -> project roots the live extension has indexed
- get_status() -> current index state
Tips:
- Asset ids are case-insensitive.
- Prefer passing type when the same id exists for multiple asset types.
- Always check the returned index state; if it is stale/incomplete, treat results as provisional.
- Use get_asset_references to follow "which weapon/model/upgrade does this asset use" chains.
It returns edges annotated with the element name, parent element and attribute that produced
them, plus the exact XML file/line, so you do not have to read source to find the link.
Start with depth 1 (the default) and pass targetTypes (e.g. ["WeaponTemplate"]) to cut noise.
Edges with a "definedIn" field come from an inheritFrom ancestor's XML.
When "truncated" is true, read "omittedByTargetType" and narrow the query instead of retrying.
- Do not attempt to read the entire snapshot file; query narrowly.`;
const TOOLS: McpTool[] = [
{
name: "get_status",
description: "Returns the current index state and basic statistics.",
inputSchema: { type: "object", properties: {} },
handler: (_args, snapshot) => statusFromSnapshot(snapshot),
},
{
name: "find_asset",
description: "Finds asset definitions by id, optionally filtered by asset type.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Asset id to find" },
type: { type: "string", description: "Optional asset type filter" },
},
required: ["id"],
},
handler: (args, snapshot) => {
const id = String(args.id ?? "");
if (!snapshot) return statusFromSnapshot(snapshot);
return {
index: statusFromSnapshot(snapshot),
data: findAssets(snapshot, id, args.type ? String(args.type) : null),
};
},
},
{
name: "find_references",
description: "Finds semantic reference sites pointing to an asset id, optionally filtered by type.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Asset id whose references to find" },
type: { type: "string", description: "Optional asset type filter" },
},
required: ["id"],
},
handler: (args, snapshot) => {
const id = String(args.id ?? "");
if (!snapshot) return statusFromSnapshot(snapshot);
return {
index: statusFromSnapshot(snapshot),
data: findReferenceGroups(snapshot, id, args.type ? String(args.type) : null),
};
},
},
{
name: "list_assets_by_type",
description: "Lists asset definitions of one type, optionally filtered by id prefix.",
inputSchema: {
type: "object",
properties: {
type: { type: "string", description: "Asset type" },
prefix: { type: "string", description: "Optional id prefix" },
limit: { type: "number", description: "Maximum number of results" },
},
required: ["type"],
},
handler: (args, snapshot) => {
const type = String(args.type ?? "");
if (!snapshot) return statusFromSnapshot(snapshot);
const limit = typeof args.limit === "number" ? args.limit : undefined;
return {
index: statusFromSnapshot(snapshot),
data: listAssetsByType(snapshot, type, args.prefix ? String(args.prefix) : "", limit),
};
},
},
{
name: "is_file_active",
description: "Returns whether a file belongs to an indexed include stream (i.e. is not a dead file).",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Absolute file path" },
},
required: ["path"],
},
handler: (args, snapshot) => {
const path = String(args.path ?? "");
if (!snapshot) return statusFromSnapshot(snapshot);
return {
index: statusFromSnapshot(snapshot),
data: { active: isFileActive(snapshot, path) },
};
},
},
{
name: "find_define",
description: "Finds $DEFINE constants by name.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Define name (with or without leading $)" },
},
required: ["name"],
},
handler: (args, snapshot) => {
const name = String(args.name ?? "").replace(/^\$/, "");
if (!snapshot) return statusFromSnapshot(snapshot);
return {
index: statusFromSnapshot(snapshot),
data: findDefine(snapshot, name),
};
},
},
{
name: "resolve_include",
description: "Resolves an Include source string from the snapshot's candidate list.",
inputSchema: {
type: "object",
properties: {
source: { type: "string", description: "Include source, e.g. DATA:Units/Example.xml" },
},
required: ["source"],
},
handler: (args, snapshot) => {
const source = String(args.source ?? "");
if (!snapshot) return statusFromSnapshot(snapshot);
return {
index: statusFromSnapshot(snapshot),
data: resolveIncludeSource(snapshot, source),
};
},
},
{
name: "get_asset_references",
description:
"Returns the outgoing references (edges) of an asset: which assets it references, through which element/attribute, and at which file/line. Also follows inheritFrom ancestors (marked with definedIn). Live index required.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Asset id whose outgoing references to return" },
type: { type: "string", description: "Optional asset type filter" },
depth: {
type: "number",
description:
"Levels of assets to expand: 1 (default) = the asset itself, including inherited XML; max 3.",
},
targetTypes: {
type: "array",
items: { type: "string" },
description:
"Only keep edges whose target is assignable to one of these types, e.g. [\"WeaponTemplate\"].",
},
maxEdges: { type: "number", description: "Hard cap on returned edges (default 200)." },
includeUnresolved: {
type: "boolean",
description: "Also return edges whose reference value could not be resolved.",
},
},
required: ["id"],
},
// Live-only: element context is not stored in the on-disk snapshot.
handler: () => ({
index: { state: "no_index" },
error:
"get_asset_references requires a live index. Open the project in VS Code (with AI Agent access enabled) and retry.",
}),
},
{
name: "list_projects",
description:
"Lists the project roots the live extension currently has indexed. Use it to discover which projects this server can answer for.",
inputSchema: { type: "object", properties: {} },
handler: () => ({
index: { state: "no_index" },
error:
"list_projects requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.",
}),
},
{
name: "get_usage_guide",
description: "Returns guidance for using the RA3 Mod XML index tools.",
inputSchema: { type: "object", properties: {} },
handler: () => ({ text: USAGE_GUIDE }),
},
];
/** Tools that can only be answered by the live extension server. */
const LIVE_ONLY_TOOLS = new Set(["get_asset_references", "list_projects"]);
function sendMessage(message: unknown): void {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function resultFor(id: unknown, result: unknown): unknown {
return { jsonrpc: "2.0", id, result };
}
function errorFor(id: unknown, code: number, message: string): unknown {
return { jsonrpc: "2.0", id, error: { code, message } };
}
async function handleRequest(
message: Record<string, unknown>,
snapshot: AgentIndexSnapshot | null,
projectDir: string | null,
live: LiveClient,
): Promise<unknown | null> {
const method = String(message.method ?? "");
const id = message.id;
const params = (message.params ?? {}) as Record<string, unknown>;
switch (method) {
case "initialize":
return resultFor(id, {
protocolVersion: params.protocolVersion ?? "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "ra3-mod-xml", version: "0.1.0" },
});
case "ping":
return resultFor(id, {});
case "tools/list":
return resultFor(id, {
tools: TOOLS.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
});
case "tools/call": {
const toolName = String(params.name ?? "");
const tool = TOOLS.find((t) => t.name === toolName);
if (!tool) return errorFor(id, -32602, `Unknown tool: ${toolName}`);
const args = (params.arguments ?? {}) as Record<string, unknown>;
const result = await live.query(toolName, args);
if (result?.mismatched) {
// The server answered for another project. Refuse it: a plausible
// wrong answer is worse than an explicit failure.
live.markUnavailable();
return textResult(id, {
index: { state: "error", projectDir: projectDir ?? undefined },
error: `The live server answered for a different project than "${projectDir}"; refusing the result. Re-run "RA3 Mod XML: Enable AI Agent access…" for this project.`,
});
}
if (LIVE_ONLY_TOOLS.has(toolName)) {
// Report a clear reason instead of an empty result, so the agent does
// not conclude "this asset has no references".
return textResult(
id,
result?.payload ?? {
index: { state: "no_index", projectDir: projectDir ?? undefined },
error: `"${toolName}" requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.`,
},
);
}
const output = result?.payload ?? tool.handler(args, snapshot);
return textResult(id, output);
}
default:
// Notifications have no id; ignore them.
if (id === undefined) return null;
return errorFor(id, -32601, `Method not found: ${method}`);
}
}
/** Wraps any tool payload into an MCP text content result. */
function textResult(id: unknown, payload: unknown): unknown {
const text = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
return resultFor(id, { content: [{ type: "text", text }] });
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
let projectDir: string | null = null;
let snapshotPath: string | null = null;
let agentHome: string | undefined;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--project" || args[i] === "-p") projectDir = args[++i] ?? null;
else if (args[i] === "--snapshot" || args[i] === "-s") snapshotPath = args[++i] ?? null;
else if (args[i] === "--agent-home") agentHome = args[++i] ?? undefined;
}
const resolvedSnapshotPath =
snapshotPath ??
(projectDir ? snapshotPathForProject(projectDir, agentHome) : null);
let snapshot: AgentIndexSnapshot | null = null;
if (resolvedSnapshotPath) snapshot = await readSnapshotFile(resolvedSnapshotPath);
// One-shot crash cleanup: a window that died without disposing leaves its
// instance file behind, and whichever instance starts next prunes it.
void pruneInstances(agentHome).catch(() => undefined);
const live = new LiveClient({ projectDir, agentHome });
const rl = createInterface({
input: process.stdin,
crlfDelay: Infinity,
});
rl.on("line", (line) => {
if (!line.trim()) return;
let message: Record<string, unknown>;
try {
message = JSON.parse(line) as Record<string, unknown>;
} catch {
return;
}
void handleRequest(message, snapshot, projectDir, live).then((response) => {
if (response != null) sendMessage(response);
});
});
}
// Only run the stdio loop when executed directly, so the module stays
// importable by tests.
if (typeof require !== "undefined" && require.main === module) {
void main();
}
+61
View File
@@ -0,0 +1,61 @@
/**
* One-time "this version can expose the index to AI agents" notification.
*
* Pure decision logic (no VS Code dependency) so the anti-nag rules can be
* unit tested:
*
* - a fresh install is informed once;
* - an upgrade from a build that predates the feature is informed once;
* - an upgrade from a build that already had the feature stays silent;
* - "Don't show again" (and simply ignoring the message) is remembered, so
* the prompt never becomes a recurring nag.
*/
/** Extension version that introduced AI Agent access. */
export const AGENT_FEATURE_VERSION = "0.1.26";
export interface AgentOnboardingState {
/** Extension version whose notification was already shown. */
informedVersion?: string;
/** The user explicitly chose "Don't show again". */
dismissed?: boolean;
}
/**
* True when the AI Agent introduction should be shown for this version.
*/
export function shouldOfferAgentOnboarding(
state: AgentOnboardingState | undefined,
currentVersion: string,
): boolean {
if (state?.dismissed) return false;
if (!state?.informedVersion) return true; // First run with this feature.
if (state.informedVersion === currentVersion) return false;
// Already informed by a build that had the feature: never repeat.
// Only upgrades from before the feature introduce it once.
return compareVersions(state.informedVersion, AGENT_FEATURE_VERSION) < 0;
}
/**
* Compares dotted numeric versions ("1.2.3" > "1.2"). Non-numeric segments
* count as 0, and a missing segment is smaller than a present one, so
* "0.1.26" > "0.1" and "0.1.26" > "0.1.26-beta".
*/
export function compareVersions(a: string, b: string): number {
const parse = (value: string): number[] =>
String(value)
.split(".")
.map((part) => {
const match = /^(\d+)/.exec(part.trim());
return match ? Number(match[1]) : 0;
});
const left = parse(a);
const right = parse(b);
const length = Math.max(left.length, right.length);
for (let i = 0; i < length; i++) {
const l = left[i] ?? 0;
const r = right[i] ?? 0;
if (l !== r) return l < r ? -1 : 1;
}
return 0;
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Query helpers over the stable AgentIndexSnapshot.
*
* Pure TypeScript and dependency-free, so the same functions can back a CLI,
* MCP tools, or a local HTTP API.
*/
import { resolve } from "node:path";
import type { AssetDef, ReferenceSite } from "../indexer/types";
import type {
AgentDefine,
AgentIndexSnapshot,
AgentIndexStatus,
AgentReferenceGroup,
AgentStream,
} from "./types";
function normalizePath(p: string): string {
return resolve(p).replace(/\\/g, "/").toLowerCase();
}
/** Returns all asset definitions whose id equals `id`, optionally filtered by type. */
export function findAssets(
snapshot: AgentIndexSnapshot,
id: string,
type?: string | null,
): AssetDef[] {
const wanted = id.toLowerCase();
const wantedType = type?.toLowerCase();
const out: AssetDef[] = [];
for (const asset of snapshot.assets) {
if (asset.id.toLowerCase() !== wanted) continue;
if (wantedType && asset.type.toLowerCase() !== wantedType) continue;
out.push(asset);
}
return out;
}
/** Returns asset definitions whose type and id prefix match. */
export function listAssetsByType(
snapshot: AgentIndexSnapshot,
type: string,
idPrefix = "",
limit?: number,
): AssetDef[] {
const wantedType = type.toLowerCase();
const wantedPrefix = idPrefix.toLowerCase();
const out: AssetDef[] = [];
for (const asset of snapshot.assets) {
if (asset.type.toLowerCase() !== wantedType) continue;
if (!asset.id.toLowerCase().startsWith(wantedPrefix)) continue;
out.push(asset);
if (limit != null && out.length >= limit) break;
}
return out;
}
/** Returns reference groups pointing to definitions matching `id` and optional `type`. */
export function findReferenceGroups(
snapshot: AgentIndexSnapshot,
id: string,
type?: string | null,
): AgentReferenceGroup[] {
const wanted = id.toLowerCase();
const wantedType = type?.toLowerCase();
return snapshot.references.filter((r) => {
if (r.id.toLowerCase() !== wanted) return false;
if (wantedType && r.type.toLowerCase() !== wantedType) return false;
return true;
});
}
/** Flattens reference groups into plain reference sites. */
export function findReferenceSites(
snapshot: AgentIndexSnapshot,
id: string,
type?: string | null,
): ReferenceSite[] {
return findReferenceGroups(snapshot, id, type).flatMap((g) => g.sites);
}
/** True when a file belongs to at least one indexed stream. */
export function isFileActive(snapshot: AgentIndexSnapshot, file: string): boolean {
const key = normalizePath(file);
return snapshot.streams.some((s) =>
s.files.some((candidate) => normalizePath(candidate) === key),
);
}
/** Returns the streams that contain a file. */
export function streamsForFile(
snapshot: AgentIndexSnapshot,
file: string,
): AgentStream[] {
const key = normalizePath(file);
return snapshot.streams.filter((s) =>
s.files.some((candidate) => normalizePath(candidate) === key),
);
}
/** Finds a define by case-insensitive name. */
export function findDefine(
snapshot: AgentIndexSnapshot,
name: string,
): AgentDefine[] {
const wanted = name.toLowerCase();
return snapshot.defines.filter((d) => d.name.toLowerCase() === wanted);
}
/** Resolves an Include source using the snapshot's candidate list. */
export function resolveIncludeSource(
snapshot: AgentIndexSnapshot,
source: string,
): { source: string; path: string } | null {
const wanted = source.toLowerCase();
const hit = snapshot.sourceCandidates.find(
(c) => c.source.toLowerCase() === wanted,
);
return hit ? { source: hit.source, path: hit.path } : null;
}
/** Returns the snapshot's status (ready_xml/ready/stale). */
export function statusFromSnapshot(
snapshot: AgentIndexSnapshot | null | undefined,
): AgentIndexStatus {
if (!snapshot) {
return { state: "no_index", detail: "No index snapshot is available." };
}
const state: AgentIndexStatus["state"] = snapshot.stale
? "stale"
: !snapshot.complete
? "ready_xml"
: "ready";
return {
state,
projectDir: snapshot.projectDir,
phase: snapshot.phase,
complete: snapshot.complete,
stale: snapshot.stale,
generatedAt: snapshot.generatedAt,
buildId: snapshot.buildId,
stats: snapshot.stats,
};
}
/** Convenience aggregate returned by MCP/CLI query tools. */
export interface QueryResult<T> {
index: AgentIndexStatus;
data: T;
}
/** Wraps any query data with current index status. */
export function withStatus<T>(snapshot: AgentIndexSnapshot | null, data: T): QueryResult<T> {
return {
index: statusFromSnapshot(snapshot),
data,
};
}
+256
View File
@@ -0,0 +1,256 @@
/**
* Runtime resolution for the agent launcher and CLI.
*
* The extension must not assume the user has Node installed. VS Code ships an
* Electron binary that can run as a plain Node process when launched with
* `ELECTRON_RUN_AS_NODE=1`, which makes it a zero-dependency runtime that is
* already present wherever the extension is installed.
*
* Verified on Windows with VS Code 1.135.0 / Electron 42.8.1 (Node 24.18.1):
* `fs`, `fs/promises`, `path`, `os`, `http`, `readline`, `zlib`, `crypto`,
* `util`, `net`, `child_process` and even `node:test` are all available, and
* the bundled `dist/agent/mcpServer.js` completes a full stdio MCP session.
*
* Pure TypeScript: no VS Code dependency, so the same rules are used by the
* launcher generator, the CLI and the tests.
*/
import { existsSync } from "node:fs";
const isWin = process.platform === "win32";
const isMac = process.platform === "darwin";
export type AgentRuntimeKind = "electron" | "node";
export interface AgentRuntime {
kind: AgentRuntimeKind;
/**
* Executable to spawn. For `electron` this is the VS Code / Electron binary
* (or a bare product name when only a hint is known).
*/
executable: string;
/** Extra environment variables required to run the executable as Node. */
env: Record<string, string>;
/**
* True when the executable is a bare command name resolved through PATH
* rather than an absolute path.
*/
viaPath: boolean;
}
/**
* True when the current process is a VS Code / Electron host.
*
* Desktop VS Code runs its extension host as the Electron binary with
* `ELECTRON_RUN_AS_NODE=1`, so `process.versions.electron` is set and
* `process.execPath` points at the VS Code executable. That is exactly the
* binary the launcher wants.
*/
export function isElectronHost(): boolean {
return typeof process.versions.electron === "string";
}
/**
* Resolves the runtime to bake into the launcher.
*
* Returns the Electron runtime when running inside VS Code (the normal case),
* and falls back to a PATH-resolved `node` otherwise (e.g. when the launcher
* is generated from a plain Node CLI or a unit test).
*/
export function resolveRuntime(): AgentRuntime {
if (isElectronHost()) {
return {
kind: "electron",
executable: process.execPath,
env: { ELECTRON_RUN_AS_NODE: "1" },
viaPath: false,
};
}
return { kind: "node", executable: "node", env: {}, viaPath: true };
}
/**
* Builds the runtime descriptor for an explicit Electron/VSCode executable.
* Used when the extension knows the host path but is not itself running as
* Electron, and by tests.
*/
export function electronRuntime(executable: string): AgentRuntime {
return {
kind: "electron",
executable,
env: { ELECTRON_RUN_AS_NODE: "1" },
viaPath: false,
};
}
/**
* Product names that indicate a VS Code-family Electron binary.
*
* Covers both the branded Windows/Linux launchers (`Code.exe`, `code-oss`,
* `codium`, `cursor`) and the macOS bundle executable, which is literally
* named `Electron`.
*/
const ELECTRON_PRODUCT_NAMES = [
"code",
"code-insiders",
"codium",
"cursor",
"electron",
];
/**
* Best-effort candidates for a VS Code / Electron binary when running under a
* plain Node process. These are only fallbacks: the authoritative path always
* comes from `process.execPath` inside the extension host.
*/
export function electronExecutableCandidates(): string[] {
const candidates: string[] = [];
if (isWin && process.env.LOCALAPPDATA) {
candidates.push(
`${process.env.LOCALAPPDATA}\\Programs\\Microsoft VS Code\\Code.exe`,
`${process.env.LOCALAPPDATA}\\Programs\\Microsoft VS Code Insiders\\Code - Insiders.exe`,
);
}
if (isWin && process.env.PROGRAMFILES) {
candidates.push(`${process.env.PROGRAMFILES}\\Microsoft VS Code\\Code.exe`);
}
if (isMac) {
candidates.push("/Applications/Visual Studio Code.app/Contents/MacOS/Electron");
} else if (!isWin) {
candidates.push("/usr/share/code/code", "/usr/bin/code");
}
return candidates;
}
/** The first existing Electron candidate, or null. */
export function findElectronExecutable(): string | null {
for (const candidate of electronExecutableCandidates()) {
try {
if (existsSync(candidate)) return candidate;
} catch {
// ignore
}
}
return null;
}
/** Renders a path for a POSIX shell single-quoted string. */
function shQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
/** Renders a path for a Windows `cmd.exe` double-quoted `set` value. */
function cmdValue(value: string): string {
// `set "VAR=value"` keeps quotes out of the value; embedded `"` would break
// it, so strip them rather than emit a broken script.
return value.replace(/"/g, "");
}
export interface LauncherScriptOptions {
runtime: AgentRuntime;
/** Absolute path to the bundled MCP server entry. */
serverPath: string;
/** Project root passed to the MCP server. */
projectDir: string;
/**
* Optional absolute path to a `node` executable used when the Electron
* binary is missing. When omitted the script falls back to `node` on PATH.
*/
nodeFallback?: string | null;
}
/**
* Generates the stable launcher script.
*
* The script prefers the Electron runtime (works without Node installed) and
* falls back to Node only when that binary is gone, so a moved/uninstalled
* VS Code does not leave the user with a dead launcher.
*/
export function launcherScript(
options: LauncherScriptOptions,
platform: NodeJS.Platform = process.platform,
): string {
const { runtime, serverPath, projectDir } = options;
const node = options.nodeFallback ?? "node";
const paths = { serverPath: cmdValue(serverPath), projectDir: cmdValue(projectDir) };
if (platform === "win32") {
// A `goto` jump is used instead of a parenthesised `if (...)` block:
// inside such a block `%errorlevel%` is expanded when the whole block is
// parsed, not when each command runs, so the exit code would be wrong.
const lines = ["@echo off", "setlocal"];
if (runtime.kind === "node") {
// No Electron available: the launcher is Node-only.
lines.push(
`set "RA3_NODE=${cmdValue(node)}"`,
`set "RA3_SERVER=${paths.serverPath}"`,
`set "RA3_PROJECT=${paths.projectDir}"`,
'"%RA3_NODE%" "%RA3_SERVER%" --project "%RA3_PROJECT%"',
"exit /b %errorlevel%",
);
return lines.join("\r\n") + "\r\n";
}
lines.push(
`set "RA3_RUNTIME=${cmdValue(runtime.executable)}"`,
`set "RA3_SERVER=${paths.serverPath}"`,
`set "RA3_PROJECT=${paths.projectDir}"`,
`set "RA3_NODE=${cmdValue(node)}"`,
'if not exist "%RA3_RUNTIME%" goto :ra3_node',
"set ELECTRON_RUN_AS_NODE=1",
'"%RA3_RUNTIME%" "%RA3_SERVER%" --project "%RA3_PROJECT%"',
"exit /b %errorlevel%",
":ra3_node",
'"%RA3_NODE%" "%RA3_SERVER%" --project "%RA3_PROJECT%"',
"exit /b %errorlevel%",
);
return lines.join("\r\n") + "\r\n";
}
const lines = ["#!/usr/bin/env sh"];
if (runtime.kind === "node") {
lines.push(
`exec ${shQuote(node)} ${shQuote(serverPath)} --project ${shQuote(projectDir)}`,
);
} else {
lines.push(
`RA3_RUNTIME=${shQuote(runtime.executable)}`,
`RA3_SERVER=${shQuote(serverPath)}`,
`RA3_PROJECT=${shQuote(projectDir)}`,
'if [ -x "$RA3_RUNTIME" ]; then',
' ELECTRON_RUN_AS_NODE=1 exec "$RA3_RUNTIME" "$RA3_SERVER" --project "$RA3_PROJECT"',
"fi",
`exec ${shQuote(node)} "$RA3_SERVER" --project "$RA3_PROJECT"`,
);
}
return lines.join("\n") + "\n";
}
/**
* True when the launcher is able to run without a Node installation: the
* Electron runtime is used unconditionally (no PATH lookup of `node`).
*
* Used by tests and by the enable flow to warn when the launcher would depend
* on Node being installed.
*/
export function isNodeFreeLauncher(script: string, platform: NodeJS.Platform = process.platform): boolean {
if (platform === "win32") {
return (
script.includes("ELECTRON_RUN_AS_NODE=1") &&
script.includes('set "RA3_RUNTIME=') &&
// The Node path must only be reachable through the guard jump.
script.includes('if not exist "%RA3_RUNTIME%" goto :ra3_node') &&
!/^\s*"node"\s/m.test(script)
);
}
return (
script.includes("ELECTRON_RUN_AS_NODE=1") &&
script.includes("RA3_RUNTIME=") &&
!/^exec node /m.test(script)
);
}
/** Product names that indicate a VS Code-family Electron binary. */
export function looksLikeElectronExecutable(path: string): boolean {
const base = path.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? "";
return ELECTRON_PRODUCT_NAMES.some((name) => base.includes(name));
}
+359
View File
@@ -0,0 +1,359 @@
/**
* Helpers for creating the stable MCP launcher and MCP client configuration.
*
* The launcher lives outside the VS Code extension install directory (under
* ~/.ra3modxml) so AI client configs do not break when the extension is
* updated to a new version. The extension refreshes the launcher on every
* activation/update.
*
* Pure TypeScript: no VS Code dependency.
*/
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { defaultAgentHome } from "./snapshot";
import {
launcherScript,
resolveRuntime,
type AgentRuntime,
} from "./runtime";
/** Key this extension uses for its MCP server entry in every client config. */
export const MCP_SERVER_KEY = "ra3-mod-xml";
export interface McpConfigTarget {
id: string;
label: string;
path: string;
}
/** Result of writing the stable launcher. */
export interface LauncherResult {
path: string;
serverPath: string;
runtime: AgentRuntime;
/**
* True when the launcher runs on the VS Code Electron runtime and therefore
* does not need a Node installation.
*/
nodeFree: boolean;
}
/** File name of the stable launcher on the current platform. */
export function launcherFileName(): string {
return process.platform === "win32" ? "ra3-mod-xml-mcp.cmd" : "ra3-mod-xml-mcp";
}
/** Absolute path to the stable launcher under the agent home. */
export function launcherPath(agentHome = defaultAgentHome()): string {
return join(agentHome, launcherFileName());
}
/**
* Path to the bundled MCP server inside an extension install/dev directory.
* The packaged extension ships this file under dist/agent/mcpServer.js.
*/
export function bundledMcpServerPath(extensionRoot: string): string {
return join(extensionRoot, "dist", "agent", "mcpServer.js");
}
/**
* Creates the stable launcher script. It prefers the VS Code Electron runtime
* (so no Node installation is required) and falls back to Node only when that
* binary is missing.
*
* Returns the launcher path plus the resolved runtime, so callers can warn
* when the launcher will depend on Node being on PATH.
*/
export async function writeLauncher(
extensionRoot: string,
projectDir: string,
agentHome = defaultAgentHome(),
runtime?: AgentRuntime,
): Promise<LauncherResult> {
const server = bundledMcpServerPath(extensionRoot);
const launcher = launcherPath(agentHome);
const resolved = runtime ?? resolveRuntime();
const script = launcherScript({
runtime: resolved,
serverPath: server,
projectDir,
});
await mkdir(dirname(launcher), { recursive: true });
await writeFile(launcher, script, "utf8");
if (process.platform !== "win32") {
await chmod(launcher, 0o755);
}
return {
path: launcher,
serverPath: server,
runtime: resolved,
nodeFree: resolved.kind === "electron",
};
}
/** MCP client config entry for one project. */
export function mcpServerConfig(
launcher: string,
projectDir: string,
): Record<string, unknown> {
return {
mcpServers: {
"ra3-mod-xml": {
command: launcher,
args: ["--project", projectDir],
},
},
};
}
/** Human-readable JSON config block users can paste into AI clients. */
export function mcpConfigJson(
launcher: string,
projectDir: string,
): string {
return JSON.stringify(mcpServerConfig(launcher, projectDir), null, 2);
}
/** Claude Desktop config path (Windows/macOS/Linux common locations). */
export function claudeDesktopConfigPath(): string {
if (process.env.APPDATA) return join(process.env.APPDATA, "Claude", "claude_desktop_config.json");
return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
}
/** Cursor's global MCP config path. */
export function cursorGlobalConfigPath(): string {
return join(homedir(), ".cursor", "mcp.json");
}
/** Cursor's project-scoped MCP config path. */
export function cursorProjectConfigPath(projectDir: string): string {
return join(projectDir, ".cursor", "mcp.json");
}
/** Common local MCP config files this extension can offer to update. */
export function commonMcpConfigTargets(projectDir: string): McpConfigTarget[] {
return [
{
id: "claude-desktop",
label: "Claude Desktop",
path: claudeDesktopConfigPath(),
},
{
id: "cursor-global",
label: "Cursor (global)",
path: cursorGlobalConfigPath(),
},
{
id: "cursor-project",
label: "Cursor (current project)",
path: cursorProjectConfigPath(projectDir),
},
];
}
/**
* Adds the RA3 Mod XML MCP server entry to a JSON config file, preserving any
* existing keys and `mcpServers`. Creates the file when it does not exist.
*/
export async function addMcpServerToConfigFile(
filePath: string,
launcher: string,
projectDir: string,
serverKey = MCP_SERVER_KEY,
): Promise<void> {
let config: Record<string, unknown> = {};
try {
config = JSON.parse(await readFile(filePath, "utf8")) as Record<string, unknown>;
} catch {
// File absent or malformed: start fresh.
}
const servers = (config.mcpServers as Record<string, unknown> | undefined) ?? {};
servers[serverKey] = {
command: launcher,
args: ["--project", projectDir],
};
config.mcpServers = servers;
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
}
/**
* Removes this extension's server entry from a JSON config file, preserving
* every other server and top-level key. Handles both the widely used
* `mcpServers` container and VS Code's `servers` container, and deletes a
* container that becomes empty.
*
* Returns true only when an entry was actually removed; a missing, malformed
* or already-clean file is left untouched.
*/
export async function removeMcpServerFromConfigFile(
filePath: string,
serverKey = MCP_SERVER_KEY,
): Promise<boolean> {
let config: Record<string, unknown>;
try {
config = JSON.parse(await readFile(filePath, "utf8")) as Record<string, unknown>;
} catch {
return false;
}
let removed = false;
for (const containerKey of ["mcpServers", "servers"] as const) {
const container = config[containerKey];
if (!container || typeof container !== "object" || Array.isArray(container)) {
continue;
}
const servers = container as Record<string, unknown>;
if (!(serverKey in servers)) continue;
delete servers[serverKey];
removed = true;
if (Object.keys(servers).length === 0) delete config[containerKey];
}
if (!removed) return false;
await writeFile(filePath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
return true;
}
/** One MCP config file this extension wrote, so uninstall can undo exactly that. */
export interface McpInstallRecord {
path: string;
serverKey: string;
/** Top-level JSON container the entry was written under. */
format: "mcpServers" | "servers";
/** Human label (Claude Desktop / Cursor global / ...). */
label?: string;
sourceVersion?: string;
}
/** Path to the managed MCP config install record under the agent home. */
export function mcpInstallRecordPath(agentHome = defaultAgentHome()): string {
return join(agentHome, "mcp-install.json");
}
/** Reads the managed MCP config install record, or returns an empty list. */
export async function readMcpInstallRecord(
agentHome = defaultAgentHome(),
): Promise<McpInstallRecord[]> {
try {
const parsed = JSON.parse(
await readFile(mcpInstallRecordPath(agentHome), "utf8"),
) as { installed?: McpInstallRecord[] };
return Array.isArray(parsed.installed) ? parsed.installed : [];
} catch {
return [];
}
}
/** Writes the managed MCP config install record. */
export async function writeMcpInstallRecord(
installed: McpInstallRecord[],
agentHome = defaultAgentHome(),
): Promise<void> {
const file = mcpInstallRecordPath(agentHome);
await mkdir(dirname(file), { recursive: true });
await writeFile(file, JSON.stringify({ installed }, null, 2), "utf8");
}
export interface InstallMcpConfigOptions {
filePath: string;
launcher: string;
projectDir: string;
label?: string;
sourceVersion?: string;
serverKey?: string;
agentHome?: string;
}
/**
* Writes the server entry into a config file and remembers that we did, so
* "uninstall AI Agent integration" can remove exactly what this extension
* created without touching the user's other MCP servers.
*/
export async function installMcpServerConfigToFile(
options: InstallMcpConfigOptions,
): Promise<void> {
const serverKey = options.serverKey ?? MCP_SERVER_KEY;
await addMcpServerToConfigFile(
options.filePath,
options.launcher,
options.projectDir,
serverKey,
);
const agentHome = options.agentHome ?? defaultAgentHome();
const path = resolve(options.filePath);
const installed = (await readMcpInstallRecord(agentHome)).filter(
(r) => resolve(r.path).toLowerCase() !== path.toLowerCase(),
);
installed.push({
path,
serverKey,
format: "mcpServers",
label: options.label,
sourceVersion: options.sourceVersion,
});
await writeMcpInstallRecord(installed, agentHome);
}
export interface UninstallMcpConfigOptions {
/** Also scan the conventional client paths for this project. */
projectDir?: string | null;
agentHome?: string;
serverKey?: string;
}
/**
* Removes the server entry from every config file this extension recorded,
* plus the conventional client config paths for `projectDir` (which covers
* configs written before the install record existed).
*
* Returns the paths that actually changed. Missing/malformed files count as
* "nothing to remove" and are not reported.
*/
export async function uninstallMcpServerConfigs(
options: UninstallMcpConfigOptions = {},
): Promise<string[]> {
const serverKey = options.serverKey ?? MCP_SERVER_KEY;
const agentHome = options.agentHome ?? defaultAgentHome();
const record = await readMcpInstallRecord(agentHome);
const candidates = new Map<string, string>();
for (const entry of record) {
if (entry.serverKey && entry.serverKey !== serverKey) continue;
candidates.set(resolve(entry.path).toLowerCase(), entry.path);
}
if (options.projectDir) {
for (const target of commonMcpConfigTargets(options.projectDir)) {
candidates.set(resolve(target.path).toLowerCase(), target.path);
}
}
const changed: string[] = [];
for (const path of candidates.values()) {
try {
if (await removeMcpServerFromConfigFile(path, serverKey)) changed.push(path);
} catch {
// Best effort; keep going through the remaining files.
}
}
const changedKeys = new Set(changed.map((p) => resolve(p).toLowerCase()));
await writeMcpInstallRecord(
record.filter((r) => !changedKeys.has(resolve(r.path).toLowerCase())),
agentHome,
);
return changed;
}
/** Deletes the stable MCP launcher. Returns false when nothing was removable. */
export async function removeLauncher(agentHome = defaultAgentHome()): Promise<boolean> {
try {
await rm(launcherPath(agentHome), { force: true });
return true;
} catch {
return false;
}
}
/** Default path used for the agent home. */
export function defaultAgentHomeForSetup(): string {
return join(homedir(), ".ra3modxml");
}
+496
View File
@@ -0,0 +1,496 @@
/**
* Agent Skill generator/installer for the RA3 Mod XML MCP tools.
*
* The Skill focuses on the functionality itself: when to use it and how to
* use the RA3 Mod XML MCP query tools. It intentionally avoids referencing
* any project-specific docs (e.g. docs/codebase-navigation-guide.md) so the
* agent is not distracted by unrelated workspace guidance.
*
* Pure TypeScript: no VS Code dependency.
*/
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { defaultAgentHome } from "./snapshot";
export const SKILL_NAME = "ra3-mod-xml";
export const SKILL_MARKER_FILE = ".ra3modxml-skill.json";
const SKILL_MD = `---
name: ra3-mod-xml
description: Use the RA3 Mod XML semantic index to find asset definitions, references, outgoing references, active files, defines, and include sources in SAGE / BinaryAssetBuilder XML projects such as Command & Conquer: Red Alert 3 mods. Use this when you need exact facts about mod assets instead of guessing or full-text searching the XML tree.
---
# RA3 Mod XML Index
This skill provides access to the semantic index built by the RA3 Mod XML VS Code extension.
## When this skill applies
Use these tools only for SAGE / BinaryAssetBuilder mod XML projects — the kind
used by Command & Conquer: Red Alert 3 mods.
Positive signals (any single one is enough to try the tools):
- \`Data/Mod.xml\` exists.
- \`Data/additionalmaps/mapmetadata_*.xml\` exists.
- A \`*.babproj\` file exists.
- XML whose root element is \`<AssetDeclaration>\`.
- XML that uses \`<Includes><Include source="DATA:…" /></Includes>\` or declares
\`xmlns="uri:ea.com:eala:asset"\`.
Do **not** use these tools for unrelated repositories. SAGE-looking XML, a
build script, copied \`.xsd\` schema files, or a folder named \`Data\` are not
by themselves evidence of a Red Alert 3 mod project: require at least one of
the positive signals above. When the repository is not a SAGE / RA3 mod
project, ignore this skill entirely.
If you are unsure whether the current project is in scope, call \`get_status\`
first: it is cheap and reports the \`projectDir\` the index belongs to. Stop
using the index tools when:
- the state is \`no_index\`, or
- the reported \`projectDir\` does not match the workspace you are working in.
In those cases read the files directly instead. Never present index results
from one project as if they belonged to another.
## When to use
Use this skill when you need any of the following:
- Find where an asset id is defined (GameObject, WeaponTemplate, Texture, etc.).
- Find which files/positions reference an asset id.
- Find which assets an asset references, and through which element/attribute.
- List assets of a particular type.
- Check whether a file is actually part of the active include graph (i.e. not a dead file).
- Resolve an Include source string.
- Look up a $DEFINE constant.
- Get the current index status and statistics.
Do not use full-text search over the XML tree when one of the MCP query tools
can answer the question directly.
## Reaching the index
Work down this list and stop at the first step that works.
1. **The query tools are already in your tool list** (names like
\`find_asset\`, \`get_status\`). Use them directly. You do not need to
configure anything.
2. **The tools are not available, but you can run commands.** The index is
reachable without any MCP setup, because the MCP server speaks JSON-RPC over
stdio. Read \`~/.ra3modxml/index.json\` first: it is a small, stable
discovery manifest listing the live instances and their project roots.
- If it does not exist, live discovery has not been enabled on this
machine yet (or no VS Code window is running). Tell the user to open the
project in VS Code and run
"RA3 Mod XML: Enable AI Agent access…". Do not guess or search further.
- If it exists, use the launcher at \`~/.ra3modxml/ra3-mod-xml-mcp.cmd\`
(Windows) or \`~/.ra3modxml/ra3-mod-xml-mcp\` (elsewhere), or call the
bundled server directly through that instance's runtime. Feed it one
JSON-RPC request per line on stdin, for example:
\`\`\`
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"find_asset","arguments":{"id":"AthenaCannon","type":"GameObject"}}}
\`\`\`
A CLI with the same capabilities is bundled alongside the MCP server
(\`cli.js\` next to \`mcpServer.js\` in the extension's \`dist/agent/\`).
It is a plain Node script: run it with
\`node <path-to-cli.js> <command>\`. If Node is not installed, do not
install anything — run the same file with the VS Code runtime recorded in
the launcher instead:
\`ELECTRON_RUN_AS_NODE=1 <runtime executable from the launcher> <path-to-cli.js> <command>\`.
\`cli.js help\` lists the commands. The CLI answers from the live index
when VS Code is running and falls back to the last exported snapshot
otherwise. Commands that need element context (\`outgoing\`, \`projects\`)
require the live index and will say so explicitly instead of returning an
empty result.
3. **You can write configuration, if the user agrees.** You may add the MCP
server to your own client's configuration. Most clients only load MCP
servers at startup, so tell the user the change takes effect in a new session;
check what your own client supports before promising otherwise.
Never fabricate index results. If none of the steps work, say the index is
unavailable and read the XML files directly.
## How to use
1. Call \`get_status\` first when you are unsure whether an index is available,
current, or belongs to the project you are working in.
2. Use narrow queries:
- \`find_asset(id, type?)\` for definition locations.
- \`find_references(id, type?)\` for incoming semantic references.
- \`get_asset_references(id, type?, depth?, targetTypes?)\` for outgoing
references (what this asset uses, and where that link is written).
- \`list_assets_by_type(type, prefix?, limit?)\` for browsing assets.
- \`is_file_active(path)\` to determine whether a file is included in an indexed stream.
- \`find_define(name)\` for $DEFINE constants.
- \`resolve_include(source)\` for Include source candidates.
3. Asset ids are case-insensitive.
4. If an id exists for multiple asset types, pass the type filter to avoid mixing definitions.
5. If the returned index state is \`stale\`, \`ready_xml\`, or \`building\`, treat
results as provisional.
6. Never read or dump the whole index snapshot; query narrowly.
See [query-guide.md](./references/query-guide.md) for the full tool reference,
parameter defaults and result shapes.
## Following references with get_asset_references
Use \`get_asset_references\` to answer "which weapon / model / upgrade / die-object
does this asset actually use" without reading source first. It returns **edges**,
not a flat list, so provenance is preserved:
- \`from\` is the asset you asked about.
- \`to\` is the resolved definition (file + line), or null when unresolved.
- \`via.element\` / \`via.parent\` / \`via.attribute\` say exactly which XML
element and attribute created the link.
- \`source\` is the file/line where that link is written.
- \`definedIn\` is present when the link comes from an \`inheritFrom\` ancestor's
XML rather than from the asset's own file. Inheritance is walked at the same
depth, so a base asset's weapon slot configuration is reported together with
the derived asset's own modules.
Practical rules:
- Start with the default \`depth: 1\` (the queried asset only). Raise it to 2 or 3
only for a specific node you already decided to follow. The maximum is 3.
- Pass \`targetTypes\` to cut noise, e.g. \`["WeaponTemplate"]\`, or
\`["GameObject"]\` for \`CreateObjectDie\`-style die-object links.
- If \`truncated\` is true, read \`omittedByTargetType\` and narrow the query
(smaller \`targetTypes\`, lower \`depth\`) instead of blindly retrying.
- Call \`find_references\` in the opposite direction: it tells you who else would
be affected by a change.
- Merged/inherited *effective values* are not computed. When \`xai:joinAction\`
(\`Replace\` / \`Remove\`) appears in the merge path, open the file and confirm
the real result yourself.
`;
const QUERY_GUIDE_MD = `# RA3 Mod XML query tool reference
The MCP server exposes these tools:
- get_status()
- find_asset(id, type?)
- find_references(id, type?)
- get_asset_references(id, type?, depth?, targetTypes?, maxEdges?, includeUnresolved?)
- list_assets_by_type(type, prefix?, limit?)
- is_file_active(path)
- find_define(name)
- resolve_include(source)
- list_projects()
- get_usage_guide()
\`get_asset_references\` and \`list_projects\` require a live index (VS Code
running with the project indexed). All other tools also answer from the last
exported snapshot when VS Code is closed.
## Result metadata
Every query result includes an \`index\` object:
\`\`\`json
{
"state": "ready",
"projectDir": "D:/Mods/Example",
"complete": true,
"stale": false
}
\`\`\`
Possible states:
- no_index: no index snapshot exists.
- building: a rebuild is in progress.
- ready_xml: XML assets are available but art assets may be incomplete.
- ready: complete index.
- stale: index may be outdated.
- error: last build failed.
Always check \`index.projectDir\`. If it does not match the project you are
working on, discard the result and read files directly instead.
## get_asset_references
Returns outgoing reference edges with full provenance:
\`\`\`json
{
"index": { "state": "ready", "projectDir": "D:/Mods/Example" },
"data": {
"roots": [{ "type": "GameObject", "id": "AthenaCannon", "file": "...", "line": 12 }],
"edges": [
{
"depth": 1,
"from": { "type": "GameObject", "id": "AthenaCannon" },
"to": { "type": "WeaponTemplate", "id": "AthenaCannonWeapon", "file": "...", "line": 88 },
"via": { "kind": "attribute", "element": "Weapon", "parent": "WeaponSlotHardpoint", "attribute": "Template" },
"source": { "file": "D:/Mods/Example/Data/Allied/Units/AthenaCannon.xml", "line": 40, "character": 24 }
}
],
"nodes": [],
"truncated": false,
"omittedByTargetType": {},
"warnings": []
}
}
\`\`\`
\`via.kind\` is one of \`attribute\`, \`content\` or \`inheritFrom\`. Edges carrying
\`definedIn\` come from an \`inheritFrom\` ancestor's XML.
This tool requires a **live** index (VS Code open with the project indexed).
It is not available from the on-disk snapshot because element context is not
stored there. When live is unavailable the tool returns an explicit error
instead of an empty result.
`;
export interface SkillInstallRecord {
/** Skill directory (the directory containing SKILL.md). */
path: string;
/** Extension version that installed/updated this copy. */
sourceVersion: string;
}
/**
* Writes a managed copy of the Skill into `targetDir` (the directory that
* should contain SKILL.md).
*/
export async function writeSkillTo(
targetDir: string,
sourceVersion: string,
): Promise<void> {
await mkdir(join(targetDir, "references"), { recursive: true });
await writeFile(join(targetDir, "SKILL.md"), SKILL_MD, "utf8");
await writeFile(
join(targetDir, "references", "query-guide.md"),
QUERY_GUIDE_MD,
"utf8",
);
const marker: SkillInstallRecord = {
path: targetDir,
sourceVersion,
};
await writeFile(
join(targetDir, SKILL_MARKER_FILE),
JSON.stringify(marker, null, 2),
"utf8",
);
}
/** Conventional ~/.agents/skills/<skill-name> path. */
export function agentsSkillsDirForUser(home = homedir()): string {
return join(home, ".agents", "skills", SKILL_NAME);
}
/** Conventional ~/.claude/skills/<skill-name> path (Claude Code). */
export function claudeSkillsDirForUser(home = homedir()): string {
return join(home, ".claude", "skills", SKILL_NAME);
}
/** Path to the managed-install record under the agent home. */
export function skillInstallRecordPath(agentHome = defaultAgentHome()): string {
return join(agentHome, "skill-install.json");
}
/** Reads the managed skill install record, or returns an empty list. */
export async function readSkillInstallRecord(
agentHome = defaultAgentHome(),
): Promise<SkillInstallRecord[]> {
try {
const text = await readFile(skillInstallRecordPath(agentHome), "utf8");
const parsed = JSON.parse(text) as { installed?: SkillInstallRecord[] };
return Array.isArray(parsed.installed) ? parsed.installed : [];
} catch {
return [];
}
}
/** Writes the managed skill install record. */
export async function writeSkillInstallRecord(
installed: SkillInstallRecord[],
agentHome = defaultAgentHome(),
): Promise<void> {
const file = skillInstallRecordPath(agentHome);
await mkdir(dirname(file), { recursive: true });
await writeFile(
file,
JSON.stringify({ installed }, null, 2),
"utf8",
);
}
/**
* Installs the Skill into several directories and records each managed copy.
* Returns the directories successfully written.
*/
export async function installSkillToDirectories(
directories: string[],
sourceVersion: string,
agentHome = defaultAgentHome(),
): Promise<string[]> {
const installed = await readSkillInstallRecord(agentHome);
const succeeded: string[] = [];
for (const dir of directories) {
try {
await writeSkillTo(dir, sourceVersion);
if (!installed.some((r) => r.path === dir)) {
installed.push({ path: dir, sourceVersion });
} else {
const record = installed.find((r) => r.path === dir);
if (record) record.sourceVersion = sourceVersion;
}
succeeded.push(dir);
} catch {
// Keep going; caller can surface per-directory failures.
}
}
await writeSkillInstallRecord(installed, agentHome);
return succeeded;
}
/** Removes one managed Skill directory and its install record entry. */
export async function uninstallSkillFromDirectory(
directory: string,
agentHome = defaultAgentHome(),
): Promise<void> {
await rm(directory, { recursive: true, force: true });
const installed = (await readSkillInstallRecord(agentHome)).filter(
(r) => r.path !== directory,
);
await writeSkillInstallRecord(installed, agentHome);
}
/**
* Reads the managed marker of a Skill directory.
*
* Returns null when the directory has no `.ra3modxml-skill.json`, or when the
* marker does not describe that exact directory. A hand-copied or
* third-party skill is therefore never treated as ours, which is what makes
* uninstall/update safe.
*/
export async function readSkillMarker(
directory: string,
): Promise<SkillInstallRecord | null> {
try {
const parsed = JSON.parse(
await readFile(join(directory, SKILL_MARKER_FILE), "utf8"),
) as SkillInstallRecord;
if (!parsed?.path) return null;
if (resolve(parsed.path) !== resolve(directory)) return null;
return parsed;
} catch {
return null;
}
}
export interface InstalledSkillStatus extends SkillInstallRecord {
/** The recorded directory currently exists. */
exists: boolean;
/** The directory still carries our marker (safe to update/remove). */
managed: boolean;
}
/** Status of every recorded Skill copy (drives the manage/uninstall UI). */
export async function installedSkillStatus(
agentHome = defaultAgentHome(),
): Promise<InstalledSkillStatus[]> {
const installed = await readSkillInstallRecord(agentHome);
const out: InstalledSkillStatus[] = [];
for (const record of installed) {
let exists = false;
try {
exists = (await stat(record.path)).isDirectory();
} catch {
exists = false;
}
const managed = exists ? (await readSkillMarker(record.path)) != null : false;
out.push({ ...record, exists, managed });
}
return out;
}
/**
* Removes recorded Skill copies that still carry our marker. Directories that
* are missing, replaced by the user, or lack the marker are skipped and
* reported instead of being deleted.
*/
export async function uninstallRecordedSkills(
directories: readonly string[],
agentHome = defaultAgentHome(),
): Promise<{ removed: string[]; skipped: string[] }> {
const removed: string[] = [];
const skipped: string[] = [];
for (const dir of directories) {
const marker = await readSkillMarker(dir);
if (!marker) {
skipped.push(dir);
continue;
}
try {
await uninstallSkillFromDirectory(dir, agentHome);
removed.push(dir);
} catch {
skipped.push(dir);
}
}
return { removed, skipped };
}
/**
* Drops record entries for directories that are already gone, without
* touching the filesystem. Used by the uninstall UI when a recorded copy no
* longer exists.
*/
export async function forgetSkillInstallRecords(
directories: readonly string[],
agentHome = defaultAgentHome(),
): Promise<void> {
const wanted = new Set(directories.map((d) => resolve(d)));
const installed = (await readSkillInstallRecord(agentHome)).filter(
(r) => !wanted.has(resolve(r.path)),
);
await writeSkillInstallRecord(installed, agentHome);
}
/**
* Re-writes every recorded Skill copy with the current extension version.
*
* Copies that no longer carry our marker are never overwritten (that would
* clobber user content), but stay in the record so the uninstall UI can still
* show them. Records whose directory disappeared are dropped.
*/
export async function syncInstalledSkills(
sourceVersion: string,
agentHome = defaultAgentHome(),
): Promise<SkillInstallRecord[]> {
const installed = await readSkillInstallRecord(agentHome);
const synced: SkillInstallRecord[] = [];
for (const record of installed) {
let exists = false;
try {
exists = (await stat(record.path)).isDirectory();
} catch {
exists = false;
}
if (!exists) continue; // Nothing left to manage.
if (!(await readSkillMarker(record.path))) {
synced.push(record); // User-owned now: keep the record, never rewrite.
continue;
}
try {
await writeSkillTo(record.path, sourceVersion);
synced.push({ path: record.path, sourceVersion });
} catch {
synced.push(record); // Keep so a later run can retry.
}
}
await writeSkillInstallRecord(synced, agentHome);
return synced;
}
+204
View File
@@ -0,0 +1,204 @@
/**
* Convert an internal ModIndex into a stable, external agent snapshot and
* read/write those snapshots on disk.
*
* Pure TypeScript: no VS Code dependency, so CLI/MCP/tools can reuse this
* module outside the extension.
*/
import { createHash } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { gunzip, gzip } from "node:zlib";
import { promisify } from "node:util";
import type { ModIndex } from "../indexer/types";
import {
AGENT_SNAPSHOT_SCHEMA_VERSION,
type AgentIndexSnapshot,
type AgentIndexStatus,
} from "./types";
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
/** Default directory used for launcher/snapshots/skill installation state. */
export function defaultAgentHome(): string {
return join(homedir(), ".ra3modxml");
}
/** Directory where current external snapshots are stored. */
export function defaultSnapshotDir(agentHome = defaultAgentHome()): string {
return join(agentHome, "snapshots");
}
/**
* Stable, collision-resistant identity for one project directory.
* Case-insensitive (Windows paths) and independent of the current drive
* mapping case, so the same project always maps to the same key.
*/
export function projectHash(projectDir: string): string {
return createHash("sha1")
.update(resolve(projectDir).toLowerCase(), "utf8")
.digest("hex")
.slice(0, 12);
}
/**
* Short, filesystem-safe, human-readable prefix for a project (directory
* basename, sanitized). Falls back to "project" when the basename has no
* usable characters.
*/
export function projectSlug(projectDir: string): string {
const raw = basename(resolve(projectDir));
const slug = raw
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
return slug || "project";
}
/**
* A stable, readable file name for one project:
* `<slug>-<sha1-12>` (e.g. `corona-9f3a1c2b4d5e`).
*/
export function snapshotBaseName(projectDir: string): string {
return `${projectSlug(projectDir)}-${projectHash(projectDir)}`;
}
/** Converts the internal ModIndex to the stable external snapshot shape. */
export function snapshotFromIndex(index: ModIndex, buildId?: number): AgentIndexSnapshot {
const assets: AgentIndexSnapshot["assets"] = [];
for (const byId of index.assets.values()) {
for (const defs of byId.values()) {
assets.push(...defs);
}
}
const defines: AgentIndexSnapshot["defines"] = [];
for (const defs of index.defines.values()) {
defines.push(...defs);
}
const references: AgentIndexSnapshot["references"] = [];
for (const [key, sites] of index.references) {
const parts = key.split("\u0000");
if (parts.length !== 4) continue;
references.push({
type: parts[0],
id: parts[1],
file: parts[2],
line: Number(parts[3]) || 0,
sites,
});
}
const streams: AgentIndexSnapshot["streams"] = index.streams.map((s) => ({
name: s.name,
entry: s.entry,
files: [...s.files],
}));
return {
schemaVersion: AGENT_SNAPSHOT_SCHEMA_VERSION,
projectDir: index.projectDir,
sdkDir: index.sdkDir,
phase: index.phase,
complete: index.complete,
stale: index.stale,
generatedAt: new Date().toISOString(),
buildId,
stats: {
assetCount: assets.length,
referenceCount: index.references.size,
defineCount: defines.length,
fileCount: index.files.size,
streamCount: streams.length,
sourceCandidateCount: index.sourceCandidates.length,
manifestFileCount: index.manifests.size,
manifestAssetCount: index.stats.manifestAssetCount,
},
assets,
defines,
references,
streams,
sourceCandidates: index.sourceCandidates,
diagnostics: index.diagnostics,
};
}
/** Returns a status object for a missing/not-yet-built index. */
export function noIndexStatus(projectDir?: string, detail?: string): AgentIndexStatus {
return {
state: "no_index",
projectDir,
detail: detail ?? "No index has been built yet.",
};
}
/** Returns a status object for the current index state. */
export function statusFromIndex(index: ModIndex | null | undefined, projectDir?: string): AgentIndexStatus {
if (!index) return noIndexStatus(projectDir);
const state: AgentIndexStatus["state"] = index.stale
? "stale"
: !index.complete
? "ready_xml"
: "ready";
return {
state,
projectDir: index.projectDir,
phase: index.phase,
complete: index.complete,
stale: index.stale,
generatedAt: new Date().toISOString(),
stats: {
assetCount: index.stats.assetCount,
referenceCount: index.stats.referenceCount,
defineCount: index.stats.defineCount,
fileCount: index.stats.indexedFiles,
streamCount: index.stats.streams,
sourceCandidateCount: index.stats.sourceCandidates,
manifestFileCount: index.stats.manifestFiles,
manifestAssetCount: index.stats.manifestAssetCount,
},
};
}
/** Serializes a snapshot to a JSON string (not compressed). */
export function snapshotToJson(snapshot: AgentIndexSnapshot): string {
return JSON.stringify(snapshot);
}
/** Writes a snapshot as gzip-compressed JSON using atomic temp+rename. */
export async function writeSnapshotFile(
filePath: string,
snapshot: AgentIndexSnapshot,
): Promise<string> {
const payload = Buffer.from(snapshotToJson(snapshot), "utf8");
const buf = await gzipAsync(payload);
const target = resolve(filePath);
await mkdir(dirname(target), { recursive: true });
const tmp = `${target}.tmp`;
await writeFile(tmp, buf);
await rename(tmp, target);
return target;
}
/** Reads a gzip-compressed JSON snapshot written by writeSnapshotFile. */
export async function readSnapshotFile(filePath: string): Promise<AgentIndexSnapshot | null> {
try {
const buf = await readFile(filePath);
const text = (await gunzipAsync(buf)).toString("utf8");
return JSON.parse(text) as AgentIndexSnapshot;
} catch {
return null;
}
}
/** Builds the conventional snapshot path for a project under the agent home. */
export function snapshotPathForProject(
projectDir: string,
agentHome = defaultAgentHome(),
): string {
return join(defaultSnapshotDir(agentHome), `${snapshotBaseName(projectDir)}.json.gz`);
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Public, stable data types for exposing RA3 Mod XML indexes to AI Agents
* and external tools.
*
* These types intentionally mirror the internal index model but use plain
* serializable arrays instead of Maps/Sets. They are independent of the VS
* Code API and of the extension's internal workspaceStorage layout.
*/
import type {
AssetDef,
DefineDef,
IndexerDiagnostic,
ReferenceSite,
SourceCandidate,
} from "../indexer/types";
/** Current external snapshot schema version. */
export const AGENT_SNAPSHOT_SCHEMA_VERSION = 1;
export type AgentIndexState =
| "no_index"
| "building"
| "ready_xml"
| "ready"
| "stale"
| "error";
export interface AgentIndexStats {
assetCount: number;
referenceCount: number;
defineCount: number;
fileCount: number;
streamCount: number;
sourceCandidateCount: number;
manifestFileCount: number;
manifestAssetCount: number;
}
export interface AgentAsset extends AssetDef {
// AssetDef is already plain/serializable.
}
export interface AgentDefine extends DefineDef {
// DefineDef is already plain/serializable.
}
/** A stream (static or global:<name>) with the normalized file paths in it. */
export interface AgentStream {
name: string;
entry: string;
files: string[];
}
/** Reference sites grouped by the definition they point to. */
export interface AgentReferenceGroup {
type: string;
id: string;
file: string;
line: number;
sites: ReferenceSite[];
}
/**
* Immutable, tool-facing snapshot of one project index.
*/
export interface AgentIndexSnapshot {
schemaVersion: number;
projectDir: string;
sdkDir: string;
/** Last finished phase: "xml" or "art". */
phase: "xml" | "art";
complete: boolean;
stale?: boolean;
generatedAt: string;
/** Build counter from the workspace; useful for change detection. */
buildId?: number;
stats: AgentIndexStats;
assets: AgentAsset[];
defines: AgentDefine[];
/**
* Reverse references grouped by target definition key.
* Consumers normally filter by `type` + `id`, then aggregate groups.
*/
references: AgentReferenceGroup[];
streams: AgentStream[];
sourceCandidates: SourceCandidate[];
diagnostics: IndexerDiagnostic[];
}
/** Status returned by query interfaces when an index may not be ready. */
export interface AgentIndexStatus {
state: AgentIndexState;
projectDir?: string;
phase?: "xml" | "art";
complete?: boolean;
stale?: boolean;
generatedAt?: string;
buildId?: number;
stats?: AgentIndexStats;
/** Human-readable explanation for no_index/error states. */
detail?: string;
}
+855 -24
View File
@@ -1,5 +1,9 @@
import * as vscode from "vscode";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { stripBom } from "./language/xmlParser";
import { ModWorkspace } from "./workspace";
import { SdkSetup } from "./sdkSetup";
import { Ra3CompletionProvider } from "./features/completion";
import { Ra3HoverProvider } from "./features/hover";
import {
@@ -14,16 +18,67 @@ import {
findUnreferencedAssets,
findUnreferencedAssetsOfType,
} from "./features/unreferenced";
import { findAsset } from "./features/findAsset";
import { Ra3Diagnostics } from "./features/diagnostics";
import {
Ra3SemanticTokensProvider,
RA3_SEMANTIC_TOKENS_LEGEND,
} from "./features/semanticTokens";
import { t } from "./localize";
import {
snapshotFromIndex,
snapshotPathForProject,
writeSnapshotFile,
} from "./agent/snapshot";
import {
claudeDesktopConfigPath,
cursorGlobalConfigPath,
cursorProjectConfigPath,
installMcpServerConfigToFile,
launcherPath,
mcpConfigJson,
removeLauncher,
uninstallMcpServerConfigs,
writeLauncher,
} from "./agent/setup";
import {
SKILL_NAME,
agentsSkillsDirForUser,
claudeSkillsDirForUser,
forgetSkillInstallRecords,
installSkillToDirectories,
installedSkillStatus,
readSkillInstallRecord,
syncInstalledSkills,
uninstallRecordedSkills,
} from "./agent/skill";
import {
shouldOfferAgentOnboarding,
type AgentOnboardingState,
} from "./agent/onboarding";
import { startLocalServer, type LocalServerHandle } from "./agent/localServer";
import { parseLoadedXml } from "./agent/forwardRefs";
import {
clearEndpointForProject,
writeEndpoint,
writeEndpointForProject,
} from "./agent/endpoint";
import {
clearInstance,
makeInstanceId,
pruneInstances,
refreshDiscovery,
writeInstance,
writeManifest,
} from "./agent/instances";
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 {
const ws = new ModWorkspace(context);
const sdkSetup = new SdkSetup(context, () => ws);
context.subscriptions.push(ws);
context.subscriptions.push(
@@ -66,12 +121,259 @@ export function activate(context: vscode.ExtensionContext): void {
new Ra3DocumentSymbolProvider(ws),
),
);
const codeLensProvider = new Ra3CodeLensProvider(ws);
context.subscriptions.push(
vscode.languages.registerCodeLensProvider(
XML_SELECTOR,
new Ra3CodeLensProvider(ws),
),
vscode.languages.registerCodeLensProvider(XML_SELECTOR, codeLensProvider),
);
// 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;
/** Running extension version (used by the agent onboarding / upgrade sync). */
const extensionVersion = String(
(context.extension.packageJSON as { version?: string }).version ?? "dev",
);
// Coalesced agent snapshot refresh: after AI Agent access is enabled, keep
// the external snapshot current without writing on every intermediate
// rebuild. The timer only fires after a quiet period following a complete,
// non-stale final index.
let agentAccessEnabled = context.workspaceState.get<boolean>(
"ra3modxml.agentAccessEnabled",
false,
);
const AGENT_ONBOARDING_KEY = "ra3modxml.agentOnboarding";
/** Session guard: one notification at most per activation. */
let agentOnboardingChecked = false;
/**
* One-time introduction of the AI Agent feature. Shown after the first
* index for this workspace, once per machine (see agent/onboarding.ts for
* the anti-nag rules). Choosing an action is optional and silent.
*/
const maybeOfferAgentOnboarding = async (): Promise<void> => {
if (agentOnboardingChecked) return;
if (!ws.activeIndex()) return;
const state = context.globalState.get<AgentOnboardingState>(
AGENT_ONBOARDING_KEY,
);
if (!shouldOfferAgentOnboarding(state, extensionVersion)) {
agentOnboardingChecked = true;
return;
}
agentOnboardingChecked = true;
const enable = t("Enable AI Agent access…");
const learnMore = t("Learn more");
const never = t("Don't show again");
const pick = await vscode.window.showInformationMessage(
t(
"RA3 Mod XML: this version can expose the project's asset index to AI agents (MCP + Agent Skill). Enable it?",
),
enable,
learnMore,
never,
);
// Whatever the user chose (including ignoring the message), remember that
// this version already informed them so it never becomes a recurring nag.
await context.globalState.update(AGENT_ONBOARDING_KEY, {
informedVersion: extensionVersion,
dismissed: pick === never,
} satisfies AgentOnboardingState);
if (pick === enable) {
void vscode.commands.executeCommand("ra3modxml.enableAgentAccess");
} else if (pick === learnMore) {
void vscode.env.openExternal(
vscode.Uri.parse(
"https://github.com/RA3CoronaDevelopers/Ra3ModXmlExt#ai-agent-access",
),
);
}
};
const AGENT_SNAPSHOT_QUIET_MS = 5000;
let agentSnapshotTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleAgentSnapshot = (): void => {
if (!agentAccessEnabled) return;
if (agentSnapshotTimer) clearTimeout(agentSnapshotTimer);
agentSnapshotTimer = setTimeout(() => {
agentSnapshotTimer = null;
const current = ws.activeIndex();
if (!current?.complete || current.stale === true) return;
void writeSnapshotFile(
snapshotPathForProject(current.projectDir),
snapshotFromIndex(current, ws.buildCount),
).catch((err) => {
ws.log(
`[agent-snapshot] export failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
}, AGENT_SNAPSHOT_QUIET_MS);
};
let agentLocalServer: LocalServerHandle | null = null;
/** Project roots whose per-project endpoint file this window wrote. */
const agentEndpointProjects = new Set<string>();
/**
* This window's own instance id. Each window writes only its own file under
* `instances/`, which is what removes the need for locking/merging between
* concurrently running VS Code windows.
*/
const agentInstanceId = makeInstanceId();
const startAgentLocalServer = async (): Promise<void> => {
if (agentLocalServer) return;
try {
// Clean up instances left behind by crashed windows. Any instance can do
// this, so a crash does not have to wait for the same workspace to be
// reopened before its stale entry disappears.
const pruned = await pruneInstances().catch(() => ({ removed: [], kept: [] }));
if (pruned.removed.length) {
ws.log(
`[agent] pruned ${pruned.removed.length} stale instance(s): ${pruned.removed.join(", ")}`,
);
}
const handle = await startLocalServer({
// Route by explicit project so a query can never be answered by
// whichever project the active editor happens to point at.
getIndex: (projectDir) =>
projectDir ? ws.indexForProject(projectDir) : ws.activeIndex(),
listProjects: () => ws.getProjectRoots(),
loadFile: async (file) => {
const text = stripBom(await readFile(file, "utf8"));
return parseLoadedXml(text);
},
});
agentLocalServer = handle;
const url = `http://127.0.0.1:${handle.port}`;
const projects = ws.getProjectRoots();
const endpoint = {
instanceId: agentInstanceId,
url,
token: handle.token,
projectDir: ws.projectRoot ?? undefined,
projects,
processId: process.pid,
updatedAt: new Date().toISOString(),
};
// One file per project, so two open windows cannot shadow each other.
for (const project of projects) {
const file = await writeEndpointForProject(project, {
...endpoint,
projectDir: project,
});
agentEndpointProjects.add(project);
ws.log(`[agent-local-server] endpoint for ${project} -> ${file}`);
}
// This window's own instance file (authoritative for liveness/pruning).
const instanceFile = await writeInstance(endpoint);
ws.log(`[agent-local-server] instance -> ${instanceFile}`);
// Legacy/global pointer for tooling that does not know the project.
await writeEndpoint(endpoint);
await writeManifest(await refreshInstanceFiles(url, handle.token, projects));
ws.log(`[agent-local-server] listening on ${url}`);
} catch (err) {
ws.log(
`[agent-local-server] failed to start: ${err instanceof Error ? err.message : String(err)}`,
);
}
};
/**
* Re-reads every live instance file (including other windows') and rewrites
* the merged discovery manifest.
*/
const refreshInstanceFiles = async (
url: string,
token: string,
projects: string[],
) => {
const instance = {
instanceId: agentInstanceId,
url,
token,
projectDir: ws.projectRoot ?? undefined,
projects,
processId: process.pid,
updatedAt: new Date().toISOString(),
};
await writeInstance(instance).catch(() => undefined);
const { kept } = await pruneInstances().catch(() => ({
removed: [],
kept: [] as typeof instance[],
}));
// Ensure this window is present even if its file was just pruned/written.
const others = kept.filter((k) => k.instanceId !== agentInstanceId);
return [...others, instance];
};
const stopAgentLocalServer = async (): Promise<void> => {
if (agentLocalServer) {
const server = agentLocalServer;
agentLocalServer = null;
await server.close().catch(() => undefined);
}
// Only remove what this window owns: another VS Code window may still be
// serving its own projects.
for (const project of agentEndpointProjects) {
await clearEndpointForProject(project).catch(() => undefined);
}
agentEndpointProjects.clear();
await clearInstance(agentInstanceId).catch(() => undefined);
// Other VS Code windows may still be serving an index. Re-derive the
// legacy global pointer and the merged manifest from the surviving
// instance files instead of clearing them, so closing this window never
// hides a still-running window from agents reading ~/.ra3modxml/index.json.
await refreshDiscovery().catch(() => undefined);
};
/**
* Republishes this window's endpoint/instance files for every project it now
* knows about. Called on each index update so projects discovered later get
* an endpoint without restarting the server, and so the merged manifest is
* refreshed.
*/
const refreshAgentEndpoints = async (): Promise<void> => {
if (!agentLocalServer) return;
const url = `http://127.0.0.1:${agentLocalServer.port}`;
const token = agentLocalServer.token;
const projects = ws.getProjectRoots();
const base = {
url,
token,
projects,
processId: process.pid,
updatedAt: new Date().toISOString(),
};
for (const project of projects) {
try {
await writeEndpointForProject(project, { ...base, projectDir: project });
agentEndpointProjects.add(project);
} catch (err) {
ws.log(
`[agent-local-server] could not write endpoint for ${project}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
const instances = await refreshInstanceFiles(url, token, projects);
await writeManifest(instances);
};
context.subscriptions.push({
dispose: () => {
if (codeLensRetryTimer) clearInterval(codeLensRetryTimer);
if (agentSnapshotTimer) clearTimeout(agentSnapshotTimer);
void stopAgentLocalServer();
},
});
context.subscriptions.push(
vscode.languages.registerDocumentSemanticTokensProvider(
XML_SELECTOR,
@@ -85,7 +387,18 @@ export function activate(context: vscode.ExtensionContext): void {
// Refresh diagnostics for every open XML document whenever a new index
// snapshot is published (XML phase, art phase, stale/final rebuild).
ws.onIndexUpdate = () => {
codeLensProvider.resetSuppressionLog();
codeLensProvider.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})`,
);
}
scheduleAgentSnapshot();
void refreshAgentEndpoints();
void maybeOfferAgentOnboarding();
for (const doc of vscode.workspace.textDocuments) {
if (doc.languageId === "xml") void diagnostics.update(doc);
}
@@ -113,7 +426,17 @@ export function activate(context: vscode.ExtensionContext): void {
);
context.subscriptions.push(
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(
@@ -131,7 +454,7 @@ export function activate(context: vscode.ExtensionContext): void {
vscode.workspace.onDidSaveTextDocument((doc) => {
if (doc.languageId !== "xml") return;
ws.invalidate(doc.uri.fsPath);
ws.scheduleRebuild("save");
ws.scheduleRebuild("save", doc);
void diagnostics.update(doc);
}),
);
@@ -141,7 +464,8 @@ export function activate(context: vscode.ExtensionContext): void {
// Search paths / builtmods locations may have changed: cached include
// resolutions and manifest lookups are no longer valid.
ws.invalidateExistence();
ws.scheduleRebuild("config");
ws.scheduleRebuildAll("config");
void sdkSetup.evaluate(ws);
}
}),
);
@@ -156,7 +480,7 @@ export function activate(context: vscode.ExtensionContext): void {
vscode.commands.registerCommand("ra3modxml.clearCache", () => {
ws.clearCaches();
void vscode.window.showInformationMessage(
"RA3 Mod XML: caches cleared; rebuilding from scratch…",
t("RA3 Mod XML: caches cleared; rebuilding from scratch…"),
);
}),
);
@@ -169,37 +493,473 @@ export function activate(context: vscode.ExtensionContext): void {
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.openIndexReport", () => {
const idx = ws.index;
const idx = ws.activeIndex();
if (!idx) {
if (ws.isBuilding) {
void vscode.window.showInformationMessage(
"RA3 Mod XML: index is still building — check the status bar. " +
"Most features become available after the XML phase.",
t(
"RA3 Mod XML: index is still building — check the status bar. Most features become available after the XML phase.",
),
);
return;
}
if (ws.getProjectRoots().length) {
void vscode.window.showInformationMessage(
t(
"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.",
t(
"RA3 Mod XML: no index available. Open a workspace that contains Data/Mod.xml, Data/additionalmaps/mapmetadata_*.xml or a mod folder.",
),
);
return;
}
const s = idx.stats;
const stale = idx.stale ? " (stale)" : "";
const stale = idx.stale ? ` ${t("(stale)")}` : "";
void vscode.window.showInformationMessage(
`RA3 Mod XML index\n` +
`Project: ${s.projectDir}\n` +
`Files: ${s.indexedFiles} (${s.parsedFiles} parsed, ${s.shallowScannedFiles} shallow-scanned, ${s.shallowCacheHits + s.recordsCacheHits} cache hits)\n` +
`Assets: ${s.assetCount} (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` +
`References: ${s.referenceCount}\n` +
`Defines: ${s.defineCount} · Streams: ${s.streams} · Candidates: ${s.sourceCandidates}\n` +
`Phase: ${s.phase} · Complete: ${s.complete}${stale}\n` +
`Build #${ws.buildCount} (trigger: ${ws.lastTrigger})\n` +
`Indexed in ${(s.elapsedMs / 1000).toFixed(1)}s\n` +
`XML walk: ${(s.walkMs / 1000).toFixed(1)}s · Candidates: ${(s.candidatesMs / 1000).toFixed(1)}s · Art scan: ${(s.artScanMs / 1000).toFixed(1)}s`,
[
t("RA3 Mod XML index"),
t("Project: {0}", s.projectDir),
t(
"Files: {0} ({1} parsed, {2} shallow-scanned, {3} cache hits)",
s.indexedFiles,
s.parsedFiles,
s.shallowScannedFiles,
s.shallowCacheHits + s.recordsCacheHits,
),
t(
"Assets: {0} ({1} from {2} manifests)",
s.assetCount,
s.manifestAssetCount,
s.manifestFiles,
),
t("References: {0}", s.referenceCount),
t(
"Defines: {0} · Streams: {1} · Candidates: {2}",
s.defineCount,
s.streams,
s.sourceCandidates,
),
t("Phase: {0} · Complete: {1}{2}", s.phase, s.complete, stale),
t("Build #{0} (trigger: {1})", ws.buildCount, ws.lastTrigger),
t("Indexed in {0}s", (s.elapsedMs / 1000).toFixed(1)),
t(
"XML walk: {0}s · Candidates: {1}s · Art scan: {2}s",
(s.walkMs / 1000).toFixed(1),
(s.candidatesMs / 1000).toFixed(1),
(s.artScanMs / 1000).toFixed(1),
),
].join("\n"),
{ modal: false },
);
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.exportIndexSnapshot", async () => {
const idx = ws.activeIndex();
if (!idx) {
void vscode.window.showInformationMessage(
t(
"RA3 Mod XML: no index available yet. Wait for indexing to finish before exporting an AI Agent snapshot.",
),
);
return;
}
const path = snapshotPathForProject(idx.projectDir);
try {
const snapshot = snapshotFromIndex(idx, ws.buildCount);
await writeSnapshotFile(path, snapshot);
void vscode.window.showInformationMessage(
t(
"RA3 Mod XML: exported AI Agent index snapshot to {0}",
path,
),
t("Reveal in Explorer"),
).then((pick) => {
if (pick) void vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(path));
});
} catch (err) {
void vscode.window.showErrorMessage(
t(
"RA3 Mod XML: failed to export AI Agent index snapshot: {0}",
err instanceof Error ? err.message : String(err),
),
);
}
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.enableAgentAccess", async () => {
const idx = ws.activeIndex();
if (!idx) {
void vscode.window.showInformationMessage(
t(
"RA3 Mod XML: no index available yet. Wait for indexing to finish before enabling AI Agent access.",
),
);
return;
}
const projectDir = idx.projectDir;
try {
const snapshot = snapshotFromIndex(idx, ws.buildCount);
await writeSnapshotFile(snapshotPathForProject(projectDir), snapshot);
const launcher = await writeLauncher(
context.extensionUri.fsPath,
projectDir,
);
const configJson = mcpConfigJson(launcher.path, projectDir);
ws.log(
`[agent] launcher runtime=${launcher.runtime.kind} (${launcher.runtime.executable}), nodeFree=${launcher.nodeFree}`,
);
if (!launcher.nodeFree) {
void vscode.window.showWarningMessage(
t(
"RA3 Mod XML: the AI Agent launcher will use Node from PATH. Install Node, or run VS Code from a normal installation so the bundled runtime can be used.",
),
);
}
agentAccessEnabled = true;
await context.workspaceState.update("ra3modxml.agentAccessEnabled", true);
void startAgentLocalServer();
const version = String((context.extension.packageJSON as { version?: string }).version ?? "dev");
const installSkill = t("Install Agent Skill (recommended)");
const writeClaude = t("Write MCP config to Claude Desktop");
const writeCursorGlobal = t("Write MCP config to Cursor (global)");
const writeCursorProject = t("Write MCP config to Cursor (project)");
const copyConfig = t("Copy MCP config");
const pick = await vscode.window.showQuickPick(
[
{
label: installSkill,
description: agentsSkillsDirForUser(),
id: "skill",
},
{
label: writeClaude,
description: claudeDesktopConfigPath(),
id: "claude",
},
{
label: writeCursorGlobal,
description: cursorGlobalConfigPath(),
id: "cursor-global",
},
{
label: writeCursorProject,
description: cursorProjectConfigPath(projectDir),
id: "cursor-project",
},
{
label: copyConfig,
id: "copy",
},
],
{
placeHolder: t("RA3 Mod XML AI Agent access enabled. Choose an optional next step."),
},
);
if (pick?.id === "skill") {
const target = agentsSkillsDirForUser();
const installed = await installSkillToDirectories([target], version);
if (installed.length) {
void vscode.window.showInformationMessage(
t("RA3 Mod XML Agent Skill installed to {0}", target),
);
} else {
void vscode.window.showErrorMessage(
t("RA3 Mod XML: could not write the Agent Skill to {0}", target),
);
}
} else if (pick?.id === "claude") {
await installMcpServerConfigToFile({
filePath: claudeDesktopConfigPath(),
launcher: launcher.path,
projectDir,
label: "Claude Desktop",
sourceVersion: version,
});
void vscode.window.showInformationMessage(
t("RA3 Mod XML MCP config written to {0}", claudeDesktopConfigPath()),
);
} else if (pick?.id === "cursor-global") {
await installMcpServerConfigToFile({
filePath: cursorGlobalConfigPath(),
launcher: launcher.path,
projectDir,
label: "Cursor (global)",
sourceVersion: version,
});
void vscode.window.showInformationMessage(
t("RA3 Mod XML MCP config written to {0}", cursorGlobalConfigPath()),
);
} else if (pick?.id === "cursor-project") {
await installMcpServerConfigToFile({
filePath: cursorProjectConfigPath(projectDir),
launcher: launcher.path,
projectDir,
label: "Cursor (project)",
sourceVersion: version,
});
void vscode.window.showInformationMessage(
t("RA3 Mod XML MCP config written to {0}", cursorProjectConfigPath(projectDir)),
);
} else if (pick?.id === "copy") {
await vscode.env.clipboard.writeText(configJson);
void vscode.window.showInformationMessage(
t("RA3 Mod XML MCP config copied to clipboard."),
);
}
} catch (err) {
void vscode.window.showErrorMessage(
t(
"RA3 Mod XML: failed to enable AI Agent access: {0}",
err instanceof Error ? err.message : String(err),
),
);
}
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.installAgentSkill", async () => {
// Installing the Skill only writes text files; it does not need an
// index, so users can prepare their agent tooling before the first build.
const projectDir = ws.projectRoot;
const version = String((context.extension.packageJSON as { version?: string }).version ?? "dev");
const choices: Array<{
label: string;
description: string;
target: string | null;
picked?: boolean;
}> = [
{
label: t("Default (~/.agents/skills)"),
description: agentsSkillsDirForUser(),
target: agentsSkillsDirForUser(),
picked: true,
},
{
label: t("Claude Code (~/.claude/skills)"),
description: claudeSkillsDirForUser(),
target: claudeSkillsDirForUser(),
},
];
if (projectDir) {
choices.push(
{
label: t("Current project .agents/skills"),
description: join(projectDir, ".agents", "skills", SKILL_NAME),
target: join(projectDir, ".agents", "skills", SKILL_NAME),
},
{
label: t("Current project .claude/skills"),
description: join(projectDir, ".claude", "skills", SKILL_NAME),
target: join(projectDir, ".claude", "skills", SKILL_NAME),
},
);
}
choices.push({
label: t("Choose a custom folder…"),
description: t("The skill is installed as <folder>/{0}", SKILL_NAME),
target: null,
});
const picked = await vscode.window.showQuickPick(choices, {
canPickMany: true,
placeHolder: t("Select Agent Skill install locations"),
});
if (!picked?.length) return;
const targets = picked
.map((p) => p.target)
.filter((p): p is string => p != null);
if (picked.some((p) => p.target == null)) {
const folder = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: t("Choose a skill folder"),
title: t("Choose the folder that should contain the {0} skill", SKILL_NAME),
});
const dir = folder?.[0]?.fsPath;
if (dir) targets.push(join(dir, SKILL_NAME));
}
if (!targets.length) return;
const succeeded = await installSkillToDirectories(targets, version);
const failed = targets.length - succeeded.length;
if (!succeeded.length) {
void vscode.window.showErrorMessage(
t("RA3 Mod XML: could not write the Agent Skill (0 of {0} locations).", targets.length),
);
return;
}
void vscode.window.showInformationMessage(
failed > 0
? t(
"RA3 Mod XML Agent Skill installed to {0} location(s); {1} failed.",
succeeded.length,
failed,
)
: t("RA3 Mod XML Agent Skill installed to {0} location(s).", succeeded.length),
t("Show installed skills"),
).then((pick) => {
if (pick) void vscode.commands.executeCommand("ra3modxml.uninstallAgentSkill");
});
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.uninstallAgentSkill", async () => {
const installed = await installedSkillStatus();
if (!installed.length) {
void vscode.window.showInformationMessage(
t("RA3 Mod XML: no recorded Agent Skill installation to remove."),
);
return;
}
const picks = await vscode.window.showQuickPick(
installed.map((entry) => ({
label: entry.path,
description: !entry.exists
? t("missing — will be dropped from the record")
: entry.managed
? t("installed by this extension (v{0})", entry.sourceVersion)
: t("not managed by this extension — will be skipped"),
detail: entry.managed || !entry.exists ? undefined : t("No RA3 Mod XML skill marker found; remove it manually if you want it gone."),
path: entry.path,
picked: entry.exists && entry.managed,
})),
{
canPickMany: true,
placeHolder: t("Select Agent Skill installations to remove"),
},
);
if (!picks?.length) return;
const selected = picks.map((p) => p.path);
// A record whose directory is already gone only needs its record entry
// dropped; a managed directory is deleted by uninstallRecordedSkills.
const missing = installed
.filter((e) => selected.includes(e.path) && !e.exists)
.map((e) => e.path);
const { removed, skipped } = await uninstallRecordedSkills(
selected.filter((p) => !missing.includes(p)),
);
if (missing.length) {
await forgetSkillInstallRecords(missing);
}
void vscode.window.showInformationMessage(
skipped.length
? t(
"RA3 Mod XML: removed {0} Agent Skill installation(s); {1} skipped (not managed by this extension).",
removed.length,
skipped.length,
)
: t("RA3 Mod XML: removed {0} Agent Skill installation(s).", removed.length),
);
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.disableAgentAccess", async () => {
agentAccessEnabled = false;
await context.workspaceState.update("ra3modxml.agentAccessEnabled", false);
// Stops the loopback server and removes this window's instance /
// endpoint files; discovery files are re-derived from other windows.
await stopAgentLocalServer();
ws.log("[agent] AI Agent access disabled for this workspace");
void vscode.window.showInformationMessage(
t(
"RA3 Mod XML: AI Agent access disabled for this workspace. Installed skills and MCP client configurations were kept.",
),
t("Uninstall AI Agent integration…"),
).then((pick) => {
if (pick) void vscode.commands.executeCommand("ra3modxml.uninstallAgentIntegration");
});
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.uninstallAgentIntegration", async () => {
const projectDir = ws.projectRoot;
const skills = await installedSkillStatus();
const removableSkills = skills.filter((s) => s.exists);
const live = agentAccessEnabled || agentLocalServer != null;
const choices: Array<{
label: string;
description?: string;
id: "disable" | "skills" | "mcp" | "launcher";
picked?: boolean;
}> = [];
if (live) {
choices.push({
label: t("Stop live AI Agent access in this window"),
id: "disable",
picked: true,
});
}
if (removableSkills.length) {
choices.push({
label: t("Remove installed Agent Skills"),
description: t("{0} location(s)", removableSkills.length),
id: "skills",
picked: true,
});
}
choices.push(
{
label: t("Remove MCP client configuration entries"),
description: t("Claude Desktop / Cursor and files recorded by this extension"),
id: "mcp",
picked: true,
},
{
label: t("Remove the stable MCP launcher"),
description: launcherPath(),
id: "launcher",
},
);
const picked = await vscode.window.showQuickPick(choices, {
canPickMany: true,
placeHolder: t("Select what to remove (nothing is removed until you confirm)"),
});
if (!picked?.length) return;
const confirm = await vscode.window.showWarningMessage(
t("Remove the selected AI Agent components?"),
{ modal: true },
t("Remove"),
);
if (confirm !== t("Remove")) return;
const summary: string[] = [];
if (picked.some((p) => p.id === "disable")) {
agentAccessEnabled = false;
await context.workspaceState.update("ra3modxml.agentAccessEnabled", false);
await stopAgentLocalServer();
summary.push(t("live access stopped"));
}
if (picked.some((p) => p.id === "skills")) {
const { removed, skipped } = await uninstallRecordedSkills(
removableSkills.filter((s) => s.managed).map((s) => s.path),
);
summary.push(t("{0} skill installation(s) removed", removed.length));
if (skipped.length) {
summary.push(t("{0} unmanaged skill folder(s) skipped", skipped.length));
}
}
if (picked.some((p) => p.id === "mcp")) {
const changed = await uninstallMcpServerConfigs({ projectDir });
summary.push(t("MCP config removed from {0} file(s)", changed.length));
}
if (picked.some((p) => p.id === "launcher")) {
await removeLauncher();
summary.push(t("MCP launcher removed"));
}
void vscode.window.showInformationMessage(
t("RA3 Mod XML AI Agent cleanup: {0}.", summary.join("; ")),
);
}),
);
context.subscriptions.push(
vscode.commands.registerCommand(
"ra3modxml.showReferences",
@@ -207,6 +967,21 @@ export function activate(context: vscode.ExtensionContext): void {
void showReferencesForDef(ws, args),
),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.findAsset", () => {
// Prefill with a single-line selection so the editor context menu entry
// can search for the id the user highlighted.
const editor = vscode.window.activeTextEditor;
const selected = editor
? editor.document.getText(editor.selection).trim()
: "";
const initial =
selected && !/[\r\n]/.test(selected) && selected.length <= 200
? selected
: "";
return findAsset(ws, initial);
}),
);
context.subscriptions.push(
vscode.commands.registerCommand(
"ra3modxml.findUnreferencedAssets",
@@ -220,7 +995,63 @@ export function activate(context: vscode.ExtensionContext): void {
),
);
void ws.initialize();
/**
* Refreshes the stable launcher for every known project. Called on
* activation for already-enabled workspaces so a VS Code update (or an
* extension update) repoints the launcher at the current runtime without
* the user having to re-run the enable command.
*/
const refreshLaunchers = async (): Promise<void> => {
const projects = ws.getProjectRoots();
if (!projects.length) return;
for (const project of projects) {
try {
const launcher = await writeLauncher(
context.extensionUri.fsPath,
project,
);
ws.log(
`[agent] refreshed launcher for ${project}: runtime=${launcher.runtime.kind}, nodeFree=${launcher.nodeFree}`,
);
} catch (err) {
ws.log(
`[agent] could not refresh launcher for ${project}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
};
if (agentAccessEnabled) {
void startAgentLocalServer();
void refreshLaunchers();
}
/**
* Keeps recorded Skill copies in sync after an extension upgrade. Only runs
* when a recorded copy is at a different version, and never rewrites a
* directory that no longer carries our marker.
*/
void (async () => {
try {
const installed = await readSkillInstallRecord();
if (!installed.some((r) => r.sourceVersion !== extensionVersion)) return;
const synced = await syncInstalledSkills(extensionVersion);
ws.log(
`[agent] synced ${synced.length} installed skill(s) to v${extensionVersion}`,
);
} catch {
// Best effort: the install/uninstall commands can always repair this.
}
})();
void sdkSetup.evaluate(ws);
void ws.initialize().then(() => {
void sdkSetup.evaluate(ws);
if (agentAccessEnabled) {
void startAgentLocalServer();
void refreshLaunchers();
}
});
}
export function deactivate(): void {
+283
View File
@@ -0,0 +1,283 @@
/**
* Pure asset-search core for the "Find asset…" command.
*
* A query is matched against the live `ModIndex` maps (type -> id ->
* definitions) plus the document-local overlay, so assets that only exist in
* the current file's include chain (including unsaved edits) are searchable
* too. Keeping the parsing/matching/ranking logic free of the VS Code API
* makes it unit-testable and reusable by other surfaces (CLI, tools).
*
* Supported query forms (all case-insensitive):
* - `Type:Id` — type filter plus id filter (both may be partial);
* - `Id` — exact / prefix / substring id match; type names match as
* well, so `GameObject` also lists the assets of that type;
* - `Type:` — every asset of that type.
*
* Manifest-style qualified names can carry several colon segments
* (`W3DContainer:W3DContainer:AUGunship`). Asset ids never contain ":", so the
* segment after the last colon is the id, exactly like `normalizeReferenceId`.
*/
import type { AssetDef, ModIndex } from "../indexer/types";
/** Default result cap; the UI reports how many matches were hidden. */
export const ASSET_SEARCH_LIMIT = 200;
export interface AssetSearchQuery {
/** Trimmed original text (used in messages). */
raw: string;
/** Lowercased type filter, or null when the query has no `Type:` part. */
type: string | null;
/** Lowercased id filter; "" when the query only filters by type. */
id: string;
}
/**
* One searchable asset (a `type:id` pair). The same id can be defined in
* several places (project + SDK + manifest, or duplicated files); the pair is
* what users search for, so the list is de-duplicated and the best-ranked
* definition is kept while `definitionCount` records the other sites.
*/
export interface AssetSearchCandidate {
def: AssetDef;
/** Distinct definition sites (file + line) for this type:id. */
definitionCount: number;
}
export interface AssetSearchResult {
matches: AssetSearchCandidate[];
/** Total matches before the limit was applied. */
total: number;
}
/** `0` exact, `1` prefix, `2` substring, `3` no match. */
const RANK_NONE = 3;
/** Parses a user query into a type filter and an id filter. */
export function parseAssetSearchQuery(rawInput: string): AssetSearchQuery {
const raw = rawInput.trim();
const colon = raw.indexOf(":");
if (colon < 0) return { raw, type: null, id: raw.toLowerCase() };
const type = raw.slice(0, colon).trim().toLowerCase();
const rest = raw.slice(colon + 1).trim();
const lastColon = rest.lastIndexOf(":");
const id = (lastColon >= 0 ? rest.slice(lastColon + 1) : rest)
.trim()
.toLowerCase();
return { raw, type: type || null, id };
}
/** True when the query asks for nothing (the UI shows only the placeholder). */
export function isEmptyAssetSearchQuery(query: AssetSearchQuery): boolean {
return !query.type && !query.id;
}
/**
* Lower rank = better. Document-local (unsaved) definitions win, then mod
* definitions, then SDK sources, then compiled manifests.
*/
export function assetOriginRank(def: AssetDef): number {
if (def.stream === "local") return 0;
switch (def.origin) {
case "project":
return 1;
case "sdk":
return 2;
default:
return 3;
}
}
/**
* Flattens the index (and the optional local overlay) into one de-duplicated
* candidate per `type:id`, keeping the best-ranked definition for each pair.
*/
export function collectAssetSearchCandidates(
index: ModIndex,
): AssetSearchCandidate[] {
const byKey = new Map<
string,
{ def: AssetDef; sites: Set<string>; count: number }
>();
const add = (def: AssetDef): void => {
const key = `${def.type}\u0000${def.id.toLowerCase()}`;
const site = `${def.file.toLowerCase()}\u0000${def.line}`;
let entry = byKey.get(key);
if (!entry) {
entry = { def, sites: new Set(), count: 0 };
byKey.set(key, entry);
}
if (entry.sites.has(site)) return;
entry.sites.add(site);
entry.count++;
if (assetOriginRank(def) < assetOriginRank(entry.def)) entry.def = def;
};
// Local overlay first: on equal origin rank the first definition wins, and
// the overlay carries the freshest text of the current file chain.
if (index.local) {
for (const byId of index.local.assets.values()) {
for (const defs of byId.values()) for (const def of defs) add(def);
}
}
for (const byId of index.assets.values()) {
for (const defs of byId.values()) for (const def of defs) add(def);
}
return [...byKey.values()].map((entry) => ({
def: entry.def,
definitionCount: entry.count,
}));
}
interface ScoredCandidate {
candidate: AssetSearchCandidate;
/** Lower is better: how strongly the query matched. */
tier: number;
/** id match rank (RANK_NONE when the query has no id part). */
idRank: number;
/** type match rank (RANK_NONE when the query has no type part). */
typeRank: number;
originRank: number;
}
/**
* Ranks the candidates for one query and returns the best `limit` matches
* plus the total number of matches (so the UI can say "N of M").
*/
export function searchAssetCandidates(
candidates: readonly AssetSearchCandidate[],
query: AssetSearchQuery,
limit: number = ASSET_SEARCH_LIMIT,
): AssetSearchResult {
if (isEmptyAssetSearchQuery(query)) return { matches: [], total: 0 };
const scored: ScoredCandidate[] = [];
for (const candidate of candidates) {
const score = scoreCandidate(candidate, query);
if (score) scored.push(score);
}
scored.sort(compareScoredCandidates);
const capped = Math.max(0, limit);
return {
matches: scored.slice(0, capped).map((entry) => entry.candidate),
total: scored.length,
};
}
function scoreCandidate(
candidate: AssetSearchCandidate,
query: AssetSearchQuery,
): ScoredCandidate | null {
const id = candidate.def.id.toLowerCase();
const type = candidate.def.type.toLowerCase();
const idRank = query.id ? rankMatch(id, query.id) : RANK_NONE;
// With a bare query the same text also matches type names, so
// `GameObject` lists that type's assets as well as every id containing
// "gameobject".
const typeRank = query.type
? rankMatch(type, query.type)
: query.id
? rankMatch(type, query.id)
: RANK_NONE;
if (query.id && query.type) {
if (idRank === RANK_NONE || typeRank === RANK_NONE) return null;
} else if (query.id) {
if (idRank === RANK_NONE && typeRank === RANK_NONE) return null;
} else if (typeRank === RANK_NONE) {
return null;
}
return {
candidate,
tier: bestTier(idRank, typeRank, query.type != null),
idRank,
typeRank,
originRank: assetOriginRank(candidate.def),
};
}
/** 0 exact, 1 prefix, 2 substring, 3 no match. */
function rankMatch(value: string, filter: string): number {
if (value === filter) return 0;
if (value.startsWith(filter)) return 1;
return value.includes(filter) ? 2 : RANK_NONE;
}
/**
* Match strength tiers. When the user filtered by type explicitly, only the
* id quality ranks the results (the type part is a filter, not a signal).
* Otherwise the strongest of the two matches wins.
*/
function bestTier(
idRank: number,
typeRank: number,
typeFiltered: boolean,
): number {
if (typeFiltered) {
if (idRank === 0) return 0;
if (idRank === 1) return 2;
if (idRank === 2) return 4;
return 6; // `Type:` alone: list the whole type
}
if (idRank === 0) return 0;
if (typeRank === 0) return 1;
if (idRank === 1) return 2;
if (typeRank === 1) return 3;
if (idRank === 2) return 4;
return 5;
}
function compareScoredCandidates(a: ScoredCandidate, b: ScoredCandidate): number {
return (
a.tier - b.tier ||
a.originRank - b.originRank ||
a.idRank - b.idRank ||
a.typeRank - b.typeRank ||
compareText(a.candidate.def.id, b.candidate.def.id) ||
compareText(a.candidate.def.type, b.candidate.def.type)
);
}
/**
* Plain code-unit comparison: asset ids and type names are ASCII, and this is
* much cheaper than `localeCompare` when a short query matches thousands of
* candidates (the list is re-ranked on every keystroke).
*/
function compareText(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
/**
* Every definition site of one search result, local overlay first and then by
* origin rank. Used by the UI when several definitions share a `type:id`
* (e.g. a mod override of a vanilla asset) so the user can pick which one to
* open.
*/
export function assetDefsForCandidate(
index: ModIndex,
candidate: AssetSearchCandidate,
): AssetDef[] {
const { type, id } = candidate.def;
const key = id.toLowerCase();
const out: AssetDef[] = [];
const seen = new Set<string>();
const push = (defs: readonly AssetDef[] | undefined): void => {
if (!defs) return;
for (const def of defs) {
const site = `${def.file.toLowerCase()}\u0000${def.line}`;
if (seen.has(site)) continue;
seen.add(site);
out.push(def);
}
};
push(index.local?.assets.get(type)?.get(key));
push(index.assets.get(type)?.get(key));
out.sort((a, b) => assetOriginRank(a) - assetOriginRank(b));
return out;
}
+77 -17
View File
@@ -2,12 +2,15 @@ import * as vscode from "vscode";
import { LineMap, parseXml } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import { isReferenceTargetType } from "../indexer/refs";
import { scheduleRebuildIfRecordsDesync } from "../indexer/referenceIndex";
import type { ModIndex } from "../indexer/types";
import {
referenceSitesForDefinition,
scheduleRebuildIfRecordsDesync,
} from "../indexer/referenceIndex";
import type { ShowReferencesArgs } from "./references";
collectReferenceSites,
definitionsForReference,
type ShowReferencesArgs,
} from "./references";
import type { ModWorkspace } from "../workspace";
import { t } from "../localize";
/** Never build a DOM for huge files just to show counts (w3x safety). */
const MAX_CODELENS_TEXT = 4 * 1024 * 1024;
@@ -21,18 +24,65 @@ const MAX_CODELENS_TEXT = 4 * 1024 * 1024;
* signal users can click to inspect an unused asset.
*/
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) {}
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,
_token: vscode.CancellationToken,
): vscode.CodeLens[] {
): Promise<vscode.CodeLens[]> {
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 [];
// 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();
if (text.length > MAX_CODELENS_TEXT) return [];
scheduleRebuildIfRecordsDesync(this.ws, document);
if (text.length > MAX_CODELENS_TEXT) {
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 root = doc.root;
if (!root) return [];
@@ -49,12 +99,16 @@ export class Ra3CodeLensProvider implements vscode.CodeLensProvider {
const id = idAttr.value;
const line = lineMap.positionAt(idAttr.valueStart).line + 1;
const count = referenceSitesForDefinition(idx, {
type: local,
// Same definition union as Find All References: document-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,
file: document.uri.fsPath,
line,
}).length;
refType: null,
selfType: null,
});
const count = collectReferenceSites(idx, defs).length;
const range = new vscode.Range(
document.positionAt(child.start),
document.positionAt(child.startTagEnd),
@@ -71,15 +125,21 @@ export class Ra3CodeLensProvider implements vscode.CodeLensProvider {
new vscode.CodeLens(range, {
title:
count === 0
? "0 references"
? t("0 references")
: count === 1
? "1 reference"
: `${count} references`,
? t("1 reference")
: t("{0} references", count),
command: "ra3modxml.showReferences",
arguments: [args],
}),
);
}
const elapsed = Date.now() - startedAt;
if (elapsed > 250) {
this.ws.log(
`[codelens] slow provider for ${uri}: ${lenses.length} lenses in ${elapsed}ms`,
);
}
return lenses;
}
}
+203 -79
View File
@@ -8,7 +8,7 @@ import {
} from "../language/context";
import { resolveElementType } from "../language/typeContext";
import * as model from "../model/schemaModel";
import type { AttributeInfo, SimpleTypeInfo } from "../model/schemaModel";
import type { AttributeInfo, ContentTypeInfo } from "../model/schemaModel";
import { isLocalReferenceAttribute } from "../indexer/refs";
import {
findContainingGameObject,
@@ -17,6 +17,7 @@ import {
} from "../indexer/logicalTree";
import type { ModWorkspace } from "../workspace";
import type { ModIndex, AssetDef } from "../indexer/types";
import { t } from "../localize";
const MAX_VALUE_ITEMS = 400;
@@ -79,10 +80,17 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
const docText =
child.doc ||
(info?.kind === "complex" ? info.doc : "") ||
(type ? `Type: ${type}` : "");
(type ? t("Type: {0}", type) : "");
item.documentation = docText ? new vscode.MarkdownString(docText) : undefined;
item.detail = type ? `RA3 XML · ${type}` : "RA3 XML";
item.detail = type ? t("RA3 XML · {0}", type) : t("RA3 XML");
item.insertText = this.elementSnippet(child.name, type, ctx.element == null);
const contentInfo = type ? model.contentInfoOfType(type) : undefined;
if (contentInfo && this.simpleContentValueKind(contentInfo)) {
item.command = {
command: "editor.action.triggerSuggest",
title: t("Suggest content value"),
};
}
items.push(item);
}
return items;
@@ -93,7 +101,11 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
): { name: string; type: string | null; doc: string }[] {
if (!parent) {
return [
{ name: "AssetDeclaration", type: null, doc: "Root element of every RA3 asset file" },
{
name: "AssetDeclaration",
type: null,
doc: t("Root element of every RA3 asset file"),
},
];
}
const parentType = resolveElementType(parent);
@@ -115,13 +127,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
if (model.isTopLevelElement(name)) {
return new vscode.SnippetString(`${open}${name} id="$1">\n\t$0\n</${name}>`);
}
const info = type ? model.typeInfo(type) : undefined;
// Simple types hold text content (asset id / enum / define / string), so
// they need an explicit closing tag and a value placeholder instead of a
// self-closing tag that can never contain a value.
if (info?.kind === "simple") {
// Simple types and simpleContent complex types hold text content (asset
// id / enum / define / string), so they need an explicit closing tag and
// a value placeholder instead of a self-closing tag that can never
// contain a value.
if (type && model.contentInfoOfType(type)) {
return new vscode.SnippetString(`${open}${name}>$1</${name}>`);
}
const info = type ? model.typeInfo(type) : undefined;
const hasChildren = info?.kind === "complex" && info.children.length > 0;
if (hasChildren) {
return new vscode.SnippetString(`${open}${name}>\n\t$0\n</${name}>`);
@@ -160,12 +173,16 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
item.sortText = attr.required ? "0" + attr.name : "1" + attr.name;
const md = new vscode.MarkdownString();
if (attr.doc) md.appendMarkdown(attr.doc + "\n\n");
if (attr.required) md.appendMarkdown(`**Required** \n`);
if (attr.refType) md.appendMarkdown(`References: \`${attr.refType}\` \n`);
if (attr.required) md.appendMarkdown(`${t("**Required**")} \n`);
if (attr.refType) {
md.appendMarkdown(`${t("References: `{0}`", attr.refType)} \n`);
}
if (attr.enumValues.length)
md.appendMarkdown(`Values: ${attr.enumValues.join(", ")} \n`);
if (attr.default != null) md.appendMarkdown(`Default: \`${attr.default}\` \n`);
md.appendMarkdown(`Type: \`${attr.type ?? "string"}\``);
md.appendMarkdown(`${t("Values: {0}", attr.enumValues.join(", "))} \n`);
if (attr.default != null) {
md.appendMarkdown(`${t("Default: `{0}`", attr.default)} \n`);
}
md.appendMarkdown(t("Type: `{0}`", attr.type ?? "string"));
item.documentation = md;
const value = this.attributeValuePlaceholder(attr, el);
item.insertText = new vscode.SnippetString(
@@ -174,7 +191,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
if (value.trigger) {
item.command = {
command: "editor.action.triggerSuggest",
title: "Suggest attribute values",
title: t("Suggest attribute values"),
};
}
items.push(item);
@@ -187,13 +204,15 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
j.insertText = new vscode.SnippetString(
layout.prefix + 'xai:joinAction="$1"',
);
j.detail = "Instance join action";
j.detail = t("Instance join action");
j.documentation = new vscode.MarkdownString(
"Controls how this element merges with the inherited definition: `Replace` or `Remove`.",
t(
"Controls how this element merges with the inherited definition: `Replace` or `Remove`.",
),
);
j.command = {
command: "editor.action.triggerSuggest",
title: "Suggest attribute values",
title: t("Suggest attribute values"),
};
items.push(j);
}
@@ -203,7 +222,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
ns.insertText = new vscode.SnippetString(
layout.prefix + 'xmlns:xai="uri:ea.com:eala:asset:instance"',
);
ns.detail = "xai namespace";
ns.detail = t("xai namespace");
items.push(ns);
}
return items;
@@ -312,7 +331,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
// Include type / source
if (isInclude && attrName === "type") {
return ["reference", "instance", "all"].map((v) =>
make(v, vscode.CompletionItemKind.EnumMember, "Include type"),
make(v, vscode.CompletionItemKind.EnumMember, t("Include type")),
);
}
if (isInclude && attrName === "source") {
@@ -321,12 +340,13 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
}
if (attrName === "xai:joinaction" || attrName === "joinaction") {
return ["Replace", "Remove"].map((v) =>
make(v, vscode.CompletionItemKind.EnumMember, "xai:joinAction"),
make(v, vscode.CompletionItemKind.EnumMember, t("xai:joinAction")),
);
}
// inheritFrom: same element type first, then everything.
if (attrName === "inheritfrom") {
if (!model.isAssetType(elType)) return [];
if (!idx) return [];
return this.assetIdItems(idx, el.name, null, prefix, make);
}
@@ -348,12 +368,18 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
}
return attrInfo.enumValues
.filter((v) => v.toLowerCase().startsWith(prefix.toLowerCase()))
.map((v) => make(v, vscode.CompletionItemKind.EnumMember, attrInfo.type ?? "enum"));
.map((v) =>
make(
v,
vscode.CompletionItemKind.EnumMember,
attrInfo.type ?? t("enum"),
),
);
}
if (attrInfo?.isBoolean) {
return ["true", "false"]
.filter((v) => v.startsWith(prefix.toLowerCase()))
.map((v) => make(v, vscode.CompletionItemKind.Value, "boolean"));
.map((v) => make(v, vscode.CompletionItemKind.Value, t("boolean")));
}
if (idx && attrInfo?.allowsDefine) {
return this.defineItems(idx, prefix, make);
@@ -412,7 +438,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
make(
v,
vscode.CompletionItemKind.EnumMember,
attrInfo.type ?? "enum",
attrInfo.type ?? t("enum"),
undefined,
range,
append ? ` ${v}` : v,
@@ -436,10 +462,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
a.source.localeCompare(b.source),
);
const items = candidates.map((c) => {
const item = make(c.source, vscode.CompletionItemKind.File, "Include source");
const item = make(
c.source,
vscode.CompletionItemKind.File,
t("Include source"),
);
item.detail = c.path;
item.documentation = new vscode.MarkdownString(
`\`${c.prefix ?? "relative"}\` · ${c.path}`,
t("`{0}` · {1}", c.prefix ?? t("relative"), c.path),
);
return item;
});
@@ -454,20 +484,49 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const lower = prefix.toLowerCase();
const scored: { def: AssetDef; score: number }[] = [];
// Manifest-style qualified values ("AudioEvent:BaseSoundEffect") are
// common in vanilla data. When the user already typed a "Type:" prefix,
// filter on the id part after the last colon and keep the prefix in the
// inserted label (e.g. typing "AudioEvent:Base" completes to
// "AudioEvent:BaseSoundEffect", never to a bare "BaseSoundEffect").
const colon = lower.lastIndexOf(":");
const idPrefix = colon >= 0 ? lower.slice(colon + 1) : lower;
const typePrefix = colon >= 0 ? prefix.slice(0, colon + 1) : "";
// Deduplicate by id: the same asset can be defined in several places at
// once (current file's local overlay + global index, project XML +
// compiled manifest, or an override). Showing one completion entry per
// 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 byId = new Map<
string,
{ best: { def: AssetDef; score: number }; extras: AssetDef[] }
>();
const consider = (def: AssetDef) => {
const key = `${def.type}:${def.id.toLowerCase()}:${def.file}:${def.line}`;
if (seen.has(key)) return;
seen.add(key);
if (!def.id.toLowerCase().startsWith(lower)) return;
const defKey = `${def.type}:${def.id.toLowerCase()}:${def.file}:${def.line}`;
if (seen.has(defKey)) return;
seen.add(defKey);
const idKey = def.id.toLowerCase();
if (!idKey.startsWith(idPrefix)) return;
let score = 3;
if (refType && model.isAssignableTo(def.type, refType)) score = 1;
if (selfType && model.isAssignableTo(def.type, selfType)) score = 0;
if (def.origin === "project") score -= 0.2;
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;
@@ -492,17 +551,39 @@ 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 origin = def.origin === "manifest" ? `manifest (${def.manifestSource ?? ""})` : def.origin;
const originLabel = (d: AssetDef) =>
d.origin === "manifest"
? d.manifestSource
? t("manifest ({0})", d.manifestSource)
: t("manifest")
: originLabelText(d.origin);
const origin = originLabel(def);
const doc = new vscode.MarkdownString();
doc.appendCodeblock(def.id);
doc.appendMarkdown(`**Type**: ${def.type} \n`);
if (def.manifestSource) doc.appendMarkdown(`**Source**: ${def.manifestSource} \n`);
doc.appendMarkdown(`**Origin**: ${origin}`);
return make(def.id, vscode.CompletionItemKind.Value, `${def.type} · ${origin}`, doc.value);
doc.appendMarkdown(`${t("**Type**: {0}", def.type)} \n`);
if (def.manifestSource) {
doc.appendMarkdown(`${t("**Source**: {0}", def.manifestSource)} \n`);
}
doc.appendMarkdown(t("**Origin**: {0}", origin));
for (const extra of byId.get(def.id.toLowerCase())?.extras ?? []) {
doc.appendMarkdown(
`\n\n${t("Also defined as **{0}** · {1}", extra.type, originLabel(extra))}`,
);
}
return make(
typePrefix ? `${typePrefix}${def.id}` : def.id,
vscode.CompletionItemKind.Value,
t("{0} · {1}", def.type, origin),
doc.value,
);
});
return this.limitItems(items, scored.length);
return this.limitItems(items, byId.size);
}
private defineItems(
@@ -512,17 +593,25 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const lower = prefix.replace(/^[=$]*/, "").toLowerCase();
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>();
for (const defines of [idx.local?.defines, idx.defines]) {
if (!defines) continue;
for (const [key, defs] of defines) {
if (!key.includes(lower)) continue;
const def = defs[0];
const dedupe = `${def.name.toLowerCase()}:${def.file}:${def.line}`;
const dedupe = def.name.toLowerCase();
if (seen.has(dedupe)) continue;
seen.add(dedupe);
const label = `$${def.name}`;
const item = make(label, vscode.CompletionItemKind.Constant, "Define", def.value);
const item = make(
label,
vscode.CompletionItemKind.Constant,
t("Define"),
def.value,
);
item.insertText = label;
items.push(item);
}
@@ -545,8 +634,10 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
make(
id,
vscode.CompletionItemKind.Value,
"local module",
"Pipeline-local id in the enclosing GameObject (includes xi:include targets).",
t("local module"),
t(
"Pipeline-local id in the enclosing GameObject (includes xi:include targets).",
),
),
);
}
@@ -579,12 +670,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
const el = ctx.element;
if (!el) return [];
const elType = resolveElementType(el);
const info = elType ? model.typeInfo(elType) : undefined;
const info = elType ? model.contentInfoOfType(elType) : undefined;
// Simple-content element: the text between the tags is the value itself
// (e.g. <CreateObject>CrateDebris_01</CreateObject>), so offer value
// completions (asset ids / enums / defines) instead of child elements.
if (info?.kind === "simple") {
// Simple-content element (simple type or simpleContent complex type):
// the text between the tags is the value itself (e.g.
// <CreateObject>CrateDebris_01</CreateObject> or
// <Sound>AudioFile</Sound>), so offer value completions (asset ids /
// enums / defines) instead of child elements.
if (info) {
return this.simpleContentItems(el, elType, info, document, position, idx);
}
@@ -594,7 +687,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
private simpleContentItems(
el: XmlElement,
elType: string | null,
info: SimpleTypeInfo,
info: ContentTypeInfo,
document: vscode.TextDocument,
position: vscode.Position,
idx: ModIndex | null,
@@ -639,7 +732,13 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
if (isList) return this.listEnumItems(info, rawPrefix, seg, valueRange, make);
return info.enumValues
.filter((v) => v.toLowerCase().startsWith(seg.token.toLowerCase()))
.map((v) => make(v, vscode.CompletionItemKind.EnumMember, elType ?? "enum"));
.map((v) =>
make(
v,
vscode.CompletionItemKind.EnumMember,
elType ?? t("enum"),
),
);
}
if (idx && info.allowsDefine) {
return this.defineItems(idx, seg.token, make);
@@ -673,13 +772,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
item.insertText = this.elementSnippet(child.name, child.type, !typedOpen);
const type = child.type;
const info = type ? model.typeInfo(type) : undefined;
item.detail = type ? `RA3 XML · ${type}` : "RA3 XML";
item.detail = type ? t("RA3 XML · {0}", type) : t("RA3 XML");
const doc = child.doc || (info?.kind === "complex" ? info.doc : "");
if (doc) item.documentation = new vscode.MarkdownString(doc);
if (info?.kind === "simple" && this.simpleContentValueKind(info)) {
const contentInfo = type ? model.contentInfoOfType(type) : undefined;
if (contentInfo && this.simpleContentValueKind(contentInfo)) {
item.command = {
command: "editor.action.triggerSuggest",
title: "Suggest content value",
title: t("Suggest content value"),
};
}
items.push(item);
@@ -687,7 +787,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
return items;
}
private simpleContentValueKind(info: SimpleTypeInfo): boolean {
private simpleContentValueKind(info: ContentTypeInfo): boolean {
return (
info.refType != null ||
info.enumValues.length > 0 ||
@@ -701,6 +801,19 @@ interface ScoredDef {
score: number;
}
function originLabelText(origin: AssetDef["origin"]): string {
switch (origin) {
case "project":
return t("project");
case "sdk":
return t("SDK");
case "manifest":
return t("manifest");
default:
return origin;
}
}
function compareScoredDefs(a: ScoredDef, b: ScoredDef): number {
return a.score - b.score || a.def.id.localeCompare(b.def.id);
}
@@ -816,9 +929,17 @@ function attributeInsertLayout(
const wordStart = findAttributeWordStart(text, offset, el.start);
const attrs = el.attrs;
const complete = attrs.filter((a) => a.hasValue);
const last = complete.length ? complete[complete.length - 1] : null;
// 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 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
// own line. Fall back to the last complete attribute for inline elements.
@@ -838,31 +959,34 @@ function attributeInsertLayout(
? text.slice(0, anchor.nameStart).match(/[ \t]*$/)?.[0] ?? ""
: "";
if (!onePerLine) {
if (alreadyOnNewLine) {
// Inline-style file, but the user started a new line: keep whatever
// indentation they already typed.
return { rangeStart: wordStart, prefix: "" };
}
const needsSpace = wordStart > el.start + 1 && !/\s/.test(text[wordStart - 1]);
return { rangeStart: wordStart, prefix: needsSpace ? " " : "" };
}
if (alreadyOnNewLine) {
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
return { rangeStart: lineStart, prefix: indent };
// 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;
return { rangeStart: lineStart, prefix: indent };
}
return { rangeStart: wordStart, prefix: "" };
}
// Insert on a new line. The editor adds the current line's indentation to
// the new line, so we must NOT embed our own indent here (it would
// compound). If whitespace was typed between the previous attribute and
// the cursor (e.g. a space used to trigger the suggestion popup), consume
// it so it does not linger as a trailing space.
const wsStart =
lastEnd >= 0 &&
wordStart > lastEnd &&
/^[ \t]*$/.test(text.slice(lastEnd, wordStart))
? lastEnd
: wordStart;
return { rangeStart: wsStart, prefix: "\n" };
// 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
// the cursor (e.g. a space used to trigger the suggestion popup), consume
// it so it does not linger as a trailing space.
const wsStart =
lastEnd >= 0 &&
wordStart > lastEnd &&
/^[ \t]*$/.test(text.slice(lastEnd, wordStart))
? lastEnd
: wordStart;
return { rangeStart: wsStart, prefix: "\n" };
}
const needsSpace = wordStart > el.start + 1 && !/\s/.test(text[wordStart - 1]);
return { rangeStart: wordStart, prefix: needsSpace ? " " : "" };
}
function attributeEndOffset(attr: XmlAttribute): number {
+208 -55
View File
@@ -1,8 +1,13 @@
import * as vscode from "vscode";
import { dirname } from "node:path";
import { LineMap, type XmlElement } from "../language/xmlParser";
import {
LineMap,
type XmlElement,
type XmlParseError,
} from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import { resolveSource, buildSearchPaths } from "../indexer/includeResolver";
import { validateSdkPath } from "../sdk";
import * as model from "../model/schemaModel";
import type { ModWorkspace } from "../workspace";
import type { ModIndex } from "../indexer/types";
@@ -15,14 +20,26 @@ import {
} from "../indexer/refs";
import type { LogicalElement } from "../indexer/logicalTree";
import { scopePathKey } from "../indexer/localScope";
import { t } from "../localize";
export class Ra3Diagnostics {
private collection: vscode.DiagnosticCollection;
private sdkCache: { path: string; unusable: boolean } | null = null;
constructor(private ws: ModWorkspace) {
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> {
if (!this.ws.isRa3Workspace()) {
this.collection.set(document.uri, []);
@@ -45,7 +62,7 @@ export class Ra3Diagnostics {
new vscode.Position(err.line, err.character),
new vscode.Position(err.line, err.character + 1),
),
err.message,
this.parseErrorMessage(err),
vscode.DiagnosticSeverity.Error,
"xml-syntax",
),
@@ -67,6 +84,42 @@ export class Ra3Diagnostics {
this.collection.set(document.uri, diags);
}
private parseErrorMessage(err: XmlParseError): string {
switch (err.code) {
case "content-before-root":
return t("Content is not allowed before the root element");
case "unterminated-comment":
return t("Unterminated comment");
case "unterminated-cdata":
return t("Unterminated CDATA section");
case "unterminated-doctype":
return t("Unterminated DOCTYPE");
case "unterminated-processing-instruction":
return t("Unterminated processing instruction");
case "unterminated-closing-tag":
return t("Unterminated closing tag");
case "unterminated-start-tag":
return t("Unterminated start tag");
case "malformed-markup":
return t("Malformed markup");
case "unexpected-closing-tag":
return t(
"Unexpected closing tag </{0}>",
err.params?.name ?? "",
);
case "mismatched-closing-tag":
return t(
"Mismatched closing tag: expected </{0}>, found </{1}>",
err.params?.expected ?? "",
err.params?.found ?? "",
);
case "element-never-closed":
return t("Element <{0}> is never closed", err.params?.name ?? "");
default:
return err.message;
}
}
clear(uri: vscode.Uri): void {
this.collection.delete(uri);
}
@@ -86,6 +139,17 @@ export class Ra3Diagnostics {
): void {
const settings = this.ws.settings;
const fileDuplicates = new Map<string, { line: number }>();
// A file whose root is not AssetDeclaration is an xi:include fragment
// (e.g. Data/Includes/GenericCelestialBuildingSuicide.xml). It is not a
// standalone RA3 document: top-level id / duplicate checks do not apply,
// and references/defines can only be resolved in the includer's context.
// When the fragment root itself is a known XSD element (e.g.
// CreateObjectDie), the root supplies the type context for its whole
// subtree, so element/attribute validation is still reliable.
const rootName = root ? localName(root.name) : "";
const isFragment = rootName !== "AssetDeclaration";
const validateTree =
!isFragment || (root !== null && model.elementTypeName(rootName) !== null);
for (const el of doc.elements) {
// Only report diagnostics for nodes that belong to the document being
@@ -102,13 +166,13 @@ export class Ra3Diagnostics {
const range = tagRange(document, el);
// Top-level assets must have an id.
if (isTopLevel) {
if (!isFragment && isTopLevel) {
const idAttr = el.attrs.find((a) => a.name === "id");
if (!idAttr || !idAttr.value) {
diags.push(
this.diag(
range,
`Top-level asset <${local}> requires an id attribute`,
t("Top-level asset <{0}> requires an id attribute", local),
vscode.DiagnosticSeverity.Error,
"missing-id",
),
@@ -124,7 +188,12 @@ export class Ra3Diagnostics {
diags.push(
this.diag(
where,
`Duplicate id "${idAttr.value}" for <${local}> (also defined on line ${prev.line})`,
t(
'Duplicate id "{0}" for <{1}> (also defined on line {2})',
idAttr.value,
local,
prev.line,
),
vscode.DiagnosticSeverity.Error,
"duplicate-id",
),
@@ -148,13 +217,13 @@ export class Ra3Diagnostics {
const isXsdElement = model.isXsdElementName(el.name);
// Unknown element.
if (settings.diagnoseUnknownElements && isXsdElement) {
if (settings.diagnoseUnknownElements && isXsdElement && validateTree) {
const knownType = model.elementTypeName(local);
if (!knownType) {
diags.push(
this.diag(
range,
`Unknown element <${local}> (not in the RA3 XSD model)`,
t("Unknown element <{0}> (not in the RA3 XSD model)", local),
vscode.DiagnosticSeverity.Warning,
"unknown-element",
),
@@ -163,7 +232,7 @@ export class Ra3Diagnostics {
}
// Attributes.
if (isXsdElement) {
if (isXsdElement && validateTree) {
const elType = resolveElementType(el);
const knownAttrs = model.attributesOfType(elType);
const knownNames = new Set(knownAttrs.map((a) => a.name));
@@ -175,41 +244,84 @@ export class Ra3Diagnostics {
continue;
}
if (settings.diagnoseUnknownElements && !knownNames.has(aName)) {
diags.push(
this.diag(
new vscode.Range(
document.positionAt(attr.nameStart),
document.positionAt(attr.nameEnd),
diags.push(
this.diag(
new vscode.Range(
document.positionAt(attr.nameStart),
document.positionAt(attr.nameEnd),
),
t('Unknown attribute "{0}" for <{1}>', aName, local),
vscode.DiagnosticSeverity.Warning,
"unknown-attribute",
),
`Unknown attribute "${aName}" for <${local}>`,
vscode.DiagnosticSeverity.Warning,
"unknown-attribute",
),
);
}
if (!attr.hasValue) continue;
this.checkValueReferences(
elType,
attr.name,
attr.value,
attr,
document,
idx,
diags,
provisional,
);
// References and $DEFINE constants inside fragments depend on the
// includer's context; don't report them until P1 resolves the real
// include sites.
if (!isFragment) {
this.checkValueReferences(
elType,
attr.name,
attr.value,
attr,
document,
idx,
diags,
provisional,
);
}
}
if (!isFragment) {
this.checkContentReferences(el, elType, document, idx, diags, provisional);
}
this.checkContentReferences(el, elType, document, idx, diags, provisional);
}
// Include-specific checks.
if (local === "Include") {
this.checkInclude(el, document, idx, diags);
} else if (local === "include" && el.name.toLowerCase().startsWith("xi:")) {
this.checkXiInclude(el, document, idx, diags);
}
}
}
private checkXiInclude(
el: XmlElement,
document: vscode.TextDocument,
idx: ModIndex | null,
diags: vscode.Diagnostic[],
): void {
const hrefAttr = el.attrs.find((a) => a.name === "href");
if (!hrefAttr?.hasValue) return;
const searchPaths = idx
? buildSearchPaths(idx.sdkDir, idx.projectDir)
: this.ws.searchPaths(document);
if (!searchPaths) return;
const resolved = resolveSource(
hrefAttr.value,
dirname(document.uri.fsPath),
searchPaths,
);
if (resolved.path) return;
if (/^(DATA|ART|AUDIO):/i.test(hrefAttr.value.trim())) {
if (this.sdkUnusable()) return;
}
diags.push(
this.diag(
new vscode.Range(
document.positionAt(hrefAttr.valueStart),
document.positionAt(hrefAttr.valueEnd),
),
t("xi:include target not found: {0}", hrefAttr.value),
vscode.DiagnosticSeverity.Warning,
"include-not-found",
),
);
}
private checkCrossFileDuplicate(
type: string,
id: string,
@@ -246,8 +358,12 @@ export class Ra3Diagnostics {
diags.push(
this.diag(
range,
`Duplicate id "${id}" for <${type}> (also defined in ${other.file})` +
(provisional ? " (based on a partial index)" : ""),
t(
'Duplicate id "{0}" for <{1}> (also defined in {2})',
id,
type,
other.file,
) + (provisional ? t(" (based on a partial index)") : ""),
vscode.DiagnosticSeverity.Error,
"duplicate-id",
),
@@ -284,8 +400,8 @@ export class Ra3Diagnostics {
diags.push(
this.diag(
range,
`Undefined define "$${m[1]}"` +
(provisional ? " (index incomplete — may be a false positive)" : ""),
t('Undefined define "${0}"', m[1]) +
(provisional ? t(" (index incomplete — may be a false positive)") : ""),
vscode.DiagnosticSeverity.Warning,
code,
),
@@ -306,20 +422,18 @@ export class Ra3Diagnostics {
const attrRef = model
.attributesOfType(elType)
.find((a) => a.name === attrName);
const expected = attrRef?.refType
? `of type \`${attrRef.refType}\``
: attrRef?.isRef
? "of the expected declared type"
: "matching";
const code = provisional ? "unresolved-reference-indexing" : "unresolved-reference";
const baseMessage = anyDef
? `Reference "${value}" has no definition ${expected} (ids with the same name exist for other types)`
: `Unresolved reference "${value}" (not found in the current index)`;
const baseMessage = unresolvedReferenceMessage(
value,
anyDef,
attrRef?.refType ?? null,
attrRef?.isRef ?? false,
);
diags.push(
this.diag(
range,
provisional
? `${baseMessage} (index incomplete — may be a false positive)`
? baseMessage + t(" (index incomplete — may be a false positive)")
: baseMessage,
severity === "warning"
? vscode.DiagnosticSeverity.Warning
@@ -337,10 +451,11 @@ export class Ra3Diagnostics {
diags: vscode.Diagnostic[],
provisional: boolean,
): void {
// Only simple-content elements carry a text value; complex elements'
// "content" is child markup and must not be scanned for value refs.
const info = elType ? model.typeInfo(elType) : undefined;
if (info?.kind !== "simple") return;
// Only simple-content elements carry a text value (simple types and
// simpleContent complex types); ordinary complex elements' "content" is
// child markup and must not be scanned for value refs.
const info = elType ? model.contentInfoOfType(elType) : undefined;
if (!info) return;
if (el.selfClosing || el.closeTagStart < 0) return;
const text = document.getText();
const raw = text.slice(el.startTagEnd, el.closeTagStart);
@@ -367,8 +482,8 @@ export class Ra3Diagnostics {
diags.push(
this.diag(
range,
`Undefined define "$${m[1]}"` +
(provisional ? " (index incomplete — may be a false positive)" : ""),
t('Undefined define "${0}"', m[1]) +
(provisional ? t(" (index incomplete — may be a false positive)") : ""),
vscode.DiagnosticSeverity.Warning,
code,
),
@@ -386,16 +501,18 @@ export class Ra3Diagnostics {
(idx.local?.assetsById.has(value.toLowerCase()) ?? false) ||
idx.assetsById.has(value.toLowerCase());
const refType = info.refType;
const expected = refType ? `of type \`${refType}\`` : "of the expected declared type";
const code = provisional ? "unresolved-reference-indexing" : "unresolved-reference";
const baseMessage = anyDef
? `Reference "${value}" has no definition ${expected} (ids with the same name exist for other types)`
: `Unresolved reference "${value}" (not found in the current index)`;
const baseMessage = unresolvedReferenceMessage(
value,
anyDef,
refType ?? null,
!refType,
);
diags.push(
this.diag(
range,
provisional
? `${baseMessage} (index incomplete — may be a false positive)`
? baseMessage + t(" (index incomplete — may be a false positive)")
: baseMessage,
severity === "warning"
? vscode.DiagnosticSeverity.Warning
@@ -420,7 +537,10 @@ export class Ra3Diagnostics {
document.positionAt(typeAttr.valueStart),
document.positionAt(typeAttr.valueEnd),
),
`Invalid Include type "${typeAttr.value}" (expected reference, instance or all)`,
t(
'Invalid Include type "{0}" (expected reference, instance or all)',
typeAttr.value,
),
vscode.DiagnosticSeverity.Error,
"include-type",
),
@@ -429,7 +549,7 @@ export class Ra3Diagnostics {
if (!sourceAttr?.hasValue) return;
const searchPaths = idx
? buildSearchPaths(idx.sdkDir, idx.projectDir)
: this.ws.searchPaths();
: this.ws.searchPaths(document);
if (!searchPaths) return;
const resolved = resolveSource(
sourceAttr.value,
@@ -439,13 +559,18 @@ export class Ra3Diagnostics {
const candidateHit =
idx?.sourceCandidates.some((c) => c.source === sourceAttr.value) ?? false;
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(
this.diag(
new vscode.Range(
document.positionAt(sourceAttr.valueStart),
document.positionAt(sourceAttr.valueEnd),
),
`Include target not found: ${sourceAttr.value}`,
t("Include target not found: {0}", sourceAttr.value),
vscode.DiagnosticSeverity.Warning,
"include-not-found",
),
@@ -471,6 +596,34 @@ function localName(tag: string): string {
return idx >= 0 ? tag.slice(idx + 1) : tag;
}
function unresolvedReferenceMessage(
value: string,
anyDef: boolean,
refType: string | null,
isRef: boolean,
): string {
if (anyDef) {
if (refType) {
return t(
'Reference "{0}" has no definition of type `{1}` (ids with the same name exist for other types)',
value,
refType,
);
}
if (isRef) {
return t(
'Reference "{0}" has no definition of the expected declared type (ids with the same name exist for other types)',
value,
);
}
return t(
'Reference "{0}" has no matching definition (ids with the same name exist for other types)',
value,
);
}
return t('Unresolved reference "{0}" (not found in the current index)', value);
}
function tagRange(document: vscode.TextDocument, el: XmlElement): vscode.Range {
return new vscode.Range(
document.positionAt(el.start),
+243
View File
@@ -0,0 +1,243 @@
/**
* "RA3 Mod XML: Find asset…" — quick-pick search over indexed assets.
*
* One picker does both the typing and the selection: results are filtered by
* the extension itself rather than by VS Code's built-in label filter, so the
* `Type:Id` form (where the type never appears in the visible label) keeps
* working. Accepting a result jumps to its definition; when the same
* `type:id` exists in several places (mod override + SDK + compiled
* manifest) a second picker chooses which definition to open.
*/
import * as vscode from "vscode";
import { relative } from "node:path";
import {
ASSET_SEARCH_LIMIT,
assetDefsForCandidate,
collectAssetSearchCandidates,
parseAssetSearchQuery,
searchAssetCandidates,
type AssetSearchCandidate,
type AssetSearchQuery,
} from "./assetSearch";
import { assetDefinitionLocation } from "./navigation";
import { referenceSitesForDef } from "../indexer/referenceIndex";
import type { AssetDef, ModIndex } from "../indexer/types";
import type { ModWorkspace } from "../workspace";
import { t } from "../localize";
interface AssetPickItem extends vscode.QuickPickItem {
candidate: AssetSearchCandidate;
}
interface DefinitionPickItem extends vscode.QuickPickItem {
def: AssetDef;
}
/** Entry point for the `ra3modxml.findAsset` command. */
export async function findAsset(
ws: ModWorkspace,
initialQuery = "",
): Promise<void> {
const index = await searchIndexFor(ws);
if (!index) {
void vscode.window.showInformationMessage(
t("RA3 Mod XML: no index available yet."),
);
return;
}
const candidates = collectAssetSearchCandidates(index);
if (!candidates.length) {
void vscode.window.showInformationMessage(
t("RA3 Mod XML: the index has no assets yet — wait for indexing to finish."),
);
return;
}
const picked = await pickAsset(index, candidates, initialQuery.trim());
if (!picked) return;
await revealAsset(ws, index, picked);
}
/**
* Index used for searching: the active XML document's project index with its
* local overlay attached when possible (so unsaved edits are searchable),
* otherwise the active project's last published snapshot.
*/
async function searchIndexFor(ws: ModWorkspace): Promise<ModIndex | null> {
const editor = vscode.window.activeTextEditor;
if (editor && editor.document.languageId === "xml" && ws.isRa3Workspace()) {
try {
const scope = await ws.getScope(editor.document);
if (scope.merged) return scope.merged;
} catch {
// Fall through to the plain snapshot.
}
}
return ws.activeIndex();
}
function pickAsset(
index: ModIndex,
candidates: readonly AssetSearchCandidate[],
initialQuery: string,
): Promise<AssetSearchCandidate | undefined> {
return new Promise<AssetSearchCandidate | undefined>((resolve) => {
const picker = vscode.window.createQuickPick<AssetPickItem>();
picker.title = t("RA3 Mod XML: Find asset");
picker.placeholder = t(
"Id, partial id or Type:Id (e.g. WeaponTemplate:AssaultRifle)",
);
let settled = false;
const finish = (value: AssetSearchCandidate | undefined): void => {
if (settled) return;
settled = true;
resolve(value);
picker.dispose();
};
const refresh = (value: string): void => {
const query = parseAssetSearchQuery(value);
const { matches, total } = searchAssetCandidates(
candidates,
query,
ASSET_SEARCH_LIMIT,
);
picker.items = matches.map((candidate) => toPickItem(index, candidate));
picker.prompt = promptFor(query, matches.length, total);
if (picker.items.length) picker.activeItems = [picker.items[0]];
};
picker.onDidChangeValue((value) => refresh(value));
picker.onDidAccept(() => {
const item = picker.activeItems[0] ?? picker.items[0];
if (item) finish(item.candidate);
});
picker.onDidHide(() => finish(undefined));
picker.value = initialQuery;
refresh(initialQuery);
picker.show();
});
}
function toPickItem(
index: ModIndex,
candidate: AssetSearchCandidate,
): AssetPickItem {
const def = candidate.def;
const refs = referenceSitesForDef(index, def).length;
const description = [def.type, originLabel(def)];
if (refs > 0) {
description.push(refs === 1 ? t("1 reference") : t("{0} references", refs));
}
const detail = [`${displayPath(index.projectDir, def.file)}:${def.line}`];
if (candidate.definitionCount > 1) {
detail.push(t("{0} definitions", candidate.definitionCount));
}
return {
label: def.id,
description: description.join(" · "),
detail: detail.join(" · "),
// The picker filters the list itself: a `Type:Id` query never appears in
// the visible label, so VS Code's built-in filter must not hide it.
alwaysShow: true,
candidate,
};
}
function promptFor(
query: AssetSearchQuery,
shown: number,
total: number,
): string {
if (!query.type && !query.id) {
return t("Type an id, a partial id or Type:Id to search.");
}
if (total === 0) return t('No asset matches "{0}".', query.raw);
if (total > shown) {
return t(
"{0} of {1} matches — keep typing to narrow the list.",
shown,
total,
);
}
return total === 1 ? t("1 asset found.") : t("{0} assets found.", total);
}
/**
* Opens a search result. Definitions of the same `type:id` are offered when
* there is more than one (e.g. the mod override and the vanilla source).
*/
async function revealAsset(
ws: ModWorkspace,
index: ModIndex,
candidate: AssetSearchCandidate,
): Promise<void> {
const defs = assetDefsForCandidate(index, candidate);
let def = defs[0] ?? candidate.def;
if (defs.length > 1) {
const picked = await vscode.window.showQuickPick(
defs.map((d) => toDefinitionItem(index, d)),
{
title: `${def.type}:${def.id}`,
placeHolder: t("Select the definition to open"),
matchOnDescription: true,
},
);
if (!picked) return;
def = picked.def;
}
const location = await assetDefinitionLocation(ws, def, index);
if (!location) {
void vscode.window.showInformationMessage(
t(
"RA3 Mod XML: no XML source location for {0} ({1}).",
`${def.type}:${def.id}`,
displayPath(index.projectDir, def.file),
),
);
return;
}
const document = await vscode.workspace.openTextDocument(location.uri);
await vscode.window.showTextDocument(document, {
selection: location.range,
preview: true,
});
}
function toDefinitionItem(
index: ModIndex,
def: AssetDef,
): DefinitionPickItem {
return {
label: `${displayPath(index.projectDir, def.file)}:${def.line}`,
description: originLabel(def),
detail: `${def.type}:${def.id}`,
def,
};
}
function originLabel(def: AssetDef): string {
if (def.stream === "local") return t("local");
switch (def.origin) {
case "sdk":
return t("SDK");
case "manifest":
return t("manifest");
default:
return t("project");
}
}
function displayPath(projectDir: string, file: string): string {
const rel = relative(projectDir, file);
return rel && !rel.startsWith("..") ? rel : file;
}
+100 -49
View File
@@ -19,6 +19,7 @@ import {
import { scopePathKey, type DocumentScope } from "../indexer/localScope";
import { dirname } from "node:path";
import { buildSearchPaths, resolveSource } from "../indexer/includeResolver";
import { t } from "../localize";
export class Ra3HoverProvider implements vscode.HoverProvider {
constructor(private ws: ModWorkspace) {}
@@ -58,37 +59,50 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
// Element name.
const nameStart = el.start + 1;
if (offset >= nameStart && offset <= nameStart + el.name.length) {
return this.elementHover(el.name);
return this.elementHover(el.name, elType);
}
return null;
}
private elementHover(name: string): vscode.Hover | null {
private elementHover(
name: string,
resolvedType: string | null = null,
): vscode.Hover | null {
if (name.startsWith("xi:")) {
const md = new vscode.MarkdownString();
md.appendCodeblock(`<${name}>`, "xml");
md.appendMarkdown(
"XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.",
t(
"XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.",
),
);
return new vscode.Hover(md);
}
const type = model.elementTypeName(name);
const type =
resolvedType ??
(model.topLevelElementType(name) ?? model.elementTypeName(name));
const info = type ? model.typeInfo(type) : undefined;
const md = new vscode.MarkdownString();
md.appendCodeblock(`<${name}>`, "xml");
if (model.isTopLevelElement(name)) md.appendMarkdown(`**Top-level asset element** \n`);
if (model.isTopLevelElement(name)) {
md.appendMarkdown(`${t("**Top-level asset element**")} \n`);
}
if (info?.kind === "complex") {
if (info.doc) md.appendMarkdown(`${info.doc} \n`);
md.appendMarkdown(
`Attributes: ${info.attributes.length} · Children: ${info.children.length} \n`,
`${t(
"Attributes: {0} · Children: {1}",
model.attributesOfType(type).length,
info.children.length,
)} \n`,
);
if (info.base) md.appendMarkdown(`Extends: \`${info.base}\``);
if (info.base) md.appendMarkdown(t("Extends: `{0}`", info.base));
} else if (info?.kind === "simple") {
md.appendMarkdown(`Simple type: \`${type}\``);
md.appendMarkdown(t("Simple type: `{0}`", type ?? ""));
} else if (type) {
md.appendMarkdown(`Type: \`${type}\``);
md.appendMarkdown(t("Type: `{0}`", type));
} else {
md.appendMarkdown("Not found in the bundled XSD model.");
md.appendMarkdown(t("Not found in the bundled XSD model."));
}
return new vscode.Hover(md);
}
@@ -104,26 +118,38 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
md.appendCodeblock(`${attrName}=""`, "xml");
if (!attr) {
if (/^(xmlns|xai:)/.test(attrName)) {
md.appendMarkdown(`Namespace/instance attribute.`);
md.appendMarkdown(t("Namespace/instance attribute."));
return new vscode.Hover(md);
}
if (!model.isXsdElementName(el.name)) {
md.appendMarkdown(
`XInclude attribute (W3C XInclude namespace) — not part of the RA3 XSD model.`,
t(
"XInclude attribute (W3C XInclude namespace) — not part of the RA3 XSD model.",
),
);
return new vscode.Hover(md);
}
md.appendMarkdown("Unknown attribute for this element.");
md.appendMarkdown(t("Unknown attribute for this element."));
return new vscode.Hover(md);
}
if (attr.doc) md.appendMarkdown(`${attr.doc} \n`);
if (attr.required) md.appendMarkdown(`**Required** \n`);
if (attr.refType) md.appendMarkdown(`References assets of type \`${attr.refType}\` \n`);
if (attr.required) md.appendMarkdown(`${t("**Required**")} \n`);
if (attr.refType) {
md.appendMarkdown(
`${t("References assets of type `{0}`", attr.refType)} \n`,
);
}
if (attr.enumValues.length)
md.appendMarkdown(`Values: \`${attr.enumValues.join("`, `")}\` \n`);
if (attr.default != null) md.appendMarkdown(`Default: \`${attr.default}\` \n`);
if (attr.allowsDefine) md.appendMarkdown(`May use \`$DEFINE\` constants \n`);
md.appendMarkdown(`Type: \`${attr.type ?? "string"}\``);
md.appendMarkdown(
`${t("Values: `{0}`", attr.enumValues.join("`, `"))} \n`,
);
if (attr.default != null) {
md.appendMarkdown(`${t("Default: `{0}`", attr.default)} \n`);
}
if (attr.allowsDefine) {
md.appendMarkdown(`${t("May use `$DEFINE` constants")} \n`);
}
md.appendMarkdown(t("Type: `{0}`", attr.type ?? "string"));
return new vscode.Hover(md);
}
@@ -155,7 +181,7 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
) {
const searchPaths = idx
? buildSearchPaths(idx.sdkDir, idx.projectDir)
: this.ws.searchPaths();
: this.ws.searchPaths(document);
const resolved = searchPaths
? resolveSource(
value,
@@ -164,17 +190,19 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
).path
: null;
if (resolved) {
md.appendMarkdown(`**Include source** \n`);
md.appendMarkdown(`${t("**Include source**")} \n`);
md.appendCodeblock(resolved);
return new vscode.Hover(md);
}
const cand = idx?.sourceCandidates.find((c) => c.source === value);
if (cand) {
md.appendMarkdown(`**Include source** \n`);
md.appendMarkdown(`${t("**Include source**")} \n`);
md.appendCodeblock(cand.path);
return new vscode.Hover(md);
}
md.appendMarkdown(`Include source: \`${value}\` (not in candidate index)`);
md.appendMarkdown(
t("Include source: `{0}` (not in candidate index)", value),
);
return new vscode.Hover(md);
}
@@ -191,7 +219,7 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
if (!idx) {
if (isReferenceAttributeOfType(elType, attrName)) {
md.appendMarkdown(
"Index is still building — references cannot be resolved yet.",
t("Index is still building — references cannot be resolved yet."),
);
return new vscode.Hover(md);
}
@@ -204,12 +232,14 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
const attrRef = model
.attributesOfType(elType)
.find((a) => a.name === attrName);
const expected = attrRef?.refType
? ` of type \`${attrRef.refType}\``
: attrRef?.isRef
? " of the expected declared type"
: "";
return this.noDefinitionHover(expected);
return this.noDefinitionHover(
attrRef?.refType
? "typed"
: attrRef?.isRef
? "untyped"
: "generic",
attrRef?.refType ?? undefined,
);
}
}
@@ -235,16 +265,16 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
if (!isReferenceContentType(elType)) return null;
if (!idx) {
const md = new vscode.MarkdownString();
md.appendMarkdown("Index is still building — references cannot be resolved yet.");
md.appendMarkdown(
t("Index is still building — references cannot be resolved yet."),
);
return new vscode.Hover(md);
}
const targets = resolveContentReferenceTargets(idx, elType, value);
if (targets.length) return this.definitionsHover(targets, document);
const info = elType ? model.typeInfo(elType) : undefined;
const refType = info?.kind === "simple" ? info.refType : null;
return this.noDefinitionHover(
refType ? ` of type \`${refType}\`` : " of the expected declared type",
);
const info = elType ? model.contentInfoOfType(elType) : undefined;
const refType = info?.refType ?? null;
return this.noDefinitionHover(refType ? "typed" : "untyped", refType ?? undefined);
}
private defineHover(
@@ -254,10 +284,10 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
if (!defs?.length) return null;
const d = defs[0];
const md = new vscode.MarkdownString();
md.appendMarkdown(`**Define** \`$${d.name}\` \n`);
md.appendMarkdown(`${t("**Define** `{0}`", `$${d.name}`)} \n`);
md.appendCodeblock(d.value);
const rel = relativePath(document, d.file);
md.appendMarkdown(`Defined in \`${rel}:${d.line}\``);
md.appendMarkdown(t("Defined in `{0}:{1}`", rel, d.line));
return new vscode.Hover(md);
}
@@ -266,23 +296,44 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
document: vscode.TextDocument,
): vscode.Hover {
const md2 = new vscode.MarkdownString();
md2.appendMarkdown(`**${targets.length} definition${targets.length > 1 ? "s" : ""}** \n`);
md2.appendMarkdown(
`${targets.length === 1 ? t("**1 definition**") : t("**{0} definitions**", targets.length)} \n`,
);
for (const { def: d } of targets.slice(0, 8)) {
const loc =
d.origin === "manifest"
? `manifest \`${d.manifestSource ?? d.file}\``
: `\`${relativePath(document, d.file)}:${d.line}\``;
md2.appendMarkdown(`- \`${d.type}\` · ${loc} \n`);
? t("manifest `{0}`", d.manifestSource ?? d.file)
: t("`{0}:{1}`", relativePath(document, d.file), d.line);
md2.appendMarkdown(`${t("- `{0}` · {1}", d.type, loc)} \n`);
}
return new vscode.Hover(md2);
}
private noDefinitionHover(expected: string): vscode.Hover {
private noDefinitionHover(
kind: "typed" | "untyped" | "generic",
refType?: string,
): vscode.Hover {
const md = new vscode.MarkdownString();
md.appendMarkdown(
`No matching definition${expected} in the current index` +
" (may exist in a compiled manifest or vanilla data).",
);
if (kind === "typed" && refType) {
md.appendMarkdown(
t(
"No matching definition of type `{0}` in the current index (may exist in a compiled manifest or vanilla data).",
refType,
),
);
} else if (kind === "untyped") {
md.appendMarkdown(
t(
"No matching definition of the expected declared type in the current index (may exist in a compiled manifest or vanilla data).",
),
);
} else {
md.appendMarkdown(
t(
"No matching definition in the current index (may exist in a compiled manifest or vanilla data).",
),
);
}
return new vscode.Hover(md);
}
@@ -301,10 +352,10 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
const lineMap = scope.lineMaps.get(scopePathKey(target.sourceFile));
const line = lineMap ? lineMap.positionAt(idAttr.valueStart).line + 1 : 0;
const md = new vscode.MarkdownString();
md.appendMarkdown(`**Local pipeline id** \`${value}\` \n`);
md.appendMarkdown(`${t("**Local pipeline id** `{0}`", value)} \n`);
md.appendCodeblock(`<${target.name}>`);
const rel = relativePath(document, target.sourceFile);
md.appendMarkdown(`Defined in \`${rel}:${line}\``);
md.appendMarkdown(t("Defined in `{0}:{1}`", rel, line));
return new vscode.Hover(md);
}
}
+125 -8
View File
@@ -1,9 +1,15 @@
import * as vscode from "vscode";
import { dirname } from "node:path";
import { findElementAt, parseXml, textContentTokenAt } from "../language/xmlParser";
import {
findElementAt,
LineMap,
parseXml,
textContentTokenAt,
} from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import {
buildSearchPaths,
buildVanillaSearchPaths,
resolveSource,
type SearchPaths,
} from "../indexer/includeResolver";
@@ -23,6 +29,7 @@ import {
import { scopePathKey, type DocumentScope } from "../indexer/localScope";
import type { ModWorkspace } from "../workspace";
import type { AssetDef, ModIndex } from "../indexer/types";
import { t } from "../localize";
function searchPathsFor(idx: ModIndex): SearchPaths {
return buildSearchPaths(idx.sdkDir, idx.projectDir);
@@ -71,7 +78,9 @@ export class Ra3DefinitionProvider implements vscode.DefinitionProvider {
(el.name === "Include" && nameLower === "source") ||
(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
? resolveSource(value, dirname(document.uri.fsPath), searchPaths).path
: null;
@@ -178,11 +187,23 @@ async function assetDefLocation(
}
if (def.origin === "manifest") {
const src = def.manifestSource;
if (src?.toUpperCase().startsWith("DATA:")) {
const resolved = resolveSource(src, null, searchPathsFor(idx)).path;
if (src) {
// 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) {
// 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);
return precise ?? new vscode.Location(vscode.Uri.file(resolved), new vscode.Position(0, 0));
}
@@ -204,6 +225,94 @@ async function assetDefLocation(
);
}
/**
* Location of one asset definition for the asset search command.
*
* Unlike {@link assetDefLocation} this needs no document scope: an already
* open (possibly unsaved) document wins, then manifest sources are resolved
* with the SDK-only search paths, then the owning indexer's cached DOM is
* asked for the precise `id` attribute range. Returns null only when a
* manifest definition has no resolvable XML source at all.
*/
export async function assetDefinitionLocation(
ws: ModWorkspace,
def: AssetDef,
idx: ModIndex,
): Promise<vscode.Location | null> {
const open = vscode.workspace.textDocuments.find(
(doc) => scopePathKey(doc.uri.fsPath) === scopePathKey(def.file),
);
if (open) {
const precise = locationInText(open.uri, open.getText(), def.id);
if (precise) return precise;
}
// While a rebuild is running, avoid readDom() mutating the live indexer's
// caches mid-build; a line-based location is a fine temporary fallback.
if (ws.isBuilding) return lineLocation(def);
if (def.origin === "manifest") {
const src = def.manifestSource;
if (!src) return null;
// manifestSource is a vanilla build path: resolve it with SDK-only search
// paths so a mod file shadowing the same DATA: path cannot hijack the
// jump. Opening the binary manifest itself would not help the user, so a
// missing SDK source stays unresolved instead.
const resolved = resolveSource(
src,
null,
buildVanillaSearchPaths(idx.sdkDir),
).path;
if (!resolved) return null;
return (
(await locationInDocument(ws, resolved, def.id)) ??
new vscode.Location(vscode.Uri.file(resolved), new vscode.Position(0, 0))
);
}
return (await locationInDocument(ws, def.file, def.id)) ?? lineLocation(def);
}
function lineLocation(def: AssetDef): vscode.Location {
const line = Math.max(0, def.line - 1);
return new vscode.Location(
vscode.Uri.file(def.file),
new vscode.Range(new vscode.Position(line, 0), new vscode.Position(line, 1)),
);
}
/** Precise `id` range inside arbitrary (possibly unsaved) document text. */
function locationInText(
uri: vscode.Uri,
text: string,
id: string,
): vscode.Location | null {
const parsed = parseXml(text);
const wanted = id.toLowerCase();
const el = parsed.elements.find((e) =>
e.attrs.some((a) => a.name === "id" && a.value.toLowerCase() === wanted),
);
if (!el) return null;
const lineMap = new LineMap(text);
const idAttr = el.attrs.find((a) => a.name === "id");
if (idAttr?.hasValue) {
return new vscode.Location(
uri,
new vscode.Range(
toVscodePosition(lineMap.positionAt(idAttr.valueStart)),
toVscodePosition(lineMap.positionAt(idAttr.valueEnd)),
),
);
}
return new vscode.Location(
uri,
new vscode.Range(
toVscodePosition(lineMap.positionAt(el.start)),
toVscodePosition(lineMap.positionAt(el.startTagEnd)),
),
);
}
function locationInCurrentDocument(
scope: DocumentScope,
id: string,
@@ -305,8 +414,10 @@ export class Ra3DocumentLinkProvider implements vscode.DocumentLinkProvider {
_token: vscode.CancellationToken,
): Promise<vscode.DocumentLink[]> {
if (!this.ws.isRa3Workspace()) return [];
const idx = this.ws.index;
const searchPaths = idx ? searchPathsFor(idx) : this.ws.searchPaths();
const idx = this.ws.indexForDocument(document) ?? this.ws.activeIndex();
const searchPaths = idx
? searchPathsFor(idx)
: this.ws.searchPaths(document);
if (!searchPaths) return [];
const text = document.getText();
const doc = parseXml(text);
@@ -387,7 +498,13 @@ export class Ra3DocumentSymbolProvider implements vscode.DocumentSymbolProvider
document.positionAt(define.end),
);
symbols.push(
new vscode.DocumentSymbol(`$${name}`, "Define", vscode.SymbolKind.Constant, range, range),
new vscode.DocumentSymbol(
`$${name}`,
t("Define"),
vscode.SymbolKind.Constant,
range,
range,
),
);
}
}
+27 -15
View File
@@ -14,12 +14,13 @@ import {
textContentTokenAt,
} from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import { attributesOfType, typeInfo } from "../model/schemaModel";
import { attributesOfType, contentInfoOfType } from "../model/schemaModel";
import {
filterAndScoreDefs,
isReferenceAttributeOfType,
isReferenceContentType,
mergeLocalAndGlobalDefs,
normalizeReferenceId,
} from "../indexer/refs";
import {
referenceSitesForDef,
@@ -76,10 +77,10 @@ export function referenceContextAt(
if (elType && isReferenceContentType(elType)) {
const token = textContentTokenAt(text, el, offset);
if (token && !token.value.startsWith("$") && !token.value.startsWith("=")) {
const info = typeInfo(elType);
const info = contentInfoOfType(elType);
return {
id: token.value,
refType: info?.kind === "simple" ? info.refType : null,
refType: info?.refType ?? null,
selfType: null,
};
}
@@ -92,9 +93,10 @@ export function definitionsForReference(
idx: ModIndex,
ctx: ReferenceContext,
): AssetDef[] {
const lookupId = normalizeReferenceId(ctx.id);
const defs = mergeLocalAndGlobalDefs(
idx.local?.assetsById.get(ctx.id.toLowerCase()),
idx.assetsById.get(ctx.id.toLowerCase()),
idx.local?.assetsById.get(lookupId.toLowerCase()),
idx.assetsById.get(lookupId.toLowerCase()),
);
return filterAndScoreDefs(defs, ctx.refType, ctx.selfType).map((t) => t.def);
}
@@ -134,7 +136,9 @@ export async function sitesToLocations(
const locations: vscode.Location[] = [];
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;
for (const site of fileSites) {
if (lineMap) {
@@ -179,7 +183,7 @@ export async function findReferenceLocations(
position: vscode.Position,
): Promise<vscode.Location[] | null> {
if (!ws.isRa3Workspace()) return null;
scheduleRebuildIfRecordsDesync(ws, document);
scheduleRebuildIfRecordsDesync(ws.recordsSyncSurfaceFor(document), document);
const scope = await ws.getScope(document);
const idx = scope.merged;
if (!idx) return null;
@@ -207,16 +211,24 @@ export async function showReferencesForDef(
ws: ModWorkspace,
args: ShowReferencesArgs,
): 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;
const def: AssetDef = {
type: args.type,
// Same definition union as the lens count / Find All References.
const defs = definitionsForReference(idx, {
id: args.id,
file: args.file,
line: args.line,
origin: "project",
};
const sites = referenceSitesForDef(idx, def);
refType: null,
selfType: null,
});
const sites = collectReferenceSites(idx, defs);
const locations = await sitesToLocations(ws, sites);
await vscode.commands.executeCommand(
"editor.action.showReferences",
+12 -11
View File
@@ -3,6 +3,7 @@ import { relative } from "node:path";
import { findElementAt, parseXml } from "../language/xmlParser";
import { unreferencedByType } from "../indexer/referenceIndex";
import type { ModWorkspace } from "../workspace";
import { t } from "../localize";
interface TypePickItem extends vscode.QuickPickItem {
type: string;
@@ -23,31 +24,31 @@ export async function findUnreferencedAssets(
ws: ModWorkspace,
args?: { type?: string },
): Promise<void> {
if (!ws.isRa3Workspace() || !ws.index) {
const idx = ws.activeIndex();
if (!ws.isRa3Workspace() || !idx) {
void vscode.window.showInformationMessage(
"RA3 Mod XML: no index available yet.",
t("RA3 Mod XML: no index available yet."),
);
return;
}
const idx = ws.index;
const byType = unreferencedByType(idx);
let type = args?.type;
if (!type) {
if (!byType.size) {
void vscode.window.showInformationMessage(
"RA3 Mod XML: no unreferenced assets found.",
t("RA3 Mod XML: no unreferenced assets found."),
);
return;
}
const pickedType = await vscode.window.showQuickPick<TypePickItem>(
[...byType.entries()].map(([t, defs]) => ({
label: t,
description: `${defs.length} unreferenced`,
type: t,
[...byType.entries()].map(([typeName, defs]) => ({
label: typeName,
description: t("{0} unreferenced", defs.length),
type: typeName,
})),
{
placeHolder: "Select an asset type",
placeHolder: t("Select an asset type"),
matchOnDescription: true,
},
);
@@ -58,7 +59,7 @@ export async function findUnreferencedAssets(
const defs = byType.get(type) ?? [];
if (!defs.length) {
void vscode.window.showInformationMessage(
`RA3 Mod XML: no unreferenced ${type} assets found.`,
t("RA3 Mod XML: no unreferenced {0} assets found.", type),
);
return;
}
@@ -70,7 +71,7 @@ export async function findUnreferencedAssets(
line: d.line,
})),
{
placeHolder: `${type}: ${defs.length} unreferenced`,
placeHolder: t("{0}: {1} unreferenced", type, defs.length),
matchOnDescription: true,
},
);
+8
View File
@@ -148,6 +148,14 @@ export interface IndexRecordsCacheEntry {
* produced before this field existed.
*/
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):
* - every cached record stores a multi-signal stamp
* `{ size, mtimeMs, birthtimeMs, ctimeMs }`;
* - on load, each file is stat-validated (no content reads); mismatches and
* missing files are dropped and re-read during the build;
* - a cold start seeds the in-memory cache immediately (`load`) and runs the
* 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;
* - `ra3modxml.reindex` / `ra3modxml.clearCache` remain the final authority.
*
@@ -80,6 +82,22 @@ export interface DiskCacheLoadStats {
validated: number;
/** Records dropped because the file changed, moved or was deleted. */
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 {
@@ -100,21 +118,18 @@ export class DiskRecordsCache {
}
/**
* Loads and stat-validates the cache. Returns the kept records plus load
* statistics; missing/corrupt/key-mismatched caches yield an empty result
* instead of an error.
* Loads the cache file without validating entries. This is fast (read +
* gunzip + JSON parse) so a cold start can seed the in-memory records
* 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[];
stats: DiskCacheLoadStats;
}> {
const stats: DiskCacheLoadStats = {
fileExists: false,
keyMatched: false,
loaded: 0,
validated: 0,
dropped: 0,
};
const start = Date.now();
const stats = emptyLoadStats();
let raw: DiskCacheFile | null = null;
try {
const buf = await readFile(this.filePath);
@@ -132,15 +147,37 @@ export class DiskRecordsCache {
} catch {
// Missing or corrupt cache: fall through with an empty result.
}
stats.loadMs = Date.now() - start;
if (!raw) return { records: [], stats };
stats.keyMatched = true;
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[] = [];
for (let i = 0; i < raw.records.length; i += VALIDATE_CONCURRENCY) {
const chunk = raw.records.slice(i, i + VALIDATE_CONCURRENCY);
const invalidKeys: string[] = [];
for (let i = 0; i < records.length; i += VALIDATE_CONCURRENCY) {
const chunk = records.slice(i, i + VALIDATE_CONCURRENCY);
const results = await Promise.all(
chunk.map(async (rec): Promise<DiskCacheRecord | null> => {
chunk.map(async (rec, index): Promise<{ rec: DiskCacheRecord | null; index: number }> => {
try {
const s = await stat(rec.key);
if (
@@ -150,24 +187,50 @@ export class DiskRecordsCache {
s.birthtimeMs === rec.stat.birthtimeMs &&
s.ctimeMs === rec.stat.ctimeMs
) {
return rec;
return { rec, index };
}
} catch {
// File missing or inaccessible.
}
return null;
return { rec: null, index };
}),
);
for (const r of results) {
if (r) {
kept.push(r);
for (const { rec, index } of results) {
if (rec) {
kept.push(rec);
stats.validated++;
} else {
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). */
+36 -9
View File
@@ -44,39 +44,66 @@ export function buildSearchPaths(
): SearchPaths {
const modParentPath = resolve(projectDir, "..");
const modGranParent = resolve(modParentPath, "..");
const sdk = sdkDir && sdkDir.trim() ? resolve(sdkDir) : "";
const sdkItems = (items: string[]): string[] => (sdk ? items : []);
return {
DATA: [
sdkDir,
...sdkItems([sdk]),
modGranParent,
join(projectDir, "Data"),
join(sdkDir, "Mods"),
...sdkItems([join(sdk, "Mods")]),
modParentPath,
join(sdkDir, "SageXml"),
...sdkItems([join(sdk, "SageXml")]),
...(extra?.DATA ?? []),
],
ART: [
sdkDir,
...sdkItems([sdk]),
modGranParent,
join(projectDir, "Art1"),
join(projectDir, "Art"),
join(sdkDir, "Mods"),
...sdkItems([join(sdk, "Mods")]),
modParentPath,
join(sdkDir, "Art"),
...sdkItems([join(sdk, "Art")]),
...(extra?.ART ?? []),
],
AUDIO: [
sdkDir,
...sdkItems([sdk]),
modGranParent,
join(projectDir, "Audio1"),
join(projectDir, "Audio"),
join(sdkDir, "Mods"),
...sdkItems([join(sdk, "Mods")]),
modParentPath,
join(sdkDir, "Audio"),
...sdkItems([join(sdk, "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 } {
for (const prefix of PREFIXES) {
if (source.toUpperCase().startsWith(`${prefix}:`)) {
+92 -7
View File
@@ -27,6 +27,7 @@ import {
type ResolveResult,
type SearchPaths,
} from "./includeResolver";
import { validateSdkPath } from "../sdk";
import {
buildExistenceSnapshot,
type ExistenceSnapshot,
@@ -140,11 +141,17 @@ export class ModIndexer {
private visitedAll = new Set<string>();
private visitedInstance = 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) {
this.searchPaths = buildSearchPaths(opts.sdkDir, opts.projectDir, {
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.
this.docs = opts.documentCache ?? new DocumentCache();
this.recordsCache = opts.recordsCache ?? new IndexRecordsCache();
@@ -175,7 +182,27 @@ export class ModIndexer {
// ~2.6 GB of art assets on a mechanical drive).
if (trust) {
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);
if (cached) {
this.files.set(key, cached.file);
@@ -194,6 +221,7 @@ export class ModIndexer {
rec.stat.birthtimeMs === st.birthtimeMs &&
rec.stat.ctimeMs === st.ctimeMs
) {
rec.validated = true;
// Force rebuilds (Re-index workspace) verify full-XML content even
// when every stat signal matches: external drives (FAT32/exFAT) can
// 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> {
const start = Date.now();
this.buildRecords.clear();
this.suppressedSdkIncludeCount = 0;
// Root list only; directories are listed lazily on first query, so the
// XML phase does not pay an upfront recursive enumeration of the SDK.
this.existence = buildExistenceSnapshot(this.searchPaths);
@@ -499,6 +528,17 @@ export class ModIndexer {
}
}
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 ──
const candidatesStart = Date.now();
@@ -533,9 +573,17 @@ export class ModIndexer {
// global.xml, audio.xml placeholders) but only its shallow XML files are
// relevant. These candidates take precedence over same-named files found
// deeper in the search paths (e.g. SageXml/Static.xml).
const sdkRootXml = (await readdir(this.opts.sdkDir)).filter(
(f) => f.toLowerCase().endsWith(".xml"),
);
let sdkRootXml: string[] = [];
if (this.opts.sdkDir) {
try {
sdkRootXml = (await readdir(this.opts.sdkDir)).filter(
(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) => ({
source: `DATA:${f}`,
path: resolve(this.opts.sdkDir, f),
@@ -778,6 +826,10 @@ export class ModIndexer {
for (const inc of records.includes) {
const resolved = this.resolveCached(inc.source, dirname(file));
if (!resolved.path) {
if (this.shouldSuppressMissingInclude(inc.source)) {
this.suppressedSdkIncludeCount++;
continue;
}
this.diagnostics.push({
file,
line: inc.line,
@@ -808,6 +860,10 @@ export class ModIndexer {
for (const xi of records.nestedXiIncludes) {
const resolved = this.resolveCached(xi.href, dirname(file));
if (!resolved.path) {
if (this.shouldSuppressMissingInclude(xi.href)) {
this.suppressedSdkIncludeCount++;
continue;
}
this.diagnostics.push({
file,
line: xi.line,
@@ -839,6 +895,10 @@ export class ModIndexer {
): Promise<void> {
const resolved = this.resolveCached(xi.href, dirname(parentFile));
if (!resolved.path) {
if (this.shouldSuppressMissingInclude(xi.href)) {
this.suppressedSdkIncludeCount++;
return;
}
this.diagnostics.push({
file: parentFile,
line: xi.line,
@@ -930,12 +990,19 @@ export class ModIndexer {
private originOf(path: string): "project" | "sdk" {
const p = resolve(path).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 (sdk && p.startsWith(sdk + "\\")) return "sdk";
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 {
// Keep the original case: type names are matched against the XSD model.
const typeKey = def.type;
@@ -947,14 +1014,32 @@ export class ModIndexer {
}
const arr = byId.get(idKey);
if (arr) {
if (arr.some((a) => a.file === def.file && a.line === def.line)) return;
if (
arr.some(
(a) =>
a.type === def.type &&
a.file === def.file &&
a.line === def.line,
)
) {
return;
}
arr.push(def);
} else {
byId.set(idKey, [def]);
}
const all = this.assetsById.get(idKey);
if (all) {
if (all.some((a) => a.file === def.file && a.line === def.line)) return;
if (
all.some(
(a) =>
a.type === def.type &&
a.file === def.file &&
a.line === def.line,
)
) {
return;
}
all.push(def);
} else {
this.assetsById.set(idKey, [def]);
+21 -3
View File
@@ -244,14 +244,32 @@ class OverlayBuilder {
}
const arr = byId.get(idKey);
if (arr) {
if (arr.some((a) => a.file === def.file && a.line === def.line)) return;
if (
arr.some(
(a) =>
a.type === def.type &&
a.file === def.file &&
a.line === def.line,
)
) {
return;
}
arr.push(def);
} else {
byId.set(idKey, [def]);
}
const all = this.overlay.assetsById.get(idKey);
if (all) {
if (all.some((a) => a.file === def.file && a.line === def.line)) return;
if (
all.some(
(a) =>
a.type === def.type &&
a.file === def.file &&
a.line === def.line,
)
) {
return;
}
all.push(def);
} else {
this.overlay.assetsById.set(idKey, [def]);
@@ -261,7 +279,7 @@ class OverlayBuilder {
private originOf(path: string): "project" | "sdk" | "manifest" {
const p = resolve(path).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 (sdk && p.startsWith(sdk + "\\")) return "sdk";
return "project";
+20 -6
View File
@@ -169,12 +169,26 @@ async function expandXi(
if (!target?.parse?.root) return;
const xpointer = xi.attrs.find((a) => a.name === "xpointer")?.value ?? "";
const selected = xpointer
? findXPointerContainer(target.parse, xpointer)?.children ?? []
: target.parse.root.children;
for (const sel of selected) {
await handleChild(sel, logicalParent, resolved, depth + 1, ctx, elements, stack);
if (xpointer) {
const container = findXPointerContainer(target.parse, xpointer);
if (!container) return;
for (const sel of container.children) {
await handleChild(sel, logicalParent, resolved, depth + 1, ctx, elements, stack);
}
} else {
// XInclude semantics: without an xpointer the whole target document is
// included, i.e. its root element replaces the <xi:include> node.
// RA3 fragments such as GenericCelestialBuildingSuicide.xml rely on this
// to splice the module element itself (CreateObjectDie) into the parent.
await handleChild(
target.parse.root,
logicalParent,
resolved,
depth + 1,
ctx,
elements,
stack,
);
}
} finally {
stack.delete(key);
+2
View File
@@ -2,6 +2,8 @@
* Parser for SAGE `.manifest` files, ported from OpenSAGE
* (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
* 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.
+3 -3
View File
@@ -12,7 +12,7 @@
import type { LineMap, XmlDocument } from "../language/xmlParser";
import type { ShallowDocument } from "./shallowScan";
import { attributesOfType, typeInfo } from "../model/schemaModel";
import { attributesOfType, contentInfoOfType } from "../model/schemaModel";
import { resolveElementType } from "../language/typeContext";
import {
isReferenceAttributeOfType,
@@ -246,10 +246,10 @@ function collectReferenceRecords(
continue;
}
const start = el.startTagEnd + raw.indexOf(value);
const info = typeInfo(elType);
const info = contentInfoOfType(elType);
out.push({
kind: "content",
refType: info?.kind === "simple" ? info.refType : null,
refType: info?.refType ?? null,
selfType: null,
value,
line: lineOf(lineMap, start),
+14 -8
View File
@@ -16,9 +16,10 @@ import { extractIndexRecords, type IndexRecords } from "./records";
import {
filterAndScoreDefs,
isReferenceTargetType,
normalizeReferenceId,
type ReferenceLookup,
} from "./refs";
import { buildSearchPaths, resolveSource } from "./includeResolver";
import { buildVanillaSearchPaths, resolveSource } from "./includeResolver";
import { normKey, recordsHash } from "./caches";
import { LineMap, parseXml } from "../language/xmlParser";
import type { AssetDef, ModIndex, ReferenceSite } from "./types";
@@ -50,7 +51,9 @@ export function buildReferenceIndex(
const map = new Map<string, ReferenceSite[]>();
for (const { file, records } of sources) {
for (const ref of records.references) {
const defs = lookup.assetsById.get(ref.value.toLowerCase());
const defs = lookup.assetsById.get(
normalizeReferenceId(ref.value).toLowerCase(),
);
if (!defs?.length) continue;
const targets = filterAndScoreDefs(defs, ref.refType, ref.selfType);
for (const target of targets) {
@@ -90,10 +93,13 @@ function normFileKey(path: string): string {
*
* 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
* `manifestSource`. A manifest asset with 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).
* `manifestSource`. `manifestSource` is resolved with the SDK-only search
* paths (not the current mod's BAB order), so a mod file shadowing the same
* DATA: path is never mistaken for the vanilla source. A manifest asset with
* 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(
idx: ModIndex,
@@ -104,13 +110,13 @@ export function referenceSitesForDefinition(
if (!byId?.length) return sites;
const defFile = normFileKey(def.file);
const searchPaths = buildSearchPaths(idx.sdkDir, idx.projectDir);
const vanillaPaths = buildVanillaSearchPaths(idx.sdkDir);
const seen = new Set(
sites.map((s) => `${s.file}\u0000${s.start}\u0000${s.end}\u0000${s.kind}`),
);
for (const other of byId) {
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;
for (const site of referenceSitesForDef(idx, other)) {
const key = `${site.file}\u0000${site.start}\u0000${site.end}\u0000${site.kind}`;
+56 -15
View File
@@ -2,7 +2,9 @@ import {
allTypeNames,
attributesOfType,
canonicalTypeName,
contentInfoOfType,
elementTypeName,
isAssetType,
isAssignableTo,
typeChain,
typeInfo,
@@ -14,6 +16,23 @@ export interface ReferenceTarget {
score: number;
}
/**
* Normalizes a reference value that may use the manifest-style qualified
* form `Type:Id` (e.g. `inheritFrom="AudioEvent:BaseSoundEffect"`,
* `Sound="AudioEvent:JAP_Refinery_Select"` or
* `Side="PlayerTemplate:Allies"`). XML asset ids never contain ":" (the same
* InstanceId rule the manifest parser relies on), so the referenceable id is
* the last colon-separated segment, exactly like `deriveAssetId`. Plain ids
* are returned unchanged. A trailing colon with an empty remainder is left
* unchanged so a half-typed value cannot accidentally match an id.
*/
export function normalizeReferenceId(value: string): string {
const idx = value.lastIndexOf(":");
if (idx < 0) return value;
const id = value.slice(idx + 1);
return id.length > 0 ? id : value;
}
/**
* The subset of `ModIndex` that reference resolution needs. Kept narrow so
* the reverse reference index can resolve records against the indexer's live
@@ -74,7 +93,7 @@ export function isReferenceAttributeOfType(
typeName: string | null,
attrName: string,
): boolean {
if (attrName.toLowerCase() === "inheritfrom") return true;
if (attrName.toLowerCase() === "inheritfrom") return isAssetType(typeName);
const attr = attributesOfType(typeName).find((a) => a.name === attrName);
if (attr == null || !(attr.refType != null || attr.isRef)) return false;
// Definitions (id) and pipeline-local references (Poid) are not references
@@ -114,9 +133,10 @@ export function resolveReferenceTargetsForType(
attrName: string,
id: string,
): ReferenceTarget[] {
const lookupId = normalizeReferenceId(id);
const defs = mergeLocalAndGlobalDefs(
idx.local?.assetsById.get(id.toLowerCase()),
idx.assetsById.get(id.toLowerCase()),
idx.local?.assetsById.get(lookupId.toLowerCase()),
idx.assetsById.get(lookupId.toLowerCase()),
);
if (!defs.length) return [];
@@ -125,6 +145,7 @@ export function resolveReferenceTargetsForType(
let selfType: string | null = null;
if (nameLower === "inheritfrom") {
if (!isAssetType(typeName)) return [];
selfType = typeName;
} else {
const attr = attributesOfType(typeName).find((a) => a.name === attrName);
@@ -141,7 +162,9 @@ export function resolveReferenceTargetsForType(
/**
* True when an element's text content is a typed reference to a global
* asset: the element's resolved XSD type is a simple type carrying an
* `xas:refType` (e.g. `<CreateObject>` with `GameObjectWeakRef`).
* `xas:refType`, or a simpleContent complex type carrying `xas:refType`
* (e.g. `<CreateObject>` with `GameObjectWeakRef`, `<Sound>` with
* `AudioFileRefWithWeight`).
*
* Only *typed* refs are treated as content references. Generic untyped
* `AssetReference` content is used by real data for shader constants,
@@ -152,8 +175,8 @@ export function resolveReferenceTargetsForType(
*/
export function isReferenceContentType(typeName: string | null): boolean {
if (!typeName) return false;
const info = typeInfo(typeName);
if (info?.kind !== "simple") return false;
const info = contentInfoOfType(typeName);
if (!info) return false;
if (typeChain(typeName).includes("Poid")) return false;
return info.refType != null;
}
@@ -170,13 +193,14 @@ export function resolveContentReferenceTargets(
): ReferenceTarget[] {
if (!isReferenceContentType(typeName)) return [];
if (!typeName) return [];
const lookupId = normalizeReferenceId(id);
const defs = mergeLocalAndGlobalDefs(
idx.local?.assetsById.get(id.toLowerCase()),
idx.assetsById.get(id.toLowerCase()),
idx.local?.assetsById.get(lookupId.toLowerCase()),
idx.assetsById.get(lookupId.toLowerCase()),
);
if (!defs.length) return [];
const info = typeInfo(typeName);
const refType = info?.kind === "simple" ? info.refType : null;
const info = contentInfoOfType(typeName);
const refType = info?.refType ?? null;
return filterAndScoreDefs(defs, refType, null);
}
@@ -223,13 +247,29 @@ export function mergeLocalAndGlobalDefs(
let referenceTargetTypeSet: Set<string> | null = null;
/**
* True when the XSD itself declares `inheritFrom` for the type. This is the
* narrower "designed reference target" signal used by CodeLens / unreferenced
* reports; the universal BAB `inheritFrom` attribute must not widen it to
* every BaseAssetType descendant.
*/
function xsdDeclaresInheritFrom(typeName: string): boolean {
const info = typeInfo(typeName);
return (
info?.kind === "complex" &&
info.attributes.some((a) => a.name.toLowerCase() === "inheritfrom")
);
}
/**
* The set of XSD types that are "reference targets by design": at least one
* typed reference attribute / simple-content reference points at them, or
* they are inheritable (`inheritFrom`). Types outside this set are
* auto-registered / structural (settings, map metadata, w3x sub-assets...),
* so a zero reference count is their normal state and counts would only be
* noise.
* the XSD explicitly declares them inheritable (`inheritFrom`). The universal
* BAB `inheritFrom` attribute on every BaseAssetType descendant is a separate
* legality concern and intentionally does NOT widen this set. Types outside
* this set are auto-registered / structural (settings, map metadata, w3x
* sub-assets...), so a zero reference count is their normal state and counts
* would only be noise.
*/
export function referenceTargetTypes(): ReadonlySet<string> {
if (referenceTargetTypeSet) return referenceTargetTypeSet;
@@ -246,7 +286,8 @@ export function referenceTargetTypes(): ReadonlySet<string> {
if (isLocalReferenceAttribute(typeName, attr.name)) continue;
if (attr.refType) add(attr.refType);
}
if (info.attributes.some((a) => a.name.toLowerCase() === "inheritfrom")) {
if (info.content?.refType) add(info.content.refType);
if (xsdDeclaresInheritFrom(typeName)) {
add(typeName);
}
} else if (
+6 -1
View File
@@ -20,7 +20,12 @@ export interface AssetDef {
viaInstance?: boolean;
/** Manifest path for origin === "manifest". */
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;
}
+7 -2
View File
@@ -1,4 +1,4 @@
import { childTypeOf, elementTypeName } from "../model/schemaModel";
import { childTypeOf, elementTypeName, topLevelElementType } from "../model/schemaModel";
import type { XmlElement } from "./xmlParser";
/**
@@ -9,7 +9,12 @@ import type { XmlElement } from "./xmlParser";
*/
export function resolveElementType(el: XmlElement): string | null {
if (!el.parent) {
return elementTypeName(el.name);
// A document root (fragment or full AssetDeclaration) has no parent to
// provide context. When the root is a top-level asset whose name also
// appears as a nested child type (EvaEvent, UpgradeTemplate, ...),
// prefer the AssetDeclaration declaration over the global single-map
// fallback.
return topLevelElementType(el.name) ?? elementTypeName(el.name);
}
const parentType = resolveElementType(el.parent);
return childTypeOf(parentType, el.name) ?? elementTypeName(el.name);
+48 -12
View File
@@ -53,6 +53,10 @@ export interface XmlElement {
export interface XmlParseError {
message: string;
/** Stable machine-readable id used by the UI layer for localization. */
code: string;
/** Dynamic values referenced by the localized message. */
params?: Record<string, string>;
offset: number;
line: number;
character: number;
@@ -249,9 +253,21 @@ export function parseXml(text: string): XmlDocument {
let i = 0;
const n = text.length;
const err = (message: string, offset: number) => {
const err = (
code: string,
message: string,
offset: number,
params?: Record<string, string>,
) => {
const pos = lineMap.positionAt(offset);
errors.push({ message, offset, line: pos.line, character: pos.character });
errors.push({
code,
message,
params,
offset,
line: pos.line,
character: pos.character,
});
};
while (i < n) {
@@ -261,7 +277,11 @@ export function parseXml(text: string): XmlDocument {
// text before the root element - ignore unless it is non-whitespace
const between = text.slice(i, lt);
if (between.trim() !== "") {
err("Content is not allowed before the root element", i);
err(
"content-before-root",
"Content is not allowed before the root element",
i,
);
}
}
i = lt;
@@ -270,7 +290,7 @@ export function parseXml(text: string): XmlDocument {
if (text.startsWith("<!--", i)) {
const close = text.indexOf("-->", i + 4);
if (close < 0) {
err("Unterminated comment", i);
err("unterminated-comment", "Unterminated comment", i);
break;
}
i = close + 3;
@@ -280,7 +300,7 @@ export function parseXml(text: string): XmlDocument {
if (text.startsWith("<![CDATA[", i)) {
const close = text.indexOf("]]>", i + 9);
if (close < 0) {
err("Unterminated CDATA section", i);
err("unterminated-cdata", "Unterminated CDATA section", i);
break;
}
i = close + 3;
@@ -290,7 +310,7 @@ export function parseXml(text: string): XmlDocument {
if (text.startsWith("<!DOCTYPE", i) || text.startsWith("<!doctype", i)) {
const close = text.indexOf(">", i);
if (close < 0) {
err("Unterminated DOCTYPE", i);
err("unterminated-doctype", "Unterminated DOCTYPE", i);
break;
}
i = close + 1;
@@ -300,7 +320,11 @@ export function parseXml(text: string): XmlDocument {
if (text.startsWith("<?", i)) {
const close = text.indexOf("?>", i + 2);
if (close < 0) {
err("Unterminated processing instruction", i);
err(
"unterminated-processing-instruction",
"Unterminated processing instruction",
i,
);
break;
}
if (i === 0 && /^<\?xml\s/i.test(text.slice(i, close + 2))) {
@@ -313,15 +337,25 @@ export function parseXml(text: string): XmlDocument {
if (text.startsWith("</", i)) {
const gt = text.indexOf(">", i + 2);
if (gt < 0) {
err("Unterminated closing tag", i);
err("unterminated-closing-tag", "Unterminated closing tag", i);
break;
}
const name = text.slice(i + 2, gt).trim();
const top = stack[stack.length - 1];
if (!top) {
err(`Unexpected closing tag </${name}>`, i);
err(
"unexpected-closing-tag",
`Unexpected closing tag </${name}>`,
i,
{ name },
);
} else if (top.name !== name) {
err(`Mismatched closing tag: expected </${top.name}>, found </${name}>`, i);
err(
"mismatched-closing-tag",
`Mismatched closing tag: expected </${top.name}>, found </${name}>`,
i,
{ expected: top.name, found: name },
);
// recover: find the matching element on the stack if possible
let idx = stack.length - 1;
while (idx >= 0 && stack[idx].name !== name) idx--;
@@ -343,13 +377,13 @@ export function parseXml(text: string): XmlDocument {
}
// opening tag
if (text[i + 1] === "!" || text[i + 1] === "?") {
err("Malformed markup", i);
err("malformed-markup", "Malformed markup", i);
i++;
continue;
}
const gt = findTagEnd(text, i + 1);
if (gt < 0) {
err("Unterminated start tag", i);
err("unterminated-start-tag", "Unterminated start tag", i);
// Recovery while typing: an attribute value whose closing quote has not
// been typed yet makes the scanner run to EOF. End the malformed start
// tag at the first line break (or EOF) so the rest of the document is
@@ -399,7 +433,9 @@ export function parseXml(text: string): XmlDocument {
for (const el of stack) {
const pos = lineMap.positionAt(el.start);
errors.push({
code: "element-never-closed",
message: `Element <${el.name}> is never closed`,
params: { name: el.name },
offset: el.start,
line: pos.line,
character: pos.character,
+63
View File
@@ -0,0 +1,63 @@
import * as vscode from "vscode";
type L10n = {
t(message: string, ...args: Array<string | number | boolean>): string;
t(
message: string,
args: Record<string, string | number | boolean>,
): string;
};
/**
* Thin wrapper around VS Code's built-in `l10n.t`.
*
* The fallback keeps the message unchanged when no bundle is loaded (for
* example in unit tests or when the extension runs with the default English
* locale), so the same call sites work in production and tests.
*/
export function t(
message: string,
...args: Array<string | number | boolean>
): string {
const l10n = (vscode as unknown as { l10n?: L10n }).l10n;
if (!l10n?.t) return formatIndexed(message, args);
try {
return l10n.t(message, ...args);
} catch {
return formatIndexed(message, args);
}
}
/** Named-placeholder variant of {@link t}. */
export function tN(
message: string,
args: Record<string, string | number | boolean>,
): string {
const l10n = (vscode as unknown as { l10n?: L10n }).l10n;
if (!l10n?.t) return formatNamed(message, args);
try {
return l10n.t(message, args);
} catch {
return formatNamed(message, args);
}
}
function formatIndexed(
message: string,
args: Array<string | number | boolean>,
): string {
return message.replace(/\{(\d+)\}/g, (match, index: string) => {
const value = args[Number(index)];
return value === undefined ? match : String(value);
});
}
function formatNamed(
message: string,
args: Record<string, string | number | boolean>,
): string {
return message.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, name: string) => {
const value = args[name];
return value === undefined ? match : String(value);
});
}
File diff suppressed because one or more lines are too long
+125 -1
View File
@@ -33,6 +33,21 @@ export interface ComplexTypeInfo {
attributes: AttributeInfo[];
base: string | null;
doc: string;
/**
* Present only for complexType + simpleContent types (e.g.
* AudioFileRefWithWeight / MultisoundSubsoundRef). Describes the text
* between the tags just like a simple type's value semantics.
*/
content?: SimpleContentInfo | null;
}
export interface SimpleContentInfo {
refType: string | null;
isRef: boolean;
enumValues: string[];
isList: boolean;
allowsDefine: boolean;
base: string | null;
}
export interface SimpleTypeInfo {
@@ -48,6 +63,24 @@ export interface SimpleTypeInfo {
export type TypeInfo = ComplexTypeInfo | SimpleTypeInfo;
/**
* Unified value semantics for element text content. Both simple types
* (`<CreateObject>` -> GameObjectWeakRef) and simpleContent complex types
* (`<Sound>` -> AudioFileRefWithWeight) share this shape so the completion /
* hover / navigation / diagnostics / indexer pipelines do not have to know
* which XSD construct produced the content.
*/
export interface ContentTypeInfo {
kind: "simple" | "simpleContent";
refType: string | null;
isRef: boolean;
enumValues: string[];
isList: boolean;
allowsDefine: boolean;
base: string | null;
doc: string;
}
interface RawModel {
version: number;
rootXsd: string;
@@ -59,6 +92,33 @@ interface RawModel {
const model = schemaModel as unknown as RawModel;
/**
* `inheritFrom` is accepted by BAB / real RA3 data on BaseAssetType-derived
* assets even though the XSD only declares it on BaseInheritableAsset
* (vanilla SageXml uses it on FXList, AIMicroManagerData,
* AITargetingHeuristic, ObjectCreationList, ...). It is therefore exposed as
* a universal attribute for every asset type.
*
* This is deliberately separate from `referenceTargetTypes()` in refs.ts:
* "may legally appear in the document" and "is a designed CodeLens / FAR
* reference target" are different decisions.
*/
const UNIVERSAL_INHERIT_FROM: AttributeInfo = {
name: "inheritFrom",
required: false,
default: null,
doc: "Inherits another asset of the same type.",
kind: "simple",
type: "@attr:inheritFrom",
refType: null,
enumValues: [],
isList: false,
allowsDefine: false,
isRef: false,
isBoolean: false,
base: "string",
};
/** Lowercase type name -> canonical (XSD) type name. */
const typeNameIndex = new Map<string, string>();
for (const name of Object.keys(model.types)) {
@@ -109,6 +169,43 @@ export function typeInfo(name: string): TypeInfo | undefined {
return model.types[name];
}
/**
* Returns content-value semantics for a type, or null when the element is a
* normal complex element (children, not text).
*/
export function contentInfoOfType(
typeName: string | null,
): ContentTypeInfo | null {
if (!typeName) return null;
const info = model.types[canonicalTypeName(typeName) ?? typeName];
if (!info) return null;
if (info.kind === "simple") {
return {
kind: "simple",
refType: info.refType,
isRef: info.isRef,
enumValues: info.enumValues,
isList: info.isList,
allowsDefine: info.allowsDefine,
base: info.base,
doc: info.doc,
};
}
if (info.kind === "complex" && info.content) {
return {
kind: "simpleContent",
refType: info.content.refType,
isRef: info.content.isRef,
enumValues: info.content.enumValues,
isList: info.content.isList,
allowsDefine: info.content.allowsDefine,
base: info.content.base,
doc: info.doc,
};
}
return null;
}
export function elementTypeName(name: string): string | null {
const t = elementToType.get(name);
return t ? t : null;
@@ -135,7 +232,23 @@ export function attributesOfElement(name: string): AttributeInfo[] {
export function attributesOfType(typeName: string | null): AttributeInfo[] {
if (!typeName) return [];
const info = model.types[canonicalTypeName(typeName) ?? typeName];
return info && info.kind === "complex" ? info.attributes : [];
if (!info || info.kind !== "complex") return [];
if (
isAssetType(typeName) &&
!info.attributes.some((a) => a.name === "inheritFrom")
) {
return [...info.attributes, UNIVERSAL_INHERIT_FROM];
}
return info.attributes;
}
/**
* True for types in the asset hierarchy (BaseAssetType and its descendants).
* These are the types on which BAB accepts the universal `inheritFrom`
* attribute even when the XSD does not declare it.
*/
export function isAssetType(typeName: string | null): boolean {
return !!typeName && typeChain(typeName).includes("BaseAssetType");
}
/**
@@ -170,6 +283,17 @@ export function elementTypeIn(
return elementTypeName(childName);
}
/**
* Resolves a top-level asset element name to the type declared inside
* AssetDeclaration. This is the type a fragment/standalone document root
* should use when its name collides with a nested child type (e.g. EvaEvent
* is both a top-level asset and an FXNugget child).
*/
export function topLevelElementType(name: string): string | null {
const declType = elementTypeName("AssetDeclaration");
return declType ? childTypeOf(declType, name) : null;
}
export function typeDoc(name: string): string {
const info = model.types[name];
return info?.doc ?? "";
+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);
}
+193
View File
@@ -0,0 +1,193 @@
import * as vscode from "vscode";
import type { ModWorkspace } from "./workspace";
import {
detectSdkPathFromRegistry,
validateSdkPath,
type SdkValidation,
} from "./sdk";
import { t } from "./localize";
/**
* 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(
t("RA3 Mod XML: open an RA3 mod project first to configure the SDK path."),
);
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 useDetected = t("Use detected path");
const chooseManually = t("Choose manually…");
const notNow = t("Not now");
const pick = await vscode.window.showWarningMessage(
t(
"RA3 Mod XML could not find a valid SDK path. Detected installed SDK: {0}",
detectedValidation.path,
),
useDetected,
chooseManually,
notNow,
);
if (pick === useDetected) {
await applySdkPath(detectedValidation.path);
} else if (pick === chooseManually) {
await this.chooseAndApply();
}
return;
}
const chooseSdkFolder = t("Choose SDK folder…");
const pick = await vscode.window.showWarningMessage(
t(
"RA3 Mod XML needs the RA3 Mod SDK path to enable vanilla data, manifests and cross-file completion/navigation. Without it, the extension runs in project-only mode.",
),
chooseSdkFolder,
t("Not now"),
);
if (pick === chooseSdkFolder) await this.chooseAndApply();
}
private async chooseAndApply(): Promise<void> {
const picked = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: t("Choose SDK root"),
title: t(
"Choose the RA3 Mod SDK root (should contain 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(
t(
"The selected directory is not a usable RA3 Mod SDK (missing {0}). Please choose again.",
validation.missing.join(", ") || t("that directory"),
),
);
return;
}
await applySdkPath(validation.path);
}
}
function statusBarText(validation: SdkValidation): string {
if (validation.status === "missing") {
return validation.path
? t("$(warning) RA3 XML: SDK path does not exist")
: t("$(warning) RA3 XML: SDK not configured");
}
if (validation.status === "not-sdk") {
return t("$(warning) RA3 XML: SDK path is invalid");
}
return t("$(warning) RA3 XML: SDK is incomplete");
}
function describeSdkValidation(validation: SdkValidation): string {
if (validation.status === "missing") {
return validation.path
? t(
"The directory configured in ra3modxml.sdkPath does not exist: {0}. Click to reconfigure, or clear ra3modxml.sdkPath to disable vanilla data features.",
validation.path,
)
: t(
"RA3 Mod SDK path is not configured. Click to set it, or clear ra3modxml.sdkPath to disable vanilla data features.",
);
}
if (validation.status === "not-sdk") {
return t(
"The directory configured in ra3modxml.sdkPath is not an RA3 Mod SDK root (missing Schemas/xsd/CnC3Types.xsd). Click to reconfigure.",
);
}
return t(
"RA3 Mod SDK is missing: {0}. Manifest / vanilla source / SDK search path features are unavailable.",
validation.missing.join(", "),
);
}
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(
t("RA3 Mod XML: SDK path set to {0}; rebuilding the index…", path),
);
}
+5 -2
View File
@@ -1,5 +1,6 @@
import * as vscode from "vscode";
import { join } from "node:path";
import { normalizeSdkPath } from "./sdk";
export interface ExtensionSettings {
sdkPath: string;
@@ -13,7 +14,7 @@ export interface ExtensionSettings {
export function readSettings(): ExtensionSettings {
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 {
sdkPath,
indexSageXml: cfg.get<boolean>("indexSageXml", true),
@@ -27,6 +28,8 @@ export function readSettings(): ExtensionSettings {
"all",
) as ExtensionSettings["definitionMode"],
additionalDataSearchPaths: cfg.get<string[]>("additionalDataSearchPaths", []),
builtmodsDirs: [join(sdkPath, "builtmods"), join(sdkPath, "builtmods-quantum")],
builtmodsDirs: sdkPath
? [join(sdkPath, "builtmods"), join(sdkPath, "builtmods-quantum")]
: [],
};
}
+845 -270
View File
File diff suppressed because it is too large Load Diff
+187
View File
@@ -0,0 +1,187 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
LIVE_ONLY,
liveArgsFor,
parseArgs,
toolNameFor,
} from "../out/agent/cli.js";
// ── Argument parsing and command mapping (pure) ───────────────────────
test("parseArgs reads the project, snapshot and command", () => {
const options = parseArgs(["--project", "D:/Mods/Alpha", "find", "AthenaCannon", "GameObject"]);
assert.equal(options.projectDir, "D:/Mods/Alpha");
assert.equal(options.command, "find");
assert.deepEqual(options.args, ["AthenaCannon", "GameObject"]);
});
test("parseArgs ignores unknown flags without failing the call", () => {
// An agent probing with a flag we do not know yet must not crash the CLI.
const withCommand = parseArgs(["--json", "status"]);
assert.equal(withCommand.command, "status");
const trailing = parseArgs(["find", "X", "--future-flag"]);
assert.equal(trailing.command, "find");
assert.deepEqual(trailing.args, ["X"]);
// A stray positional is still treated as the command name.
assert.equal(parseArgs([]).command, "status");
});
test("parseArgs reads outgoing-specific options", () => {
const options = parseArgs([
"outgoing", "AthenaCannon", "GameObject",
"--depth", "2",
"--target-types", "WeaponTemplate, GameObject",
"--max-edges", "25",
"--include-unresolved",
]);
assert.equal(options.command, "outgoing");
assert.equal(options.depth, 2);
assert.deepEqual(options.targetTypes, ["WeaponTemplate", "GameObject"]);
assert.equal(options.maxEdges, 25);
assert.equal(options.includeUnresolved, true);
});
test("toolNameFor maps every CLI command to a live tool", () => {
assert.equal(toolNameFor("find"), "find_asset");
assert.equal(toolNameFor("refs"), "find_references");
assert.equal(toolNameFor("outgoing"), "get_asset_references");
assert.equal(toolNameFor("list"), "list_assets_by_type");
assert.equal(toolNameFor("active"), "is_file_active");
assert.equal(toolNameFor("define"), "find_define");
assert.equal(toolNameFor("resolve"), "resolve_include");
assert.equal(toolNameFor("projects"), "list_projects");
assert.equal(toolNameFor("status"), "get_status");
});
test("LIVE_ONLY covers exactly the commands needing DOM/project context", () => {
assert.equal(LIVE_ONLY.has("outgoing"), true);
assert.equal(LIVE_ONLY.has("projects"), true);
assert.equal(LIVE_ONLY.has("find"), false);
assert.equal(LIVE_ONLY.has("status"), false);
});
test("liveArgsFor builds the right payload per command", () => {
assert.deepEqual(
liveArgsFor({ command: "find", args: ["X", "GameObject"] }),
{ id: "X", type: "GameObject" },
);
assert.deepEqual(
liveArgsFor({ command: "active", args: ["D:/f.xml"] }),
{ path: "D:/f.xml" },
);
// Outgoing omits unset options so the server applies its own defaults.
const outgoing = liveArgsFor({ command: "outgoing", args: ["X"] });
assert.equal(outgoing.id, "X");
assert.equal(outgoing.depth, undefined);
assert.equal(outgoing.targetTypes, undefined);
assert.equal(outgoing.maxEdges, undefined);
// An explicit depth of 0 is falsy but must still be forwarded.
const zero = liveArgsFor({ command: "outgoing", args: ["X"], depth: 0 });
assert.equal(zero.depth, 0);
});
// ── Project inference from the current directory ──────────────────────
function makeModProject() {
const root = mkdtempSync(join(tmpdir(), "ra3-cli-proj-"));
mkdirSync(join(root, "Data"), { recursive: true });
writeFileSync(join(root, "Data", "Mod.xml"), "<AssetDeclaration/>");
return root;
}
test("the CLI finds the project root by walking up from the cwd", async () => {
const root = makeModProject();
const nested = join(root, "Data", "Allied", "Units");
mkdirSync(nested, { recursive: true });
const previous = process.cwd();
try {
process.chdir(nested);
const options = parseArgs([]);
const resolved = (await import("../out/agent/cli.js")).resolveProjectDir(options);
assert.equal(resolved?.toLowerCase(), root.toLowerCase());
} finally {
process.chdir(previous);
rmSync(root, { recursive: true, force: true });
}
});
test("resolveProjectDir prefers an explicit --project over the cwd", async () => {
const { resolveProjectDir } = await import("../out/agent/cli.js");
const options = parseArgs(["--project", "D:/Somewhere/Else", "status"]);
assert.equal(resolveProjectDir(options), join("D:\\Somewhere\\Else").replace(/\\/g, "\\"));
});
// ── End-to-end execution (skipped when the sandbox forbids spawning) ──
function canSpawnShell() {
try {
if (process.platform === "win32") {
execFileSync("cmd.exe", ["/d", "/c", "exit 0"], { stdio: "ignore" });
} else {
execFileSync("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" });
}
return true;
} catch {
return false;
}
}
const spawnable = canSpawnShell();
const cliPath = join(process.cwd(), "dist", "agent", "cli.js");
test("CLI exits 3 with a clear message outside a project", { skip: !spawnable || !existsSync(cliPath) }, (t) => {
const empty = mkdtempSync(join(tmpdir(), "ra3-cli-empty-"));
try {
let code = 0;
let stderr = "";
try {
execFileSync(process.execPath, [cliPath, "status"], {
cwd: empty,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch (err) {
if (err?.code === "EPERM") {
t.skip("sandbox forbids spawning");
return;
}
code = err.status;
stderr = err.stderr ?? "";
}
assert.equal(code, 3);
assert.match(stderr, /No project found/);
} finally {
rmSync(empty, { recursive: true, force: true });
}
});
test("CLI reports a live-only command as unavailable instead of empty", { skip: !spawnable || !existsSync(cliPath) }, (t) => {
const empty = mkdtempSync(join(tmpdir(), "ra3-cli-liveonly-"));
try {
let stdout = "";
try {
stdout = execFileSync(process.execPath, [cliPath, "--project", "D:/Mods/Nope", "outgoing", "X"], {
cwd: empty,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch (err) {
if (err?.code === "EPERM") {
t.skip("sandbox forbids spawning");
return;
}
stdout = err.stdout ?? "";
}
const payload = JSON.parse(stdout);
assert.equal(payload.source, "unavailable");
// Must explain why, never look like "this asset has no references".
assert.match(payload.error, /requires a live index/);
} finally {
rmSync(empty, { recursive: true, force: true });
}
});
+102
View File
@@ -0,0 +1,102 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import {
clearEndpoint,
clearEndpointForProject,
endpointPathForProject,
isProcessAlive,
readEndpoint,
readEndpointForProject,
sameProject,
writeEndpoint,
writeEndpointForProject,
} from "../out/agent/endpoint.js";
const PROJECT_A = "D:/Mods/ExampleA";
const PROJECT_B = "D:/Mods/ExampleB";
test("endpoint file round-trips and clears", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-endpoint-test-"));
try {
await writeEndpoint(
{ url: "http://127.0.0.1:12345", token: "abc", projectDir: PROJECT_A },
home,
);
const loaded = await readEndpoint(home);
assert.equal(loaded?.url, "http://127.0.0.1:12345");
assert.equal(loaded?.token, "abc");
await clearEndpoint(home);
assert.equal(await readEndpoint(home), null);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("per-project endpoints do not shadow each other", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-endpoint-multi-"));
try {
// Two "windows" enable agent access for different projects.
await writeEndpointForProject(
PROJECT_A,
{ url: "http://127.0.0.1:1111", token: "token-a", processId: process.pid },
home,
);
await writeEndpointForProject(
PROJECT_B,
{ url: "http://127.0.0.1:2222", token: "token-b", processId: process.pid },
home,
);
const a = await readEndpointForProject(PROJECT_A, home);
const b = await readEndpointForProject(PROJECT_B, home);
assert.equal(a?.url, "http://127.0.0.1:1111");
assert.ok(sameProject(a.projectDir, PROJECT_A));
assert.equal(b?.url, "http://127.0.0.1:2222");
assert.ok(sameProject(b.projectDir, PROJECT_B));
// Clearing one project must not disturb the other.
await clearEndpointForProject(PROJECT_A, home);
assert.equal(await readEndpointForProject(PROJECT_A, home), null);
assert.equal((await readEndpointForProject(PROJECT_B, home))?.token, "token-b");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("a per-project endpoint recording another project is rejected", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-endpoint-mismatch-"));
try {
const file = endpointPathForProject(PROJECT_A, home);
// Simulate a stale/edited file that claims to serve a different project.
mkdirSync(dirname(file), { recursive: true });
writeFileSync(
file,
JSON.stringify({
url: "http://127.0.0.1:3333",
token: "t",
projectDir: PROJECT_B,
}),
);
assert.equal(await readEndpointForProject(PROJECT_A, home), null);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("isProcessAlive detects dead pids and trusts unknown ones", () => {
assert.equal(isProcessAlive(process.pid), true);
assert.equal(isProcessAlive(undefined), true);
assert.equal(isProcessAlive(0), true);
assert.equal(isProcessAlive(NaN), true);
// Not a valid Windows PID, so it cannot correspond to a running process.
assert.equal(isProcessAlive(0x7fffffff), false);
});
test("sameProject is case-insensitive", () => {
assert.equal(sameProject("D:/Mods/Example", "d:/mods/example"), true);
assert.equal(sameProject("D:/Mods/Example", "D:/Mods/Other"), false);
});
+308
View File
@@ -0,0 +1,308 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
collectAssetReferences,
parseLoadedXml,
} from "../out/agent/forwardRefs.js";
// ── Fixture files ────────────────────────────────────────────────────
// AthenaCannon has no WeaponSetUpdate of its own: it inherits BaseCannon,
// which owns the weapon slot, and has its own die-object content reference.
const ATHENA = `<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="AthenaCannon" inheritFrom="BaseCannon">
<CreateObjectDie>
<CreateObject>AthenaCannon_Die</CreateObject>
</CreateObjectDie>
</GameObject>
</AssetDeclaration>`;
const BASE = `<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="BaseCannon">
<WeaponSetUpdate>
<WeaponSlotHardpoint>
<Weapon Template="AthenaCannonWeapon" />
</WeaponSlotHardpoint>
</WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
const DIE = `<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="AthenaCannon_Die">
<WeaponSetUpdate>
<WeaponSlotHardpoint>
<Weapon Template="DieExplosionWeapon" />
</WeaponSlotHardpoint>
</WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
const FILES = {
"D:/Mods/Example/Data/AthenaCannon.xml": ATHENA,
"D:/Mods/Example/Data/BaseCannon.xml": BASE,
"D:/Mods/Example/Data/AthenaCannon_Die.xml": DIE,
};
function def(type, id, file, line) {
return { type, id, file, line, origin: "project", stream: "static" };
}
const ATHENA_DEF = def("GameObject", "AthenaCannon", "D:/Mods/Example/Data/AthenaCannon.xml", 3);
const BASE_DEF = def("GameObject", "BaseCannon", "D:/Mods/Example/Data/BaseCannon.xml", 3);
const DIE_DEF = def("GameObject", "AthenaCannon_Die", "D:/Mods/Example/Data/AthenaCannon_Die.xml", 3);
const WEAPON_DEF = def(
"WeaponTemplate",
"AthenaCannonWeapon",
"D:/Mods/Example/Data/Weapon.xml",
88,
);
const DIE_WEAPON_DEF = def(
"WeaponTemplate",
"DieExplosionWeapon",
"D:/Mods/Example/Data/Weapon.xml",
120,
);
function makeIndex() {
const all = [ATHENA_DEF, BASE_DEF, DIE_DEF, WEAPON_DEF, DIE_WEAPON_DEF];
const assetsById = new Map();
for (const d of all) {
const key = d.id.toLowerCase();
if (!assetsById.has(key)) assetsById.set(key, []);
assetsById.get(key).push(d);
}
const assets = new Map([
["GameObject", new Map([["athenacannon", [ATHENA_DEF]], ["basecannon", [BASE_DEF]], ["athenacannon_die", [DIE_DEF]]])],
["WeaponTemplate", new Map([["athenacannonweapon", [WEAPON_DEF]], ["dieexplosionweapon", [DIE_WEAPON_DEF]]])],
]);
return {
projectDir: "D:/Mods/Example",
sdkDir: "",
complete: true,
phase: "art",
assets,
assetsById,
defines: new Map(),
files: new Map(),
streams: [],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
references: new Map(),
recordsHashes: new Map(),
stats: {},
};
}
function loader(map = FILES) {
return async (file) => {
const text = map[file];
return text ? parseLoadedXml(text) : null;
};
}
function findEdge(edges, predicate) {
return edges.find(predicate);
}
test("depth 1 returns only the queried asset's own edges", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{},
);
assert.equal(result.roots.length, 1);
// Own content ref + inherited weapon ref + the inheritFrom edge itself.
assert.ok(result.edges.length >= 3);
assert.deepEqual(
[...new Set(result.edges.map((e) => e.depth))],
[1],
"all edges must be at depth 1",
);
assert.equal(result.truncated, false);
});
test("attribute references carry element, parent and attribute provenance", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
);
const weapon = findEdge(
result.edges,
(e) => e.via.kind === "attribute" && e.to?.id === "AthenaCannonWeapon",
);
assert.ok(weapon, "weapon edge should exist");
assert.equal(weapon.via.element, "Weapon");
assert.equal(weapon.via.parent, "WeaponSlotHardpoint");
assert.equal(weapon.via.attribute, "Template");
assert.equal(weapon.to.type, "WeaponTemplate");
assert.equal(weapon.to.line, 88);
assert.ok(weapon.source.file.endsWith("BaseCannon.xml"));
assert.ok(weapon.source.line > 0);
});
test("content references (CreateObjectDie) are reported with kind=content", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
);
const die = findEdge(result.edges, (e) => e.via.kind === "content");
assert.ok(die, "die-object content edge should exist");
assert.equal(die.via.element, "CreateObject");
assert.equal(die.via.parent, "CreateObjectDie");
assert.equal(die.via.attribute, null);
assert.equal(die.to.type, "GameObject");
assert.equal(die.to.id, "AthenaCannon_Die");
assert.ok(die.source.file.endsWith("AthenaCannon.xml"));
});
test("inheritFrom is walked and marked with definedIn", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
);
const inherit = findEdge(result.edges, (e) => e.via.kind === "inheritFrom");
assert.ok(inherit, "inheritFrom edge should exist");
assert.equal(inherit.to.id, "BaseCannon");
assert.equal(inherit.value, "BaseCannon");
assert.equal(inherit.definedIn, undefined, "the inheritFrom edge itself is on AthenaCannon");
const inheritedWeapon = findEdge(
result.edges,
(e) => e.to?.id === "AthenaCannonWeapon",
);
assert.ok(inheritedWeapon, "weapon from the ancestor must still be reported");
assert.deepEqual(inheritedWeapon.definedIn, { type: "GameObject", id: "BaseCannon" });
assert.equal(inheritedWeapon.from.id, "AthenaCannon", "edge is attributed to the queried asset");
});
test("targetTypes filters edges, but inheritFrom edges always survive", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{ targetTypes: ["WeaponTemplate"] },
);
assert.ok(result.edges.length > 0);
assert.ok(
result.edges.some((e) => e.to.type === "WeaponTemplate"),
"expected at least one WeaponTemplate edge",
);
for (const edge of result.edges) {
// inheritFrom is kept so the caller can see where the weapon is written.
if (edge.via.kind === "inheritFrom") continue;
assert.equal(edge.to.type, "WeaponTemplate", `${edge.via.element} should be filtered out`);
}
// Nodes mirror the kept edges, so the inheritFrom target may appear too.
const inheritIds = new Set(
result.edges
.filter((e) => e.via.kind === "inheritFrom")
.map((e) => e.to.id.toLowerCase()),
);
for (const node of result.nodes) {
assert.ok(
node.type === "WeaponTemplate" || inheritIds.has(node.id.toLowerCase()),
`unexpected node ${node.type}:${node.id}`,
);
}
});
test("depth 2 expands into referenced assets", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{ depth: 2 },
);
const depths = new Set(result.edges.map((e) => e.depth));
assert.ok(depths.has(2), "expected depth-2 edges from the die-object GameObject");
const dieWeapon = findEdge(result.edges, (e) => e.to?.id === "DieExplosionWeapon");
assert.ok(dieWeapon, "the die object's weapon should appear at depth 2");
assert.equal(dieWeapon.depth, 2);
assert.equal(dieWeapon.from.id, "AthenaCannon_Die");
});
test("depth is clamped to the max of 3", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{ depth: 99 },
);
for (const edge of result.edges) {
assert.ok(edge.depth <= 3, `depth ${edge.depth} exceeded the clamp`);
}
});
test("maxEdges truncates and reports what was dropped", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{ maxEdges: 1 },
);
assert.equal(result.edges.length, 1);
assert.equal(result.truncated, true);
const omitted = Object.values(result.omittedByTargetType).reduce((a, b) => a + b, 0);
assert.ok(omitted >= 1, "expected the dropped edges to be summarised");
});
test("unresolved references are opt-in", async () => {
const text = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="Ghost">
<WeaponSetUpdate><WeaponSlotHardpoint><Weapon Template="DoesNotExist" /></WeaponSlotHardpoint></WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
const ghost = def("GameObject", "Ghost", "D:/Mods/Example/Data/Ghost.xml", 2);
const index = makeIndex();
index.assetsById.set("ghost", [ghost]);
const ghostLoader = loader({ "D:/Mods/Example/Data/Ghost.xml": text });
const without = await collectAssetReferences(index, "Ghost", "GameObject", ghostLoader);
assert.equal(without.edges.length, 0);
const withUnresolved = await collectAssetReferences(index, "Ghost", "GameObject", ghostLoader, {
includeUnresolved: true,
});
assert.equal(withUnresolved.edges.length, 1);
assert.equal(withUnresolved.edges[0].to, null);
assert.equal(withUnresolved.edges[0].value, "DoesNotExist");
});
test("unknown ids produce a warning instead of throwing", async () => {
const result = await collectAssetReferences(
makeIndex(),
"NoSuchAsset",
"GameObject",
loader(),
);
assert.equal(result.edges.length, 0);
assert.equal(result.roots.length, 0);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0], /No definition found/);
});
test("unreadable files produce a warning and no edges", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
async () => null,
);
assert.equal(result.edges.length, 0);
assert.ok(result.warnings.some((w) => w.includes("Could not read")));
});
+219
View File
@@ -0,0 +1,219 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
INSTANCE_SCHEMA_VERSION,
clearInstance,
instanceFileName,
instancesDir,
makeInstanceId,
manifestPath,
projectsOf,
pruneInstances,
readInstances,
readManifest,
refreshDiscovery,
writeInstance,
writeManifest,
} from "../out/agent/instances.js";
import { endpointPath, readEndpoint } from "../out/agent/endpoint.js";
function instanceHome() {
return mkdtempSync(join(tmpdir(), "ra3-instances-"));
}
function makeInstance(id, pid = process.pid) {
return {
instanceId: id,
url: `http://127.0.0.1:${10000 + (pid % 1000)}`,
token: `tok-${id}`,
projectDir: "D:/Mods/Alpha",
projects: ["D:/Mods/Alpha"],
processId: pid,
};
}
test("instances are written and read back", async () => {
const home = instanceHome();
try {
const file = await writeInstance(makeInstance("a-1"), home);
assert.ok(existsSync(file));
assert.ok(file.includes(instancesDir(home)));
assert.equal(file.endsWith(instanceFileName("a-1")), true);
const all = await readInstances(home);
assert.equal(all.length, 1);
assert.equal(all[0].instanceId, "a-1");
assert.equal(all[0].token, "tok-a-1");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("concurrent windows do not overwrite each other", async () => {
const home = instanceHome();
try {
// Two windows enabling agent access at the same time: separate files, so
// there is no read-modify-write race to guard.
await writeInstance(makeInstance("win-a"), home);
await writeInstance(makeInstance("win-b"), home);
const ids = (await readInstances(home)).map((i) => i.instanceId).sort();
assert.deepEqual(ids, ["win-a", "win-b"]);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("pruneInstances removes entries whose PID is dead", async () => {
const home = instanceHome();
try {
// 0x7fffffff is not a valid Windows PID, so it cannot be alive.
await writeInstance(makeInstance("alive", process.pid), home);
await writeInstance(makeInstance("dead", 0x7fffffff), home);
const result = await pruneInstances(home);
assert.deepEqual(result.removed, ["dead"]);
assert.deepEqual(
result.kept.map((i) => i.instanceId),
["alive"],
);
// The dead file must actually be gone from disk.
assert.equal(existsSync(join(instancesDir(home), instanceFileName("dead"))), false);
assert.equal((await readInstances(home)).length, 1);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("pruneInstances keeps entries with an unknown PID", async () => {
const home = instanceHome();
try {
// An older/simpler instance file without processId must never be pruned.
await writeInstance(
{ instanceId: "no-pid", url: "http://127.0.0.1:1", token: "t" },
home,
);
const result = await pruneInstances(home);
assert.deepEqual(result.removed, []);
assert.equal(result.kept.length, 1);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("clearInstance only removes its own file", async () => {
const home = instanceHome();
try {
await writeInstance(makeInstance("mine"), home);
await writeInstance(makeInstance("theirs"), home);
await clearInstance("mine", home);
const ids = (await readInstances(home)).map((i) => i.instanceId);
assert.deepEqual(ids, ["theirs"]);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("readInstances tolerates corrupt and unrelated files", async () => {
const home = instanceHome();
try {
await writeInstance(makeInstance("good"), home);
writeFileSync(join(instancesDir(home), "broken.json"), "{ not json");
writeFileSync(join(instancesDir(home), "notes.txt"), "ignore me");
const all = await readInstances(home);
assert.deepEqual(all.map((i) => i.instanceId), ["good"]);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("projectsOf unions projects across instances without duplicates", () => {
const projects = projectsOf([
{ instanceId: "a", url: "u", token: "t", projectDir: "D:/Mods/Alpha", projects: ["D:/Mods/Alpha"] },
{ instanceId: "b", url: "u", token: "t", projectDir: "D:/Mods/Beta", projects: ["D:/Mods/beta", "D:/Mods/Gamma"] },
]);
// "beta" appears twice with different casing and must collapse to one entry,
// keeping the first spelling seen.
assert.deepEqual(projects, ["D:/Mods/Alpha", "D:/Mods/beta", "D:/Mods/Gamma"]);
});
test("writeManifest produces a discovery manifest without tokens", async () => {
const home = instanceHome();
try {
const manifest = await writeManifest(
[makeInstance("a-1"), makeInstance("b-2")],
home,
);
assert.equal(manifest.schemaVersion, INSTANCE_SCHEMA_VERSION);
assert.deepEqual(manifest.projects, ["D:/Mods/Alpha"]);
assert.equal(manifest.instances.length, 2);
const raw = readFileSync(manifestPath(home), "utf8");
// The manifest is for discovery; secrets must not leak into it.
assert.equal(raw.includes("tok-a-1"), false);
assert.equal(raw.includes('"token"'), false);
const reread = await readManifest(home);
assert.equal(reread?.instances.length, 2);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("makeInstanceId is unique across rapid calls", () => {
const ids = new Set();
for (let i = 0; i < 200; i++) ids.add(makeInstanceId(1234));
assert.equal(ids.size, 200);
for (const id of ids) assert.ok(id.startsWith("1234-"), id);
});
test("refreshDiscovery keeps surviving windows discoverable", async () => {
const home = instanceHome();
try {
const mine = makeInstance("mine");
const other = {
...makeInstance("other"),
url: "http://127.0.0.1:19999",
token: "tok-other",
projectDir: "D:/Mods/Beta",
projects: ["D:/Mods/Beta"],
};
await writeInstance(mine, home);
await writeInstance(other, home);
// This window closes: only its own instance file goes away.
await clearInstance(mine.instanceId, home);
const manifest = await refreshDiscovery(home);
assert.deepEqual(manifest.projects, ["D:/Mods/Beta"]);
assert.deepEqual(manifest.instances.map((i) => i.instanceId), ["other"]);
// The legacy global pointer must follow the survivor, not be deleted.
const endpoint = await readEndpoint(home);
assert.equal(endpoint?.url, other.url);
assert.equal(endpoint?.token, other.token);
assert.equal(existsSync(endpointPath(home)), true);
// The merged manifest on disk must agree with the returned value.
const reread = await readManifest(home);
assert.deepEqual(reread?.instances.map((i) => i.instanceId), ["other"]);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("refreshDiscovery clears the global pointer when the last window closes", async () => {
const home = instanceHome();
try {
await writeInstance(makeInstance("only"), home);
await clearInstance("only", home);
const manifest = await refreshDiscovery(home);
assert.deepEqual(manifest.instances, []);
assert.deepEqual(manifest.projects, []);
assert.equal(existsSync(endpointPath(home)), false);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
+205
View File
@@ -0,0 +1,205 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
LiveClient,
findEndpoint,
liveUrlForTool,
normalizePath,
queryLive,
responseProjectMismatch,
} from "../out/agent/liveClient.js";
import { writeInstance } from "../out/agent/instances.js";
import { writeEndpoint, writeEndpointForProject } from "../out/agent/endpoint.js";
const PROJECT_A = "D:/Mods/Alpha";
const PROJECT_B = "D:/Mods/Beta";
function home() {
return mkdtempSync(join(tmpdir(), "ra3-liveclient-"));
}
test("live URLs pin the requested project for every tool", () => {
const cases = [
["get_status", {}],
["find_asset", { id: "X" }],
["find_references", { id: "X" }],
["list_assets_by_type", { type: "GameObject" }],
["is_file_active", { path: "D:/f.xml" }],
["find_define", { name: "D" }],
["resolve_include", { source: "DATA:a.xml" }],
["list_projects", {}],
];
for (const [tool, args] of cases) {
const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, tool, args);
assert.ok(url, `${tool} should have a live URL`);
assert.equal(
new URL(url).searchParams.get("project"),
PROJECT_A,
`${tool} must pin the project`,
);
}
});
test("get_asset_references serialises all of its options", () => {
const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, "get_asset_references", {
id: "AthenaCannon",
type: "GameObject",
depth: 2,
targetTypes: ["WeaponTemplate", "GameObject"],
maxEdges: 25,
includeUnresolved: true,
});
const q = new URL(url).searchParams;
assert.equal(q.get("id"), "AthenaCannon");
assert.equal(q.get("type"), "GameObject");
assert.equal(q.get("depth"), "2");
assert.equal(q.get("targetTypes"), "WeaponTemplate,GameObject");
assert.equal(q.get("maxEdges"), "25");
assert.equal(q.get("includeUnresolved"), "true");
assert.equal(q.get("project"), PROJECT_A);
});
test("unknown tools have no live URL", () => {
assert.equal(liveUrlForTool("http://127.0.0.1:1", PROJECT_A, "not_a_tool", {}), null);
});
test("responseProjectMismatch refuses another project's answer", () => {
assert.equal(
responseProjectMismatch({ index: { projectDir: "d:/mods/alpha" } }, PROJECT_A),
false,
);
assert.equal(
responseProjectMismatch({ index: { projectDir: PROJECT_B } }, PROJECT_A),
true,
);
// Nothing to compare against: not a mismatch.
assert.equal(responseProjectMismatch({ index: { state: "ready" } }, PROJECT_A), false);
assert.equal(responseProjectMismatch({ index: { projectDir: PROJECT_B } }, null), false);
});
test("normalizePath ignores case and trailing separators", () => {
assert.equal(normalizePath("D:\\Mods\\Alpha\\"), normalizePath("d:/mods/alpha"));
});
test("findEndpoint prefers the per-project endpoint", async () => {
const h = home();
try {
await writeEndpointForProject(
PROJECT_A,
{ url: "http://127.0.0.1:1111", token: "tok-a", processId: process.pid },
h,
);
const endpoint = await findEndpoint({ projectDir: PROJECT_A, agentHome: h });
assert.equal(endpoint?.url, "http://127.0.0.1:1111");
} finally {
rmSync(h, { recursive: true, force: true });
}
});
test("findEndpoint finds a live instance when no per-project file exists", async () => {
const h = home();
try {
// A window that has not yet written per-project endpoints, only its own
// instance file. Discovery must still find it.
await writeInstance(
{
instanceId: "w1",
url: "http://127.0.0.1:2222",
token: "tok-inst",
projects: [PROJECT_A, PROJECT_B],
processId: process.pid,
},
h,
);
const endpoint = await findEndpoint({ projectDir: PROJECT_B, agentHome: h });
assert.equal(endpoint?.url, "http://127.0.0.1:2222");
} finally {
rmSync(h, { recursive: true, force: true });
}
});
test("findEndpoint ignores instances that do not serve the project", async () => {
const h = home();
try {
await writeInstance(
{
instanceId: "other",
url: "http://127.0.0.1:3333",
token: "tok",
projects: ["D:/Mods/Unrelated"],
processId: process.pid,
},
h,
);
assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null);
} finally {
rmSync(h, { recursive: true, force: true });
}
});
test("findEndpoint ignores dead instances", async () => {
const h = home();
try {
await writeInstance(
{
instanceId: "dead",
url: "http://127.0.0.1:4444",
token: "tok",
projects: [PROJECT_A],
processId: 0x7fffffff,
},
h,
);
assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null);
} finally {
rmSync(h, { recursive: true, force: true });
}
});
test("findEndpoint does not use a global endpoint recording another project", async () => {
const h = home();
try {
await writeEndpoint(
{ url: "http://127.0.0.1:5555", token: "tok", projectDir: PROJECT_B, processId: process.pid },
h,
);
assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null);
// It is still usable when asked for its own project.
assert.ok(await findEndpoint({ projectDir: PROJECT_B, agentHome: h }));
} finally {
rmSync(h, { recursive: true, force: true });
}
});
test("a query with no reachable live instance returns null", async () => {
const h = home();
try {
assert.equal(
await queryLive("get_status", {}, { projectDir: PROJECT_A, agentHome: h }),
null,
);
} finally {
rmSync(h, { recursive: true, force: true });
}
});
test("the negative cache suppresses repeated attempts and can be reset", async () => {
let now = 1_000_000;
const client = new LiveClient({ projectDir: PROJECT_A, now: () => now });
assert.equal(client.suppressed, false);
client.markUnavailable();
assert.equal(client.suppressed, true);
// Still suppressed just before the cooldown expires.
now += 4999;
assert.equal(client.suppressed, true);
// Expired afterwards.
now += 2;
assert.equal(client.suppressed, false);
client.markUnavailable();
client.reset();
assert.equal(client.suppressed, false);
});
+297
View File
@@ -0,0 +1,297 @@
/**
* End-to-end live-path test.
*
* Starts a real local HTTP live server (the same module the extension runs),
* registers it exactly the way the extension does (per-project endpoint +
* instance file + merged manifest), and then drives it through the shared
* `LiveClient` that both the MCP server and the CLI use.
*
* This is the in-process equivalent of "start the extension, then query it
* from the CLI/MCP", so it covers the transport, project pinning, project
* resolution and cross-project refusal without needing to spawn anything.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { startLocalServer } from "../out/agent/localServer.js";
import {
clearInstance,
readManifest,
pruneInstances,
writeInstance,
writeManifest,
} from "../out/agent/instances.js";
import { writeEndpointForProject } from "../out/agent/endpoint.js";
import { LiveClient, findEndpoint } from "../out/agent/liveClient.js";
import { parseLoadedXml } from "../out/agent/forwardRefs.js";
const PROJECT_A = "D:/Mods/Alpha";
const PROJECT_B = "D:/Mods/Beta";
function def(type, id, file, line) {
return { type, id, file, line, origin: "project", stream: "static" };
}
const UNIT_FILE = `${PROJECT_A}/Data/AthenaCannon.xml`;
const BASE_FILE = `${PROJECT_A}/Data/BaseCannon.xml`;
const UNIT = def("GameObject", "AthenaCannon", UNIT_FILE, 2);
const BASE = def("GameObject", "BaseCannon", BASE_FILE, 2);
const WEAPON = def("WeaponTemplate", "AthenaCannonWeapon", `${PROJECT_A}/Data/Weapon.xml`, 88);
const ATHENA_XML = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="AthenaCannon" inheritFrom="BaseCannon">
<CreateObjectDie><CreateObject>AthenaCannon_Die</CreateObject></CreateObjectDie>
</GameObject>
</AssetDeclaration>`;
const BASE_XML = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="BaseCannon">
<WeaponSetUpdate><WeaponSlotHardpoint><Weapon Template="AthenaCannonWeapon" /></WeaponSlotHardpoint></WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
const XML_FILES = {
[UNIT_FILE]: ATHENA_XML,
[BASE_FILE]: BASE_XML,
};
function makeIndex(projectDir) {
const assets = new Map([
["GameObject", new Map([["athenacannon", [UNIT]], ["basecannon", [BASE]]])],
["WeaponTemplate", new Map([["athenacannonweapon", [WEAPON]]])],
]);
return {
projectDir,
sdkDir: "",
complete: true,
phase: "art",
stale: false,
assets,
assetsById: new Map([
["athenacannon", [UNIT]],
["basecannon", [BASE]],
["athenacannonweapon", [WEAPON]],
]),
defines: new Map([
["d", [{ name: "D", value: "1", file: UNIT_FILE, line: 1, origin: "project" }]],
]),
files: new Map(),
streams: [
{
name: "static",
entry: `${projectDir}/Data/Mod.xml`,
files: new Set([UNIT_FILE.toLowerCase().replace(/\\/g, "/")]),
},
],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
references: new Map([
[
`GameObject\u0000athenacannon\u0000${UNIT_FILE}\u00002`,
[{ file: `${projectDir}/Data/Other.xml`, line: 3, start: 1, end: 2, kind: "attr" }],
],
]),
recordsHashes: new Map(),
stats: {
projectDir, sdkDir: "", phase: "art", complete: true,
indexedFiles: 2, parsedFiles: 2, shallowScannedFiles: 0, deferredArtFiles: 0,
shallowCacheHits: 0, recordsCacheHits: 0, resolveCacheHits: 0, resolveCalls: 0,
snapshotHits: 0, snapshotFallbacks: 0, candidatesMs: 0, walkMs: 0, artScanMs: 0,
assetCount: 3, referenceCount: 1, defineCount: 1, manifestFiles: 0,
manifestAssetCount: 0, streams: 1, sourceCandidates: 0, elapsedMs: 1,
},
};
}
/** Boots a live server + registration files, and returns a cleanup handle. */
async function bootLive() {
const agentHome = mkdtempSync(join(tmpdir(), "ra3-e2e-"));
const handle = await startLocalServer({
getIndex: (projectDir) =>
!projectDir || projectDir === PROJECT_A ? makeIndex(PROJECT_A) : null,
listProjects: () => [PROJECT_A, PROJECT_B],
loadFile: async (file) => {
const text = XML_FILES[file];
return text ? parseLoadedXml(text) : null;
},
});
const endpoint = {
instanceId: "e2e-1",
url: `http://127.0.0.1:${handle.port}`,
token: handle.token,
projectDir: PROJECT_A,
projects: [PROJECT_A, PROJECT_B],
processId: process.pid,
updatedAt: new Date().toISOString(),
};
await writeEndpointForProject(PROJECT_A, { ...endpoint, projectDir: PROJECT_A }, agentHome);
await writeInstance(endpoint, agentHome);
await writeManifest([endpoint], agentHome);
return {
agentHome,
endpoint,
async close() {
await handle.close();
rmSync(agentHome, { recursive: true, force: true });
},
};
}
test("LiveClient reaches a real live server and answers every snapshot tool", async () => {
const live = await bootLive();
try {
const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome });
const status = await client.query("get_status");
assert.equal(status?.mismatched, false);
assert.equal(status.payload.state, "ready");
assert.equal(status.payload.projectDir, PROJECT_A);
const found = await client.query("find_asset", { id: "AthenaCannon", type: "GameObject" });
assert.equal(found.payload.data.length, 1);
const refs = await client.query("find_references", { id: "AthenaCannon" });
assert.equal(refs.payload.data.length, 1);
const active = await client.query("is_file_active", { path: UNIT_FILE });
assert.equal(active.payload.data.active, true);
const define = await client.query("find_define", { name: "D" });
assert.equal(define.payload.data.length, 1);
const list = await client.query("list_assets_by_type", { type: "GameObject" });
assert.equal(list.payload.data.length, 2);
} finally {
await live.close();
}
});
test("the live path answers get_asset_references with element provenance", async () => {
const live = await bootLive();
try {
const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome });
const result = await client.query("get_asset_references", {
id: "AthenaCannon",
type: "GameObject",
targetTypes: ["WeaponTemplate"],
});
const data = result.payload.data;
assert.ok(data, "expected edge data");
// The weapon is written in BaseCannon's XML, reached through inheritFrom.
const weapon = data.edges.find((e) => e.to?.id === "AthenaCannonWeapon");
assert.ok(weapon, "expected the inherited weapon edge");
assert.equal(weapon.via.element, "Weapon");
assert.equal(weapon.via.parent, "WeaponSlotHardpoint");
assert.equal(weapon.definedIn.id, "BaseCannon");
assert.equal(weapon.source.file, BASE_FILE);
} finally {
await live.close();
}
});
test("list_projects reports the live project roots", async () => {
const live = await bootLive();
try {
const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome });
const result = await client.query("list_projects");
assert.deepEqual(result.payload.data, [PROJECT_A, PROJECT_B]);
} finally {
await live.close();
}
});
test("a client for an unknown project cannot use another project's answer", async () => {
const live = await bootLive();
try {
// The registered instance only serves PROJECT_A/PROJECT_B, so a client
// pinned to a third project must find nothing at all rather than fall
// back to PROJECT_A's data.
const outsider = new LiveClient({
projectDir: "D:/Mods/Unrelated",
agentHome: live.agentHome,
});
assert.equal(await outsider.query("find_asset", { id: "AthenaCannon" }), null);
} finally {
await live.close();
}
});
test("discovery works through the instance file alone (no per-project endpoint)", async () => {
const live = await bootLive();
try {
// Simulate a window that registered its instance but whose per-project
// endpoint has not been written yet.
rmSync(join(live.agentHome, "endpoints"), { recursive: true, force: true });
const endpoint = await findEndpoint({
projectDir: PROJECT_A,
agentHome: live.agentHome,
});
assert.equal(endpoint?.instanceId, "e2e-1");
} finally {
await live.close();
}
});
test("the merged manifest lists projects for discovery", async () => {
const live = await bootLive();
try {
const manifest = await readManifest(live.agentHome);
assert.ok(manifest);
assert.deepEqual(new Set(manifest.projects), new Set([PROJECT_A, PROJECT_B]));
assert.equal(manifest.instances.length, 1);
} finally {
await live.close();
}
});
test("a crashed instance is pruned by a later instance and then unreachable", async () => {
const live = await bootLive();
try {
// Add a second instance that looks crashed.
await writeInstance(
{
instanceId: "dead-window",
url: "http://127.0.0.1:1",
token: "t",
projects: [PROJECT_A],
processId: 0x7fffffff,
},
live.agentHome,
);
const pruned = await pruneInstances(live.agentHome);
assert.ok(pruned.removed.includes("dead-window"));
// The surviving instance still works after the prune.
const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome });
const status = await client.query("get_status");
assert.equal(status.payload.state, "ready");
} finally {
await live.close();
}
});
test("clearing this window's instance leaves other windows untouched", async () => {
const live = await bootLive();
try {
await writeInstance(
{
instanceId: "other-window",
url: "http://127.0.0.1:9999",
token: "t2",
projects: [PROJECT_A],
processId: process.pid,
},
live.agentHome,
);
await clearInstance("e2e-1", live.agentHome);
const { readInstances } = await import("../out/agent/instances.js");
const ids = (await readInstances(live.agentHome)).map((i) => i.instanceId);
assert.deepEqual(ids, ["other-window"]);
} finally {
await live.close();
}
});
+283
View File
@@ -0,0 +1,283 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { startLocalServer } from "../out/agent/localServer.js";
import { parseLoadedXml } from "../out/agent/forwardRefs.js";
const PROJECT_A = "D:/Mods/Example";
const PROJECT_B = "D:/Mods/Other";
function makeStats() {
return {
projectDir: "P",
sdkDir: "",
phase: "art",
complete: true,
indexedFiles: 1,
parsedFiles: 1,
shallowScannedFiles: 0,
deferredArtFiles: 0,
shallowCacheHits: 0,
recordsCacheHits: 0,
resolveCacheHits: 0,
resolveCalls: 0,
snapshotHits: 0,
snapshotFallbacks: 0,
candidatesMs: 0,
walkMs: 0,
artScanMs: 0,
assetCount: 1,
referenceCount: 1,
defineCount: 1,
manifestFiles: 0,
manifestAssetCount: 0,
streams: 1,
sourceCandidates: 1,
elapsedMs: 1,
};
}
const CANNON_XML = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="AthenaCannon" inheritFrom="BaseCannon">
<CreateObjectDie><CreateObject>AthenaCannon_Die</CreateObject></CreateObjectDie>
</GameObject>
</AssetDeclaration>`;
const BASE_XML = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="BaseCannon">
<WeaponSetUpdate><WeaponSlotHardpoint><Weapon Template="AthenaCannonWeapon" /></WeaponSlotHardpoint></WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
const FILES = {
"D:/Mods/Example/Data/AthenaCannon.xml": CANNON_XML,
"D:/Mods/Example/Data/BaseCannon.xml": BASE_XML,
};
function makeIndex(projectDir, unitId, extraDefs = []) {
const file = `${projectDir}/Data/${unitId}.xml`;
const unit = { type: "GameObject", id: unitId, file, line: 2, origin: "project", stream: "static" };
const all = [unit, ...extraDefs];
const assets = new Map();
const assetsById = new Map();
for (const d of all) {
if (!assets.has(d.type)) assets.set(d.type, new Map());
const byId = assets.get(d.type);
if (!byId.has(d.id.toLowerCase())) byId.set(d.id.toLowerCase(), []);
byId.get(d.id.toLowerCase()).push(d);
const key = d.id.toLowerCase();
if (!assetsById.has(key)) assetsById.set(key, []);
assetsById.get(key).push(d);
}
return {
projectDir,
sdkDir: "",
complete: true,
phase: "art",
stale: false,
assets,
assetsById,
defines: new Map([
["exampledefine", [{ name: "ExampleDefine", value: "1", file, line: 2, origin: "project" }]],
]),
files: new Map(),
streams: [
{
name: "static",
entry: `${projectDir}/Data/Mod.xml`,
files: new Set([file.toLowerCase().replace(/\\/g, "/")]),
},
],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
references: new Map([
[
`GameObject\u0000${unitId.toLowerCase()}\u0000${file}\u00002`,
[{ file: `${projectDir}/Data/Other.xml`, line: 3, start: 1, end: 2, kind: "attr" }],
],
]),
recordsHashes: new Map(),
stats: makeStats(),
};
}
const PROJECT_A_RELATED = [
{
type: "GameObject",
id: "BaseCannon",
file: "D:/Mods/Example/Data/BaseCannon.xml",
line: 2,
origin: "project",
stream: "static",
},
{
type: "GameObject",
id: "AthenaCannon_Die",
file: "D:/Mods/Example/Data/AthenaCannon_Die.xml",
line: 2,
origin: "project",
stream: "static",
},
{
type: "WeaponTemplate",
id: "AthenaCannonWeapon",
file: "D:/Mods/Example/Data/Weapon.xml",
line: 88,
origin: "project",
stream: "static",
},
];
/** Routes to a distinct index per requested project, like the extension does. */
function routedServerOptions() {
const indexes = new Map([
[PROJECT_A, makeIndex(PROJECT_A, "AthenaCannon", PROJECT_A_RELATED)],
[PROJECT_B, makeIndex(PROJECT_B, "OtherUnit")],
]);
return {
getIndex: (projectDir) => (projectDir ? indexes.get(projectDir) ?? null : indexes.get(PROJECT_A)),
listProjects: () => [...indexes.keys()],
loadFile: async (file) => {
const text = FILES[file];
return text ? parseLoadedXml(text) : null;
},
};
}
async function withServer(fn) {
const handle = await startLocalServer({ ...routedServerOptions(), token: "test-token" });
const base = `http://127.0.0.1:${handle.port}`;
const headers = { authorization: "Bearer test-token" };
const get = async (path) => (await fetch(`${base}${path}`, { headers })).json();
try {
await fn({ base, headers, get });
} finally {
await handle.close();
}
}
test("local server requires the bearer token", async () => {
await withServer(async ({ base }) => {
const unauthorized = await fetch(`${base}/status`);
assert.equal(unauthorized.status, 401);
const forbidden = await fetch(`${base}/status`, {
headers: { authorization: "Bearer wrong" },
});
assert.equal(forbidden.status, 401);
});
});
test("local server exposes read-only queries", async () => {
await withServer(async ({ get }) => {
const status = await get(`/status?project=${encodeURIComponent(PROJECT_A)}`);
assert.equal(status.state, "ready");
assert.equal(status.projectDir, PROJECT_A);
const asset = await get(
`/find_asset?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&type=GameObject`,
);
assert.equal(asset.data.length, 1);
const refs = await get(
`/find_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon`,
);
assert.equal(refs.data.length, 1);
const active = await get(
`/is_file_active?project=${encodeURIComponent(PROJECT_A)}&path=D:/Mods/Example/Data/AthenaCannon.xml`,
);
assert.equal(active.data.active, true);
});
});
test("?project= selects the index instead of the active editor's project", async () => {
await withServer(async ({ get }) => {
const a = await get(`/find_asset?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon`);
assert.equal(a.index.projectDir, PROJECT_A);
assert.equal(a.data.length, 1);
const b = await get(`/find_asset?project=${encodeURIComponent(PROJECT_B)}&id=OtherUnit`);
assert.equal(b.index.projectDir, PROJECT_B);
assert.equal(b.data.length, 1);
// The same id must not resolve when asked about the other project.
const cross = await get(
`/find_asset?project=${encodeURIComponent(PROJECT_B)}&id=AthenaCannon`,
);
assert.equal(cross.data.length, 0);
assert.equal(cross.index.projectDir, PROJECT_B);
});
});
test("an unknown project reports no_index without faking a projectDir", async () => {
await withServer(async ({ get }) => {
const unknown = "D:/Mods/Unknown";
const result = await get(`/status?project=${encodeURIComponent(unknown)}`);
assert.equal(result.state, "no_index");
assert.equal(result.projectDir, unknown);
});
});
test("/projects lists the known roots", async () => {
await withServer(async ({ get }) => {
const result = await get("/projects");
assert.deepEqual(new Set(result.data), new Set([PROJECT_A, PROJECT_B]));
});
});
test("/get_asset_references returns provenance-carrying edges", async () => {
await withServer(async ({ get }) => {
const result = await get(
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&type=GameObject`,
);
assert.equal(result.index.state, "ready");
const data = result.data;
assert.ok(data, "expected a data payload");
assert.equal(data.edges.length >= 3, true);
const weapon = data.edges.find((e) => e.to?.id === "AthenaCannonWeapon");
assert.ok(weapon, "expected the inherited weapon edge");
assert.equal(weapon.via.element, "Weapon");
assert.equal(weapon.via.parent, "WeaponSlotHardpoint");
assert.equal(weapon.definedIn.id, "BaseCannon");
assert.equal(weapon.source.file, "D:/Mods/Example/Data/BaseCannon.xml");
const die = data.edges.find((e) => e.via.kind === "content");
assert.ok(die, "expected the CreateObjectDie content edge");
assert.equal(die.to.id, "AthenaCannon_Die");
});
});
test("/get_asset_references honours targetTypes and depth parameters", async () => {
await withServer(async ({ get }) => {
const filtered = await get(
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&targetTypes=WeaponTemplate`,
);
for (const edge of filtered.data.edges) {
if (edge.via.kind === "inheritFrom") continue;
assert.equal(edge.to.type, "WeaponTemplate");
}
const shallow = await get(
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&depth=1`,
);
for (const edge of shallow.data.edges) {
assert.equal(edge.depth, 1);
}
const deep = await get(
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&depth=2`,
);
for (const edge of deep.data.edges) {
assert.ok(edge.depth <= 2);
}
});
});
test("/get_asset_references explains itself when live data is unavailable", async () => {
await withServer(async ({ get }) => {
const missing = await get(`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=Nope`);
assert.equal(missing.data.roots.length, 0);
assert.ok(missing.data.warnings.length > 0);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { test } from "node:test";
import assert from "node:assert/strict";
// The URL building, project pinning and cross-project refusal now live in
// `liveClient` and are covered by agentLiveClient.test.mjs. What remains
// MCP-layer-specific is that the server module is *importable*: it must not
// start its stdio loop as a side effect of being imported, otherwise tests and
// any tool that merely inspects the module would hang waiting on stdin.
test("the MCP server module imports without starting its stdio loop", async () => {
const mod = await import("../out/agent/mcpServer.js");
assert.equal(typeof mod, "object");
// Reaching this line proves main() did not run on import.
});
test("the CLI module imports without running", async () => {
const mod = await import("../out/agent/cli.js");
assert.equal(typeof mod.parseArgs, "function");
assert.equal(typeof mod.toolNameFor, "function");
assert.equal(typeof mod.liveArgsFor, "function");
assert.ok(mod.LIVE_ONLY instanceof Set);
});
+241
View File
@@ -0,0 +1,241 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
electronExecutableCandidates,
electronRuntime,
findElectronExecutable,
isElectronHost,
isNodeFreeLauncher,
launcherScript,
looksLikeElectronExecutable,
resolveRuntime,
} from "../out/agent/runtime.js";
import { writeLauncher } from "../out/agent/setup.js";
const WIN = "win32";
const LINUX = "linux";
// ── Pure launcher generation ──────────────────────────────────────────
test("electron runtime launcher runs without Node on PATH", () => {
const script = launcherScript(
{
runtime: electronRuntime("C:\\Apps\\VSCode\\Code.exe"),
serverPath: "C:\\ext\\dist\\agent\\mcpServer.js",
projectDir: "D:\\Mods\\Example",
},
WIN,
);
assert.ok(script.startsWith("@echo off"));
assert.ok(script.includes("set ELECTRON_RUN_AS_NODE=1"));
assert.ok(script.includes("C:\\Apps\\VSCode\\Code.exe"));
assert.ok(script.includes("C:\\ext\\dist\\agent\\mcpServer.js"));
// The Node path exists but must be guarded by the runtime-existence jump.
assert.ok(script.includes('if not exist "%RA3_RUNTIME%" goto :ra3_node'));
assert.ok(script.includes(":ra3_node"));
assert.equal(isNodeFreeLauncher(script, WIN), true);
});
test("Windows launcher avoids the parse-time %errorlevel% batch pitfall", () => {
const script = launcherScript(
{
runtime: electronRuntime("C:\\Apps\\VSCode\\Code.exe"),
serverPath: "C:\\ext\\mcpServer.js",
projectDir: "D:\\P",
},
WIN,
);
// Inside a parenthesised block %errorlevel% would expand at parse time.
assert.ok(!/^if exist .*\(\s*$/m.test(script), "must not use an if (...) block");
assert.equal(
(script.match(/exit \/b %errorlevel%/g) ?? []).length,
2,
"both Electron and Node endings should propagate the exit code",
);
});
test("node runtime launcher is generated when Electron is unavailable", () => {
const script = launcherScript(
{
runtime: { kind: "node", executable: "node", env: {}, viaPath: true },
serverPath: "C:\\ext\\mcpServer.js",
projectDir: "D:\\P",
},
WIN,
);
assert.ok(!script.includes("ELECTRON_RUN_AS_NODE"));
assert.ok(script.includes('set "RA3_NODE=node"'));
assert.equal(isNodeFreeLauncher(script, WIN), false);
});
test("POSIX launcher prefers Electron and falls back to Node", () => {
const script = launcherScript(
{
runtime: electronRuntime("/usr/share/code/code"),
serverPath: "/ext/mcpServer.js",
projectDir: "/mods/example",
},
LINUX,
);
assert.ok(script.startsWith("#!/usr/bin/env sh"));
assert.ok(script.includes("ELECTRON_RUN_AS_NODE=1 exec"));
assert.ok(script.includes('if [ -x "$RA3_RUNTIME" ]'));
assert.equal(isNodeFreeLauncher(script, LINUX), true);
});
test("launcher quotes paths safely", () => {
const win = launcherScript(
{
runtime: electronRuntime("C:\\Program Files\\VS Code\\Code.exe"),
serverPath: "C:\\my ext\\mcpServer.js",
projectDir: "D:\\My Mods\\Example",
},
WIN,
);
assert.ok(win.includes('set "RA3_RUNTIME=C:\\Program Files\\VS Code\\Code.exe"'));
assert.ok(win.includes('set "RA3_SERVER=C:\\my ext\\mcpServer.js"'));
const sh = launcherScript(
{
runtime: electronRuntime("/opt/it's here/code"),
serverPath: "/tmp/server.js",
projectDir: "/tmp/proj",
},
LINUX,
);
// Single quotes inside a single-quoted POSIX string must be escaped.
assert.ok(sh.includes(`'/opt/it'\\''s here/code'`));
});
test("looksLikeElectronExecutable recognises VS Code-family binaries", () => {
assert.equal(looksLikeElectronExecutable("C:\\...\\Microsoft VS Code\\Code.exe"), true);
assert.equal(looksLikeElectronExecutable("/usr/share/code/code"), true);
assert.equal(looksLikeElectronExecutable("/Applications/Visual Studio Code.app/Contents/MacOS/Electron"), true);
assert.equal(looksLikeElectronExecutable("C:\\Windows\\System32\\cmd.exe"), false);
});
test("runtime resolution falls back to Node outside an Electron host", () => {
// Tests run under plain Node, so the resolved runtime must be Node.
assert.equal(isElectronHost(), false);
const runtime = resolveRuntime();
assert.equal(runtime.kind, "node");
assert.equal(runtime.viaPath, true);
assert.deepEqual(runtime.env, {});
});
test("electronRuntime carries the ELECTRON_RUN_AS_NODE env", () => {
const runtime = electronRuntime("C:\\Code.exe");
assert.equal(runtime.kind, "electron");
assert.equal(runtime.executable, "C:\\Code.exe");
assert.deepEqual(runtime.env, { ELECTRON_RUN_AS_NODE: "1" });
assert.equal(runtime.viaPath, false);
});
test("electronExecutableCandidates returns platform-appropriate probes", () => {
const candidates = electronExecutableCandidates();
assert.ok(Array.isArray(candidates));
if (process.platform === "win32") {
assert.ok(candidates.every((c) => c.endsWith(".exe")));
}
// findElectronExecutable must never throw and returns a string or null.
const found = findElectronExecutable();
assert.ok(found === null || typeof found === "string");
});
// ── writeLauncher integration ─────────────────────────────────────────
test("writeLauncher writes an executable launcher and reports node-freeness", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-runtime-test-"));
try {
const result = await writeLauncher("C:\\ext", "D:\\Mods\\Example", home, electronRuntime("C:\\Code.exe"));
assert.ok(existsSync(result.path));
assert.equal(result.nodeFree, true);
assert.equal(result.runtime.kind, "electron");
const text = readFileSync(result.path, "utf8");
assert.ok(text.includes("ELECTRON_RUN_AS_NODE=1"));
assert.ok(text.includes("mcpServer.js"));
} finally {
rmSync(home, { recursive: true, force: true });
}
});
// ── Live launcher execution (skipped when the sandbox forbids spawning) ──
const electron = findElectronExecutable();
/**
* True when this process may launch the launcher, i.e. spawn a shell.
*
* Some sandboxes allow spawning `node` directly but deny `cmd.exe`/`sh`, so
* the probe must exercise the same capability the test needs instead of just
* spawning any child process.
*/
function canSpawnShell() {
try {
if (process.platform === "win32") {
execFileSync("cmd.exe", ["/d", "/c", "exit 0"], { stdio: "ignore" });
} else {
execFileSync("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" });
}
return true;
} catch {
return false;
}
}
const spawnable = canSpawnShell();
test(
"generated launcher completes an MCP session on the Electron runtime",
{ skip: !spawnable || !electron },
(t) => {
const home = mkdtempSync(join(tmpdir(), "ra3-launcher-run-"));
try {
const serverPath = join(process.cwd(), "dist", "agent", "mcpServer.js");
const launcher = join(home, process.platform === "win32" ? "launch.cmd" : "launch.sh");
writeFileSync(
launcher,
launcherScript({
runtime: electronRuntime(electron),
serverPath,
projectDir: "D:/Mods/Example",
// Point the Node fallback at a path that cannot exist, so a
// successful session proves the Electron branch was taken and the
// launcher really is Node-free.
nodeFallback: join(home, "no-such-node"),
}),
"utf8",
);
const requests = [
JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }),
].join("\n");
let stdout;
try {
stdout = execFileSync(launcher, [], {
input: requests,
encoding: "utf8",
timeout: 60000,
shell: process.platform === "win32",
});
} catch (err) {
if (err?.code === "EPERM") {
t.skip("sandbox forbids spawning a shell");
return;
}
throw err;
}
const lines = stdout.split(/\r?\n/).filter((l) => l.trim());
const init = JSON.parse(lines[0]);
assert.equal(init.result.serverInfo.name, "ra3-mod-xml");
const tools = JSON.parse(lines[1]);
assert.ok(tools.result.tools.length >= 9);
} finally {
rmSync(home, { recursive: true, force: true });
}
},
);
+138
View File
@@ -0,0 +1,138 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
addMcpServerToConfigFile,
installMcpServerConfigToFile,
launcherPath,
mcpConfigJson,
mcpServerConfig,
readMcpInstallRecord,
removeLauncher,
removeMcpServerFromConfigFile,
uninstallMcpServerConfigs,
} from "../out/agent/setup.js";
test("mcpServerConfig and mcpConfigJson use the stable launcher", () => {
const config = mcpServerConfig("C:/Users/me/.ra3modxml/ra3-mod-xml-mcp.cmd", "D:/Mods/Example");
assert.deepEqual(config.mcpServers["ra3-mod-xml"].args, ["--project", "D:/Mods/Example"]);
const json = mcpConfigJson("C:/launcher.cmd", "D:/Proj");
assert.ok(json.includes("C:/launcher.cmd"));
assert.ok(json.includes("D:/Proj"));
});
test("addMcpServerToConfigFile creates and merges config", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-setup-test-"));
try {
const file = join(dir, "mcp.json");
await addMcpServerToConfigFile(file, "C:/launcher.cmd", "D:/Proj");
const first = JSON.parse(readFileSync(file, "utf8"));
assert.ok(first.mcpServers["ra3-mod-xml"]);
writeFileSync(file, JSON.stringify({ mcpServers: { other: { command: "x" } } }, null, 2));
await addMcpServerToConfigFile(file, "C:/launcher.cmd", "D:/Proj");
const merged = JSON.parse(readFileSync(file, "utf8"));
assert.ok(merged.mcpServers.other);
assert.ok(merged.mcpServers["ra3-mod-xml"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("removeMcpServerFromConfigFile removes only our entry and preserves the rest", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-setup-remove-"));
try {
const file = join(dir, "mcp.json");
writeFileSync(
file,
JSON.stringify(
{
mcpServers: {
other: { command: "x" },
"ra3-mod-xml": { command: "launcher", args: ["--project", "D:/Mods/A"] },
},
someOtherKey: 1,
},
null,
2,
),
);
assert.equal(await removeMcpServerFromConfigFile(file), true);
const parsed = JSON.parse(readFileSync(file, "utf8"));
assert.ok(parsed.mcpServers.other);
assert.equal(parsed.mcpServers["ra3-mod-xml"], undefined);
assert.equal(parsed.someOtherKey, 1);
// Nothing left to remove: second call is a no-op.
assert.equal(await removeMcpServerFromConfigFile(file), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("removeMcpServerFromConfigFile handles VS Code's servers key and drops empty containers", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-setup-vscode-"));
try {
const file = join(dir, "mcp.json");
writeFileSync(
file,
JSON.stringify({ servers: { "ra3-mod-xml": { command: "x" } }, inputs: [] }, null, 2),
);
assert.equal(await removeMcpServerFromConfigFile(file), true);
const parsed = JSON.parse(readFileSync(file, "utf8"));
assert.equal(parsed.servers, undefined);
assert.deepEqual(parsed.inputs, []);
// A missing file is "nothing to remove", not an error.
assert.equal(await removeMcpServerFromConfigFile(join(dir, "nope.json")), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("installed MCP configs can be uninstalled through the install record", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-setup-home-"));
const dir = mkdtempSync(join(tmpdir(), "ra3-setup-record-"));
try {
const file = join(dir, "mcp.json");
await installMcpServerConfigToFile({
filePath: file,
launcher: "C:/launcher.cmd",
projectDir: "D:/Mods/A",
label: "Test client",
sourceVersion: "1.2.3",
agentHome: home,
});
const record = await readMcpInstallRecord(home);
assert.equal(record.length, 1);
assert.equal(record[0].label, "Test client");
assert.equal(record[0].serverKey, "ra3-mod-xml");
assert.equal(record[0].sourceVersion, "1.2.3");
const removed = await uninstallMcpServerConfigs({ agentHome: home });
assert.deepEqual(removed, [file]);
assert.equal((await readMcpInstallRecord(home)).length, 0);
// Our entry was the only server: the empty container is dropped.
assert.deepEqual(JSON.parse(readFileSync(file, "utf8")), {});
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(dir, { recursive: true, force: true });
}
});
test("removeLauncher deletes the stable launcher and tolerates its absence", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-setup-launcher-"));
try {
writeFileSync(launcherPath(home), "dummy", "utf8");
assert.equal(existsSync(launcherPath(home)), true);
assert.equal(await removeLauncher(home), true);
assert.equal(existsSync(launcherPath(home)), false);
// force: true, so removing it again is still a success.
assert.equal(await removeLauncher(home), true);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
+229
View File
@@ -0,0 +1,229 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
SKILL_MARKER_FILE,
forgetSkillInstallRecords,
installSkillToDirectories,
installedSkillStatus,
readSkillInstallRecord,
readSkillMarker,
uninstallRecordedSkills,
uninstallSkillFromDirectory,
writeSkillInstallRecord,
writeSkillTo,
} from "../out/agent/skill.js";
test("writeSkillTo creates SKILL.md and avoids project-doc noise", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-test-"));
try {
const skillDir = join(dir, "ra3-mod-xml");
await writeSkillTo(skillDir, "0.1.25");
assert.ok(existsSync(join(skillDir, "SKILL.md")));
assert.ok(existsSync(join(skillDir, "references", "query-guide.md")));
assert.ok(existsSync(join(skillDir, SKILL_MARKER_FILE)));
const content = readFileSync(join(skillDir, "SKILL.md"), "utf8");
assert.ok(content.includes("find_asset"));
assert.ok(!content.includes("codebase-navigation-guide"));
// The bundled reference must list every tool the MCP server exposes.
const guide = readFileSync(
join(skillDir, "references", "query-guide.md"),
"utf8",
);
for (const tool of [
"get_status",
"find_asset",
"find_references",
"get_asset_references",
"list_assets_by_type",
"is_file_active",
"find_define",
"resolve_include",
"list_projects",
"get_usage_guide",
]) {
assert.ok(guide.includes(tool), `query-guide.md must mention ${tool}`);
}
assert.equal(guide.includes("CnC3Types.xsd"), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("SKILL.md scopes itself to SAGE/RA3 projects and warns off others", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-scope-"));
try {
const skillDir = join(dir, "ra3-mod-xml");
await writeSkillTo(skillDir, "0.1.25");
const content = readFileSync(join(skillDir, "SKILL.md"), "utf8");
// Must state its applicability and list concrete positive signals.
assert.match(content, /When this skill applies/);
assert.ok(content.includes("Data/Mod.xml"));
assert.ok(content.includes("babproj"));
assert.ok(content.includes("AssetDeclaration"));
// Must give an explicit negative rule and a cheap probe.
assert.match(content, /Do \*\*not\*\* use these tools for unrelated repositories/);
assert.ok(content.includes("get_status"));
assert.ok(content.includes("projectDir"));
// `CnC3Types.xsd` is the shared SAGE base schema (the RA3 Mod SDK ships it
// too), so it must not be presented as evidence in either direction.
assert.equal(content.includes("CnC3Types.xsd"), false);
assert.equal(content.includes("Tiberium Wars"), false);
// Second-phase capability must be documented.
assert.ok(content.includes("get_asset_references"));
assert.ok(content.includes("definedIn"));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("SKILL.md explains how to reach the index without MCP", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-reach-"));
try {
const skillDir = join(dir, "ra3-mod-xml");
await writeSkillTo(skillDir, "0.1.25");
const content = readFileSync(join(skillDir, "SKILL.md"), "utf8");
assert.match(content, /## Reaching the index/);
// The discovery manifest is the stable entry point.
assert.ok(content.includes("~/.ra3modxml/index.json"));
// The launcher and the bundled CLI are both mentioned.
assert.ok(content.includes("ra3-mod-xml-mcp"));
assert.ok(content.includes("cli.js"));
// The CLI is a Node script, and the skill must say how to run it when Node
// is not installed (VS Code's Electron binary as Node).
assert.ok(content.includes("ELECTRON_RUN_AS_NODE=1"));
// The stdio escape hatch must be shown, since it needs no setup at all.
assert.ok(content.includes("tools/call"));
// It must not pretend configuring a client takes effect immediately.
assert.ok(content.includes("new session"));
// And it must refuse to invent results.
assert.match(content, /Never fabricate index results/);
// The bundled tool reference must be linked from SKILL.md, otherwise
// skills-compatible clients never load it (resources load on demand).
assert.ok(content.includes("./references/query-guide.md"));
// The tool list must stay a real list; a merged bullet hides an item.
assert.match(content, /indexed stream\.\n\s+- `find_define\(name\)`/);
// Instructions must not send it looking for project-specific docs.
assert.equal(content.includes("codebase-navigation-guide"), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("installSkillToDirectories records managed copies and uninstall removes them", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-skill-home-"));
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-target-"));
try {
const target = join(dir, "ra3-mod-xml");
const succeeded = await installSkillToDirectories([target], "0.1.25", home);
assert.deepEqual(succeeded, [target]);
const record = await readSkillInstallRecord(home);
assert.equal(record.length, 1);
assert.equal(record[0].path, target);
await uninstallSkillFromDirectory(target, home);
assert.equal(existsSync(target), false);
assert.equal((await readSkillInstallRecord(home)).length, 0);
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(dir, { recursive: true, force: true });
}
});
test("readSkillMarker requires our marker to describe the same directory", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-marker-"));
try {
const managed = join(dir, "managed");
await writeSkillTo(managed, "1.0.0");
const marker = await readSkillMarker(managed);
assert.equal(marker?.sourceVersion, "1.0.0");
// A hand-copied skill without a marker is never treated as ours.
const foreign = join(dir, "foreign");
mkdirSync(foreign, { recursive: true });
writeFileSync(join(foreign, "SKILL.md"), "user content", "utf8");
assert.equal(await readSkillMarker(foreign), null);
// A marker pointing at another directory must not make this one managed.
const spoofed = join(dir, "spoofed");
mkdirSync(spoofed, { recursive: true });
writeFileSync(
join(spoofed, SKILL_MARKER_FILE),
JSON.stringify({ path: managed, sourceVersion: "1.0.0" }),
"utf8",
);
assert.equal(await readSkillMarker(spoofed), null);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("uninstallRecordedSkills removes managed copies and skips user-owned ones", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-skill-uninstall-home-"));
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-uninstall-"));
try {
const managed = join(dir, "managed");
await installSkillToDirectories([managed], "1.0.0", home);
// A directory the user owns: recorded, but with no marker of ours.
const foreign = join(dir, "foreign");
mkdirSync(foreign, { recursive: true });
writeFileSync(join(foreign, "SKILL.md"), "user content", "utf8");
const record = await readSkillInstallRecord(home);
record.push({ path: foreign, sourceVersion: "0.0.0" });
await writeSkillInstallRecord(record, home);
const status = await installedSkillStatus(home);
assert.equal(status.length, 2);
assert.deepEqual(
status.map((s) => ({ path: s.path, managed: s.managed })),
[
{ path: managed, managed: true },
{ path: foreign, managed: false },
],
);
const { removed, skipped } = await uninstallRecordedSkills(
[managed, foreign],
home,
);
assert.deepEqual(removed, [managed]);
assert.deepEqual(skipped, [foreign]);
assert.equal(existsSync(managed), false);
assert.equal(readFileSync(join(foreign, "SKILL.md"), "utf8"), "user content");
// The unmanaged entry stays in the record so it can still be reviewed.
const after = await readSkillInstallRecord(home);
assert.deepEqual(after.map((r) => r.path), [foreign]);
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(dir, { recursive: true, force: true });
}
});
test("installedSkillStatus and forgetSkillInstallRecords handle a missing directory", async () => {
const home = mkdtempSync(join(tmpdir(), "ra3-skill-missing-home-"));
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-missing-"));
try {
const gone = join(dir, "gone");
await writeSkillInstallRecord([{ path: gone, sourceVersion: "1.0.0" }], home);
const status = await installedSkillStatus(home);
assert.equal(status.length, 1);
assert.equal(status[0].exists, false);
assert.equal(status[0].managed, false);
await forgetSkillInstallRecords([gone], home);
assert.equal((await readSkillInstallRecord(home)).length, 0);
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(dir, { recursive: true, force: true });
}
});
+184
View File
@@ -0,0 +1,184 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { snapshotFromIndex, writeSnapshotFile, readSnapshotFile } from "../out/agent/snapshot.js";
import {
findAssets,
findReferenceGroups,
isFileActive,
listAssetsByType,
resolveIncludeSource,
statusFromSnapshot,
} from "../out/agent/query.js";
function makeStats() {
return {
projectDir: "P",
sdkDir: "",
phase: "art",
complete: true,
indexedFiles: 3,
parsedFiles: 2,
shallowScannedFiles: 1,
deferredArtFiles: 0,
shallowCacheHits: 0,
recordsCacheHits: 0,
resolveCacheHits: 0,
resolveCalls: 0,
snapshotHits: 0,
snapshotFallbacks: 0,
candidatesMs: 0,
walkMs: 0,
artScanMs: 0,
assetCount: 2,
referenceCount: 1,
defineCount: 1,
manifestFiles: 0,
manifestAssetCount: 0,
streams: 1,
sourceCandidates: 1,
elapsedMs: 1,
};
}
function makeIndex() {
const file = "D:/Mods/Example/Data/Units/Example.xml";
const assets = new Map([
[
"GameObject",
new Map([
[
"exampleunit",
[
{
type: "GameObject",
id: "ExampleUnit",
file,
line: 5,
origin: "project",
stream: "static",
},
],
],
]),
],
]);
const references = new Map([
[
"GameObject\u0000exampleunit\u0000D:/Mods/Example/Data/Units/Example.xml\u00005",
[
{
file: "D:/Mods/Example/Data/Other.xml",
line: 3,
start: 10,
end: 21,
kind: "attr",
},
],
],
]);
return {
projectDir: "D:/Mods/Example",
sdkDir: "",
complete: true,
phase: "art",
stale: false,
assets,
assetsById: new Map([
[
"exampleunit",
[
{
type: "GameObject",
id: "ExampleUnit",
file,
line: 5,
origin: "project",
stream: "static",
},
],
],
]),
defines: new Map([
[
"exampledefine",
[
{
name: "ExampleDefine",
value: "1",
file,
line: 2,
origin: "project",
},
],
],
]),
files: new Map(),
streams: [
{
name: "static",
entry: "D:/Mods/Example/Data/Mod.xml",
files: new Set(["d:/mods/example/data/units/example.xml"]),
},
],
manifests: new Map(),
sourceCandidates: [
{
source: "DATA:Units/Example.xml",
path: "D:/Mods/Example/Data/Units/Example.xml",
prefix: "DATA",
baseDir: "D:/Mods/Example/Data",
},
],
diagnostics: [],
references,
recordsHashes: new Map(),
stats: makeStats(),
};
}
test("snapshotFromIndex flattens assets, defines, streams and references", () => {
const snapshot = snapshotFromIndex(makeIndex(), 42);
assert.equal(snapshot.schemaVersion, 1);
assert.equal(snapshot.assets.length, 1);
assert.equal(snapshot.assets[0].id, "ExampleUnit");
assert.equal(snapshot.defines.length, 1);
assert.equal(snapshot.streams.length, 1);
assert.deepEqual(snapshot.streams[0].files, [
"d:/mods/example/data/units/example.xml",
]);
assert.equal(snapshot.references.length, 1);
assert.equal(snapshot.references[0].sites.length, 1);
assert.equal(snapshot.buildId, 42);
});
test("query helpers operate on snapshots", () => {
const snapshot = snapshotFromIndex(makeIndex(), 1);
assert.equal(findAssets(snapshot, "ExampleUnit").length, 1);
assert.equal(findAssets(snapshot, "exampleunit", "GameObject").length, 1);
assert.equal(findAssets(snapshot, "exampleunit", "WeaponTemplate").length, 0);
assert.equal(listAssetsByType(snapshot, "gameobject", "exa").length, 1);
assert.equal(findReferenceGroups(snapshot, "ExampleUnit").length, 1);
assert.equal(isFileActive(snapshot, "D:/Mods/Example/Data/Units/Example.xml"), true);
assert.equal(isFileActive(snapshot, "D:/Mods/Example/Data/Dead.xml"), false);
assert.equal(resolveIncludeSource(snapshot, "data:units/example.xml")?.path, "D:/Mods/Example/Data/Units/Example.xml");
assert.equal(statusFromSnapshot(snapshot).state, "ready");
});
test("snapshot file write/read round-trips", async () => {
const dir = mkdtempSync(join(tmpdir(), "ra3-agent-test-"));
try {
const file = join(dir, "snapshot.json.gz");
const snapshot = snapshotFromIndex(makeIndex(), 7);
await writeSnapshotFile(file, snapshot);
const loaded = await readSnapshotFile(file);
assert.ok(loaded);
assert.equal(loaded.assets.length, snapshot.assets.length);
assert.equal(loaded.references[0].sites[0].file, "D:/Mods/Example/Data/Other.xml");
assert.equal(loaded.buildId, 7);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+318
View File
@@ -0,0 +1,318 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
assetDefsForCandidate,
assetOriginRank,
collectAssetSearchCandidates,
isEmptyAssetSearchQuery,
parseAssetSearchQuery,
searchAssetCandidates,
} from "../out/features/assetSearch.js";
function makeDef(type, id, file, line, extra = {}) {
return { type, id, file, line, origin: "project", ...extra };
}
function put(map, d) {
let byId = map.get(d.type);
if (!byId) {
byId = new Map();
map.set(d.type, byId);
}
const key = d.id.toLowerCase();
const arr = byId.get(key);
if (arr) arr.push(d);
else byId.set(key, [d]);
}
/** Minimal ModIndex surface used by the search core. */
function makeIndex({ assets = [], local = [] } = {}) {
const index = { assets: new Map() };
for (const d of assets) put(index.assets, d);
if (local.length) {
const localAssets = new Map();
for (const d of local) put(localAssets, d);
index.local = {
assets: localAssets,
assetsById: new Map(),
defines: new Map(),
};
}
return index;
}
const PROJECT = "D:/Mods/Corona";
const SDK = "C:/Apps/RA3-MODSDK-X";
test("parseAssetSearchQuery splits Type:Id, bare id and type-only queries", () => {
assert.deepEqual(parseAssetSearchQuery(" Assault "), {
raw: "Assault",
type: null,
id: "assault",
});
assert.deepEqual(parseAssetSearchQuery("WeaponTemplate:AssaultRifle"), {
raw: "WeaponTemplate:AssaultRifle",
type: "weapontemplate",
id: "assaultrifle",
});
assert.deepEqual(parseAssetSearchQuery("Weapon:"), {
raw: "Weapon:",
type: "weapon",
id: "",
});
// A bare leading colon is just an id search.
assert.deepEqual(parseAssetSearchQuery(":Assault"), {
raw: ":Assault",
type: null,
id: "assault",
});
// Manifest-style qualified names: type = first segment, id = last segment.
assert.deepEqual(parseAssetSearchQuery("W3DContainer:W3DContainer:AU"), {
raw: "W3DContainer:W3DContainer:AU",
type: "w3dcontainer",
id: "au",
});
});
test("parseAssetSearchQuery recognises empty queries", () => {
assert.ok(isEmptyAssetSearchQuery(parseAssetSearchQuery("")));
assert.ok(isEmptyAssetSearchQuery(parseAssetSearchQuery(" ")));
assert.ok(isEmptyAssetSearchQuery(parseAssetSearchQuery(":")));
assert.ok(!isEmptyAssetSearchQuery(parseAssetSearchQuery("Weapon:")));
});
test("collectAssetSearchCandidates dedupes type:id and counts definition sites", () => {
const index = makeIndex({
assets: [
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/weapons.xml`, 4),
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/weapons.xml`, 9),
makeDef("WeaponTemplate", "AssaultRifle", `${SDK}/builtmods/static.manifest`, 0, {
origin: "manifest",
manifestSource: "DATA:globaldata/weapon.xml",
}),
makeDef("GameObject", "AssaultRifle", `${PROJECT}/Data/units.xml`, 2),
],
});
const candidates = collectAssetSearchCandidates(index);
assert.equal(candidates.length, 2);
const weapon = candidates.find((c) => c.def.type === "WeaponTemplate");
assert.equal(weapon.definitionCount, 3);
// The mod definition wins over the manifest one.
assert.equal(weapon.def.origin, "project");
assert.equal(weapon.def.line, 4);
const gameObject = candidates.find((c) => c.def.type === "GameObject");
assert.equal(gameObject.definitionCount, 1);
});
test("collectAssetSearchCandidates merges the local overlay (unsaved wins)", () => {
const index = makeIndex({
assets: [makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 12)],
local: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 12, {
stream: "local",
}),
makeDef("GameObject", "TankPrototype", `${PROJECT}/Data/units.xml`, 40, {
stream: "local",
}),
],
});
const byId = new Map(
collectAssetSearchCandidates(index).map((c) => [c.def.id, c]),
);
assert.equal(byId.size, 2);
assert.equal(byId.get("Tank").def.stream, "local");
// The same file+line from overlay and global index is one definition.
assert.equal(byId.get("Tank").definitionCount, 1);
assert.equal(byId.get("TankPrototype").definitionCount, 1);
});
test("assetOriginRank orders local, project, SDK and manifest definitions", () => {
assert.equal(assetOriginRank(makeDef("A", "a", "f", 1, { stream: "local" })), 0);
assert.equal(assetOriginRank(makeDef("A", "a", "f", 1)), 1);
assert.equal(assetOriginRank(makeDef("A", "a", "f", 1, { origin: "sdk" })), 2);
assert.equal(
assetOriginRank(makeDef("A", "a", "f", 1, { origin: "manifest" })),
3,
);
});
test("searchAssetCandidates finds exact, prefix and partial ids", () => {
const index = makeIndex({
assets: [
makeDef("GameObject", "CrateDebris_01", `${PROJECT}/Data/a.xml`, 1),
makeDef("GameObject", "CrateDebris_02", `${PROJECT}/Data/a.xml`, 5),
makeDef("GameObject", "MyCrateDebris", `${PROJECT}/Data/a.xml`, 9),
makeDef("GameObject", "Unrelated", `${PROJECT}/Data/a.xml`, 13),
],
});
const candidates = collectAssetSearchCandidates(index);
const exact = searchAssetCandidates(
candidates,
parseAssetSearchQuery("CrateDebris_02"),
);
assert.equal(exact.total, 1);
assert.equal(exact.matches[0].def.id, "CrateDebris_02");
const partial = searchAssetCandidates(
candidates,
parseAssetSearchQuery("cratedebris"),
);
assert.equal(partial.total, 3);
// Prefix matches rank before substring matches.
assert.deepEqual(
partial.matches.map((m) => m.def.id),
["CrateDebris_01", "CrateDebris_02", "MyCrateDebris"],
);
const mid = searchAssetCandidates(
candidates,
parseAssetSearchQuery("Debris"),
);
assert.equal(mid.total, 3);
});
test("searchAssetCandidates is case-insensitive and type-aware for bare queries", () => {
const index = makeIndex({
assets: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/a.xml`, 1),
makeDef("WeaponTemplate", "TankGun", `${PROJECT}/Data/b.xml`, 1),
makeDef("GameObject", "OxTank", `${PROJECT}/Data/a.xml`, 5),
],
});
const candidates = collectAssetSearchCandidates(index);
const upper = searchAssetCandidates(candidates, parseAssetSearchQuery("TANK"));
assert.equal(upper.total, 3);
// A bare query that matches a type name lists that type's assets too.
const byType = searchAssetCandidates(
candidates,
parseAssetSearchQuery("GameObject"),
);
assert.equal(byType.total, 2);
assert.deepEqual(
byType.matches.map((m) => m.def.id).sort(),
["OxTank", "Tank"],
);
});
test("searchAssetCandidates honours Type:Id, Type: and prefixed queries", () => {
const index = makeIndex({
assets: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/a.xml`, 1),
makeDef("GameObject", "TankPrototype", `${PROJECT}/Data/a.xml`, 5),
makeDef("WeaponTemplate", "TankGun", `${PROJECT}/Data/b.xml`, 1),
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/b.xml`, 9),
],
});
const candidates = collectAssetSearchCandidates(index);
const typed = searchAssetCandidates(
candidates,
parseAssetSearchQuery("GameObject:Tank"),
);
assert.deepEqual(
typed.matches.map((m) => m.def.id),
["Tank", "TankPrototype"],
);
// Partial type names work as a filter as well.
const partialType = searchAssetCandidates(
candidates,
parseAssetSearchQuery("weapontemplate:assault"),
);
assert.equal(partialType.total, 1);
assert.equal(partialType.matches[0].def.id, "AssaultRifle");
// `Type:` lists every asset of that type.
const typeOnly = searchAssetCandidates(
candidates,
parseAssetSearchQuery("WeaponTemplate:"),
);
assert.equal(typeOnly.total, 2);
// A mismatching type excludes otherwise matching ids.
const mismatch = searchAssetCandidates(
candidates,
parseAssetSearchQuery("WeaponTemplate:Tank"),
);
assert.equal(mismatch.total, 1); // TankGun only (prefix), not GameObject Tank
assert.equal(mismatch.matches[0].def.id, "TankGun");
});
test("searchAssetCandidates ranks mod definitions before vanilla ones", () => {
const index = makeIndex({
assets: [
makeDef("WeaponTemplate", "AssaultRifle", `${SDK}/builtmods/static.manifest`, 0, {
origin: "manifest",
}),
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/weapon.xml`, 7),
],
});
const candidates = collectAssetSearchCandidates(index);
const result = searchAssetCandidates(
candidates,
parseAssetSearchQuery("AssaultRifle"),
);
assert.equal(result.total, 1);
assert.equal(result.matches[0].def.origin, "project");
assert.equal(result.matches[0].definitionCount, 2);
});
test("searchAssetCandidates returns no rows for an empty query and caps results", () => {
const assets = [];
for (let i = 0; i < 25; i++) {
assets.push(
makeDef("GameObject", `Target_${String(i).padStart(2, "0")}`, "f", i + 1),
);
}
const candidates = collectAssetSearchCandidates(makeIndex({ assets }));
const empty = searchAssetCandidates(candidates, parseAssetSearchQuery(""));
assert.equal(empty.total, 0);
assert.equal(empty.matches.length, 0);
const all = searchAssetCandidates(candidates, parseAssetSearchQuery("Target"));
assert.equal(all.total, 25);
assert.equal(all.matches.length, 25);
const capped = searchAssetCandidates(
candidates,
parseAssetSearchQuery("Target"),
10,
);
assert.equal(capped.total, 25);
assert.equal(capped.matches.length, 10);
});
test("assetDefsForCandidate lists distinct sites, local overlay first", () => {
const index = makeIndex({
assets: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 3),
makeDef("GameObject", "Tank", `${SDK}/SageXml/units.xml`, 1, {
origin: "sdk",
}),
makeDef("GameObject", "Tank", `${SDK}/SageXml/units.xml`, 1, {
origin: "sdk",
}),
],
local: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 3, {
stream: "local",
}),
],
});
const candidate = collectAssetSearchCandidates(index).find(
(c) => c.def.id === "Tank",
);
const defs = assetDefsForCandidate(index, candidate);
assert.equal(defs.length, 2);
assert.equal(defs[0].stream, "local");
assert.equal(defs[1].origin, "sdk");
});
+107 -27
View File
@@ -17,6 +17,18 @@ class CodeLens {
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 Module = require("module");
@@ -32,6 +44,7 @@ require.cache["vscode-stub"] = {
exports: {
Range,
CodeLens,
EventEmitter,
},
};
@@ -72,6 +85,20 @@ function makeDocument(text = TEXT) {
}
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 = {
file: "C:/mod/Data/Other.xml",
line: 7,
@@ -88,37 +115,33 @@ function makeIndex() {
};
const references = new Map();
references.set(
assetDefKey({
type: "GameObject",
id: "TestTank",
file: FILE,
line: 2,
}),
assetDefKey(tankDef),
[tankSite, secondSite],
);
references.set(
assetDefKey({
type: "GameObject",
id: "BaseVehicle",
file: FILE,
line: 3,
}),
[],
);
references.set(assetDefKey(baseDef), []);
return {
references,
assets: new Map(),
assetsById: new Map([
["testtank", [tankDef]],
["basevehicle", [baseDef]],
]),
stats: { indexedFiles: 10 },
sdkDir: SDK,
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({
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");
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);
});
test("CodeLens returns nothing without a workspace or index", () => {
test("CodeLens returns nothing without a workspace or index", async () => {
const noWorkspace = new Ra3CodeLensProvider({
isRa3Workspace: () => false,
index: makeIndex(),
});
assert.deepEqual(noWorkspace.provideCodeLenses(makeDocument(), {}), []);
assert.deepEqual(await noWorkspace.provideCodeLenses(makeDocument(), {}), []);
const noIndex = new Ra3CodeLensProvider({
isRa3Workspace: () => true,
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 = {
type: "GameObject",
id: "TestTank",
@@ -175,30 +246,39 @@ test("CodeLens counts references attached to a manifest definition with the same
assets: new Map([
["GameObject", new Map([["testtank", [manifestDef]]])],
]),
assetsById: new Map([["testtank", [manifestDef]]]),
stats: { indexedFiles: 10 },
sdkDir: SDK,
projectDir: PROJECT,
};
const provider = new Ra3CodeLensProvider({
isRa3Workspace: () => true,
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");
assert.ok(tank, "lens is shown for the SageXml-backed definition");
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();
idx.recordsHashes = new Map([[normKey(FILE), "stale-hash"]]);
const calls = [];
const provider = new Ra3CodeLensProvider({
const ws = {
isRa3Workspace: () => true,
index: idx,
invalidate: (p) => calls.push(["invalidate", p]),
scheduleRebuild: (r) => calls.push(["schedule", r]),
});
provider.provideCodeLenses(makeDocument(), {});
getCodeLensScope: async () => ({ merged: idx }),
recordsSyncSurfaceFor: () => ws,
log: () => {},
};
const provider = new Ra3CodeLensProvider(ws);
await provider.provideCodeLenses(makeDocument(), {});
assert.ok(
calls.some(([kind]) => kind === "invalidate"),
"the stale file is invalidated",
+263
View File
@@ -308,6 +308,21 @@ test("element and attribute name completions work without an index", async () =>
assert.ok(labels.includes("Surfaces"));
});
test("universal inheritFrom is offered on asset attribute completion", async () => {
const text = `<AssetDeclaration>\n <FXList `;
const line1 = text.split("\n")[1];
const pos = new Position(1, line1.length);
const items = await providerNoIndex.provideCompletionItems(
makeDocument(text),
pos,
token,
);
const labels = listItems(items).map((i) => i.label);
assert.ok(labels.includes("inheritFrom"), "FXList offers universal inheritFrom");
assert.ok(labels.includes("id"));
});
test("attribute completion after a closed quote inserts a space", async () => {
const text =
`<AssetDeclaration>\n` +
@@ -416,6 +431,64 @@ test("whitespace used to trigger the popup is consumed on newline insert", async
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 () => {
const text =
`<AssetDeclaration>\n` +
@@ -704,6 +777,138 @@ test("simple-content value completion works before the closing tag is typed", as
assert.equal(item.range.end.character, pos.character);
});
test("asset value completion keeps a typed Type: prefix the user already typed", async () => {
const def = {
type: "AudioEvent",
id: "BaseSoundEffect",
file: "Sounds.xml",
line: 1,
origin: "sdk",
};
const idx = {
assets: new Map([["AudioEvent", new Map([["basesoundeffect", [def]]])]]),
assetsById: new Map([["basesoundeffect", [def]]]),
};
// Qualified input: the typed "AudioEvent:" prefix must be kept, so the
// completed value is "AudioEvent:BaseSoundEffect" and the replacement
// range still covers only the current segment.
const qualifiedText =
`<AssetDeclaration>\n` +
` <AudioEvent id="X" inheritFrom="AudioEvent:Base\n` +
`</AssetDeclaration>`;
const qualifiedLine = qualifiedText.split("\n")[1];
const qualifiedPos = new Position(1, qualifiedLine.length);
const qualifiedItems = await makeProvider(idx).provideCompletionItems(
makeDocument(qualifiedText),
qualifiedPos,
token,
);
const qualifiedLabels = qualifiedItems.map((i) => i.label);
assert.ok(
qualifiedLabels.includes("AudioEvent:BaseSoundEffect"),
"qualified label offered for AudioEvent:Base",
);
assert.ok(
!qualifiedLabels.includes("BaseSoundEffect"),
"the bare id is not offered when a type prefix was typed",
);
const qualifiedItem = qualifiedItems.find(
(i) => i.label === "AudioEvent:BaseSoundEffect",
);
assert.equal(qualifiedItem.insertText, "AudioEvent:BaseSoundEffect");
assert.equal(
qualifiedItem.range.start.character,
qualifiedLine.lastIndexOf('"') + 1,
);
assert.equal(qualifiedItem.range.end.character, qualifiedPos.character);
// Plain input stays plain: no prefix typed -> bare id, unchanged behavior.
const plainText =
`<AssetDeclaration>\n` +
` <AudioEvent id="X" inheritFrom="Base\n` +
`</AssetDeclaration>`;
const plainLine = plainText.split("\n")[1];
const plainPos = new Position(1, plainLine.length);
const plainItems = await makeProvider(idx).provideCompletionItems(
makeDocument(plainText),
plainPos,
token,
);
const plainItem = plainItems.find((i) => i.label === "BaseSoundEffect");
assert.ok(plainItem, "bare id offered without a type prefix");
assert.equal(plainItem.insertText, "BaseSoundEffect");
});
test("simpleContent complex child inserts a value pair and triggers suggest", async () => {
const text =
`<AssetDeclaration>\n` +
` <AudioEvent id="A">\n` +
` <S`;
const line = text.split("\n")[2];
const pos = new Position(2, line.length);
const items = await providerNoIndex.provideCompletionItems(
makeDocument(text),
pos,
token,
);
const sound = listItems(items).find((i) => i.label === "Sound");
assert.ok(sound, "Sound child is offered under AudioEvent");
assert.equal(sound.insertText.value, "Sound>$1</Sound>");
assert.ok(sound.command, "simpleContent child re-triggers value suggest");
});
test("simpleContent complex text offers typed asset ids as the value", async () => {
const text =
`<AssetDeclaration>\n` +
` <AudioEvent id="A">\n` +
` <Sound>V</Sound>\n` +
` </AudioEvent>\n` +
`</AssetDeclaration>`;
const line = text.split("\n")[2];
const pos = new Position(2, line.indexOf(">V") + 2);
const audioFile = {
type: "AudioFile",
id: "VoiceFile",
file: "AudioFiles.xml",
line: 1,
origin: "project",
};
const audioEvent = {
type: "AudioEvent",
id: "VoiceEvent",
file: "Voice.xml",
line: 1,
origin: "project",
};
const idx = {
assets: new Map([
["AudioFile", new Map([["voicefile", [audioFile]]])],
["AudioEvent", new Map([["voiceevent", [audioEvent]]])],
]),
assetsById: new Map([
["voicefile", [audioFile]],
["voiceevent", [audioEvent]],
]),
};
const items = await makeProvider(idx).provideCompletionItems(
makeDocument(text),
pos,
token,
);
const labels = items.map((i) => i.label);
assert.ok(labels.includes("VoiceFile"));
assert.ok(
!labels.includes("VoiceEvent"),
"Sound content is filtered by AudioFileRefWithWeight refType AudioFile",
);
const item = items.find((i) => i.label === "VoiceFile");
assert.equal(item.range.start.character, line.indexOf(">V") + 1);
assert.equal(item.range.end.character, pos.character);
});
test("content start after accepting a simple-content snippet offers values, not attributes", async () => {
const text =
`<AssetDeclaration>\n` +
@@ -859,3 +1064,61 @@ test("current-file local overlay assets survive the global 400 cap", async () =>
assert.equal(result.isIncomplete, true);
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",
);
});
+496 -1
View File
@@ -1,6 +1,9 @@
import { test } from "node:test";
import assert from "node:assert/strict";
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.
const CompletionItemKind = {};
@@ -37,7 +40,7 @@ class Hover {
class Location {
constructor(uri, range) {
this.uri = uri;
this.range = range;
this.range = range instanceof Position ? new Range(range, range) : range;
}
}
class Diagnostic {
@@ -236,6 +239,315 @@ test("Ctrl+click on simple-content text jumps to the definition", async () => {
);
});
test("qualified Type:Id inheritFrom is diagnosed and navigated as resolved", async () => {
const text =
`<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n` +
` <AudioEvent id="BaseSoundEffect"/>\n` +
` <AudioEvent id="X" inheritFrom="AudioEvent:BaseSoundEffect"/>\n` +
`</AssetDeclaration>`;
const def = {
type: "AudioEvent",
id: "BaseSoundEffect",
file: URI,
line: 1,
origin: "project",
};
const idx = makeIdx([def]);
const scope = await makeScope(text, idx);
// Diagnostics: the manifest-style qualified value must not be reported as
// an unresolved reference (the reported FutureTank scenario).
const collection = new FakeDiagnosticCollection();
const diagnostics = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: false,
reportUnresolvedReferences: "warning",
},
});
diagnostics["collection"] = collection;
await diagnostics.update(makeDocument(text));
const messages = collection.last.diags.map((d) => d.message);
assert.ok(
!messages.some((m) => m.includes("AudioEvent:BaseSoundEffect")),
"qualified inheritFrom is not unresolved",
);
// Ctrl+click on the qualified value jumps to the plain-id definition.
const provider = new Ra3DefinitionProvider({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: { definitionMode: "all" },
indexer: null,
});
const line = text.split("\n")[2];
const pos = new Position(2, line.indexOf("AudioEvent:BaseSoundEffect") + 8);
const locations = await provider.provideDefinition(makeDocument(text), pos, {});
assert.ok(locations && locations.length === 1, "qualified reference resolves");
const defLine = text.split("\n")[1];
const defStartChar =
defLine.indexOf('id="BaseSoundEffect"') + 'id="'.length;
assert.equal(locations[0].range.start.line, 1);
assert.equal(locations[0].range.start.character, defStartChar);
assert.equal(
locations[0].range.end.character,
defStartChar + "BaseSoundEffect".length,
);
});
test("hover on simpleContent complex content shows the referenced definition", async () => {
const text =
`<AssetDeclaration>\n` +
` <AudioEvent id="A">\n` +
` <Sound>VoiceFile</Sound>\n` +
` </AudioEvent>\n` +
`</AssetDeclaration>`;
const def = {
type: "AudioFile",
id: "VoiceFile",
file: URI,
line: 2,
origin: "project",
};
const scope = await makeScope(text, makeIdx([def]));
const provider = new Ra3HoverProvider({
isRa3Workspace: () => true,
getScope: async () => scope,
searchPaths: () => null,
});
const line = text.split("\n")[2];
const pos = new Position(2, line.indexOf("VoiceFile") + 3);
const hover = await provider.provideHover(makeDocument(text), pos, {});
assert.ok(hover, "hover is returned for AudioFileRefWithWeight content");
assert.match(hover.contents.value, /1 definition/);
assert.match(hover.contents.value, /AudioFile/);
});
test("Ctrl+click on simpleContent complex content jumps to the definition", async () => {
const text =
`<AssetDeclaration>\n` +
` <AudioEvent id="A">\n` +
` <Sound>VoiceFile</Sound>\n` +
` </AudioEvent>\n` +
`</AssetDeclaration>`;
const def = {
type: "AudioFile",
id: "VoiceFile",
file: URI,
line: 2,
origin: "project",
};
const scope = await makeScope(text, makeIdx([def]));
const provider = new Ra3DefinitionProvider({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: { definitionMode: "all" },
indexer: null,
});
const line = text.split("\n")[2];
const pos = new Position(2, line.indexOf("VoiceFile") + 3);
const locations = await provider.provideDefinition(makeDocument(text), pos, {});
assert.ok(locations && locations.length === 1);
assert.equal(locations[0].uri.fsPath, URI);
});
test("diagnostics report unresolved simpleContent complex content references", async () => {
const text =
`<AssetDeclaration>\n` +
` <Multisound id="M">\n` +
` <Subsound>MissingEvent</Subsound>\n` +
` </Multisound>\n` +
`</AssetDeclaration>`;
const scope = await makeScope(text, makeIdx([]));
const collection = new FakeDiagnosticCollection();
const provider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: false,
reportUnresolvedReferences: "warning",
},
});
provider["collection"] = collection;
await provider.update(makeDocument(text));
const messages = collection.last.diags.map((d) => d.message);
assert.ok(
messages.some((m) => m.includes('Unresolved reference "MissingEvent"')),
"MultisoundSubsoundRef text is diagnosed as a typed content reference",
);
});
test("Ctrl+click on a manifest definition maps to SageXml even when the mod shadows the DATA path", async () => {
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 () => {
const text =
`<AssetDeclaration>\n` +
@@ -279,3 +591,186 @@ test("diagnostics report unresolved typed content references only", async () =>
"untyped WeakReference content is not diagnosed as a global ref",
);
});
test("fragment diagnostics skip document-level checks but keep subtree validation", async () => {
const text =
`<CreateObjectDie xmlns="uri:ea.com:eala:asset" id="ModuleTag_X" CreationList="OCL_X">\n` +
` <DieMuxData DeathTypo="SUICIDED"/>\n` +
`</CreateObjectDie>`;
const scope = await makeScope(text, makeIdx([]));
const collection = new FakeDiagnosticCollection();
const provider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: true,
reportUnresolvedReferences: "warning",
},
});
provider["collection"] = collection;
await provider.update(makeDocument(text));
const codes = collection.last.diags.map((d) => d.code);
assert.ok(
!codes.includes("missing-id"),
"fragment children are not treated as top-level assets",
);
assert.ok(
!codes.some((c) => c.startsWith("unresolved-reference")),
"fragment references are deferred to the includer context",
);
assert.ok(
codes.includes("unknown-attribute"),
"a known fragment root still validates its subtree attributes",
);
});
test("fragment diagnostics ignore unknown wrapper roots and still report missing xi:include", async () => {
const text =
`<CommonArmorDraws xmlns="uri:ea.com:eala:asset" xmlns:xi="http://www.w3.org/2001/XInclude">\n` +
` <ScriptedModelDraw id="M" Bogus="x"/>\n` +
` <xi:include href="MissingTarget.xml"/>\n` +
`</CommonArmorDraws>`;
const scope = await makeScope(text, makeIdx([]));
const collection = new FakeDiagnosticCollection();
const provider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: true,
reportUnresolvedReferences: "warning",
},
});
provider["collection"] = collection;
await provider.update(makeDocument(text));
const codes = collection.last.diags.map((d) => d.code);
assert.ok(
!codes.includes("unknown-element"),
"wrapper roots are not validated as standalone documents",
);
assert.ok(
!codes.includes("unknown-attribute"),
"unknown wrapper roots do not trigger subtree attribute guessing",
);
assert.ok(
codes.includes("include-not-found"),
"missing xi:include targets inside fragments are still reported",
);
});
test("diagnostics accept universal inheritFrom on asset types", async () => {
const text =
`<AssetDeclaration>\n` +
` <FXList id="FX_A" inheritFrom="FX_Base">\n` +
` <NuggetList/>\n` +
` </FXList>\n` +
`</AssetDeclaration>`;
const scope = await makeScope(text, makeIdx([]));
const collection = new FakeDiagnosticCollection();
const provider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: true,
reportUnresolvedReferences: "none",
},
});
provider["collection"] = collection;
await provider.update(makeDocument(text));
const codes = collection.last.diags.map((d) => d.code);
assert.ok(
!codes.includes("unknown-attribute"),
"FXList inheritFrom must not be flagged as unknown",
);
const badText =
`<AssetDeclaration>\n` +
` <FXList id="FX_B" Bogus="x">\n` +
` <NuggetList/>\n` +
` </FXList>\n` +
`</AssetDeclaration>`;
const badScope = await makeScope(badText, makeIdx([]));
const badCollection = new FakeDiagnosticCollection();
const badProvider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => badScope,
settings: {
diagnoseUnknownElements: true,
reportUnresolvedReferences: "none",
},
});
badProvider["collection"] = badCollection;
await badProvider.update(makeDocument(badText));
assert.ok(
badCollection.last.diags.map((d) => d.code).includes("unknown-attribute"),
"a real unknown attribute is still reported",
);
});
test("diagnostics keep simpleContent extension attributes known", async () => {
const text =
`<AssetDeclaration>\n` +
` <AudioEvent id="A">\n` +
` <Sound Weight="100">AudioFile</Sound>\n` +
` </AudioEvent>\n` +
`</AssetDeclaration>`;
const scope = await makeScope(text, makeIdx([]));
const collection = new FakeDiagnosticCollection();
const provider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: true,
reportUnresolvedReferences: "none",
},
});
provider["collection"] = collection;
await provider.update(makeDocument(text));
const codes = collection.last.diags.map((d) => d.code);
assert.ok(
!codes.includes("unknown-attribute"),
"Weight on AudioFileRefWithWeight must be known",
);
});
test("diagnostics use the top-level asset type for colliding fragment roots", async () => {
const text =
`<EvaEvent id="IncomingTransmission" Priority="100" TimeBetweenEvents="0ms" ExpirationTime="10000ms"/>`;
const scope = await makeScope(text, makeIdx([]));
const collection = new FakeDiagnosticCollection();
const provider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: true,
reportUnresolvedReferences: "none",
},
});
provider["collection"] = collection;
await provider.update(makeDocument(text));
const codes = collection.last.diags.map((d) => d.code);
assert.ok(
!codes.includes("unknown-attribute"),
"EvaEvent fragment root attributes must resolve against the top-level asset type",
);
});
test("full documents still require ids on top-level assets", async () => {
const text = `<AssetDeclaration>\n <GameObject/>\n</AssetDeclaration>`;
const scope = await makeScope(text, makeIdx([]));
const collection = new FakeDiagnosticCollection();
const provider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: true,
reportUnresolvedReferences: "warning",
},
});
provider["collection"] = collection;
await provider.update(makeDocument(text));
const codes = collection.last.diags.map((d) => d.code);
assert.ok(
codes.includes("missing-id"),
"AssetDeclaration documents keep top-level id checks",
);
});
+53
View File
@@ -137,3 +137,56 @@ test("diskCacheKey differs when the identity changes", () => {
assert.notEqual(a, b);
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 { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import {
buildSearchPaths,
buildVanillaSearchPaths,
resolveSource,
manifestPathForReference,
} 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"));
});
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", () => {
const dirs = [join(sdk, "builtmods")];
assert.equal(manifestPathForReference("DATA:static.xml", dirs), join(dirs[0], "static.manifest"));
+322
View File
@@ -12,6 +12,7 @@ import {
IndexRecordsCache,
} from "../out/indexer/caches.js";
import { resolveReferenceTargetsForType } from "../out/indexer/refs.js";
import { assetDefKey } from "../out/indexer/referenceIndex.js";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const project = join(root, "test", "fixtures", "minimod");
@@ -29,6 +30,75 @@ async function buildIndex() {
return indexer.build();
}
function u32(value) {
const b = Buffer.alloc(4);
b.writeUInt32LE(value >>> 0);
return b;
}
function u16(value) {
const b = Buffer.alloc(2);
b.writeUInt16LE(value >>> 0);
return b;
}
/** Minimal version-5 manifest with one asset entry per supplied descriptor. */
function minimalManifestV5(assets) {
const nameParts = [];
const sourceParts = [];
let nameOffset = 0;
let sourceOffset = 0;
const entries = assets.map((asset) => {
const name = Buffer.from(`${asset.name}\0`, "ascii");
const source = Buffer.from(`${asset.source ?? ""}\0`, "ascii");
const entry = {
typeId: asset.typeId,
nameOffset,
sourceFileNameOffset: sourceOffset,
};
nameParts.push(name);
sourceParts.push(source);
nameOffset += name.length;
sourceOffset += source.length;
return entry;
});
const names = Buffer.concat(nameParts);
const sources = Buffer.concat(sourceParts);
const parts = [
Buffer.from([0, 1]), // isBigEndian=false, isLinked=true
u16(5), // version
u32(0), // streamChecksum
u32(0), // allTypesHash
u32(assets.length), // assetCount
u32(0), // totalInstanceDataSize
u32(0), // maxInstanceChunkSize
u32(0), // maxRelocationChunkSize
u32(0), // maxImportsChunkSize
u32(0), // assetReferenceBufferSize
u32(0), // referencedManifestNameBufferSize
u32(names.length), // assetNameBufferSize
u32(sources.length), // sourceFileNameBufferSize
];
for (const entry of entries) {
parts.push(
u32(entry.typeId),
u32(0), // instanceId
u32(0), // typeHash
u32(0), // instanceHash
u32(0), // assetReferenceOffset
u32(0), // assetReferenceCount
u32(entry.nameOffset),
u32(entry.sourceFileNameOffset),
u32(0), // instanceDataSize
u32(0), // relocationDataSize
u32(0), // importsDataSize
);
}
parts.push(names, sources);
return Buffer.concat(parts);
}
test("indexes assets, defines, streams and include errors", async () => {
const idx = await buildIndex();
@@ -133,6 +203,177 @@ test("w3x files appear in Include source completion candidates", async () => {
);
});
test("manifest assets sharing an id keep every type in assetsById", async () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ra3-manifest-multitype-"));
const projectDir = join(tmp, "project");
const sdkDir = join(tmp, "sdk");
const builtmodsDir = join(sdkDir, "builtmods");
fs.mkdirSync(join(projectDir, "Data"), { recursive: true });
fs.mkdirSync(builtmodsDir, { recursive: true });
fs.writeFileSync(join(sdkDir, "Static.xml"), "<AssetDeclaration/>");
fs.writeFileSync(
join(projectDir, "Data", "Mod.xml"),
`<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Includes>
<Include type="reference" source="DATA:static.xml" />
</Includes>
<GameObject id="AlliedMCV">
<Draws>
<ScriptedModelDraw id="ModuleTag_Draw_Hover">
<ModelConditionState ParseCondStateType="PARSE_DEFAULT">
<Model Name="AUMCV_Hover" />
</ModelConditionState>
</ScriptedModelDraw>
</Draws>
</GameObject>
</AssetDeclaration>`,
);
fs.writeFileSync(
join(builtmodsDir, "static.manifest"),
minimalManifestV5([
{
typeId: 0x11111111,
name: "W3DHierarchy:AUMCV_HOVER",
source: "ART:aumcv_hover.w3x",
},
{
typeId: 0x22222222,
name: "W3DAnimation:AUMCV_HOVER",
source: "ART:aumcv_hover.w3x",
},
{
typeId: 0x33333333,
name: "W3DContainer:AUMCV_HOVER",
source: "ART:aumcv_hover.w3x",
},
{
typeId: 0x44444444,
name: "Texture:ABAirfield",
source: "ART:abairfield.tga",
},
{
typeId: 0x55555555,
name: "W3DContainer:ABAIRFIELD",
source: "ART:abairfield.w3x",
},
]),
);
const indexer = new ModIndexer({
projectDir,
sdkDir,
builtmodsDirs: [builtmodsDir],
indexSageXml: false,
additionalDataSearchPaths: [],
walker: new CachedDirectoryWalker(),
});
const idx = await indexer.build();
// The reported AUMCV_HOVER shape: Hierarchy/Animation precede the
// W3DContainer, so the by-id index must not drop the render asset.
const hover = idx.assetsById.get("aumcv_hover");
assert.ok(hover?.some((d) => d.type === "W3DContainer"), "W3DContainer retained");
assert.ok(hover?.some((d) => d.type === "W3DHierarchy"), "W3DHierarchy retained");
assert.ok(hover?.some((d) => d.type === "W3DAnimation"), "W3DAnimation retained");
const targets = resolveReferenceTargetsForType(
idx,
"ScriptedModelDrawModel",
"Name",
"AUMCV_Hover",
);
assert.equal(targets.length, 1);
assert.equal(targets[0].def.type, "W3DContainer");
const container = hover.find((d) => d.type === "W3DContainer");
const sites = idx.references.get(assetDefKey(container));
assert.ok(
sites?.some((s) => s.kind === "attr" && /Mod\.xml$/.test(s.file)),
"Model reference is attributed to the W3DContainer definition",
);
// Common Texture-first shape must also keep the render definition.
const airfield = idx.assetsById.get("abairfield");
assert.ok(airfield?.some((d) => d.type === "Texture"), "Texture retained");
assert.ok(airfield?.some((d) => d.type === "W3DContainer"), "W3DContainer retained");
});
test("qualified Type:Id inheritFrom resolves against an instance-included SageXml definition", async () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ra3-qualified-ref-"));
try {
const projectDir = join(tmp, "project");
const sdkDir = join(tmp, "sdk");
fs.mkdirSync(join(projectDir, "Data"), { recursive: true });
fs.mkdirSync(join(sdkDir, "SageXml", "Sounds"), { recursive: true });
fs.writeFileSync(
join(projectDir, "Data", "Mod.xml"),
`<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Includes>
<Include type="all" source="Units.xml" />
</Includes>
</AssetDeclaration>`,
);
fs.writeFileSync(
join(projectDir, "Data", "Units.xml"),
`<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Includes>
<Include type="instance" source="DATA:SageXml/Sounds/BaseSoundEffect.xml" />
</Includes>
<AudioEvent id="ALL_FutureTank_ArmPrimaryWeapon" inheritFrom="AudioEvent:BaseSoundEffect" />
</AssetDeclaration>`,
);
fs.writeFileSync(
join(sdkDir, "SageXml", "Sounds", "BaseSoundEffect.xml"),
`<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<AudioEvent id="BaseSoundEffect" />
</AssetDeclaration>`,
);
const indexer = new ModIndexer({
projectDir,
sdkDir,
builtmodsDirs: [],
indexSageXml: false,
additionalDataSearchPaths: [],
walker: new CachedDirectoryWalker(),
});
const idx = await indexer.build();
assert.ok(
!idx.diagnostics.some((d) => d.code === "include-not-found"),
"the DATA:SageXml instance include resolves",
);
const defs = idx.assetsById.get("basesoundeffect");
assert.ok(
defs?.some((d) => d.type === "AudioEvent"),
"the SageXml definition is indexed through the instance include",
);
const targets = resolveReferenceTargetsForType(
idx,
"AudioEvent",
"inheritFrom",
"AudioEvent:BaseSoundEffect",
);
assert.equal(targets.length, 1);
assert.equal(targets[0].def.id, "BaseSoundEffect");
assert.equal(targets[0].def.type, "AudioEvent");
const sageDef = defs.find((d) => d.type === "AudioEvent");
const sites = idx.references.get(assetDefKey(sageDef));
assert.ok(
sites?.some((s) => /Units\.xml$/.test(s.file)),
"the qualified inheritFrom lands in the reverse index (FAR / CodeLens)",
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("build publishes an immutable XML phase before art scanning", async () => {
let phaseA;
const indexer = new ModIndexer({
@@ -315,6 +556,47 @@ test("trusted rebuilds skip unchanged files; invalidation forces re-reads", asyn
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 () => {
const idx = await buildIndex();
assert.equal(typeof idx.stats.candidatesMs, "number");
@@ -360,3 +642,43 @@ test("w3x with a UTF-8 BOM is indexed with correct offsets", async (t) => {
assert.ok(def, "BOM-prefixed w3x asset indexed");
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"));
});
+48
View File
@@ -99,6 +99,54 @@ test("logical xi:include expansion gives included modules their Draws context",
assert.ok(localIds.includes("ModuleTag_Headlight"));
});
test("xi:include without xpointer splices the target root element itself", async (t) => {
const tmp = await mkdtemp(join(tmpdir(), "ra3-local-noxpointer-"));
t.after(() => rm(tmp, { recursive: true, force: true }));
const dataDir = join(tmp, "Data");
const includesDir = join(dataDir, "Includes");
await mkdir(includesDir, { recursive: true });
const mainPath = join(dataDir, "Main.xml");
const fragmentPath = join(includesDir, "Fragment.xml");
await writeFile(
fragmentPath,
'<CreateObjectDie xmlns="uri:ea.com:eala:asset" id="ModuleTag_X" CreationList="OCL_X"><DieMuxData DeathTypes="SUICIDED"/></CreateObjectDie>',
"utf8",
);
await writeFile(
mainPath,
'<AssetDeclaration xmlns="uri:ea.com:eala:asset" xmlns:xi="http://www.w3.org/2001/XInclude">' +
'<GameObject id="G"><Behaviors><xi:include href="DATA:Includes/Fragment.xml"/></Behaviors></GameObject>' +
"</AssetDeclaration>",
"utf8",
);
const searchPaths = buildSearchPaths(sdk, tmp);
const text = await readFile(mainPath, "utf8");
const scope = await buildDocumentScope(mainPath, text, 1, {
projectDir: tmp,
sdkDir: sdk,
searchPaths,
readRecords: readParsed,
readDom: readParsed,
});
const behaviors = scope.expanded.elements.find((e) => e.name === "Behaviors");
assert.ok(behaviors, "Behaviors exists");
assert.equal(behaviors.children.length, 1);
const module = behaviors.children[0];
assert.equal(module.name, "CreateObjectDie");
assert.equal(
module.attrs.find((a) => a.name === "id")?.value,
"ModuleTag_X",
);
assert.match(module.sourceFile, /Fragment\.xml$/i);
const dieMux = scope.expanded.elements.find((e) => e.name === "DieMuxData");
assert.ok(dieMux, "DieMuxData is expanded");
assert.equal(
dieMux.parent,
module,
"DieMuxData stays inside the included CreateObjectDie module",
);
});
test("local overlay wins over a global definition with the same id", async () => {
const scope = await makeScope();
const global = {
+36
View File
@@ -0,0 +1,36 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
// Minimal vscode shim without l10n: exercises the fallback path that unit
// tests and extension hosts without a loaded bundle use.
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: {},
};
const { t, tN } = require("../out/localize.js");
test("t fallback substitutes positional placeholders", () => {
assert.equal(t("Hello {0}!", "World"), "Hello World!");
assert.equal(t("{0} and {1}", "A", 2), "A and 2");
assert.equal(t("No placeholders"), "No placeholders");
});
test("tN fallback substitutes named placeholders", () => {
assert.equal(tN("Hello {name}", { name: "Codex" }), "Hello Codex");
assert.equal(
tN("Keep {missing}", {}),
"Keep {missing}",
"unknown placeholders stay untouched",
);
});
+64
View File
@@ -0,0 +1,64 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
AGENT_FEATURE_VERSION,
compareVersions,
shouldOfferAgentOnboarding,
} from "../out/agent/onboarding.js";
test("compareVersions orders dotted numeric versions", () => {
assert.equal(compareVersions("0.1.26", "0.1.26"), 0);
assert.equal(compareVersions("0.1.26", "0.1.25"), 1);
assert.equal(compareVersions("0.1.25", "0.1.26"), -1);
assert.equal(compareVersions("0.1.26", "0.1"), 1);
assert.equal(compareVersions("0.2", "0.1.99"), 1);
assert.equal(compareVersions("1.0.0", "2.0.0"), -1);
assert.equal(compareVersions("0.1.26-beta", "0.1.26"), 0);
assert.equal(compareVersions("dev", "0.1.25"), -1);
});
test("a fresh install is offered the AI Agent introduction once", () => {
assert.equal(shouldOfferAgentOnboarding(undefined, "0.1.26"), true);
assert.equal(shouldOfferAgentOnboarding({}, "0.1.26"), true);
});
test("upgrading from before the feature informs once", () => {
assert.equal(
shouldOfferAgentOnboarding({ informedVersion: "0.1.25" }, "0.1.26"),
true,
);
// ...but not again on the same version or later ones.
assert.equal(
shouldOfferAgentOnboarding({ informedVersion: "0.1.26" }, "0.1.26"),
false,
);
assert.equal(
shouldOfferAgentOnboarding({ informedVersion: "0.1.26" }, "0.1.27"),
false,
);
});
test("upgrading from a build that already had the feature stays silent", () => {
// 0.1.26 introduced it; someone informed on 0.1.26 must not be re-prompted.
assert.equal(
shouldOfferAgentOnboarding({ informedVersion: "0.1.26" }, "0.1.30"),
false,
);
});
test("dismissed state suppresses the prompt forever", () => {
assert.equal(
shouldOfferAgentOnboarding(
{ informedVersion: "0.1.20", dismissed: true },
"0.1.26",
),
false,
);
assert.equal(
shouldOfferAgentOnboarding(
{ informedVersion: AGENT_FEATURE_VERSION, dismissed: true },
"0.1.30",
),
false,
);
});
+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));
});
+23
View File
@@ -113,3 +113,26 @@ test("extractIndexRecords records typed references and skips non-references", ()
false,
);
});
test("extractIndexRecords records simpleContent complex text as references", () => {
const text = `<AssetDeclaration>
<AudioEvent id="A">
<Sound Weight="100">VoiceFile</Sound>
</AudioEvent>
<Multisound id="M">
<Subsound Weight="50">VoiceEvent</Subsound>
</Multisound>
</AssetDeclaration>`;
const lineMap = new LineMap(text);
const records = extractIndexRecords(parseXml(text), lineMap, text);
const content = records.references.filter((r) => r.kind === "content");
const sound = content.find((r) => r.value === "VoiceFile");
assert.ok(sound, "Sound text is recorded as a content reference");
assert.equal(sound.refType, "AudioFile");
assert.equal(sound.selfType, null);
const subsound = content.find((r) => r.value === "VoiceEvent");
assert.ok(subsound, "Subsound text is recorded as a content reference");
assert.equal(subsound.refType, "BaseAudioEventInfo");
});
+96 -40
View File
@@ -2,7 +2,13 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
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 { ModIndexer } from "../out/indexer/indexer.js";
import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js";
@@ -151,48 +157,98 @@ test("records extracted from XML resolve through the reference index", () => {
assert.equal(csSites[0].kind, "attr");
});
test("qualified Type:Id reference records resolve to plain-id definitions", () => {
const def = makeDef("AudioEvent", "BaseSoundEffect", "C:/sdk/Sounds.xml", 2);
const lookup = {
assets: new Map(),
assetsById: new Map([["basesoundeffect", [def]]]),
};
const records = {
assets: [],
defines: [],
includes: [],
rootXiIncludes: [],
nestedXiIncludes: [],
references: [
{
kind: "attr",
refType: null,
selfType: "AudioEvent",
value: "AudioEvent:BaseSoundEffect",
line: 3,
start: 10,
end: 40,
},
],
};
const map = buildReferenceIndex(
[{ file: "C:/mod/AudioEvent.xml", records }],
lookup,
);
const sites = map.get(assetDefKey(def));
assert.equal(sites?.length, 1);
assert.equal(sites[0].file, "C:/mod/AudioEvent.xml");
assert.equal(sites[0].start, 10);
assert.equal(sites[0].end, 40);
});
test("referenceSitesForDefinition unions manifest-source sites onto the SageXml source file", () => {
const sourceFile = join(project, "Data", "Includes", "Units.xml");
const manifestDef = {
type: "GameObject",
id: "Tank",
file: join(sdk, "builtmods", "static.manifest"),
line: 0,
origin: "manifest",
manifestSource: "DATA:Includes/Units.xml",
};
const site = {
file: "C:/mod/ref.xml",
line: 3,
start: 10,
end: 14,
kind: "attr",
};
const idx = {
assets: new Map([["GameObject", new Map([["tank", [manifestDef]]])]]),
assetsById: new Map([["tank", [manifestDef]]]),
references: new Map([[assetDefKey(manifestDef), [site]]]),
projectDir: project,
sdkDir: sdk,
};
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 sites = referenceSitesForDefinition(idx, {
type: "GameObject",
id: "Tank",
file: sourceFile,
line: 4,
});
assert.equal(sites.length, 1);
assert.equal(sites[0].file, "C:/mod/ref.xml");
const manifestDef = {
type: "GameObject",
id: "Tank",
file: join(sdkDir, "builtmods", "static.manifest"),
line: 0,
origin: "manifest",
manifestSource: "DATA:Includes/Units.xml",
};
const site = {
file: "C:/mod/ref.xml",
line: 3,
start: 10,
end: 14,
kind: "attr",
};
const idx = {
assets: new Map([["GameObject", new Map([["tank", [manifestDef]]])]]),
assetsById: new Map([["tank", [manifestDef]]]),
references: new Map([[assetDefKey(manifestDef), [site]]]),
projectDir,
sdkDir,
};
// A different file does not inherit the manifest definition's sites.
const other = referenceSitesForDefinition(idx, {
type: "GameObject",
id: "Tank",
file: "C:/mod/elsewhere.xml",
line: 4,
});
assert.equal(other.length, 0);
const sites = referenceSitesForDefinition(idx, {
type: "GameObject",
id: "Tank",
file: sourceFile,
line: 4,
});
assert.equal(sites.length, 1);
assert.equal(sites[0].file, "C:/mod/ref.xml");
// 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, {
type: "GameObject",
id: "Tank",
file: shadowFile,
line: 4,
});
assert.equal(other.length, 0);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("the minimod indexer publishes a semantic reverse reference index", async () => {
+93 -6
View File
@@ -122,15 +122,25 @@ function makeScope() {
function makeWs(scope) {
const parse = parseXml(TEXT);
const lineMap = new LineMap(TEXT);
const indexer = {
readDom: async (path) =>
path === FILE
? { file: { path: FILE }, parse, lineMap, records: null }
: null,
};
return {
isRa3Workspace: () => true,
getScope: async () => scope,
indexer: {
readDom: async (path) =>
path === FILE
? { file: { path: FILE }, parse, lineMap, records: null }
: null,
},
indexer,
indexerForFile: () => indexer,
activeIndexer: () => indexer,
recordsSyncSurfaceFor: () => ({
get index() {
return scope.merged;
},
invalidate: () => {},
scheduleRebuild: () => {},
}),
};
}
@@ -167,3 +177,80 @@ test("FAR from the reference site itself returns the same result", async () => {
assert.equal(refs.length, 1);
assert.equal(refs[0].range.start.line, 4);
});
test("FAR includes simpleContent complex content references", async () => {
const text = `<AssetDeclaration>
<AudioFile id="VoiceFile"/>
<AudioEvent id="A">
<Sound>VoiceFile</Sound>
</AudioEvent>
</AssetDeclaration>`;
const parse = parseXml(text);
const lineMap = new LineMap(text);
const records = extractIndexRecords(parse, lineMap, text);
const def = {
type: "AudioFile",
id: "VoiceFile",
file: FILE,
line: 2,
origin: "project",
};
const lookup = {
assets: new Map([["AudioFile", new Map([["voicefile", [def]]])]]),
assetsById: new Map([["voicefile", [def]]]),
};
const references = buildReferenceIndex([{ file: FILE, records }], lookup);
const idx = {
...lookup,
references,
complete: true,
phase: "art",
projectDir: "C:/mod",
sdkDir: "C:/sdk",
defines: new Map(),
files: new Map(),
streams: [],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
stats: {},
};
const scope = { merged: idx };
const localParse = parseXml(text);
const localLineMap = new LineMap(text);
const localIndexer = {
readDom: async (path) =>
path === FILE
? { file: { path: FILE }, parse: localParse, lineMap: localLineMap, records: null }
: null,
};
const localWs = {
isRa3Workspace: () => true,
getScope: async () => scope,
indexer: localIndexer,
indexerForFile: () => localIndexer,
activeIndexer: () => localIndexer,
recordsSyncSurfaceFor: () => ({
get index() {
return scope.merged;
},
invalidate: () => {},
scheduleRebuild: () => {},
}),
};
const provider = new Ra3ReferenceProvider(localWs);
const document = makeDocument(text);
const defLine = text.split("\n")[1];
const defPos = new Position(1, defLine.indexOf('id="') + 4);
const refs = await provider.provideReferences(document, defPos, {
includeDeclaration: true,
}, {});
assert.ok(refs, "references are returned");
assert.equal(refs.length, 1);
assert.equal(refs[0].range.start.line, 3);
assert.equal(
text.split("\n")[3].slice(refs[0].range.start.character, refs[0].range.end.character),
"VoiceFile",
);
});
+184
View File
@@ -9,6 +9,8 @@ import {
isReferenceAttribute,
isReferenceAttributeOfType,
isReferenceContentType,
isReferenceTargetType,
normalizeReferenceId,
resolveContentReferenceTargets,
resolveReferenceTargets,
resolveReferenceTargetsForType,
@@ -70,7 +72,25 @@ test("isReferenceAttribute distinguishes references from enums/paths", () => {
// Typed references and inheritFrom are references.
assert.equal(isReferenceAttribute("GameObject", "CommandSet"), true);
assert.equal(isReferenceAttribute("GameObject", "inheritFrom"), true);
assert.equal(isReferenceAttribute("FXList", "inheritFrom"), true);
assert.equal(isReferenceAttribute("AIMicroManagerData", "inheritFrom"), true);
assert.equal(isReferenceAttribute("FireWeaponNugget", "WeaponName"), true);
// inheritFrom is an asset-level attribute; non-asset elements stay non-refs.
assert.equal(isReferenceAttribute("Include", "inheritFrom"), false);
});
test("universal inheritFrom legality is separate from CodeLens target design", () => {
// FXList and other BaseAssetType descendants legally accept inheritFrom,
// but the XSD does not declare it there. That must not widen the designed
// reference-target set (Credits is still not a CodeLens target).
assert.ok(
model.attributesOfElement("FXList").some((a) => a.name === "inheritFrom"),
);
assert.ok(
model.attributesOfElement("Credits").some((a) => a.name === "inheritFrom"),
);
assert.equal(isReferenceTargetType("Credits"), false);
assert.equal(isReferenceTargetType("FXList"), true); // via FXListRef, not universal attr
});
test("attribute-level xas:refType is preserved in the model", () => {
@@ -287,6 +307,170 @@ test("typed simple content resolves like a typed attribute reference", () => {
assert.equal(targets[0].def.type, "GameObject");
});
test("simpleContent complex types resolve as typed content references", () => {
// <Sound>AudioFile</Sound> / <Subsound>VoiceEvent</Subsound> use
// simpleContent complex types (AudioFileRefWithWeight /
// MultisoundSubsoundRef) whose text is still a typed asset reference.
assert.equal(isReferenceContentType("AudioFileRefWithWeight"), true);
assert.equal(isReferenceContentType("MultisoundSubsoundRef"), true);
// Inline Frame's simpleContent is a scalar float, not a reference.
assert.equal(isReferenceContentType("@inline:Frame"), false);
const idx = {
assetsById: new Map([
[
"shared",
[
{ type: "AudioFile", id: "Shared", file: "Audio.xml", line: 1, origin: "project" },
{ type: "AudioEvent", id: "Shared", file: "Voice.xml", line: 2, origin: "project" },
],
],
]),
assets: new Map(),
defines: new Map(),
};
const soundTargets = resolveContentReferenceTargets(
idx,
"AudioFileRefWithWeight",
"Shared",
);
assert.equal(soundTargets.length, 1);
assert.equal(soundTargets[0].def.type, "AudioFile");
const subsoundTargets = resolveContentReferenceTargets(
idx,
"MultisoundSubsoundRef",
"Shared",
);
assert.equal(subsoundTargets.length, 1);
assert.equal(subsoundTargets[0].def.type, "AudioEvent");
});
test("normalizeReferenceId strips a manifest-style Type: prefix", () => {
assert.equal(normalizeReferenceId("BaseSoundEffect"), "BaseSoundEffect");
assert.equal(
normalizeReferenceId("AudioEvent:BaseSoundEffect"),
"BaseSoundEffect",
);
// Art-asset manifest names can carry a subtype segment; the referenceable
// id is still the last colon segment.
assert.equal(
normalizeReferenceId("W3dContainer:W3DContainer:ABC_SKN"),
"ABC_SKN",
);
// A trailing colon has no id yet; keep the raw value so a half-typed
// qualified value never matches anything.
assert.equal(normalizeReferenceId("AudioEvent:"), "AudioEvent:");
});
test("qualified Type:Id inheritFrom values resolve to plain-id definitions", () => {
const def = {
type: "AudioEvent",
id: "BaseSoundEffect",
file: "Sounds.xml",
line: 1,
origin: "sdk",
};
const idx = {
assetsById: new Map([["basesoundeffect", [def]]]),
assets: new Map(),
defines: new Map(),
};
// The reported scenario: <AudioEvent inheritFrom="AudioEvent:BaseSoundEffect"/>.
const qualified = resolveReferenceTargetsForType(
idx,
"AudioEvent",
"inheritFrom",
"AudioEvent:BaseSoundEffect",
);
assert.equal(qualified.length, 1);
assert.equal(qualified[0].def.id, "BaseSoundEffect");
// Plain ids keep working unchanged.
assert.equal(
resolveReferenceTargetsForType(idx, "AudioEvent", "inheritFrom", "BaseSoundEffect")
.length,
1,
);
// A wrong type prefix is still filtered by selfType: the AudioEvent def
// must never satisfy a GameObject inheritFrom.
assert.equal(
resolveReferenceTargetsForType(
idx,
"GameObject",
"inheritFrom",
"GameObject:BaseSoundEffect",
).length,
0,
);
});
test("qualified Type:Id values resolve for typed attributes and content refs", () => {
const audioEvent = {
type: "AudioEvent",
id: "JAP_Refinery_Select",
file: "SoundEffects.xml",
line: 1,
origin: "sdk",
};
const playerTemplate = {
type: "PlayerTemplate",
id: "Allies",
file: "PlayerTemplates.xml",
line: 1,
origin: "manifest",
};
const audioFile = {
type: "AudioFile",
id: "Shared",
file: "Audio.xml",
line: 1,
origin: "project",
};
const idx = {
assetsById: new Map([
["jap_refinery_select", [audioEvent]],
["allies", [playerTemplate]],
["shared", [audioFile]],
]),
assets: new Map(),
defines: new Map(),
};
// SoundOrEvaEvent@Sound refType is BaseAudioEventInfo; the concrete
// "AudioEvent:" prefix must survive normalization and the type filter.
const soundTargets = resolveReferenceTargetsForType(
idx,
"SoundOrEvaEvent",
"Sound",
"AudioEvent:JAP_Refinery_Select",
);
assert.equal(soundTargets.length, 1);
assert.equal(soundTargets[0].def.type, "AudioEvent");
// Side="PlayerTemplate:Allies" style attribute.
const sideTargets = resolveReferenceTargetsForType(
idx,
"SideSound",
"Side",
"PlayerTemplate:Allies",
);
assert.equal(sideTargets.length, 1);
assert.equal(sideTargets[0].def.type, "PlayerTemplate");
// Simple-content references use the same convention.
const contentTargets = resolveContentReferenceTargets(
idx,
"AudioFileRefWithWeight",
"AudioFile:Shared",
);
assert.equal(contentTargets.length, 1);
assert.equal(contentTargets[0].def.type, "AudioFile");
});
test("untyped and pipeline-local content is not a global reference", () => {
// Generic AssetReference content is used for shader constants and model
// sub-object names, not global asset ids; Poid is pipeline-local.
+64
View File
@@ -17,6 +17,70 @@ test("GameObject has expected attributes", () => {
assert.ok(attrs.some((a) => a.name === "inheritFrom"));
});
test("inheritFrom is a universal asset attribute, not only BaseInheritableAsset", () => {
// BAB / vanilla data accepts inheritFrom on FXList, AIMicroManagerData,
// ObjectCreationList, OnDemandTextureImage and AITargetingHeuristic even
// though the XSD only declares it on BaseInheritableAsset.
for (const name of [
"FXList",
"AIMicroManagerData",
"ObjectCreationList",
"OnDemandTextureImage",
"AITargetingHeuristic",
]) {
assert.ok(
model.attributesOfElement(name).some((a) => a.name === "inheritFrom"),
`${name} should accept universal inheritFrom`,
);
}
// Structural elements that are not assets must not get the attribute.
assert.ok(
!model.attributesOfElement("Include").some((a) => a.name === "inheritFrom"),
"Include is not an asset and must not accept inheritFrom",
);
});
test("simpleContent extension attributes are preserved by the model generator", () => {
const sound = model.typeInfo("AudioFileRefWithWeight");
assert.equal(sound?.kind, "complex");
assert.ok(sound.attributes.some((a) => a.name === "Weight"));
assert.ok(sound.attributes.some((a) => a.name === "Volume"));
const subsound = model.typeInfo("MultisoundSubsoundRef");
assert.equal(subsound?.kind, "complex");
assert.ok(subsound.attributes.some((a) => a.name === "Weight"));
assert.ok(subsound.attributes.some((a) => a.name === "PitchShiftLow"));
assert.ok(subsound.attributes.some((a) => a.name === "PitchShiftHigh"));
assert.ok(subsound.attributes.some((a) => a.name === "Volume"));
assert.ok(subsound.attributes.some((a) => a.name === "PlayPercent"));
assert.ok(subsound.attributes.some((a) => a.name === "VolumeShift"));
});
test("contentInfoOfType unifies simple and simpleContent content semantics", () => {
const simple = model.contentInfoOfType("GameObjectWeakRef");
assert.equal(simple?.kind, "simple");
assert.equal(simple?.refType, "GameObject");
const sound = model.contentInfoOfType("AudioFileRefWithWeight");
assert.equal(sound?.kind, "simpleContent");
assert.equal(sound?.refType, "AudioFile");
assert.equal(sound?.isRef, true);
const subsound = model.contentInfoOfType("MultisoundSubsoundRef");
assert.equal(subsound?.kind, "simpleContent");
assert.equal(subsound?.refType, "BaseAudioEventInfo");
// Ordinary complex elements and structural elements have no content value.
assert.equal(model.contentInfoOfType("GameObject"), null);
assert.equal(model.contentInfoOfType("Include"), null);
// Inline simpleContent with a scalar base has content info but no refType.
const frame = model.contentInfoOfType("@inline:Frame");
assert.ok(frame, "inline simpleContent is exposed through contentInfoOfType");
assert.equal(frame?.refType, null);
assert.equal(frame?.base, "float");
});
test("attribute-level xas:refType is captured (module ids, map objects)", () => {
// ModuleData@id is declared as <xs:attribute name="id" type="Poid"
// xas:refType="ModuleData" />; the refType must reach every module subtype.
+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,
);
});
+21
View File
@@ -39,3 +39,24 @@ test("model childTypeOf primitives", () => {
assert.equal(model.childTypeOf("WeaponSlot_WeaponData", "Weapon"), null);
assert.equal(model.childTypeOf(null, "Weapon"), null);
});
test("fragment roots prefer top-level AssetDeclaration types over name collisions", () => {
// <EvaEvent> is both a top-level asset and an FXNugget child. A fragment
// root has no parent context, so it must resolve to the top-level asset
// type (with Priority / TimeBetweenEvents etc.), not to EvaEventFXNugget.
const evaDoc = parseXml(
`<EvaEvent id="IncomingTransmission" Priority="100" TimeBetweenEvents="0ms" ExpirationTime="10000ms"/>`,
);
assert.equal(resolveElementType(evaDoc.root), "EvaEvent");
assert.ok(
model.attributesOfType("EvaEvent").some((a) => a.name === "Priority"),
);
const upgradeDoc = parseXml(`<UpgradeTemplate id="Upgrade_X" inheritFrom="Base"/>`);
assert.equal(resolveElementType(upgradeDoc.root), "UpgradeTemplate");
// Non-top-level fragment roots still fall back to the contextual child
// mapping (no AssetDeclaration child exists for Weapon).
const weaponDoc = parseXml(`<Weapon Ordering="PRIMARY_WEAPON"/>`);
assert.equal(resolveElementType(weaponDoc.root), "WeaponRef");
});
+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();
});

Some files were not shown because too many files have changed in this diff Show More