支持 W3X

This commit is contained in:
2026-08-02 14:32:18 +02:00
parent 45fa2b9a27
commit 4cc07b9abc
22 changed files with 1052 additions and 79 deletions
+1
View File
@@ -3,3 +3,4 @@ out/
dist/ dist/
*.vsix *.vsix
OpenSAGE/ OpenSAGE/
debug.log
+7
View File
@@ -18,6 +18,11 @@
- **引用导航**:从引用值(`CommandSet="..."``Weapon="..."``inheritFrom`)跳转到定义(严格按引用类型过滤,候选由 `ra3modxml.definitionMode` 控制:`all` 列出 mod + 原版、`project-only` 优先项目内定义);`Ctrl+点击` Include / `xi:include href` 打开目标文件;Find All References 搜索整个工作区;文档大纲列出顶层资产与 `$DEFINE` - **引用导航**:从引用值(`CommandSet="..."``Weapon="..."``inheritFrom`)跳转到定义(严格按引用类型过滤,候选由 `ra3modxml.definitionMode` 控制:`all` 列出 mod + 原版、`project-only` 优先项目内定义);`Ctrl+点击` Include / `xi:include href` 打开目标文件;Find All References 搜索整个工作区;文档大纲列出顶层资产与 `$DEFINE`
- **错误检查**:XML 格式错误、未知元素/属性(`xi:` 等外来命名空间不误报)、顶层资产缺 `id`、重复 ID、未解析引用(含类型不匹配)、Include / 嵌套 `xi:include` 目标找不到、`$DEFINE` 未定义。 - **错误检查**:XML 格式错误、未知元素/属性(`xi:` 等外来命名空间不误报)、顶层资产缺 `id`、重复 ID、未解析引用(含类型不匹配)、Include / 嵌套 `xi:include` 目标找不到、`$DEFINE` 未定义。
- **manifest 支持**`<Include type="reference">` 指向的 `static/global/audio.manifest`SDK `builtmods`)会被解析,manifest 中的原版资产 ID 可用于补全/悬停/导航/诊断。 - **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 级缓存并跨重建复用,保存文件触发的重建不会重读未变化的模型文件。
## 使用 ## 使用
@@ -72,6 +77,8 @@ src/
manifestParser.ts .manifest 二进制解析(移植 OpenSAGE ManifestFile.cs manifestParser.ts .manifest 二进制解析(移植 OpenSAGE ManifestFile.cs
fileScanner.ts 目录扫描与 Include source 候选 fileScanner.ts 目录扫描与 Include source 候选
refs.ts 引用目标解析(按引用类型过滤) refs.ts 引用目标解析(按引用类型过滤)
shallowScan.ts .w3x 等大体积美术资产顶层浅扫描(纯 TS,不建 DOM)
caches.ts 跨重建持久缓存(DocumentCache / ShallowScanCache
indexer.ts 工作区索引器(后台、缓存、增量重建) indexer.ts 工作区索引器(后台、缓存、增量重建)
features/ completion / hover / navigation / diagnostics / semanticTokens features/ completion / hover / navigation / diagnostics / semanticTokens
syntaxes/ TextMate 注入语法 syntaxes/ TextMate 注入语法
-1
View File
@@ -1 +0,0 @@
[0801/032923.150:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 拒绝访问。 (0x5)
+95
View File
@@ -590,3 +590,98 @@ token:文档出现解析错误时,由插件用自己的容错解析树继续
配色,可能与 TextMate XML 配色略有差异——只在打字过程中出现,可接受; 配色,可能与 TextMate XML 配色略有差异——只在打字过程中出现,可接受;
- 未实现 `provideDocumentSemanticTokensEdits`(delta 版本),每次全量计算;单文件 - 未实现 `provideDocumentSemanticTokensEdits`(delta 版本),每次全量计算;单文件
解析在 KB 级,开销可忽略。 解析在 KB 级,开销可忽略。
---
## 十三、问题分析(第八轮,2026-08-02):`.w3x` 美术资产未被索引(AUGunship_SKN
### 问题
AttachTest `Harbinger Gunship\GameObject.xml` 中 `<Model Name="AUGunship_SKN"/>`
报两条错误:
1. Problems 面板诊断:`Unresolved reference "AUGunship_SKN" (not found in the current index)`
`unresolved-reference`,来自 `features/diagnostics.ts`);
2. 悬停提示:`No matching definition of type BaseRenderAssetType in the current index
(may exist in a compiled manifest or vanilla data).`(来自 `features/hover.ts`)。
两条是同一缺失定义的两种呈现(诊断 + hover),不是两个独立 bug。
`W3DContainer:AUGunship_SKN` 确实由 mod 定义——但定义在
`Harbinger Gunship\W3X\AUGUNSHIP_SKN.w3x` 里,而索引器只解析 `.xml` / `.manifestxml`。
### 关键事实(实测)
1. **`.w3x` 是文本 XML**:文件头即 `<?xml ...?>` + `<AssetDeclaration>`,内容为
`<W3DContainer id="AUGUNSHIP_SKN" Hierarchy="AUGUNSHIP_SKL">` + `<SubObject>` 子树。
附带 UTF-8 BOM(抽样 292 个 Corona w3x0 个 UTF-1640 个带 BOM)。
2. **w3x 通过 `<Include type="all">` 链进索引**`Mod.xml → … → W3X.xml → W3X/*.w3x`
也常用 `ART:xxx.w3x`SDK 根、项目 `Art` 等搜索路径)。索引器此前只把 w3x 登记为
文件(`stream.files`),从不解析,因此其顶层资产不在 `assetsById` 中。
3. **类型匹配本来是对的**`Model@Name` 的 refType 是 `BaseRenderAssetType`
`W3DContainer → BaseRenderAssetType` 可赋值(`isAssignableTo` = true)。缺的只是定义。
4. **manifest / SageXml 支持早已存在**(第二轮/第三轮),但该资产是 mod 自己的 w3x,
不在 `builtmods/*.manifest` 也不在 SageXml——hover 的 "may exist in a compiled manifest
or vanilla data" 只是通用兜底文案。
### 规模调查(CoronaD: 盘已连接)
- **3788 个 w3x,共 2.64 GB**;最大 22.8 MB163 个超过原 4 MB 解析上限,11 个超 10 MB。
- 大文件结构符合"建模软件导出"模式:顶层是少量固定 W3D 资产
`W3DHierarchy` + 若干 `W3DMesh` / `W3DContainer` / `W3DCollisionBox`
有的还带 `<Includes>` 引用 `ART:*.xml`);体积大头是 `W3DMesh` 内的
`Vertices/V`、`Normals/N`、`TexCoords/T`、`Triangles/T` 等 `maxOccurs="unbounded"`
数值元素。例如 21.7 MB 的 `CBRefinery_BLD.W3X` 只有 22 个顶层记录,
Vertices+Triangles 块占约 12 MB55%)。
- 全量建 DOM 的代价(实测 6.3 MB Aegis 文件):407 ms、193,651 个元素、
**堆内存 +109 MB(约 17 倍文本体积)**22 MB 文件外推约 1.5 s + ~380 MB/份。
浅扫描(只取顶层记录):6.3 MB ~200 ms、22 MB ~600 ms,保留内存近似为零。
- **读整个文件不可避免,但建树不是**:浅扫描仍然是线性扫描全文(要知道顶层元素边界
就必须扫完),只是不分配子节点对象——所以"事后优化"完全有意义,且是必要项。
### 修复
1. **新增浅扫描模块** `src/indexer/shallowScan.ts`(纯 TS):
单次线性扫描,只提取顶层元素 `name + id`(含精确 offset)、顶层 `<Includes>`
的 `Include@type/source`、任意层级 `<xi:include>` 的 `href/xpointer`、`<Defines>`
常量;注释 / CDATA / DOCTYPE / PI 整体跳过;属性解析兼容引号内 `>` 与 `/`。
2. **索引模式三分**`.xml`/`.manifestxml` 全量解析(4 MB 上限不变);
`.w3x` 一律浅扫描;未知扩展名先嗅探文件头(512 字节,BOM/空白后以 `<` 开头且无
NUL 字节 → 按 XML 浅扫描,否则按二进制仅登记)。w3x 自身的 `<Includes>` 与
嵌套 `xi:include` 会继续被 walk(BAB 语义)。
3. **持久缓存**`DocumentCache` / `ShallowScanCache` 移到 `src/indexer/caches.ts`
由 `ModWorkspace` 持有并传入每次新建的 `ModIndexer`;读取时按 `mtimeMs + size`
校验,未变化不重读。这是 w3x 方案在 Corona 上可用的前提(否则每次保存都重读 2.6 GB)。
4. **BOM 剥离**`xmlParser` 新增 `stripBom()`,全量解析与浅扫描前统一剥离,
保证第一行偏移与编辑器一致。
5. **Include source 补全候选**:DATA 目录与项目相对路径候选加入 `.w3x`
`fileScanner`),`W3X/xxx.w3x`、`ART/xxx.w3x` 可补全。
6. 索引报告/状态栏新增 `shallowScannedFiles` / `shallowCacheHits` 统计。
### 验证
- 单元测试 **53 → 63 全绿**:新增 `test/shallowScan.test.mjs`(顶层资产 / Includes /
xi:include / Defines / 引号内 `>` / CDATA / 未闭合标签 / 数值载荷不产生记录);
indexer 新增 w3x 链、`.w3d` 嗅探、二进制 `.dds` 跳过、跨重建缓存命中、
BOM 偏移断言;xmlParser 新增 `stripBom` 用例。
- AttachTest 实机:
- 首次构建 1.2 s,浅扫 62 个文件;`Model@Name=AUGunship_SKN` →
`W3DContainer @ …\W3X\AUGUNSHIP_SKN.w3x:3``Hierarchy=AUGunship_SKL`、
`AUGunship_FP` 同样解析;资产数 35,546 → 35,607。
- 第二次构建 354 ms,0 次重扫,62 次缓存命中。
- Corona 实机(D: 盘):
- 首次构建 241 s8,976 个文件、**浅扫 4,829 个**、资产 64,868
manifest 35,322)、3 个流、183 个 Define。
- 第二次构建 38 s:0 次重扫、4,829 次缓存命中、资产数一致。
- `CBREFINERY_BLD` 正确解析到 `W3DHierarchy @ cb/CBRefinery_BLD.w3x:16` 与
`W3DContainer @ …:776412`。
### 边界与后续
- 首次建索引较慢(Corona ~4 分钟,机械盘):读 2.6 GB 是下限,浅扫描本身 ~30-90 s;
后续可做并行扫描或把 w3x 顶层记录做成独立小缓存文件。
- 第二次构建仍有 ~38 s(主要是对 ~9k 文件逐文件 stat + 重建 Map,机械盘随机读);
后续可做"文件清单 + stat 快照"级缓存。
- 浅扫描对 w3x 不做 XSD 校验(编辑器特性不注册 w3x 语言);若用户手动把 `*.w3x`
关联为 xml,完整解析仍会发生在打开的文档上(VS Code 自身行为)。
- UTF-16 XML 的 w3x 会被嗅探判定为二进制(文件头含 NUL);实测生态中不存在,
如遇可再扩展解码。
+31 -7
View File
@@ -85,7 +85,9 @@ src/
manifestParser.ts .manifest 二进制解析 + 类型/ID 推导(纯 TS) manifestParser.ts .manifest 二进制解析 + 类型/ID 推导(纯 TS)
fileScanner.ts 目录遍历缓存 + Include source 候选收集 fileScanner.ts 目录遍历缓存 + Include source 候选收集
refs.ts 引用目标解析(按 refType / isRef / inheritFrom 过滤,纯 TS refs.ts 引用目标解析(按 refType / isRef / inheritFrom 过滤,纯 TS
indexer.ts 工作区索引器(资产/Define/流/manifest 合并,LRU 解析缓存 shallowScan.ts 大体积美术资产(.w3x 等)顶层浅扫描(纯 TS,不建 DOM
caches.ts 跨重建持久缓存(DocumentCache / ShallowScanCache
indexer.ts 工作区索引器(资产/Define/流/manifest/w3x 合并)
types.ts 共享类型 types.ts 共享类型
features/ features/
completion.ts 补全 provider(元素/属性/值,上下文感知;xs:list 多值按当前段过滤) completion.ts 补全 provider(元素/属性/值,上下文感知;xs:list 多值按当前段过滤)
@@ -109,6 +111,11 @@ test/
1. **语言激活范围**:不劫持 `*.xml`。通过 `workspaceContains:**/Data/Mod.xml``**/*.babproj` 激活;语法高亮为**纯注入** grammar(不声明 `language`,避免覆盖内置 XML 语法)。 1. **语言激活范围**:不劫持 `*.xml`。通过 `workspaceContains:**/Data/Mod.xml``**/*.babproj` 激活;语法高亮为**纯注入** grammar(不声明 `language`,避免覆盖内置 XML 语法)。
2. **索引范围与默认值**:索引“项目 Data + additionalmaps + 沿 include 可达的 SageXml 原版源码”;SDK 路径默认 `C:\Apps\RA3-MODSDK-X`(可配置)。`reference` include 解析为 `builtmods` 下对应 manifest(惰性解析、按文件缓存),manifest 缺失/无效时回退到占位 XML。 2. **索引范围与默认值**:索引“项目 Data + additionalmaps + 沿 include 可达的 SageXml 原版源码”;SDK 路径默认 `C:\Apps\RA3-MODSDK-X`(可配置)。`reference` include 解析为 `builtmods` 下对应 manifest(惰性解析、按文件缓存),manifest 缺失/无效时回退到占位 XML。
**美术资产(.w3x**`<Include type="all">` / `ART:` 指向的 `.w3x`(及内容嗅探为
XML 的未知扩展名文件)按其顶层资产入库(`W3DContainer` / `W3DMesh` /
`W3DHierarchy` / `W3DCollisionBox` 等),使 `Model@Name``Hierarchy``Mesh`
等引用可解析;大模型文件**浅扫描**(不建 DOM),结果缓存在 workspace 级、
跨重建复用(详见设计决策 14)。
3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer``W3DContainer`),类型匹配严格遵循 XSD 继承链。 3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer``W3DContainer`),类型匹配严格遵循 XSD 继承链。
4. **上下文感知元素类型**:同名元素按父元素类型解析(`resolveElementType` 沿解析树逐层 `childTypeOf`,失败回退全局映射),保证 `<Weapon>` 等元素的属性/引用判定正确。 4. **上下文感知元素类型**:同名元素按父元素类型解析(`resolveElementType` 沿解析树逐层 `childTypeOf`,失败回退全局映射),保证 `<Weapon>` 等元素的属性/引用判定正确。
5. **引用判定与解析**`refType``isRef` 均视为引用;带 `refType` 时严格按类型过滤(同名 ID 不串类型);`inheritFrom` 按可继承类型过滤。**局部作用域例外**(`isLocalReferenceAttribute`):`id` 是元素自身的定义点——无 refType 或 refType 与自身类型兼容时不检查、不解析(`RoadObject@id→Road` 这类跨类型 id 引用保留检查);Poid 类型属性是管线局部引用,全局索引无法判定,不检查、不解析。 5. **引用判定与解析**`refType``isRef` 均视为引用;带 `refType` 时严格按类型过滤(同名 ID 不串类型);`inheritFrom` 按可继承类型过滤。**局部作用域例外**(`isLocalReferenceAttribute`):`id` 是元素自身的定义点——无 refType 或 refType 与自身类型兼容时不检查、不解析(`RoadObject@id→Road` 这类跨类型 id 引用保留检查);Poid 类型属性是管线局部引用,全局索引无法判定,不检查、不解析。
@@ -128,6 +135,20 @@ test/
XML 编辑器皆然);扩展注册 `DocumentSemanticTokensProvider`,仅当解析报错时用 XML 编辑器皆然);扩展注册 `DocumentSemanticTokensProvider`,仅当解析报错时用
语义 token 覆盖标签名 / 属性名 / 属性值(标准 token 类型 `type` / `property` / 语义 token 覆盖标签名 / 属性名 / 属性值(标准 token 类型 `type` / `property` /
`string`,主题自带配色)。合法文件返回空,观感与纯 TextMate 完全一致。 `string`,主题自带配色)。合法文件返回空,观感与纯 TextMate 完全一致。
14. **大体积美术资产浅扫描 + 跨重建持久缓存**(第八轮,2026-08-02):
- 实测 Corona3788 个 w3x / 2.64 GB163 个超过 4 MB,最大 22.8 MB;大文件是
`W3DHierarchy` + 若干 `W3DMesh``Vertices/V``Triangles/T` 等 unbounded 数值
载荷),顶层记录通常只有几个到二十几个。
- 全量解析内存放大约 17 倍(6.3 MB 文本 → +109 MB DOM),不可接受;`scanXmlShallow`
单次线性扫描只提取顶层 `name+id``<Includes>``<xi:include>``<Defines>`
22 MB 文件 ~600 ms、保留内存≈0。
- 索引按扩展名三分:`.xml`/`.manifestxml` 全量解析(4 MB 上限不变);`.w3x` 浅扫描;
未知扩展名嗅探文件头(`<` 开头、无 NUL)决定按 XML 浅扫描或二进制登记。
- `DocumentCache` / `ShallowScanCache``ModWorkspace` 持有,每次重建传入新的
`ModIndexer`;按 `mtimeMs + size` 校验,未变化不重读。Corona 第二次构建
w3x 重扫数为 0(4,829 次缓存命中)。
- 读取整个文件不可避免(顶层边界需要全量扫描),但建 DOM 不是;优化的是
"不分配子节点对象"与"跨重建不重读",两者叠加后方案可行。
## 三、实施步骤 ## 三、实施步骤
@@ -136,22 +157,25 @@ test/
3. [x] 生成模型:`schema-model.json`XSD295 顶层元素 / 1851 类型)与 `asset-types.json`79 个 AssetType 哈希)。 3. [x] 生成模型:`schema-model.json`XSD295 顶层元素 / 1851 类型)与 `asset-types.json`79 个 AssetType 哈希)。
4. [x] 纯 TS 核心:include 解析、manifest 解析、XML 解析封装、索引器、引用解析。 4. [x] 纯 TS 核心:include 解析、manifest 解析、XML 解析封装、索引器、引用解析。
5. [x] 功能层:补全、hover、导航、诊断、大纲、高亮 grammar。 5. [x] 功能层:补全、hover、导航、诊断、大纲、高亮 grammar。
6. [x] 单测(fixture Mod53 个用例全绿:含 xs:list 枚举、未闭合引号恢复、list 多值 6. [x] 单测(fixture Mod63 个用例全绿:含 xs:list 枚举、未闭合引号恢复、list 多值
分段、带 vscode stub 的补全集成、语义 token 兜底)+ 编译 + `vsce package` 打包 分段、带 vscode stub 的补全集成、语义 token 兜底)+ 编译 + `vsce package` 打包
ra3-mod-xml-0.1.0.vsix,约 495KB)。 ra3-mod-xml-0.1.0.vsix,约 495KB)。
7. [x] 在 AttachTest / GenEvoTest / Corona 上做冒烟验证,并按真实项目反馈修复问题(详见 `docs/analysis-issues.md` 轮分析)。 7. [x] 在 AttachTest / GenEvoTest / Corona 上做冒烟验证,并按真实项目反馈修复问题(详见 `docs/analysis-issues.md` 轮分析)。
8. [x] w3x 美术资产索引(第八轮):新增 `shallowScan.ts` / `caches.ts`w3x 与内容嗅探
XML 走浅扫描,Include source 补全候选加入 w3x,BOM 剥离,缓存跨重建持久化;
AttachTest 报错场景(`AUGunship_SKN`)修复,Corona 首次/二次构建实测验证。
## 四、验证结果(实测) ## 四、验证结果(实测)
| 项目 | 规模 | 索引耗时 | 资产数 | 说明 | | 项目 | 规模 | 索引耗时 | 资产数 | 说明 |
|---|---|---|---|---| |---|---|---|---|---|
| AttachTest | 71 文件 | ~0.6s | 35,546manifest 35,322 | 2 个流(static + mapmetadata | | AttachTest | 88 文件 | ~1.2s | 35,607manifest 35,322w3x 浅扫 62 | 2 个流(static + mapmetadata;二次构建 354ms / 62 缓存命中 |
| GenEvoTest | 24 文件 | ~1.8s | 35,392manifest 35,322 | 项目 IDalliedmcv 等)正确收录 | | GenEvoTest | 66 文件 | 首次 ~2.6s / 二次 ~0.4s | 35,502manifest 35,322w3x 浅扫 38 | 2 个流、73 个 Define;二次构建 0 重扫 / 38 缓存命中 |
| Corona | 3,448 文件(+ 非 XML 资产路径) | ~54.5s | 55,305manifest 35,322 | 3 个流、183 个 Define、0 诊断 | | Corona | 8,976 文件 | 首次 ~241s / 二次 ~38s | 64,868manifest 35,322w3x 浅扫 4,829 | 3 个流、183 个 Define、0 诊断;二次构建 0 重扫 / 4,829 缓存命中 |
单元测试覆盖:XML 解析(自闭合/容错/偏移/未闭合引号行尾恢复)、补全上下文(未闭合引号仍为 attribute-value、list 多值分段)、补全集成(vscode stub 下 `LocomotorTemplate@Surfaces` 未闭合引号枚举补全、空格后第二段过滤与替换范围)、语义 token(标签/属性/值范围、合法文档返回空、malformed 返回兜底 token)、include 解析(BAB 顺序、SDK 根优先于 SageXml)、manifest 二进制解析(合成 v5 样本、类型/ID 推导)、索引器(资产/Define/流/缺失 include/嵌套 xi:include)、XSD 模型(上下文类型、`childTypeOf`、大小写规范化、属性级 refType、外来命名空间判定、`xs:list` 枚举继承与 `isList` 标记)、引用过滤(`Weapon="X"` 只跳 `WeaponTemplate`、模块 `id` 定义点、Poid 局部引用、`xi:include` 不校验、`Side="Allies"` 命中 manifest 的 `PlayerTemplate`)。 单元测试覆盖:XML 解析(自闭合/容错/偏移/未闭合引号行尾恢复)、补全上下文(未闭合引号仍为 attribute-value、list 多值分段)、补全集成(vscode stub 下 `LocomotorTemplate@Surfaces` 未闭合引号枚举补全、空格后第二段过滤与替换范围)、语义 token(标签/属性/值范围、合法文档返回空、malformed 返回兜底 token)、include 解析(BAB 顺序、SDK 根优先于 SageXml)、manifest 二进制解析(合成 v5 样本、类型/ID 推导)、索引器(资产/Define/流/缺失 include/嵌套 xi:include)、XSD 模型(上下文类型、`childTypeOf`、大小写规范化、属性级 refType、外来命名空间判定、`xs:list` 枚举继承与 `isList` 标记)、引用过滤(`Weapon="X"` 只跳 `WeaponTemplate`、模块 `id` 定义点、Poid 局部引用、`xi:include` 不校验、`Side="Allies"` 命中 manifest 的 `PlayerTemplate`)。
> 注:`D:\Mods\CoronaMod` 位于移动硬盘,当前未连接;GenEvoTest / Corona 的回归需在 D: 盘可用时补跑(用户会另行通知)。 > 注:D: 盘移动硬盘已恢复连接;Corona 已按上述新数据回归(第八轮)。
## 五、假设与开放问题 ## 五、假设与开放问题
+8
View File
@@ -70,6 +70,14 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
- `TypeId` 哈希 → 类型名的映射来自 OpenSAGE 的 `AssetType` 枚举(本工作区可提取); - `TypeId` 哈希 → 类型名的映射来自 OpenSAGE 的 `AssetType` 枚举(本工作区可提取);
- 资产名与源文件名各自存放在独立的空字符结尾字符串缓冲区中。 - 资产名与源文件名各自存放在独立的空字符结尾字符串缓冲区中。
**补充(美术资产 `.w3x` 索引)**
- `W3X.xml` / `ART:` include 链中的 `.w3x` 是文本 XML(建模工具导出,BAB 同样按
XML 编译),其顶层资产(`W3DContainer` / `W3DMesh` / `W3DHierarchy` 等)应参与
补全、悬停、导航与诊断——`Model@Name``Hierarchy``Mesh` 等引用依赖这些定义;
- 大模型文件(实测 Corona 最大 22.8 MB,顶点/三角形数据占大头)采用**顶层浅扫描**
(不建 DOM 树),结果在 workspace 级缓存并跨重建复用,避免每次保存都重读整个
美术资产目录(Corona 约 2.6 GB)。
### P1:非近期目标(本期不做,但预留扩展点) ### P1:非近期目标(本期不做,但预留扩展点)
6. **高效搜索**Mod 项目巨大(Corona 约 7500 个 XML、38MB)时直接全文搜索很慢,需要一个高效的 XML 内容索引机制。 6. **高效搜索**Mod 项目巨大(Corona 约 7500 个 XML、38MB)时直接全文搜索很慢,需要一个高效的 XML 内容索引机制。
+1 -1
View File
@@ -135,7 +135,7 @@ export function activate(context: vscode.ExtensionContext): void {
void vscode.window.showInformationMessage( void vscode.window.showInformationMessage(
`RA3 Mod XML index\n` + `RA3 Mod XML index\n` +
`Project: ${s.projectDir}\n` + `Project: ${s.projectDir}\n` +
`Files: ${s.indexedFiles} (${s.parsedFiles} parsed)\n` + `Files: ${s.indexedFiles} (${s.parsedFiles} parsed, ${s.shallowScannedFiles} shallow-scanned, ${s.shallowCacheHits} cached)\n` +
`Assets: ${s.assetCount} (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` + `Assets: ${s.assetCount} (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` +
`Defines: ${s.defineCount} · Streams: ${s.streams} · Candidates: ${s.sourceCandidates}\n` + `Defines: ${s.defineCount} · Streams: ${s.streams} · Candidates: ${s.sourceCandidates}\n` +
`Indexed in ${(s.elapsedMs / 1000).toFixed(1)}s`, `Indexed in ${(s.elapsedMs / 1000).toFixed(1)}s`,
+94
View File
@@ -0,0 +1,94 @@
/**
* Caches shared by the indexer.
*
* The workspace owns one instance of each cache and passes them into every
* ModIndexer, so a rebuild (which creates a fresh indexer) does not re-read
* files whose stat (mtime/size) is unchanged. This is what makes indexing
* large art-asset corpora (Corona: ~3800 .w3x files, 2.6 GB) practical:
* after the first build, save-triggered rebuilds only re-scan files that
* actually changed.
*/
import { resolve } from "node:path";
import type { ParsedFile } from "./types";
/** Case-insensitive absolute path key. */
export function normKey(path: string): string {
return resolve(path).toLowerCase();
}
/**
* LRU cache for fully parsed XML documents. Parse trees of huge mods can be
* memory-heavy, so only a bounded number of recent documents is retained;
* evicted entries are re-read from disk on demand.
*/
export class DocumentCache {
private map = new Map<string, ParsedFile>();
constructor(private capacity = 64) {}
get(path: string): ParsedFile | undefined {
const key = normKey(path);
const hit = this.map.get(key);
if (!hit) return undefined;
this.map.delete(key);
this.map.set(key, hit);
return hit;
}
set(parsed: ParsedFile): void {
const key = normKey(parsed.file.path);
this.map.delete(key);
this.map.set(key, parsed);
if (this.map.size > this.capacity) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined) this.map.delete(oldest);
}
}
invalidate(path: string): void {
this.map.delete(normKey(path));
}
clear(): void {
this.map.clear();
}
}
/**
* Cache for shallow-scanned art-asset documents (.w3x and content-sniffed
* XML files). The retained records are tiny (top-level assets, includes,
* defines) compared to a full DOM, so the capacity can be much larger.
*/
export class ShallowScanCache {
private map = new Map<string, ParsedFile>();
constructor(private capacity = 8192) {}
get(path: string): ParsedFile | undefined {
const key = normKey(path);
const hit = this.map.get(key);
if (!hit) return undefined;
this.map.delete(key);
this.map.set(key, hit);
return hit;
}
set(parsed: ParsedFile): void {
const key = normKey(parsed.file.path);
this.map.delete(key);
this.map.set(key, parsed);
if (this.map.size > this.capacity) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined) this.map.delete(oldest);
}
}
invalidate(path: string): void {
this.map.delete(normKey(path));
}
clear(): void {
this.map.clear();
}
}
+14 -3
View File
@@ -1,7 +1,18 @@
import { readdir, stat } from "node:fs/promises"; import { readdir, stat } from "node:fs/promises";
import { join, relative, resolve } from "node:path"; import { extname, join, relative, resolve } from "node:path";
import type { FileWalker, SourceCandidate } from "./types"; import type { FileWalker, SourceCandidate } from "./types";
/**
* Extensions offered as Include/@source candidates in DATA directories:
* regular XML plus art-asset XML (.w3x) that mods include via W3X.xml hubs.
* ART/AUDIO directories already list every file.
*/
const DATA_CANDIDATE_EXTENSIONS = new Set([".xml", ".w3x"]);
function isDataCandidate(path: string): boolean {
return DATA_CANDIDATE_EXTENSIONS.has(extname(path).toLowerCase());
}
/** /**
* Recursive file list walker with a simple directory-mtime cache. Used to * Recursive file list walker with a simple directory-mtime cache. Used to
* enumerate candidate files for Include/@source completion. Directory scans * enumerate candidate files for Include/@source completion. Directory scans
@@ -79,13 +90,13 @@ export async function collectSourceCandidates(
for (const dir of dataDirs) { for (const dir of dataDirs) {
const files = await walker.listFiles(dir); const files = await walker.listFiles(dir);
for (const f of files) { for (const f of files) {
if (!f.toLowerCase().endsWith(".xml")) continue; if (!isDataCandidate(f)) continue;
add(f, dir, "DATA"); add(f, dir, "DATA");
} }
if (samePath(dir, projectDataDir)) { if (samePath(dir, projectDataDir)) {
// Also offer project-relative paths (what Mod.xml itself uses). // Also offer project-relative paths (what Mod.xml itself uses).
for (const f of files) { for (const f of files) {
if (f.toLowerCase().endsWith(".xml")) add(f, projectDataDir, null); if (isDataCandidate(f)) add(f, projectDataDir, null);
} }
} }
} }
+253 -65
View File
@@ -11,9 +11,15 @@
* outside the extension. * outside the extension.
*/ */
import { readFile, readdir, stat } from "node:fs/promises"; import { open, readFile, readdir, stat } from "node:fs/promises";
import { basename, dirname, extname, join, resolve } from "node:path"; import { basename, dirname, extname, join, resolve } from "node:path";
import { LineMap, parseXml, type XmlDocument, type XmlElement } from "../language/xmlParser"; import {
LineMap,
parseXml,
stripBom,
type XmlDocument,
type XmlElement,
} from "../language/xmlParser";
import { import {
buildSearchPaths, buildSearchPaths,
manifestPathForReference, manifestPathForReference,
@@ -28,6 +34,8 @@ import {
} from "./manifestParser"; } from "./manifestParser";
import { canonicalTypeName } from "../model/schemaModel"; import { canonicalTypeName } from "../model/schemaModel";
import { collectSourceCandidates } from "./fileScanner"; import { collectSourceCandidates } from "./fileScanner";
import { DocumentCache, ShallowScanCache, normKey } from "./caches";
import { scanXmlShallow } from "./shallowScan";
import type { import type {
AssetDef, AssetDef,
DefineDef, DefineDef,
@@ -42,54 +50,27 @@ import type {
const MAX_DEPTH = 300; const MAX_DEPTH = 300;
/** Files above this size are never parsed (safety against binary blobs). */ /** Files above this size are never parsed (safety against binary blobs). */
const MAX_PARSE_BYTES = 4 * 1024 * 1024; const MAX_PARSE_BYTES = 4 * 1024 * 1024;
/** Only these extensions are treated as XML documents. */
const XML_EXTENSIONS = new Set([".xml", ".manifestxml"]);
function normKey(path: string): string {
return resolve(path).toLowerCase();
}
/** /**
* LRU cache for parsed documents. The parse trees of huge mods can be * Fully parsed XML documents. `.xml` / `.manifestxml` files are small enough
* memory-heavy, so only a bounded number of recent documents is retained; * that a full DOM is affordable.
* evicted entries are re-read from disk on demand.
*/ */
export class DocumentCache { const FULL_XML_EXTENSIONS = new Set([".xml", ".manifestxml"]);
private map = new Map<string, ParsedFile>(); /**
* XML documents whose top-level structure is all the index needs (art-asset
* files exported by modeling tools, e.g. .w3x). They are shallow-scanned so
* multi-megabyte vertex/triangle payloads never become a DOM.
*/
const SHALLOW_XML_EXTENSIONS = new Set([".w3x"]);
/** Bytes peeked when deciding whether an unknown extension is XML text. */
const SNIFF_BYTES = 512;
constructor(private capacity = 64) {} type XmlMode = "full" | "shallow" | "binary";
get(path: string): ParsedFile | undefined {
const key = normKey(path);
const hit = this.map.get(key);
if (!hit) return undefined;
this.map.delete(key);
this.map.set(key, hit);
return hit;
}
set(parsed: ParsedFile): void {
const key = normKey(parsed.file.path);
this.map.delete(key);
this.map.set(key, parsed);
if (this.map.size > this.capacity) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined) this.map.delete(oldest);
}
}
invalidate(path: string): void {
this.map.delete(normKey(path));
}
clear(): void {
this.map.clear();
}
}
export class ModIndexer { export class ModIndexer {
private searchPaths: SearchPaths; private searchPaths: SearchPaths;
private docs = new DocumentCache(); private docs: DocumentCache;
private shallowDocs: ShallowScanCache;
private scanCounters = { shallowScannedFiles: 0, shallowCacheHits: 0 };
private assets = new Map<string, Map<string, AssetDef[]>>(); private assets = new Map<string, Map<string, AssetDef[]>>();
private assetsById = new Map<string, AssetDef[]>(); private assetsById = new Map<string, AssetDef[]>();
private defines = new Map<string, DefineDef[]>(); private defines = new Map<string, DefineDef[]>();
@@ -106,42 +87,124 @@ export class ModIndexer {
this.searchPaths = buildSearchPaths(opts.sdkDir, opts.projectDir, { this.searchPaths = buildSearchPaths(opts.sdkDir, opts.projectDir, {
DATA: opts.additionalDataSearchPaths, DATA: opts.additionalDataSearchPaths,
}); });
// Caches may be owned by the workspace so they survive rebuilds.
this.docs = opts.documentCache ?? new DocumentCache();
this.shallowDocs = opts.shallowCache ?? new ShallowScanCache();
} }
/** Re-reads and caches a document; null when unreadable. */ /**
* Returns a document for indexing/navigation:
* - `.xml` / `.manifestxml` files are fully parsed (bounded by
* MAX_PARSE_BYTES);
* - `.w3x` (and unknown-extension files whose content looks like XML) are
* shallow-scanned, so huge model files never become a DOM;
* - everything else is registered as a file but never parsed.
*
* Cached entries are reused when the file stat is unchanged, which lets a
* workspace-owned cache survive rebuilds.
*/
async readDocument(path: string): Promise<ParsedFile | null> { async readDocument(path: string): Promise<ParsedFile | null> {
const hit = this.docs.get(path); const key = normKey(path);
if (hit) return hit; const mode = await this.detectXmlMode(path);
if (mode === "shallow") return this.scanShallow(path);
if (mode === "binary") {
try {
const st = await stat(path);
const file: IndexedFile = { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } };
this.files.set(key, file);
return { file, parse: null, shallow: null, lineMap: null };
} catch {
const file: IndexedFile = { path: resolve(path), stat: null };
this.files.set(key, file);
return { file, parse: null, shallow: null, lineMap: null };
}
}
try { try {
const [st, text] = await Promise.all([stat(path), readFile(path, "utf8")]); const st = await stat(path);
const hit = this.docs.get(key);
if (
hit?.file.stat &&
hit.file.stat.mtimeMs === st.mtimeMs &&
hit.file.stat.size === st.size
) {
this.files.set(key, hit.file);
return hit;
}
if (st.size > MAX_PARSE_BYTES) { if (st.size > MAX_PARSE_BYTES) {
const file: IndexedFile = { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } }; const file: IndexedFile = { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } };
const parsed: ParsedFile = { file, parse: null, lineMap: null }; const parsed: ParsedFile = { file, parse: null, shallow: null, lineMap: null };
this.docs.set(parsed); this.docs.set(parsed);
this.files.set(normKey(parsed.file.path), file); this.files.set(key, file);
return parsed; return parsed;
} }
const text = stripBom(await readFile(path, "utf8"));
const parse = parseXml(text); const parse = parseXml(text);
const parsed: ParsedFile = { const parsed: ParsedFile = {
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } }, file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
parse, parse,
shallow: null,
lineMap: new LineMap(text), lineMap: new LineMap(text),
}; };
this.docs.set(parsed); this.docs.set(parsed);
this.files.set(normKey(parsed.file.path), parsed.file); this.files.set(key, parsed.file);
return parsed; return parsed;
} catch { } catch {
const parsed: ParsedFile = { const parsed: ParsedFile = {
file: { path: resolve(path), stat: null }, file: { path: resolve(path), stat: null },
parse: null, parse: null,
shallow: null,
lineMap: null, lineMap: null,
}; };
this.docs.set(parsed); this.docs.set(parsed);
this.files.set(normKey(parsed.file.path), parsed.file); this.files.set(key, parsed.file);
return parsed; return parsed;
} }
} }
/**
* Shallow-scans a large art-asset XML document (no DOM built) and caches
* the top-level records. Cache hits are counted separately so tests and
* the index report can verify that rebuilds skip unchanged files.
*/
private async scanShallow(path: string): Promise<ParsedFile | null> {
const key = normKey(path);
try {
const st = await stat(path);
const hit = this.shallowDocs.get(key);
if (
hit?.file.stat &&
hit.file.stat.mtimeMs === st.mtimeMs &&
hit.file.stat.size === st.size
) {
this.scanCounters.shallowCacheHits++;
this.files.set(key, hit.file);
return hit;
}
const text = stripBom(await readFile(path, "utf8"));
const shallow = scanXmlShallow(text);
const parsed: ParsedFile = {
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
parse: null,
shallow,
lineMap: new LineMap(text),
};
this.shallowDocs.set(parsed);
this.files.set(key, parsed.file);
this.scanCounters.shallowScannedFiles++;
return parsed;
} catch {
return null;
}
}
/** Decides how a resolved include target should be consumed. */
private async detectXmlMode(path: string): Promise<XmlMode> {
const ext = extname(path).toLowerCase();
if (FULL_XML_EXTENSIONS.has(ext)) return "full";
if (SHALLOW_XML_EXTENSIONS.has(ext)) return "shallow";
return (await looksLikeXml(path)) ? "shallow" : "binary";
}
/** Returns the cached parse if present (does not read from disk). */ /** Returns the cached parse if present (does not read from disk). */
cachedDocument(path: string): ParsedFile | undefined { cachedDocument(path: string): ParsedFile | undefined {
return this.docs.get(path); return this.docs.get(path);
@@ -253,6 +316,8 @@ export class ModIndexer {
parsedFiles: [...this.files.values()].filter( parsedFiles: [...this.files.values()].filter(
(f) => f.stat != null && f.stat.size <= MAX_PARSE_BYTES, (f) => f.stat != null && f.stat.size <= MAX_PARSE_BYTES,
).length, ).length,
shallowScannedFiles: this.scanCounters.shallowScannedFiles,
shallowCacheHits: this.scanCounters.shallowCacheHits,
assetCount: [...this.assets.values()].reduce((sum, byId) => sum + byId.size, 0), assetCount: [...this.assets.values()].reduce((sum, byId) => sum + byId.size, 0),
defineCount: this.defines.size, defineCount: this.defines.size,
manifestFiles: this.manifests.size, manifestFiles: this.manifests.size,
@@ -294,11 +359,15 @@ export class ModIndexer {
stream.files.add(key); stream.files.add(key);
// Binary assets (w3x/dds/...) are referenced but never parsed. // readDocument handles every mode: full XML parse, shallow scan for
if (!isXmlPath(path)) return; // art-asset XML (.w3x / content-sniffed), or binary registration only.
const parsed = await this.readDocument(path); const parsed = await this.readDocument(path);
if (!parsed?.parse?.root) return; if (!parsed) return;
if (parsed.shallow) {
await this.walkShallow(parsed, stream, depth, mode === "instance");
return;
}
if (!parsed.parse?.root) return;
const root = parsed.parse.root; const root = parsed.parse.root;
for (const child of root.children) { for (const child of root.children) {
@@ -366,12 +435,12 @@ export class ModIndexer {
const manifestPath = manifestPathForReference(source, this.opts.builtmodsDirs); const manifestPath = manifestPathForReference(source, this.opts.builtmodsDirs);
if (manifestPath) { if (manifestPath) {
const loaded = await this.loadManifest(manifestPath, stream.name); const loaded = await this.loadManifest(manifestPath, stream.name);
if (!loaded && isXmlPath(resolved.path)) { if (!loaded) {
// The manifest could not be parsed (missing/invalid): fall back // The manifest could not be parsed (missing/invalid): fall back
// to the placeholder XML so its content is still available. // to the placeholder target so its content is still available.
await this.walk(resolved.path, "instance", stream, depth + 1); await this.walk(resolved.path, "instance", stream, depth + 1);
} }
} else if (isXmlPath(resolved.path)) { } else {
// reference to a real XML file: treat its assets as available // reference to a real XML file: treat its assets as available
await this.walk(resolved.path, "instance", stream, depth + 1); await this.walk(resolved.path, "instance", stream, depth + 1);
} }
@@ -400,7 +469,95 @@ export class ModIndexer {
continue; continue;
} }
stream.files.add(normKey(resolved.path)); stream.files.add(normKey(resolved.path));
if (isXmlPath(resolved.path)) { if ((await this.detectXmlMode(resolved.path)) !== "binary") {
await this.walk(resolved.path, "all", stream, depth + 1);
}
}
}
/**
* Consumes a shallow-scanned art-asset document: top-level assets,
* top-level <Includes>, <Defines> and nested <xi:include> targets.
*/
private async walkShallow(
parsed: ParsedFile,
stream: StreamInfo,
depth: number,
viaInstance: boolean,
): Promise<void> {
const scan = parsed.shallow;
if (!scan) return;
const file = parsed.file.path;
for (const asset of scan.assets) {
this.addAsset({
type: asset.name,
id: asset.id,
file,
line: lineOf(parsed, asset.idValueStart),
origin: this.originOf(file),
stream: stream.name,
viaInstance,
});
}
for (const define of scan.defines) {
const entry: DefineDef = {
name: define.name,
value: define.value,
file,
line: lineOf(parsed, define.start),
origin: this.originOf(file),
};
const arr = this.defines.get(define.name.toLowerCase());
if (arr) arr.push(entry);
else this.defines.set(define.name.toLowerCase(), [entry]);
}
for (const inc of scan.includes) {
const resolved = resolveSource(inc.source, dirname(file), this.searchPaths);
if (!resolved.path) {
this.diagnostics.push({
file,
line: lineOf(parsed, inc.start),
message: `Include target not found: ${inc.source}`,
severity: "warning",
code: "include-not-found",
});
continue;
}
if (inc.type === "all" || inc.type === "instance") {
await this.walk(
resolved.path,
inc.type === "all" ? "all" : "instance",
stream,
depth + 1,
);
} else if (inc.type === "reference") {
const manifestPath = manifestPathForReference(inc.source, this.opts.builtmodsDirs);
if (manifestPath) {
const loaded = await this.loadManifest(manifestPath, stream.name);
if (!loaded) await this.walk(resolved.path, "instance", stream, depth + 1);
} else {
await this.walk(resolved.path, "instance", stream, depth + 1);
}
}
}
for (const xi of scan.xiIncludes) {
const resolved = resolveSource(xi.href, dirname(file), this.searchPaths);
if (!resolved.path) {
this.diagnostics.push({
file,
line: lineOf(parsed, xi.start),
message: `xi:include target not found: ${xi.href}`,
severity: "warning",
code: "include-not-found",
});
continue;
}
stream.files.add(normKey(resolved.path));
if ((await this.detectXmlMode(resolved.path)) !== "binary") {
await this.walk(resolved.path, "all", stream, depth + 1); await this.walk(resolved.path, "all", stream, depth + 1);
} }
} }
@@ -425,9 +582,17 @@ export class ModIndexer {
}); });
return; return;
} }
if (!isXmlPath(resolved.path)) return; if ((await this.detectXmlMode(resolved.path)) === "binary") return;
const target = await this.readDocument(resolved.path); const target = await this.readDocument(resolved.path);
if (!target?.parse?.root) return; if (!target) return;
if (target.shallow) {
// Shallow-scanned targets have no tree to select xpointer children
// from; index their top-level content as a whole.
stream.files.add(normKey(target.file.path));
await this.walk(target.file.path, "all", stream, depth + 1);
return;
}
if (!target.parse?.root) return;
const xpointer = xi.attrs.find((a) => a.name === "xpointer")?.value ?? ""; const xpointer = xi.attrs.find((a) => a.name === "xpointer")?.value ?? "";
let candidates: XmlElement[]; let candidates: XmlElement[];
@@ -551,9 +716,32 @@ function lineOf(parsed: ParsedFile, offset: number): number {
return parsed.lineMap.positionAt(offset).line + 1; return parsed.lineMap.positionAt(offset).line + 1;
} }
function isXmlPath(path: string): boolean { /**
const ext = extname(path).toLowerCase(); * Content sniffing for include targets with unknown extensions (e.g. art
return XML_EXTENSIONS.has(ext); * formats beyond .w3x): a small header that starts with "<" after an
* optional UTF-8 BOM / whitespace and contains no NUL bytes is treated as
* XML text; anything else is a binary asset (registered, never parsed).
*/
async function looksLikeXml(path: string): Promise<boolean> {
try {
const fh = await open(path, "r");
try {
const buf = Buffer.alloc(SNIFF_BYTES);
const { bytesRead } = await fh.read(buf, 0, SNIFF_BYTES, 0);
const head = buf.subarray(0, bytesRead);
if (head.includes(0)) return false;
let i = 0;
if (head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf) i = 3;
while (i < head.length && (head[i] === 0x20 || head[i] === 0x09 || head[i] === 0x0a || head[i] === 0x0d)) {
i++;
}
return i < head.length && head[i] === 0x3c; // "<"
} finally {
await fh.close();
}
} catch {
return false;
}
} }
async function findCaseInsensitiveDir(dir: string): Promise<string | null> { async function findCaseInsensitiveDir(dir: string): Promise<string | null> {
+268
View File
@@ -0,0 +1,268 @@
/**
* Shallow XML scanner for large art-asset documents (e.g. .w3x).
*
* Full XML parsing builds an element tree whose memory footprint is roughly
* 17x the source text (measured on Corona model files). Model exports like
* <W3DMesh> contain hundreds of thousands of tiny numeric elements
* (Vertices/V, Triangles/T, ...) that the extension never needs: the index
* only consumes top-level asset definitions (name + id), top-level
* <Includes>, nested <xi:include> targets and <Defines> constants.
*
* This scanner performs a single linear pass without building child nodes,
* so multi-megabyte model files can be indexed in linear time with ~zero
* retained memory.
*
* Pure TypeScript: no vscode dependency, reusable outside the extension.
*/
export interface ShallowAssetRecord {
/** Top-level element name, e.g. "W3DContainer". */
name: string;
/** Value of the id attribute. */
id: string;
/** Offset of the first id value character. */
idValueStart: number;
/** Offset one past the last id value character. */
idValueEnd: number;
/** Offset of the element's "<". */
start: number;
/** Offset one past the ">" of the start tag. */
startTagEnd: number;
}
export interface ShallowIncludeRecord {
/** "all" | "instance" | "reference", or null when absent/unknown. */
type: "all" | "instance" | "reference" | null;
source: string;
/** Offset of the <Include> element. */
start: number;
}
export interface ShallowXiIncludeRecord {
href: string;
xpointer: string | null;
/** Offset of the <xi:include> element. */
start: number;
}
export interface ShallowDefineRecord {
name: string;
value: string;
/** Offset of the <Define> element. */
start: number;
}
export interface ShallowScanError {
message: string;
offset: number;
}
export interface ShallowDocument {
assets: ShallowAssetRecord[];
includes: ShallowIncludeRecord[];
xiIncludes: ShallowXiIncludeRecord[];
defines: ShallowDefineRecord[];
errors: ShallowScanError[];
}
interface AttrHit {
value: string;
valueStart: number;
valueEnd: number;
}
/**
* Matches a complete XML tag while respecting quoted attribute values, so a
* ">" or "/" inside a value never terminates the tag early.
*/
const TAG_RE = /<(?:"[^"]*"|'[^']*'|[^'"<>])*>/g;
export function scanXmlShallow(text: string): ShallowDocument {
const errors: ShallowScanError[] = [];
const assets: ShallowAssetRecord[] = [];
const includes: ShallowIncludeRecord[] = [];
const xiIncludes: ShallowXiIncludeRecord[] = [];
const defines: ShallowDefineRecord[] = [];
// Depth of the currently open element stack. The document root opens at
// depth 0 -> 1, so its direct children open when depth === 1.
let depth = 0;
let inIncludes = false;
let inDefines = false;
let i = 0;
while (i < text.length) {
const lt = text.indexOf("<", i);
if (lt < 0) break;
// Non-element constructs: comments, CDATA, DOCTYPE and processing
// instructions are skipped whole so their content never looks like tags.
if (text.startsWith("<!--", lt)) {
const close = text.indexOf("-->", lt + 4);
if (close < 0) {
errors.push({ message: "Unterminated comment", offset: lt });
break;
}
i = close + 3;
continue;
}
if (text.startsWith("<![CDATA[", lt)) {
const close = text.indexOf("]]>", lt + 9);
if (close < 0) {
errors.push({ message: "Unterminated CDATA section", offset: lt });
break;
}
i = close + 3;
continue;
}
if (text.startsWith("<!", lt)) {
const close = text.indexOf(">", lt + 2);
if (close < 0) {
errors.push({ message: "Unterminated DOCTYPE", offset: lt });
break;
}
i = close + 1;
continue;
}
if (text.startsWith("<?", lt)) {
const close = text.indexOf("?>", lt + 2);
if (close < 0) {
errors.push({ message: "Unterminated processing instruction", offset: lt });
break;
}
i = close + 2;
continue;
}
TAG_RE.lastIndex = lt;
const m = TAG_RE.exec(text);
if (!m) {
errors.push({ message: "Unterminated tag", offset: lt });
break;
}
const tag = m[0];
const gt = m.index + tag.length - 1;
const inner = tag.slice(1, -1);
const closing = inner.startsWith("/");
const selfClosing = !closing && /\/\s*$/.test(inner);
const body = closing ? inner.slice(1) : inner;
let nameEnd = 0;
while (nameEnd < body.length && !/[\s/>]/.test(body[nameEnd])) nameEnd++;
const name = body.slice(0, nameEnd);
const base = lt + 1;
if (!closing) {
// Top-level elements (direct children of the root).
if (depth === 1) {
inIncludes = name === "Includes";
inDefines = name === "Defines";
const idAttr = findAttr(inner, base, "id");
if (idAttr && idAttr.value) {
assets.push({
name,
id: idAttr.value,
idValueStart: idAttr.valueStart,
idValueEnd: idAttr.valueEnd,
start: lt,
startTagEnd: gt + 1,
});
}
}
// <Include> entries inside the top-level <Includes> block.
if (inIncludes && depth === 2 && name === "Include") {
const type = findAttr(inner, base, "type");
const source = findAttr(inner, base, "source");
if (source?.value) {
includes.push({
type:
type?.value === "all" || type?.value === "instance" || type?.value === "reference"
? type.value
: null,
source: source.value,
start: lt,
});
}
}
// <Define> entries inside the top-level <Defines> block.
if (inDefines && depth === 2 && name === "Define") {
const nameAttr = findAttr(inner, base, "name");
const valueAttr = findAttr(inner, base, "value");
if (nameAttr?.value) {
defines.push({ name: nameAttr.value, value: valueAttr?.value ?? "", start: lt });
}
}
// Nested <xi:include> (or any *:include) anywhere in the document.
if (localName(name) === "include") {
const href = findAttr(inner, base, "href");
const xpointer = findAttr(inner, base, "xpointer");
if (href?.value) {
xiIncludes.push({
href: href.value,
xpointer: xpointer?.value ?? null,
start: lt,
});
}
}
if (!selfClosing) depth++;
} else {
depth--;
}
i = gt + 1;
}
return { assets, includes, xiIncludes, defines, errors };
}
/**
* Scans the attributes of a tag (its inner text, without "<" and ">") for
* the first occurrence of `want` and returns its value with absolute offsets
* (base = offset one past the "<").
*/
function findAttr(inner: string, base: number, want: string): AttrHit | null {
let i = 0;
while (i < inner.length) {
while (i < inner.length && /\s/.test(inner[i])) i++;
// Self-closing marker or end of tag: no more attributes.
if (i >= inner.length || inner[i] === "/" || inner[i] === ">") break;
const nameStart = i;
while (i < inner.length && !/[\s=/>]/.test(inner[i])) i++;
const name = inner.slice(nameStart, i);
while (i < inner.length && /\s/.test(inner[i])) i++;
if (inner[i] === "=") {
i++;
while (i < inner.length && /\s/.test(inner[i])) i++;
const q = inner[i];
if (q === '"' || q === "'") {
const vStart = i + 1;
const vEnd = inner.indexOf(q, vStart);
if (vEnd < 0) {
return name === want ? { value: "", valueStart: -1, valueEnd: -1 } : null;
}
if (name === want) {
return {
value: inner.slice(vStart, vEnd),
valueStart: base + vStart,
valueEnd: base + vEnd,
};
}
i = vEnd + 1;
continue;
}
// Unquoted value (tolerated).
const vs = i;
while (i < inner.length && !/[\s>]/.test(inner[i])) i++;
if (name === want) {
return { value: inner.slice(vs, i), valueStart: base + vs, valueEnd: base + i };
}
continue;
}
// Attribute without a value (rare but tolerated).
if (name === want) return { value: "", valueStart: -1, valueEnd: -1 };
}
return null;
}
function localName(name: string): string {
const idx = name.lastIndexOf(":");
return idx >= 0 ? name.slice(idx + 1) : name;
}
+15
View File
@@ -1,6 +1,8 @@
import type { XmlDocument } from "../language/xmlParser"; import type { XmlDocument } from "../language/xmlParser";
import type { ManifestInfo } from "./manifestParser"; import type { ManifestInfo } from "./manifestParser";
import type { LineMap } from "../language/xmlParser"; import type { LineMap } from "../language/xmlParser";
import type { ShallowDocument } from "./shallowScan";
import type { DocumentCache, ShallowScanCache } from "./caches";
export type AssetOrigin = "project" | "sdk" | "manifest"; export type AssetOrigin = "project" | "sdk" | "manifest";
@@ -65,6 +67,10 @@ export interface IndexStats {
sdkDir: string; sdkDir: string;
indexedFiles: number; indexedFiles: number;
parsedFiles: number; parsedFiles: number;
/** Art-asset documents indexed via shallow scan (no DOM tree). */
shallowScannedFiles: number;
/** Shallow scans served from the persistent cache (unchanged files). */
shallowCacheHits: number;
assetCount: number; assetCount: number;
defineCount: number; defineCount: number;
manifestFiles: number; manifestFiles: number;
@@ -102,6 +108,10 @@ export interface IndexOptions {
additionalDataSearchPaths: string[]; additionalDataSearchPaths: string[];
/** Directory walker used to enumerate files for source completion. */ /** Directory walker used to enumerate files for source completion. */
walker: FileWalker; walker: FileWalker;
/** Optional parse-tree cache shared across rebuilds (owned by the workspace). */
documentCache?: DocumentCache;
/** Optional shallow-scan cache shared across rebuilds (owned by the workspace). */
shallowCache?: ShallowScanCache;
} }
export interface FileWalker { export interface FileWalker {
@@ -121,5 +131,10 @@ export interface ParseCache {
export interface ParsedFile { export interface ParsedFile {
file: IndexedFile; file: IndexedFile;
parse: XmlDocument | null; parse: XmlDocument | null;
/**
* Shallow records for large art-asset XML documents (.w3x etc.); null for
* fully parsed XML and for binary files.
*/
shallow: ShallowDocument | null;
lineMap: LineMap | null; lineMap: LineMap | null;
} }
+8
View File
@@ -66,6 +66,14 @@ export interface Position {
character: number; character: number;
} }
/**
* Removes a leading UTF-8 byte-order mark (U+FEFF) so source offsets match
* the text as editors expose it (VS Code strips the BOM from document text).
*/
export function stripBom(text: string): string {
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}
/** Precomputes line start offsets for offset <-> position conversion. */ /** Precomputes line start offsets for offset <-> position conversion. */
export class LineMap { export class LineMap {
private lineStarts: number[] = [0]; private lineStarts: number[] = [0];
+10 -1
View File
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { CachedDirectoryWalker } from "./indexer/fileScanner"; import { CachedDirectoryWalker } from "./indexer/fileScanner";
import { ModIndexer } from "./indexer/indexer"; import { ModIndexer } from "./indexer/indexer";
import { DocumentCache, ShallowScanCache } from "./indexer/caches";
import type { ModIndex } from "./indexer/types"; import type { ModIndex } from "./indexer/types";
import { readSettings, type ExtensionSettings } from "./settings"; import { readSettings, type ExtensionSettings } from "./settings";
@@ -15,6 +16,11 @@ export class ModWorkspace {
settings: ExtensionSettings; settings: ExtensionSettings;
private walker = new CachedDirectoryWalker(); private walker = new CachedDirectoryWalker();
// Caches owned here survive rebuilds: a fresh ModIndexer reuses them and
// only re-reads files whose stat changed (crucial for the ~2.6 GB of .w3x
// art assets in a project like Corona).
private documentCache = new DocumentCache();
private shallowCache = new ShallowScanCache();
private statusBar: vscode.StatusBarItem; private statusBar: vscode.StatusBarItem;
private rebuildTimer: ReturnType<typeof setTimeout> | null = null; private rebuildTimer: ReturnType<typeof setTimeout> | null = null;
private building = false; private building = false;
@@ -81,6 +87,8 @@ export class ModWorkspace {
indexSageXml: this.settings.indexSageXml, indexSageXml: this.settings.indexSageXml,
additionalDataSearchPaths: this.settings.additionalDataSearchPaths, additionalDataSearchPaths: this.settings.additionalDataSearchPaths,
walker: this.walker, walker: this.walker,
documentCache: this.documentCache,
shallowCache: this.shallowCache,
}); });
const started = Date.now(); const started = Date.now();
this.index = await indexer.build(); this.index = await indexer.build();
@@ -90,7 +98,7 @@ export class ModWorkspace {
this.statusBar.text = `$(symbol-misc) RA3 XML: ${formatCount(s.assetCount)} assets`; this.statusBar.text = `$(symbol-misc) RA3 XML: ${formatCount(s.assetCount)} assets`;
this.statusBar.tooltip = this.statusBar.tooltip =
`${s.projectDir}\n` + `${s.projectDir}\n` +
`${s.indexedFiles} files indexed (${secs}s)\n` + `${s.indexedFiles} files indexed (${s.parsedFiles} parsed, ${s.shallowScannedFiles} art assets shallow-scanned, ${secs}s)\n` +
`${s.assetCount} assets (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` + `${s.assetCount} assets (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` +
`${s.defineCount} defines, ${s.streams} streams, ${s.sourceCandidates} include candidates`; `${s.defineCount} defines, ${s.streams} streams, ${s.sourceCandidates} include candidates`;
} catch (err) { } catch (err) {
@@ -113,6 +121,7 @@ export class ModWorkspace {
return { return {
file: { path, stat: null }, file: { path, stat: null },
parse, parse,
shallow: null,
lineMap: new LineMap(text), lineMap: new LineMap(text),
}; };
} }
@@ -0,0 +1,2 @@
This is a fake DDS payload used to verify that binary assets included from
an XML hub are registered as files but never parsed as XML.
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<W3DMesh id="Tank_FP" />
</AssetDeclaration>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<W3DContainer id="Tank_SKN" Hierarchy="Tank_SKL" />
<W3DHierarchy id="Tank_SKL" />
</AssetDeclaration>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Includes>
<Include type="all" source="Models/Tank_SKN.w3x" />
<Include type="all" source="Models/Tank_FP.w3d" />
<Include type="all" source="Models/Tank_Damaged.dds" />
</Includes>
</AssetDeclaration>
+1
View File
@@ -9,5 +9,6 @@
<Include type="all" source="Includes/Weapons.xml" /> <Include type="all" source="Includes/Weapons.xml" />
<Include type="all" source="Includes/Shared.xml" /> <Include type="all" source="Includes/Shared.xml" />
<Include type="all" source="Includes/Refs.xml" /> <Include type="all" source="Includes/Refs.xml" />
<Include type="all" source="Includes/VehicleArt.xml" />
</Includes> </Includes>
</AssetDeclaration> </AssetDeclaration>
+121
View File
@@ -2,8 +2,12 @@ import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import fs from "node:fs";
import os from "node:os";
import { ModIndexer } from "../out/indexer/indexer.js"; import { ModIndexer } from "../out/indexer/indexer.js";
import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js"; import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js";
import { DocumentCache, ShallowScanCache } from "../out/indexer/caches.js";
import { resolveReferenceTargetsForType } from "../out/indexer/refs.js";
const root = dirname(dirname(fileURLToPath(import.meta.url))); const root = dirname(dirname(fileURLToPath(import.meta.url)));
const project = join(root, "test", "fixtures", "minimod"); const project = join(root, "test", "fixtures", "minimod");
@@ -70,3 +74,120 @@ test("provides include source candidates", async () => {
assert.ok(xml.some((c) => c.source === "Includes/Units.xml")); assert.ok(xml.some((c) => c.source === "Includes/Units.xml"));
assert.ok(xml.some((c) => c.source === "DATA:static.xml")); assert.ok(xml.some((c) => c.source === "DATA:static.xml"));
}); });
test("indexes art-asset XML (.w3x / sniffed .w3d) via shallow scan and skips binary", async () => {
const idx = await buildIndex();
// The .w3x hub chain: Mod.xml -> VehicleArt.xml -> Models/Tank_SKN.w3x.
const skn = idx.assetsById.get("tank_skn");
assert.ok(skn?.some((d) => d.type === "W3DContainer"), "W3DContainer from .w3x indexed");
assert.ok(
idx.assetsById.get("tank_skl")?.some((d) => d.type === "W3DHierarchy"),
"W3DHierarchy from .w3x indexed",
);
const container = skn.find((d) => d.type === "W3DContainer");
assert.match(container.file, /Tank_SKN\.w3x$/);
assert.ok(container.line > 0, "definition line recorded from shallow scan");
// Unknown extension with XML content is sniffed and indexed.
assert.ok(
idx.assetsById.get("tank_fp")?.some((d) => d.type === "W3DMesh"),
"unknown-extension XML (.w3d) sniffed and indexed",
);
// Binary content is registered as a file but never parsed.
assert.equal(idx.assetsById.get("tank_damaged"), undefined, "binary asset never indexed");
assert.ok(
idx.files.has(
join(project, "Data", "Includes", "Models", "Tank_Damaged.dds").toLowerCase(),
),
"binary include target registered as a file",
);
// The reported Harbinger scenario: <Model Name="Tank_SKN"/> (refType
// BaseRenderAssetType) must resolve to the W3DContainer defined in the w3x.
const targets = resolveReferenceTargetsForType(
idx,
"ScriptedModelDrawModel",
"Name",
"Tank_SKN",
);
assert.equal(targets.length, 1);
assert.equal(targets[0].def.type, "W3DContainer");
assert.match(targets[0].def.file, /Tank_SKN\.w3x$/);
});
test("w3x files appear in Include source completion candidates", async () => {
const idx = await buildIndex();
assert.ok(
idx.sourceCandidates.some((c) => c.source === "Includes/Models/Tank_SKN.w3x"),
"project-relative w3x candidate",
);
assert.ok(
idx.sourceCandidates.some((c) => c.source === "DATA:Includes/Models/Tank_SKN.w3x"),
"DATA: w3x candidate",
);
});
test("shallow scans and full parses are cached across rebuilds", async () => {
const documentCache = new DocumentCache();
const shallowCache = new ShallowScanCache();
const makeIndexer = () =>
new ModIndexer({
projectDir: project,
sdkDir: sdk,
builtmodsDirs: [join(sdk, "builtmods")],
indexSageXml: true,
additionalDataSearchPaths: [],
walker: new CachedDirectoryWalker(),
documentCache,
shallowCache,
});
const first = await makeIndexer().build();
const second = await makeIndexer().build();
assert.equal(first.stats.shallowScannedFiles, 2, "w3x + sniffed w3d scanned on first build");
assert.equal(second.stats.shallowScannedFiles, 0, "unchanged art assets are not re-scanned");
assert.equal(second.stats.shallowCacheHits, 2);
assert.equal(second.stats.assetCount, first.stats.assetCount);
assert.equal(second.stats.indexedFiles, first.stats.indexedFiles);
});
test("w3x with a UTF-8 BOM is indexed with correct offsets", async (t) => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ra3modxml-bom-"));
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
const projectDir = join(tmp, "project");
fs.mkdirSync(join(projectDir, "Data", "Models"), { recursive: true });
fs.writeFileSync(
join(projectDir, "Data", "Mod.xml"),
`<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration>
<Includes>
<Include type="all" source="Models/Tank_BOM.w3x"/>
</Includes>
</AssetDeclaration>`,
"utf8",
);
fs.writeFileSync(
join(projectDir, "Data", "Models", "Tank_BOM.w3x"),
"\uFEFF<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<AssetDeclaration>\n" +
" <W3DContainer id=\"Tank_BOM\" />\n" +
"</AssetDeclaration>\n",
"utf8",
);
const idx = await new ModIndexer({
projectDir,
sdkDir: sdk,
builtmodsDirs: [join(sdk, "builtmods")],
indexSageXml: false,
additionalDataSearchPaths: [],
walker: new CachedDirectoryWalker(),
}).build();
const def = idx.assetsById.get("tank_bom")?.find((d) => d.type === "W3DContainer");
assert.ok(def, "BOM-prefixed w3x asset indexed");
assert.equal(def.line, 3, "id line is correct despite the BOM");
});
+100
View File
@@ -0,0 +1,100 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { scanXmlShallow } from "../out/indexer/shallowScan.js";
test("extracts top-level assets, includes, defines and xi:include", () => {
const text = `<?xml version="1.0"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Defines>
<Define name="MODEL_SCALE" value="1.0"/>
</Defines>
<Includes>
<Include type="all" source="Art/Model_SKN.w3x"/>
<Include type="reference" source="DATA:static.xml"/>
</Includes>
<W3DContainer id="Model_SKN" Hierarchy="Model_SKL">
<SubObject SubObjectID="GUN">
<RenderObject><Mesh>Model_SKN.GUN</Mesh></RenderObject>
</SubObject>
</W3DContainer>
<W3DMesh id="Model_SKN.GUN"/>
<xi:include href="DATA:Includes/Extra.xml" xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:Extra/child::*)"/>
</AssetDeclaration>`;
const doc = scanXmlShallow(text);
assert.equal(doc.errors.length, 0);
assert.deepEqual(
doc.assets.map((a) => `${a.name}:${a.id}`),
["W3DContainer:Model_SKN", "W3DMesh:Model_SKN.GUN"],
);
assert.equal(doc.includes.length, 2);
assert.equal(doc.includes[0].type, "all");
assert.equal(doc.includes[0].source, "Art/Model_SKN.w3x");
assert.equal(doc.includes[1].type, "reference");
assert.equal(doc.defines.length, 1);
assert.equal(doc.defines[0].name, "MODEL_SCALE");
assert.equal(doc.defines[0].value, "1.0");
assert.equal(doc.xiIncludes.length, 1);
assert.equal(doc.xiIncludes[0].href, "DATA:Includes/Extra.xml");
assert.match(doc.xiIncludes[0].xpointer, /xpointer\(/);
// Recorded offsets must slice the original text back out.
const container = doc.assets[0];
assert.equal(text.slice(container.idValueStart, container.idValueEnd), "Model_SKN");
assert.equal(text.slice(container.start, container.startTagEnd).startsWith("<W3DContainer"), true);
const mesh = doc.assets[1];
assert.equal(text.slice(mesh.idValueStart, mesh.idValueEnd), "Model_SKN.GUN");
});
test("handles > and / inside quoted values, self-closing tags and comments", () => {
const text = `<?xml version="1.0"?>
<!-- a > comment with < inside -->
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<W3DContainer id="Weird" Description="a > b < c" />
<W3DMesh id="X" VertexData="a / b"/>
</AssetDeclaration>`;
const doc = scanXmlShallow(text);
assert.equal(doc.errors.length, 0);
assert.deepEqual(
doc.assets.map((a) => a.id),
["Weird", "X"],
);
});
test("skips CDATA and processing instructions", () => {
const text = `<AssetDeclaration>
<!-- <W3DMesh id="FAKE"/> -->
<![CDATA[ <W3DMesh id="FAKE2"/> ]]>
<?xml-stylesheet href="x"?>
<W3DContainer id="REAL"/>
</AssetDeclaration>`;
const doc = scanXmlShallow(text);
assert.equal(doc.errors.length, 0);
assert.deepEqual(
doc.assets.map((a) => a.id),
["REAL"],
);
});
test("reports unterminated tags and keeps partial results", () => {
const doc = scanXmlShallow('<AssetDeclaration><W3DMesh id="A"/><W3DMesh id="B"');
assert.ok(doc.errors.length >= 1);
assert.deepEqual(
doc.assets.map((a) => a.id),
["A"],
);
});
test("nested module payload does not create asset records", () => {
// Only top-level elements with an id are assets; the hundreds of thousands
// of numeric V/T elements inside a mesh are not.
const doc = scanXmlShallow(`<AssetDeclaration>
<W3DMesh id="MESH">
<Vertices><V X="1" Y="2" Z="3"/><V X="4" Y="5" Z="6"/></Vertices>
<Triangles><T A="0" B="1" C="2"/></Triangles>
</W3DMesh>
</AssetDeclaration>`);
assert.deepEqual(
doc.assets.map((a) => a.id),
["MESH"],
);
});
+6 -1
View File
@@ -1,6 +1,11 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { parseXml, findElementAt } from "../out/language/xmlParser.js"; import { parseXml, findElementAt, stripBom } from "../out/language/xmlParser.js";
test("stripBom removes a leading UTF-8 byte-order mark", () => {
assert.equal(stripBom("\uFEFF<A/>"), "<A/>");
assert.equal(stripBom("<A/>"), "<A/>");
});
test("parses elements, attributes and positions", () => { test("parses elements, attributes and positions", () => {
const text = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n\t<GameObject id="X" KindOf="A B"/>\n</AssetDeclaration>`; const text = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n\t<GameObject id="X" KindOf="A B"/>\n</AssetDeclaration>`;