first commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
*.vsix
|
||||||
|
OpenSAGE/
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.vscode/**
|
||||||
|
.git/**
|
||||||
|
src/**
|
||||||
|
test/**
|
||||||
|
tools/**
|
||||||
|
out/**
|
||||||
|
node_modules/**
|
||||||
|
prompts
|
||||||
|
check_duplicate_ids.py
|
||||||
|
debug.log
|
||||||
|
docs/**
|
||||||
|
OpenSAGE/**
|
||||||
|
test/**
|
||||||
|
**/*.map
|
||||||
|
tsconfig.json
|
||||||
|
esbuild.mjs
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# RA3 Mod XML(VS Code 扩展)
|
||||||
|
|
||||||
|
面向《命令与征服:红色警戒 3》Mod XML(SAGE / BinaryAssetBuilder 格式)的 VS Code 工具扩展。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- **语法高亮**:在普通 XML 高亮之上叠加领域标记(`$DEFINE` 常量、`inheritFrom`、`xai:joinAction`、结构标签)。
|
||||||
|
- **自动补全**:
|
||||||
|
- 元素名:按当前父元素的 XSD 模型补全子元素;顶层资产(`AssetDeclaration` 内)补全 `GameObject`、`WeaponTemplate` 等 99+ 类型。
|
||||||
|
- 属性名:必填属性优先,附带类型/文档/默认值;自动提示 `xai:joinAction` 与 `xmlns:xai`。
|
||||||
|
- 属性值:
|
||||||
|
- 引用型属性(如 `CommandSet`、`Weapon`)按 `xas:refType` 补全对应类型的资产 ID(**同名 ID 只补全匹配类型**);
|
||||||
|
- `inheritFrom` 补全可继承的资产 ID;
|
||||||
|
- 枚举(如 `Include type`)、布尔值、`$DEFINE` 常量;
|
||||||
|
- `<Include source>` 补全可解析的 `DATA:` / `ART:` / `AUDIO:` 与项目相对路径。
|
||||||
|
- **悬停提示**:元素/属性显示 XSD 文档、类型、必填/默认值;引用值显示定义位置;`$DEFINE` 显示值与定义位置。
|
||||||
|
- **引用导航**:从引用值(`CommandSet="..."`、`Weapon="..."`、`inheritFrom`)跳转到定义(严格按引用类型过滤);`Ctrl+点击` Include 打开目标文件;Find All References 搜索整个工作区;文档大纲列出顶层资产与 `$DEFINE`。
|
||||||
|
- **错误检查**:XML 格式错误、未知元素/属性、顶层资产缺 `id`、重复 ID、未解析引用(含类型不匹配)、Include 找不到、`$DEFINE` 未定义。
|
||||||
|
- **manifest 支持**:`<Include type="reference">` 指向的 `static/global/audio.manifest`(SDK `builtmods`)会被解析,manifest 中的原版资产 ID 可用于补全/悬停/导航/诊断。
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
1. 用 VS Code 打开 RA3 Mod 项目文件夹(含 `Data/Mod.xml` 或 `mod.babproj`)。
|
||||||
|
2. 插件自动激活并开始后台索引(状态栏显示资产数量)。
|
||||||
|
3. 编辑任意 `*.xml` 即可获得补全、跳转与诊断。
|
||||||
|
|
||||||
|
### 设置(`settings.json`)
|
||||||
|
|
||||||
|
| 设置 | 默认值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `ra3modxml.sdkPath` | `C:\Apps\RA3-MODSDK-X` | Mod SDK 根目录 |
|
||||||
|
| `ra3modxml.indexSageXml` | `true` | 是否索引 SDK 的 `SageXml` 原版源码 |
|
||||||
|
| `ra3modxml.reportUnresolvedReferences` | `warning` | 未解析引用诊断级别(`warning`/`information`/`none`) |
|
||||||
|
| `ra3modxml.diagnoseUnknownElements` | `true` | 是否报告未知元素/属性(自定义 XSD 项目可关闭) |
|
||||||
|
| `ra3modxml.additionalDataSearchPaths` | `[]` | 追加的 `DATA:` 搜索目录 |
|
||||||
|
|
||||||
|
### 命令
|
||||||
|
|
||||||
|
- `RA3 Mod XML: Re-index workspace`:手动重建索引。
|
||||||
|
- `RA3 Mod XML: Show index report`:查看索引统计。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
npm run generate-model # 从 SDK XSD 重新生成 src/model/schema-model.json
|
||||||
|
npm test # 单元测试(tsc + node --test)
|
||||||
|
npm run build # esbuild 打包到 dist/
|
||||||
|
npm run package # 生成可安装的 .vsix
|
||||||
|
```
|
||||||
|
|
||||||
|
测试夹具:`test/fixtures/minimod`(含 include、重复 ID、同名不同类型 ID、manifest 回退等场景)。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
extension.ts 激活入口与 provider 注册
|
||||||
|
workspace.ts 项目检测、索引生命周期、状态栏
|
||||||
|
language/xmlParser.ts 带源码偏移的轻量 XML 解析器
|
||||||
|
language/context.ts 补全上下文分析
|
||||||
|
model/schemaModel.ts XSD 模型运行时(由 tools 生成 JSON 驱动)
|
||||||
|
indexer/
|
||||||
|
includeResolver.ts Include 路径解析(纯 TS,移植 check_duplicate_ids.py)
|
||||||
|
manifestParser.ts .manifest 二进制解析(移植 OpenSAGE ManifestFile.cs)
|
||||||
|
refs.ts 引用目标解析(按引用类型过滤)
|
||||||
|
indexer.ts 工作区索引器(后台、缓存、增量重建)
|
||||||
|
features/ completion / hover / navigation / diagnostics
|
||||||
|
syntaxes/ TextMate 注入语法
|
||||||
|
tools/ XSD → 模型、AssetType 枚举提取
|
||||||
|
```
|
||||||
|
|
||||||
|
解析/索引核心不依赖 VS Code API,可被其他工具复用(见 `docs/plan.md` 的远期目标:搜索与索引复用)。
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
|
||||||
|
- 领域说明与需求:`docs/requirements.md`
|
||||||
|
- 调研与设计决策:`docs/plan.md`
|
||||||
|
- Manifest 格式参考:OpenSAGE `src/OpenSage.Game/Data/StreamFS/ManifestFile.cs`(本仓库 `OpenSAGE/` 子目录,commit `d45d361`)
|
||||||
@@ -0,0 +1,631 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
check_duplicate_ids.py — RA3 Mod XML 重复 ID 检测工具
|
||||||
|
|
||||||
|
从 Data/Mod.xml 开始递归遍历所有 <Include type="all">,解析 DATA:/ART:/AUDIO:
|
||||||
|
前缀路径,收集所有顶层元素(AssetDeclaration 的直接子元素),并报告相同标签名
|
||||||
|
+ 相同 id 的重复冲突。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python tools/check_duplicate_ids.py --sdk-dir C:\Apps\RA3-MOD-SDK-X
|
||||||
|
python tools/check_duplicate_ids.py --sdk-dir C:\Apps\Ra3QuantumCompiler
|
||||||
|
python tools/check_duplicate_ids.py --sdk-dir C:\Apps\RA3-MOD-SDK-X --project-dir D:\Mods\CoronaMod\mods\mods\corona
|
||||||
|
假如路径里有空格,请用引号括起来。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Tuple
|
||||||
|
|
||||||
|
# ── 命名空间常量 ────────────────────────────────────────────────────
|
||||||
|
NS_EALA = 'uri:ea.com:eala:asset'
|
||||||
|
NS_XAI = 'uri:ea.com:eala:asset:instance'
|
||||||
|
NS_XI = 'http://www.w3.org/2001/XInclude'
|
||||||
|
|
||||||
|
|
||||||
|
def strip_ns(tag: str) -> str:
|
||||||
|
"""去掉 XML 命名空间前缀,返回纯标签名。"""
|
||||||
|
if '}' in tag:
|
||||||
|
return tag.split('}', 1)[1]
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
def get_ns_uri(tag: str) -> str:
|
||||||
|
"""提取标签的命名空间 URI(无花括号)。"""
|
||||||
|
if '}' in tag:
|
||||||
|
return tag.split('}', 1)[0][1:]
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def norm(p: Path) -> Path:
|
||||||
|
"""返回标准化的绝对路径。"""
|
||||||
|
return p.resolve()
|
||||||
|
|
||||||
|
g_cache: Dict[Tuple[str, Optional[Path]], Optional[Path]] = {}
|
||||||
|
def find_file(source: str, search_paths: dict, current_dir: Optional[Path]) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
解析 Include/@source 到实际文件路径:
|
||||||
|
- DATA:/ART:/AUDIO: 前缀 → 按优先级在各搜索路径中查找
|
||||||
|
- 无前缀 → 相对于当前文件所在目录
|
||||||
|
对于 ART: 路径,支持 2-letter prefix matching:
|
||||||
|
如果源路径无目录分隔符且直接查找失败,提取文件名前 2 个小写字
|
||||||
|
母作为子目录再次尝试(如 JUAntiShip → ju/JUAntiShip)。
|
||||||
|
返回第一个匹配的文件,未找到返回 None。
|
||||||
|
"""
|
||||||
|
source = source.strip().replace('\\', '/')
|
||||||
|
# 前缀统一大写
|
||||||
|
for prefix in ("DATA:", "ART:", "AUDIO:"):
|
||||||
|
if source[:len(prefix)].upper() == prefix:
|
||||||
|
source = prefix + source[len(prefix):]
|
||||||
|
break
|
||||||
|
if (source, current_dir) in g_cache:
|
||||||
|
return g_cache[(source, current_dir)]
|
||||||
|
result = _find_no_cache(source, search_paths, current_dir)
|
||||||
|
g_cache[(source, current_dir)] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _find_no_cache(source: str, search_paths: dict, current_dir: Optional[Path]) -> Optional[Path]:
|
||||||
|
for prefix in ('DATA:', 'ART:', 'AUDIO:'):
|
||||||
|
if source.upper().startswith(prefix):
|
||||||
|
rel = source[len(prefix):].lstrip('/')
|
||||||
|
# 直接查找
|
||||||
|
result = _search_in_paths(rel, search_paths.get(prefix, []))
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
# ART: 路径:如果无目录分隔符,尝试 2-letter prefix matching
|
||||||
|
if prefix == 'ART:' and '/' not in rel and '\\' not in rel:
|
||||||
|
first_two = rel[:2].lower()
|
||||||
|
prefixed_rel = f"{first_two}/{rel}"
|
||||||
|
result = _search_in_paths(prefixed_rel, search_paths.get(prefix, []))
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
# 无前缀 → 相对路径
|
||||||
|
if current_dir is not None:
|
||||||
|
candidate = norm(current_dir / source)
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _search_in_paths(rel_path: str, base_paths: list[Path]) -> Optional[Path]:
|
||||||
|
"""在多个基路径中依次搜索相对路径,返回第一个存在的文件。"""
|
||||||
|
for base in base_paths:
|
||||||
|
candidate = norm(base / rel_path)
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# xpointer 简易解析
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def parse_xpointer(xpointer_str: str) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
简易解析 xpointer 表达式,仅支持本项目使用的格式:
|
||||||
|
xmlns(n=uri:ea.com:eala:asset) xpointer(/n:ElementName/child::*)
|
||||||
|
返回 { 'ns_map': {'n': 'uri:...'}, 'element': 'ElementName' }
|
||||||
|
不支持复杂 xpath 时返回 None。
|
||||||
|
"""
|
||||||
|
if not xpointer_str:
|
||||||
|
return None
|
||||||
|
# 提取 xmlns 映射
|
||||||
|
ns_map = {}
|
||||||
|
for m in re.finditer(r'xmlns\((\w+)=([^\s)]+)\)', xpointer_str):
|
||||||
|
ns_map[m.group(1)] = m.group(2)
|
||||||
|
# 提取 xpointer(/prefix:ElementName/child::*)
|
||||||
|
m = re.search(r'xpointer\(/(\w+):(\w+)/child::\*\)', xpointer_str)
|
||||||
|
if m:
|
||||||
|
prefix = m.group(1)
|
||||||
|
elem_name = m.group(2)
|
||||||
|
return {'ns_map': ns_map, 'element': elem_name, 'prefix': prefix}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_xpointer_target(root: ET.Element, xpointer_info: dict) -> list[ET.Element]:
|
||||||
|
"""
|
||||||
|
根据解析后的 xpointer 信息,从已解析的 XML 树中找到对应元素并返回其子元素。
|
||||||
|
"""
|
||||||
|
prefix = xpointer_info.get('prefix', '')
|
||||||
|
elem_name = xpointer_info.get('element', '')
|
||||||
|
ns_map = xpointer_info.get('ns_map', {})
|
||||||
|
ns_uri = ns_map.get(prefix, NS_EALA)
|
||||||
|
# 构造带命名空间的标签名
|
||||||
|
target_tag = f'{{{ns_uri}}}{elem_name}'
|
||||||
|
# 寻找匹配元素
|
||||||
|
for elem in root.iter():
|
||||||
|
if elem.tag == target_tag:
|
||||||
|
return list(elem)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# 核心检测器
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class DuplicateIdChecker:
|
||||||
|
"""
|
||||||
|
从 Mod.xml 出发递归遍历 Include 树,收集顶层元素,报告重复 ID 冲突。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, sdk_dir: Path, project_dir: Path, cwd: Optional[Path] = None):
|
||||||
|
self.sdk_dir = norm(sdk_dir)
|
||||||
|
self.project_dir = norm(project_dir)
|
||||||
|
self._cwd = norm(cwd) if cwd else self.project_dir
|
||||||
|
|
||||||
|
# ── 自动推断搜索路径 ──
|
||||||
|
# 从 CLI 命令推导的结构:
|
||||||
|
# project_dir = D:\Mods\CoronaMod\mods\mods\corona
|
||||||
|
# project_dir.parent = D:\Mods\CoronaMod\mods\mods
|
||||||
|
# project_dir.parent.parent = D:\Mods\CoronaMod\mods
|
||||||
|
shared = self.project_dir.parent.parent # D:\Mods\CoronaMod\mods
|
||||||
|
mods = self.project_dir.parent # D:\Mods\CoronaMod\mods\mods
|
||||||
|
|
||||||
|
pj_data = self.project_dir / 'Data'
|
||||||
|
pj_art1 = self.project_dir / 'Art1'
|
||||||
|
pj_art = self.project_dir / 'Art'
|
||||||
|
pj_aud1 = self.project_dir / 'Audio1'
|
||||||
|
pj_aud = self.project_dir / 'Audio'
|
||||||
|
sdk_art = self.sdk_dir / 'Art'
|
||||||
|
sdk_aud = self.sdk_dir / 'Audio'
|
||||||
|
sdk_sage = self.sdk_dir / 'SageXml'
|
||||||
|
|
||||||
|
self._search_paths: dict[str, list[Path]] = {
|
||||||
|
'DATA:': [sdk_dir, pj_data, mods, sdk_sage],
|
||||||
|
'ART:': [sdk_dir, pj_art1, pj_art, mods, sdk_art],
|
||||||
|
'AUDIO:': [sdk_dir, pj_aud1, pj_aud, mods, sdk_aud],
|
||||||
|
}
|
||||||
|
# SageXml 路径:用于标记来自 SageXml 的元素
|
||||||
|
self._sagexml_dir = sdk_sage
|
||||||
|
# SDK Art 路径:用于标记来自 SDK Art 的元素
|
||||||
|
self._sdk_art_dir = sdk_art
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
self._visited: set[str] = set() # 已访问过的文件(绝对路径)
|
||||||
|
self.manifest: list[tuple] = [] # (tag, id, rel_path, line)
|
||||||
|
self.dep_tree: list[tuple] = [] # (depth, ref_src, resolved_path)
|
||||||
|
self.not_found: list[tuple] = [] # (source, parent_file)
|
||||||
|
self.errors: list[str] = []
|
||||||
|
self._sagexml_files: set[str] = set() # SageXml 下的文件(用于冲突过滤)
|
||||||
|
|
||||||
|
# ── 公开入口 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def run(self) -> int:
|
||||||
|
"""
|
||||||
|
执行完整检测流程。
|
||||||
|
返回: 0=正常, 1=发现冲突, 2=严重错误
|
||||||
|
"""
|
||||||
|
t_start = time.perf_counter()
|
||||||
|
|
||||||
|
mod_xml = self.project_dir / 'Data' / 'Mod.xml'
|
||||||
|
if not mod_xml.is_file():
|
||||||
|
print(f"❌ Mod.xml 未找到: {mod_xml}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
print(f"🔍 起始文件: {mod_xml}")
|
||||||
|
print(f"📁 项目目录: {self.project_dir}")
|
||||||
|
print(f"📁 SDK 目录 : {self.sdk_dir}")
|
||||||
|
print(f"📁 工作目录 : {self._cwd}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for prefix in ('DATA:', 'ART:', 'AUDIO:'):
|
||||||
|
paths = self._search_paths.get(prefix, [])
|
||||||
|
markers = [f"{' ✔' if p.is_dir() else ''}" for p in paths]
|
||||||
|
lines = [f" {i}. {p}{m}" for i, (p, m) in enumerate(zip(paths, markers), 1)]
|
||||||
|
print(f" {prefix} 搜索路径:")
|
||||||
|
for l in lines:
|
||||||
|
print(l)
|
||||||
|
print()
|
||||||
|
|
||||||
|
self._process_file(mod_xml, depth=0, reference='Mod.xml')
|
||||||
|
|
||||||
|
# ── 冲突检测 ──
|
||||||
|
# 仅在模组文件(非 SageXml)之间检测冲突。
|
||||||
|
# SageXml 中的同名元素是引擎基础,被模组覆盖是正常行为。
|
||||||
|
grouped: dict[tuple[str, str], list] = defaultdict(list)
|
||||||
|
for tag, eid, src, line in self.manifest:
|
||||||
|
# 跳过来自 SageXml 的条目
|
||||||
|
if src in self._sagexml_files:
|
||||||
|
continue
|
||||||
|
grouped[(tag, eid)].append((src, line))
|
||||||
|
conflicts = {k: v for k, v in grouped.items() if len(v) > 1}
|
||||||
|
|
||||||
|
# 统计 SageXml 覆盖信息(用于报告)
|
||||||
|
sagexml_overrides = 0
|
||||||
|
sagexml_only = set()
|
||||||
|
for tag, eid, src, line in self.manifest:
|
||||||
|
if src in self._sagexml_files:
|
||||||
|
sagexml_only.add((tag, eid))
|
||||||
|
if sagexml_only:
|
||||||
|
# 检查这些 SageXml 提供的 id 是否被模组覆盖
|
||||||
|
mod_ids = set()
|
||||||
|
for tag, eid, src, line in self.manifest:
|
||||||
|
if src not in self._sagexml_files:
|
||||||
|
mod_ids.add((tag, eid))
|
||||||
|
sagexml_overrides = len(sagexml_only & mod_ids)
|
||||||
|
|
||||||
|
# ── 输出 ──
|
||||||
|
self._print_dep_tree()
|
||||||
|
self._print_manifest()
|
||||||
|
self._print_conflicts(conflicts)
|
||||||
|
self._print_not_found()
|
||||||
|
self._print_errors()
|
||||||
|
|
||||||
|
if conflicts:
|
||||||
|
print(f"\n⚠️ 发现 {len(conflicts)} 组重复 ID(相同标签名 + 相同 id)")
|
||||||
|
else:
|
||||||
|
print("\n✅ 未发现模组内的重复 ID 冲突")
|
||||||
|
if sagexml_overrides:
|
||||||
|
print(f"📌 模组覆盖了 {sagexml_overrides} 个来自 SageXml 的 id(正常行为)")
|
||||||
|
|
||||||
|
t_elapsed = time.perf_counter() - t_start
|
||||||
|
if t_elapsed < 60:
|
||||||
|
print(f"⏱ 耗时: {t_elapsed:.2f} 秒")
|
||||||
|
else:
|
||||||
|
minutes = int(t_elapsed // 60)
|
||||||
|
seconds = t_elapsed % 60
|
||||||
|
print(f"⏱ 耗时: {minutes} 分 {seconds:.1f} 秒")
|
||||||
|
print()
|
||||||
|
return 1 if conflicts else 0
|
||||||
|
|
||||||
|
# ── 文件处理 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _process_file(self, file_path: Path, depth: int, reference: str):
|
||||||
|
"""解析一个 XML 文件:收集顶层元素,递归处理 Includes。"""
|
||||||
|
resolved = norm(file_path)
|
||||||
|
key = str(resolved)
|
||||||
|
if key in self._visited:
|
||||||
|
return
|
||||||
|
self._visited.add(key)
|
||||||
|
|
||||||
|
# 记录依赖
|
||||||
|
try:
|
||||||
|
rel = resolved.relative_to(self.project_dir)
|
||||||
|
except ValueError:
|
||||||
|
rel = resolved
|
||||||
|
self.dep_tree.append((depth, reference, str(rel)))
|
||||||
|
|
||||||
|
# 标记是否来自 SageXml
|
||||||
|
is_sagexml = self._sagexml_dir in resolved.parents\
|
||||||
|
or str(resolved).startswith(str(self._sagexml_dir))
|
||||||
|
if is_sagexml:
|
||||||
|
self._sagexml_files.add(str(rel))
|
||||||
|
# 检测是否来自 SDK Art
|
||||||
|
is_sdk_art = self._sdk_art_dir in resolved.parents\
|
||||||
|
or str(resolved).startswith(str(self._sdk_art_dir))
|
||||||
|
if is_sdk_art:
|
||||||
|
# 检测文件大小是否小于等于 75 字节(空 XML)
|
||||||
|
try:
|
||||||
|
if resolved.stat().st_size <= 75:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._sagexml_files.add(str(rel)) # 也视为基础资源,避免冲突
|
||||||
|
|
||||||
|
root = self._parse(resolved)
|
||||||
|
if root is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
local = strip_ns(root.tag)
|
||||||
|
if local != 'AssetDeclaration':
|
||||||
|
self.errors.append(
|
||||||
|
f" 跳过非 AssetDeclaration 根元素: {rel} (根={local})"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 遍历 AssetDeclaration 的直接子元素
|
||||||
|
for child in root:
|
||||||
|
child_local = strip_ns(child.tag)
|
||||||
|
ns_uri = get_ns_uri(child.tag)
|
||||||
|
|
||||||
|
# ── <Includes> ──
|
||||||
|
if child_local == 'Includes':
|
||||||
|
self._process_includes(child, resolved, depth)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── <xi:include> 在顶层 ──
|
||||||
|
if child_local == 'include' and ns_uri == NS_XI:
|
||||||
|
self._process_xi_include(child, resolved, depth)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── <Tags> 跳过 ──
|
||||||
|
if child_local == 'Tags':
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── 顶层内容元素 ──
|
||||||
|
element_id = child.get('id')
|
||||||
|
if element_id:
|
||||||
|
line = getattr(child, 'sourceline', None)
|
||||||
|
self.manifest.append((child_local, element_id, str(rel), line))
|
||||||
|
|
||||||
|
def _process_includes(self, includes_elem, current_file: Path, depth: int):
|
||||||
|
"""处理 <Includes> 块中的 <Include> 元素。"""
|
||||||
|
for inc in includes_elem:
|
||||||
|
local = strip_ns(inc.tag)
|
||||||
|
if local != 'Include':
|
||||||
|
continue
|
||||||
|
if inc.get('type') != 'all':
|
||||||
|
continue
|
||||||
|
source = inc.get('source')
|
||||||
|
if not source:
|
||||||
|
continue
|
||||||
|
|
||||||
|
resolved = find_file(source, self._search_paths, current_file.parent)
|
||||||
|
if resolved and resolved.is_file():
|
||||||
|
self._process_file(resolved, depth + 1, reference=source)
|
||||||
|
else:
|
||||||
|
self.not_found.append((source, str(current_file)))
|
||||||
|
|
||||||
|
def _process_xi_include(self, xi_elem, current_file: Path, depth: int):
|
||||||
|
"""
|
||||||
|
处理位于 AssetDeclaration 顶层的 <xi:include>。
|
||||||
|
解析 xpointer(如有),读取目标文件并提取可能成为顶层元素的子元素。
|
||||||
|
"""
|
||||||
|
href = xi_elem.get('href')
|
||||||
|
if not href:
|
||||||
|
return
|
||||||
|
resolved = find_file(href, self._search_paths, current_file.parent)
|
||||||
|
if not resolved or not resolved.is_file():
|
||||||
|
self.not_found.append((f"xi:{href}", str(current_file)))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
rel = resolved.relative_to(self.project_dir)
|
||||||
|
except ValueError:
|
||||||
|
rel = resolved
|
||||||
|
|
||||||
|
# 避免重复处理
|
||||||
|
key = str(norm(resolved))
|
||||||
|
if key in self._visited:
|
||||||
|
return
|
||||||
|
self._visited.add(key)
|
||||||
|
|
||||||
|
self.dep_tree.append((depth, f"xi:{href}", str(rel)))
|
||||||
|
|
||||||
|
root = self._parse(resolved)
|
||||||
|
if root is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 获取 xpointer
|
||||||
|
xp_str = xi_elem.get('xpointer', '')
|
||||||
|
xp_info = parse_xpointer(xpointer_str=xp_str)
|
||||||
|
|
||||||
|
if xp_info:
|
||||||
|
# xpointer 指向某个容器元素,取其子元素
|
||||||
|
target_children = resolve_xpointer_target(root, xp_info)
|
||||||
|
candidates = target_children
|
||||||
|
else:
|
||||||
|
# 没有 xpointer 或无法解析,取根元素的直接子元素
|
||||||
|
candidates = list(root)
|
||||||
|
|
||||||
|
# 将候选项作为顶层元素处理
|
||||||
|
for elem in candidates:
|
||||||
|
elem_local = strip_ns(elem.tag)
|
||||||
|
# 跳过非内容元素
|
||||||
|
if elem_local in ('Tags', 'Includes'):
|
||||||
|
continue
|
||||||
|
element_id = elem.get('id')
|
||||||
|
if element_id:
|
||||||
|
line = getattr(elem, 'sourceline', None)
|
||||||
|
self.manifest.append((elem_local, element_id, str(rel), line))
|
||||||
|
|
||||||
|
# ── 辅助方法 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _parse(self, file_path: Path) -> Optional[ET.Element]:
|
||||||
|
"""解析 XML 文件,返回根元素。
|
||||||
|
|
||||||
|
自动处理常见编码问题:当 XML 声明 encoding 与实际编码不匹配时
|
||||||
|
(例如声明 us-ascii 但文件实际为 UTF-8),尝试自动修正。
|
||||||
|
"""
|
||||||
|
# 第一次尝试:正常解析
|
||||||
|
try:
|
||||||
|
tree = ET.parse(file_path)
|
||||||
|
return tree.getroot()
|
||||||
|
except ET.ParseError as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
# 如果是不规范的 token 错误,可能是编码声明与实际编码不符
|
||||||
|
if 'not well-formed (invalid token)' in error_msg:
|
||||||
|
try:
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
raw = f.read()
|
||||||
|
# 修复常见的错误编码声明:us-ascii → utf-8
|
||||||
|
raw = raw.replace(b'us-ascii', b'utf-8')
|
||||||
|
raw = raw.replace(b'US-ASCII', b'UTF-8')
|
||||||
|
raw = raw.replace(b'us-ASCII', b'utf-8')
|
||||||
|
tree = ET.fromstring(raw)
|
||||||
|
return tree
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.errors.append(f" XML 解析错误: {file_path} — {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
self.errors.append(f" 读取失败: {file_path} — {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── 输出方法 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _print_dep_tree(self):
|
||||||
|
"""打印 Include 依赖树。"""
|
||||||
|
print("═" * 60)
|
||||||
|
print("📂 Include 依赖树")
|
||||||
|
print("═" * 60)
|
||||||
|
# 只打印前 100 行以免刷屏
|
||||||
|
shown = 0
|
||||||
|
for depth, ref, resolved in self.dep_tree:
|
||||||
|
indent = " " * depth
|
||||||
|
prefix = "├─ " if depth > 0 else "└─ "
|
||||||
|
src = ref if depth > 0 else resolved
|
||||||
|
print(f"{indent}{prefix}{src}")
|
||||||
|
shown += 1
|
||||||
|
if shown >= 200:
|
||||||
|
remaining = len(self.dep_tree) - shown
|
||||||
|
if remaining > 0:
|
||||||
|
print(f" ... 还有 {remaining} 个文件未显示")
|
||||||
|
break
|
||||||
|
print(f" (共处理 {len(self.dep_tree)} 个文件)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
def _print_manifest(self):
|
||||||
|
"""打印所有收集到的顶层元素清单。"""
|
||||||
|
print("═" * 60)
|
||||||
|
print("📋 顶层元素清单(按来源文件分组)")
|
||||||
|
print("═" * 60)
|
||||||
|
# 按来源文件分组
|
||||||
|
by_file: dict[str, list] = defaultdict(list)
|
||||||
|
for tag, eid, src, line in self.manifest:
|
||||||
|
by_file[src].append((tag, eid, line))
|
||||||
|
|
||||||
|
for file_idx, (src, elems) in enumerate(sorted(by_file.items()), 1):
|
||||||
|
if file_idx > 50:
|
||||||
|
remaining = len(by_file) - 50
|
||||||
|
print(f"\n ... 还有 {remaining} 个文件的元素未显示")
|
||||||
|
break
|
||||||
|
print(f"\n 📄 {src} ({len(elems)} 个元素):")
|
||||||
|
for tag, eid, line in sorted(elems, key=lambda x: (x[0], x[1])):
|
||||||
|
loc = f" (行 {line})" if line else ""
|
||||||
|
print(f" • <{tag} id=\"{eid}\">{loc}")
|
||||||
|
|
||||||
|
total = len(self.manifest)
|
||||||
|
print(f"\n 共 {total} 个顶层元素")
|
||||||
|
print()
|
||||||
|
|
||||||
|
def _print_conflicts(self, conflicts: dict):
|
||||||
|
"""打印重复 ID 冲突报告。"""
|
||||||
|
print("═" * 60)
|
||||||
|
print("⚠️ 重复 ID 冲突报告")
|
||||||
|
print("═" * 60)
|
||||||
|
if not conflicts:
|
||||||
|
print(" (无冲突)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 按元素类型分组显示
|
||||||
|
by_type: dict[str, list] = defaultdict(list)
|
||||||
|
for (tag, eid), locations in conflicts.items():
|
||||||
|
by_type[tag].append((eid, locations))
|
||||||
|
|
||||||
|
total_conflicts = len(conflicts)
|
||||||
|
total_duplicates = sum(len(v) for v in conflicts.values())
|
||||||
|
|
||||||
|
for tag, items in sorted(by_type.items()):
|
||||||
|
print(f"\n ── <{tag}> ({len(items)} 个重复 id) ──")
|
||||||
|
for eid, locations in sorted(items):
|
||||||
|
print(f"\n id=\"{eid}\" — 出现 {len(locations)} 次:")
|
||||||
|
for src, line in locations:
|
||||||
|
loc = f" (行 {line})" if line else ""
|
||||||
|
print(f" • {src}{loc}")
|
||||||
|
|
||||||
|
print(f"\n 📊 总计: {total_conflicts} 组冲突,涉及 {total_duplicates} 个引用")
|
||||||
|
print()
|
||||||
|
|
||||||
|
def _print_not_found(self):
|
||||||
|
"""打印找不到的文件列表。"""
|
||||||
|
if not self.not_found:
|
||||||
|
return
|
||||||
|
print("═" * 60)
|
||||||
|
print("⚠️ 未找到的文件")
|
||||||
|
print("═" * 60)
|
||||||
|
shown = 0
|
||||||
|
for src, parent in self.not_found:
|
||||||
|
if shown >= 30:
|
||||||
|
remaining = len(self.not_found) - shown
|
||||||
|
print(f" ... 还有 {remaining} 个未显示")
|
||||||
|
break
|
||||||
|
print(f" • {src} (引用自 {parent})")
|
||||||
|
shown += 1
|
||||||
|
print(f" 共 {len(self.not_found)} 个文件未找到")
|
||||||
|
print()
|
||||||
|
|
||||||
|
def _print_errors(self):
|
||||||
|
"""打印解析错误。"""
|
||||||
|
if not self.errors:
|
||||||
|
return
|
||||||
|
print("═" * 60)
|
||||||
|
print("❌ 解析错误")
|
||||||
|
print("═" * 60)
|
||||||
|
for err in self.errors:
|
||||||
|
print(err)
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# 命令行入口
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="RA3 Quantum Compiler XML 重复 ID 检测工具",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
使用示例:
|
||||||
|
%(prog)s --sdk-dir C:\\Apps\\Ra3QuantumCompiler
|
||||||
|
%(prog)s --sdk-dir C:\\Apps\\Ra3QuantumCompiler --project-dir D:\\Mods\\CoronaMod\\mods\\mods\\corona
|
||||||
|
%(prog)s --sdk-dir C:\\Apps\\Ra3QuantumCompiler --cwd D:\\Mods\\CoronaMod\\mods\\mods\\corona
|
||||||
|
|
||||||
|
路径自动推断规则:
|
||||||
|
DATA: 搜索路径 = {sdk} → {project}/Data → {mods} → {sdk}/SageXml
|
||||||
|
ART: 搜索路径 = {sdk} → {project}/Art1 → {project}/Art → {mods}
|
||||||
|
AUDIO:搜索路径 = {sdk} → {project}/Audio1 → {project}/Audio → {mods}
|
||||||
|
其中 mods = {project}/..
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--sdk-dir',
|
||||||
|
default=None,
|
||||||
|
help='RA3 Mod SDK 目录',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--project-dir',
|
||||||
|
default=None,
|
||||||
|
help='项目根目录(默认: 自动检测,从当前目录向上查找含 Data/Mod.xml 的目录)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--cwd',
|
||||||
|
default=None,
|
||||||
|
help='工作目录(默认等于 --project-dir)',
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# ── 确定项目目录 ──
|
||||||
|
project_dir = None
|
||||||
|
if args.project_dir:
|
||||||
|
project_dir = Path(args.project_dir)
|
||||||
|
else:
|
||||||
|
# 自动检测:从当前目录向上找 Data/Mod.xml
|
||||||
|
cur = Path.cwd()
|
||||||
|
for p in [cur] + list(cur.parents):
|
||||||
|
if (p / 'Data' / 'Mod.xml').is_file():
|
||||||
|
project_dir = p
|
||||||
|
break
|
||||||
|
if project_dir is None:
|
||||||
|
print("❌ 无法自动检测项目目录(未找到 Data/Mod.xml)", file=sys.stderr)
|
||||||
|
print(" 请使用 --project-dir 参数指定", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
if args.sdk_dir is None:
|
||||||
|
print("❌ 未指定 SDK 目录", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
sdk_dir = Path(args.sdk_dir)
|
||||||
|
cwd = Path(args.cwd) if args.cwd else project_dir
|
||||||
|
|
||||||
|
# ── 检查基本路径有效性 ──
|
||||||
|
if not sdk_dir.is_dir():
|
||||||
|
print(f"❌ SDK 目录不存在: {sdk_dir}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
if not project_dir.is_dir():
|
||||||
|
print(f"❌ 项目目录不存在: {project_dir}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
checker = DuplicateIdChecker(sdk_dir=sdk_dir, project_dir=project_dir, cwd=cwd)
|
||||||
|
sys.exit(checker.run())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[0801/032923.150:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 拒绝访问。 (0x5)
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# 问题分析:AttachTest 高亮异常与误报诊断
|
||||||
|
|
||||||
|
> 日期:2026-08-01。基于在 AttachTest `Data/Mod.xml` 上复现的问题。
|
||||||
|
> 本文件只做分析,**代码尚未修改**,修复方案见文末,待审阅后实施。
|
||||||
|
|
||||||
|
## 一、现象
|
||||||
|
|
||||||
|
1. **语法高亮异常**:
|
||||||
|
- `<?xml version="1.0" encoding="UTF-8"?>` 显示为纯文本颜色;
|
||||||
|
- `<Includes` 之后的 `>` 无高亮;
|
||||||
|
- `<Include type="reference" ...>` 中 `Include` 之后的内容全部无高亮;
|
||||||
|
- 整体表现为"只有少量标签名被着色,其余按纯文本处理"。
|
||||||
|
2. **误报诊断**:
|
||||||
|
- `<Include type="reference">` 的 `type` 值报 `Unresolved reference "reference"`;
|
||||||
|
- `<Include source="DATA:Static.xml">` 的 `source` 值报 `Unresolved reference`;
|
||||||
|
- 同一处 hover 显示解析结果为 `C:\Apps\RA3-MODSDK-X\SageXml\Static.xml`,但实际应按编译参数解析到 SDK 根目录 `C:\Apps\RA3-MODSDK-X\Static.xml`。
|
||||||
|
|
||||||
|
## 二、实测证据
|
||||||
|
|
||||||
|
用当前构建代码对 AttachTest 索引后验证:
|
||||||
|
|
||||||
|
```
|
||||||
|
[resolve] DATA:Static.xml -> C:\Apps\RA3-MODSDK-X\Static.xml ← 解析器结果正确
|
||||||
|
[candidate] 精确匹配 -> C:\Apps\RA3-MODSDK-X\SageXml\Static.xml ← hover 用的是这条
|
||||||
|
[model] Include@type refType: null enum: [reference, instance, all]
|
||||||
|
[model] Include@source refType: null type: anyURI
|
||||||
|
[diag] Include@type='reference' targets: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
三个问题均稳定复现。
|
||||||
|
|
||||||
|
## 三、根因分析
|
||||||
|
|
||||||
|
### 1. 高亮异常:grammar 注册方式覆盖了内置 XML 语法
|
||||||
|
|
||||||
|
`package.json` 中的 grammar 贡献写法:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"language": "xml",
|
||||||
|
"scopeName": "source.ra3modxml",
|
||||||
|
"injectTo": ["source.xml"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
同时填写 `language` 与 `injectTo` 时,VS Code 会把该 grammar 注册为 **`xml` 语言的主 grammar**(替换内置 XML 语法),而不仅是注入。结果是:
|
||||||
|
|
||||||
|
- 内置 `source.xml` 的完整 token 化规则不再生效;
|
||||||
|
- 我的语法只有几个领域关键词模式,没有 XML 声明、标签括号、属性名/值的规则;
|
||||||
|
- 于是 `<?xml ...?>`、`>`、属性区全部退化为纯文本,只有 `$DEFINE`、`inheritFrom`、结构标签名等被我的模式着色——与现象完全吻合。
|
||||||
|
|
||||||
|
正确的做法是注入式 grammar 不声明 `language`(VS Code 官方注入语法示例即省略该字段)。
|
||||||
|
|
||||||
|
### 2. 误报 `Unresolved reference`:诊断把所有属性值都当成"引用"检查
|
||||||
|
|
||||||
|
诊断代码在 `checkValueReferences` 中先调用 `resolveReferenceTargets(...)`,并以其返回空数组作为"未解析"依据。而 `resolveReferenceTargets` 对**非引用属性**(如 `Include@type` 枚举、`Include@source` 的 `anyURI`)也返回空数组——两者无法区分。
|
||||||
|
|
||||||
|
这是早期实现回归:最初代码有 `isRef`(检查 `refType` 或 `inheritFrom`)守卫,重构为共享函数后丢失了该守卫。
|
||||||
|
|
||||||
|
同理,hover 对非引用属性值也会走到"未找到匹配定义"分支,显示误导性文案。
|
||||||
|
|
||||||
|
### 3. `DATA:Static.xml` hover 路径错误:候选表优先于解析器,且候选表大小写不统一
|
||||||
|
|
||||||
|
- 解析器 `resolveSource` 按搜索路径正确解析到 SDK 根 `Static.xml`;
|
||||||
|
- 但 hover/定义跳转/文档链接都是**先查 `idx.sourceCandidates` 精确匹配**;
|
||||||
|
- 候选表里同一 source 字符串 `DATA:Static.xml` 出现两条:一条来自 `SageXml\Static.xml`(扫描更早、排在前面),一条来自 SDK 根目录浅扫描;
|
||||||
|
- `find` 精确匹配返回第一条 → 显示 SageXml 路径,而按 BAB 顺序应先命中 SDK 根目录。
|
||||||
|
|
||||||
|
修复方向相应调整为:**候选表只服务补全**;hover/跳转/链接一律先走 `resolveSource`(按 BAB 顺序校验文件存在)。候选表本身也按 source 去重并让 SDK 根目录条目排前。
|
||||||
|
|
||||||
|
另外,当前搜索路径来自 `check_duplicate_ids.py` 的简版(`[SDK根, 项目Data, Mods, SDK/SageXml]`),与 BAB 编译参数不完全一致,缺少 `modGranParent` 与 `SDK\Mods` 两个条目。`defaultscript.cs` 的 `getIncludePaths()` 实际参数为:
|
||||||
|
|
||||||
|
```
|
||||||
|
/data: ".;{modGranParent};{0}\Data;.\Mods;{1};.\SageXml"
|
||||||
|
/art: ".;{modGranParent};{0}\Art1;{0}\Art;.\Mods;{1};.\Art"
|
||||||
|
/audio: ".;{modGranParent};{0}\Audio1;{0}\Audio;.\Mods;{1};.\Audio"
|
||||||
|
其中 {0}=ModPath, {1}=modParentPath, {2}=modGranParent, "." = SDK 根目录(编译时 cd 到 SDK)
|
||||||
|
```
|
||||||
|
|
||||||
|
展开后(以 AttachTest 为例):
|
||||||
|
|
||||||
|
| 前缀 | 搜索路径(BAB 顺序) |
|
||||||
|
|---|---|
|
||||||
|
| DATA | SDK根 → modGranParent → Mod\Data → SDK\Mods → modParentPath → SDK\SageXml |
|
||||||
|
| ART | SDK根 → modGranParent → Mod\Art1 → Mod\Art → SDK\Mods → modParentPath → SDK\Art |
|
||||||
|
| AUDIO | SDK根 → modGranParent → Mod\Audio1 → Mod\Audio → SDK\Mods → modParentPath → SDK\Audio |
|
||||||
|
|
||||||
|
`DATA:Static.xml` 在 BAB 顺序下首先命中 SDK 根的占位文件(与用户预期一致)。
|
||||||
|
|
||||||
|
## 四、修复方案
|
||||||
|
|
||||||
|
### 4.1 语法高亮(P0)
|
||||||
|
|
||||||
|
- `package.json` 的 `grammars` 条目**移除 `"language": "xml"`**,只保留 `scopeName`、`path`、`injectTo`,使其成为纯粹的注入语法,叠加在内置 XML 语法之上。
|
||||||
|
- 精简 `syntaxes/ra3modxml.tmLanguage.json`:保留不冲突的模式(`$DEFINE` 常量、`inheritFrom`、`xai:joinAction`、TODO 注释、结构标签名);删除对 `Replace`/`Remove` 的裸词匹配(避免误染普通内容)。
|
||||||
|
- 验证方式:扩展开发宿主中打开 `Data/Mod.xml`,确认 XML 声明、标签、属性恢复完整高亮且领域关键词仍有专属颜色。
|
||||||
|
|
||||||
|
### 4.2 诊断与 hover 的引用判断(P0)
|
||||||
|
|
||||||
|
- 恢复"是否引用属性"守卫:仅当属性为 `inheritFrom` 或模型中有 `refType` 时才进入未解析引用检查;`Include@type`、`Include@source` 等不再误报。
|
||||||
|
- hover 同步加守卫:非引用属性值不再显示"未找到匹配定义"文案;`Include@source` 走专门的"Include 源文件"分支。
|
||||||
|
|
||||||
|
### 4.3 Include 源解析(P0)
|
||||||
|
|
||||||
|
- hover / 定义跳转 / 文档链接统一改为**优先调用 `resolveSource`**(按 BAB 顺序、校验文件存在),候选表只作为兜底与补全来源。
|
||||||
|
- `buildSearchPaths` 对齐 BAB 编译参数:DATA/ART/AUDIO 各补上 `modGranParent` 与 `SDK\Mods` 两个搜索条目,顺序与 `defaultscript.cs` 一致。
|
||||||
|
- 候选表去重改为大小写不敏感(按 `source.toLowerCase()` 去重),避免同一文件因大小写不同出现两条候选。
|
||||||
|
|
||||||
|
### 4.4 测试补充
|
||||||
|
|
||||||
|
- 新增/更新单测:
|
||||||
|
- 非引用属性(`Include@type`、`Include@source`)不会产生 unresolved-reference 目标;
|
||||||
|
- `DATA:Static.xml` 按 BAB 顺序解析到 SDK 根目录而非 SageXml;
|
||||||
|
- `buildSearchPaths` 展开结果与 BAB 参数一致(构造 SDK 内/外两种项目布局断言顺序)。
|
||||||
|
- 回归验证:AttachTest / GenEvoTest / Corona 各索引一次,确认 0 误报、补全/跳转正常。
|
||||||
|
|
||||||
|
### 4.5 不在本次范围
|
||||||
|
|
||||||
|
- P1 搜索与索引复用设计;
|
||||||
|
- manifest 深度解析(资产类型不匹配提示的进一步细化)。
|
||||||
|
|
||||||
|
## 五、风险与影响
|
||||||
|
|
||||||
|
- grammar 改动只影响高亮层,不影响索引与诊断逻辑;
|
||||||
|
- 搜索路径顺序变化可能影响个别 include 的解析结果(更接近 BAB 真实行为),需在三个真实项目上回归;
|
||||||
|
- 候选表去重策略变化理论上减少补全条目重复,不影响条目正确性。
|
||||||
|
|
||||||
|
## 六、实施结果(2026-08-01)
|
||||||
|
|
||||||
|
已按上述方案完成修复并验证:
|
||||||
|
|
||||||
|
1. **grammar 改为纯注入**:`package.json` 的 `grammars` 条目移除 `"language": "xml"`,内置 XML 语法恢复为主语法,领域关键词以注入方式叠加。
|
||||||
|
2. **引用属性守卫恢复**:新增 `isReferenceAttribute()` 纯函数,诊断与 hover 仅对 `inheritFrom` / 带 `refType` 的属性做未解析引用检查;`Include@type`、`Include@source` 不再误报。
|
||||||
|
3. **Include 源解析优先 `resolveSource`**:hover / 定义跳转 / 文档链接统一先按 BAB 顺序解析;候选表仅作补全来源并按 source 去重、SDK 根目录条目排前。
|
||||||
|
4. **搜索路径对齐 BAB 参数**:`buildSearchPaths` 的 DATA/ART/AUDIO 各补入 `modGranParent` 与 `SDK\Mods`,顺序与 `defaultscript.cs` 的 `/data /art /audio` 一致。
|
||||||
|
|
||||||
|
实测(AttachTest,C: 盘):
|
||||||
|
|
||||||
|
```
|
||||||
|
DATA:Static.xml resolves to: C:\Apps\RA3-MODSDK-X\Static.xml (OK,不再指向 SageXml)
|
||||||
|
DATA:static.xml candidates: 1 -> C:\Apps\RA3-MODSDK-X\Static.xml (去重生效)
|
||||||
|
isReferenceAttribute(Include,type) = false
|
||||||
|
isReferenceAttribute(Include,source) = false
|
||||||
|
CommandSet 引用仍能解析到 LogicCommandSet 定义(无回归)
|
||||||
|
```
|
||||||
|
|
||||||
|
单元测试 24/24 通过(新增:BAB 搜索路径顺序、SDK 根优先于 SageXml、`isReferenceAttribute` 判定)。
|
||||||
|
|
||||||
|
> 注:本环境当前无法访问 `D:\Mods\CoronaMod`(D: 盘不可见),GenEvoTest / Corona 的实机回归待 D: 盘可用后补跑;相关逻辑已被 AttachTest 与单元测试覆盖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、问题分析(第二轮,2026-08-01)
|
||||||
|
|
||||||
|
用户在 AttachTest `Guardian Tank\GameObject.xml` 上报了三个新问题,均已修复并验证。
|
||||||
|
|
||||||
|
### 问题 A:`Side="Allies"` 误报"未被定义"
|
||||||
|
|
||||||
|
**现象**:`Side="Allies"` 报 unresolved reference,但 `global.manifest` 中存在 `PlayerTemplate:Allies`。
|
||||||
|
|
||||||
|
**根因**:`Side` 属性确实是引用(`PlayerTemplateWeakRef` → `PlayerTemplate`),但 manifest 解析器依赖 OpenSAGE `AssetType.cs` 的 TypeId 哈希表,而该表**不完整**(实测 `Global.manifest` 11268 个资产中有 2707 个哈希未知,`PlayerTemplate` 即缺失)。未知类型被记成 `#哈希`,无法与 `refType=PlayerTemplate` 匹配。
|
||||||
|
|
||||||
|
**修复**:manifest 资产名本身是 `类型名:ID` 格式(如 `PlayerTemplate:Allies`),新增 `deriveAssetType()`——哈希已知时用哈希;未知时从名称前缀推导类型(资产 ID 不允许含冒号,切分无歧义)。修复后 `Side="Allies"` 正确解析到 `PlayerTemplate@Global.manifest`。
|
||||||
|
|
||||||
|
### 问题 B:`<Weapon>` 报没有 `Ordering` 属性
|
||||||
|
|
||||||
|
**现象**:`WeaponSetUpdate → WeaponSlotTurret → Weapon` 链中 `<Weapon Ordering=...>` 报未知属性;XSD 里 `WeaponSlot_WeaponData` 确实有 `Ordering`。
|
||||||
|
|
||||||
|
**根因**:模型用"元素名→类型"的**全局单映射**,同名元素(如 `<Weapon>` 出现在武器槽、慢速死亡、引用等许多上下文)以"先到先得"注册。`Weapon` 被注册成 `WeaponRef`,因此按全局映射查不到 `Ordering`。这是典型的**上下文相关类型**问题。
|
||||||
|
|
||||||
|
**修复**:新增上下文感知类型解析:
|
||||||
|
- `childTypeOf(父类型, 子元素名)` / `elementTypeIn(父元素, 子元素名)` / `attributesOfType(类型)`;
|
||||||
|
- `resolveElementType(元素)` 沿解析树向上逐层用父类型的子元素声明解析真实类型,失败时回退全局映射。
|
||||||
|
|
||||||
|
修复后 `Weapon`(在 `WeaponSlotTurret` 下)解析为 `WeaponSlot_WeaponData`,`Ordering` 合法;且 `Ordering` 本身不是引用,不再被误判。
|
||||||
|
|
||||||
|
### 问题 C:嵌套 `xi:include`(第 248 行)未处理
|
||||||
|
|
||||||
|
**现象**:`<xi:include href="DATA:Includes/HeadlightDraw2.xml" xpointer="...">` 位于元素内部(非 `AssetDeclaration` 直接子级),原实现只处理根级 `xi:include`,嵌套的一律静默忽略。
|
||||||
|
|
||||||
|
**根因**:索引器 `walk()` 只遍历根的直接子元素处理 `xi:include`。
|
||||||
|
|
||||||
|
**修复**:解析后遍历文档全部元素,对**任意层级**的 `xi:include`:解析 `href`(按 BAB 搜索路径);目标缺失时产生 `include-not-found` 诊断(不再静默);目标存在时记录并 walk 目标文件,使其内容/资产进入索引。`href` 的文档链接(Ctrl+点击)此前已可用。
|
||||||
|
|
||||||
|
### 举一反三的测试
|
||||||
|
|
||||||
|
- `test/typeContext.test.mjs`:用真实结构(GameObject → BehaviorModules → WeaponSetUpdate → WeaponSlotTurret → Weapon)验证上下文类型解析;`childTypeOf` 原语断言。
|
||||||
|
- `test/manifestTypes.test.mjs`:`deriveAssetType` 前缀推导(含无冒号/前导冒号边界);模拟 `PlayerTemplate:Allies` 后 `Side` 引用解析成功;`isReferenceAttributeOfType` 判定。
|
||||||
|
- `test/indexer.test.mjs`:新增嵌套 `xi:include` 断言——目标文件被索引、目标资产可用、缺失目标产生诊断。
|
||||||
|
- AttachTest 实机验证:`Side="Allies"` → `PlayerTemplate@Global.manifest`;`Weapon` 类型 → `WeaponSlot_WeaponData`(含 `Ordering`);`HeadlightDraw2.xml` 进入索引、无相关诊断。
|
||||||
|
|
||||||
|
单元测试 28/28 通过,`.vsix` 已重新打包。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、问题分析(第三轮,2026-08-01)
|
||||||
|
|
||||||
|
用户在 AttachTest `Guardian Tank\GameObject.xml` 上报了三个问题,均已修复并验证。
|
||||||
|
|
||||||
|
### 问题 A:`Locomotor="..."` 误报 "has no definition of type true"
|
||||||
|
|
||||||
|
**现象**:`LocomotorSet/@Locomotor` 引用报错,且类型显示为 **`true`**。
|
||||||
|
|
||||||
|
**根因**:模型生成器把简单类型的 `xas:isRef="true"` 当成了 `refType` 值。`Locomotor` 的类型是 `AssetReference`(只有 `xas:isRef="true"`、没有 `refType`),于是 refType 被写成字符串 `"true"`,导致任何类型匹配都失败。这是生成器 `refType` 取值逻辑的 bug。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- 生成器只从 `xas:refType` 取 refType,`xas:isRef` 只作为布尔标记 `isRef`(属性条目新增该字段);
|
||||||
|
- `isReferenceAttributeOfType` 对 `refType` 或 `isRef` 都视为引用;
|
||||||
|
- `resolveReferenceTargetsForType` 对**无类型引用**(isRef 且无 refType)不做类型过滤,直接匹配同名 id——正是 `Locomotor` 这类"引用任意已声明资产"的语义。
|
||||||
|
|
||||||
|
修复后 `Locomotor="AlliedAntiVehicleVehicleTech1Locomotor"` 正确解析到 mod 的 `LocomotorTemplate`(以及 static.manifest 中的同名原版资产)。
|
||||||
|
|
||||||
|
### 问题 B:`Name="AUAntiVehicleVehicleTech1_SKN"` 误报 BaseRenderAssetType 未定义
|
||||||
|
|
||||||
|
**现象**:W3D 模型名引用报"no definition of type BaseRenderAssetType",但资产确实存在于 static.manifest。
|
||||||
|
|
||||||
|
**根因**:两个叠加原因:
|
||||||
|
1. manifest 名格式是 `类型:子类型:文件名`(如 `W3dContainer:W3DContainer:AUANTIVEHICLEVEHICLETECH1_SKN`),旧的 id 提取只切掉第一个前缀,得到 `W3DContainer:AUANTIVEHICLEVEHICLETECH1_SKN`,无法匹配 XML 里的 `AUAntiVehicleVehicleTech1_SKN`;
|
||||||
|
2. manifest 哈希表里的类型名是 `W3dContainer`(小写 d),而 XSD 类型名是 `W3DContainer`,大小写不一致导致继承链匹配失败。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- `deriveAssetId` 改为取**最后一个冒号段**(资产 ID 不允许含冒号),兼容 `PlayerTemplate:Allies` 与 `W3dContainer:W3DContainer:...` 两种格式;
|
||||||
|
- `canonicalTypeName` 大小写不敏感规范化,`typeChain` / `isAssignableTo` / 属性查询统一走规范化;manifest 资产入库时类型名也规范化。
|
||||||
|
|
||||||
|
类型匹配严格遵循 XSD 继承链:`W3DContainer` / `W3DMesh` → `BaseRenderAssetType` ✓;`W3DHierarchy` → `BaseAssetType`(**不是** BaseRenderAssetType)✗。实测 `_SKN` 只解析出 `W3DContainer@Static.manifest` 一个候选,碰撞盒(id 带 `.OBBOX` 后缀)与层级自然排除。
|
||||||
|
|
||||||
|
### 问题 C:跳转候选"只能打开文件、不能精确定位"
|
||||||
|
|
||||||
|
**现象**:`Template="AlliedAntiVehicleVehicleTech1Cannon"` 有两个候选——mod 定义和 `SageXml\globaldata\weapon.xml`(由 manifest 的 `manifestSource` 映射而来)。后者只打开文件、不定位。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- 所有 XML 定义的跳转改为**精确范围**:用 `id` 属性值的起止偏移构造 Range(找不到时回退到元素起始标签),mod 与 SageXml 一致;
|
||||||
|
- manifest 定义映射到源码文件时,同样在源码文件内查找同名 id 并精确定位(实测定位到 `weapon.xml:2357`);
|
||||||
|
- 新增配置 `ra3modxml.definitionMode`:`all`(默认,mod + 原版全部列出,mod 优先)或 `project-only`(项目内已定义时只跳 mod 定义)。
|
||||||
|
|
||||||
|
### 举一反三的测试(31/31 通过)
|
||||||
|
|
||||||
|
- `test/manifestTypes.test.mjs`:`deriveAssetId` 最后一段提取(含 W3D/纹理双前缀、无冒号边界);大小写规范化后的 `isAssignableTo`(`W3dContainer`→`BaseRenderAssetType` 为真、`W3dHierarchy` 为假)。
|
||||||
|
- `test/refs.test.mjs`:无类型引用(`LocomotorSet/@Locomotor`)解析到任意声明类型的同名 id;非引用属性仍返回空。
|
||||||
|
- AttachTest 实机:三个原始场景全部按预期(Locomotor 命中 LocomotorTemplate、_SKN 命中 W3DContainer、cannon 双候选均可精确定位)。
|
||||||
|
|
||||||
|
`.vsix` 已重新打包(13:33)。D 盘恢复后照旧可补跑 GenEvoTest / Corona 回归。
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
# 调研结论与实施计划(已按最新代码同步更新)
|
||||||
|
|
||||||
|
> 说明:本文档随实现演进持续同步。最近一次同步(2026-08-01)对齐了实现过程中新增的模块与设计变更:BAB 精确搜索路径、manifest 类型/ID 推导、上下文感知元素类型、无类型引用、精确跳转范围、嵌套 `xi:include`、注入式语法高亮等。
|
||||||
|
|
||||||
|
## 一、调研结论(带证据)
|
||||||
|
|
||||||
|
### 1. Include 解析规则(证据:`check_duplicate_ids.py` + `defaultscript.cs`)
|
||||||
|
|
||||||
|
从 `Data/Mod.xml` 递归处理 `<Include type="all">`(以及任意层级的 `xi:include`)。路径解析按 `defaultscript.cs` `getIncludePaths()` 的编译参数(`/data /art /audio`,`.` 为 SDK 根目录):
|
||||||
|
|
||||||
|
- `DATA:`:SDK 根 → modGranParent → 项目 `Data` → `SDK\Mods` → modParentPath → `SDK\SageXml`
|
||||||
|
- `ART:`:SDK 根 → modGranParent → 项目 `Art1` → 项目 `Art` → `SDK\Mods` → modParentPath → `SDK\Art`
|
||||||
|
- `AUDIO:`:SDK 根 → modGranParent → 项目 `Audio1` → 项目 `Audio` → `SDK\Mods` → modParentPath → `SDK\Audio`
|
||||||
|
|
||||||
|
其中 modParentPath = 项目目录的父目录,modGranParent = modParentPath 的父目录(如 AttachTest 位于 SDK 内时二者都收敛到 SDK 目录)。无前缀路径相对于当前文件目录解析;`ART:` 支持 2 字母前缀匹配。`defaultscript.cs` 第 4 步“建立全局数据”按 `additionalmaps\mapmetadata_*.xml` 逐个编译;第 5 步“建立基础数据”编译 `Data/Mod.xml`。
|
||||||
|
|
||||||
|
### 2. 文件规模(证据:实测统计)
|
||||||
|
|
||||||
|
- Corona `Data`:**7540 个 XML / 38.1 MB**
|
||||||
|
- SDK `SageXml`:**5976 个 XML / 21.4 MB**
|
||||||
|
- 合计约 1.3 万个文件、60 MB → 索引必须后台化、缓存解析结果、按需惰性加载。
|
||||||
|
|
||||||
|
### 3. XSD 结构(证据:`Schemas/xsd` 实测)
|
||||||
|
|
||||||
|
- 共 **821 个 XSD / 1.5 MB**,入口 `CnC3Types.xsd`。
|
||||||
|
- 根元素 `AssetDeclaration`:`Tags` / `Includes` / `Defines` + **295 个顶层资产元素**(含内联声明)。
|
||||||
|
- 每个元素名对应一个 `complexType`,子元素用 `xs:sequence` / `xs:choice` 定义,属性用 `xs:attribute` 定义;复杂类型通过 `xs:extension` 继承(如 `BaseInheritableAsset` 提供 `inheritFrom`)。
|
||||||
|
- `Includes/Ref.xsd` 定义了大量带 `xas:refType="<资产类型>"` 的引用类型(如 `CommandSet` 引用 `LogicCommandSet`)→ 补全/导航按引用类型过滤的依据。
|
||||||
|
- `XmlEdit:Default` 提供默认值;`xs:enumeration` 提供枚举值;`xas:isRef="true"` 无 `refType` 时为**无类型引用**(匹配任意已声明资产)。
|
||||||
|
- **同名元素在不同父节点下类型不同**(如 `<Weapon>` 在武器槽下是 `WeaponSlot_WeaponData`,在别处可能是 `WeaponRef`)→ 需要上下文感知解析。
|
||||||
|
|
||||||
|
### 4. 领域特有约定
|
||||||
|
|
||||||
|
- `$NAME` / `=$NAME` 是 `<Defines>` 中定义的常量引用(Corona `GlobalData/GlobalDefines.xml` 实测),属性值补全与 hover 支持。
|
||||||
|
- `xai:joinAction` 实测取值 `Replace`、`Remove`。
|
||||||
|
- 原版数据双来源:SDK `SageXml` 提供 XML 源码;`reference` 指向的编译 manifest 提供完整资产表(含美术素材)。
|
||||||
|
|
||||||
|
### 5. 网络与工具链(证据:本机实测)
|
||||||
|
|
||||||
|
- Node v24.16.0、npm 11.13.0、VS Code 1.130 可用;无全局 `vsce`、Python 不可用。
|
||||||
|
- npm 注册表可用(需提升权限安装依赖);esbuild 原生二进制在沙箱内受限,构建需提升权限。
|
||||||
|
|
||||||
|
### 6. manifest 二进制格式(证据:工作区 `OpenSAGE/` 源码)
|
||||||
|
|
||||||
|
用户已克隆 OpenSAGE 并切到 `d45d361`,`src/OpenSage.Game/Data/StreamFS/ManifestFile.cs` 给出完整格式:
|
||||||
|
|
||||||
|
- 头部:首 4 字节为 0 时版本固定为 7;否则 `IsBigEndian`(1B) + `IsLinked`(1B) + `Version`(u16,5/6) + 10 个 u32 缓冲区/计数信息。
|
||||||
|
- 资产条目(版本 ≥ 6 时每条 48B):`TypeId`/`InstanceId`/`TypeHash`/`InstanceHash`/`AssetReferenceOffset`/`AssetReferenceCount`/`NameOffset`/`SourceFileNameOffset`/`InstanceDataSize`/`RelocationDataSize`/`ImportsDataSize`。
|
||||||
|
- 之后依次是:资产引用缓冲区、被引用 manifest 名缓冲区、资产名字符串缓冲区、源文件名字符串缓冲区。
|
||||||
|
- `TypeId` 为哈希;OpenSAGE `AssetType.cs` 枚举提供部分哈希→类型名映射(**不完整**:`Global.manifest` 11268 个资产中 2707 个哈希未知,如 `PlayerTemplate`)。
|
||||||
|
- **实测发现**:manifest 资产名是 `类型名:ID` 格式,美术资产还带子类型段(`W3dContainer:W3DContainer:AUANTIVEHICLEVEHICLETECH1_SKN`)。类型可从第一个冒号段推导,可引用 ID 取最后一个冒号段。
|
||||||
|
|
||||||
|
**结论**:manifest 解析为 P0 能力,用于 reference include 的资产补全/悬停/导航/诊断。
|
||||||
|
|
||||||
|
## 二、技术方案
|
||||||
|
|
||||||
|
### 选型
|
||||||
|
|
||||||
|
- **TypeScript + VS Code 扩展 API(非 LSP)**:补全 / hover / 跳转 / 诊断 / 大纲均用原生 provider,无需语言服务器进程。
|
||||||
|
- **核心与编辑器解耦**:`language/`、`model/`、`indexer/` 为纯 TS 模块(不 import `vscode`),可单测与复用(呼应 P1 需求 7)。
|
||||||
|
- **esbuild 打包**,产物 `dist/extension.js`。
|
||||||
|
- **XSD → JSON 模型**:开发期工具 `tools/xsd-to-model.mjs` 把 821 个 XSD 解析成 `schema-model.json`(元素树、属性、文档、枚举、引用类型映射),随插件发布;运行时不再解析 XSD。
|
||||||
|
- **运行时 XML 解析**:自研带源码偏移的轻量解析器 `language/xmlParser.ts`(标签/属性/值均记录起止偏移,容错解析以支持输入中的补全与诊断)。`fast-xml-parser` 仅用于开发期 XSD 生成。
|
||||||
|
- **AssetType 哈希表**:`tools/extract-asset-types.mjs` 从 OpenSAGE `AssetType.cs` 提取 `asset-types.json`;哈希未知时以 manifest 名称前缀推导类型。
|
||||||
|
|
||||||
|
### 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
extension.ts 激活入口(provider 注册、索引调度、诊断调度)
|
||||||
|
workspace.ts 项目检测(Data/Mod.xml / mod.babproj)、索引生命周期、状态栏、重建防抖
|
||||||
|
settings.ts 配置读取(sdkPath、indexSageXml、definitionMode 等)
|
||||||
|
language/
|
||||||
|
xmlParser.ts 带源码偏移的轻量 XML 解析器(格式错误定位、容错)
|
||||||
|
context.ts 补全上下文分析(元素名/属性名/属性值/内容)
|
||||||
|
typeContext.ts 上下文感知元素类型解析(resolveElementType 沿解析树逐层解析)
|
||||||
|
model/
|
||||||
|
schemaModel.ts schema-model.json 的类型/属性/子元素查询 + 类型名规范化(纯 TS)
|
||||||
|
schema-model.json 由 tools/xsd-to-model.mjs 生成
|
||||||
|
asset-types.json 由 tools/extract-asset-types.mjs 生成(TypeId 哈希→类型名)
|
||||||
|
indexer/
|
||||||
|
includeResolver.ts Include 路径解析(纯 TS,BAB /data /art /audio 顺序)
|
||||||
|
manifestParser.ts .manifest 二进制解析 + 类型/ID 推导(纯 TS)
|
||||||
|
fileScanner.ts 目录遍历缓存 + Include source 候选收集
|
||||||
|
refs.ts 引用目标解析(按 refType / isRef / inheritFrom 过滤,纯 TS)
|
||||||
|
indexer.ts 工作区索引器(资产/Define/流/manifest 合并,LRU 解析缓存)
|
||||||
|
types.ts 共享类型
|
||||||
|
features/
|
||||||
|
completion.ts 补全 provider(元素/属性/值,上下文感知)
|
||||||
|
hover.ts hover provider
|
||||||
|
navigation.ts 定义/引用/文档链接/大纲
|
||||||
|
diagnostics.ts 实时诊断
|
||||||
|
syntaxes/
|
||||||
|
ra3modxml.tmLanguage.json 注入 source.xml 的领域高亮(纯注入,不替换 XML 主语法)
|
||||||
|
tools/
|
||||||
|
xsd-to-model.mjs XSD → schema-model.json
|
||||||
|
extract-asset-types.mjs OpenSAGE AssetType.cs → asset-types.json
|
||||||
|
test/
|
||||||
|
fixtures/minimod 样例 Mod(include 各种情形、同名 ID、嵌套 xi:include、manifest 回退)
|
||||||
|
*.test.mjs 8 个测试文件(xmlParser / includeResolver / manifestParser /
|
||||||
|
indexer / schemaModel / refs / typeContext / manifestTypes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键设计决策
|
||||||
|
|
||||||
|
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。
|
||||||
|
3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer` ↔ `W3DContainer`),类型匹配严格遵循 XSD 继承链。
|
||||||
|
4. **上下文感知元素类型**:同名元素按父元素类型解析(`resolveElementType` 沿解析树逐层 `childTypeOf`,失败回退全局映射),保证 `<Weapon>` 等元素的属性/引用判定正确。
|
||||||
|
5. **引用判定与解析**:`refType` 或 `isRef` 均视为引用;带 `refType` 时严格按类型过滤(同名 ID 不串类型);无类型引用匹配任意声明 ID;`inheritFrom` 按可继承类型过滤。
|
||||||
|
6. **重复 ID 诊断**:与 `check_duplicate_ids.py` 一致——SageXml 不参与冲突判定,mod 覆盖原版视为正常。
|
||||||
|
7. **未解析引用诊断**:按设置严重级别报告(默认 warning);类型不匹配时给出明确文案("有同名 ID 但类型不匹配")。`definitionMode` 设置控制跳转候选:`all`(mod + 原版全部列出,mod 优先)或 `project-only`。
|
||||||
|
8. **跳转精度**:XML 定义跳转到 `id` 属性值的精确 Range;manifest 定义映射到源码文件(如 SageXml)时也在文件内精确定位;找不到再回退到记录行。
|
||||||
|
9. **嵌套 `xi:include`**:任意层级处理——目标缺失产生诊断、目标存在则纳入索引;根级 `xpointer` 容器内容按顶层资产索引。
|
||||||
|
10. **性能**:索引在后台执行;解析结果 LRU 缓存(约 64 个文档);文件保存后防抖全量重建(1.5s),重建期间的新请求标记脏并在完成后重跑;状态栏显示进度与统计。
|
||||||
|
|
||||||
|
## 三、实施步骤
|
||||||
|
|
||||||
|
1. [x] 调研(Include 规则、XSD、示例项目、规模、工具链)——见本文档第一部分。
|
||||||
|
2. [x] 脚手架:`package.json`、`tsconfig.json`、esbuild、`.vscodeignore`、README。
|
||||||
|
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,31 个用例全绿)+ 编译 + `vsce package` 打包(ra3-mod-xml-0.1.0.vsix,约 259KB)。
|
||||||
|
7. [x] 在 AttachTest / GenEvoTest / Corona 上做冒烟验证,并按真实项目反馈修复问题(详见 `docs/analysis-issues.md` 三轮分析)。
|
||||||
|
|
||||||
|
## 四、验证结果(实测)
|
||||||
|
|
||||||
|
| 项目 | 规模 | 索引耗时 | 资产数 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 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 诊断 |
|
||||||
|
|
||||||
|
单元测试覆盖:XML 解析(自闭合/容错/偏移)、include 解析(BAB 顺序、SDK 根优先于 SageXml)、manifest 二进制解析(合成 v5 样本、类型/ID 推导)、索引器(资产/Define/流/缺失 include/嵌套 xi:include)、XSD 模型(上下文类型、`childTypeOf`、大小写规范化)、引用过滤(`Weapon="X"` 只跳 `WeaponTemplate`、无类型引用、`Side="Allies"` 命中 manifest 的 `PlayerTemplate`)。
|
||||||
|
|
||||||
|
> 注:`D:\Mods\CoronaMod` 位于移动硬盘,当前未连接;GenEvoTest / Corona 的回归需在 D: 盘可用时补跑(用户会另行通知)。
|
||||||
|
|
||||||
|
## 五、假设与开放问题
|
||||||
|
|
||||||
|
- 假设 SDK 路径默认 `C:\Apps\RA3-MODSDK-X`(与 prompts 一致),可在设置中修改。
|
||||||
|
- 假设补全/导航以“文本语义分析”为主,不做完整 XSD 校验(BAB 才是权威校验器)。
|
||||||
|
- 开放:是否发布到 VS Code Marketplace(需要 publisher)——本期先保证本地 `vsce package` 可安装。
|
||||||
|
- 开放:嵌套 `xi:include` 内容的"虚拟合并"进父文档(用于父文档内的补全/诊断感知被内联内容)——当前仅保证目标文件可索引、可导航、缺失可诊断。
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# RA3 Mod XML VSCode 插件 — 情况描述与需求清单
|
||||||
|
|
||||||
|
> 由 `prompts` 中的零散描述整理而成。目标产物:一个用于编辑《命令与征服:红色警戒 3》Mod XML 文件的 VS Code 扩展。
|
||||||
|
|
||||||
|
## 一、情况描述(整理后)
|
||||||
|
|
||||||
|
红警 3 的 Mod 数据以 XML 文件组织,由 EA 的 BinaryAssetBuilder(BAB)编译。一个 Mod 项目通常包含两类数据:
|
||||||
|
|
||||||
|
1. **基础数据(Static Data)**:单位、武器、建筑、贴图、音频、AI、UI 等绝大多数内容。入口文件是 `Data/Mod.xml`。
|
||||||
|
2. **全局数据(Global Data)**:无法放进基础数据里的全局配置(游戏设置、全局单例等)。入口是 `Data/additionalmaps/mapmetadata_*.xml` 这类文件——它们原本是 EA 的地图元数据文件,但因为加载最早且允许写任意标签,被 modder 用作“全局数据”入口。
|
||||||
|
|
||||||
|
XML 之间的组织靠 `<Include>` 标签,共有三种语义:
|
||||||
|
|
||||||
|
| type | 语义 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `reference` | 引用预编译 manifest | 不展开内容。典型用法是 `DATA:static.xml` / `DATA:global.xml` / `DATA:audio.xml` 这三个占位文件,实际对应 SDK `builtmods` 里已编译的 `static.manifest` / `global.manifest` / `audio.manifest` |
|
||||||
|
| `instance` | 源码级可见 | 不展开进当前文件,但编译时“看得到”目标源码;典型用于 `inheritFrom` 继承 |
|
||||||
|
| `all` | 内容合并 | 等价于把目标文件内容直接复制进来;`Mod.xml` 通过它递归聚合整个 Mod |
|
||||||
|
|
||||||
|
路径解析规则(来自现有工具 `check_duplicate_ids.py` 与编译脚本 `defaultscript.cs`):
|
||||||
|
|
||||||
|
- 带前缀 `DATA:` / `ART:` / `AUDIO:` 的路径按固定搜索顺序查找;
|
||||||
|
- 无前缀的路径相对于当前文件所在目录;
|
||||||
|
- `ART:` 路径支持“文件名前两个小写字母作为子目录”的匹配(如 `JUAntiShip` → `ju/JUAntiShip`)。
|
||||||
|
|
||||||
|
继承机制:`inheritFrom` 让一个元素默认获得目标元素的所有内容;具体合并行为由 `xai:joinAction`(`uri:ea.com:eala:asset:instance` 命名空间)控制,实际项目中出现的取值为 `Replace`、`Remove`。
|
||||||
|
|
||||||
|
全部 XML 语法由 XSD 定义:SDK 自带 `Schemas/xsd/CnC3Types.xsd`(及其 800+ 个子 XSD)。大型 Mod 项目(如 Corona)还会携带自己修改过的 XSD 副本。
|
||||||
|
|
||||||
|
## 二、需求清单
|
||||||
|
|
||||||
|
### P0:近期核心功能
|
||||||
|
|
||||||
|
1. **语法高亮**:为 RA3 Mod XML 提供可读的高亮;重点补充普通 XML 高亮之外的领域标记(如 `$DEFINE` 常量引用、`inheritFrom` 等)。
|
||||||
|
2. **自动补全**:
|
||||||
|
- 元素名(按当前父元素的 XSD 定义补全,含顶层资产元素);
|
||||||
|
- 属性名(按当前元素的 XSD 定义补全,`id` 必填者优先);
|
||||||
|
- 属性值:
|
||||||
|
- 引用型属性(XSD 中带 `xas:refType`)补全已定义的资产 ID;
|
||||||
|
- `inheritFrom` 补全可继承的资产 ID;
|
||||||
|
- 枚举值(XSD `xs:enumeration`);
|
||||||
|
- `$DEFINE` 常量(如 `$CIV_HEALTH_SMALL`);
|
||||||
|
- `<Include source>` 补全可解析的文件路径(`DATA:` / `ART:` / `AUDIO:`)。
|
||||||
|
3. **引用提示(Hover)**:元素/属性悬停显示 XSD 文档、类型、默认值;资产 ID 悬停显示定义位置;`$DEFINE` 悬停显示值与定义位置。
|
||||||
|
4. **引用导航**:
|
||||||
|
- 从引用型属性值跳转到对应资产定义(Go to Definition);
|
||||||
|
- 查找某资产 ID 的所有引用(Find All References);
|
||||||
|
- `<Include source>` / `xi:include href` 直接打开目标文件;
|
||||||
|
- `inheritFrom` 跳转到被继承元素;
|
||||||
|
- 文档大纲:显示文件内的顶层资产元素。
|
||||||
|
5. **错误检查(实时诊断)**:
|
||||||
|
- XML 格式错误(well-formedness);
|
||||||
|
- 未知元素 / 未知属性(相对 XSD 模型);
|
||||||
|
- 缺失必填 `id`(顶层资产);
|
||||||
|
- 重复 ID(同类型 + 同 id,mod 文件之间;覆盖原版 SageXml 不算冲突);
|
||||||
|
- 引用未解析(引用了不存在的资产 ID,可配置是否忽略原版 manifest 中的 ID);
|
||||||
|
- `<Include>` 目标文件找不到、Include 循环;
|
||||||
|
- `$DEFINE` 未定义。
|
||||||
|
|
||||||
|
**补充(manifest 解析,支持 include reference 后的补全/导航/诊断)**:
|
||||||
|
|
||||||
|
- 当 `Mod.xml`(或其他文件)用 `<Include type="reference" source="DATA:static.xml" />` 引用占位文件时,实际内容来自 SDK `builtmods` 下对应的已编译二进制 manifest(`static.manifest` / `global.manifest` / `audio.manifest`)。
|
||||||
|
- 通过解析这些 manifest,可以得到其包含的全部资产(名称、类型、来源文件),从而:
|
||||||
|
- **代码补全**:例如 reference 了 `audio.xml` 后,所有音频资产 ID 都能出现在引用型属性(如 `AudioEventRef`)的补全里;
|
||||||
|
- **引用导航/悬停**:能定位资产来自哪个 manifest、哪个源文件;
|
||||||
|
- **诊断**:能把“引用了 manifest 中的 ID”识别为已解析,而不是误报未解析引用。
|
||||||
|
- manifest 为二进制格式,解析逻辑参考 OpenSAGE `ManifestFile.cs`(用户已在本工作区 `OpenSAGE/` 克隆并切到指定 commit)。关键格式要点:
|
||||||
|
- 头部含版本(5/6/7)、端序标志、各缓冲区大小、资产数量;
|
||||||
|
- 每个资产条目含 `TypeId`(哈希)、`NameOffset`、`SourceFileNameOffset` 等;
|
||||||
|
- `TypeId` 哈希 → 类型名的映射来自 OpenSAGE 的 `AssetType` 枚举(本工作区可提取);
|
||||||
|
- 资产名与源文件名各自存放在独立的空字符结尾字符串缓冲区中。
|
||||||
|
|
||||||
|
### P1:非近期目标(本期不做,但预留扩展点)
|
||||||
|
|
||||||
|
6. **高效搜索**:Mod 项目巨大(Corona 约 7500 个 XML、38MB)时直接全文搜索很慢,需要一个高效的 XML 内容索引机制。
|
||||||
|
7. **索引机制的复用性**:希望索引不仅能服务 VS Code 插件,也能被其他工具(如搜索、静态分析)复用,因此索引/解析核心应设计成与编辑器无关的纯模块。
|
||||||
|
|
||||||
|
## 三、环境与参考资源
|
||||||
|
|
||||||
|
- SDK:`C:\Apps\RA3-MODSDK-X`(含 `defaultscript.cs` 编译脚本、`Schemas/xsd`、`SageXml` 原版源码、`builtmods` 编译产物)。
|
||||||
|
- 中小项目:`C:\Apps\RA3-MODSDK-X\Mods\AttachTest`、`D:\Mods\CoronaMod\mods\mods\GenEvoTest`。
|
||||||
|
- 大型项目:`D:\Mods\CoronaMod\mods\mods\corona`(自带 `xsd/`)。
|
||||||
|
- 现有工具:工作区 `check_duplicate_ids.py`(include 解析与重复 ID 检测的参考实现)。
|
||||||
|
- manifest 格式参考:OpenSAGE `src/OpenSage.Game/Data/StreamFS/ManifestFile.cs`(commit `d45d361`,最新分支已移除该文件)。本机网络受限未能拉取,且 manifest 为压缩/哈希的二进制,本期不实现其解析。
|
||||||
|
|
||||||
|
## 四、验收标准
|
||||||
|
|
||||||
|
- 在 AttachTest / GenEvoTest 上开箱即用(高亮、补全、跳转、诊断)。
|
||||||
|
- 在 Corona 规模的目录上不卡 UI:索引在后台执行、保存文件后增量更新。
|
||||||
|
- 纯解析/索引核心不依赖 VS Code API,可被其他工具复用。
|
||||||
|
- 可用 `vsce package` 打出可安装的 `.vsix`。
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import * as esbuild from "esbuild";
|
||||||
|
|
||||||
|
const watch = process.argv.includes("--watch");
|
||||||
|
|
||||||
|
const ctx = await esbuild.context({
|
||||||
|
entryPoints: ["./src/extension.ts"],
|
||||||
|
bundle: true,
|
||||||
|
outfile: "dist/extension.js",
|
||||||
|
external: ["vscode"],
|
||||||
|
format: "cjs",
|
||||||
|
platform: "node",
|
||||||
|
target: "es2022",
|
||||||
|
sourcemap: true,
|
||||||
|
logLevel: "info",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (watch) {
|
||||||
|
await ctx.watch();
|
||||||
|
console.log("Watching for changes...");
|
||||||
|
} else {
|
||||||
|
await ctx.rebuild();
|
||||||
|
await ctx.dispose();
|
||||||
|
}
|
||||||
Generated
+2962
File diff suppressed because it is too large
Load Diff
+115
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"publisher": "ra3-mod-xml",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"vscode": "^1.85.0"
|
||||||
|
},
|
||||||
|
"categories": [
|
||||||
|
"Programming Languages",
|
||||||
|
"Linters"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"red alert 3",
|
||||||
|
"ra3",
|
||||||
|
"mod",
|
||||||
|
"sage",
|
||||||
|
"xml",
|
||||||
|
"binaryassetbuilder"
|
||||||
|
],
|
||||||
|
"main": "./dist/extension.js",
|
||||||
|
"activationEvents": [
|
||||||
|
"workspaceContains:**/Data/Mod.xml",
|
||||||
|
"workspaceContains:**/mod.babproj",
|
||||||
|
"workspaceContains:**/*.babproj"
|
||||||
|
],
|
||||||
|
"contributes": {
|
||||||
|
"grammars": [
|
||||||
|
{
|
||||||
|
"scopeName": "source.ra3modxml",
|
||||||
|
"path": "./syntaxes/ra3modxml.tmLanguage.json",
|
||||||
|
"injectTo": [
|
||||||
|
"source.xml"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration": {
|
||||||
|
"title": "RA3 Mod XML",
|
||||||
|
"properties": {
|
||||||
|
"ra3modxml.sdkPath": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "C:\\Apps\\RA3-MODSDK-X",
|
||||||
|
"description": "Path to the RA3 Mod SDK root. Used to resolve DATA:/ART:/AUDIO: includes and to index vanilla SageXml sources."
|
||||||
|
},
|
||||||
|
"ra3modxml.indexSageXml": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Index vanilla game XML sources shipped with the SDK (SageXml) so completions/references can resolve vanilla asset ids."
|
||||||
|
},
|
||||||
|
"ra3modxml.reportUnresolvedReferences": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"warning",
|
||||||
|
"information",
|
||||||
|
"none"
|
||||||
|
],
|
||||||
|
"default": "warning",
|
||||||
|
"description": "Severity for attribute references (CommandSet=..., inheritFrom=...) that cannot be resolved in the index. IDs that only exist in compiled manifests cannot be resolved yet."
|
||||||
|
},
|
||||||
|
"ra3modxml.diagnoseUnknownElements": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Report element/attribute names that are not known from the bundled XSD model."
|
||||||
|
},
|
||||||
|
"ra3modxml.definitionMode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"all",
|
||||||
|
"project-only"
|
||||||
|
],
|
||||||
|
"default": "all",
|
||||||
|
"description": "Go-to-definition candidates: 'all' lists the mod definition together with any vanilla (SDK/manifest) definitions (mod first); 'project-only' jumps directly to the mod definition and skips vanilla candidates when the id is defined in the project."
|
||||||
|
},
|
||||||
|
"ra3modxml.additionalDataSearchPaths": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"default": [],
|
||||||
|
"description": "Extra absolute directories appended to the DATA: search path (checked after the project folders)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "ra3modxml.reindex",
|
||||||
|
"title": "RA3 Mod XML: Re-index workspace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "ra3modxml.openIndexReport",
|
||||||
|
"title": "RA3 Mod XML: Show index report"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "node esbuild.mjs",
|
||||||
|
"watch": "node esbuild.mjs --watch",
|
||||||
|
"generate-model": "node tools/xsd-to-model.mjs",
|
||||||
|
"pretest": "tsc",
|
||||||
|
"test": "node --test \"test/*.test.mjs\"",
|
||||||
|
"package": "npm run build && vsce package"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"fast-xml-parser": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vscode/vsce": "^2.30.0",
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/vscode": "^1.85.0",
|
||||||
|
"esbuild": "^0.21.5",
|
||||||
|
"typescript": "^5.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
我希望创建一个用于红警3 Modding XML 的 VSCode 扩展插件。这个扩展插件将提供以下功能:
|
||||||
|
1. 语法高亮:为红警3 Modding XML 文件提供语法高亮显示,使代码更易读。可能许多 XML 插件已经支持这个功能。
|
||||||
|
2. 自动补全:根据红警3 Modding XML 的语法规则,提供自动补全功能,帮助用户快速编写代码。
|
||||||
|
3. 引用提示:在用户输入时,提供相关的引用提示,帮助用户了解可用的标签和属性。
|
||||||
|
4. 引用导航:允许用户快速跳转到相关的标签或属性定义,提升代码浏览和编辑效率。
|
||||||
|
5. 错误检查:在用户编写代码时,实时检查语法错误,并提供错误提示,帮助用户快速修复问题。
|
||||||
|
[非近期目标] 6. 搜索:在 mod 项目巨大时,提供高效的搜索方式(直接搜索可能会很慢),这种特殊需求可能有很多需要考虑的部分,需要后期仔细设计,暂时不考虑。或者可以留一个极简的可拓展的搜索接口。
|
||||||
|
[非近期目标] 这些功能,可能会需要一个高效的索引 mod xml 内容的机制,才能实现 VSCode 拓展。那么这个机制能否用在 VSCode 拓展以外的部分呢?之前提到的“搜索”可能是一个例子,假如要能支持其他使用方式(而不仅仅是一个 VSCode 拓展),该怎么设计呢?
|
||||||
|
|
||||||
|
典型红警3 Mod XML 里有两种较为重要的入口:
|
||||||
|
1. Data/Mod.xml
|
||||||
|
这个是最主要的入口文件,定义了绝大部分内容
|
||||||
|
2. Data/mapmetadata_*.xml
|
||||||
|
这个本来是 EA 为红警3 提供的地图元数据文件,按照EA的原设计,应该是用来定义 <MapMetadata> 标签的内容。
|
||||||
|
但是这些 mapmetadata 文件在游戏中是最早被加载的,而且 XML 里也不止可以写 MapMetadata 标签,还可以写其他标签,所以这些最早加载的 XML 可以用来初始化游戏的很多全局配置,所以被玩家用来实现“全局数据”的配置
|
||||||
|
|
||||||
|
因此,mod 的 XML 文件可以分为两类:
|
||||||
|
1. 基础数据(Static Data),包含模组绝大多数素材、贴图、音频、单位定义、配置等
|
||||||
|
2. 全局数据(Global Data),包含一些无法卸载基础数据里的全局配置
|
||||||
|
|
||||||
|
从入口文件开始,XML 会包含其他 XML,主要方式是通过 <Include> 标签,
|
||||||
|
有三种方式:
|
||||||
|
1. reference:引用其他 XML 文件的内容。本身不会包含被引用 XML 的内容,只是引用了其他 XML 的内容。
|
||||||
|
最典型的使用方式是 reference static.xml, global.xml, audio.xml。
|
||||||
|
这三个 XML 一般用来代表游戏本体(而非 mod 的)基础数据、全局数据和音频数据。
|
||||||
|
这三个文件本身是占位符(空文件)。但是等价于 reference 了一个包含所有定义的 XML
|
||||||
|
实际上 BinaryAssetBuilder 会去引用 SDK 文件夹 builtmods 里的已编译二进制文件:
|
||||||
|
C:\Apps\RA3-MODSDK-X\builtmods,这里面的 static.manifest、global.manifest、audio.manifest
|
||||||
|
当前 XML 编译成 manifest 之后,会添加对应 manifest 的引用。
|
||||||
|
对于 manifest 的格式,可以参考这个开源代码:https://github.com/OpenSAGE/OpenSAGE/blob/d45d361e8f41211e34cc219d72d0502a6b2d5694/src/OpenSage.Game/Data/StreamFS/ManifestFile.cs (注意:最新版本的分支不再有这个文件夹,链接里的 commit 是最后一刻可以参考的版本)
|
||||||
|
2. instance:引用包含其他 XML 文件的源码。本身不会包含被引用 XML 的内容,只是在编译过程中能“看到”那个XML的源码。这种情况下被引用的是一个真实存在的 XML 文件,不是占位符,也不需要预编译的 manifest 文件。当前 XML 编译成 manifest 之后,不会因为 instance 就去引用另一个 manifest 文件。典型的使用方式是引用游戏本体的源码、使用 inheritFrom 进行继承。
|
||||||
|
3. all:等同于把目标 XML 的内容直接复制到当前 XML 里。因此 mod.xml 会递归使用 all 方式引用所有的 XML 文件,最终 mod.xml 会包含 mod 的所有 XML 内容。
|
||||||
|
|
||||||
|
inheritFrom 代表继承另一个 XML 元素的内容,默认拥有对象元素的所有内容。具体的继承细节还可以被uri:ea.com:eala:asset:instance:joinAction(一般是 xai:joinAction)控制
|
||||||
|
|
||||||
|
关于 include 规则,可以参考我之前开发的一个“检测重复id”的python小工具:它也需要解析 include 才能读取所有 xml 才能检测重复 id。你可以参考这个工具的源码,了解 include 的解析规则:check_duplicate_id.py
|
||||||
|
|
||||||
|
你还可以参考 defaultscript.cs
|
||||||
|
这里面有 mod 的编译脚本,尤其是它如何调用 BinaryAssetBuilder 的
|
||||||
|
可以重点关注的步骤是建立基础数据(Static Data)和建立全局数据(Global Data)
|
||||||
|
但是需要注意:这里面既有较为标准规范、适合参考的部分,也有一些是玩家 modder 们自行加上去的 hack,可能并不适合直接参考。
|
||||||
|
例如:玩家为了方便制作增量补丁,定义了 Art1、Data1 这样的文件夹结构
|
||||||
|
除此之外游戏模组还有一些和 XML 关系不大的部分(例如 HLSL),我们可以暂时不考虑这些部分。
|
||||||
|
你在分析的 defaultscript.cs 的时候,需要注意区分哪些是应该实际参考的
|
||||||
|
实际上有用的部分应该是和 check_duplicate_id.py 里类似的。
|
||||||
|
|
||||||
|
Mod SDK 里既提供编译后的原版游戏本体 manifest(用来支持 include reference),也提供原版游戏本体的部分源码(用来支持 include instance,或者就是供 modder 参考)。你可以参考这些源码,这些源码按照 defaultscript.cs 或者 check_duplicate_id.py 的 include 规则。例如,SDK 的 SageXml 文件夹对应 DATA:,里面的 XML 文件就是游戏本体的源码;游戏本体的美术素材(art)和音频素材(audio)也有对应的文件,只不过它们一般是空的(主要靠 reference 了)
|
||||||
|
|
||||||
|
游戏的 XML 格式由 XSD 规定,MOD SDK 自带 XSD:C:\Apps\RA3-MODSDK-X\Schemas\xsd\CnC3Types.xsd
|
||||||
|
|
||||||
|
可供参考的中小项目:
|
||||||
|
D:\Mods\CoronaMod\mods\mods\GenEvoTest
|
||||||
|
C:\Apps\RA3-MODSDK-X\Mods\AttachTest
|
||||||
|
可供参考的大型项目:
|
||||||
|
D:\Mods\CoronaMod\mods\mods\corona\
|
||||||
|
该大型项目自带自己的 xsd: D:\Mods\CoronaMod\mods\mods\corona\xsd\CnC3Types.xsd
|
||||||
|
以上,提供了很多外部项目进行参考,你被鼓励读取并考虑把文件复制到工作区里进行分析(本工作区内你可以随意操作),以避免对这些外部项目进行破坏性操作
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import * as vscode from "vscode";
|
||||||
|
import { ModWorkspace } from "./workspace";
|
||||||
|
import { Ra3CompletionProvider } from "./features/completion";
|
||||||
|
import { Ra3HoverProvider } from "./features/hover";
|
||||||
|
import {
|
||||||
|
Ra3DefinitionProvider,
|
||||||
|
Ra3DocumentLinkProvider,
|
||||||
|
Ra3DocumentSymbolProvider,
|
||||||
|
Ra3ReferenceProvider,
|
||||||
|
} from "./features/navigation";
|
||||||
|
import { Ra3Diagnostics } from "./features/diagnostics";
|
||||||
|
|
||||||
|
const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }];
|
||||||
|
|
||||||
|
export function activate(context: vscode.ExtensionContext): void {
|
||||||
|
const ws = new ModWorkspace(context);
|
||||||
|
context.subscriptions.push(ws);
|
||||||
|
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.languages.registerCompletionItemProvider(
|
||||||
|
XML_SELECTOR,
|
||||||
|
new Ra3CompletionProvider(ws),
|
||||||
|
"<",
|
||||||
|
'"',
|
||||||
|
"=",
|
||||||
|
":",
|
||||||
|
".",
|
||||||
|
"/",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.languages.registerHoverProvider(XML_SELECTOR, new Ra3HoverProvider(ws)),
|
||||||
|
);
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.languages.registerDefinitionProvider(
|
||||||
|
XML_SELECTOR,
|
||||||
|
new Ra3DefinitionProvider(ws),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.languages.registerReferenceProvider(
|
||||||
|
XML_SELECTOR,
|
||||||
|
new Ra3ReferenceProvider(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.languages.registerDocumentLinkProvider(
|
||||||
|
XML_SELECTOR,
|
||||||
|
new Ra3DocumentLinkProvider(ws),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.languages.registerDocumentSymbolProvider(
|
||||||
|
XML_SELECTOR,
|
||||||
|
new Ra3DocumentSymbolProvider(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const diagnostics = new Ra3Diagnostics(ws);
|
||||||
|
context.subscriptions.push(diagnostics);
|
||||||
|
|
||||||
|
const diagnosticTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
const scheduleDiagnostics = (doc: vscode.TextDocument) => {
|
||||||
|
if (doc.languageId !== "xml") return;
|
||||||
|
const key = doc.uri.toString();
|
||||||
|
const existing = diagnosticTimers.get(key);
|
||||||
|
if (existing) clearTimeout(existing);
|
||||||
|
diagnosticTimers.set(
|
||||||
|
key,
|
||||||
|
setTimeout(() => {
|
||||||
|
diagnosticTimers.delete(key);
|
||||||
|
void diagnostics.update(doc);
|
||||||
|
}, 500),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.workspace.onDidChangeTextDocument((e) => {
|
||||||
|
scheduleDiagnostics(e.document);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.workspace.onDidOpenTextDocument((doc) => {
|
||||||
|
if (doc.languageId === "xml") void diagnostics.update(doc);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.window.onDidChangeActiveTextEditor((editor) => {
|
||||||
|
if (editor?.document.languageId === "xml") void diagnostics.update(editor.document);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.workspace.onDidCloseTextDocument((doc) => {
|
||||||
|
diagnostics.clear(doc.uri);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.workspace.onDidSaveTextDocument((doc) => {
|
||||||
|
if (doc.languageId !== "xml") return;
|
||||||
|
ws.scheduleRebuild();
|
||||||
|
void diagnostics.update(doc);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.workspace.onDidChangeConfiguration((e) => {
|
||||||
|
if (e.affectsConfiguration("ra3modxml")) ws.scheduleRebuild();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.commands.registerCommand("ra3modxml.reindex", () => ws.rebuild()),
|
||||||
|
);
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.commands.registerCommand("ra3modxml.openIndexReport", () => {
|
||||||
|
const idx = ws.index;
|
||||||
|
if (!idx) {
|
||||||
|
void vscode.window.showInformationMessage(
|
||||||
|
"RA3 Mod XML: no index available. Open a workspace that contains Data/Mod.xml.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = idx.stats;
|
||||||
|
void vscode.window.showInformationMessage(
|
||||||
|
`RA3 Mod XML index\n` +
|
||||||
|
`Project: ${s.projectDir}\n` +
|
||||||
|
`Files: ${s.indexedFiles} (${s.parsedFiles} parsed)\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`,
|
||||||
|
{ modal: false },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
void ws.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deactivate(): void {
|
||||||
|
// All subscriptions are disposed by VS Code.
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import * as vscode from "vscode";
|
||||||
|
import { parseXml, type XmlElement } from "../language/xmlParser";
|
||||||
|
import { analyzeContext, type CompletionContext } from "../language/context";
|
||||||
|
import { resolveElementType } from "../language/typeContext";
|
||||||
|
import * as model from "../model/schemaModel";
|
||||||
|
import type { ModWorkspace } from "../workspace";
|
||||||
|
import type { ModIndex, AssetDef } from "../indexer/types";
|
||||||
|
|
||||||
|
const MAX_VALUE_ITEMS = 400;
|
||||||
|
|
||||||
|
export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
||||||
|
constructor(private ws: ModWorkspace) {}
|
||||||
|
|
||||||
|
async provideCompletionItems(
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
position: vscode.Position,
|
||||||
|
_token: vscode.CancellationToken,
|
||||||
|
): Promise<vscode.CompletionItem[]> {
|
||||||
|
const text = document.getText();
|
||||||
|
const offset = document.offsetAt(position);
|
||||||
|
const doc = parseXml(text);
|
||||||
|
const ctx = analyzeContext(doc, text, offset);
|
||||||
|
const idx = this.ws.index;
|
||||||
|
|
||||||
|
switch (ctx.kind) {
|
||||||
|
case "element-name":
|
||||||
|
return this.elementNameItems(ctx, document, position);
|
||||||
|
case "attribute-name":
|
||||||
|
return this.attributeNameItems(ctx, document, position);
|
||||||
|
case "attribute-value":
|
||||||
|
return idx ? this.valueItems(ctx, document, position, idx) : [];
|
||||||
|
case "content":
|
||||||
|
return idx ? this.contentItems(ctx, document, position, idx) : [];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Element name ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private elementNameItems(
|
||||||
|
ctx: CompletionContext,
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
position: vscode.Position,
|
||||||
|
): vscode.CompletionItem[] {
|
||||||
|
if (ctx.closing) return [];
|
||||||
|
const parent = ctx.element?.parent ?? null;
|
||||||
|
const names = this.childrenOf(parent);
|
||||||
|
if (!names.length) return [];
|
||||||
|
|
||||||
|
const start = ctx.element
|
||||||
|
? document.offsetAt(
|
||||||
|
document.positionAt(ctx.element.start + (ctx.closing ? 2 : 1)),
|
||||||
|
)
|
||||||
|
: document.offsetAt(position);
|
||||||
|
const range = new vscode.Range(document.positionAt(start), position);
|
||||||
|
const items: vscode.CompletionItem[] = [];
|
||||||
|
for (const child of names) {
|
||||||
|
const item = new vscode.CompletionItem(child.name, vscode.CompletionItemKind.Field);
|
||||||
|
item.range = range;
|
||||||
|
const type = model.elementTypeName(child.name);
|
||||||
|
const info = type ? model.typeInfo(type) : undefined;
|
||||||
|
const docText =
|
||||||
|
child.doc ||
|
||||||
|
(info?.kind === "complex" ? info.doc : "") ||
|
||||||
|
(type ? `Type: ${type}` : "");
|
||||||
|
item.documentation = docText ? new vscode.MarkdownString(docText) : undefined;
|
||||||
|
item.detail = type ? `RA3 XML · ${type}` : "RA3 XML";
|
||||||
|
item.insertText = this.elementSnippet(child.name, type);
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private childrenOf(
|
||||||
|
parent: XmlElement | null,
|
||||||
|
): { name: string; type: string | null; doc: string }[] {
|
||||||
|
if (!parent) {
|
||||||
|
return [
|
||||||
|
{ name: "AssetDeclaration", type: null, doc: "Root element of every RA3 asset file" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const parentType = resolveElementType(parent);
|
||||||
|
const children = parentType
|
||||||
|
? model.childrenOfType(parentType)
|
||||||
|
: model.childrenOfElement(parent.name);
|
||||||
|
if (children.length) {
|
||||||
|
return children.map((c) => ({ name: c.name, type: c.type, doc: c.doc }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private elementSnippet(name: string, type: string | null): vscode.SnippetString {
|
||||||
|
if (model.isTopLevelElement(name)) {
|
||||||
|
return new vscode.SnippetString(`<${name} id="$1">\n\t$0\n</${name}>`);
|
||||||
|
}
|
||||||
|
const info = type ? model.typeInfo(type) : undefined;
|
||||||
|
const hasChildren = info?.kind === "complex" && info.children.length > 0;
|
||||||
|
if (hasChildren) {
|
||||||
|
return new vscode.SnippetString(`<${name}>\n\t$0\n</${name}>`);
|
||||||
|
}
|
||||||
|
return new vscode.SnippetString(`<${name} />`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attribute name ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private attributeNameItems(
|
||||||
|
ctx: CompletionContext,
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
position: vscode.Position,
|
||||||
|
): vscode.CompletionItem[] {
|
||||||
|
const el = ctx.element;
|
||||||
|
if (!el) return [];
|
||||||
|
const elType = resolveElementType(el);
|
||||||
|
const attrs = model.attributesOfType(elType);
|
||||||
|
const used = new Set(ctx.existingAttrs.map((a) => a.toLowerCase()));
|
||||||
|
const items: vscode.CompletionItem[] = [];
|
||||||
|
|
||||||
|
const wordStart = findAttributeWordStart(document, position, el);
|
||||||
|
const range = new vscode.Range(document.positionAt(wordStart), position);
|
||||||
|
|
||||||
|
for (const attr of attrs) {
|
||||||
|
if (used.has(attr.name.toLowerCase())) continue;
|
||||||
|
const item = new vscode.CompletionItem(attr.name, vscode.CompletionItemKind.Property);
|
||||||
|
item.range = range;
|
||||||
|
item.sortText = attr.required ? "0" + attr.name : "1" + attr.name;
|
||||||
|
const md = new vscode.MarkdownString();
|
||||||
|
if (attr.doc) md.appendMarkdown(attr.doc + "\n\n");
|
||||||
|
if (attr.required) md.appendMarkdown(`**Required** \n`);
|
||||||
|
if (attr.refType) md.appendMarkdown(`References: \`${attr.refType}\` \n`);
|
||||||
|
if (attr.enumValues.length)
|
||||||
|
md.appendMarkdown(`Values: ${attr.enumValues.join(", ")} \n`);
|
||||||
|
if (attr.default != null) md.appendMarkdown(`Default: \`${attr.default}\` \n`);
|
||||||
|
md.appendMarkdown(`Type: \`${attr.type ?? "string"}\``);
|
||||||
|
item.documentation = md;
|
||||||
|
if (attr.required) {
|
||||||
|
item.insertText = attr.name === "id" ? 'id="$1"' : `${attr.name}="$1"`;
|
||||||
|
} else {
|
||||||
|
item.insertText = `${attr.name}="$1"`;
|
||||||
|
}
|
||||||
|
item.command = {
|
||||||
|
command: "editor.action.triggerSuggest",
|
||||||
|
title: "Suggest attribute values",
|
||||||
|
};
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Namespace/instance helpers.
|
||||||
|
if (!used.has("xai:joinaction")) {
|
||||||
|
const j = new vscode.CompletionItem("xai:joinAction", vscode.CompletionItemKind.Property);
|
||||||
|
j.range = range;
|
||||||
|
j.insertText = 'xai:joinAction="$1"';
|
||||||
|
j.detail = "Instance join action";
|
||||||
|
j.documentation = new vscode.MarkdownString(
|
||||||
|
"Controls how this element merges with the inherited definition: `Replace` or `Remove`.",
|
||||||
|
);
|
||||||
|
items.push(j);
|
||||||
|
}
|
||||||
|
if (!used.has("xmlns:xai")) {
|
||||||
|
const ns = new vscode.CompletionItem("xmlns:xai", vscode.CompletionItemKind.Property);
|
||||||
|
ns.range = range;
|
||||||
|
ns.insertText = 'xmlns:xai="uri:ea.com:eala:asset:instance"';
|
||||||
|
ns.detail = "xai namespace";
|
||||||
|
items.push(ns);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attribute value ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private valueItems(
|
||||||
|
ctx: CompletionContext,
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
position: vscode.Position,
|
||||||
|
idx: ModIndex,
|
||||||
|
): vscode.CompletionItem[] {
|
||||||
|
const el = ctx.element;
|
||||||
|
const attr = ctx.attr;
|
||||||
|
if (!el || !attr) return [];
|
||||||
|
const prefix = ctx.valuePrefix;
|
||||||
|
|
||||||
|
const endOffset = attr.quoteEnd > attr.valueEnd ? attr.valueEnd : document.offsetAt(position);
|
||||||
|
const valueRange = new vscode.Range(
|
||||||
|
document.positionAt(attr.valueStart),
|
||||||
|
document.positionAt(Math.max(attr.valueStart, endOffset)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const make = (
|
||||||
|
label: string,
|
||||||
|
kind: vscode.CompletionItemKind,
|
||||||
|
detail: string,
|
||||||
|
doc?: string,
|
||||||
|
) => {
|
||||||
|
const item = new vscode.CompletionItem(label, kind);
|
||||||
|
item.range = valueRange;
|
||||||
|
item.insertText = label;
|
||||||
|
item.detail = detail;
|
||||||
|
if (doc) item.documentation = new vscode.MarkdownString(doc);
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isInclude = el.name === "Include";
|
||||||
|
const attrName = attr.name.toLowerCase();
|
||||||
|
|
||||||
|
// Include type / source
|
||||||
|
if (isInclude && attrName === "type") {
|
||||||
|
return ["reference", "instance", "all"].map((v) =>
|
||||||
|
make(v, vscode.CompletionItemKind.EnumMember, "Include type"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isInclude && attrName === "source") {
|
||||||
|
return this.includeSourceItems(idx, prefix, make);
|
||||||
|
}
|
||||||
|
if (attrName === "xai:joinaction" || attrName === "joinaction") {
|
||||||
|
return ["Replace", "Remove"].map((v) =>
|
||||||
|
make(v, vscode.CompletionItemKind.EnumMember, "xai:joinAction"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const elType = resolveElementType(el);
|
||||||
|
const attrInfo = model
|
||||||
|
.attributesOfType(elType)
|
||||||
|
.find((a) => a.name.toLowerCase() === attrName);
|
||||||
|
|
||||||
|
// inheritFrom: same element type first, then everything.
|
||||||
|
if (attrName === "inheritfrom") {
|
||||||
|
return this.assetIdItems(idx, el.name, null, prefix, make);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrInfo?.refType) {
|
||||||
|
return this.assetIdItems(idx, null, attrInfo.refType, prefix, make);
|
||||||
|
}
|
||||||
|
if (attrInfo?.enumValues?.length) {
|
||||||
|
return attrInfo.enumValues
|
||||||
|
.filter((v) => v.toLowerCase().startsWith(prefix.toLowerCase()))
|
||||||
|
.map((v) => make(v, vscode.CompletionItemKind.EnumMember, attrInfo.type ?? "enum"));
|
||||||
|
}
|
||||||
|
if (attrInfo?.isBoolean) {
|
||||||
|
return ["true", "false"]
|
||||||
|
.filter((v) => v.startsWith(prefix.toLowerCase()))
|
||||||
|
.map((v) => make(v, vscode.CompletionItemKind.Value, "boolean"));
|
||||||
|
}
|
||||||
|
if (attrInfo?.allowsDefine) {
|
||||||
|
return this.defineItems(idx, prefix, make);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private includeSourceItems(
|
||||||
|
idx: ModIndex,
|
||||||
|
prefix: string,
|
||||||
|
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
|
||||||
|
): vscode.CompletionItem[] {
|
||||||
|
const lower = prefix.toLowerCase();
|
||||||
|
const candidates = idx.sourceCandidates
|
||||||
|
.filter((c) => c.source.toLowerCase().includes(lower))
|
||||||
|
.slice(0, MAX_VALUE_ITEMS);
|
||||||
|
const priority: Record<string, number> = { "": 0, DATA: 1, ART: 2, AUDIO: 3 };
|
||||||
|
candidates.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(priority[a.prefix ?? ""] ?? 4) - (priority[b.prefix ?? ""] ?? 4) ||
|
||||||
|
a.source.localeCompare(b.source),
|
||||||
|
);
|
||||||
|
return candidates.map((c) => {
|
||||||
|
const item = make(c.source, vscode.CompletionItemKind.File, "Include source");
|
||||||
|
item.detail = c.path;
|
||||||
|
item.documentation = new vscode.MarkdownString(
|
||||||
|
`\`${c.prefix ?? "relative"}\` · ${c.path}`,
|
||||||
|
);
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private assetIdItems(
|
||||||
|
idx: ModIndex,
|
||||||
|
selfType: string | null,
|
||||||
|
refType: string | null,
|
||||||
|
prefix: string,
|
||||||
|
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
|
||||||
|
): vscode.CompletionItem[] {
|
||||||
|
const lower = prefix.toLowerCase();
|
||||||
|
const scored: { def: AssetDef; score: number }[] = [];
|
||||||
|
|
||||||
|
const consider = (def: AssetDef) => {
|
||||||
|
if (!def.id.toLowerCase().startsWith(lower)) return;
|
||||||
|
let score = 3;
|
||||||
|
if (refType && model.isAssignableTo(def.type, refType)) score = 1;
|
||||||
|
if (selfType && model.isAssignableTo(def.type, selfType)) score = 0;
|
||||||
|
if (def.origin === "project") score -= 0.2;
|
||||||
|
scored.push({ def, score });
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetType = selfType ?? refType;
|
||||||
|
if (!targetType) {
|
||||||
|
for (const list of idx.assetsById.values()) for (const d of list) consider(d);
|
||||||
|
} else {
|
||||||
|
for (const [typeName, byId] of idx.assets) {
|
||||||
|
if (!model.isAssignableTo(typeName, targetType)) continue;
|
||||||
|
for (const list of byId.values()) for (const d of list) consider(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scored.sort((a, b) => a.score - b.score || a.def.id.localeCompare(b.def.id));
|
||||||
|
return scored.slice(0, MAX_VALUE_ITEMS).map(({ def }) => {
|
||||||
|
const origin = def.origin === "manifest" ? `manifest (${def.manifestSource ?? ""})` : def.origin;
|
||||||
|
const doc = new vscode.MarkdownString();
|
||||||
|
doc.appendCodeblock(def.id);
|
||||||
|
doc.appendMarkdown(`**Type**: ${def.type} \n`);
|
||||||
|
if (def.manifestSource) doc.appendMarkdown(`**Source**: ${def.manifestSource} \n`);
|
||||||
|
doc.appendMarkdown(`**Origin**: ${origin}`);
|
||||||
|
return make(def.id, vscode.CompletionItemKind.Value, `${def.type} · ${origin}`, doc.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private defineItems(
|
||||||
|
idx: ModIndex,
|
||||||
|
prefix: string,
|
||||||
|
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
|
||||||
|
): vscode.CompletionItem[] {
|
||||||
|
const lower = prefix.replace(/^[=$]*/, "").toLowerCase();
|
||||||
|
const items: vscode.CompletionItem[] = [];
|
||||||
|
for (const [key, defs] of idx.defines) {
|
||||||
|
if (!key.includes(lower)) continue;
|
||||||
|
const def = defs[0];
|
||||||
|
const label = `$${def.name}`;
|
||||||
|
const item = make(label, vscode.CompletionItemKind.Constant, "Define", def.value);
|
||||||
|
item.insertText = label;
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
return items.slice(0, MAX_VALUE_ITEMS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Element content ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private contentItems(
|
||||||
|
ctx: CompletionContext,
|
||||||
|
_document: vscode.TextDocument,
|
||||||
|
_position: vscode.Position,
|
||||||
|
_idx: ModIndex,
|
||||||
|
): vscode.CompletionItem[] {
|
||||||
|
const el = ctx.element;
|
||||||
|
if (!el) return [];
|
||||||
|
// Reuse element-name suggestions with a plain replacement range.
|
||||||
|
const elType = resolveElementType(el);
|
||||||
|
const names = elType ? model.childrenOfType(elType) : model.childrenOfElement(el.name);
|
||||||
|
const items: vscode.CompletionItem[] = [];
|
||||||
|
for (const child of names) {
|
||||||
|
const item = new vscode.CompletionItem(child.name, vscode.CompletionItemKind.Field);
|
||||||
|
item.insertText = this.elementSnippet(child.name, child.type);
|
||||||
|
const type = child.type;
|
||||||
|
const info = type ? model.typeInfo(type) : undefined;
|
||||||
|
item.detail = type ? `RA3 XML · ${type}` : "RA3 XML";
|
||||||
|
const doc = child.doc || (info?.kind === "complex" ? info.doc : "");
|
||||||
|
if (doc) item.documentation = new vscode.MarkdownString(doc);
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAttributeWordStart(
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
position: vscode.Position,
|
||||||
|
el: { start: number },
|
||||||
|
): number {
|
||||||
|
const offset = document.offsetAt(position);
|
||||||
|
const tagStart = el.start;
|
||||||
|
let i = offset;
|
||||||
|
const text = document.getText();
|
||||||
|
while (i > tagStart) {
|
||||||
|
const c = text[i - 1];
|
||||||
|
if (/[\s=<>"/]/.test(c)) break;
|
||||||
|
i--;
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import * as vscode from "vscode";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { LineMap, parseXml, type XmlElement } from "../language/xmlParser";
|
||||||
|
import { resolveElementType } from "../language/typeContext";
|
||||||
|
import { resolveSource, buildSearchPaths } from "../indexer/includeResolver";
|
||||||
|
import * as model from "../model/schemaModel";
|
||||||
|
import type { ModWorkspace } from "../workspace";
|
||||||
|
import type { ModIndex } from "../indexer/types";
|
||||||
|
import {
|
||||||
|
isReferenceAttributeOfType,
|
||||||
|
resolveReferenceTargetsForType,
|
||||||
|
} from "../indexer/refs";
|
||||||
|
|
||||||
|
export class Ra3Diagnostics {
|
||||||
|
private collection: vscode.DiagnosticCollection;
|
||||||
|
|
||||||
|
constructor(private ws: ModWorkspace) {
|
||||||
|
this.collection = vscode.languages.createDiagnosticCollection("ra3modxml");
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(document: vscode.TextDocument): Promise<void> {
|
||||||
|
const idx = this.ws.index;
|
||||||
|
if (!idx) {
|
||||||
|
this.collection.set(document.uri, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = document.getText();
|
||||||
|
const lineMap = new LineMap(text);
|
||||||
|
const doc = parseXml(text);
|
||||||
|
const diags: vscode.Diagnostic[] = [];
|
||||||
|
|
||||||
|
for (const err of doc.errors) {
|
||||||
|
diags.push(
|
||||||
|
this.diag(
|
||||||
|
new vscode.Range(
|
||||||
|
new vscode.Position(err.line, err.character),
|
||||||
|
new vscode.Position(err.line, err.character + 1),
|
||||||
|
),
|
||||||
|
err.message,
|
||||||
|
vscode.DiagnosticSeverity.Error,
|
||||||
|
"xml-syntax",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.root) {
|
||||||
|
this.checkElements(doc.root, doc, lineMap, idx, document, diags);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.collection.set(document.uri, diags);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(uri: vscode.Uri): void {
|
||||||
|
this.collection.delete(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.collection.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkElements(
|
||||||
|
root: XmlElement,
|
||||||
|
doc: { elements: XmlElement[] },
|
||||||
|
lineMap: LineMap,
|
||||||
|
idx: ModIndex,
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
diags: vscode.Diagnostic[],
|
||||||
|
): void {
|
||||||
|
const settings = this.ws.settings;
|
||||||
|
const fileDuplicates = new Map<string, { line: number }>();
|
||||||
|
|
||||||
|
for (const el of doc.elements) {
|
||||||
|
const local = localName(el.name);
|
||||||
|
const isTopLevel = el.parent === root && !["Tags", "Includes", "Defines"].includes(local);
|
||||||
|
const range = tagRange(document, el);
|
||||||
|
|
||||||
|
// Top-level assets must have an id.
|
||||||
|
if (isTopLevel) {
|
||||||
|
const idAttr = el.attrs.find((a) => a.name === "id");
|
||||||
|
if (!idAttr || !idAttr.value) {
|
||||||
|
diags.push(
|
||||||
|
this.diag(
|
||||||
|
range,
|
||||||
|
`Top-level asset <${local}> requires an id attribute`,
|
||||||
|
vscode.DiagnosticSeverity.Error,
|
||||||
|
"missing-id",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const key = `${local.toLowerCase()}:${idAttr.value.toLowerCase()}`;
|
||||||
|
const prev = fileDuplicates.get(key);
|
||||||
|
if (prev) {
|
||||||
|
const where = new vscode.Range(
|
||||||
|
document.positionAt(idAttr.valueStart),
|
||||||
|
document.positionAt(idAttr.valueEnd),
|
||||||
|
);
|
||||||
|
diags.push(
|
||||||
|
this.diag(
|
||||||
|
where,
|
||||||
|
`Duplicate id "${idAttr.value}" for <${local}> (also defined on line ${prev.line})`,
|
||||||
|
vscode.DiagnosticSeverity.Error,
|
||||||
|
"duplicate-id",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
fileDuplicates.set(key, { line: lineMap.positionAt(el.start).line + 1 });
|
||||||
|
}
|
||||||
|
this.checkCrossFileDuplicate(
|
||||||
|
local,
|
||||||
|
idAttr.value,
|
||||||
|
document,
|
||||||
|
idx,
|
||||||
|
diags,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown element.
|
||||||
|
if (settings.diagnoseUnknownElements && !el.name.startsWith("xi:")) {
|
||||||
|
const knownType = model.elementTypeName(local);
|
||||||
|
if (!knownType) {
|
||||||
|
diags.push(
|
||||||
|
this.diag(
|
||||||
|
range,
|
||||||
|
`Unknown element <${local}> (not in the RA3 XSD model)`,
|
||||||
|
vscode.DiagnosticSeverity.Warning,
|
||||||
|
"unknown-element",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attributes.
|
||||||
|
const elType = resolveElementType(el);
|
||||||
|
const knownAttrs = model.attributesOfType(elType);
|
||||||
|
const knownNames = new Set(knownAttrs.map((a) => a.name));
|
||||||
|
for (const attr of el.attrs) {
|
||||||
|
const aName = attr.name;
|
||||||
|
if (aName.startsWith("xmlns") || aName.startsWith("xai:") || aName.startsWith("xi:")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (settings.diagnoseUnknownElements && !knownNames.has(aName)) {
|
||||||
|
diags.push(
|
||||||
|
this.diag(
|
||||||
|
new vscode.Range(
|
||||||
|
document.positionAt(attr.nameStart),
|
||||||
|
document.positionAt(attr.nameEnd),
|
||||||
|
),
|
||||||
|
`Unknown attribute "${aName}" for <${local}>`,
|
||||||
|
vscode.DiagnosticSeverity.Warning,
|
||||||
|
"unknown-attribute",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attr.hasValue) continue;
|
||||||
|
this.checkValueReferences(
|
||||||
|
elType,
|
||||||
|
attr.name,
|
||||||
|
attr.value,
|
||||||
|
attr,
|
||||||
|
document,
|
||||||
|
idx,
|
||||||
|
diags,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include-specific checks.
|
||||||
|
if (local === "Include") {
|
||||||
|
this.checkInclude(el, document, idx, diags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkCrossFileDuplicate(
|
||||||
|
type: string,
|
||||||
|
id: string,
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
idx: ModIndex,
|
||||||
|
diags: vscode.Diagnostic[],
|
||||||
|
): void {
|
||||||
|
const byType = idx.assets.get(type);
|
||||||
|
const defs = byType?.get(id.toLowerCase());
|
||||||
|
if (!defs || defs.length < 2) return;
|
||||||
|
const self = defs.filter(
|
||||||
|
(d) =>
|
||||||
|
d.origin === "project" &&
|
||||||
|
!d.viaInstance &&
|
||||||
|
d.file.toLowerCase() === document.uri.fsPath.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (!self.length) return;
|
||||||
|
const others = defs.filter(
|
||||||
|
(d) =>
|
||||||
|
d.origin === "project" &&
|
||||||
|
!d.viaInstance &&
|
||||||
|
d.file.toLowerCase() !== document.uri.fsPath.toLowerCase() &&
|
||||||
|
d.stream === self[0].stream,
|
||||||
|
);
|
||||||
|
for (const other of others) {
|
||||||
|
const range = self[0].line > 0
|
||||||
|
? new vscode.Range(new vscode.Position(self[0].line - 1, 0), new vscode.Position(self[0].line - 1, 1))
|
||||||
|
: new vscode.Range(0, 0, 0, 1);
|
||||||
|
diags.push(
|
||||||
|
this.diag(
|
||||||
|
range,
|
||||||
|
`Duplicate id "${id}" for <${type}> (also defined in ${other.file})`,
|
||||||
|
vscode.DiagnosticSeverity.Error,
|
||||||
|
"duplicate-id",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkValueReferences(
|
||||||
|
elType: string | null,
|
||||||
|
attrName: string,
|
||||||
|
value: string,
|
||||||
|
attr: { valueStart: number; valueEnd: number },
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
idx: ModIndex,
|
||||||
|
diags: vscode.Diagnostic[],
|
||||||
|
): void {
|
||||||
|
if (!value) return;
|
||||||
|
const range = new vscode.Range(
|
||||||
|
document.positionAt(attr.valueStart),
|
||||||
|
document.positionAt(attr.valueEnd),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Undefined $DEFINE references.
|
||||||
|
const defineRe = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = defineRe.exec(value)) !== null) {
|
||||||
|
if (!idx.defines.has(m[1].toLowerCase())) {
|
||||||
|
diags.push(
|
||||||
|
this.diag(
|
||||||
|
range,
|
||||||
|
`Undefined define "$${m[1]}"`,
|
||||||
|
vscode.DiagnosticSeverity.Warning,
|
||||||
|
"undefined-define",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith("$") || value.startsWith("=")) return;
|
||||||
|
const severity = this.ws.settings.reportUnresolvedReferences;
|
||||||
|
if (severity === "none") return;
|
||||||
|
if (!isReferenceAttributeOfType(elType, attrName)) return;
|
||||||
|
const targets = resolveReferenceTargetsForType(idx, elType, attrName, value);
|
||||||
|
if (targets.length) return;
|
||||||
|
const anyDef = idx.assetsById.has(value.toLowerCase());
|
||||||
|
const attrRef = model
|
||||||
|
.attributesOfType(elType)
|
||||||
|
.find((a) => a.name === attrName);
|
||||||
|
const expected = attrRef?.refType
|
||||||
|
? `of type \`${attrRef.refType}\``
|
||||||
|
: attrRef?.isRef
|
||||||
|
? "of the expected declared type"
|
||||||
|
: "matching";
|
||||||
|
diags.push(
|
||||||
|
this.diag(
|
||||||
|
range,
|
||||||
|
anyDef
|
||||||
|
? `Reference "${value}" has no definition ${expected} (ids with the same name exist for other types)`
|
||||||
|
: `Unresolved reference "${value}" (not found in the current index)`,
|
||||||
|
severity === "warning"
|
||||||
|
? vscode.DiagnosticSeverity.Warning
|
||||||
|
: vscode.DiagnosticSeverity.Information,
|
||||||
|
"unresolved-reference",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkInclude(
|
||||||
|
el: XmlElement,
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
idx: ModIndex,
|
||||||
|
diags: vscode.Diagnostic[],
|
||||||
|
): void {
|
||||||
|
const typeAttr = el.attrs.find((a) => a.name === "type");
|
||||||
|
const sourceAttr = el.attrs.find((a) => a.name === "source");
|
||||||
|
if (typeAttr?.hasValue && !["reference", "instance", "all"].includes(typeAttr.value)) {
|
||||||
|
diags.push(
|
||||||
|
this.diag(
|
||||||
|
new vscode.Range(
|
||||||
|
document.positionAt(typeAttr.valueStart),
|
||||||
|
document.positionAt(typeAttr.valueEnd),
|
||||||
|
),
|
||||||
|
`Invalid Include type "${typeAttr.value}" (expected reference, instance or all)`,
|
||||||
|
vscode.DiagnosticSeverity.Error,
|
||||||
|
"include-type",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!sourceAttr?.hasValue) return;
|
||||||
|
const resolved = resolveSource(
|
||||||
|
sourceAttr.value,
|
||||||
|
dirname(document.uri.fsPath),
|
||||||
|
buildSearchPaths(idx.sdkDir, idx.projectDir),
|
||||||
|
);
|
||||||
|
if (!resolved.path && !idx.sourceCandidates.some((c) => c.source === sourceAttr.value)) {
|
||||||
|
diags.push(
|
||||||
|
this.diag(
|
||||||
|
new vscode.Range(
|
||||||
|
document.positionAt(sourceAttr.valueStart),
|
||||||
|
document.positionAt(sourceAttr.valueEnd),
|
||||||
|
),
|
||||||
|
`Include target not found: ${sourceAttr.value}`,
|
||||||
|
vscode.DiagnosticSeverity.Warning,
|
||||||
|
"include-not-found",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private diag(
|
||||||
|
range: vscode.Range,
|
||||||
|
message: string,
|
||||||
|
severity: vscode.DiagnosticSeverity,
|
||||||
|
code: string,
|
||||||
|
): vscode.Diagnostic {
|
||||||
|
const d = new vscode.Diagnostic(range, message, severity);
|
||||||
|
d.code = code;
|
||||||
|
d.source = "RA3 Mod XML";
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function localName(tag: string): string {
|
||||||
|
const idx = tag.lastIndexOf(":");
|
||||||
|
return idx >= 0 ? tag.slice(idx + 1) : tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagRange(document: vscode.TextDocument, el: XmlElement): vscode.Range {
|
||||||
|
return new vscode.Range(
|
||||||
|
document.positionAt(el.start),
|
||||||
|
document.positionAt(el.startTagEnd),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import * as vscode from "vscode";
|
||||||
|
import { findElementAt, parseXml } from "../language/xmlParser";
|
||||||
|
import { resolveElementType } from "../language/typeContext";
|
||||||
|
import * as model from "../model/schemaModel";
|
||||||
|
import type { ModWorkspace } from "../workspace";
|
||||||
|
import type { ModIndex } from "../indexer/types";
|
||||||
|
import {
|
||||||
|
isReferenceAttributeOfType,
|
||||||
|
resolveReferenceTargetsForType,
|
||||||
|
} from "../indexer/refs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { buildSearchPaths, resolveSource } from "../indexer/includeResolver";
|
||||||
|
|
||||||
|
export class Ra3HoverProvider implements vscode.HoverProvider {
|
||||||
|
constructor(private ws: ModWorkspace) {}
|
||||||
|
|
||||||
|
async provideHover(
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
position: vscode.Position,
|
||||||
|
_token: vscode.CancellationToken,
|
||||||
|
): Promise<vscode.Hover | null> {
|
||||||
|
const text = document.getText();
|
||||||
|
const offset = document.offsetAt(position);
|
||||||
|
const doc = parseXml(text);
|
||||||
|
const el = findElementAt(doc, offset);
|
||||||
|
if (!el) return null;
|
||||||
|
const elType = resolveElementType(el);
|
||||||
|
|
||||||
|
// Attribute name.
|
||||||
|
for (const attr of el.attrs) {
|
||||||
|
if (offset >= attr.nameStart && offset <= attr.nameEnd) {
|
||||||
|
return this.attributeHover(elType, attr.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Attribute value.
|
||||||
|
for (const attr of el.attrs) {
|
||||||
|
if (attr.hasValue && offset >= attr.valueStart && offset <= attr.valueEnd) {
|
||||||
|
return this.valueHover(el, elType, attr.name, attr.value, document, this.ws.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Element name.
|
||||||
|
const nameStart = el.start + 1;
|
||||||
|
if (offset >= nameStart && offset <= nameStart + el.name.length) {
|
||||||
|
return this.elementHover(el.name);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private elementHover(name: string): vscode.Hover | null {
|
||||||
|
const type = model.elementTypeName(name);
|
||||||
|
const info = type ? model.typeInfo(type) : undefined;
|
||||||
|
const md = new vscode.MarkdownString();
|
||||||
|
md.appendCodeblock(`<${name}>`, "xml");
|
||||||
|
if (model.isTopLevelElement(name)) md.appendMarkdown(`**Top-level asset element** \n`);
|
||||||
|
if (info?.kind === "complex") {
|
||||||
|
if (info.doc) md.appendMarkdown(`${info.doc} \n`);
|
||||||
|
md.appendMarkdown(
|
||||||
|
`Attributes: ${info.attributes.length} · Children: ${info.children.length} \n`,
|
||||||
|
);
|
||||||
|
if (info.base) md.appendMarkdown(`Extends: \`${info.base}\``);
|
||||||
|
} else if (info?.kind === "simple") {
|
||||||
|
md.appendMarkdown(`Simple type: \`${type}\``);
|
||||||
|
} else if (type) {
|
||||||
|
md.appendMarkdown(`Type: \`${type}\``);
|
||||||
|
} else {
|
||||||
|
md.appendMarkdown("Not found in the bundled XSD model.");
|
||||||
|
}
|
||||||
|
return new vscode.Hover(md);
|
||||||
|
}
|
||||||
|
|
||||||
|
private attributeHover(elementType: string | null, attrName: string): vscode.Hover | null {
|
||||||
|
const attrs = model.attributesOfType(elementType);
|
||||||
|
const attr = attrs.find((a) => a.name === attrName);
|
||||||
|
const md = new vscode.MarkdownString();
|
||||||
|
md.appendCodeblock(`${attrName}=""`, "xml");
|
||||||
|
if (!attr) {
|
||||||
|
if (/^(xmlns|xai:)/.test(attrName)) {
|
||||||
|
md.appendMarkdown(`Namespace/instance attribute.`);
|
||||||
|
return new vscode.Hover(md);
|
||||||
|
}
|
||||||
|
md.appendMarkdown("Unknown attribute for this element.");
|
||||||
|
return new vscode.Hover(md);
|
||||||
|
}
|
||||||
|
if (attr.doc) md.appendMarkdown(`${attr.doc} \n`);
|
||||||
|
if (attr.required) md.appendMarkdown(`**Required** \n`);
|
||||||
|
if (attr.refType) md.appendMarkdown(`References assets of type \`${attr.refType}\` \n`);
|
||||||
|
if (attr.enumValues.length)
|
||||||
|
md.appendMarkdown(`Values: \`${attr.enumValues.join("`, `")}\` \n`);
|
||||||
|
if (attr.default != null) md.appendMarkdown(`Default: \`${attr.default}\` \n`);
|
||||||
|
if (attr.allowsDefine) md.appendMarkdown(`May use \`$DEFINE\` constants \n`);
|
||||||
|
md.appendMarkdown(`Type: \`${attr.type ?? "string"}\``);
|
||||||
|
return new vscode.Hover(md);
|
||||||
|
}
|
||||||
|
|
||||||
|
private valueHover(
|
||||||
|
el: { name: string },
|
||||||
|
elType: string | null,
|
||||||
|
attrName: string,
|
||||||
|
value: string,
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
idx: ModIndex | null,
|
||||||
|
): vscode.Hover | null {
|
||||||
|
const md = new vscode.MarkdownString();
|
||||||
|
|
||||||
|
// $DEFINE reference.
|
||||||
|
const defineMatch = /\$([A-Za-z_][A-Za-z0-9_]*)/.exec(value);
|
||||||
|
if (defineMatch && idx) {
|
||||||
|
const defs = idx.defines.get(defineMatch[1].toLowerCase());
|
||||||
|
if (defs?.length) {
|
||||||
|
const d = defs[0];
|
||||||
|
md.appendMarkdown(`**Define** \`$${d.name}\` \n`);
|
||||||
|
md.appendCodeblock(d.value);
|
||||||
|
const rel = relativePath(document, d.file);
|
||||||
|
md.appendMarkdown(`Defined in \`${rel}:${d.line}\``);
|
||||||
|
return new vscode.Hover(md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include source.
|
||||||
|
if (el.name === "Include" && attrName === "source") {
|
||||||
|
const resolved = idx
|
||||||
|
? resolveSource(
|
||||||
|
value,
|
||||||
|
dirname(document.uri.fsPath),
|
||||||
|
buildSearchPaths(idx.sdkDir, idx.projectDir),
|
||||||
|
).path
|
||||||
|
: null;
|
||||||
|
if (resolved) {
|
||||||
|
md.appendMarkdown(`**Include source** \n`);
|
||||||
|
md.appendCodeblock(resolved);
|
||||||
|
return new vscode.Hover(md);
|
||||||
|
}
|
||||||
|
const cand = idx?.sourceCandidates.find((c) => c.source === value);
|
||||||
|
if (cand) {
|
||||||
|
md.appendMarkdown(`**Include source** \n`);
|
||||||
|
md.appendCodeblock(cand.path);
|
||||||
|
return new vscode.Hover(md);
|
||||||
|
}
|
||||||
|
md.appendMarkdown(`Include source: \`${value}\` (not in candidate index)`);
|
||||||
|
return new vscode.Hover(md);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset reference / inheritFrom.
|
||||||
|
if (idx) {
|
||||||
|
if (!isReferenceAttributeOfType(elType, attrName)) return null;
|
||||||
|
const targets = resolveReferenceTargetsForType(idx, elType, attrName, value);
|
||||||
|
if (targets.length) {
|
||||||
|
const md2 = new vscode.MarkdownString();
|
||||||
|
md2.appendMarkdown(`**${targets.length} definition${targets.length > 1 ? "s" : ""}** \n`);
|
||||||
|
for (const { def: d } of targets.slice(0, 8)) {
|
||||||
|
const loc =
|
||||||
|
d.origin === "manifest"
|
||||||
|
? `manifest \`${d.manifestSource ?? d.file}\``
|
||||||
|
: `\`${relativePath(document, d.file)}:${d.line}\``;
|
||||||
|
md2.appendMarkdown(`- \`${d.type}\` · ${loc} \n`);
|
||||||
|
}
|
||||||
|
return new vscode.Hover(md2);
|
||||||
|
}
|
||||||
|
const attrRef = model
|
||||||
|
.attributesOfType(elType)
|
||||||
|
.find((a) => a.name === attrName);
|
||||||
|
const expected = attrRef?.refType
|
||||||
|
? ` of type \`${attrRef.refType}\``
|
||||||
|
: attrRef?.isRef
|
||||||
|
? " of the expected declared type"
|
||||||
|
: "";
|
||||||
|
md.appendMarkdown(
|
||||||
|
`No matching definition${expected} in the current index` +
|
||||||
|
" (may exist in a compiled manifest or vanilla data).",
|
||||||
|
);
|
||||||
|
return new vscode.Hover(md);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativePath(document: vscode.TextDocument, abs: string): string {
|
||||||
|
const root = vscode.workspace.getWorkspaceFolder(document.uri)?.uri.fsPath;
|
||||||
|
if (!root) return abs;
|
||||||
|
const rel = abs.toLowerCase().startsWith(root.toLowerCase())
|
||||||
|
? abs.slice(root.length + 1)
|
||||||
|
: abs;
|
||||||
|
return rel;
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import * as vscode from "vscode";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { findElementAt, parseXml } from "../language/xmlParser";
|
||||||
|
import { resolveElementType } from "../language/typeContext";
|
||||||
|
import {
|
||||||
|
buildSearchPaths,
|
||||||
|
resolveSource,
|
||||||
|
type SearchPaths,
|
||||||
|
} from "../indexer/includeResolver";
|
||||||
|
import { resolveReferenceTargetsForType } from "../indexer/refs";
|
||||||
|
import type { ModWorkspace } from "../workspace";
|
||||||
|
import type { AssetDef, ModIndex } from "../indexer/types";
|
||||||
|
|
||||||
|
function searchPathsFor(idx: ModIndex): SearchPaths {
|
||||||
|
return buildSearchPaths(idx.sdkDir, idx.projectDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Go to definition ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class Ra3DefinitionProvider implements vscode.DefinitionProvider {
|
||||||
|
constructor(private ws: ModWorkspace) {}
|
||||||
|
|
||||||
|
async provideDefinition(
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
position: vscode.Position,
|
||||||
|
_token: vscode.CancellationToken,
|
||||||
|
): Promise<vscode.Location | vscode.Location[] | null> {
|
||||||
|
const idx = this.ws.index;
|
||||||
|
if (!idx) return null;
|
||||||
|
const text = document.getText();
|
||||||
|
const offset = document.offsetAt(position);
|
||||||
|
const doc = parseXml(text);
|
||||||
|
const el = findElementAt(doc, offset);
|
||||||
|
if (!el) return null;
|
||||||
|
const elType = resolveElementType(el);
|
||||||
|
|
||||||
|
const attr = el.attrs.find(
|
||||||
|
(a) => a.hasValue && offset >= a.valueStart && offset <= a.valueEnd,
|
||||||
|
);
|
||||||
|
if (!attr) return null;
|
||||||
|
const value = attr.value;
|
||||||
|
const nameLower = attr.name.toLowerCase();
|
||||||
|
|
||||||
|
// Include source / xi:include href -> open the file.
|
||||||
|
if (
|
||||||
|
(el.name === "Include" && nameLower === "source") ||
|
||||||
|
(el.name === "include" && nameLower === "href")
|
||||||
|
) {
|
||||||
|
const resolved =
|
||||||
|
resolveSource(value, dirname(document.uri.fsPath), searchPathsFor(idx)).path ??
|
||||||
|
idx.sourceCandidates.find((c) => c.source === value)?.path ??
|
||||||
|
null;
|
||||||
|
return resolved
|
||||||
|
? new vscode.Location(vscode.Uri.file(resolved), new vscode.Position(0, 0))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset reference / inheritFrom (filtered by the attribute's ref type).
|
||||||
|
if (value && !value.startsWith("$")) {
|
||||||
|
let targets = resolveReferenceTargetsForType(idx, elType, attr.name, value);
|
||||||
|
if (!targets.length) return null;
|
||||||
|
if (
|
||||||
|
this.ws.settings.definitionMode === "project-only" &&
|
||||||
|
targets.some((t) => t.def.origin === "project")
|
||||||
|
) {
|
||||||
|
targets = targets.filter((t) => t.def.origin === "project");
|
||||||
|
}
|
||||||
|
const locations: vscode.Location[] = [];
|
||||||
|
for (const { def } of targets.slice(0, 8)) {
|
||||||
|
const loc = await assetDefLocation(this.ws, def, idx);
|
||||||
|
if (loc) locations.push(loc);
|
||||||
|
}
|
||||||
|
return locations.length ? locations : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a location for an asset definition. For XML sources the location is
|
||||||
|
* the precise range of the id attribute value (or the element start tag when
|
||||||
|
* the id value cannot be located), falling back to the recorded line.
|
||||||
|
*/
|
||||||
|
async function assetDefLocation(
|
||||||
|
ws: ModWorkspace,
|
||||||
|
def: AssetDef,
|
||||||
|
idx: ModIndex,
|
||||||
|
): Promise<vscode.Location | null> {
|
||||||
|
if (def.origin === "manifest") {
|
||||||
|
const src = def.manifestSource;
|
||||||
|
if (src?.toUpperCase().startsWith("DATA:")) {
|
||||||
|
const resolved = resolveSource(src, null, searchPathsFor(idx)).path;
|
||||||
|
if (resolved) {
|
||||||
|
// The recorded source file is XML (e.g. SageXml) when available:
|
||||||
|
// jump to the precise definition inside it, not just the file.
|
||||||
|
const precise = await locationInDocument(ws, resolved, def.id);
|
||||||
|
return precise ?? new vscode.Location(vscode.Uri.file(resolved), new vscode.Position(0, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(await locationInDocument(ws, def.file, def.id)) ??
|
||||||
|
new vscode.Location(
|
||||||
|
vscode.Uri.file(def.file),
|
||||||
|
new vscode.Position(Math.max(0, def.line - 1), 0),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the precise range of an asset definition inside an XML file: the id
|
||||||
|
* attribute value when present, otherwise the element start tag.
|
||||||
|
*/
|
||||||
|
async function locationInDocument(
|
||||||
|
ws: ModWorkspace,
|
||||||
|
file: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<vscode.Location | null> {
|
||||||
|
const parsed = await ws.indexer?.readDocument(file);
|
||||||
|
if (parsed?.parse && parsed.lineMap) {
|
||||||
|
const el = parsed.parse.elements.find(
|
||||||
|
(e) =>
|
||||||
|
e.attrs.some(
|
||||||
|
(a) => a.name === "id" && a.value.toLowerCase() === id.toLowerCase(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (el) {
|
||||||
|
const idAttr = el.attrs.find((a) => a.name === "id");
|
||||||
|
if (idAttr?.hasValue) {
|
||||||
|
return new vscode.Location(
|
||||||
|
vscode.Uri.file(file),
|
||||||
|
new vscode.Range(
|
||||||
|
toVscodePosition(parsed.lineMap.positionAt(idAttr.valueStart)),
|
||||||
|
toVscodePosition(parsed.lineMap.positionAt(idAttr.valueEnd)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new vscode.Location(
|
||||||
|
vscode.Uri.file(file),
|
||||||
|
new vscode.Range(
|
||||||
|
toVscodePosition(parsed.lineMap.positionAt(el.start)),
|
||||||
|
toVscodePosition(parsed.lineMap.positionAt(el.startTagEnd)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toVscodePosition(p: { line: number; character: number }): vscode.Position {
|
||||||
|
return new vscode.Position(p.line, p.character);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find all references ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class Ra3ReferenceProvider implements vscode.ReferenceProvider {
|
||||||
|
async provideReferences(
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
position: vscode.Position,
|
||||||
|
_context: vscode.ReferenceContext,
|
||||||
|
_token: vscode.CancellationToken,
|
||||||
|
): Promise<vscode.Location[] | null> {
|
||||||
|
const text = document.getText();
|
||||||
|
const offset = document.offsetAt(position);
|
||||||
|
const doc = parseXml(text);
|
||||||
|
const el = findElementAt(doc, offset);
|
||||||
|
if (!el) return null;
|
||||||
|
const attr = el.attrs.find(
|
||||||
|
(a) =>
|
||||||
|
(a.hasValue && offset >= a.valueStart && offset <= a.valueEnd) ||
|
||||||
|
(offset >= a.nameStart && offset <= a.nameEnd),
|
||||||
|
);
|
||||||
|
if (!attr?.hasValue) return null;
|
||||||
|
const id = attr.value;
|
||||||
|
if (!id || id.startsWith("$")) return null;
|
||||||
|
|
||||||
|
const locations: vscode.Location[] = [];
|
||||||
|
const pattern = `["']${escapeRegExp(id)}["']`;
|
||||||
|
await findTextInWorkspace(
|
||||||
|
{ pattern, isRegExp: true },
|
||||||
|
{ include: "**/*.xml", maxResults: 2000 },
|
||||||
|
(result: { uri: vscode.Uri; matches: { range: vscode.Range }[] }) => {
|
||||||
|
if (!result.uri) return;
|
||||||
|
for (const m of result.matches) {
|
||||||
|
locations.push(new vscode.Location(result.uri, m.range));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return locations.length ? locations : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Document links (ctrl+click on includes) ─────────────────────────
|
||||||
|
|
||||||
|
export class Ra3DocumentLinkProvider implements vscode.DocumentLinkProvider {
|
||||||
|
constructor(private ws: ModWorkspace) {}
|
||||||
|
|
||||||
|
async provideDocumentLinks(
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
_token: vscode.CancellationToken,
|
||||||
|
): Promise<vscode.DocumentLink[]> {
|
||||||
|
const idx = this.ws.index;
|
||||||
|
if (!idx) return [];
|
||||||
|
const text = document.getText();
|
||||||
|
const doc = parseXml(text);
|
||||||
|
const links: vscode.DocumentLink[] = [];
|
||||||
|
for (const el of doc.elements) {
|
||||||
|
if (el.name !== "Include" && el.name !== "include") continue;
|
||||||
|
const srcAttr = el.attrs.find((a) => a.name === "source" || a.name === "href");
|
||||||
|
if (!srcAttr?.hasValue) continue;
|
||||||
|
const target =
|
||||||
|
resolveSource(
|
||||||
|
srcAttr.value,
|
||||||
|
dirname(document.uri.fsPath),
|
||||||
|
searchPathsFor(idx),
|
||||||
|
).path ??
|
||||||
|
idx.sourceCandidates.find((c) => c.source === srcAttr.value)?.path ??
|
||||||
|
null;
|
||||||
|
if (!target) continue;
|
||||||
|
links.push(
|
||||||
|
new vscode.DocumentLink(
|
||||||
|
new vscode.Range(
|
||||||
|
document.positionAt(srcAttr.valueStart),
|
||||||
|
document.positionAt(srcAttr.valueEnd),
|
||||||
|
),
|
||||||
|
vscode.Uri.file(target),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Document symbols (outline) ──────────────────────────────────────
|
||||||
|
|
||||||
|
export class Ra3DocumentSymbolProvider implements vscode.DocumentSymbolProvider {
|
||||||
|
async provideDocumentSymbols(
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
_token: vscode.CancellationToken,
|
||||||
|
): Promise<vscode.DocumentSymbol[]> {
|
||||||
|
const text = document.getText();
|
||||||
|
const doc = parseXml(text);
|
||||||
|
const root = doc.root;
|
||||||
|
if (!root) return [];
|
||||||
|
const symbols: vscode.DocumentSymbol[] = [];
|
||||||
|
for (const child of root.children) {
|
||||||
|
const local = localName(child.name);
|
||||||
|
if (local === "Tags" || local === "Includes" || local === "Defines") continue;
|
||||||
|
const idAttr = child.attrs.find((a) => a.name === "id");
|
||||||
|
const label = idAttr ? `${local} ${idAttr.value}` : local;
|
||||||
|
const fullRange = new vscode.Range(
|
||||||
|
document.positionAt(child.start),
|
||||||
|
document.positionAt(child.end),
|
||||||
|
);
|
||||||
|
const selectionRange = new vscode.Range(
|
||||||
|
document.positionAt(child.start),
|
||||||
|
document.positionAt(child.startTagEnd),
|
||||||
|
);
|
||||||
|
symbols.push(
|
||||||
|
new vscode.DocumentSymbol(
|
||||||
|
label,
|
||||||
|
"",
|
||||||
|
vscode.SymbolKind.Class,
|
||||||
|
fullRange,
|
||||||
|
selectionRange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
const range = new vscode.Range(
|
||||||
|
document.positionAt(define.start),
|
||||||
|
document.positionAt(define.end),
|
||||||
|
);
|
||||||
|
symbols.push(
|
||||||
|
new vscode.DocumentSymbol(`$${name}`, "Define", vscode.SymbolKind.Constant, range, range),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return symbols;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function localName(tag: string): string {
|
||||||
|
const idx = tag.lastIndexOf(":");
|
||||||
|
return idx >= 0 ? tag.slice(idx + 1) : tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `workspace.findTextInFiles` is a stable VS Code API (since 1.66) but is
|
||||||
|
* missing from the published typings, so we declare the subset we need and
|
||||||
|
* call it via a safe cast.
|
||||||
|
*/
|
||||||
|
interface TextSearchQuery {
|
||||||
|
pattern: string;
|
||||||
|
isRegExp?: boolean;
|
||||||
|
isCaseSensitive?: boolean;
|
||||||
|
isWordMatch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextSearchOptions {
|
||||||
|
include?: string;
|
||||||
|
exclude?: string;
|
||||||
|
maxResults?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTextInWorkspace(
|
||||||
|
query: TextSearchQuery,
|
||||||
|
options: TextSearchOptions,
|
||||||
|
callback: (result: { uri: vscode.Uri; matches: { range: vscode.Range }[] }) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const api = vscode.workspace as unknown as {
|
||||||
|
findTextInFiles(
|
||||||
|
query: TextSearchQuery,
|
||||||
|
options: TextSearchOptions,
|
||||||
|
callback: (result: { uri: vscode.Uri; matches: { range: vscode.Range }[] }) => void,
|
||||||
|
): Promise<unknown>;
|
||||||
|
};
|
||||||
|
return api.findTextInFiles(query, options, callback).then(() => undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { readdir, stat } from "node:fs/promises";
|
||||||
|
import { join, relative, resolve } from "node:path";
|
||||||
|
import type { FileWalker, SourceCandidate } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursive file list walker with a simple directory-mtime cache. Used to
|
||||||
|
* enumerate candidate files for Include/@source completion. Directory scans
|
||||||
|
* outside the workspace (e.g. the SDK) are cached until the directory mtime
|
||||||
|
* changes.
|
||||||
|
*/
|
||||||
|
export class CachedDirectoryWalker implements FileWalker {
|
||||||
|
private cache = new Map<string, { mtimeMs: number; files: string[] }>();
|
||||||
|
|
||||||
|
async listFiles(dir: string): Promise<string[]> {
|
||||||
|
const key = dir.toLowerCase();
|
||||||
|
try {
|
||||||
|
const dirStat = await stat(dir);
|
||||||
|
const cached = this.cache.get(key);
|
||||||
|
if (cached && cached.mtimeMs === dirStat.mtimeMs) {
|
||||||
|
return cached.files;
|
||||||
|
}
|
||||||
|
const files: string[] = [];
|
||||||
|
await this.walk(dir, files);
|
||||||
|
this.cache.set(key, { mtimeMs: dirStat.mtimeMs, files });
|
||||||
|
return files;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async walk(dir: string, out: string[]): Promise<void> {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await this.walk(full, out);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the candidate list for Include/@source completion from a set of
|
||||||
|
* search directories. For DATA directories only *.xml files are listed; for
|
||||||
|
* ART/AUDIO directories every file is listed (includes commonly point at
|
||||||
|
* .w3x/.dds/.mpf files there).
|
||||||
|
*/
|
||||||
|
export async function collectSourceCandidates(
|
||||||
|
walker: FileWalker,
|
||||||
|
dataDirs: string[],
|
||||||
|
artDirs: string[],
|
||||||
|
audioDirs: string[],
|
||||||
|
projectDataDir: string,
|
||||||
|
): Promise<SourceCandidate[]> {
|
||||||
|
const out: SourceCandidate[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const add = (path: string, baseDir: string, prefix: "DATA" | "ART" | "AUDIO" | null) => {
|
||||||
|
const rel = relative(baseDir, path).replace(/\\/g, "/");
|
||||||
|
if (rel.startsWith("..")) return;
|
||||||
|
const source = prefix ? `${prefix}:${rel}` : rel;
|
||||||
|
const key = `${prefix ?? ""}:${source.toLowerCase()}`;
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
out.push({ source, path: resolve(path), prefix, baseDir: resolve(baseDir) });
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const dir of dataDirs) {
|
||||||
|
const files = await walker.listFiles(dir);
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.toLowerCase().endsWith(".xml")) 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of artDirs) {
|
||||||
|
for (const f of await walker.listFiles(dir)) {
|
||||||
|
add(f, dir, "ART");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const dir of audioDirs) {
|
||||||
|
for (const f of await walker.listFiles(dir)) {
|
||||||
|
add(f, dir, "AUDIO");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function samePath(a: string, b: string): boolean {
|
||||||
|
return resolve(a).toLowerCase() === resolve(b).toLowerCase();
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Include path resolution for RA3 Mod XML, ported from the reference
|
||||||
|
* implementation in check_duplicate_ids.py.
|
||||||
|
*
|
||||||
|
* Pure TypeScript: no vscode dependency, so the module can be reused outside
|
||||||
|
* the extension (e.g. by search/analysis tools).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join, resolve, normalize, isAbsolute } from "node:path";
|
||||||
|
import { statSync } from "node:fs";
|
||||||
|
|
||||||
|
export type IncludeKind = "all" | "instance" | "reference";
|
||||||
|
export type SourcePrefix = "DATA" | "ART" | "AUDIO" | null;
|
||||||
|
|
||||||
|
export interface SearchPaths {
|
||||||
|
DATA: string[];
|
||||||
|
ART: string[];
|
||||||
|
AUDIO: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveResult {
|
||||||
|
path: string | null;
|
||||||
|
prefix: SourcePrefix;
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREFIXES: Exclude<SourcePrefix, null>[] = ["DATA", "ART", "AUDIO"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the search path lists used by the SDK compiler:
|
||||||
|
* (from defaultscript.cs getIncludePaths(), where "." is the SDK root):
|
||||||
|
* DATA: sdk -> modGranParent -> project/Data -> sdk/Mods -> modParentPath -> sdk/SageXml
|
||||||
|
* ART: sdk -> modGranParent -> project/Art1 -> project/Art -> sdk/Mods -> modParentPath -> sdk/Art
|
||||||
|
* AUDIO: sdk -> modGranParent -> project/Audio1 -> project/Audio -> sdk/Mods -> modParentPath -> sdk/Audio
|
||||||
|
*
|
||||||
|
* `extra` directories (from user settings) are appended after the defaults
|
||||||
|
* for their matching prefix.
|
||||||
|
*/
|
||||||
|
export function buildSearchPaths(
|
||||||
|
sdkDir: string,
|
||||||
|
projectDir: string,
|
||||||
|
extra?: Partial<Record<"DATA" | "ART" | "AUDIO", string[]>>,
|
||||||
|
): SearchPaths {
|
||||||
|
const modParentPath = resolve(projectDir, "..");
|
||||||
|
const modGranParent = resolve(modParentPath, "..");
|
||||||
|
return {
|
||||||
|
DATA: [
|
||||||
|
sdkDir,
|
||||||
|
modGranParent,
|
||||||
|
join(projectDir, "Data"),
|
||||||
|
join(sdkDir, "Mods"),
|
||||||
|
modParentPath,
|
||||||
|
join(sdkDir, "SageXml"),
|
||||||
|
...(extra?.DATA ?? []),
|
||||||
|
],
|
||||||
|
ART: [
|
||||||
|
sdkDir,
|
||||||
|
modGranParent,
|
||||||
|
join(projectDir, "Art1"),
|
||||||
|
join(projectDir, "Art"),
|
||||||
|
join(sdkDir, "Mods"),
|
||||||
|
modParentPath,
|
||||||
|
join(sdkDir, "Art"),
|
||||||
|
...(extra?.ART ?? []),
|
||||||
|
],
|
||||||
|
AUDIO: [
|
||||||
|
sdkDir,
|
||||||
|
modGranParent,
|
||||||
|
join(projectDir, "Audio1"),
|
||||||
|
join(projectDir, "Audio"),
|
||||||
|
join(sdkDir, "Mods"),
|
||||||
|
modParentPath,
|
||||||
|
join(sdkDir, "Audio"),
|
||||||
|
...(extra?.AUDIO ?? []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitPrefix(source: string): { prefix: SourcePrefix; rest: string } {
|
||||||
|
for (const prefix of PREFIXES) {
|
||||||
|
if (source.toUpperCase().startsWith(`${prefix}:`)) {
|
||||||
|
return { prefix, rest: source.slice(prefix.length + 1).replace(/^[/\\]+/, "") };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { prefix: null, rest: source.replace(/^[/\\]+/, "") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an Include/@source (or xi:include/@href) to an absolute file path,
|
||||||
|
* or null when not found.
|
||||||
|
*
|
||||||
|
* - DATA:/ART:/AUDIO: prefixes are resolved against the corresponding search
|
||||||
|
* paths, in order.
|
||||||
|
* - ART: paths without a directory separator also try the 2-letter prefix
|
||||||
|
* subdirectory (e.g. JUAntiShip -> ju/JUAntiShip).
|
||||||
|
* - Paths without a prefix are resolved relative to the including file.
|
||||||
|
*/
|
||||||
|
export function resolveSource(
|
||||||
|
source: string,
|
||||||
|
currentDir: string | null,
|
||||||
|
searchPaths: SearchPaths,
|
||||||
|
): ResolveResult {
|
||||||
|
const raw = source.trim().replace(/\\/g, "/");
|
||||||
|
const { prefix, rest } = splitPrefix(raw);
|
||||||
|
|
||||||
|
if (prefix) {
|
||||||
|
const bases = searchPaths[prefix] ?? [];
|
||||||
|
const direct = findInBases(rest, bases);
|
||||||
|
if (direct) return { path: direct, prefix, raw };
|
||||||
|
if (prefix === "ART" && !rest.includes("/")) {
|
||||||
|
const two = rest.slice(0, 2).toLowerCase();
|
||||||
|
const prefixed = findInBases(`${two}/${rest}`, bases);
|
||||||
|
if (prefixed) return { path: prefixed, prefix, raw };
|
||||||
|
}
|
||||||
|
return { path: null, prefix, raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentDir && isAbsolute(rest)) {
|
||||||
|
return { path: fileExists(rest) ? rest : null, prefix: null, raw };
|
||||||
|
}
|
||||||
|
if (currentDir) {
|
||||||
|
const candidate = resolve(currentDir, rest);
|
||||||
|
return { path: fileExists(candidate) ? candidate : null, prefix: null, raw };
|
||||||
|
}
|
||||||
|
return { path: null, prefix: null, raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInBases(relPath: string, bases: string[]): string | null {
|
||||||
|
for (const base of bases) {
|
||||||
|
const candidate = normalize(resolve(base, relPath));
|
||||||
|
if (fileExists(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileExists(path: string): boolean {
|
||||||
|
try {
|
||||||
|
return statSync(path).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For `<Include type="reference" source="DATA:static.xml">`, returns the
|
||||||
|
* compiled manifest file that backs the placeholder, when present in one of
|
||||||
|
* the builtmods directories. The placeholder file name maps to
|
||||||
|
* `<name>.manifest` (e.g. static.xml -> static.manifest).
|
||||||
|
*/
|
||||||
|
export function manifestPathForReference(
|
||||||
|
source: string,
|
||||||
|
builtmodsDirs: string[],
|
||||||
|
): string | null {
|
||||||
|
const base = basenameWithoutExt(stripPrefix(source).replace(/\\/g, "/"));
|
||||||
|
if (!base) return null;
|
||||||
|
for (const dir of builtmodsDirs) {
|
||||||
|
const candidate = join(dir, `${base}.manifest`);
|
||||||
|
try {
|
||||||
|
if (statSync(candidate).isFile()) return candidate;
|
||||||
|
} catch {
|
||||||
|
// continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function basenameWithoutExt(path: string): string {
|
||||||
|
const idx = path.lastIndexOf("/");
|
||||||
|
const file = idx >= 0 ? path.slice(idx + 1) : path;
|
||||||
|
const dot = file.lastIndexOf(".");
|
||||||
|
return dot > 0 ? file.slice(0, dot) : file;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripPrefix(source: string): string {
|
||||||
|
const idx = source.indexOf(":");
|
||||||
|
return idx >= 0 ? source.slice(idx + 1) : source;
|
||||||
|
}
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
/**
|
||||||
|
* Workspace indexer for RA3 Mod XML.
|
||||||
|
*
|
||||||
|
* Walks the include graph from Data/Mod.xml (static stream) and
|
||||||
|
* Data/additionalmaps/mapmetadata_*.xml (global streams), collects asset
|
||||||
|
* definitions, `$DEFINE` constants, resolved `reference` includes (parsed
|
||||||
|
* from compiled .manifest files) and a file-name index used for
|
||||||
|
* Include/@source completion.
|
||||||
|
*
|
||||||
|
* Pure TypeScript (no vscode dependency) so the indexing core can be reused
|
||||||
|
* outside the extension.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile, readdir, stat } from "node:fs/promises";
|
||||||
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
||||||
|
import { LineMap, parseXml, type XmlDocument, type XmlElement } from "../language/xmlParser";
|
||||||
|
import {
|
||||||
|
buildSearchPaths,
|
||||||
|
manifestPathForReference,
|
||||||
|
resolveSource,
|
||||||
|
type SearchPaths,
|
||||||
|
} from "./includeResolver";
|
||||||
|
import {
|
||||||
|
deriveAssetId,
|
||||||
|
deriveAssetType,
|
||||||
|
parseManifest,
|
||||||
|
type ManifestInfo,
|
||||||
|
} from "./manifestParser";
|
||||||
|
import { canonicalTypeName } from "../model/schemaModel";
|
||||||
|
import { collectSourceCandidates } from "./fileScanner";
|
||||||
|
import type {
|
||||||
|
AssetDef,
|
||||||
|
DefineDef,
|
||||||
|
IndexOptions,
|
||||||
|
IndexedFile,
|
||||||
|
ModIndex,
|
||||||
|
ParsedFile,
|
||||||
|
SourceCandidate,
|
||||||
|
StreamInfo,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export class DocumentCache {
|
||||||
|
private map = new Map<string, ParsedFile>();
|
||||||
|
|
||||||
|
constructor(private capacity = 64) {}
|
||||||
|
|
||||||
|
get(path: string): ParsedFile | undefined {
|
||||||
|
const key = normKey(path);
|
||||||
|
const hit = this.map.get(key);
|
||||||
|
if (!hit) return undefined;
|
||||||
|
this.map.delete(key);
|
||||||
|
this.map.set(key, hit);
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(parsed: ParsedFile): void {
|
||||||
|
const key = normKey(parsed.file.path);
|
||||||
|
this.map.delete(key);
|
||||||
|
this.map.set(key, parsed);
|
||||||
|
if (this.map.size > this.capacity) {
|
||||||
|
const oldest = this.map.keys().next().value;
|
||||||
|
if (oldest !== undefined) this.map.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(path: string): void {
|
||||||
|
this.map.delete(normKey(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.map.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ModIndexer {
|
||||||
|
private searchPaths: SearchPaths;
|
||||||
|
private docs = new DocumentCache();
|
||||||
|
private assets = new Map<string, Map<string, AssetDef[]>>();
|
||||||
|
private assetsById = new Map<string, AssetDef[]>();
|
||||||
|
private defines = new Map<string, DefineDef[]>();
|
||||||
|
private files = new Map<string, IndexedFile>();
|
||||||
|
private streams: StreamInfo[] = [];
|
||||||
|
private manifests = new Map<string, ManifestInfo>();
|
||||||
|
private sourceCandidates: SourceCandidate[] = [];
|
||||||
|
private diagnostics: ModIndex["diagnostics"] = [];
|
||||||
|
private visitedAll = new Set<string>();
|
||||||
|
private visitedInstance = new Set<string>();
|
||||||
|
private manifestAssetKeys = new Set<string>();
|
||||||
|
|
||||||
|
constructor(private opts: IndexOptions) {
|
||||||
|
this.searchPaths = buildSearchPaths(opts.sdkDir, opts.projectDir, {
|
||||||
|
DATA: opts.additionalDataSearchPaths,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-reads and caches a document; null when unreadable. */
|
||||||
|
async readDocument(path: string): Promise<ParsedFile | null> {
|
||||||
|
const hit = this.docs.get(path);
|
||||||
|
if (hit) return hit;
|
||||||
|
try {
|
||||||
|
const [st, text] = await Promise.all([stat(path), readFile(path, "utf8")]);
|
||||||
|
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, lineMap: null };
|
||||||
|
this.docs.set(parsed);
|
||||||
|
this.files.set(normKey(parsed.file.path), file);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
const parse = parseXml(text);
|
||||||
|
const parsed: ParsedFile = {
|
||||||
|
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
|
||||||
|
parse,
|
||||||
|
lineMap: new LineMap(text),
|
||||||
|
};
|
||||||
|
this.docs.set(parsed);
|
||||||
|
this.files.set(normKey(parsed.file.path), parsed.file);
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
const parsed: ParsedFile = {
|
||||||
|
file: { path: resolve(path), stat: null },
|
||||||
|
parse: null,
|
||||||
|
lineMap: null,
|
||||||
|
};
|
||||||
|
this.docs.set(parsed);
|
||||||
|
this.files.set(normKey(parsed.file.path), parsed.file);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the cached parse if present (does not read from disk). */
|
||||||
|
cachedDocument(path: string): ParsedFile | undefined {
|
||||||
|
return this.docs.get(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async build(): Promise<ModIndex> {
|
||||||
|
const start = Date.now();
|
||||||
|
const projectData = await findCaseInsensitiveDir(join(this.opts.projectDir, "Data"));
|
||||||
|
const additionalMaps = projectData
|
||||||
|
? await findCaseInsensitiveDir(join(projectData, "additionalmaps"))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// ── Streams ──
|
||||||
|
const staticEntry = projectData ? join(projectData, "Mod.xml") : null;
|
||||||
|
if (staticEntry) {
|
||||||
|
const stream: StreamInfo = { name: "static", entry: staticEntry, files: new Set() };
|
||||||
|
this.streams.push(stream);
|
||||||
|
await this.walk(staticEntry, "all", stream, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (additionalMaps) {
|
||||||
|
let entries: string[] = [];
|
||||||
|
try {
|
||||||
|
entries = await readdir(additionalMaps);
|
||||||
|
} catch {
|
||||||
|
entries = [];
|
||||||
|
}
|
||||||
|
const metadataFiles = entries
|
||||||
|
.filter((f) => /^mapmetadata_.*\.xml$/i.test(f))
|
||||||
|
.sort();
|
||||||
|
for (const f of metadataFiles) {
|
||||||
|
const entry = join(additionalMaps, f);
|
||||||
|
const stream: StreamInfo = {
|
||||||
|
name: `global:${basename(f, ".xml")}`,
|
||||||
|
entry,
|
||||||
|
files: new Set(),
|
||||||
|
};
|
||||||
|
this.streams.push(stream);
|
||||||
|
await this.walk(entry, "all", stream, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Source completion candidates ──
|
||||||
|
const dataDirs = [
|
||||||
|
projectData ?? join(this.opts.projectDir, "Data"),
|
||||||
|
join(this.opts.sdkDir, "SageXml"),
|
||||||
|
...this.opts.additionalDataSearchPaths,
|
||||||
|
];
|
||||||
|
if (!this.opts.indexSageXml) {
|
||||||
|
const sage = join(this.opts.sdkDir, "SageXml");
|
||||||
|
const idx = dataDirs.findIndex((d) => normKey(d) === normKey(sage));
|
||||||
|
if (idx >= 0) dataDirs.splice(idx, 1);
|
||||||
|
}
|
||||||
|
const artDirs = [
|
||||||
|
join(this.opts.projectDir, "Art1"),
|
||||||
|
join(this.opts.projectDir, "Art"),
|
||||||
|
join(this.opts.sdkDir, "Art"),
|
||||||
|
];
|
||||||
|
const audioDirs = [
|
||||||
|
join(this.opts.projectDir, "Audio1"),
|
||||||
|
join(this.opts.projectDir, "Audio"),
|
||||||
|
join(this.opts.sdkDir, "Audio"),
|
||||||
|
];
|
||||||
|
this.sourceCandidates = await collectSourceCandidates(
|
||||||
|
this.opts.walker,
|
||||||
|
dataDirs,
|
||||||
|
artDirs,
|
||||||
|
audioDirs,
|
||||||
|
projectData ?? join(this.opts.projectDir, "Data"),
|
||||||
|
);
|
||||||
|
// The SDK root itself is the first DATA: search base (static.xml,
|
||||||
|
// global.xml, audio.xml placeholders) but only its shallow XML files are
|
||||||
|
// relevant. These candidates take precedence over same-named files found
|
||||||
|
// deeper in the search paths (e.g. SageXml/Static.xml).
|
||||||
|
const sdkRootXml = (await readdir(this.opts.sdkDir)).filter(
|
||||||
|
(f) => f.toLowerCase().endsWith(".xml"),
|
||||||
|
);
|
||||||
|
const sdkRootCandidates: SourceCandidate[] = sdkRootXml.map((f) => ({
|
||||||
|
source: `DATA:${f}`,
|
||||||
|
path: resolve(this.opts.sdkDir, f),
|
||||||
|
prefix: "DATA",
|
||||||
|
baseDir: resolve(this.opts.sdkDir),
|
||||||
|
}));
|
||||||
|
this.sourceCandidates = dedupeSourceCandidates([
|
||||||
|
...sdkRootCandidates,
|
||||||
|
...this.sourceCandidates,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const manifestAssetCount = [...this.manifests.values()].reduce(
|
||||||
|
(sum, m) => sum + m.assets.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectDir: resolve(this.opts.projectDir),
|
||||||
|
sdkDir: resolve(this.opts.sdkDir),
|
||||||
|
assets: this.assets,
|
||||||
|
assetsById: this.assetsById,
|
||||||
|
defines: this.defines,
|
||||||
|
files: this.files,
|
||||||
|
streams: this.streams,
|
||||||
|
manifests: this.manifests,
|
||||||
|
sourceCandidates: this.sourceCandidates,
|
||||||
|
diagnostics: this.diagnostics,
|
||||||
|
stats: {
|
||||||
|
projectDir: resolve(this.opts.projectDir),
|
||||||
|
sdkDir: resolve(this.opts.sdkDir),
|
||||||
|
indexedFiles: this.files.size,
|
||||||
|
parsedFiles: [...this.files.values()].filter(
|
||||||
|
(f) => f.stat != null && f.stat.size <= MAX_PARSE_BYTES,
|
||||||
|
).length,
|
||||||
|
assetCount: [...this.assets.values()].reduce((sum, byId) => sum + byId.size, 0),
|
||||||
|
defineCount: this.defines.size,
|
||||||
|
manifestFiles: this.manifests.size,
|
||||||
|
manifestAssetCount,
|
||||||
|
streams: this.streams.length,
|
||||||
|
sourceCandidates: this.sourceCandidates.length,
|
||||||
|
elapsedMs: Date.now() - start,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Include walk ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async walk(
|
||||||
|
path: string,
|
||||||
|
mode: "all" | "instance",
|
||||||
|
stream: StreamInfo,
|
||||||
|
depth: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const key = normKey(path);
|
||||||
|
if (depth > MAX_DEPTH) {
|
||||||
|
this.diagnostics.push({
|
||||||
|
file: path,
|
||||||
|
line: 0,
|
||||||
|
message: "Include depth exceeded - possible include cycle",
|
||||||
|
severity: "warning",
|
||||||
|
code: "include-cycle",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === "all") {
|
||||||
|
if (this.visitedAll.has(key)) return;
|
||||||
|
this.visitedAll.add(key);
|
||||||
|
} else {
|
||||||
|
if (this.visitedInstance.has(key)) return;
|
||||||
|
this.visitedInstance.add(key);
|
||||||
|
if (this.visitedAll.has(key)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.files.add(key);
|
||||||
|
|
||||||
|
// Binary assets (w3x/dds/...) are referenced but never parsed.
|
||||||
|
if (!isXmlPath(path)) return;
|
||||||
|
|
||||||
|
const parsed = await this.readDocument(path);
|
||||||
|
if (!parsed?.parse?.root) return;
|
||||||
|
const root = parsed.parse.root;
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (!resolved.path) {
|
||||||
|
this.diagnostics.push({
|
||||||
|
file: parsed.file.path,
|
||||||
|
line: lineOf(parsed, el.start),
|
||||||
|
message: `xi:include target not found: ${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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleXiInclude(
|
||||||
|
xi: XmlElement,
|
||||||
|
parent: ParsedFile,
|
||||||
|
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);
|
||||||
|
if (!resolved.path) {
|
||||||
|
this.diagnostics.push({
|
||||||
|
file: parent.file.path,
|
||||||
|
line: lineOf(parent, xi.start),
|
||||||
|
message: `xi:include target not found: ${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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream.files.add(normKey(target.file.path));
|
||||||
|
await this.walk(target.file.path, "all", stream, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manifest loading ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns true when the manifest was parsed successfully. */
|
||||||
|
private async loadManifest(path: string, streamName: string): Promise<boolean> {
|
||||||
|
const key = normKey(path);
|
||||||
|
let info = this.manifests.get(key);
|
||||||
|
if (!info) {
|
||||||
|
try {
|
||||||
|
const data = await readFile(path);
|
||||||
|
const buffer = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||||
|
info = parseManifest(buffer);
|
||||||
|
} catch {
|
||||||
|
this.diagnostics.push({
|
||||||
|
file: path,
|
||||||
|
line: 0,
|
||||||
|
message: "Manifest file could not be read",
|
||||||
|
severity: "warning",
|
||||||
|
code: "manifest-read-error",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.manifests.set(key, info);
|
||||||
|
}
|
||||||
|
if (info.error) return false;
|
||||||
|
for (const asset of info.assets) {
|
||||||
|
if (!asset.name) continue;
|
||||||
|
const id = deriveAssetId(asset.name);
|
||||||
|
const assetKey = `${asset.typeId}:${id.toLowerCase()}`;
|
||||||
|
if (this.manifestAssetKeys.has(assetKey)) continue;
|
||||||
|
this.manifestAssetKeys.add(assetKey);
|
||||||
|
this.addAsset({
|
||||||
|
type:
|
||||||
|
canonicalTypeName(deriveAssetType(asset.typeName, asset.name)) ??
|
||||||
|
`#${asset.typeId.toString(16)}`,
|
||||||
|
id,
|
||||||
|
file: path,
|
||||||
|
line: 0,
|
||||||
|
origin: "manifest",
|
||||||
|
stream: streamName,
|
||||||
|
manifest: path,
|
||||||
|
manifestSource: asset.sourceFileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private originOf(path: string): "project" | "sdk" {
|
||||||
|
const p = resolve(path).toLowerCase();
|
||||||
|
const project = resolve(this.opts.projectDir).toLowerCase();
|
||||||
|
const sdk = resolve(this.opts.sdkDir).toLowerCase();
|
||||||
|
if (p.startsWith(project + "\\")) return "project";
|
||||||
|
if (sdk && p.startsWith(sdk + "\\")) return "sdk";
|
||||||
|
return "project";
|
||||||
|
}
|
||||||
|
|
||||||
|
private addAsset(def: AssetDef): void {
|
||||||
|
// Keep the original case: type names are matched against the XSD model.
|
||||||
|
const typeKey = def.type;
|
||||||
|
const idKey = def.id.toLowerCase();
|
||||||
|
let byId = this.assets.get(typeKey);
|
||||||
|
if (!byId) {
|
||||||
|
byId = new Map();
|
||||||
|
this.assets.set(typeKey, byId);
|
||||||
|
}
|
||||||
|
const arr = byId.get(idKey);
|
||||||
|
if (arr) {
|
||||||
|
if (arr.some((a) => a.file === def.file && a.line === def.line)) return;
|
||||||
|
arr.push(def);
|
||||||
|
} else {
|
||||||
|
byId.set(idKey, [def]);
|
||||||
|
}
|
||||||
|
const all = this.assetsById.get(idKey);
|
||||||
|
if (all) {
|
||||||
|
if (all.some((a) => a.file === def.file && a.line === def.line)) return;
|
||||||
|
all.push(def);
|
||||||
|
} else {
|
||||||
|
this.assetsById.set(idKey, [def]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Module-level helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function localName(tag: string): string {
|
||||||
|
const idx = tag.lastIndexOf(":");
|
||||||
|
return idx >= 0 ? tag.slice(idx + 1) : tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineOf(parsed: ParsedFile, offset: number): number {
|
||||||
|
if (!parsed.lineMap) return 0;
|
||||||
|
return parsed.lineMap.positionAt(offset).line + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isXmlPath(path: string): boolean {
|
||||||
|
const ext = extname(path).toLowerCase();
|
||||||
|
return XML_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findCaseInsensitiveDir(dir: string): Promise<string | null> {
|
||||||
|
const parent = dirname(dir);
|
||||||
|
const wanted = basename(dir);
|
||||||
|
try {
|
||||||
|
const entries = await readdir(parent, { withFileTypes: true });
|
||||||
|
const hit = entries.find(
|
||||||
|
(e) => e.isDirectory() && e.name.toLowerCase() === wanted.toLowerCase(),
|
||||||
|
);
|
||||||
|
return hit ? join(parent, hit.name) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findXPointerContainer(doc: XmlDocument, xpointer: string): XmlElement | null {
|
||||||
|
// Supports the form used by the mods:
|
||||||
|
// xmlns(n=uri:ea.com:eala:asset) xpointer(/n:ElementName/child::*)
|
||||||
|
const m = /xpointer\(\/\w+:(\w+)\/child::\*\)/.exec(xpointer);
|
||||||
|
if (!m) return null;
|
||||||
|
const name = m[1];
|
||||||
|
return doc.elements.find((el) => localName(el.name) === name) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the first candidate for each case-insensitive source string. */
|
||||||
|
function dedupeSourceCandidates(candidates: SourceCandidate[]): SourceCandidate[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: SourceCandidate[] = [];
|
||||||
|
for (const c of candidates) {
|
||||||
|
const key = c.source.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* Parser for SAGE `.manifest` files, ported from OpenSAGE
|
||||||
|
* (src/OpenSage.Game/Data/StreamFS/ManifestFile.cs, commit d45d361).
|
||||||
|
*
|
||||||
|
* The manifest is a binary index produced by BinaryAssetBuilder: every asset
|
||||||
|
* compiled into a stream is listed with hashed type/instance ids, an offset
|
||||||
|
* into the asset-name string buffer, and an optional source file name.
|
||||||
|
* Parsing it lets the extension treat `reference` includes (static.xml,
|
||||||
|
* global.xml, audio.xml) as real, searchable asset pools.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { assetTypeNameFromHash } from "../model/schemaModel";
|
||||||
|
|
||||||
|
export interface ManifestAsset {
|
||||||
|
typeId: number;
|
||||||
|
typeName: string | null;
|
||||||
|
name: string;
|
||||||
|
sourceFileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManifestReferenceEntry {
|
||||||
|
referenceType: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManifestInfo {
|
||||||
|
version: number;
|
||||||
|
isBigEndian: boolean;
|
||||||
|
isLinked: boolean;
|
||||||
|
assetCount: number;
|
||||||
|
assets: ManifestAsset[];
|
||||||
|
manifestReferences: ManifestReferenceEntry[];
|
||||||
|
/** Present when the file could not be parsed. */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives an asset type from its manifest name. BAB stores manifest assets
|
||||||
|
* as "TypeName:Id" (e.g. "PlayerTemplate:Allies"), so the part before the
|
||||||
|
* first colon is the type even when the TypeId hash is unknown to the
|
||||||
|
* bundled AssetType table. Asset ids cannot contain ":" (InstanceId pattern),
|
||||||
|
* so the split is unambiguous.
|
||||||
|
*/
|
||||||
|
export function deriveAssetType(typeName: string | null, assetName: string): string | null {
|
||||||
|
if (typeName) return typeName;
|
||||||
|
const idx = assetName.indexOf(":");
|
||||||
|
if (idx <= 0) return null;
|
||||||
|
const prefix = assetName.slice(0, idx);
|
||||||
|
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix) ? prefix : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the referenceable asset id from a manifest name. BAB stores
|
||||||
|
* manifest names as "TypeName:InstanceId" where the instance id may itself
|
||||||
|
* carry a subtype prefix for art assets, e.g.
|
||||||
|
* W3dContainer:W3DContainer:AUANTIVEHICLEVEHICLETECH1_SKN
|
||||||
|
* The referenceable id is the last colon-separated segment (asset ids cannot
|
||||||
|
* contain ":" per the InstanceId pattern).
|
||||||
|
*/
|
||||||
|
export function deriveAssetId(assetName: string): string {
|
||||||
|
const idx = assetName.lastIndexOf(":");
|
||||||
|
return idx >= 0 ? assetName.slice(idx + 1) : assetName;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Cursor {
|
||||||
|
private pos = 0;
|
||||||
|
constructor(private buf: Uint8Array) {}
|
||||||
|
|
||||||
|
get position(): number {
|
||||||
|
return this.pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte(): number {
|
||||||
|
if (this.pos >= this.buf.length) throw new Error("Unexpected end of file");
|
||||||
|
return this.buf[this.pos++];
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16(): number {
|
||||||
|
const a = this.byte();
|
||||||
|
const b = this.byte();
|
||||||
|
return a | (b << 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32(): number {
|
||||||
|
const a = this.byte();
|
||||||
|
const b = this.byte();
|
||||||
|
const c = this.byte();
|
||||||
|
const d = this.byte();
|
||||||
|
return ((a | (b << 8) | (c << 16) | (d << 24)) >>> 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
skip(n: number): void {
|
||||||
|
this.pos += n;
|
||||||
|
if (this.pos > this.buf.length) throw new Error("Unexpected end of file");
|
||||||
|
}
|
||||||
|
|
||||||
|
nullTerminatedString(): string {
|
||||||
|
const start = this.pos;
|
||||||
|
while (this.pos < this.buf.length && this.buf[this.pos] !== 0) {
|
||||||
|
this.pos++;
|
||||||
|
}
|
||||||
|
const end = this.pos;
|
||||||
|
if (this.pos < this.buf.length) this.pos++; // consume NUL
|
||||||
|
return decodeAscii(this.buf, start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
readNameBuffer(endPosition: number): Map<number, string> {
|
||||||
|
const names = new Map<number, string>();
|
||||||
|
let nameOffset = 0;
|
||||||
|
while (this.pos < endPosition) {
|
||||||
|
const start = this.pos;
|
||||||
|
names.set(nameOffset, this.nullTerminatedString());
|
||||||
|
nameOffset += this.pos - start;
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeAscii(buf: Uint8Array, start: number, end: number): string {
|
||||||
|
// Asset ids are ASCII; anything else decodes with replacement chars.
|
||||||
|
let out = "";
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
out += String.fromCharCode(buf[i]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseManifest(buffer: Uint8Array): ManifestInfo {
|
||||||
|
try {
|
||||||
|
return parseManifestInner(buffer);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
version: 0,
|
||||||
|
isBigEndian: false,
|
||||||
|
isLinked: false,
|
||||||
|
assetCount: 0,
|
||||||
|
assets: [],
|
||||||
|
manifestReferences: [],
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseManifestInner(buffer: Uint8Array): ManifestInfo {
|
||||||
|
const cursor = new Cursor(buffer);
|
||||||
|
|
||||||
|
const testValue = cursor.uint32();
|
||||||
|
let version: number;
|
||||||
|
let isBigEndian: boolean;
|
||||||
|
let isLinked: boolean;
|
||||||
|
|
||||||
|
if (testValue === 0) {
|
||||||
|
version = cursor.uint16();
|
||||||
|
if (version !== 7) throw new Error(`Unsupported manifest version ${version}`);
|
||||||
|
isBigEndian = readBooleanChecked(cursor);
|
||||||
|
isLinked = readBooleanChecked(cursor);
|
||||||
|
} else {
|
||||||
|
cursor.skip(-4);
|
||||||
|
isBigEndian = readBooleanChecked(cursor);
|
||||||
|
isLinked = readBooleanChecked(cursor);
|
||||||
|
version = cursor.uint16();
|
||||||
|
if (version !== 5 && version !== 6) {
|
||||||
|
throw new Error(`Unsupported manifest version ${version}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamChecksum = cursor.uint32();
|
||||||
|
const allTypesHash = cursor.uint32();
|
||||||
|
const assetCount = cursor.uint32();
|
||||||
|
const totalInstanceDataSize = cursor.uint32();
|
||||||
|
const maxInstanceChunkSize = cursor.uint32();
|
||||||
|
const maxRelocationChunkSize = cursor.uint32();
|
||||||
|
const maxImportsChunkSize = cursor.uint32();
|
||||||
|
const assetReferenceBufferSize = cursor.uint32();
|
||||||
|
const referencedManifestNameBufferSize = cursor.uint32();
|
||||||
|
const assetNameBufferSize = cursor.uint32();
|
||||||
|
const sourceFileNameBufferSize = cursor.uint32();
|
||||||
|
|
||||||
|
void streamChecksum;
|
||||||
|
void allTypesHash;
|
||||||
|
void totalInstanceDataSize;
|
||||||
|
void maxInstanceChunkSize;
|
||||||
|
void maxRelocationChunkSize;
|
||||||
|
void maxImportsChunkSize;
|
||||||
|
|
||||||
|
interface RawEntry {
|
||||||
|
typeId: number;
|
||||||
|
nameOffset: number;
|
||||||
|
sourceFileNameOffset: number;
|
||||||
|
}
|
||||||
|
const rawEntries: RawEntry[] = [];
|
||||||
|
for (let i = 0; i < assetCount; i++) {
|
||||||
|
const typeId = cursor.uint32();
|
||||||
|
cursor.uint32(); // instanceId
|
||||||
|
cursor.uint32(); // typeHash
|
||||||
|
cursor.uint32(); // instanceHash
|
||||||
|
cursor.uint32(); // assetReferenceOffset
|
||||||
|
cursor.uint32(); // assetReferenceCount
|
||||||
|
const nameOffset = cursor.uint32();
|
||||||
|
const sourceFileNameOffset = cursor.uint32();
|
||||||
|
cursor.uint32(); // instanceDataSize
|
||||||
|
cursor.uint32(); // relocationDataSize
|
||||||
|
cursor.uint32(); // importsDataSize
|
||||||
|
if (version >= 6) {
|
||||||
|
cursor.byte(); // isTokenized
|
||||||
|
cursor.skip(3);
|
||||||
|
}
|
||||||
|
rawEntries.push({
|
||||||
|
typeId,
|
||||||
|
nameOffset,
|
||||||
|
sourceFileNameOffset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset references buffer (not needed for completions).
|
||||||
|
cursor.skip(assetReferenceBufferSize);
|
||||||
|
|
||||||
|
// Referenced manifest names.
|
||||||
|
const manifestRefsEnd = cursor.position + referencedManifestNameBufferSize;
|
||||||
|
const manifestReferences: ManifestReferenceEntry[] = [];
|
||||||
|
while (cursor.position < manifestRefsEnd) {
|
||||||
|
const referenceType = cursor.byte();
|
||||||
|
const path = cursor.nullTerminatedString();
|
||||||
|
manifestReferences.push({ referenceType, path });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset names.
|
||||||
|
const assetNamesEnd = cursor.position + assetNameBufferSize;
|
||||||
|
const assetNames = cursor.readNameBuffer(assetNamesEnd);
|
||||||
|
|
||||||
|
// Source file names.
|
||||||
|
const sourceNamesEnd = cursor.position + sourceFileNameBufferSize;
|
||||||
|
const sourceNames = cursor.readNameBuffer(sourceNamesEnd);
|
||||||
|
|
||||||
|
const assets: ManifestAsset[] = rawEntries.map((entry) => ({
|
||||||
|
typeId: entry.typeId,
|
||||||
|
typeName: assetTypeNameFromHash(entry.typeId) ?? null,
|
||||||
|
name: assetNames.get(entry.nameOffset) ?? "",
|
||||||
|
sourceFileName: sourceNames.get(entry.sourceFileNameOffset) ?? "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
isBigEndian,
|
||||||
|
isLinked,
|
||||||
|
assetCount,
|
||||||
|
assets,
|
||||||
|
manifestReferences,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBooleanChecked(cursor: Cursor): boolean {
|
||||||
|
const value = cursor.byte();
|
||||||
|
if (value === 0) return false;
|
||||||
|
if (value === 1) return true;
|
||||||
|
throw new Error(`Invalid boolean byte ${value}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
attributesOfType,
|
||||||
|
elementTypeName,
|
||||||
|
isAssignableTo,
|
||||||
|
} from "../model/schemaModel";
|
||||||
|
import type { AssetDef, ModIndex } from "./types";
|
||||||
|
|
||||||
|
export interface ReferenceTarget {
|
||||||
|
def: AssetDef;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when an attribute is a typed reference: either the instance
|
||||||
|
* inheritance attribute `inheritFrom`, or an XSD attribute whose simple type
|
||||||
|
* carries an `xas:refType`. Enumerations and file paths (e.g.
|
||||||
|
* `Include/@type`, `Include/@source`) are not references.
|
||||||
|
*/
|
||||||
|
export function isReferenceAttribute(elementName: string, attrName: string): boolean {
|
||||||
|
return isReferenceAttributeOfType(elementTypeName(elementName), attrName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same check, but driven by a resolved XSD type name. */
|
||||||
|
export function isReferenceAttributeOfType(
|
||||||
|
typeName: string | null,
|
||||||
|
attrName: string,
|
||||||
|
): boolean {
|
||||||
|
if (attrName.toLowerCase() === "inheritfrom") return true;
|
||||||
|
const attr = attributesOfType(typeName).find((a) => a.name === attrName);
|
||||||
|
return attr != null && (attr.refType != null || attr.isRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the definitions a reference attribute value should point to.
|
||||||
|
*
|
||||||
|
* The result is strictly filtered by the attribute's reference type (from the
|
||||||
|
* XSD model) so that an id shared by several asset types only resolves to the
|
||||||
|
* matching definition (e.g. `Weapon="X"` jumps to the WeaponTemplate with
|
||||||
|
* id "X", never to a GameObject that happens to share the id).
|
||||||
|
*
|
||||||
|
* Returns [] when the attribute is not a typed reference or nothing matches.
|
||||||
|
*/
|
||||||
|
export function resolveReferenceTargets(
|
||||||
|
idx: ModIndex,
|
||||||
|
elementType: string,
|
||||||
|
attrName: string,
|
||||||
|
id: string,
|
||||||
|
): ReferenceTarget[] {
|
||||||
|
return resolveReferenceTargetsForType(
|
||||||
|
idx,
|
||||||
|
elementTypeName(elementType),
|
||||||
|
attrName,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same resolution, driven by a resolved XSD type name. */
|
||||||
|
export function resolveReferenceTargetsForType(
|
||||||
|
idx: ModIndex,
|
||||||
|
typeName: string | null,
|
||||||
|
attrName: string,
|
||||||
|
id: string,
|
||||||
|
): ReferenceTarget[] {
|
||||||
|
const defs = idx.assetsById.get(id.toLowerCase());
|
||||||
|
if (!defs?.length) return [];
|
||||||
|
|
||||||
|
const nameLower = attrName.toLowerCase();
|
||||||
|
let refType: string | null = null;
|
||||||
|
let selfType: string | null = null;
|
||||||
|
|
||||||
|
if (nameLower === "inheritfrom") {
|
||||||
|
selfType = typeName;
|
||||||
|
} else {
|
||||||
|
const attr = attributesOfType(typeName).find((a) => a.name === attrName);
|
||||||
|
if (!attr || (!attr.refType && !attr.isRef)) return [];
|
||||||
|
refType = attr.refType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets: ReferenceTarget[] = [];
|
||||||
|
for (const def of defs) {
|
||||||
|
if (refType && !isAssignableTo(def.type, refType)) continue;
|
||||||
|
if (selfType && !isAssignableTo(def.type, selfType)) continue;
|
||||||
|
let score = 3;
|
||||||
|
if (def.origin === "project") score = 0;
|
||||||
|
else if (def.origin === "sdk") score = 1;
|
||||||
|
else score = 2;
|
||||||
|
targets.push({ def, score });
|
||||||
|
}
|
||||||
|
targets.sort((a, b) => a.score - b.score || a.def.id.localeCompare(b.def.id));
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { XmlDocument } from "../language/xmlParser";
|
||||||
|
import type { ManifestInfo } from "./manifestParser";
|
||||||
|
import type { LineMap } from "../language/xmlParser";
|
||||||
|
|
||||||
|
export type AssetOrigin = "project" | "sdk" | "manifest";
|
||||||
|
|
||||||
|
export interface AssetDef {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
/** Absolute path of the defining file (for manifests: the manifest path). */
|
||||||
|
file: string;
|
||||||
|
/** 1-based line of the id attribute (or 0 when unknown, e.g. manifests). */
|
||||||
|
line: number;
|
||||||
|
origin: AssetOrigin;
|
||||||
|
/** "static" or "global:<name>" when the asset comes from a stream. */
|
||||||
|
stream?: string;
|
||||||
|
/** Set for assets that are only reachable through `instance` includes. */
|
||||||
|
viaInstance?: boolean;
|
||||||
|
/** Manifest path for origin === "manifest". */
|
||||||
|
manifest?: string;
|
||||||
|
/** Source file recorded inside a manifest (e.g. "DATA:globaldata/armor.xml"). */
|
||||||
|
manifestSource?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DefineDef {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
file: string;
|
||||||
|
line: number;
|
||||||
|
origin: AssetOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexedFile {
|
||||||
|
path: string;
|
||||||
|
stat: { mtimeMs: number; size: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamInfo {
|
||||||
|
name: string;
|
||||||
|
entry: string;
|
||||||
|
files: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceCandidate {
|
||||||
|
/** Suggested value for Include/@source. */
|
||||||
|
source: string;
|
||||||
|
/** Absolute path the candidate resolves to. */
|
||||||
|
path: string;
|
||||||
|
/** "DATA" | "ART" | "AUDIO" | null (relative). */
|
||||||
|
prefix: "DATA" | "ART" | "AUDIO" | null;
|
||||||
|
/** Directory that acts as the root of the relative path. */
|
||||||
|
baseDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexerDiagnostic {
|
||||||
|
file: string;
|
||||||
|
line: number;
|
||||||
|
message: string;
|
||||||
|
severity: "warning" | "error" | "information";
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexStats {
|
||||||
|
projectDir: string;
|
||||||
|
sdkDir: string;
|
||||||
|
indexedFiles: number;
|
||||||
|
parsedFiles: number;
|
||||||
|
assetCount: number;
|
||||||
|
defineCount: number;
|
||||||
|
manifestFiles: number;
|
||||||
|
manifestAssetCount: number;
|
||||||
|
streams: number;
|
||||||
|
sourceCandidates: number;
|
||||||
|
elapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModIndex {
|
||||||
|
projectDir: string;
|
||||||
|
sdkDir: string;
|
||||||
|
/** type -> id -> definitions (project + sdk + manifest, deduplicated). */
|
||||||
|
assets: Map<string, Map<string, AssetDef[]>>;
|
||||||
|
/** id -> definitions across all types. */
|
||||||
|
assetsById: Map<string, AssetDef[]>;
|
||||||
|
/** `$NAME` -> definitions. */
|
||||||
|
defines: Map<string, DefineDef[]>;
|
||||||
|
/** absolute path -> file record (only files touched by the include walk). */
|
||||||
|
files: Map<string, IndexedFile>;
|
||||||
|
streams: StreamInfo[];
|
||||||
|
manifests: Map<string, ManifestInfo>;
|
||||||
|
/** Files suggested for Include/@source completion. */
|
||||||
|
sourceCandidates: SourceCandidate[];
|
||||||
|
/** Problems found while indexing (unresolved includes, cycles, ...). */
|
||||||
|
diagnostics: IndexerDiagnostic[];
|
||||||
|
stats: IndexStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexOptions {
|
||||||
|
projectDir: string;
|
||||||
|
sdkDir: string;
|
||||||
|
builtmodsDirs: string[];
|
||||||
|
indexSageXml: boolean;
|
||||||
|
additionalDataSearchPaths: string[];
|
||||||
|
/** Directory walker used to enumerate files for source completion. */
|
||||||
|
walker: FileWalker;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileWalker {
|
||||||
|
/** Recursively lists files under a directory. Cached by the caller. */
|
||||||
|
listFiles(dir: string): Promise<string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParseCache {
|
||||||
|
get(path: string): IndexedFile | undefined;
|
||||||
|
set(file: IndexedFile): void;
|
||||||
|
clear(): void;
|
||||||
|
/** Removes the cached entry for a path. */
|
||||||
|
invalidate(path: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A parsed file plus its line map, produced on demand. */
|
||||||
|
export interface ParsedFile {
|
||||||
|
file: IndexedFile;
|
||||||
|
parse: XmlDocument | null;
|
||||||
|
lineMap: LineMap | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import type { XmlAttribute, XmlDocument, XmlElement } from "./xmlParser";
|
||||||
|
|
||||||
|
export type ContextKind =
|
||||||
|
| "element-name"
|
||||||
|
| "attribute-name"
|
||||||
|
| "attribute-value"
|
||||||
|
| "content"
|
||||||
|
| "none";
|
||||||
|
|
||||||
|
export interface CompletionContext {
|
||||||
|
kind: ContextKind;
|
||||||
|
/** The element whose start tag the cursor is in (or whose content). */
|
||||||
|
element: XmlElement | null;
|
||||||
|
/** Set when the cursor is inside a closing tag name ("</..."). */
|
||||||
|
closing: boolean;
|
||||||
|
/** The attribute being edited (attribute-value context). */
|
||||||
|
attr: XmlAttribute | null;
|
||||||
|
/** Text between the opening quote and the cursor. */
|
||||||
|
valuePrefix: string;
|
||||||
|
/** Names of attributes already present on the element. */
|
||||||
|
existingAttrs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function analyzeContext(
|
||||||
|
doc: XmlDocument,
|
||||||
|
text: string,
|
||||||
|
offset: number,
|
||||||
|
): CompletionContext {
|
||||||
|
// Find the innermost element whose span contains the offset.
|
||||||
|
let container: XmlElement | null = null;
|
||||||
|
for (const el of doc.elements) {
|
||||||
|
if (el.end < 0) continue;
|
||||||
|
if (offset >= el.start && offset <= el.end) {
|
||||||
|
if (!container || el.depth > container.depth) container = el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!container) return empty("none");
|
||||||
|
|
||||||
|
// Inside the start tag of the element.
|
||||||
|
if (offset >= container.start && offset <= container.startTagEnd) {
|
||||||
|
return analyzeStartTag(container, text, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise the cursor is in element content.
|
||||||
|
return {
|
||||||
|
kind: "content",
|
||||||
|
element: container,
|
||||||
|
closing: false,
|
||||||
|
attr: null,
|
||||||
|
valuePrefix: "",
|
||||||
|
existingAttrs: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyzeStartTag(
|
||||||
|
el: XmlElement,
|
||||||
|
text: string,
|
||||||
|
offset: number,
|
||||||
|
): CompletionContext {
|
||||||
|
const closing = text.startsWith("</", el.start);
|
||||||
|
const nameStart = el.start + (closing ? 2 : 1);
|
||||||
|
const nameEnd = nameStart + el.name.length;
|
||||||
|
const existingAttrs = el.attrs.map((a) => a.name);
|
||||||
|
|
||||||
|
if (offset <= nameEnd) {
|
||||||
|
return {
|
||||||
|
kind: "element-name",
|
||||||
|
element: el,
|
||||||
|
closing,
|
||||||
|
attr: null,
|
||||||
|
valuePrefix: "",
|
||||||
|
existingAttrs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside an attribute value?
|
||||||
|
for (const attr of el.attrs) {
|
||||||
|
if (attr.hasValue && offset >= attr.quoteStart && offset <= attr.quoteEnd) {
|
||||||
|
const start = attr.valueStart;
|
||||||
|
const prefix = offset > start ? text.slice(start, offset) : "";
|
||||||
|
return {
|
||||||
|
kind: "attribute-value",
|
||||||
|
element: el,
|
||||||
|
closing: false,
|
||||||
|
attr,
|
||||||
|
valuePrefix: prefix,
|
||||||
|
existingAttrs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right after "=" (no quotes yet) or between attributes.
|
||||||
|
const before = text.slice(el.start, offset);
|
||||||
|
const trimmed = before.replace(/\s+$/, "");
|
||||||
|
if (trimmed.endsWith("=")) {
|
||||||
|
return {
|
||||||
|
kind: "attribute-value",
|
||||||
|
element: el,
|
||||||
|
closing: false,
|
||||||
|
attr: lastAttrOf(el),
|
||||||
|
valuePrefix: "",
|
||||||
|
existingAttrs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "attribute-name",
|
||||||
|
element: el,
|
||||||
|
closing: false,
|
||||||
|
attr: null,
|
||||||
|
valuePrefix: "",
|
||||||
|
existingAttrs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastAttrOf(el: XmlElement): XmlAttribute | null {
|
||||||
|
return el.attrs.length ? el.attrs[el.attrs.length - 1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function empty(kind: ContextKind): CompletionContext {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
element: null,
|
||||||
|
closing: false,
|
||||||
|
attr: null,
|
||||||
|
valuePrefix: "",
|
||||||
|
existingAttrs: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { childTypeOf, elementTypeName } from "../model/schemaModel";
|
||||||
|
import type { XmlElement } from "./xmlParser";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the XSD type of an element from the parsed document tree by
|
||||||
|
* walking up to the root and applying context-aware child lookups at every
|
||||||
|
* level. Falls back to the global element->type map when the parent chain
|
||||||
|
* does not declare the child.
|
||||||
|
*/
|
||||||
|
export function resolveElementType(el: XmlElement): string | null {
|
||||||
|
if (!el.parent) {
|
||||||
|
return elementTypeName(el.name);
|
||||||
|
}
|
||||||
|
const parentType = resolveElementType(el.parent);
|
||||||
|
return childTypeOf(parentType, el.name) ?? elementTypeName(el.name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
/**
|
||||||
|
* Lightweight XML parser with source offsets.
|
||||||
|
*
|
||||||
|
* The extension needs exact positions of tags, attributes and values for
|
||||||
|
* completions, hover, navigation and diagnostics. fast-xml-parser does not
|
||||||
|
* provide offsets, so we use this small purpose-built parser instead. It is
|
||||||
|
* deliberately tolerant: malformed documents still produce a partial tree
|
||||||
|
* plus a list of errors, so completion keeps working while typing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface XmlAttribute {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
/** Offset of the first character of the name. */
|
||||||
|
nameStart: number;
|
||||||
|
/** Offset one past the last character of the name. */
|
||||||
|
nameEnd: number;
|
||||||
|
/** Offset of the first value character (after the opening quote). */
|
||||||
|
valueStart: number;
|
||||||
|
/** Offset one past the last value character (before the closing quote). */
|
||||||
|
valueEnd: number;
|
||||||
|
/** Offset of the opening quote. */
|
||||||
|
quoteStart: number;
|
||||||
|
/** Offset one past the closing quote. */
|
||||||
|
quoteEnd: number;
|
||||||
|
hasValue: boolean;
|
||||||
|
/** True when the value is delimited with double quotes. */
|
||||||
|
doubleQuoted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XmlElement {
|
||||||
|
name: string;
|
||||||
|
attrs: XmlAttribute[];
|
||||||
|
children: XmlElement[];
|
||||||
|
parent: XmlElement | null;
|
||||||
|
/** Offset of "<". */
|
||||||
|
start: number;
|
||||||
|
/** Offset one past the ">" of the start tag. */
|
||||||
|
startTagEnd: number;
|
||||||
|
/** Offset one past the end of the whole element (closing tag or "/>"). */
|
||||||
|
end: number;
|
||||||
|
selfClosing: boolean;
|
||||||
|
/** Offset of "</" of the closing tag, or -1 when self-closing. */
|
||||||
|
closeTagStart: number;
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XmlParseError {
|
||||||
|
message: string;
|
||||||
|
offset: number;
|
||||||
|
line: number;
|
||||||
|
character: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XmlDocument {
|
||||||
|
root: XmlElement | null;
|
||||||
|
/** All elements in document order (including the root). */
|
||||||
|
elements: XmlElement[];
|
||||||
|
errors: XmlParseError[];
|
||||||
|
/** Offset one past "?>" of the XML declaration, or 0. */
|
||||||
|
declarationEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
line: number;
|
||||||
|
character: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Precomputes line start offsets for offset <-> position conversion. */
|
||||||
|
export class LineMap {
|
||||||
|
private lineStarts: number[] = [0];
|
||||||
|
|
||||||
|
constructor(text: string) {
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text.charCodeAt(i) === 10) {
|
||||||
|
this.lineStarts.push(i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
positionAt(offset: number): Position {
|
||||||
|
let lo = 0;
|
||||||
|
let hi = this.lineStarts.length - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi + 1) >> 1;
|
||||||
|
if (this.lineStarts[mid] <= offset) {
|
||||||
|
lo = mid;
|
||||||
|
} else {
|
||||||
|
hi = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { line: lo, character: offset - this.lineStarts[lo] };
|
||||||
|
}
|
||||||
|
|
||||||
|
lineStart(line: number): number {
|
||||||
|
if (line < 0) return 0;
|
||||||
|
if (line >= this.lineStarts.length) return this.lineStarts[this.lineStarts.length - 1];
|
||||||
|
return this.lineStarts[line];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawTag {
|
||||||
|
name: string;
|
||||||
|
selfClosing: boolean;
|
||||||
|
start: number;
|
||||||
|
contentStart: number;
|
||||||
|
contentEnd: number;
|
||||||
|
end: number;
|
||||||
|
attrs: XmlAttribute[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAME_RE = /[A-Za-z_][\w:.-]*/y;
|
||||||
|
|
||||||
|
function parseTag(content: string, contentStart: number): RawTag {
|
||||||
|
const base = contentStart;
|
||||||
|
let j = 0;
|
||||||
|
while (j < content.length && /\s/.test(content[j])) {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
let name: string;
|
||||||
|
NAME_RE.lastIndex = j;
|
||||||
|
const m = NAME_RE.exec(content);
|
||||||
|
if (!m) {
|
||||||
|
name = "";
|
||||||
|
} else {
|
||||||
|
name = m[0];
|
||||||
|
}
|
||||||
|
const attrs: XmlAttribute[] = [];
|
||||||
|
let i = m ? m.index + name.length : j;
|
||||||
|
let selfClosing = false;
|
||||||
|
|
||||||
|
while (i < content.length) {
|
||||||
|
// skip whitespace
|
||||||
|
while (i < content.length && /\s/.test(content[i])) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i >= content.length) break;
|
||||||
|
const c = content[i];
|
||||||
|
// The tag content excludes the terminating ">", so a bare "/" (outside
|
||||||
|
// quotes) can only be the self-closing marker: "<name .../>".
|
||||||
|
if (c === "/") {
|
||||||
|
selfClosing = true;
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (c === ">") {
|
||||||
|
i += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// attribute name
|
||||||
|
const attrNameStart = i;
|
||||||
|
while (i < content.length && !/[\s=/>]/.test(content[i])) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
const attrName = content.slice(attrNameStart, i);
|
||||||
|
const nameEnd = base + i;
|
||||||
|
while (i < content.length && /\s/.test(content[i])) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
let hasValue = false;
|
||||||
|
let value = "";
|
||||||
|
let valueStart = -1;
|
||||||
|
let valueEnd = -1;
|
||||||
|
let quoteStart = -1;
|
||||||
|
let quoteEnd = -1;
|
||||||
|
let doubleQuoted = true;
|
||||||
|
if (content[i] === "=") {
|
||||||
|
i++;
|
||||||
|
while (i < content.length && /\s/.test(content[i])) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
const q = content[i];
|
||||||
|
if (q === '"' || q === "'") {
|
||||||
|
doubleQuoted = q === '"';
|
||||||
|
hasValue = true;
|
||||||
|
quoteStart = base + i;
|
||||||
|
i++;
|
||||||
|
const valueStartLocal = i;
|
||||||
|
while (i < content.length && content[i] !== q) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
valueStart = base + valueStartLocal;
|
||||||
|
valueEnd = base + i;
|
||||||
|
value = content.slice(valueStartLocal, i);
|
||||||
|
if (content[i] === q) {
|
||||||
|
i++;
|
||||||
|
quoteEnd = base + i;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// unquoted value - tolerate
|
||||||
|
const vs = i;
|
||||||
|
while (i < content.length && !/[\s>]/.test(content[i])) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
value = content.slice(vs, i);
|
||||||
|
hasValue = true;
|
||||||
|
valueStart = base + vs;
|
||||||
|
valueEnd = base + i;
|
||||||
|
quoteStart = valueStart;
|
||||||
|
quoteEnd = valueEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attrs.push({
|
||||||
|
name: attrName,
|
||||||
|
value,
|
||||||
|
nameStart: base + attrNameStart,
|
||||||
|
nameEnd,
|
||||||
|
valueStart,
|
||||||
|
valueEnd,
|
||||||
|
quoteStart,
|
||||||
|
quoteEnd,
|
||||||
|
hasValue,
|
||||||
|
doubleQuoted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
selfClosing,
|
||||||
|
start: base - 1,
|
||||||
|
contentStart: base,
|
||||||
|
contentEnd: base + i,
|
||||||
|
end: base + i,
|
||||||
|
attrs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseXml(text: string): XmlDocument {
|
||||||
|
const lineMap = new LineMap(text);
|
||||||
|
const errors: XmlParseError[] = [];
|
||||||
|
const elements: XmlElement[] = [];
|
||||||
|
const stack: XmlElement[] = [];
|
||||||
|
let root: XmlElement | null = null;
|
||||||
|
let declarationEnd = 0;
|
||||||
|
let i = 0;
|
||||||
|
const n = text.length;
|
||||||
|
|
||||||
|
const err = (message: string, offset: number) => {
|
||||||
|
const pos = lineMap.positionAt(offset);
|
||||||
|
errors.push({ message, offset, line: pos.line, character: pos.character });
|
||||||
|
};
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
const lt = text.indexOf("<", i);
|
||||||
|
if (lt < 0) break;
|
||||||
|
if (lt > i && stack.length === 0 && errors.length === 0) {
|
||||||
|
// text before the root element - ignore unless it is non-whitespace
|
||||||
|
const between = text.slice(i, lt);
|
||||||
|
if (between.trim() !== "") {
|
||||||
|
err("Content is not allowed before the root element", i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = lt;
|
||||||
|
|
||||||
|
// comment
|
||||||
|
if (text.startsWith("<!--", i)) {
|
||||||
|
const close = text.indexOf("-->", i + 4);
|
||||||
|
if (close < 0) {
|
||||||
|
err("Unterminated comment", i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i = close + 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// CDATA
|
||||||
|
if (text.startsWith("<![CDATA[", i)) {
|
||||||
|
const close = text.indexOf("]]>", i + 9);
|
||||||
|
if (close < 0) {
|
||||||
|
err("Unterminated CDATA section", i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i = close + 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// DOCTYPE
|
||||||
|
if (text.startsWith("<!DOCTYPE", i) || text.startsWith("<!doctype", i)) {
|
||||||
|
const close = text.indexOf(">", i);
|
||||||
|
if (close < 0) {
|
||||||
|
err("Unterminated DOCTYPE", i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i = close + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// processing instruction / declaration
|
||||||
|
if (text.startsWith("<?", i)) {
|
||||||
|
const close = text.indexOf("?>", i + 2);
|
||||||
|
if (close < 0) {
|
||||||
|
err("Unterminated processing instruction", i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (i === 0 && /^<\?xml\s/i.test(text.slice(i, close + 2))) {
|
||||||
|
declarationEnd = close + 2;
|
||||||
|
}
|
||||||
|
i = close + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// closing tag
|
||||||
|
if (text.startsWith("</", i)) {
|
||||||
|
const gt = text.indexOf(">", i + 2);
|
||||||
|
if (gt < 0) {
|
||||||
|
err("Unterminated closing tag", i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const name = text.slice(i + 2, gt).trim();
|
||||||
|
const top = stack[stack.length - 1];
|
||||||
|
if (!top) {
|
||||||
|
err(`Unexpected closing tag </${name}>`, i);
|
||||||
|
} else if (top.name !== name) {
|
||||||
|
err(`Mismatched closing tag: expected </${top.name}>, found </${name}>`, i);
|
||||||
|
// recover: find the matching element on the stack if possible
|
||||||
|
let idx = stack.length - 1;
|
||||||
|
while (idx >= 0 && stack[idx].name !== name) idx--;
|
||||||
|
if (idx >= 0) {
|
||||||
|
const closingCount = stack.length - 1 - idx;
|
||||||
|
for (let k = 0; k < closingCount; k++) {
|
||||||
|
const el = stack.pop()!;
|
||||||
|
el.end = gt + 1;
|
||||||
|
el.closeTagStart = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const el = stack.pop()!;
|
||||||
|
el.end = gt + 1;
|
||||||
|
el.closeTagStart = i;
|
||||||
|
}
|
||||||
|
i = gt + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// opening tag
|
||||||
|
if (text[i + 1] === "!" || text[i + 1] === "?") {
|
||||||
|
err("Malformed markup", i);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const gt = findTagEnd(text, i + 1);
|
||||||
|
if (gt < 0) {
|
||||||
|
err("Unterminated start tag", i);
|
||||||
|
const content = text.slice(i + 1);
|
||||||
|
const raw = parseTag(content, i + 1);
|
||||||
|
if (raw.name) {
|
||||||
|
const el = buildElement(raw, stack.length);
|
||||||
|
elements.push(el);
|
||||||
|
root = root ?? el;
|
||||||
|
stack.push(el);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const content = text.slice(i + 1, gt);
|
||||||
|
const raw = parseTag(content, i + 1);
|
||||||
|
raw.end = gt + 1;
|
||||||
|
const el = buildElement(raw, stack.length);
|
||||||
|
elements.push(el);
|
||||||
|
if (stack.length === 0) {
|
||||||
|
root = root ?? el;
|
||||||
|
} else {
|
||||||
|
const parent = stack[stack.length - 1];
|
||||||
|
parent.children.push(el);
|
||||||
|
el.parent = parent;
|
||||||
|
}
|
||||||
|
if (!raw.selfClosing) {
|
||||||
|
stack.push(el);
|
||||||
|
}
|
||||||
|
i = gt + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stack.length > 0) {
|
||||||
|
for (const el of stack) {
|
||||||
|
const pos = lineMap.positionAt(el.start);
|
||||||
|
errors.push({
|
||||||
|
message: `Element <${el.name}> is never closed`,
|
||||||
|
offset: el.start,
|
||||||
|
line: pos.line,
|
||||||
|
character: pos.character,
|
||||||
|
});
|
||||||
|
el.end = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { root, elements, errors, declarationEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTagEnd(text: string, from: number): number {
|
||||||
|
let i = from;
|
||||||
|
let quote: string | null = null;
|
||||||
|
while (i < text.length) {
|
||||||
|
const c = text[i];
|
||||||
|
if (quote) {
|
||||||
|
if (c === quote) quote = null;
|
||||||
|
} else if (c === '"' || c === "'") {
|
||||||
|
quote = c;
|
||||||
|
} else if (c === ">") {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildElement(raw: RawTag, depth: number): XmlElement {
|
||||||
|
return {
|
||||||
|
name: raw.name,
|
||||||
|
attrs: raw.attrs,
|
||||||
|
children: [],
|
||||||
|
parent: null,
|
||||||
|
start: raw.start,
|
||||||
|
startTagEnd: raw.end,
|
||||||
|
end: raw.selfClosing ? raw.end : -1,
|
||||||
|
selfClosing: raw.selfClosing,
|
||||||
|
closeTagStart: -1,
|
||||||
|
depth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the innermost element whose span contains `offset`. */
|
||||||
|
export function findElementAt(doc: XmlDocument, offset: number): XmlElement | null {
|
||||||
|
let best: XmlElement | null = null;
|
||||||
|
for (const el of doc.elements) {
|
||||||
|
if (el.end < 0) continue;
|
||||||
|
if (offset >= el.start && offset <= el.end) {
|
||||||
|
if (!best || el.depth > best.depth) {
|
||||||
|
best = el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds an element by name that contains the offset (including its start tag). */
|
||||||
|
export function findOpenTagElementAt(doc: XmlDocument, offset: number): XmlElement | null {
|
||||||
|
const el = findElementAt(doc, offset);
|
||||||
|
if (!el) return null;
|
||||||
|
// When the cursor is inside the start tag itself, `el` is already the
|
||||||
|
// innermost candidate. If the cursor is before the element's start, use
|
||||||
|
// the parent.
|
||||||
|
if (offset >= el.start && offset <= el.startTagEnd) return el;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"source": "OpenSAGE src/OpenSage.Game/Data/StreamFS/AssetType.cs",
|
||||||
|
"count": 79,
|
||||||
|
"types": {
|
||||||
|
"299416263": "AudioLod",
|
||||||
|
"315614191": "LocalBuildListMonitor",
|
||||||
|
"354222972": "GameLodPreset",
|
||||||
|
"376113229": "AudioFile",
|
||||||
|
"400896388": "CrowdResponse",
|
||||||
|
"439207783": "TheaterOfWarTemplate",
|
||||||
|
"504350110": "InGameUIPlayerPowerCommandSlots",
|
||||||
|
"530081230": "IntelDB",
|
||||||
|
"531798225": "StaticGameLod",
|
||||||
|
"534008255": "LargeGroupAudioMap",
|
||||||
|
"565855655": "ImageSequence",
|
||||||
|
"568797146": "Texture",
|
||||||
|
"607123156": "AIStrategicStateDefinition",
|
||||||
|
"608742960": "W3dAnimation",
|
||||||
|
"610186489": "InGameUIVoiceChatCommandSlots",
|
||||||
|
"680780553": "Environment",
|
||||||
|
"686292351": "FXParticleSystemTemplate",
|
||||||
|
"726253425": "Achievement",
|
||||||
|
"741706624": "MpGameRules",
|
||||||
|
"819131716": "RadiusCursorLibrary",
|
||||||
|
"866546168": "InGameUISettings",
|
||||||
|
"926814458": "AITargetingHeuristic",
|
||||||
|
"962203606": "InGameUILookAtCommandSlots",
|
||||||
|
"980180622": "ArmorTemplate",
|
||||||
|
"1345252658": "OnlineChatColors",
|
||||||
|
"1350608344": "MappableKey",
|
||||||
|
"1443425905": "AudioSettings",
|
||||||
|
"1447728787": "VideoEventList",
|
||||||
|
"1449288224": "PackedTextureImage",
|
||||||
|
"1482556238": "CampaignTemplate",
|
||||||
|
"1628705662": "InGameUIGroupSelectionCommandSlots",
|
||||||
|
"1641540160": "W3dHierarchy",
|
||||||
|
"1713477273": "PlayerPowerButtonTemplateStore",
|
||||||
|
"1874610847": "ExperienceLevelTemplate",
|
||||||
|
"1883691512": "MusicTrack",
|
||||||
|
"2007776008": "TargetingInTurretArcCompare",
|
||||||
|
"2070603733": "MiscAudio",
|
||||||
|
"2101756272": "LogicCommand",
|
||||||
|
"2178440954": "SpecialPowerTemplate",
|
||||||
|
"2219670431": "AudioEvent",
|
||||||
|
"2254974584": "FXList",
|
||||||
|
"2359666647": "TargetingDistanceCompare",
|
||||||
|
"2384988189": "MultiplayerColor",
|
||||||
|
"2421413379": "UnitOverlayIconSettings",
|
||||||
|
"2430161325": "Weather",
|
||||||
|
"2458866148": "InGameUIFixedElementHotKeySlotMap",
|
||||||
|
"2467477932": "OnDemandTexture",
|
||||||
|
"2486173485": "GameObject",
|
||||||
|
"2496977262": "WeaponTemplate",
|
||||||
|
"2525284163": "StanceTemplate",
|
||||||
|
"2525492603": "Mouse",
|
||||||
|
"2565744451": "InGameUISideBarCommandSlots",
|
||||||
|
"2745675575": "Multisound",
|
||||||
|
"2800139175": "HotKeySlot",
|
||||||
|
"2811124014": "InGameUIUnitAbilityCommandSlots",
|
||||||
|
"2812698028": "InGameUITacticalCommandSlots",
|
||||||
|
"2893598307": "AIBudgetStateDefinition",
|
||||||
|
"2901356964": "DynamicGameLod",
|
||||||
|
"2905958645": "DamageFX",
|
||||||
|
"3188107749": "TargetingCompareList",
|
||||||
|
"3266421346": "W3dMesh",
|
||||||
|
"3319822471": "AttributeModifier",
|
||||||
|
"3477794083": "MissionTemplate",
|
||||||
|
"3558134211": "DialogEvent",
|
||||||
|
"3587539190": "ArmyDefinition",
|
||||||
|
"3604279694": "AIPersonalityDefinition",
|
||||||
|
"3614134471": "UnitTypeIcon",
|
||||||
|
"3650896041": "PhaseEffect",
|
||||||
|
"3741098742": "DefaultHotKeys",
|
||||||
|
"3786401627": "UpgradeTemplate",
|
||||||
|
"3810008068": "W3dCollisionBox",
|
||||||
|
"3899542881": "ObjectCreationList",
|
||||||
|
"3928762264": "AmbientStream",
|
||||||
|
"3959844197": "LogicCommandSet",
|
||||||
|
"3971904488": "SkirmishOpeningMove",
|
||||||
|
"3972178387": "LocomotorTemplate",
|
||||||
|
"4042295058": "W3dContainer",
|
||||||
|
"4157475773": "ShadowMap",
|
||||||
|
"4262364347": "OnDemandTextureImage"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,214 @@
|
|||||||
|
import schemaModel from "./schema-model.json";
|
||||||
|
import assetTypes from "./asset-types.json";
|
||||||
|
|
||||||
|
export interface ChildInfo {
|
||||||
|
name: string;
|
||||||
|
type: string | null;
|
||||||
|
min: number;
|
||||||
|
max: number; // -1 = unbounded
|
||||||
|
doc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttributeInfo {
|
||||||
|
name: string;
|
||||||
|
required: boolean;
|
||||||
|
default: string | null;
|
||||||
|
doc: string;
|
||||||
|
kind: string;
|
||||||
|
type: string | null;
|
||||||
|
refType: string | null;
|
||||||
|
/** True for reference-typed attributes whose simple type has no refType. */
|
||||||
|
isRef: boolean;
|
||||||
|
enumValues: string[];
|
||||||
|
allowsDefine: boolean;
|
||||||
|
isBoolean: boolean;
|
||||||
|
base: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComplexTypeInfo {
|
||||||
|
kind: "complex";
|
||||||
|
children: ChildInfo[];
|
||||||
|
attributes: AttributeInfo[];
|
||||||
|
base: string | null;
|
||||||
|
doc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimpleTypeInfo {
|
||||||
|
kind: "simple";
|
||||||
|
base: string | null;
|
||||||
|
refType: string | null;
|
||||||
|
isRef: boolean;
|
||||||
|
enumValues: string[];
|
||||||
|
allowsDefine: boolean;
|
||||||
|
doc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TypeInfo = ComplexTypeInfo | SimpleTypeInfo;
|
||||||
|
|
||||||
|
interface RawModel {
|
||||||
|
version: number;
|
||||||
|
rootXsd: string;
|
||||||
|
topLevelElements: string[];
|
||||||
|
elements: Record<string, { type: string | null; doc: string }>;
|
||||||
|
types: Record<string, TypeInfo>;
|
||||||
|
subTypesOf: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = schemaModel as unknown as RawModel;
|
||||||
|
|
||||||
|
/** Lowercase type name -> canonical (XSD) type name. */
|
||||||
|
const typeNameIndex = new Map<string, string>();
|
||||||
|
for (const name of Object.keys(model.types)) {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (!typeNameIndex.has(lower)) typeNameIndex.set(lower, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a possibly-mis-cased type name to its canonical XSD spelling. */
|
||||||
|
export function canonicalTypeName(name: string | null): string | null {
|
||||||
|
if (!name) return null;
|
||||||
|
return typeNameIndex.get(name.toLowerCase()) ?? name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** element name -> type name, collected from every complex type's children. */
|
||||||
|
const elementToType = new Map<string, string>();
|
||||||
|
for (const type of Object.values(model.types)) {
|
||||||
|
if (type.kind !== "complex") continue;
|
||||||
|
for (const child of type.children) {
|
||||||
|
if (!elementToType.has(child.name)) {
|
||||||
|
elementToType.set(child.name, child.type ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [name, info] of Object.entries(model.elements)) {
|
||||||
|
elementToType.set(name, info.type ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const modelMeta = {
|
||||||
|
rootXsd: model.rootXsd,
|
||||||
|
topLevelElementCount: model.topLevelElements.length,
|
||||||
|
typeCount: Object.keys(model.types).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function topLevelElements(): string[] {
|
||||||
|
return model.topLevelElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTopLevelElement(name: string): boolean {
|
||||||
|
return model.topLevelElements.includes(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typeInfo(name: string): TypeInfo | undefined {
|
||||||
|
return model.types[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elementTypeName(name: string): string | null {
|
||||||
|
const t = elementToType.get(name);
|
||||||
|
return t ? t : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function childrenOfElement(name: string): ChildInfo[] {
|
||||||
|
const type = elementTypeName(name);
|
||||||
|
if (!type) return [];
|
||||||
|
return childrenOfType(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function childrenOfType(typeName: string | null): ChildInfo[] {
|
||||||
|
if (!typeName) return [];
|
||||||
|
const info = model.types[canonicalTypeName(typeName) ?? typeName];
|
||||||
|
return info && info.kind === "complex" ? info.children : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attributesOfElement(name: string): AttributeInfo[] {
|
||||||
|
const type = elementTypeName(name);
|
||||||
|
if (!type) return [];
|
||||||
|
return attributesOfType(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attributesOfType(typeName: string | null): AttributeInfo[] {
|
||||||
|
if (!typeName) return [];
|
||||||
|
const info = model.types[canonicalTypeName(typeName) ?? typeName];
|
||||||
|
return info && info.kind === "complex" ? info.attributes : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the type of a child element inside a KNOWN parent type, or null
|
||||||
|
* when the parent type is unknown or the child is not declared there.
|
||||||
|
*/
|
||||||
|
export function childTypeOf(
|
||||||
|
parentTypeName: string | null,
|
||||||
|
childName: string,
|
||||||
|
): string | null {
|
||||||
|
if (!parentTypeName) return null;
|
||||||
|
const info = model.types[canonicalTypeName(parentTypeName) ?? parentTypeName];
|
||||||
|
if (info?.kind !== "complex") return null;
|
||||||
|
return info.children.find((c) => c.name === childName)?.type ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context-aware element type resolution: prefers the child declaration inside
|
||||||
|
* the parent element's type, falling back to the global element map. Same
|
||||||
|
* element names used in different parents (e.g. <Weapon> under a weapon slot
|
||||||
|
* vs. a plain reference) therefore resolve to their contextually correct type.
|
||||||
|
*/
|
||||||
|
export function elementTypeIn(
|
||||||
|
parentElementName: string | null,
|
||||||
|
childName: string,
|
||||||
|
): string | null {
|
||||||
|
if (parentElementName) {
|
||||||
|
const parentType = elementTypeName(parentElementName);
|
||||||
|
const typed = childTypeOf(parentType, childName);
|
||||||
|
if (typed) return typed;
|
||||||
|
}
|
||||||
|
return elementTypeName(childName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typeDoc(name: string): string {
|
||||||
|
const info = model.types[name];
|
||||||
|
return info?.doc ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The type itself plus all ancestors (nearest first). */
|
||||||
|
export function typeChain(name: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let cur: string | null = canonicalTypeName(name);
|
||||||
|
while (cur && !seen.has(cur)) {
|
||||||
|
seen.add(cur);
|
||||||
|
out.push(cur);
|
||||||
|
const info: TypeInfo | undefined = model.types[cur];
|
||||||
|
cur = info && "base" in info && info.base ? canonicalTypeName(info.base) : null;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when an asset of type `actualType` satisfies a reference to `refType`.
|
||||||
|
* Falls back to exact name matching; unknown types only match exactly.
|
||||||
|
*/
|
||||||
|
export function isAssignableTo(actualType: string, refType: string | null): boolean {
|
||||||
|
if (!refType) return true;
|
||||||
|
const actual = canonicalTypeName(actualType) ?? actualType;
|
||||||
|
const ref = canonicalTypeName(refType) ?? refType;
|
||||||
|
if (actual === ref) return true;
|
||||||
|
return typeChain(actual).includes(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a manifest TypeId hash to a type name, when known. */
|
||||||
|
export function assetTypeNameFromHash(hash: number): string | undefined {
|
||||||
|
return (assetTypes as { types: Record<string, string> }).types[hash];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assetTypeHashCount(): number {
|
||||||
|
return (assetTypes as { count: number }).count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Elements that are structurally relevant everywhere. */
|
||||||
|
export const STRUCTURAL_ELEMENTS = [
|
||||||
|
"AssetDeclaration",
|
||||||
|
"Includes",
|
||||||
|
"Include",
|
||||||
|
"Tags",
|
||||||
|
"Tag",
|
||||||
|
"Defines",
|
||||||
|
"Define",
|
||||||
|
];
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import * as vscode from "vscode";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export interface ExtensionSettings {
|
||||||
|
sdkPath: string;
|
||||||
|
indexSageXml: boolean;
|
||||||
|
reportUnresolvedReferences: "warning" | "information" | "none";
|
||||||
|
diagnoseUnknownElements: boolean;
|
||||||
|
definitionMode: "all" | "project-only";
|
||||||
|
additionalDataSearchPaths: string[];
|
||||||
|
builtmodsDirs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSettings(): ExtensionSettings {
|
||||||
|
const cfg = vscode.workspace.getConfiguration("ra3modxml");
|
||||||
|
const sdkPath = cfg.get<string>("sdkPath", "C:\\Apps\\RA3-MODSDK-X");
|
||||||
|
return {
|
||||||
|
sdkPath,
|
||||||
|
indexSageXml: cfg.get<boolean>("indexSageXml", true),
|
||||||
|
reportUnresolvedReferences: cfg.get<string>(
|
||||||
|
"reportUnresolvedReferences",
|
||||||
|
"warning",
|
||||||
|
) as ExtensionSettings["reportUnresolvedReferences"],
|
||||||
|
diagnoseUnknownElements: cfg.get<boolean>("diagnoseUnknownElements", true),
|
||||||
|
definitionMode: cfg.get<string>(
|
||||||
|
"definitionMode",
|
||||||
|
"all",
|
||||||
|
) as ExtensionSettings["definitionMode"],
|
||||||
|
additionalDataSearchPaths: cfg.get<string[]>("additionalDataSearchPaths", []),
|
||||||
|
builtmodsDirs: [join(sdkPath, "builtmods"), join(sdkPath, "builtmods-quantum")],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import * as vscode from "vscode";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { CachedDirectoryWalker } from "./indexer/fileScanner";
|
||||||
|
import { ModIndexer } from "./indexer/indexer";
|
||||||
|
import type { ModIndex } from "./indexer/types";
|
||||||
|
import { readSettings, type ExtensionSettings } from "./settings";
|
||||||
|
|
||||||
|
const REBUILD_DEBOUNCE_MS = 1500;
|
||||||
|
|
||||||
|
export class ModWorkspace {
|
||||||
|
index: ModIndex | null = null;
|
||||||
|
indexer: ModIndexer | null = null;
|
||||||
|
projectRoot: string | null = null;
|
||||||
|
settings: ExtensionSettings;
|
||||||
|
|
||||||
|
private walker = new CachedDirectoryWalker();
|
||||||
|
private statusBar: vscode.StatusBarItem;
|
||||||
|
private rebuildTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private building = false;
|
||||||
|
private dirty = false;
|
||||||
|
|
||||||
|
constructor(context: vscode.ExtensionContext) {
|
||||||
|
this.settings = readSettings();
|
||||||
|
this.statusBar = vscode.window.createStatusBarItem(
|
||||||
|
vscode.StatusBarAlignment.Left,
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
this.statusBar.name = "RA3 Mod XML";
|
||||||
|
this.statusBar.command = "ra3modxml.openIndexReport";
|
||||||
|
context.subscriptions.push(this.statusBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
isRa3Workspace(): boolean {
|
||||||
|
return this.projectRoot != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
detectProjectRoot(): string | null {
|
||||||
|
const folders = vscode.workspace.workspaceFolders;
|
||||||
|
if (!folders?.length) return null;
|
||||||
|
for (const folder of folders) {
|
||||||
|
const found = findProjectRoot(folder.uri.fsPath);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
this.projectRoot = this.detectProjectRoot();
|
||||||
|
if (!this.projectRoot) {
|
||||||
|
this.statusBar.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.statusBar.text = "$(sync~spin) RA3 XML: indexing…";
|
||||||
|
this.statusBar.show();
|
||||||
|
await this.rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleRebuild(): void {
|
||||||
|
if (!this.projectRoot) return;
|
||||||
|
if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
|
||||||
|
this.rebuildTimer = setTimeout(() => {
|
||||||
|
void this.rebuild();
|
||||||
|
}, REBUILD_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rebuild(): Promise<void> {
|
||||||
|
if (!this.projectRoot) return;
|
||||||
|
if (this.building) {
|
||||||
|
this.dirty = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.building = true;
|
||||||
|
this.settings = readSettings();
|
||||||
|
try {
|
||||||
|
this.statusBar.text = "$(sync~spin) RA3 XML: indexing…";
|
||||||
|
const indexer = new ModIndexer({
|
||||||
|
projectDir: this.projectRoot,
|
||||||
|
sdkDir: this.settings.sdkPath,
|
||||||
|
builtmodsDirs: this.settings.builtmodsDirs,
|
||||||
|
indexSageXml: this.settings.indexSageXml,
|
||||||
|
additionalDataSearchPaths: this.settings.additionalDataSearchPaths,
|
||||||
|
walker: this.walker,
|
||||||
|
});
|
||||||
|
const started = Date.now();
|
||||||
|
this.index = await indexer.build();
|
||||||
|
this.indexer = indexer;
|
||||||
|
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
||||||
|
const s = this.index.stats;
|
||||||
|
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.assetCount} assets (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` +
|
||||||
|
`${s.defineCount} defines, ${s.streams} streams, ${s.sourceCandidates} include candidates`;
|
||||||
|
} catch (err) {
|
||||||
|
this.index = null;
|
||||||
|
this.statusBar.text = "$(error) RA3 XML: indexing failed";
|
||||||
|
this.statusBar.tooltip = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
this.building = false;
|
||||||
|
if (this.dirty) {
|
||||||
|
this.dirty = false;
|
||||||
|
void this.rebuild();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses the (possibly unsaved) in-memory text of the active document. */
|
||||||
|
async parseText(path: string, text: string) {
|
||||||
|
const { parseXml, LineMap } = await import("./language/xmlParser");
|
||||||
|
const parse = parseXml(text);
|
||||||
|
return {
|
||||||
|
file: { path, stat: null },
|
||||||
|
parse,
|
||||||
|
lineMap: new LineMap(text),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
|
||||||
|
this.statusBar.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findProjectRoot(startDir: string): string | null {
|
||||||
|
let dir = startDir;
|
||||||
|
// Guard against walking above reasonable roots.
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
try {
|
||||||
|
if (existsSync(join(dir, "Data", "Mod.xml"))) return dir;
|
||||||
|
if (existsSync(join(dir, "mod.babproj"))) return dir;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parent = dirname(dir);
|
||||||
|
if (parent === dir) break;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(n: number): string {
|
||||||
|
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
|
||||||
|
"name": "RA3 Mod XML",
|
||||||
|
"scopeName": "source.ra3modxml",
|
||||||
|
"injectionSelector": "L:source.xml",
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"name": "constant.other.ra3.define",
|
||||||
|
"match": "\\$[A-Za-z_][A-Za-z0-9_]*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "keyword.other.ra3.inherit",
|
||||||
|
"match": "\\binheritFrom\\b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "keyword.other.ra3.joinaction",
|
||||||
|
"match": "xai:joinAction"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "entity.name.tag.ra3.include",
|
||||||
|
"match": "</?(AssetDeclaration|Includes|Include|Tags|Tag|Defines|Define)\\b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment.line.ra3.todo",
|
||||||
|
"match": "(?i)(TODO|FIXME|HACK|XXX)(?=\\s|:|-|$)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||||
|
<GameObject id="BaseVehicle" Side="Allies" />
|
||||||
|
</AssetDeclaration>
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||||
|
<GameObject id="SageStaticMarker" />
|
||||||
|
</AssetDeclaration>
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||||
|
<GameObject id="VanillaTank" />
|
||||||
|
<WeaponTemplate id="VanillaCannon" />
|
||||||
|
</AssetDeclaration>
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<AssetDeclaration xmlns="uri:ea.com:eala:asset"></AssetDeclaration>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<AssetDeclaration xmlns="uri:ea.com:eala:asset"></AssetDeclaration>
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||||
|
<Includes>
|
||||||
|
<Include type="all" source="SageXml/Vanilla.xml" />
|
||||||
|
</Includes>
|
||||||
|
</AssetDeclaration>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||||
|
<GameObject id="BaseVehicle" Side="Allies" />
|
||||||
|
</AssetDeclaration>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||||
|
<HeadlightDraw2 id="FragLight">
|
||||||
|
<Model Name="frag.mdl" />
|
||||||
|
</HeadlightDraw2>
|
||||||
|
</AssetDeclaration>
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||||
|
<!-- Same id on two different asset types: navigation must filter by the
|
||||||
|
attribute's reference type. -->
|
||||||
|
<WeaponTemplate id="SharedThing" />
|
||||||
|
<GameObject id="SharedThing" />
|
||||||
|
|
||||||
|
<!-- Nested xi:include: content is inlined into the parent element. -->
|
||||||
|
<GameObject id="XiNestedGameObject">
|
||||||
|
<Draws>
|
||||||
|
<xi:include
|
||||||
|
href="DATA:Includes/Fragment.xml"
|
||||||
|
xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:HeadlightDraw2/child::*)" />
|
||||||
|
<xi:include href="DATA:Missing/Nested.xml" />
|
||||||
|
</Draws>
|
||||||
|
</GameObject>
|
||||||
|
</AssetDeclaration>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||||
|
<Texture id="TestTexture" File="test.dds" />
|
||||||
|
</AssetDeclaration>
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||||
|
<Defines>
|
||||||
|
<Define name="TEST_HEALTH" value="100.0" />
|
||||||
|
</Defines>
|
||||||
|
<GameObject
|
||||||
|
id="TestTank"
|
||||||
|
inheritFrom="BaseVehicle"
|
||||||
|
CommandSet="TestTankCommandSet"
|
||||||
|
KindOf="SELECTABLE">
|
||||||
|
</GameObject>
|
||||||
|
<GameObject
|
||||||
|
id="TestTank"
|
||||||
|
Side="Allies">
|
||||||
|
</GameObject>
|
||||||
|
<Includes>
|
||||||
|
<Include type="instance" source="BaseVehicle.xml" />
|
||||||
|
</Includes>
|
||||||
|
</AssetDeclaration>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||||
|
<WeaponTemplate id="TestTankCannon" AttackRange="100.0" />
|
||||||
|
<LogicCommandSet id="TestTankCommandSet" />
|
||||||
|
<Includes>
|
||||||
|
<Include type="all" source="Missing/File.xml" />
|
||||||
|
</Includes>
|
||||||
|
</AssetDeclaration>
|
||||||
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||||
|
<Tags />
|
||||||
|
<Includes>
|
||||||
|
<Include type="reference" source="DATA:static.xml" />
|
||||||
|
<Include type="reference" source="DATA:global.xml" />
|
||||||
|
<Include type="reference" source="DATA:audio.xml" />
|
||||||
|
<Include type="all" source="Includes/Units.xml" />
|
||||||
|
<Include type="all" source="Includes/Weapons.xml" />
|
||||||
|
<Include type="all" source="Includes/Shared.xml" />
|
||||||
|
<Include type="all" source="Includes/Refs.xml" />
|
||||||
|
</Includes>
|
||||||
|
</AssetDeclaration>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||||
|
<MpGameRules id="TheMpGameRules">
|
||||||
|
<SkirmishStartCash LoCash="10000" HiCash="50000" />
|
||||||
|
</MpGameRules>
|
||||||
|
</AssetDeclaration>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import {
|
||||||
|
buildSearchPaths,
|
||||||
|
resolveSource,
|
||||||
|
manifestPathForReference,
|
||||||
|
} from "../out/indexer/includeResolver.js";
|
||||||
|
|
||||||
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||||
|
const project = join(root, "test", "fixtures", "minimod");
|
||||||
|
const sdk = join(root, "test", "fixtures", "fakesdk");
|
||||||
|
const paths = buildSearchPaths(sdk, project);
|
||||||
|
|
||||||
|
test("resolves relative includes against the current file directory", () => {
|
||||||
|
const current = join(project, "Data", "Includes");
|
||||||
|
const r = resolveSource("BaseVehicle.xml", current, paths);
|
||||||
|
assert.equal(r.path, join(project, "Data", "Includes", "BaseVehicle.xml"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves DATA: includes through search paths", () => {
|
||||||
|
const r = resolveSource("DATA:static.xml", null, paths);
|
||||||
|
assert.equal(r.path, join(sdk, "static.xml"));
|
||||||
|
const r2 = resolveSource("DATA:SageXml/Vanilla.xml", null, paths);
|
||||||
|
assert.equal(r2.path, join(sdk, "SageXml", "Vanilla.xml"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("case-insensitive prefix", () => {
|
||||||
|
const r = resolveSource("data:static.xml", null, paths);
|
||||||
|
assert.equal(r.path, join(sdk, "static.xml"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing target returns null", () => {
|
||||||
|
const r = resolveSource("DATA:nope/nothere.xml", null, paths);
|
||||||
|
assert.equal(r.path, null);
|
||||||
|
assert.equal(r.prefix, "DATA");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search paths follow the BinaryAssetBuilder /data /art /audio order", () => {
|
||||||
|
// defaultscript.cs getIncludePaths():
|
||||||
|
// /data: ".;{modGranParent};{0}\Data;.\Mods;{1};.\SageXml"
|
||||||
|
// /art: ".;{modGranParent};{0}\Art1;{0}\Art;.\Mods;{1};.\Art"
|
||||||
|
// /audio: ".;{modGranParent};{0}\Audio1;{0}\Audio;.\Mods;{1};.\Audio"
|
||||||
|
// where "." = sdkDir, {0} = projectDir, {1} = modParentPath,
|
||||||
|
// {2} = modGranParent.
|
||||||
|
const modParent = dirname(project);
|
||||||
|
const granParent = dirname(modParent);
|
||||||
|
assert.deepEqual(paths.DATA, [
|
||||||
|
sdk,
|
||||||
|
granParent,
|
||||||
|
join(project, "Data"),
|
||||||
|
join(sdk, "Mods"),
|
||||||
|
modParent,
|
||||||
|
join(sdk, "SageXml"),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(paths.ART, [
|
||||||
|
sdk,
|
||||||
|
granParent,
|
||||||
|
join(project, "Art1"),
|
||||||
|
join(project, "Art"),
|
||||||
|
join(sdk, "Mods"),
|
||||||
|
modParent,
|
||||||
|
join(sdk, "Art"),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(paths.AUDIO, [
|
||||||
|
sdk,
|
||||||
|
granParent,
|
||||||
|
join(project, "Audio1"),
|
||||||
|
join(project, "Audio"),
|
||||||
|
join(sdk, "Mods"),
|
||||||
|
modParent,
|
||||||
|
join(sdk, "Audio"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DATA:Static.xml prefers the SDK root over SageXml", () => {
|
||||||
|
// Both the SDK root and SageXml contain a Static.xml; BAB order resolves to
|
||||||
|
// the SDK root placeholder first.
|
||||||
|
const r = resolveSource("DATA:Static.xml", null, paths);
|
||||||
|
assert.equal(r.path, join(sdk, "Static.xml"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("manifest mapping strips the prefix", () => {
|
||||||
|
const dirs = [join(sdk, "builtmods")];
|
||||||
|
assert.equal(manifestPathForReference("DATA:static.xml", dirs), join(dirs[0], "static.manifest"));
|
||||||
|
assert.equal(manifestPathForReference("DATA:audio.xml", dirs), join(dirs[0], "audio.manifest"));
|
||||||
|
assert.equal(manifestPathForReference("DATA:missing.xml", dirs), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { ModIndexer } from "../out/indexer/indexer.js";
|
||||||
|
import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js";
|
||||||
|
|
||||||
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||||
|
const project = join(root, "test", "fixtures", "minimod");
|
||||||
|
const sdk = join(root, "test", "fixtures", "fakesdk");
|
||||||
|
|
||||||
|
async function buildIndex() {
|
||||||
|
const indexer = new ModIndexer({
|
||||||
|
projectDir: project,
|
||||||
|
sdkDir: sdk,
|
||||||
|
builtmodsDirs: [join(sdk, "builtmods")],
|
||||||
|
indexSageXml: true,
|
||||||
|
additionalDataSearchPaths: [],
|
||||||
|
walker: new CachedDirectoryWalker(),
|
||||||
|
});
|
||||||
|
return indexer.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("indexes assets, defines, streams and include errors", async () => {
|
||||||
|
const idx = await buildIndex();
|
||||||
|
|
||||||
|
assert.equal(idx.stats.streams, 2); // static + global
|
||||||
|
assert.ok(idx.assetsById.has("testtank"));
|
||||||
|
assert.ok(idx.assetsById.has("testtankcannon"));
|
||||||
|
assert.ok(idx.assetsById.has("testtankcommandset"));
|
||||||
|
assert.ok(idx.assetsById.has("testtexture"));
|
||||||
|
assert.ok(idx.assetsById.has("thempgamerules"));
|
||||||
|
assert.ok(idx.assetsById.has("vanillatank"), "vanilla data via reference include fallback");
|
||||||
|
assert.ok(idx.assetsById.has("basevehicle"));
|
||||||
|
|
||||||
|
const baseVehicle = idx.assetsById.get("basevehicle");
|
||||||
|
assert.ok(baseVehicle.some((d) => d.viaInstance), "instance-included asset marked");
|
||||||
|
|
||||||
|
const defs = idx.defines.get("test_health");
|
||||||
|
assert.equal(defs?.[0].value, "100.0");
|
||||||
|
|
||||||
|
const missing = idx.diagnostics.find((d) => d.code === "include-not-found");
|
||||||
|
assert.ok(missing, "missing include reported");
|
||||||
|
assert.match(missing.message, /Missing\/File\.xml/);
|
||||||
|
|
||||||
|
// Nested xi:include: existing target is walked, missing target is reported.
|
||||||
|
assert.ok(
|
||||||
|
idx.diagnostics.some((d) => /Missing\/Nested\.xml/.test(d.message)),
|
||||||
|
"nested missing xi:include reported",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
idx.files.has(
|
||||||
|
join(project, "Data", "Includes", "Fragment.xml").toLowerCase(),
|
||||||
|
),
|
||||||
|
"nested xi:include target indexed",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
idx.assetsById.has("fraglight"),
|
||||||
|
"nested xi:include target assets available",
|
||||||
|
);
|
||||||
|
|
||||||
|
const staticStream = idx.streams.find((s) => s.name === "static");
|
||||||
|
assert.ok(staticStream.files.size >= 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provides include source candidates", async () => {
|
||||||
|
const idx = await buildIndex();
|
||||||
|
const xml = idx.sourceCandidates.filter((c) => c.source.endsWith(".xml"));
|
||||||
|
assert.ok(xml.length >= 4);
|
||||||
|
assert.ok(xml.some((c) => c.source === "Includes/Units.xml"));
|
||||||
|
assert.ok(xml.some((c) => c.source === "DATA:static.xml"));
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseManifest } from "../out/indexer/manifestParser.js";
|
||||||
|
|
||||||
|
function u32(v) {
|
||||||
|
const b = Buffer.alloc(4);
|
||||||
|
b.writeUInt32LE(v >>> 0);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
function u16(v) {
|
||||||
|
const b = Buffer.alloc(2);
|
||||||
|
b.writeUInt16LE(v >>> 0);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parses a minimal version 5 manifest", () => {
|
||||||
|
const name = Buffer.from("VanillaTank\0", "ascii");
|
||||||
|
const src = Buffer.from("DATA:globaldata/armor.xml\0", "ascii");
|
||||||
|
const parts = [
|
||||||
|
Buffer.from([0, 1]), // isBigEndian=false, isLinked=true (one byte each)
|
||||||
|
u16(5), // version
|
||||||
|
u32(0x1234), // streamChecksum
|
||||||
|
u32(0), // allTypesHash
|
||||||
|
u32(1), // assetCount
|
||||||
|
u32(0), // totalInstanceDataSize
|
||||||
|
u32(0), // maxInstanceChunkSize
|
||||||
|
u32(0), // maxRelocationChunkSize
|
||||||
|
u32(0), // maxImportsChunkSize
|
||||||
|
u32(0), // assetReferenceBufferSize
|
||||||
|
u32(0), // referencedManifestNameBufferSize
|
||||||
|
u32(name.length), // assetNameBufferSize
|
||||||
|
u32(src.length), // sourceFileNameBufferSize
|
||||||
|
// one asset entry (version 5 = 44 bytes, no tokenized flag)
|
||||||
|
u32(0x942fff2d), // typeId (GameObject)
|
||||||
|
u32(0), // instanceId
|
||||||
|
u32(0), // typeHash
|
||||||
|
u32(0), // instanceHash
|
||||||
|
u32(0), // assetReferenceOffset
|
||||||
|
u32(0), // assetReferenceCount
|
||||||
|
u32(0), // nameOffset
|
||||||
|
u32(0), // sourceFileNameOffset
|
||||||
|
u32(0), // instanceDataSize
|
||||||
|
u32(0), // relocationDataSize
|
||||||
|
u32(0), // importsDataSize
|
||||||
|
name,
|
||||||
|
src,
|
||||||
|
];
|
||||||
|
const info = parseManifest(new Uint8Array(Buffer.concat(parts)));
|
||||||
|
assert.equal(info.version, 5);
|
||||||
|
assert.equal(info.assetCount, 1);
|
||||||
|
assert.equal(info.assets.length, 1);
|
||||||
|
assert.equal(info.assets[0].name, "VanillaTank");
|
||||||
|
assert.equal(info.assets[0].typeName, "GameObject");
|
||||||
|
assert.equal(info.assets[0].sourceFileName, "DATA:globaldata/armor.xml");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports an error for garbage input", () => {
|
||||||
|
const info = parseManifest(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]));
|
||||||
|
assert.ok(info.error);
|
||||||
|
assert.equal(info.assets.length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { deriveAssetId, deriveAssetType } from "../out/indexer/manifestParser.js";
|
||||||
|
import { isAssignableTo } from "../out/model/schemaModel.js";
|
||||||
|
import {
|
||||||
|
isReferenceAttributeOfType,
|
||||||
|
resolveReferenceTargetsForType,
|
||||||
|
} from "../out/indexer/refs.js";
|
||||||
|
|
||||||
|
test("deriveAssetType falls back to the TypeName:Id prefix", () => {
|
||||||
|
assert.equal(deriveAssetType(null, "PlayerTemplate:Allies"), "PlayerTemplate");
|
||||||
|
assert.equal(deriveAssetType(null, "AudioFile:A06_Loop"), "AudioFile");
|
||||||
|
assert.equal(deriveAssetType("GameObject", "GameObject:X"), "GameObject");
|
||||||
|
assert.equal(deriveAssetType(null, "NoColonHere"), null);
|
||||||
|
assert.equal(deriveAssetType(null, ":LeadingColon"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deriveAssetId uses the last colon segment", () => {
|
||||||
|
// Art assets carry an extra subtype segment: Type:SubType:FileName.
|
||||||
|
assert.equal(
|
||||||
|
deriveAssetId("W3dContainer:W3DContainer:AUANTIVEHICLEVEHICLETECH1_SKN"),
|
||||||
|
"AUANTIVEHICLEVEHICLETECH1_SKN",
|
||||||
|
);
|
||||||
|
assert.equal(deriveAssetId("PlayerTemplate:Allies"), "Allies");
|
||||||
|
assert.equal(deriveAssetId("AudioFile:A06_Loop"), "A06_Loop");
|
||||||
|
assert.equal(deriveAssetId("Texture:Texture:AUAntiVehicleVehicleTech1"), "AUAntiVehicleVehicleTech1");
|
||||||
|
assert.equal(deriveAssetId("NoColon"), "NoColon");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("case-normalized type matching follows the XSD inheritance chain", () => {
|
||||||
|
// Manifest hashes use "W3d*" casing while the XSD types are "W3D*"; the
|
||||||
|
// canonical lookup must bridge the two and respect the real chain.
|
||||||
|
assert.ok(
|
||||||
|
isAssignableTo("W3dContainer", "BaseRenderAssetType"),
|
||||||
|
"W3dContainer is a render asset",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
isAssignableTo("W3DMesh", "BaseRenderAssetType"),
|
||||||
|
"W3DMesh is a render asset",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!isAssignableTo("W3dHierarchy", "BaseRenderAssetType"),
|
||||||
|
"W3dHierarchy is NOT a render asset (per XSD)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("manifest-derived PlayerTemplate ids satisfy Side references", () => {
|
||||||
|
// Simulate a manifest asset whose TypeId hash is unknown but whose name
|
||||||
|
// carries the type prefix (exactly what global.manifest does for
|
||||||
|
// PlayerTemplate:Allies).
|
||||||
|
const idx = {
|
||||||
|
assetsById: new Map([
|
||||||
|
[
|
||||||
|
"allies",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "PlayerTemplate",
|
||||||
|
id: "Allies",
|
||||||
|
file: "global.manifest",
|
||||||
|
line: 0,
|
||||||
|
origin: "manifest",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
assets: new Map([["PlayerTemplate", new Map([["allies", []]])]]),
|
||||||
|
projectDir: ".",
|
||||||
|
sdkDir: ".",
|
||||||
|
defines: new Map(),
|
||||||
|
files: new Map(),
|
||||||
|
streams: [],
|
||||||
|
manifests: new Map(),
|
||||||
|
sourceCandidates: [],
|
||||||
|
diagnostics: [],
|
||||||
|
stats: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.ok(isAssignableTo("PlayerTemplate", "PlayerTemplate"));
|
||||||
|
assert.equal(isReferenceAttributeOfType("GameObject", "Side"), true);
|
||||||
|
const targets = resolveReferenceTargetsForType(idx, "GameObject", "Side", "Allies");
|
||||||
|
assert.equal(targets.length, 1);
|
||||||
|
assert.equal(targets[0].def.type, "PlayerTemplate");
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { ModIndexer } from "../out/indexer/indexer.js";
|
||||||
|
import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js";
|
||||||
|
import { isReferenceAttribute, resolveReferenceTargets } from "../out/indexer/refs.js";
|
||||||
|
import * as model from "../out/model/schemaModel.js";
|
||||||
|
|
||||||
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||||
|
const project = join(root, "test", "fixtures", "minimod");
|
||||||
|
const sdk = join(root, "test", "fixtures", "fakesdk");
|
||||||
|
|
||||||
|
async function buildIndex() {
|
||||||
|
const indexer = new ModIndexer({
|
||||||
|
projectDir: project,
|
||||||
|
sdkDir: sdk,
|
||||||
|
builtmodsDirs: [join(sdk, "builtmods")],
|
||||||
|
indexSageXml: true,
|
||||||
|
additionalDataSearchPaths: [],
|
||||||
|
walker: new CachedDirectoryWalker(),
|
||||||
|
});
|
||||||
|
return indexer.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("definition resolution filters by the attribute's ref type", async () => {
|
||||||
|
const idx = await buildIndex();
|
||||||
|
const defs = idx.assetsById.get("sharedthing");
|
||||||
|
assert.equal(defs.length, 2);
|
||||||
|
const types = defs.map((d) => d.type).sort();
|
||||||
|
assert.deepEqual(types, ["GameObject", "WeaponTemplate"]);
|
||||||
|
|
||||||
|
// WeaponName="SharedThing" on a WeaponTemplateRef attribute must only resolve
|
||||||
|
// to the WeaponTemplate definition.
|
||||||
|
const targets = resolveReferenceTargets(idx, "FireWeaponNugget", "WeaponName", "SharedThing");
|
||||||
|
assert.equal(targets.length, 1);
|
||||||
|
assert.equal(targets[0].def.type, "WeaponTemplate");
|
||||||
|
assert.match(targets[0].def.file, /Refs\.xml/);
|
||||||
|
|
||||||
|
// A non-reference attribute must never resolve anything.
|
||||||
|
const none = resolveReferenceTargets(idx, "FireWeaponNugget", "EditorName", "SharedThing");
|
||||||
|
assert.equal(none.length, 0);
|
||||||
|
|
||||||
|
// inheritFrom filters to types assignable to the element itself.
|
||||||
|
const inherit = resolveReferenceTargets(idx, "GameObject", "inheritFrom", "SharedThing");
|
||||||
|
assert.equal(inherit.length, 1);
|
||||||
|
assert.equal(inherit[0].def.type, "GameObject");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the model knows a real WeaponTemplateRef attribute pair", () => {
|
||||||
|
const attrs = model.attributesOfElement("FireWeaponNugget");
|
||||||
|
const weapon = attrs.find((a) => a.name === "WeaponName");
|
||||||
|
assert.equal(weapon?.refType, "WeaponTemplate");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isReferenceAttribute distinguishes references from enums/paths", () => {
|
||||||
|
// Include/@type and Include/@source must never be treated as references.
|
||||||
|
assert.equal(isReferenceAttribute("Include", "type"), false);
|
||||||
|
assert.equal(isReferenceAttribute("Include", "source"), false);
|
||||||
|
// Typed references and inheritFrom are references.
|
||||||
|
assert.equal(isReferenceAttribute("GameObject", "CommandSet"), true);
|
||||||
|
assert.equal(isReferenceAttribute("GameObject", "inheritFrom"), true);
|
||||||
|
assert.equal(isReferenceAttribute("FireWeaponNugget", "WeaponName"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("untyped references (isRef without refType) resolve to any declared id", () => {
|
||||||
|
// LocomotorSet/@Locomotor has type AssetReference: xas:isRef="true" with
|
||||||
|
// no refType. It must be treated as a reference and match the id regardless
|
||||||
|
// of the target asset type.
|
||||||
|
const idx = {
|
||||||
|
assetsById: new Map([
|
||||||
|
[
|
||||||
|
"alliedantivehiclevehicletech1locomotor",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "LocomotorTemplate",
|
||||||
|
id: "AlliedAntiVehicleVehicleTech1Locomotor",
|
||||||
|
file: "Locomotor.xml",
|
||||||
|
line: 5,
|
||||||
|
origin: "project",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
assets: new Map(),
|
||||||
|
projectDir: ".",
|
||||||
|
sdkDir: ".",
|
||||||
|
defines: new Map(),
|
||||||
|
files: new Map(),
|
||||||
|
streams: [],
|
||||||
|
manifests: new Map(),
|
||||||
|
sourceCandidates: [],
|
||||||
|
diagnostics: [],
|
||||||
|
stats: {},
|
||||||
|
};
|
||||||
|
assert.equal(isReferenceAttribute("LocomotorSet", "Locomotor"), true);
|
||||||
|
const targets = resolveReferenceTargets(idx, "LocomotorSet", "Locomotor", "AlliedAntiVehicleVehicleTech1Locomotor");
|
||||||
|
assert.equal(targets.length, 1);
|
||||||
|
assert.equal(targets[0].def.type, "LocomotorTemplate");
|
||||||
|
// A non-reference attribute still returns nothing.
|
||||||
|
assert.equal(resolveReferenceTargets(idx, "LocomotorSet", "EditorName", "X").length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import * as model from "../out/model/schemaModel.js";
|
||||||
|
|
||||||
|
test("top-level elements include core asset types", () => {
|
||||||
|
for (const name of ["GameObject", "WeaponTemplate", "Texture", "LogicCommandSet", "SpecialPowerTemplate"]) {
|
||||||
|
assert.ok(model.isTopLevelElement(name), name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GameObject has expected attributes", () => {
|
||||||
|
const attrs = model.attributesOfElement("GameObject");
|
||||||
|
const id = attrs.find((a) => a.name === "id");
|
||||||
|
assert.ok(id?.required);
|
||||||
|
const cmd = attrs.find((a) => a.name === "CommandSet");
|
||||||
|
assert.equal(cmd?.refType, "LogicCommandSet");
|
||||||
|
assert.ok(attrs.some((a) => a.name === "inheritFrom"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Include has reference/instance/all enum", () => {
|
||||||
|
const attrs = model.attributesOfElement("Include");
|
||||||
|
const type = attrs.find((a) => a.name === "type");
|
||||||
|
assert.deepEqual(type?.enumValues, ["reference", "instance", "all"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("type assignability follows inheritance", () => {
|
||||||
|
assert.ok(model.isAssignableTo("GameObject", "BaseInheritableAsset"));
|
||||||
|
assert.ok(model.isAssignableTo("GameObject", "GameObject"));
|
||||||
|
assert.ok(!model.isAssignableTo("WeaponTemplate", "GameObject"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asset type hashes resolve", () => {
|
||||||
|
assert.equal(model.assetTypeNameFromHash(0x942fff2d), "GameObject");
|
||||||
|
assert.equal(model.assetTypeNameFromHash(0x166b084d), "AudioFile");
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseXml } from "../out/language/xmlParser.js";
|
||||||
|
import { resolveElementType } from "../out/language/typeContext.js";
|
||||||
|
import * as model from "../out/model/schemaModel.js";
|
||||||
|
|
||||||
|
test("context-aware type resolution follows the parent chain", () => {
|
||||||
|
const text = `<?xml version="1.0"?>
|
||||||
|
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||||
|
<GameObject id="Tank">
|
||||||
|
<BehaviorModules>
|
||||||
|
<WeaponSetUpdate>
|
||||||
|
<WeaponSlotTurret ID="1">
|
||||||
|
<Weapon Ordering="PRIMARY_WEAPON" />
|
||||||
|
</WeaponSlotTurret>
|
||||||
|
</WeaponSetUpdate>
|
||||||
|
</BehaviorModules>
|
||||||
|
</GameObject>
|
||||||
|
</AssetDeclaration>`;
|
||||||
|
const doc = parseXml(text);
|
||||||
|
const weapon = doc.elements.find((e) => e.name === "Weapon");
|
||||||
|
assert.ok(weapon, "Weapon element parsed");
|
||||||
|
|
||||||
|
const weaponType = resolveElementType(weapon);
|
||||||
|
assert.equal(weaponType, "WeaponSlot_WeaponData");
|
||||||
|
|
||||||
|
const attrs = model.attributesOfType(weaponType);
|
||||||
|
assert.ok(attrs.some((a) => a.name === "Ordering"), "Ordering attribute present");
|
||||||
|
|
||||||
|
// The same element name in a non-slot context resolves differently
|
||||||
|
// (e.g. a plain WeaponRef) and must not leak the slot attributes.
|
||||||
|
const slot = doc.elements.find((e) => e.name === "WeaponSlotTurret");
|
||||||
|
assert.equal(resolveElementType(slot), "WeaponSlot_Turret");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("model childTypeOf primitives", () => {
|
||||||
|
assert.equal(model.childTypeOf("WeaponSetUpdateModuleData", "WeaponSlotTurret"), "WeaponSlot_Turret");
|
||||||
|
assert.equal(model.childTypeOf("WeaponSlot_Turret", "Weapon"), "WeaponSlot_WeaponData");
|
||||||
|
assert.equal(model.childTypeOf("WeaponSlot_WeaponData", "Weapon"), null);
|
||||||
|
assert.equal(model.childTypeOf(null, "Weapon"), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseXml, findElementAt } from "../out/language/xmlParser.js";
|
||||||
|
|
||||||
|
test("parses elements, attributes and positions", () => {
|
||||||
|
const text = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n\t<GameObject id="X" KindOf="A B"/>\n</AssetDeclaration>`;
|
||||||
|
const doc = parseXml(text);
|
||||||
|
assert.equal(doc.errors.length, 0);
|
||||||
|
assert.equal(doc.root.name, "AssetDeclaration");
|
||||||
|
const go = doc.root.children[0];
|
||||||
|
assert.equal(go.name, "GameObject");
|
||||||
|
assert.equal(go.selfClosing, true);
|
||||||
|
assert.equal(go.attrs.length, 2);
|
||||||
|
assert.equal(go.attrs[0].name, "id");
|
||||||
|
assert.equal(go.attrs[0].value, "X");
|
||||||
|
assert.equal(go.attrs[1].value, "A B");
|
||||||
|
const idAttr = go.attrs[0];
|
||||||
|
assert.equal(text.slice(idAttr.valueStart, idAttr.valueEnd), "X");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects mismatched and unclosed tags", () => {
|
||||||
|
const doc = parseXml("<A><B></A>");
|
||||||
|
assert.ok(doc.errors.length >= 1);
|
||||||
|
assert.match(doc.errors[0].message, /Mismatched|never closed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles CRLF and comments", () => {
|
||||||
|
const text = "<?xml version=\"1.0\"?>\r\n<!-- hello -->\r\n<AssetDeclaration>\r\n</AssetDeclaration>\r\n";
|
||||||
|
const doc = parseXml(text);
|
||||||
|
assert.equal(doc.errors.length, 0);
|
||||||
|
assert.equal(doc.root.name, "AssetDeclaration");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findElementAt returns innermost element", () => {
|
||||||
|
const text = `<A><B id="1"><C/></B></A>`;
|
||||||
|
const doc = parseXml(text);
|
||||||
|
const c = doc.elements.find((e) => e.name === "C");
|
||||||
|
const at = findElementAt(doc, c.start + 1);
|
||||||
|
assert.equal(at.name, "C");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tolerates partial input while typing", () => {
|
||||||
|
const text = `<AssetDeclaration>\n\t<GameObject id="TestTank" Com`;
|
||||||
|
const doc = parseXml(text);
|
||||||
|
assert.ok(doc.errors.length >= 1); // unclosed
|
||||||
|
assert.equal(doc.elements.length, 2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Extracts the SAGE AssetType hash -> type name mapping from OpenSAGE's
|
||||||
|
// AssetType.cs into src/model/asset-types.json.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node tools/extract-asset-types.mjs [path-to-AssetType.cs]
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const defaultSource = resolve(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"OpenSAGE",
|
||||||
|
"src",
|
||||||
|
"OpenSage.Game",
|
||||||
|
"Data",
|
||||||
|
"StreamFS",
|
||||||
|
"AssetType.cs",
|
||||||
|
);
|
||||||
|
const sourcePath = process.argv[2] ?? defaultSource;
|
||||||
|
|
||||||
|
const text = readFileSync(sourcePath, "utf8");
|
||||||
|
|
||||||
|
// Match lines like:
|
||||||
|
// GameObject = 0x942FFF2D,
|
||||||
|
// W3dAnimation = 0x2448AE30,
|
||||||
|
const typeRegex = /^\s*(\w+)\s*=\s*0x([0-9A-Fa-f]{8})\s*,/gm;
|
||||||
|
const types = new Map();
|
||||||
|
let match;
|
||||||
|
while ((match = typeRegex.exec(text)) !== null) {
|
||||||
|
const name = match[1];
|
||||||
|
const hash = match[2].toUpperCase();
|
||||||
|
const numeric = parseInt(hash, 16);
|
||||||
|
if (types.has(numeric)) {
|
||||||
|
console.warn(`duplicate hash 0x${hash}: ${types.get(numeric)} vs ${name}`);
|
||||||
|
}
|
||||||
|
types.set(numeric, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (types.size === 0) {
|
||||||
|
console.error(`No AssetType entries found in ${sourcePath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
version: 1,
|
||||||
|
source: "OpenSAGE src/OpenSage.Game/Data/StreamFS/AssetType.cs",
|
||||||
|
count: types.size,
|
||||||
|
types: Object.fromEntries(
|
||||||
|
[...types.entries()].map(([hash, name]) => [hash, name]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const outPath = resolve(__dirname, "..", "src", "model", "asset-types.json");
|
||||||
|
mkdirSync(dirname(outPath), { recursive: true });
|
||||||
|
writeFileSync(outPath, JSON.stringify(output, null, 1));
|
||||||
|
console.log(`Wrote ${types.size} asset types to ${outPath}`);
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
// Generates a compact JSON schema model from the RA3 Mod SDK XSD files.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node tools/xsd-to-model.mjs [path-to-CnC3Types.xsd] [output.json]
|
||||||
|
//
|
||||||
|
// The model is used at runtime by the extension for completions, hover
|
||||||
|
// documentation and diagnostics. It is regenerated by developers, not by
|
||||||
|
// end users.
|
||||||
|
|
||||||
|
import { XMLParser } from "fast-xml-parser";
|
||||||
|
import {
|
||||||
|
readFileSync,
|
||||||
|
writeFileSync,
|
||||||
|
mkdirSync,
|
||||||
|
existsSync,
|
||||||
|
statSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { dirname, resolve, basename, relative } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DEFAULT_ROOT =
|
||||||
|
process.env.RA3_SDK_XSD ??
|
||||||
|
"C:\\Apps\\RA3-MODSDK-X\\Schemas\\xsd\\CnC3Types.xsd";
|
||||||
|
const rootXsdPath = resolve(process.argv[2] ?? DEFAULT_ROOT);
|
||||||
|
const outPath = resolve(
|
||||||
|
process.argv[3] ?? resolve(__dirname, "..", "src", "model", "schema-model.json"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existsSync(rootXsdPath)) {
|
||||||
|
console.error(`Root XSD not found: ${rootXsdPath}`);
|
||||||
|
console.error("Pass a path or set RA3_SDK_XSD.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── XML parsing ──────────────────────────────────────────────────────
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
attributeNamePrefix: "@_",
|
||||||
|
removeNSPrefix: true,
|
||||||
|
parseTagValue: false,
|
||||||
|
parseAttributeValue: false,
|
||||||
|
trimValues: true,
|
||||||
|
ignoreDeclaration: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const asArray = (x) => (Array.isArray(x) ? x : x == null ? [] : [x]);
|
||||||
|
|
||||||
|
// ── Registry ─────────────────────────────────────────────────────────
|
||||||
|
const loadedFiles = new Map(); // absolute path -> parsed document
|
||||||
|
const complexTypes = new Map(); // name -> raw node
|
||||||
|
const simpleTypes = new Map(); // name -> raw node
|
||||||
|
const globalElements = new Map(); // name -> {type, doc}
|
||||||
|
|
||||||
|
function loadXsd(absPath, seen = new Set()) {
|
||||||
|
const key = absPath.toLowerCase();
|
||||||
|
if (loadedFiles.has(key)) return loadedFiles.get(key);
|
||||||
|
if (seen.has(key)) return null; // include cycle
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
|
let text;
|
||||||
|
try {
|
||||||
|
text = readFileSync(absPath, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(` ! cannot read ${absPath}: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const doc = parser.parse(text);
|
||||||
|
loadedFiles.set(key, doc);
|
||||||
|
|
||||||
|
const schema = doc?.schema ?? doc?.["xs:schema"] ?? {};
|
||||||
|
for (const inc of asArray(schema.include)) {
|
||||||
|
const loc = inc?.["@_schemaLocation"];
|
||||||
|
if (!loc) continue;
|
||||||
|
loadXsd(resolve(dirname(absPath), loc), seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ct of asArray(schema.complexType)) {
|
||||||
|
const name = ct?.["@_name"];
|
||||||
|
if (name) complexTypes.set(name, ct);
|
||||||
|
}
|
||||||
|
for (const st of asArray(schema.simpleType)) {
|
||||||
|
const name = st?.["@_name"];
|
||||||
|
if (name) simpleTypes.set(name, st);
|
||||||
|
}
|
||||||
|
for (const el of asArray(schema.element)) {
|
||||||
|
const name = el?.["@_name"];
|
||||||
|
if (!name) continue;
|
||||||
|
if (el.complexType) {
|
||||||
|
// Inline complexType for a global element (e.g. AssetDeclaration).
|
||||||
|
const keyName = `@global:${name}`;
|
||||||
|
complexTypes.set(keyName, el.complexType);
|
||||||
|
globalElements.set(name, { type: keyName, doc: docOf(el) });
|
||||||
|
} else if (el["@_type"]) {
|
||||||
|
globalElements.set(name, { type: el["@_type"], doc: docOf(el) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function docOf(node) {
|
||||||
|
const doc = node?.annotation?.documentation;
|
||||||
|
if (doc == null) return "";
|
||||||
|
return String(doc).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attribute / simple type helpers ──────────────────────────────────
|
||||||
|
|
||||||
|
function normalizeTypeName(raw) {
|
||||||
|
if (!raw) return null;
|
||||||
|
return String(raw).replace(/^xs:/, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILTIN = new Set([
|
||||||
|
"string",
|
||||||
|
"boolean",
|
||||||
|
"integer",
|
||||||
|
"int",
|
||||||
|
"long",
|
||||||
|
"short",
|
||||||
|
"byte",
|
||||||
|
"unsignedInt",
|
||||||
|
"unsignedShort",
|
||||||
|
"unsignedByte",
|
||||||
|
"decimal",
|
||||||
|
"float",
|
||||||
|
"double",
|
||||||
|
"anyURI",
|
||||||
|
"date",
|
||||||
|
"dateTime",
|
||||||
|
"duration",
|
||||||
|
"token",
|
||||||
|
"NMTOKEN",
|
||||||
|
"Name",
|
||||||
|
"ID",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function patternAllowsDefine(pattern) {
|
||||||
|
return /\(=\[_a-zA-Z]/.test(pattern) || /=\$/.test(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enumOfSimpleType(node) {
|
||||||
|
const restriction = node?.restriction;
|
||||||
|
if (!restriction) return [];
|
||||||
|
const enums = asArray(restriction.enumeration).map((e) => e?.["@_value"]);
|
||||||
|
return enums.filter((v) => v != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patternOfSimpleType(node) {
|
||||||
|
const restriction = node?.restriction;
|
||||||
|
if (!restriction) return null;
|
||||||
|
const pats = asArray(restriction.pattern).map((p) => p?.["@_value"]);
|
||||||
|
return pats.length ? pats : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an attribute type reference to a normalized descriptor.
|
||||||
|
* Returns { kind: "builtin"|"simple"|"complex", name, refType, enumValues,
|
||||||
|
* allowsDefine, isBoolean, base, doc }
|
||||||
|
*/
|
||||||
|
function resolveTypeDescriptor(typeName, memo = new Map()) {
|
||||||
|
const name = normalizeTypeName(typeName);
|
||||||
|
if (name == null) return null;
|
||||||
|
if (memo.has(name)) return memo.get(name);
|
||||||
|
if (BUILTIN.has(name)) {
|
||||||
|
const desc = {
|
||||||
|
kind: "builtin",
|
||||||
|
name,
|
||||||
|
refType: null,
|
||||||
|
enumValues: [],
|
||||||
|
allowsDefine: false,
|
||||||
|
isBoolean: name === "boolean",
|
||||||
|
base: null,
|
||||||
|
doc: "",
|
||||||
|
};
|
||||||
|
memo.set(name, desc);
|
||||||
|
return desc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const st = simpleTypes.get(name);
|
||||||
|
if (st) {
|
||||||
|
memo.set(name, null); // guard against cycles
|
||||||
|
const base = normalizeTypeName(st?.restriction?.["@_base"]);
|
||||||
|
const baseDesc = base ? resolveTypeDescriptor(base, memo) : null;
|
||||||
|
const enumValues = enumOfSimpleType(st);
|
||||||
|
const patterns = patternOfSimpleType(st);
|
||||||
|
const refType =
|
||||||
|
st?.["@_refType"] ??
|
||||||
|
(baseDesc?.refType ?? null);
|
||||||
|
const desc = {
|
||||||
|
kind: "simple",
|
||||||
|
name,
|
||||||
|
refType: normalizeTypeName(refType) || baseDesc?.refType || null,
|
||||||
|
enumValues: enumValues.length ? enumValues : baseDesc?.enumValues ?? [],
|
||||||
|
allowsDefine:
|
||||||
|
(patterns ? patterns.some(patternAllowsDefine) : false) ||
|
||||||
|
baseDesc?.allowsDefine ||
|
||||||
|
false,
|
||||||
|
isRef:
|
||||||
|
st?.["@_isRef"] === "true" ||
|
||||||
|
st?.["@_isWeakRef"] === "true" ||
|
||||||
|
baseDesc?.isRef ||
|
||||||
|
false,
|
||||||
|
isBoolean: name === "SageBool" || baseDesc?.isBoolean || false,
|
||||||
|
base,
|
||||||
|
doc: docOf(st),
|
||||||
|
};
|
||||||
|
memo.set(name, desc);
|
||||||
|
return desc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (complexTypes.has(name) || name.startsWith("@")) {
|
||||||
|
const desc = {
|
||||||
|
kind: "complex",
|
||||||
|
name,
|
||||||
|
refType: null,
|
||||||
|
enumValues: [],
|
||||||
|
allowsDefine: false,
|
||||||
|
isRef: false,
|
||||||
|
isBoolean: false,
|
||||||
|
base: null,
|
||||||
|
doc: "",
|
||||||
|
};
|
||||||
|
memo.set(name, desc);
|
||||||
|
return desc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desc = {
|
||||||
|
kind: "unknown",
|
||||||
|
name,
|
||||||
|
refType: null,
|
||||||
|
enumValues: [],
|
||||||
|
allowsDefine: false,
|
||||||
|
isRef: false,
|
||||||
|
isBoolean: false,
|
||||||
|
base: null,
|
||||||
|
doc: "",
|
||||||
|
};
|
||||||
|
memo.set(name, desc);
|
||||||
|
return desc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Complex type expansion ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function collectChildren(node, out = []) {
|
||||||
|
if (!node || typeof node !== "object") return out;
|
||||||
|
for (const el of asArray(node.element)) {
|
||||||
|
const name = el?.["@_name"];
|
||||||
|
if (!name) continue;
|
||||||
|
const child = {
|
||||||
|
name,
|
||||||
|
type: normalizeTypeName(el["@_type"]),
|
||||||
|
min: parseInt(el["@_minOccurs"] ?? "1", 10),
|
||||||
|
max: el["@_maxOccurs"] === "unbounded" ? -1 : parseInt(el["@_maxOccurs"] ?? "1", 10),
|
||||||
|
doc: docOf(el),
|
||||||
|
};
|
||||||
|
if (el.complexType) {
|
||||||
|
const inlineKey = `@inline:${name}`;
|
||||||
|
if (!complexTypes.has(inlineKey)) complexTypes.set(inlineKey, el.complexType);
|
||||||
|
child.type = inlineKey;
|
||||||
|
}
|
||||||
|
out.push(child);
|
||||||
|
}
|
||||||
|
for (const key of ["sequence", "choice", "all"]) {
|
||||||
|
for (const container of asArray(node[key])) {
|
||||||
|
collectChildren(container, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAttributes(node, out = []) {
|
||||||
|
if (!node) return out;
|
||||||
|
for (const attr of asArray(node.attribute)) {
|
||||||
|
const name = attr?.["@_name"];
|
||||||
|
if (!name) continue;
|
||||||
|
const use = attr?.["@_use"];
|
||||||
|
let desc = null;
|
||||||
|
let inlineEnum = [];
|
||||||
|
if (attr["@_type"]) {
|
||||||
|
desc = resolveTypeDescriptor(attr["@_type"]);
|
||||||
|
} else if (attr.simpleType) {
|
||||||
|
inlineEnum = enumOfSimpleType(attr.simpleType);
|
||||||
|
const pats = patternOfSimpleType(attr.simpleType);
|
||||||
|
desc = {
|
||||||
|
kind: "simple",
|
||||||
|
name: `@attr:${name}`,
|
||||||
|
refType: attr.simpleType?.["@_refType"] ?? null,
|
||||||
|
enumValues: inlineEnum,
|
||||||
|
allowsDefine: pats ? pats.some(patternAllowsDefine) : false,
|
||||||
|
isBoolean: false,
|
||||||
|
base: normalizeTypeName(attr.simpleType?.restriction?.["@_base"]),
|
||||||
|
doc: docOf(attr.simpleType),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const entry = {
|
||||||
|
name,
|
||||||
|
required: use === "required",
|
||||||
|
default: attr["@_Default"] ?? attr["@_default"] ?? null,
|
||||||
|
doc: docOf(attr),
|
||||||
|
kind: desc?.kind ?? "unknown",
|
||||||
|
type: desc?.name ?? null,
|
||||||
|
refType: normalizeTypeName(desc?.refType) ?? null,
|
||||||
|
enumValues: desc?.enumValues ?? inlineEnum ?? [],
|
||||||
|
allowsDefine: desc?.allowsDefine ?? false,
|
||||||
|
isRef: desc?.isRef ?? false,
|
||||||
|
isBoolean: desc?.isBoolean ?? false,
|
||||||
|
base: normalizeTypeName(desc?.base) ?? null,
|
||||||
|
};
|
||||||
|
out.push(entry);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandedTypes = new Map(); // typeName -> {children, attributes, base, doc}
|
||||||
|
|
||||||
|
function expandComplexType(name, chain = []) {
|
||||||
|
if (expandedTypes.has(name)) return expandedTypes.get(name);
|
||||||
|
if (chain.includes(name)) {
|
||||||
|
// inheritance cycle (should not happen) - bail out
|
||||||
|
const empty = { children: [], attributes: [], base: null, doc: "" };
|
||||||
|
expandedTypes.set(name, empty);
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
const node = complexTypes.get(name);
|
||||||
|
if (!node) return null;
|
||||||
|
|
||||||
|
const extension = node?.complexContent?.extension;
|
||||||
|
const baseName = normalizeTypeName(extension?.["@_base"] ?? node?.simpleContent?.extension?.["@_base"]);
|
||||||
|
const base = baseName ? expandComplexType(baseName, [...chain, name]) : null;
|
||||||
|
|
||||||
|
const ownChildren = collectChildren(extension ?? node);
|
||||||
|
const ownAttributes = collectAttributes(extension ?? node);
|
||||||
|
|
||||||
|
const children = [...(base?.children ?? []), ...ownChildren];
|
||||||
|
const attributes = [...(base?.attributes ?? [])];
|
||||||
|
for (const attr of ownAttributes) {
|
||||||
|
const idx = attributes.findIndex((a) => a.name === attr.name);
|
||||||
|
if (idx >= 0) attributes[idx] = attr;
|
||||||
|
else attributes.push(attr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
children,
|
||||||
|
attributes,
|
||||||
|
base: baseName,
|
||||||
|
doc: docOf(node),
|
||||||
|
};
|
||||||
|
expandedTypes.set(name, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build model ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log(`Loading XSD tree from ${rootXsdPath} ...`);
|
||||||
|
const start = Date.now();
|
||||||
|
loadXsd(rootXsdPath);
|
||||||
|
console.log(
|
||||||
|
`Loaded ${loadedFiles.size} XSD files in ${((Date.now() - start) / 1000).toFixed(1)}s` +
|
||||||
|
` (${complexTypes.size} complexTypes, ${simpleTypes.size} simpleTypes, ${globalElements.size} global elements)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Top-level elements come from the AssetDeclaration inline choice plus any
|
||||||
|
// global elements registered directly.
|
||||||
|
const assetDecl = globalElements.get("AssetDeclaration");
|
||||||
|
const topLevelElements = new Set();
|
||||||
|
if (assetDecl) {
|
||||||
|
const declType = expandComplexType(assetDecl.type);
|
||||||
|
for (const child of declType?.children ?? []) {
|
||||||
|
topLevelElements.add(child.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize resolved complex types.
|
||||||
|
const typesOut = {};
|
||||||
|
for (const name of complexTypes.keys()) {
|
||||||
|
const resolved = expandComplexType(name);
|
||||||
|
if (resolved) {
|
||||||
|
typesOut[name] = {
|
||||||
|
kind: "complex",
|
||||||
|
...resolved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [name, node] of simpleTypes) {
|
||||||
|
const desc = resolveTypeDescriptor(name);
|
||||||
|
typesOut[name] = {
|
||||||
|
kind: "simple",
|
||||||
|
base: desc?.base ?? normalizeTypeName(node?.restriction?.["@_base"]) ?? null,
|
||||||
|
refType: desc?.refType ?? node?.["@_refType"] ?? null,
|
||||||
|
isRef: node?.["@_isRef"] === "true" || desc?.refType != null,
|
||||||
|
enumValues: desc?.enumValues ?? [],
|
||||||
|
allowsDefine: desc?.allowsDefine ?? false,
|
||||||
|
doc: docOf(node),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inheritance map: subtype -> [base names], used to match refType against
|
||||||
|
// concrete asset types (a ref to BaseAudioEventInfo should also match
|
||||||
|
// AudioEvent).
|
||||||
|
const subTypesOf = {};
|
||||||
|
for (const [name, node] of complexTypes) {
|
||||||
|
const base = normalizeTypeName(node?.complexContent?.extension?.["@_base"]);
|
||||||
|
if (base) {
|
||||||
|
(subTypesOf[name] ??= []).push(base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = {
|
||||||
|
version: 2,
|
||||||
|
rootXsd: basename(rootXsdPath),
|
||||||
|
targetNamespace: "uri:ea.com:eala:asset",
|
||||||
|
topLevelElements: [...topLevelElements].sort(),
|
||||||
|
elements: Object.fromEntries(
|
||||||
|
[...globalElements.entries()].map(([name, info]) => [
|
||||||
|
name,
|
||||||
|
{ type: normalizeTypeName(info.type), doc: info.doc },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
types: typesOut,
|
||||||
|
subTypesOf,
|
||||||
|
};
|
||||||
|
|
||||||
|
mkdirSync(dirname(outPath), { recursive: true });
|
||||||
|
writeFileSync(outPath, JSON.stringify(model));
|
||||||
|
const sizeKb = Math.round(statSync(outPath).size / 1024);
|
||||||
|
console.log(
|
||||||
|
`Wrote ${outPath} (${sizeKb} KB, ${topLevelElements.size} top-level elements, ` +
|
||||||
|
`${Object.keys(typesOut).length} types)`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"ES2022"
|
||||||
|
],
|
||||||
|
"outDir": "out",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user