Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44b2eaf273 | ||
|
|
4cc07b9abc |
@@ -3,3 +3,4 @@ out/
|
||||
dist/
|
||||
*.vsix
|
||||
OpenSAGE/
|
||||
debug.log
|
||||
@@ -18,6 +18,14 @@
|
||||
- **引用导航**:从引用值(`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` 未定义。
|
||||
- **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 级缓存并跨重建复用,保存文件触发的重建不会重读未变化的模型文件。
|
||||
- **大项目性能**:索引记录(资产 / Define / Include / 行号)与 include 解析结果
|
||||
跨重建缓存,保存触发的重建零 stat、零重读(Corona 实测约 2 秒);DOM 树只按需
|
||||
保留并设元素预算,避免内存膨胀。
|
||||
|
||||
## 使用
|
||||
|
||||
@@ -72,7 +80,11 @@ src/
|
||||
manifestParser.ts .manifest 二进制解析(移植 OpenSAGE ManifestFile.cs)
|
||||
fileScanner.ts 目录扫描与 Include source 候选
|
||||
refs.ts 引用目标解析(按引用类型过滤)
|
||||
indexer.ts 工作区索引器(后台、缓存、增量重建)
|
||||
shallowScan.ts .w3x 等大体积美术资产顶层浅扫描(纯 TS,不建 DOM)
|
||||
records.ts 每文件紧凑索引记录(资产/Define/Include/xi + 行号)
|
||||
caches.ts 跨重建持久缓存(DocumentCache / IndexRecordsCache /
|
||||
IncludeResolveCache)
|
||||
indexer.ts 工作区索引器(后台、缓存、记录驱动重建)
|
||||
features/ completion / hover / navigation / diagnostics / semanticTokens
|
||||
syntaxes/ TextMate 注入语法
|
||||
tools/ XSD → 模型、AssetType 枚举提取
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
[0801/032923.150:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 拒绝访问。 (0x5)
|
||||
@@ -590,3 +590,175 @@ token:文档出现解析错误时,由插件用自己的容错解析树继续
|
||||
配色,可能与 TextMate XML 配色略有差异——只在打字过程中出现,可接受;
|
||||
- 未实现 `provideDocumentSemanticTokensEdits`(delta 版本),每次全量计算;单文件
|
||||
解析在 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 w3x:0 个 UTF-16,40 个带 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" 只是通用兜底文案。
|
||||
|
||||
### 规模调查(Corona,D: 盘已连接)
|
||||
|
||||
- **3788 个 w3x,共 2.64 GB**;最大 22.8 MB;163 个超过原 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 MB(55%)。
|
||||
- 全量建 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 s:8,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);实测生态中不存在,
|
||||
如遇可再扩展解码。
|
||||
|
||||
---
|
||||
|
||||
## 十四、问题分析(第九轮,2026-08-02):Corona 重建性能与内存
|
||||
|
||||
### 目标与基线
|
||||
|
||||
第八轮后 Corona 的实测基线:
|
||||
|
||||
| 场景 | 耗时 |
|
||||
|---|---|
|
||||
| 首次全量建索引 | ~180-290s(机械盘波动) |
|
||||
| 信任二次构建(保存触发) | 8-21s |
|
||||
| 强制 reindex | 26-38s |
|
||||
| 首建后保留堆 | ~2.5GB(原因不明) |
|
||||
|
||||
### 调查 1:二次构建为何仍有 8-21s
|
||||
|
||||
插桩 `fs.statSync` 后发现:**每次构建(含信任重建)都会执行约 11 万次同步 statSync**,
|
||||
全部来自 `resolveSource` 的 include 目标存在性检查(BAB 搜索路径逐 base `statSync`)。
|
||||
机械盘上这就是 10-20s 的来源——即使文件内容全部命中缓存,include 解析仍在逐路径 stat。
|
||||
|
||||
修复:新增 `IncludeResolveCache`(workspace 级、跨重建复用):
|
||||
- 键 = 当前文件目录 + source 字符串;命中直接返回,不 stat;
|
||||
- 内容编辑不影响"文件是否存在",因此保存触发的重建**不清除**解析缓存;
|
||||
- 文件创建/删除(watcher `onDidCreate` / `onDidDelete`)与 `ra3modxml.reindex`
|
||||
(强制校验)清除缓存;
|
||||
- `reference` 的 manifest 查找(`builtmods/*.manifest` 存在性)同样缓存;
|
||||
- 配置变更(搜索路径 / builtmods 目录)时清除。
|
||||
|
||||
### 调查 2:首建后保留堆 2.5GB 是什么
|
||||
|
||||
逐级清空测量(构建 → 清 DOM 缓存 → 清 records → 清 walker → 清索引 Map):
|
||||
|
||||
- `DocumentCache`(元素预算 1M、64 条):实际只保留 64 条 / 1788 个元素 ≈ 1MB;
|
||||
- `IndexRecordsCache`(8976 个文件的紧凑记录):约 11MB;
|
||||
- 目录 walker:约 4MB;
|
||||
- 全部索引 Map(manifests + assets + assetsById + defines + files + streams +
|
||||
candidates + diagnostics):约 75MB;
|
||||
- 全部清空并强制 GC 后,堆回到基线(0MB 保留)。
|
||||
|
||||
结论:2.5GB 是**构建期可回收垃圾**(60MB XML 的 DOM 瞬态 + 2.6GB w3x 扫描文本/行映射
|
||||
的分配压力),不是常驻泄漏;VSCode 正常 GC 压力下会回收。扩展常驻内存约为
|
||||
基线 + ~100MB。顺带修复了一个潜在常驻风险:w3x 的 `LineMap`(Corona 全量约 700MB)
|
||||
不再随 `ShallowScanCache` 保留,浅扫描结果以"带行号的紧凑记录"形式缓存。
|
||||
|
||||
### 其他改动
|
||||
|
||||
1. **`IndexRecordsCache` 取代 DOM 依赖的重建**:新增 `src/indexer/records.ts`,
|
||||
每个文件解析时提取紧凑索引记录(顶层资产 / Define / Include / xi:include +
|
||||
1-based 行号),跨重建缓存;信任重建完全不接触 DOM。
|
||||
2. **`DocumentCache` 双重淘汰**:条数(LRU)+ 元素预算(超预算先淘汰最大树),
|
||||
把 DOM 常驻内存封顶;DOM 只服务于按需特性(跳转精确范围等)。
|
||||
3. **候选目录扫描并行化**(`collectSourceCandidates` 各目录 `Promise.all`)。
|
||||
4. **阶段计时**:`stats.candidatesMs` / `stats.walkMs` / `stats.resolveCalls` /
|
||||
`stats.resolveCacheHits` 进入索引报告,便于后续定位耗时。
|
||||
5. 版本 0.1.0 → **0.1.1**。
|
||||
|
||||
### 实测(Corona,D: 机械盘)
|
||||
|
||||
| 场景 | 优化前 | 优化后 |
|
||||
|---|---|---|
|
||||
| 首次全量建索引 | ~180-290s | ~250s(含 2.6GB w3x 读取+浅扫描、~7.6 万次冷 statSync) |
|
||||
| 信任二次构建 | 8-21s | **2.0s**(statSync 0 次,resolveHits 15,333) |
|
||||
| 强制 reindex | 26-38s | ~5s(保留解析缓存时);显式命令会清解析缓存,约 15-25s |
|
||||
| 首建后保留堆 | ~2.5GB(疑为泄漏) | 确认是可回收垃圾;常驻 ~100MB |
|
||||
|
||||
单元测试 **63 → 73 全绿**:新增 `records.test.mjs`(DOM→记录提取、浅扫描→记录)、
|
||||
`caches.test.mjs`(元素预算淘汰、LRU、records/resolve 缓存),indexer 测试补充
|
||||
resolve 缓存命中与"信任重建 0 次重解析"断言。
|
||||
|
||||
### 遗留
|
||||
|
||||
- 首次全量建索引仍受限于机械盘读 2.6GB + 冷 statSync(~4 分钟);后续可考虑
|
||||
并行浅扫描(worker)或把 w3x 顶层记录持久化到磁盘缓存。
|
||||
- `ra3modxml.reindex` 出于正确性会清空解析缓存(可能 ~15-25s);如接受 watcher
|
||||
可靠性可改为保留。
|
||||
|
||||
+50
-8
@@ -85,7 +85,11 @@ src/
|
||||
manifestParser.ts .manifest 二进制解析 + 类型/ID 推导(纯 TS)
|
||||
fileScanner.ts 目录遍历缓存 + Include source 候选收集
|
||||
refs.ts 引用目标解析(按 refType / isRef / inheritFrom 过滤,纯 TS)
|
||||
indexer.ts 工作区索引器(资产/Define/流/manifest 合并,LRU 解析缓存)
|
||||
shallowScan.ts 大体积美术资产(.w3x 等)顶层浅扫描(纯 TS,不建 DOM)
|
||||
records.ts 每文件紧凑索引记录(资产/Define/Include/xi + 行号)
|
||||
caches.ts 跨重建持久缓存(DocumentCache / IndexRecordsCache /
|
||||
IncludeResolveCache)
|
||||
indexer.ts 工作区索引器(资产/Define/流/manifest/w3x 合并)
|
||||
types.ts 共享类型
|
||||
features/
|
||||
completion.ts 补全 provider(元素/属性/值,上下文感知;xs:list 多值按当前段过滤)
|
||||
@@ -109,6 +113,11 @@ test/
|
||||
|
||||
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。
|
||||
**美术资产(.w3x)**:`<Include type="all">` / `ART:` 指向的 `.w3x`(及内容嗅探为
|
||||
XML 的未知扩展名文件)按其顶层资产入库(`W3DContainer` / `W3DMesh` /
|
||||
`W3DHierarchy` / `W3DCollisionBox` 等),使 `Model@Name`、`Hierarchy`、`Mesh`
|
||||
等引用可解析;大模型文件**浅扫描**(不建 DOM),结果缓存在 workspace 级、
|
||||
跨重建复用(详见设计决策 14)。
|
||||
3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer` ↔ `W3DContainer`),类型匹配严格遵循 XSD 继承链。
|
||||
4. **上下文感知元素类型**:同名元素按父元素类型解析(`resolveElementType` 沿解析树逐层 `childTypeOf`,失败回退全局映射),保证 `<Weapon>` 等元素的属性/引用判定正确。
|
||||
5. **引用判定与解析**:`refType` 或 `isRef` 均视为引用;带 `refType` 时严格按类型过滤(同名 ID 不串类型);`inheritFrom` 按可继承类型过滤。**局部作用域例外**(`isLocalReferenceAttribute`):`id` 是元素自身的定义点——无 refType 或 refType 与自身类型兼容时不检查、不解析(`RoadObject@id→Road` 这类跨类型 id 引用保留检查);Poid 类型属性是管线局部引用,全局索引无法判定,不检查、不解析。
|
||||
@@ -128,6 +137,33 @@ test/
|
||||
XML 编辑器皆然);扩展注册 `DocumentSemanticTokensProvider`,仅当解析报错时用
|
||||
语义 token 覆盖标签名 / 属性名 / 属性值(标准 token 类型 `type` / `property` /
|
||||
`string`,主题自带配色)。合法文件返回空,观感与纯 TextMate 完全一致。
|
||||
14. **大体积美术资产浅扫描 + 跨重建持久缓存**(第八轮,2026-08-02):
|
||||
- 实测 Corona:3788 个 w3x / 2.64 GB,163 个超过 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 不是;优化的是
|
||||
"不分配子节点对象"与"跨重建不重读",两者叠加后方案可行。
|
||||
15. **重建零 stat + 记录驱动索引**(第九轮,2026-08-02,v0.1.1):
|
||||
- 插桩发现每次重建(含信任重建)都有约 11 万次同步 `statSync`
|
||||
(`resolveSource` 的 include 存在性检查),机械盘上占 10-20s;
|
||||
`IncludeResolveCache` 按(目录 + source)缓存解析结果,内容编辑不清、
|
||||
创建/删除文件与强制 reindex 才清。信任重建 statSync 降到 0。
|
||||
- `IndexRecordsCache` 缓存每文件紧凑索引记录(顶层资产 / Define / Include /
|
||||
xi:include + 1-based 行号),信任重建完全不接触 DOM;`DocumentCache`
|
||||
双重淘汰(条数 LRU + 元素预算,超预算先淘汰最大树)把 DOM 常驻内存封顶。
|
||||
- w3x 缓存不再保留 LineMap(Corona 全量约 700MB),浅扫描直接产出带行号的记录。
|
||||
- 实测:Corona 信任二次构建 21s → **2.0s**(statSync 0);首建后 2.5GB
|
||||
堆保留确认为构建期可回收垃圾,常驻 ~100MB;强制 reindex ~5-25s。
|
||||
- 候选目录扫描并行化;`stats` 新增 `candidatesMs` / `walkMs` / `resolveCalls` /
|
||||
`resolveCacheHits` 供索引报告定位耗时。
|
||||
|
||||
## 三、实施步骤
|
||||
|
||||
@@ -136,22 +172,28 @@ test/
|
||||
3. [x] 生成模型:`schema-model.json`(XSD,295 顶层元素 / 1851 类型)与 `asset-types.json`(79 个 AssetType 哈希)。
|
||||
4. [x] 纯 TS 核心:include 解析、manifest 解析、XML 解析封装、索引器、引用解析。
|
||||
5. [x] 功能层:补全、hover、导航、诊断、大纲、高亮 grammar。
|
||||
6. [x] 单测(fixture Mod,53 个用例全绿:含 xs:list 枚举、未闭合引号恢复、list 多值
|
||||
6. [x] 单测(fixture Mod,73 个用例全绿:含 xs:list 枚举、未闭合引号恢复、list 多值
|
||||
分段、带 vscode stub 的补全集成、语义 token 兜底)+ 编译 + `vsce package` 打包
|
||||
(ra3-mod-xml-0.1.0.vsix,约 495KB)。
|
||||
7. [x] 在 AttachTest / GenEvoTest / Corona 上做冒烟验证,并按真实项目反馈修复问题(详见 `docs/analysis-issues.md` 六轮分析)。
|
||||
(ra3-mod-xml-0.1.1.vsix,约 499KB)。
|
||||
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 首次/二次构建实测验证。
|
||||
9. [x] Corona 性能与内存优化(第九轮,v0.1.1):`records.ts` 记录驱动索引、
|
||||
`IncludeResolveCache` 零 stat 重建、DOM 元素预算淘汰、w3x LineMap 移除、
|
||||
候选并行扫描、阶段计时;Corona 信任重建 2.0s;确认首建 2.5GB 为可回收垃圾。
|
||||
|
||||
## 四、验证结果(实测)
|
||||
|
||||
| 项目 | 规模 | 索引耗时 | 资产数 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| AttachTest | 71 文件 | ~0.6s | 35,546(manifest 35,322) | 2 个流(static + mapmetadata) |
|
||||
| GenEvoTest | 24 文件 | ~1.8s | 35,392(manifest 35,322) | 项目 ID(alliedmcv 等)正确收录 |
|
||||
| Corona | 3,448 文件(+ 非 XML 资产路径) | ~54.5s | 55,305(manifest 35,322) | 3 个流、183 个 Define、0 诊断 |
|
||||
| AttachTest | 88 文件 | ~1.2s | 35,607(manifest 35,322,w3x 浅扫 62) | 2 个流(static + mapmetadata);二次构建 354ms / 62 缓存命中 |
|
||||
| GenEvoTest | 66 文件 | 首次 ~2.6s / 二次 ~0.4s | 35,502(manifest 35,322,w3x 浅扫 38) | 2 个流、73 个 Define;二次构建 0 重扫 / 38 缓存命中 |
|
||||
| Corona | 8,976 文件 | 首次 ~250s / 信任二次 ~2s / 强制 ~5-25s | 64,868(manifest 35,322,w3x 浅扫 4,829) | 3 个流、183 个 Define、0 诊断;二次构建 statSync 0、resolveHits 15,333 |
|
||||
|
||||
单元测试覆盖: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 已在第八 / 九轮按上述新数据回归。
|
||||
|
||||
## 五、假设与开放问题
|
||||
|
||||
|
||||
@@ -70,6 +70,15 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
|
||||
- `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);索引记录与 include 解析结果同样跨重建缓存,
|
||||
保存触发的重建零 stat、零重读(Corona 实测约 2 秒)。
|
||||
|
||||
### P1:非近期目标(本期不做,但预留扩展点)
|
||||
|
||||
6. **高效搜索**:Mod 项目巨大(Corona 约 7500 个 XML、38MB)时直接全文搜索很慢,需要一个高效的 XML 内容索引机制。
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ra3-mod-xml",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ra3-mod-xml",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "^4.5.0"
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"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.0",
|
||||
"version": "0.1.1",
|
||||
"publisher": "ra3-mod-xml",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+9
-3
@@ -109,18 +109,24 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidSaveTextDocument((doc) => {
|
||||
if (doc.languageId !== "xml") return;
|
||||
ws.invalidate(doc.uri.fsPath);
|
||||
ws.scheduleRebuild();
|
||||
void diagnostics.update(doc);
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidChangeConfiguration((e) => {
|
||||
if (e.affectsConfiguration("ra3modxml")) ws.scheduleRebuild();
|
||||
if (e.affectsConfiguration("ra3modxml")) {
|
||||
// Search paths / builtmods locations may have changed: cached include
|
||||
// resolutions and manifest lookups are no longer valid.
|
||||
ws.invalidateExistence();
|
||||
ws.scheduleRebuild();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("ra3modxml.reindex", () => ws.rebuild()),
|
||||
vscode.commands.registerCommand("ra3modxml.reindex", () => ws.rebuild(true)),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("ra3modxml.openIndexReport", () => {
|
||||
@@ -135,7 +141,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
void vscode.window.showInformationMessage(
|
||||
`RA3 Mod XML index\n` +
|
||||
`Project: ${s.projectDir}\n` +
|
||||
`Files: ${s.indexedFiles} (${s.parsedFiles} parsed)\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` +
|
||||
`Defines: ${s.defineCount} · Streams: ${s.streams} · Candidates: ${s.sourceCandidates}\n` +
|
||||
`Indexed in ${(s.elapsedMs / 1000).toFixed(1)}s`,
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 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 { IndexedFile, ParsedFile } from "./types";
|
||||
import type { IndexRecords } from "./records";
|
||||
import type { ResolveResult } from "./includeResolver";
|
||||
|
||||
/** 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 (~17x the source text), so
|
||||
* retention is bounded twice: by entry count (least-recently-used eviction)
|
||||
* and by a total element budget (largest trees are evicted first). Evicted
|
||||
* entries are re-read from disk on demand.
|
||||
*/
|
||||
export class DocumentCache {
|
||||
private map = new Map<string, ParsedFile>();
|
||||
private totalElements = 0;
|
||||
|
||||
constructor(
|
||||
private capacity = 64,
|
||||
private elementBudget = 2_000_000,
|
||||
) {}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Number of cached documents (for diagnostics). */
|
||||
get size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
|
||||
/** Total elements held by cached parse trees (for diagnostics). */
|
||||
get elements(): number {
|
||||
return this.totalElements;
|
||||
}
|
||||
|
||||
set(parsed: ParsedFile): void {
|
||||
const key = normKey(parsed.file.path);
|
||||
const prev = this.map.get(key);
|
||||
if (prev) this.totalElements -= elementCount(prev);
|
||||
this.map.delete(key);
|
||||
this.map.set(key, parsed);
|
||||
this.totalElements += elementCount(parsed);
|
||||
this.evict();
|
||||
}
|
||||
|
||||
invalidate(path: string): void {
|
||||
const key = normKey(path);
|
||||
const prev = this.map.get(key);
|
||||
if (prev) this.totalElements -= elementCount(prev);
|
||||
this.map.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
this.totalElements = 0;
|
||||
}
|
||||
|
||||
private evict(): void {
|
||||
while (this.map.size > this.capacity || this.totalElements > this.elementBudget) {
|
||||
if (this.map.size === 0) break;
|
||||
if (this.map.size > this.capacity) {
|
||||
// Over capacity: drop the least recently used entry.
|
||||
const oldest = this.map.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.remove(oldest);
|
||||
} else {
|
||||
// Over the element budget: drop the largest tree, which frees the
|
||||
// most memory per eviction.
|
||||
let largestKey: string | undefined;
|
||||
let largest = -1;
|
||||
for (const [key, value] of this.map) {
|
||||
const n = elementCount(value);
|
||||
if (n > largest) {
|
||||
largest = n;
|
||||
largestKey = key;
|
||||
}
|
||||
}
|
||||
if (largestKey === undefined || largest <= 0) break;
|
||||
this.remove(largestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private remove(key: string): void {
|
||||
const prev = this.map.get(key);
|
||||
if (prev) this.totalElements -= elementCount(prev);
|
||||
this.map.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function elementCount(parsed: ParsedFile): number {
|
||||
return parsed.parse?.elements.length ?? 0;
|
||||
}
|
||||
|
||||
export interface IndexRecordsCacheEntry {
|
||||
stat: IndexedFile["stat"];
|
||||
records: IndexRecords;
|
||||
/** "shallow" for art-asset scans (.w3x), "full" for parsed XML. */
|
||||
kind: "shallow" | "full";
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for per-file index records (top-level assets, defines, includes,
|
||||
* xi:include targets). Records are tiny compared to DOM trees or line maps of
|
||||
* multi-megabyte model files, so the capacity comfortably covers a whole mod
|
||||
* (Corona: ~9k files) and rebuilds never re-read unchanged files.
|
||||
*/
|
||||
export class IndexRecordsCache {
|
||||
private map = new Map<string, IndexRecordsCacheEntry>();
|
||||
|
||||
constructor(private capacity = 16384) {}
|
||||
|
||||
get(path: string): IndexRecordsCacheEntry | 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;
|
||||
}
|
||||
|
||||
/** Number of cached record sets (for diagnostics). */
|
||||
get size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
|
||||
set(path: string, entry: IndexRecordsCacheEntry): void {
|
||||
const key = normKey(path);
|
||||
this.map.delete(key);
|
||||
this.map.set(key, entry);
|
||||
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 Include/@source (and xi:include/@href) resolution results.
|
||||
*
|
||||
* Resolving a source performs synchronous statSync existence checks against
|
||||
* every search base; a Corona build issues ~110k of them (tens of seconds on
|
||||
* a mechanical drive). Content edits never change *existence*, so this cache
|
||||
* survives content rebuilds and is only cleared when files are created or
|
||||
* deleted (or on a forced reindex).
|
||||
*/
|
||||
export class IncludeResolveCache {
|
||||
private map = new Map<string, ResolveResult>();
|
||||
private manifestMap = new Map<string, string | null>();
|
||||
|
||||
constructor(private capacity = 262144) {}
|
||||
|
||||
get(key: string): ResolveResult | undefined {
|
||||
const hit = this.map.get(key);
|
||||
if (!hit) return undefined;
|
||||
this.map.delete(key);
|
||||
this.map.set(key, hit);
|
||||
return hit;
|
||||
}
|
||||
|
||||
set(key: string, result: ResolveResult): void {
|
||||
this.map.delete(key);
|
||||
this.map.set(key, result);
|
||||
if (this.map.size > this.capacity) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
if (oldest !== undefined) this.map.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
getManifest(key: string): string | null | undefined {
|
||||
const hit = this.manifestMap.get(key);
|
||||
if (hit === undefined) return undefined;
|
||||
this.manifestMap.delete(key);
|
||||
this.manifestMap.set(key, hit);
|
||||
return hit;
|
||||
}
|
||||
|
||||
setManifest(key: string, path: string | null): void {
|
||||
this.manifestMap.delete(key);
|
||||
this.manifestMap.set(key, path);
|
||||
if (this.manifestMap.size > this.capacity) {
|
||||
const oldest = this.manifestMap.keys().next().value;
|
||||
if (oldest !== undefined) this.manifestMap.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
this.manifestMap.clear();
|
||||
}
|
||||
|
||||
/** Number of cached resolutions (for diagnostics). */
|
||||
get size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
}
|
||||
+27
-13
@@ -1,7 +1,18 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* enumerate candidate files for Include/@source completion. Directory scans
|
||||
@@ -76,29 +87,32 @@ export async function collectSourceCandidates(
|
||||
out.push({ source, path: resolve(path), prefix, baseDir: resolve(baseDir) });
|
||||
};
|
||||
|
||||
for (const dir of dataDirs) {
|
||||
const files = await walker.listFiles(dir);
|
||||
// List directories in parallel; the walker caches each directory by its
|
||||
// mtime, so rebuilds after the first are still cheap.
|
||||
const [dataLists, artLists, audioLists] = await Promise.all([
|
||||
Promise.all(dataDirs.map(async (dir) => ({ dir, files: await walker.listFiles(dir) }))),
|
||||
Promise.all(artDirs.map(async (dir) => ({ dir, files: await walker.listFiles(dir) }))),
|
||||
Promise.all(audioDirs.map(async (dir) => ({ dir, files: await walker.listFiles(dir) }))),
|
||||
]);
|
||||
|
||||
for (const { dir, files } of dataLists) {
|
||||
for (const f of files) {
|
||||
if (!f.toLowerCase().endsWith(".xml")) continue;
|
||||
if (!isDataCandidate(f)) continue;
|
||||
add(f, dir, "DATA");
|
||||
}
|
||||
if (samePath(dir, projectDataDir)) {
|
||||
// Also offer project-relative paths (what Mod.xml itself uses).
|
||||
for (const f of files) {
|
||||
if (f.toLowerCase().endsWith(".xml")) add(f, projectDataDir, null);
|
||||
if (isDataCandidate(f)) add(f, projectDataDir, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of artDirs) {
|
||||
for (const f of await walker.listFiles(dir)) {
|
||||
add(f, dir, "ART");
|
||||
}
|
||||
for (const { dir, files } of artLists) {
|
||||
for (const f of files) add(f, dir, "ART");
|
||||
}
|
||||
for (const dir of audioDirs) {
|
||||
for (const f of await walker.listFiles(dir)) {
|
||||
add(f, dir, "AUDIO");
|
||||
}
|
||||
for (const { dir, files } of audioLists) {
|
||||
for (const f of files) add(f, dir, "AUDIO");
|
||||
}
|
||||
|
||||
return out;
|
||||
|
||||
+391
-187
@@ -11,13 +11,21 @@
|
||||
* outside the extension.
|
||||
*/
|
||||
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import { open, readFile, readdir, stat } from "node:fs/promises";
|
||||
import type { Stats } from "node:fs";
|
||||
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 {
|
||||
buildSearchPaths,
|
||||
manifestPathForReference,
|
||||
resolveSource,
|
||||
type ResolveResult,
|
||||
type SearchPaths,
|
||||
} from "./includeResolver";
|
||||
import {
|
||||
@@ -28,6 +36,14 @@ import {
|
||||
} from "./manifestParser";
|
||||
import { canonicalTypeName } from "../model/schemaModel";
|
||||
import { collectSourceCandidates } from "./fileScanner";
|
||||
import { DocumentCache, IncludeResolveCache, IndexRecordsCache, normKey } from "./caches";
|
||||
import type { IndexRecordsCacheEntry } from "./caches";
|
||||
import { scanXmlShallow } from "./shallowScan";
|
||||
import {
|
||||
extractIndexRecords,
|
||||
recordsFromShallow,
|
||||
type IndexRecordXi,
|
||||
} from "./records";
|
||||
import type {
|
||||
AssetDef,
|
||||
DefineDef,
|
||||
@@ -42,54 +58,35 @@ import type {
|
||||
const MAX_DEPTH = 300;
|
||||
/** Files above this size are never parsed (safety against binary blobs). */
|
||||
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
|
||||
* memory-heavy, so only a bounded number of recent documents is retained;
|
||||
* evicted entries are re-read from disk on demand.
|
||||
* Fully parsed XML documents. `.xml` / `.manifestxml` files are small enough
|
||||
* that a full DOM is affordable.
|
||||
*/
|
||||
export class DocumentCache {
|
||||
private map = new Map<string, ParsedFile>();
|
||||
const FULL_XML_EXTENSIONS = new Set([".xml", ".manifestxml"]);
|
||||
/**
|
||||
* 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) {}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
type XmlMode = "full" | "shallow" | "binary";
|
||||
|
||||
export class ModIndexer {
|
||||
private searchPaths: SearchPaths;
|
||||
private docs = new DocumentCache();
|
||||
private docs: DocumentCache;
|
||||
private recordsCache: IndexRecordsCache;
|
||||
private resolveCache: IncludeResolveCache;
|
||||
private scanCounters = {
|
||||
shallowScannedFiles: 0,
|
||||
shallowCacheHits: 0,
|
||||
recordsCacheHits: 0,
|
||||
resolveCacheHits: 0,
|
||||
resolveCalls: 0,
|
||||
};
|
||||
private phase = { candidatesMs: 0, walkMs: 0 };
|
||||
private assets = new Map<string, Map<string, AssetDef[]>>();
|
||||
private assetsById = new Map<string, AssetDef[]>();
|
||||
private defines = new Map<string, DefineDef[]>();
|
||||
@@ -106,42 +103,221 @@ export class ModIndexer {
|
||||
this.searchPaths = buildSearchPaths(opts.sdkDir, opts.projectDir, {
|
||||
DATA: opts.additionalDataSearchPaths,
|
||||
});
|
||||
// Caches may be owned by the workspace so they survive rebuilds.
|
||||
this.docs = opts.documentCache ?? new DocumentCache();
|
||||
this.recordsCache = opts.recordsCache ?? new IndexRecordsCache();
|
||||
this.resolveCache = opts.resolveCache ?? new IncludeResolveCache();
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
const hit = this.docs.get(path);
|
||||
if (hit) return hit;
|
||||
const key = normKey(path);
|
||||
const trust =
|
||||
this.opts.trustUnchanged === true && !this.opts.changedFiles?.has(key);
|
||||
|
||||
// Trusted fast path: a file that the watcher has not reported as changed
|
||||
// is reused without any stat / content sniff / read at all. This is what
|
||||
// makes save-triggered rebuilds cheap on huge corpora (Corona: ~9k files,
|
||||
// ~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);
|
||||
const cached = this.docs.get(key);
|
||||
if (cached) {
|
||||
this.files.set(key, cached.file);
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Untrusted / cache miss: verify the stat against caches, then read.
|
||||
try {
|
||||
const [st, text] = await Promise.all([stat(path), readFile(path, "utf8")]);
|
||||
if (st.size > MAX_PARSE_BYTES) {
|
||||
const st = await stat(path);
|
||||
const rec = this.recordsCache.get(key);
|
||||
if (rec?.stat && rec.stat.mtimeMs === st.mtimeMs && rec.stat.size === st.size) {
|
||||
return this.recordsParsed(path, rec);
|
||||
}
|
||||
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;
|
||||
}
|
||||
const mode = await this.detectXmlMode(path);
|
||||
if (mode === "shallow") return this.scanShallow(path, st);
|
||||
if (mode === "binary") {
|
||||
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, records: null, lineMap: null };
|
||||
this.docs.set(parsed);
|
||||
this.files.set(normKey(parsed.file.path), file);
|
||||
this.files.set(key, file);
|
||||
return parsed;
|
||||
}
|
||||
if (st.size > MAX_PARSE_BYTES) {
|
||||
const file: IndexedFile = { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } };
|
||||
const parsed: ParsedFile = { file, parse: null, records: null, lineMap: null };
|
||||
this.docs.set(parsed);
|
||||
this.files.set(key, file);
|
||||
return parsed;
|
||||
}
|
||||
const text = stripBom(await readFile(path, "utf8"));
|
||||
const lineMap = new LineMap(text);
|
||||
const parse = parseXml(text);
|
||||
const records = extractIndexRecords(parse, lineMap);
|
||||
const parsed: ParsedFile = {
|
||||
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
|
||||
parse,
|
||||
lineMap: new LineMap(text),
|
||||
records,
|
||||
lineMap,
|
||||
};
|
||||
this.docs.set(parsed);
|
||||
this.files.set(normKey(parsed.file.path), parsed.file);
|
||||
this.recordsCache.set(key, { stat: parsed.file.stat, records, kind: "full" });
|
||||
this.files.set(key, parsed.file);
|
||||
return parsed;
|
||||
} catch {
|
||||
const parsed: ParsedFile = {
|
||||
file: { path: resolve(path), stat: null },
|
||||
parse: null,
|
||||
records: null,
|
||||
lineMap: null,
|
||||
};
|
||||
this.docs.set(parsed);
|
||||
this.files.set(normKey(parsed.file.path), parsed.file);
|
||||
this.files.set(key, parsed.file);
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow-scans a large art-asset XML document (no DOM built) and caches
|
||||
* its compact index records. The transient LineMap used to compute record
|
||||
* lines is discarded, so the cache never retains megabytes of line offsets
|
||||
* for model files (Corona w3x alone would otherwise keep ~700 MB).
|
||||
*/
|
||||
private async scanShallow(path: string, st: Stats): Promise<ParsedFile | null> {
|
||||
const key = normKey(path);
|
||||
try {
|
||||
const text = stripBom(await readFile(path, "utf8"));
|
||||
const lineMap = new LineMap(text);
|
||||
const records = recordsFromShallow(scanXmlShallow(text), lineMap);
|
||||
const parsed: ParsedFile = {
|
||||
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
|
||||
parse: null,
|
||||
records,
|
||||
lineMap: null,
|
||||
};
|
||||
this.recordsCache.set(key, { stat: parsed.file.stat, records, kind: "shallow" });
|
||||
this.files.set(key, parsed.file);
|
||||
this.scanCounters.shallowScannedFiles++;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a lean ParsedFile from cached index records (no DOM / line map). */
|
||||
private recordsParsed(path: string, entry: IndexRecordsCacheEntry): ParsedFile {
|
||||
if (entry.kind === "shallow") this.scanCounters.shallowCacheHits++;
|
||||
else this.scanCounters.recordsCacheHits++;
|
||||
const file: IndexedFile = { path: resolve(path), stat: entry.stat };
|
||||
this.files.set(normKey(path), file);
|
||||
return { file, parse: null, records: entry.records, lineMap: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a document and guarantees a DOM parse tree. Used only for
|
||||
* root-level <xi:include> xpointer selection (rare), where the target's
|
||||
* container children are needed.
|
||||
*/
|
||||
private async readDom(path: string): Promise<ParsedFile | null> {
|
||||
const key = normKey(path);
|
||||
const cached = this.docs.get(key);
|
||||
if (cached?.parse?.root) {
|
||||
this.files.set(key, cached.file);
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const st = await stat(path);
|
||||
const hit = this.docs.get(key);
|
||||
if (
|
||||
hit?.parse?.root &&
|
||||
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) return null;
|
||||
const text = stripBom(await readFile(path, "utf8"));
|
||||
const lineMap = new LineMap(text);
|
||||
const parse = parseXml(text);
|
||||
const records = extractIndexRecords(parse, lineMap);
|
||||
const parsed: ParsedFile = {
|
||||
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
|
||||
parse,
|
||||
records,
|
||||
lineMap,
|
||||
};
|
||||
this.docs.set(parsed);
|
||||
this.recordsCache.set(key, { stat: parsed.file.stat, records, kind: "full" });
|
||||
this.files.set(key, parsed.file);
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an include source through the cross-rebuild cache. Existence
|
||||
* does not change on content edits, so trusted rebuilds pay zero statSync
|
||||
* for include resolution (Corona does ~110k checks per build otherwise).
|
||||
*/
|
||||
private resolveCached(source: string, currentDir: string | null): ResolveResult {
|
||||
const key = `${normKey(currentDir ?? "")}\u0000${source}`;
|
||||
const hit = this.resolveCache.get(key);
|
||||
if (hit) {
|
||||
this.scanCounters.resolveCacheHits++;
|
||||
return hit;
|
||||
}
|
||||
this.scanCounters.resolveCalls++;
|
||||
const result = resolveSource(source, currentDir, this.searchPaths);
|
||||
this.resolveCache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Cached manifest lookup for `reference` includes. */
|
||||
private manifestPathCached(source: string): string | null {
|
||||
const key = source.toLowerCase();
|
||||
const hit = this.resolveCache.getManifest(key);
|
||||
if (hit !== undefined) {
|
||||
this.scanCounters.resolveCacheHits++;
|
||||
return hit;
|
||||
}
|
||||
this.scanCounters.resolveCalls++;
|
||||
const path = manifestPathForReference(source, this.opts.builtmodsDirs);
|
||||
this.resolveCache.setManifest(key, path);
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Returns the cached parse if present (does not read from disk). */
|
||||
cachedDocument(path: string): ParsedFile | undefined {
|
||||
return this.docs.get(path);
|
||||
@@ -155,6 +331,7 @@ export class ModIndexer {
|
||||
: null;
|
||||
|
||||
// ── Streams ──
|
||||
const walkStart = Date.now();
|
||||
const staticEntry = projectData ? join(projectData, "Mod.xml") : null;
|
||||
if (staticEntry) {
|
||||
const stream: StreamInfo = { name: "static", entry: staticEntry, files: new Set() };
|
||||
@@ -183,8 +360,10 @@ export class ModIndexer {
|
||||
await this.walk(entry, "all", stream, 0);
|
||||
}
|
||||
}
|
||||
this.phase.walkMs = Date.now() - walkStart;
|
||||
|
||||
// ── Source completion candidates ──
|
||||
const candidatesStart = Date.now();
|
||||
const dataDirs = [
|
||||
projectData ?? join(this.opts.projectDir, "Data"),
|
||||
join(this.opts.sdkDir, "SageXml"),
|
||||
@@ -229,6 +408,7 @@ export class ModIndexer {
|
||||
...sdkRootCandidates,
|
||||
...this.sourceCandidates,
|
||||
]);
|
||||
this.phase.candidatesMs = Date.now() - candidatesStart;
|
||||
|
||||
const manifestAssetCount = [...this.manifests.values()].reduce(
|
||||
(sum, m) => sum + m.assets.length,
|
||||
@@ -253,6 +433,13 @@ export class ModIndexer {
|
||||
parsedFiles: [...this.files.values()].filter(
|
||||
(f) => f.stat != null && f.stat.size <= MAX_PARSE_BYTES,
|
||||
).length,
|
||||
shallowScannedFiles: this.scanCounters.shallowScannedFiles,
|
||||
shallowCacheHits: this.scanCounters.shallowCacheHits,
|
||||
recordsCacheHits: this.scanCounters.recordsCacheHits,
|
||||
resolveCacheHits: this.scanCounters.resolveCacheHits,
|
||||
resolveCalls: this.scanCounters.resolveCalls,
|
||||
candidatesMs: this.phase.candidatesMs,
|
||||
walkMs: this.phase.walkMs,
|
||||
assetCount: [...this.assets.values()].reduce((sum, byId) => sum + byId.size, 0),
|
||||
defineCount: this.defines.size,
|
||||
manifestFiles: this.manifests.size,
|
||||
@@ -294,166 +481,160 @@ export class ModIndexer {
|
||||
|
||||
stream.files.add(key);
|
||||
|
||||
// Binary assets (w3x/dds/...) are referenced but never parsed.
|
||||
if (!isXmlPath(path)) return;
|
||||
|
||||
// readDocument returns compact index records for every indexable XML
|
||||
// document (full parse or shallow scan), or a bare file registration
|
||||
// for binary / unparseable targets.
|
||||
const parsed = await this.readDocument(path);
|
||||
if (!parsed?.parse?.root) return;
|
||||
const root = parsed.parse.root;
|
||||
if (!parsed) return;
|
||||
if (parsed.records) {
|
||||
await this.applyRecords(parsed, stream, depth, mode === "instance");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of root.children) {
|
||||
const local = localName(child.name);
|
||||
if (local === "Tags" || local === "Includes" || local === "Defines") continue;
|
||||
if (local === "include") {
|
||||
await this.handleXiInclude(child, parsed, stream, depth);
|
||||
continue;
|
||||
}
|
||||
const idAttr = child.attrs.find((a) => a.name === "id");
|
||||
if (idAttr) {
|
||||
this.addAsset({
|
||||
type: local,
|
||||
id: idAttr.value,
|
||||
file: parsed.file.path,
|
||||
line: lineOf(parsed, idAttr.valueStart),
|
||||
origin: this.originOf(parsed.file.path),
|
||||
stream: stream.name,
|
||||
viaInstance: mode === "instance",
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Applies a document's compact index records: top-level assets, defines,
|
||||
* <Includes>, nested <xi:include> and root-level <xi:include> targets.
|
||||
* Works identically for fully parsed XML and shallow-scanned art assets.
|
||||
*/
|
||||
private async applyRecords(
|
||||
parsed: ParsedFile,
|
||||
stream: StreamInfo,
|
||||
depth: number,
|
||||
viaInstance: boolean,
|
||||
): Promise<void> {
|
||||
const records = parsed.records;
|
||||
if (!records) return;
|
||||
const file = parsed.file.path;
|
||||
const origin = this.originOf(file);
|
||||
|
||||
for (const asset of records.assets) {
|
||||
this.addAsset({
|
||||
type: asset.type,
|
||||
id: asset.id,
|
||||
file,
|
||||
line: asset.line,
|
||||
origin,
|
||||
stream: stream.name,
|
||||
viaInstance,
|
||||
});
|
||||
}
|
||||
|
||||
for (const child of root.children) {
|
||||
if (localName(child.name) !== "Defines") continue;
|
||||
for (const define of child.children) {
|
||||
if (localName(define.name) !== "Define") continue;
|
||||
const name = define.attrs.find((a) => a.name === "name")?.value;
|
||||
const value = define.attrs.find((a) => a.name === "value")?.value;
|
||||
if (!name) continue;
|
||||
const entry: DefineDef = {
|
||||
name,
|
||||
value: value ?? "",
|
||||
file: parsed.file.path,
|
||||
line: lineOf(parsed, define.start),
|
||||
origin: this.originOf(parsed.file.path),
|
||||
};
|
||||
const arr = this.defines.get(name.toLowerCase());
|
||||
if (arr) arr.push(entry);
|
||||
else this.defines.set(name.toLowerCase(), [entry]);
|
||||
}
|
||||
for (const define of records.defines) {
|
||||
const entry: DefineDef = {
|
||||
name: define.name,
|
||||
value: define.value,
|
||||
file,
|
||||
line: define.line,
|
||||
origin,
|
||||
};
|
||||
const arr = this.defines.get(define.name.toLowerCase());
|
||||
if (arr) arr.push(entry);
|
||||
else this.defines.set(define.name.toLowerCase(), [entry]);
|
||||
}
|
||||
|
||||
const includesElem = root.children.find((c) => localName(c.name) === "Includes");
|
||||
if (includesElem) {
|
||||
for (const inc of includesElem.children) {
|
||||
if (localName(inc.name) !== "Include") continue;
|
||||
const type = inc.attrs.find((a) => a.name === "type")?.value;
|
||||
const source = inc.attrs.find((a) => a.name === "source")?.value;
|
||||
if (!source) continue;
|
||||
const resolved = resolveSource(source, dirname(parsed.file.path), this.searchPaths);
|
||||
if (!resolved.path) {
|
||||
this.diagnostics.push({
|
||||
file: parsed.file.path,
|
||||
line: lineOf(parsed, inc.start),
|
||||
message: `Include target not found: ${source}`,
|
||||
severity: "warning",
|
||||
code: "include-not-found",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (type === "all" || type === "instance") {
|
||||
await this.walk(resolved.path, type === "all" ? "all" : "instance", stream, depth + 1);
|
||||
} else if (type === "reference") {
|
||||
const manifestPath = manifestPathForReference(source, this.opts.builtmodsDirs);
|
||||
if (manifestPath) {
|
||||
const loaded = await this.loadManifest(manifestPath, stream.name);
|
||||
if (!loaded && isXmlPath(resolved.path)) {
|
||||
// The manifest could not be parsed (missing/invalid): fall back
|
||||
// to the placeholder XML so its content is still available.
|
||||
await this.walk(resolved.path, "instance", stream, depth + 1);
|
||||
}
|
||||
} else if (isXmlPath(resolved.path)) {
|
||||
// reference to a real XML file: treat its assets as available
|
||||
await this.walk(resolved.path, "instance", stream, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nested <xi:include> anywhere in the tree (not just under the root):
|
||||
// the target content is inlined into the parent element. We make the
|
||||
// target file available and surface missing targets instead of ignoring
|
||||
// them silently.
|
||||
for (const el of parsed.parse.elements) {
|
||||
if (localName(el.name) !== "include") continue;
|
||||
if (el.parent === root) continue; // already handled in the loop above
|
||||
const href = el.attrs.find((a) => a.name === "href")?.value;
|
||||
if (!href) continue;
|
||||
const resolved = resolveSource(href, dirname(parsed.file.path), this.searchPaths);
|
||||
for (const inc of records.includes) {
|
||||
const resolved = this.resolveCached(inc.source, dirname(file));
|
||||
if (!resolved.path) {
|
||||
this.diagnostics.push({
|
||||
file: parsed.file.path,
|
||||
line: lineOf(parsed, el.start),
|
||||
message: `xi:include target not found: ${href}`,
|
||||
file,
|
||||
line: inc.line,
|
||||
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 = this.manifestPathCached(inc.source);
|
||||
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 records.nestedXiIncludes) {
|
||||
const resolved = this.resolveCached(xi.href, dirname(file));
|
||||
if (!resolved.path) {
|
||||
this.diagnostics.push({
|
||||
file,
|
||||
line: xi.line,
|
||||
message: `xi:include target not found: ${xi.href}`,
|
||||
severity: "warning",
|
||||
code: "include-not-found",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
stream.files.add(normKey(resolved.path));
|
||||
if (isXmlPath(resolved.path)) {
|
||||
await this.walk(resolved.path, "all", stream, depth + 1);
|
||||
}
|
||||
await this.walk(resolved.path, "all", stream, depth + 1);
|
||||
}
|
||||
|
||||
for (const xi of records.rootXiIncludes) {
|
||||
await this.handleRootXiInclude(xi, file, stream, depth);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleXiInclude(
|
||||
xi: XmlElement,
|
||||
parent: ParsedFile,
|
||||
/**
|
||||
* Root-level <xi:include> with an xpointer selects a named container's
|
||||
* children in the target document, so the target's DOM is needed. These
|
||||
* targets are rare, so they are parsed on demand (the tree is also cached).
|
||||
*/
|
||||
private async handleRootXiInclude(
|
||||
xi: IndexRecordXi,
|
||||
parentFile: string,
|
||||
stream: StreamInfo,
|
||||
depth: number,
|
||||
): Promise<void> {
|
||||
const href = xi.attrs.find((a) => a.name === "href")?.value;
|
||||
if (!href) return;
|
||||
const resolved = resolveSource(href, dirname(parent.file.path), this.searchPaths);
|
||||
const resolved = this.resolveCached(xi.href, dirname(parentFile));
|
||||
if (!resolved.path) {
|
||||
this.diagnostics.push({
|
||||
file: parent.file.path,
|
||||
line: lineOf(parent, xi.start),
|
||||
message: `xi:include target not found: ${href}`,
|
||||
file: parentFile,
|
||||
line: xi.line,
|
||||
message: `xi:include target not found: ${xi.href}`,
|
||||
severity: "warning",
|
||||
code: "include-not-found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isXmlPath(resolved.path)) return;
|
||||
const target = await this.readDocument(resolved.path);
|
||||
if (!target?.parse?.root) return;
|
||||
|
||||
const xpointer = xi.attrs.find((a) => a.name === "xpointer")?.value ?? "";
|
||||
let candidates: XmlElement[];
|
||||
if (xpointer) {
|
||||
const container = findXPointerContainer(target.parse, xpointer);
|
||||
candidates = container ? container.children : [];
|
||||
} else {
|
||||
candidates = target.parse.root.children;
|
||||
}
|
||||
for (const el of candidates) {
|
||||
const local = localName(el.name);
|
||||
if (local === "Tags" || local === "Includes" || local === "Defines") continue;
|
||||
const idAttr = el.attrs.find((a) => a.name === "id");
|
||||
if (idAttr) {
|
||||
this.addAsset({
|
||||
type: local,
|
||||
id: idAttr.value,
|
||||
file: target.file.path,
|
||||
line: lineOf(target, idAttr.valueStart),
|
||||
origin: this.originOf(target.file.path),
|
||||
stream: stream.name,
|
||||
});
|
||||
const target = await this.readDom(resolved.path);
|
||||
if (target?.parse?.root) {
|
||||
const xpointer = xi.xpointer ?? "";
|
||||
let candidates: XmlElement[];
|
||||
if (xpointer) {
|
||||
const container = findXPointerContainer(target.parse, xpointer);
|
||||
candidates = container ? container.children : [];
|
||||
} else {
|
||||
candidates = target.parse.root.children;
|
||||
}
|
||||
for (const el of candidates) {
|
||||
const local = localName(el.name);
|
||||
if (local === "Tags" || local === "Includes" || local === "Defines") continue;
|
||||
const idAttr = el.attrs.find((a) => a.name === "id");
|
||||
if (idAttr) {
|
||||
this.addAsset({
|
||||
type: local,
|
||||
id: idAttr.value,
|
||||
file: target.file.path,
|
||||
line: lineOf(target, idAttr.valueStart),
|
||||
origin: this.originOf(target.file.path),
|
||||
stream: stream.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
stream.files.add(normKey(target.file.path));
|
||||
await this.walk(target.file.path, "all", stream, depth + 1);
|
||||
stream.files.add(normKey(resolved.path));
|
||||
await this.walk(resolved.path, "all", stream, depth + 1);
|
||||
}
|
||||
|
||||
// ── Manifest loading ──────────────────────────────────────────────
|
||||
@@ -551,9 +732,32 @@ function lineOf(parsed: ParsedFile, offset: number): number {
|
||||
return parsed.lineMap.positionAt(offset).line + 1;
|
||||
}
|
||||
|
||||
function isXmlPath(path: string): boolean {
|
||||
const ext = extname(path).toLowerCase();
|
||||
return XML_EXTENSIONS.has(ext);
|
||||
/**
|
||||
* Content sniffing for include targets with unknown extensions (e.g. art
|
||||
* 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> {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Compact per-file index records.
|
||||
*
|
||||
* A rebuild only needs each file's top-level assets, defines, includes and
|
||||
* xi:include targets — not the DOM. Extracting these records at parse time
|
||||
* and caching them across rebuilds lets Corona-scale rebuilds skip both the
|
||||
* DOM and the file I/O for unchanged files, while keeping the DOM cache small
|
||||
* for on-demand features (hover / navigation / outline).
|
||||
*
|
||||
* Pure TypeScript: no vscode dependency.
|
||||
*/
|
||||
|
||||
import type { LineMap, XmlDocument } from "../language/xmlParser";
|
||||
import type { ShallowDocument } from "./shallowScan";
|
||||
|
||||
export interface IndexRecordAsset {
|
||||
/** Top-level element name, e.g. "W3DContainer". */
|
||||
type: string;
|
||||
id: string;
|
||||
/** 1-based line of the id attribute value. */
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface IndexRecordDefine {
|
||||
name: string;
|
||||
value: string;
|
||||
/** 1-based line of the <Define> element. */
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface IndexRecordInclude {
|
||||
type: "all" | "instance" | "reference" | null;
|
||||
source: string;
|
||||
/** 1-based line of the <Include> element. */
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface IndexRecordXi {
|
||||
href: string;
|
||||
xpointer: string | null;
|
||||
/** 1-based line of the <xi:include> element. */
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface IndexRecords {
|
||||
assets: IndexRecordAsset[];
|
||||
defines: IndexRecordDefine[];
|
||||
includes: IndexRecordInclude[];
|
||||
/** <xi:include> elements that are direct children of the root. */
|
||||
rootXiIncludes: IndexRecordXi[];
|
||||
/** <xi:include> elements nested anywhere else in the document. */
|
||||
nestedXiIncludes: IndexRecordXi[];
|
||||
}
|
||||
|
||||
const INCLUDE_TYPES = new Set(["all", "instance", "reference"]);
|
||||
|
||||
function lineOf(lineMap: LineMap, offset: number): number {
|
||||
return lineMap.positionAt(offset).line + 1;
|
||||
}
|
||||
|
||||
function localName(tag: string): string {
|
||||
const idx = tag.lastIndexOf(":");
|
||||
return idx >= 0 ? tag.slice(idx + 1) : tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the index records of a fully parsed document. Mirrors the walk
|
||||
* semantics of the indexer exactly: top-level assets (excluding
|
||||
* Tags/Includes/Defines), $DEFINE constants, the top-level <Includes> block
|
||||
* and root/nested <xi:include> elements.
|
||||
*/
|
||||
export function extractIndexRecords(parse: XmlDocument, lineMap: LineMap): IndexRecords {
|
||||
const assets: IndexRecordAsset[] = [];
|
||||
const defines: IndexRecordDefine[] = [];
|
||||
const includes: IndexRecordInclude[] = [];
|
||||
const rootXiIncludes: IndexRecordXi[] = [];
|
||||
const nestedXiIncludes: IndexRecordXi[] = [];
|
||||
const root = parse.root;
|
||||
if (!root) return { assets, defines, includes, rootXiIncludes, nestedXiIncludes };
|
||||
|
||||
for (const child of root.children) {
|
||||
const local = localName(child.name);
|
||||
if (local === "Tags" || local === "Includes" || local === "Defines") continue;
|
||||
if (local === "include") {
|
||||
const href = child.attrs.find((a) => a.name === "href")?.value;
|
||||
if (href) {
|
||||
rootXiIncludes.push({
|
||||
href,
|
||||
xpointer: child.attrs.find((a) => a.name === "xpointer")?.value ?? null,
|
||||
line: lineOf(lineMap, child.start),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const idAttr = child.attrs.find((a) => a.name === "id");
|
||||
if (idAttr) {
|
||||
assets.push({ type: local, id: idAttr.value, line: lineOf(lineMap, idAttr.valueStart) });
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of root.children) {
|
||||
if (localName(child.name) !== "Defines") continue;
|
||||
for (const define of child.children) {
|
||||
if (localName(define.name) !== "Define") continue;
|
||||
const name = define.attrs.find((a) => a.name === "name")?.value;
|
||||
if (!name) continue;
|
||||
defines.push({
|
||||
name,
|
||||
value: define.attrs.find((a) => a.name === "value")?.value ?? "",
|
||||
line: lineOf(lineMap, define.start),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const includesElem = root.children.find((c) => localName(c.name) === "Includes");
|
||||
if (includesElem) {
|
||||
for (const inc of includesElem.children) {
|
||||
if (localName(inc.name) !== "Include") continue;
|
||||
const source = inc.attrs.find((a) => a.name === "source")?.value;
|
||||
if (!source) continue;
|
||||
const type = inc.attrs.find((a) => a.name === "type")?.value;
|
||||
includes.push({
|
||||
type:
|
||||
type && INCLUDE_TYPES.has(type)
|
||||
? (type as "all" | "instance" | "reference")
|
||||
: null,
|
||||
source,
|
||||
line: lineOf(lineMap, inc.start),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const el of parse.elements) {
|
||||
if (localName(el.name) !== "include") continue;
|
||||
if (el.parent === root) continue; // already handled above
|
||||
const href = el.attrs.find((a) => a.name === "href")?.value;
|
||||
if (!href) continue;
|
||||
nestedXiIncludes.push({
|
||||
href,
|
||||
xpointer: el.attrs.find((a) => a.name === "xpointer")?.value ?? null,
|
||||
line: lineOf(lineMap, el.start),
|
||||
});
|
||||
}
|
||||
|
||||
return { assets, defines, includes, rootXiIncludes, nestedXiIncludes };
|
||||
}
|
||||
|
||||
/** Converts a shallow scan (offsets) into index records (1-based lines). */
|
||||
export function recordsFromShallow(scan: ShallowDocument, lineMap: LineMap): IndexRecords {
|
||||
return {
|
||||
assets: scan.assets.map((a) => ({
|
||||
type: a.name,
|
||||
id: a.id,
|
||||
line: lineOf(lineMap, a.idValueStart),
|
||||
})),
|
||||
defines: scan.defines.map((d) => ({
|
||||
name: d.name,
|
||||
value: d.value,
|
||||
line: lineOf(lineMap, d.start),
|
||||
})),
|
||||
includes: scan.includes.map((i) => ({
|
||||
type: i.type,
|
||||
source: i.source,
|
||||
line: lineOf(lineMap, i.start),
|
||||
})),
|
||||
rootXiIncludes: scan.rootXiIncludes.map((x) => ({
|
||||
href: x.href,
|
||||
xpointer: x.xpointer,
|
||||
line: lineOf(lineMap, x.start),
|
||||
})),
|
||||
nestedXiIncludes: scan.nestedXiIncludes.map((x) => ({
|
||||
href: x.href,
|
||||
xpointer: x.xpointer,
|
||||
line: lineOf(lineMap, x.start),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* 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[];
|
||||
/** <xi:include> elements that are direct children of the root. */
|
||||
rootXiIncludes: ShallowXiIncludeRecord[];
|
||||
/** <xi:include> elements nested anywhere else in the document. */
|
||||
nestedXiIncludes: 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 rootXiIncludes: ShallowXiIncludeRecord[] = [];
|
||||
const nestedXiIncludes: 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) {
|
||||
const rec: ShallowXiIncludeRecord = {
|
||||
href: href.value,
|
||||
xpointer: xpointer?.value ?? null,
|
||||
start: lt,
|
||||
};
|
||||
if (depth === 1) rootXiIncludes.push(rec);
|
||||
else nestedXiIncludes.push(rec);
|
||||
}
|
||||
}
|
||||
if (!selfClosing) depth++;
|
||||
} else {
|
||||
depth--;
|
||||
}
|
||||
i = gt + 1;
|
||||
}
|
||||
|
||||
return { assets, includes, rootXiIncludes, nestedXiIncludes, 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;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { XmlDocument } from "../language/xmlParser";
|
||||
import type { ManifestInfo } from "./manifestParser";
|
||||
import type { LineMap } from "../language/xmlParser";
|
||||
import type { IndexRecords } from "./records";
|
||||
import type { DocumentCache, IncludeResolveCache, IndexRecordsCache } from "./caches";
|
||||
|
||||
export type AssetOrigin = "project" | "sdk" | "manifest";
|
||||
|
||||
@@ -65,6 +67,20 @@ export interface IndexStats {
|
||||
sdkDir: string;
|
||||
indexedFiles: 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;
|
||||
/** Parsed XML files served from the persistent records cache. */
|
||||
recordsCacheHits: number;
|
||||
/** Include/xi:include resolutions served from the resolve cache. */
|
||||
resolveCacheHits: number;
|
||||
/** Include/xi:include resolutions performed during this build. */
|
||||
resolveCalls: number;
|
||||
/** Time spent enumerating Include source candidates (ms). */
|
||||
candidatesMs: number;
|
||||
/** Time spent walking the include graph (ms). */
|
||||
walkMs: number;
|
||||
assetCount: number;
|
||||
defineCount: number;
|
||||
manifestFiles: number;
|
||||
@@ -102,6 +118,23 @@ export interface IndexOptions {
|
||||
additionalDataSearchPaths: string[];
|
||||
/** Directory walker used to enumerate files for source completion. */
|
||||
walker: FileWalker;
|
||||
/** Optional parse-tree cache shared across rebuilds (owned by the workspace). */
|
||||
documentCache?: DocumentCache;
|
||||
/** Optional index-records cache shared across rebuilds (owned by the workspace). */
|
||||
recordsCache?: IndexRecordsCache;
|
||||
/** Optional include-resolution cache shared across rebuilds (owned by the workspace). */
|
||||
resolveCache?: IncludeResolveCache;
|
||||
/**
|
||||
* When true, cached documents whose path is not in `changedFiles` are used
|
||||
* without a per-file stat. The workspace enables this while a file watcher
|
||||
* invalidates caches for changed paths; a forced reindex passes false.
|
||||
*/
|
||||
trustUnchanged?: boolean;
|
||||
/**
|
||||
* Normalized paths (see `normKey`) known to have changed since the caches
|
||||
* were populated. Only consulted when `trustUnchanged` is true.
|
||||
*/
|
||||
changedFiles?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface FileWalker {
|
||||
@@ -121,5 +154,10 @@ export interface ParseCache {
|
||||
export interface ParsedFile {
|
||||
file: IndexedFile;
|
||||
parse: XmlDocument | null;
|
||||
/**
|
||||
* Compact index records (assets/defines/includes/xi:include with lines).
|
||||
* Present for every indexable XML document; null for binary files.
|
||||
*/
|
||||
records: IndexRecords | null;
|
||||
lineMap: LineMap | null;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,14 @@ export interface Position {
|
||||
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. */
|
||||
export class LineMap {
|
||||
private lineStarts: number[] = [0];
|
||||
|
||||
+78
-2
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { CachedDirectoryWalker } from "./indexer/fileScanner";
|
||||
import { ModIndexer } from "./indexer/indexer";
|
||||
import { DocumentCache, IncludeResolveCache, IndexRecordsCache } from "./indexer/caches";
|
||||
import type { ModIndex } from "./indexer/types";
|
||||
import { readSettings, type ExtensionSettings } from "./settings";
|
||||
|
||||
@@ -15,12 +16,21 @@ export class ModWorkspace {
|
||||
settings: ExtensionSettings;
|
||||
|
||||
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 recordsCache = new IndexRecordsCache();
|
||||
private resolveCache = new IncludeResolveCache();
|
||||
private context: vscode.ExtensionContext;
|
||||
private watchers: vscode.FileSystemWatcher[] = [];
|
||||
private statusBar: vscode.StatusBarItem;
|
||||
private rebuildTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private building = false;
|
||||
private dirty = false;
|
||||
|
||||
constructor(context: vscode.ExtensionContext) {
|
||||
this.context = context;
|
||||
this.settings = readSettings();
|
||||
this.statusBar = vscode.window.createStatusBarItem(
|
||||
vscode.StatusBarAlignment.Left,
|
||||
@@ -51,11 +61,69 @@ export class ModWorkspace {
|
||||
this.statusBar.hide();
|
||||
return;
|
||||
}
|
||||
this.startWatching();
|
||||
this.statusBar.text = "$(sync~spin) RA3 XML: indexing…";
|
||||
this.statusBar.show();
|
||||
await this.rebuild();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates cached documents for a path (called by the file watcher and
|
||||
* on document save), so the next rebuild re-reads it instead of trusting
|
||||
* the cached copy.
|
||||
*/
|
||||
invalidate(path: string): void {
|
||||
if (!path) return;
|
||||
this.documentCache.invalidate(path);
|
||||
this.recordsCache.invalidate(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when files are created or deleted: include-resolution results
|
||||
* (which encode file existence) are no longer trustworthy.
|
||||
*/
|
||||
invalidateExistence(): void {
|
||||
this.resolveCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the project, SDK and extra DATA roots for file changes and
|
||||
* invalidates the corresponding cache entries. With caches invalidated
|
||||
* precisely, rebuilds can trust every other cached file and skip per-file
|
||||
* stats (huge win on mechanical drives; Corona rebuild dropped from ~38s
|
||||
* to a few seconds).
|
||||
*/
|
||||
private startWatching(): void {
|
||||
if (!this.projectRoot) return;
|
||||
const roots = new Set([
|
||||
this.projectRoot,
|
||||
this.settings.sdkPath,
|
||||
...this.settings.additionalDataSearchPaths,
|
||||
]);
|
||||
for (const root of roots) {
|
||||
if (!existsSync(root)) continue;
|
||||
try {
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(
|
||||
new vscode.RelativePattern(root, "**/*"),
|
||||
);
|
||||
watcher.onDidCreate((uri) => {
|
||||
this.invalidate(uri.fsPath);
|
||||
this.invalidateExistence();
|
||||
});
|
||||
watcher.onDidChange((uri) => this.invalidate(uri.fsPath));
|
||||
watcher.onDidDelete((uri) => {
|
||||
this.invalidate(uri.fsPath);
|
||||
this.invalidateExistence();
|
||||
});
|
||||
this.watchers.push(watcher);
|
||||
this.context.subscriptions.push(watcher);
|
||||
} catch {
|
||||
// The root may be temporarily unavailable (e.g. removable drive);
|
||||
// indexing still works, just without watcher-based invalidation.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scheduleRebuild(): void {
|
||||
if (!this.projectRoot) return;
|
||||
if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
|
||||
@@ -64,12 +132,13 @@ export class ModWorkspace {
|
||||
}, REBUILD_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async rebuild(): Promise<void> {
|
||||
async rebuild(force = false): Promise<void> {
|
||||
if (!this.projectRoot) return;
|
||||
if (this.building) {
|
||||
this.dirty = true;
|
||||
return;
|
||||
}
|
||||
if (force) this.resolveCache.clear();
|
||||
this.building = true;
|
||||
this.settings = readSettings();
|
||||
try {
|
||||
@@ -81,6 +150,12 @@ export class ModWorkspace {
|
||||
indexSageXml: this.settings.indexSageXml,
|
||||
additionalDataSearchPaths: this.settings.additionalDataSearchPaths,
|
||||
walker: this.walker,
|
||||
documentCache: this.documentCache,
|
||||
recordsCache: this.recordsCache,
|
||||
resolveCache: this.resolveCache,
|
||||
// Trust cache entries unless the user explicitly asked for a full
|
||||
// verification (ra3modxml.reindex).
|
||||
trustUnchanged: !force,
|
||||
});
|
||||
const started = Date.now();
|
||||
this.index = await indexer.build();
|
||||
@@ -90,7 +165,7 @@ export class ModWorkspace {
|
||||
this.statusBar.text = `$(symbol-misc) RA3 XML: ${formatCount(s.assetCount)} assets`;
|
||||
this.statusBar.tooltip =
|
||||
`${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.defineCount} defines, ${s.streams} streams, ${s.sourceCandidates} include candidates`;
|
||||
} catch (err) {
|
||||
@@ -113,6 +188,7 @@ export class ModWorkspace {
|
||||
return {
|
||||
file: { path, stat: null },
|
||||
parse,
|
||||
records: null,
|
||||
lineMap: new LineMap(text),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
DocumentCache,
|
||||
IncludeResolveCache,
|
||||
IndexRecordsCache,
|
||||
} from "../out/indexer/caches.js";
|
||||
|
||||
function parsed(path, elements) {
|
||||
return {
|
||||
file: { path, stat: { mtimeMs: 1, size: 1 } },
|
||||
parse: { root: { name: "r" }, elements: new Array(elements), errors: [] },
|
||||
records: null,
|
||||
lineMap: null,
|
||||
};
|
||||
}
|
||||
|
||||
test("DocumentCache evicts the largest tree when over the element budget", () => {
|
||||
const cache = new DocumentCache(64, 100);
|
||||
cache.set(parsed("a.xml", 60));
|
||||
cache.set(parsed("b.xml", 60));
|
||||
assert.equal(cache.get("a.xml"), undefined, "largest tree evicted first");
|
||||
assert.ok(cache.get("b.xml"));
|
||||
});
|
||||
|
||||
test("DocumentCache evicts least recently used when over capacity", () => {
|
||||
const cache = new DocumentCache(2, 1_000_000);
|
||||
cache.set(parsed("a.xml", 10));
|
||||
cache.set(parsed("b.xml", 10));
|
||||
cache.set(parsed("c.xml", 10));
|
||||
assert.equal(cache.get("a.xml"), undefined);
|
||||
assert.ok(cache.get("b.xml"));
|
||||
assert.ok(cache.get("c.xml"));
|
||||
});
|
||||
|
||||
test("DocumentCache invalidate frees budget", () => {
|
||||
const cache = new DocumentCache(64, 100);
|
||||
cache.set(parsed("a.xml", 60));
|
||||
cache.invalidate("a.xml");
|
||||
cache.set(parsed("b.xml", 60));
|
||||
assert.ok(cache.get("b.xml"), "invalidating a freed its budget share");
|
||||
cache.set(parsed("c.xml", 60));
|
||||
assert.equal(cache.get("a.xml"), undefined);
|
||||
assert.ok(cache.get("c.xml"));
|
||||
});
|
||||
|
||||
test("IndexRecordsCache stores and invalidates entries", () => {
|
||||
const cache = new IndexRecordsCache();
|
||||
const entry = {
|
||||
stat: { mtimeMs: 1, size: 1 },
|
||||
records: { assets: [], defines: [], includes: [], rootXiIncludes: [], nestedXiIncludes: [] },
|
||||
kind: "full",
|
||||
};
|
||||
cache.set("a.xml", entry);
|
||||
assert.equal(cache.get("a.xml"), entry);
|
||||
cache.invalidate("a.xml");
|
||||
assert.equal(cache.get("a.xml"), undefined);
|
||||
});
|
||||
|
||||
test("IncludeResolveCache stores sources and manifest lookups", () => {
|
||||
const cache = new IncludeResolveCache();
|
||||
const key = "dir|DATA:static.xml";
|
||||
const result = { path: "C:/sdk/static.xml", prefix: "DATA", raw: "DATA:static.xml" };
|
||||
assert.equal(cache.get(key), undefined);
|
||||
cache.set(key, result);
|
||||
assert.equal(cache.get(key), result);
|
||||
assert.equal(cache.getManifest("static.xml"), undefined);
|
||||
cache.setManifest("static.xml", "C:/sdk/builtmods/static.manifest");
|
||||
assert.equal(cache.getManifest("static.xml"), "C:/sdk/builtmods/static.manifest");
|
||||
cache.clear();
|
||||
assert.equal(cache.get(key), undefined);
|
||||
assert.equal(cache.getManifest("static.xml"), undefined);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+1
@@ -9,5 +9,6 @@
|
||||
<Include type="all" source="Includes/Weapons.xml" />
|
||||
<Include type="all" source="Includes/Shared.xml" />
|
||||
<Include type="all" source="Includes/Refs.xml" />
|
||||
<Include type="all" source="Includes/VehicleArt.xml" />
|
||||
</Includes>
|
||||
</AssetDeclaration>
|
||||
|
||||
@@ -2,8 +2,16 @@ import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { ModIndexer } from "../out/indexer/indexer.js";
|
||||
import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js";
|
||||
import {
|
||||
DocumentCache,
|
||||
IncludeResolveCache,
|
||||
IndexRecordsCache,
|
||||
} from "../out/indexer/caches.js";
|
||||
import { resolveReferenceTargetsForType } from "../out/indexer/refs.js";
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const project = join(root, "test", "fixtures", "minimod");
|
||||
@@ -70,3 +78,182 @@ test("provides include source candidates", async () => {
|
||||
assert.ok(xml.some((c) => c.source === "Includes/Units.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 recordsCache = new IndexRecordsCache();
|
||||
const resolveCache = new IncludeResolveCache();
|
||||
const makeIndexer = () =>
|
||||
new ModIndexer({
|
||||
projectDir: project,
|
||||
sdkDir: sdk,
|
||||
builtmodsDirs: [join(sdk, "builtmods")],
|
||||
indexSageXml: true,
|
||||
additionalDataSearchPaths: [],
|
||||
walker: new CachedDirectoryWalker(),
|
||||
documentCache,
|
||||
recordsCache,
|
||||
resolveCache,
|
||||
});
|
||||
|
||||
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.ok(second.stats.recordsCacheHits > 0, "XML records served from cache");
|
||||
assert.ok(second.stats.resolveCacheHits > 0, "include resolutions served from cache");
|
||||
assert.equal(second.stats.resolveCalls, 0, "no include re-resolved on a trusted rebuild");
|
||||
assert.equal(second.stats.assetCount, first.stats.assetCount);
|
||||
assert.equal(second.stats.indexedFiles, first.stats.indexedFiles);
|
||||
});
|
||||
|
||||
test("trusted rebuilds skip unchanged files; invalidation forces re-reads", 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,
|
||||
});
|
||||
|
||||
// Trusted rebuild: cached files are reused without any per-file stat/read.
|
||||
const first = await new ModIndexer({ ...opts(), trustUnchanged: true }).build();
|
||||
assert.equal(first.stats.shallowScannedFiles, 2);
|
||||
const second = await new ModIndexer({ ...opts(), trustUnchanged: true }).build();
|
||||
assert.equal(second.stats.shallowScannedFiles, 0);
|
||||
assert.equal(second.stats.shallowCacheHits, 2);
|
||||
assert.ok(second.stats.recordsCacheHits > 0);
|
||||
|
||||
// Simulate the file watcher / save handler: invalidate one art asset.
|
||||
// The next trusted rebuild must re-scan exactly that file.
|
||||
recordsCache.invalidate(join(project, "Data", "Includes", "Models", "Tank_SKN.w3x"));
|
||||
const third = await new ModIndexer({ ...opts(), trustUnchanged: true }).build();
|
||||
assert.equal(third.stats.shallowScannedFiles, 1);
|
||||
assert.equal(third.stats.shallowCacheHits, 1);
|
||||
assert.ok(
|
||||
third.assetsById.get("tank_skn")?.some((d) => d.type === "W3DContainer"),
|
||||
"re-scanned w3x asset present",
|
||||
);
|
||||
|
||||
// Invalidate one XML file: exactly its records are re-extracted.
|
||||
recordsCache.invalidate(join(project, "Data", "Includes", "Units.xml"));
|
||||
const fourth = await new ModIndexer({ ...opts(), trustUnchanged: true }).build();
|
||||
assert.equal(fourth.stats.recordsCacheHits, third.stats.recordsCacheHits - 1);
|
||||
assert.ok(
|
||||
fourth.assetsById.get("testtank")?.some((d) => d.type === "GameObject"),
|
||||
"re-parsed XML asset present",
|
||||
);
|
||||
|
||||
// A forced rebuild (ra3modxml.reindex) verifies stats but still reuses
|
||||
// content caches for unchanged files.
|
||||
const forced = await new ModIndexer({ ...opts(), trustUnchanged: false }).build();
|
||||
assert.equal(forced.stats.shallowScannedFiles, 0);
|
||||
assert.equal(forced.stats.shallowCacheHits, 2);
|
||||
});
|
||||
|
||||
test("index stats include candidate/walk phase timings", async () => {
|
||||
const idx = await buildIndex();
|
||||
assert.equal(typeof idx.stats.candidatesMs, "number");
|
||||
assert.equal(typeof idx.stats.walkMs, "number");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseXml, LineMap } from "../out/language/xmlParser.js";
|
||||
import { extractIndexRecords, recordsFromShallow } from "../out/indexer/records.js";
|
||||
import { scanXmlShallow } from "../out/indexer/shallowScan.js";
|
||||
|
||||
test("extractIndexRecords mirrors the walk semantics", () => {
|
||||
const text = `<?xml version="1.0"?>
|
||||
<AssetDeclaration>
|
||||
<Defines>
|
||||
<Define name="HP" value="100"/>
|
||||
</Defines>
|
||||
<Includes>
|
||||
<Include type="all" source="Units.xml"/>
|
||||
<Include type="instance" source="Base.xml"/>
|
||||
</Includes>
|
||||
<GameObject id="Tank" CommandSet="TankCommandSet"/>
|
||||
<WeaponTemplate id="TankGun"/>
|
||||
<xi:include href="DATA:Extra.xml" xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:Extra/child::*)"/>
|
||||
<GameObject id="Tank2">
|
||||
<Draws>
|
||||
<xi:include href="DATA:Nested.xml"/>
|
||||
</Draws>
|
||||
</GameObject>
|
||||
</AssetDeclaration>`;
|
||||
const lineMap = new LineMap(text);
|
||||
const records = extractIndexRecords(parseXml(text), lineMap);
|
||||
assert.deepEqual(
|
||||
records.assets.map((a) => [a.type, a.id, a.line]),
|
||||
[
|
||||
["GameObject", "Tank", 10],
|
||||
["WeaponTemplate", "TankGun", 11],
|
||||
["GameObject", "Tank2", 13],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
records.defines.map((d) => [d.name, d.value]),
|
||||
[["HP", "100"]],
|
||||
);
|
||||
assert.deepEqual(
|
||||
records.includes.map((i) => [i.type, i.source]),
|
||||
[
|
||||
["all", "Units.xml"],
|
||||
["instance", "Base.xml"],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
records.rootXiIncludes.map((x) => [x.href, x.line]),
|
||||
[["DATA:Extra.xml", 12]],
|
||||
);
|
||||
assert.deepEqual(
|
||||
records.nestedXiIncludes.map((x) => [x.href, x.line]),
|
||||
[["DATA:Nested.xml", 15]],
|
||||
);
|
||||
});
|
||||
|
||||
test("recordsFromShallow converts offsets to 1-based lines", () => {
|
||||
const text = `<AssetDeclaration>\n <W3DContainer id="A"/>\n</AssetDeclaration>`;
|
||||
const lineMap = new LineMap(text);
|
||||
const records = recordsFromShallow(scanXmlShallow(text), lineMap);
|
||||
assert.equal(records.assets.length, 1);
|
||||
assert.equal(records.assets[0].type, "W3DContainer");
|
||||
assert.equal(records.assets[0].id, "A");
|
||||
assert.equal(records.assets[0].line, 2);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
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.rootXiIncludes.length, 1);
|
||||
assert.equal(doc.rootXiIncludes[0].href, "DATA:Includes/Extra.xml");
|
||||
assert.match(doc.rootXiIncludes[0].xpointer, /xpointer\(/);
|
||||
assert.equal(doc.nestedXiIncludes.length, 0);
|
||||
|
||||
// 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"],
|
||||
);
|
||||
});
|
||||
|
||||
test("distinguishes root-level and nested xi:include", () => {
|
||||
const doc = scanXmlShallow(`<AssetDeclaration>
|
||||
<xi:include href="DATA:Top.xml"/>
|
||||
<W3DMesh id="MESH">
|
||||
<SubObject>
|
||||
<RenderObject>
|
||||
<xi:include href="DATA:Nested.xml" xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:X/child::*)"/>
|
||||
</RenderObject>
|
||||
</SubObject>
|
||||
</W3DMesh>
|
||||
</AssetDeclaration>`);
|
||||
assert.deepEqual(
|
||||
doc.rootXiIncludes.map((x) => x.href),
|
||||
["DATA:Top.xml"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
doc.nestedXiIncludes.map((x) => x.href),
|
||||
["DATA:Nested.xml"],
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import { test } from "node:test";
|
||||
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", () => {
|
||||
const text = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n\t<GameObject id="X" KindOf="A B"/>\n</AssetDeclaration>`;
|
||||
|
||||
Reference in New Issue
Block a user