diff --git a/CHANGELOG.md b/CHANGELOG.md index a591944..e67db0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ ### Fixed - Manifest assets that share the same id under different types are now all kept in the by-id index. Previously the first same-id entry (e.g. `W3DHierarchy:AUMCV_HOVER`) could shadow later definitions (e.g. `W3DContainer:AUMCV_HOVER`), causing `Model@Name` and other `BaseRenderAssetType` references to be reported as unresolved even though the asset existed in `Static.manifest`. +- `xi:include` without an `xpointer` now splices the target document's root element itself (XInclude semantics), so fragments like `GenericCelestialBuildingSuicide.xml` keep their module wrapper (`CreateObjectDie`) instead of only inserting its children. +- Fragment files (documents whose root is not `AssetDeclaration`) no longer trigger standalone-document diagnostics: top-level `missing-id`, duplicate-id, unresolved-reference and undefined-define checks are skipped, unknown wrapper roots are not reported as unknown elements, and a known fragment root still validates its subtree's elements/attributes. + +### Added + +- Missing `xi:include` targets now surface in the Problems panel as `include-not-found` warnings for the edited document (previously only tracked in indexer diagnostics). ## 0.1.21 — 2026-08-11 diff --git a/docs/analysis-issues.md b/docs/analysis-issues.md index f5c3cbb..99df062 100644 --- a/docs/analysis-issues.md +++ b/docs/analysis-issues.md @@ -1906,3 +1906,55 @@ W3DContainer:AUMCV_HOVER `docs/plan.md` 的 manifest 建模小节补充:`assetsById` 必须保留同 id 的不同 类型定义,去重身份为 `(type, file, line)`。 + +--- + +## 二十八、问题分析(2026-08-11):xi:include 无 xpointer 语义与片段文件诊断(P0) + +### 现象 + +`Data/Includes/GenericCelestialBuildingSuicide.xml` 这类被 `xi:include` 引用的 +片段文件在独立打开时被报一串错误:`DieMuxData` 报 `missing-id`(“顶层资产需要 +id”),wrapper 根不在 XSD 里的文件报 `unknown-element`,引用在完整索引下能解析 +前还会报未解析引用。 + +### 根因 + +1. **无 `xpointer` 的展开语义错误**:`expandDocument` 把目标 `root.children` + 拼进父节点。按 XInclude 语义(也是 Corona 的实际用法),没有 `xpointer` 时应 + 整体包含目标文档的根元素。`GenericCelestialBuildingSuicide.xml` 的根 + `CreateObjectDie` 本身就是要放进 GameObject 的模块;旧实现会丢掉它,只把 + `DieMuxData` 拼进去。 +2. **诊断层把片段当完整文档**:`isTopLevel` 假定根一定是 `AssetDeclaration`, + 于是片段根的子元素被当成顶层资产要求 id;未知 wrapper 根也被当成未知元素。 +3. **引用/define 与上下文耦合**:片段里的引用可能由 include 者(或 include 者 + 的 include 链)提供,独立打开片段时无法可靠判定。 + +### 修复(P0,不猜测外部上下文) + +1. `logicalTree.expandDocument`:无 `xpointer` 时 `handleChild(parse.root)`, + 整体包含目标根元素;有 `xpointer` 时保持 `/n:Name/child::*` 语义。 +2. `diagnostics` 片段模式:根 localName 不是 `AssetDeclaration` 即为片段。 + - 一律跳过顶层 `missing-id` / 跨文件重复 id、未解析引用、未定义 `$DEFINE`; + - 根是已知 XSD 元素时(如 `CreateObjectDie`),根自身提供类型上下文,整棵子树 + 的未知元素 / 未知属性仍正常校验; + - 根不在 XSD 中(wrapper/container,如 `CommonArmorDraws`)时,只报 XML 语法 + 与片段内部 `xi:include` / `` 目标缺失,其余检查延后到上下文诊断。 +3. 新增 `checkXiInclude`:`xi:include` 目标缺失在 Problems 中上报 + `include-not-found`(此前只在 indexer 内部诊断)。 + +### 测试(202 → 202 全绿) + +- `localScope.test.mjs`:无 `xpointer` 的 `xi:include` 把目标根元素 + `CreateObjectDie` 整体拼入 `Behaviors`,`DieMuxData` 仍挂在它下面; +- `contentFeatures.test.mjs`:片段已知根不再报 `missing-id` / 未解析引用,但子树 + 未知属性仍报;未知 wrapper 根不报元素/属性,片段内部缺失 `xi:include` 仍报; + 完整 `AssetDeclaration` 文档的顶层 id 检查不受影响。 + +### 边界与后续 + +- **P1 上下文诊断**:indexer 增加“反向 include 表”(`xi:include` 目标 → + include 者列表),打开片段时用 include 者的逻辑树做真实上下文校验,再恢复引用 / + define / 子元素结构检查。多上下文取并集去重。 +- `` 与 `xi:include` 语义不同:前者的目标 + 是完整 `AssetDeclaration`,不进入片段模式;后者才允许片段文件。 diff --git a/docs/plan.md b/docs/plan.md index e157e2f..5913eec 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -464,7 +464,7 @@ test/ 展开(第十二轮,见第六节);顶层 `` 与 `inheritFrom` + `xai:joinAction` 的深合并仍未实现,后续如需要“当前文档视角的全量合并诊断”再继续。 -## 六、include 展开设计备忘(2026-08-01;xi:include 部分已实施于第十二轮) +## 六、include 展开设计备忘(2026-08-01;xi:include 部分已实施于第十二轮,无 xpointer 语义与片段诊断见第二十八轮) > 目的:集中记录 include 处理相关的现状、结论与设计,下次遇到 include 问题时从这里继续, > 并在实施后把结果回写本节。 @@ -477,7 +477,8 @@ test/ | `reference` → builtmods manifest 解析 / 缺失回退占位 XML | 已实现 | | 嵌套 `xi:include`(任意层级):目标可索引、缺失报 `include-not-found`、Ctrl+点击跳转、`href` hover 解析目标 | 已实现(第二轮 + 第五轮) | | `xi:include` 及其属性不参与 XSD 校验(外来命名空间守卫 `isXsdElementName` / `isXsdAttributeName`) | 已实现(第五轮) | -| include 目标内容“虚拟合并”进父文档的逻辑树 | 已实现 `xi:include`(第十二轮);顶层 `` 仍不展开 | +| include 目标内容“虚拟合并”进父文档的逻辑树 | 已实现 `xi:include`(第十二轮);**无 `xpointer` 时整体包含目标根元素**(第二十八轮修正);顶层 `` 仍不展开 | +| 片段文件(根非 `AssetDeclaration`)的诊断 | 已实现 P0(第二十八轮):跳过顶层 id/重复/引用/define 检查;根为已知 XSD 元素时仍校验子树;根未知时只报语法与 include 缺失 | ### 2. 已确认的方向 @@ -488,9 +489,11 @@ BAB(`defaultscript.cs`)编译时正是这样把整个 Mod 合并成一份大 - **不要**把 include 目标展开成文本再整体重新解析:源码偏移会断裂,诊断 / 跳转 / hover / 补全全部无法映射回原始文件。 -- **要做**的是:解析器逐文件解析(现状不变);展开器把目标文件选中节点按 `xpointer` - 子集挂进父元素,节点保留各自的源文件与原始偏移(来源追溯)。后续分析跑在逻辑树上, - 范围映射按节点 `sourceFile` 回到对应文件的 lineMap。 +- **要做**的是:解析器逐文件解析(现状不变);展开器把目标文件选中节点挂进父元素—— + 有 `xpointer` 时取 `/n:Name/child::*` 选中 children,无 `xpointer` 时按 XInclude 语义 + 整体包含目标根元素(RA3 片段如 `CreateObjectDie` 依赖这一行为);节点保留各自的源文件 + 与原始偏移(来源追溯)。后续分析跑在逻辑树上,范围映射按节点 `sourceFile` 回到对应 + 文件的 lineMap。 - 现有 `parseXml` 已记录标签 / 属性 / 值的起止偏移,`XmlElement` 结构可直接复用;拼接时 用浅拷贝节点壳并重建 parent 链,避免破坏目标文件缓存树自身的 parent 指针。 @@ -498,7 +501,7 @@ BAB(`defaultscript.cs`)编译时正是这样把整个 Mod 合并成一份大 | 构造 | 拼入逻辑树 | 理由 | |---|---|---| -| `xi:include` | ✅ | 内容并入父元素(HeadlightDraw2 场景) | +| `xi:include` | ✅ | 有 `xpointer`:选中容器 children;无 `xpointer`:整体包含目标根元素(CreateObjectDie / TechUpgradeReceiver 等片段场景) | | EA `` | ✅ | BAB“内容合并”,等价于复制进来 | | `type="instance"` | ❌ | 只影响编译可见性;拼树会把 BaseVehicle 的顶层资产错误塞进当前文档 | | `type="reference"` | ❌ | manifest 编译产物,无文本内容 | diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index cf92d65..ab558a0 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -66,6 +66,7 @@ " (based on a partial index)": " (based on a partial index)", " (index incomplete — may be a false positive)": " (index incomplete — may be a false positive)", "Include target not found: {0}": "Include target not found: {0}", + "xi:include target not found: {0}": "xi:include target not found: {0}", "XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.": "XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.", "**Top-level asset element**": "**Top-level asset element**", "Attributes: {0} · Children: {1}": "Attributes: {0} · Children: {1}", diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index 3875780..9d02b42 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -66,6 +66,7 @@ " (based on a partial index)": "(基于部分索引)", " (index incomplete — may be a false positive)": "(索引不完整——可能是误报)", "Include target not found: {0}": "找不到 Include 目标:{0}", + "xi:include target not found: {0}": "找不到 xi:include 目标:{0}", "XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.": "XInclude 元素(W3C XInclude 命名空间)——不属于 RA3 XSD 模型。", "**Top-level asset element**": "**顶层资产元素**", "Attributes: {0} · Children: {1}": "属性:{0} · 子元素:{1}", diff --git a/package.json b/package.json index 29e5b56..749fb9d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "ra3-mod-xml", "displayName": "%ra3modxml.displayName%", "description": "%ra3modxml.description%", - "version": "0.1.21", + "version": "0.1.22", "publisher": "lanyi", "license": "SEE LICENSE IN LICENSE", "repository": { diff --git a/src/features/diagnostics.ts b/src/features/diagnostics.ts index 0ec22ea..94eeb06 100644 --- a/src/features/diagnostics.ts +++ b/src/features/diagnostics.ts @@ -139,6 +139,17 @@ export class Ra3Diagnostics { ): void { const settings = this.ws.settings; const fileDuplicates = new Map(); + // A file whose root is not AssetDeclaration is an xi:include fragment + // (e.g. Data/Includes/GenericCelestialBuildingSuicide.xml). It is not a + // standalone RA3 document: top-level id / duplicate checks do not apply, + // and references/defines can only be resolved in the includer's context. + // When the fragment root itself is a known XSD element (e.g. + // CreateObjectDie), the root supplies the type context for its whole + // subtree, so element/attribute validation is still reliable. + const rootName = root ? localName(root.name) : ""; + const isFragment = rootName !== "AssetDeclaration"; + const validateTree = + !isFragment || (root !== null && model.elementTypeName(rootName) !== null); for (const el of doc.elements) { // Only report diagnostics for nodes that belong to the document being @@ -155,7 +166,7 @@ export class Ra3Diagnostics { const range = tagRange(document, el); // Top-level assets must have an id. - if (isTopLevel) { + if (!isFragment && isTopLevel) { const idAttr = el.attrs.find((a) => a.name === "id"); if (!idAttr || !idAttr.value) { diags.push( @@ -206,7 +217,7 @@ export class Ra3Diagnostics { const isXsdElement = model.isXsdElementName(el.name); // Unknown element. - if (settings.diagnoseUnknownElements && isXsdElement) { + if (settings.diagnoseUnknownElements && isXsdElement && validateTree) { const knownType = model.elementTypeName(local); if (!knownType) { diags.push( @@ -221,7 +232,7 @@ export class Ra3Diagnostics { } // Attributes. - if (isXsdElement) { + if (isXsdElement && validateTree) { const elType = resolveElementType(el); const knownAttrs = model.attributesOfType(elType); const knownNames = new Set(knownAttrs.map((a) => a.name)); @@ -247,27 +258,70 @@ export class Ra3Diagnostics { } if (!attr.hasValue) continue; - this.checkValueReferences( - elType, - attr.name, - attr.value, - attr, - document, - idx, - diags, - provisional, - ); + // References and $DEFINE constants inside fragments depend on the + // includer's context; don't report them until P1 resolves the real + // include sites. + if (!isFragment) { + this.checkValueReferences( + elType, + attr.name, + attr.value, + attr, + document, + idx, + diags, + provisional, + ); + } + } + if (!isFragment) { + this.checkContentReferences(el, elType, document, idx, diags, provisional); } - this.checkContentReferences(el, elType, document, idx, diags, provisional); } // Include-specific checks. if (local === "Include") { this.checkInclude(el, document, idx, diags); + } else if (local === "include" && el.name.toLowerCase().startsWith("xi:")) { + this.checkXiInclude(el, document, idx, diags); } } } + private checkXiInclude( + el: XmlElement, + document: vscode.TextDocument, + idx: ModIndex | null, + diags: vscode.Diagnostic[], + ): void { + const hrefAttr = el.attrs.find((a) => a.name === "href"); + if (!hrefAttr?.hasValue) return; + const searchPaths = idx + ? buildSearchPaths(idx.sdkDir, idx.projectDir) + : this.ws.searchPaths(document); + if (!searchPaths) return; + const resolved = resolveSource( + hrefAttr.value, + dirname(document.uri.fsPath), + searchPaths, + ); + if (resolved.path) return; + if (/^(DATA|ART|AUDIO):/i.test(hrefAttr.value.trim())) { + if (this.sdkUnusable()) return; + } + diags.push( + this.diag( + new vscode.Range( + document.positionAt(hrefAttr.valueStart), + document.positionAt(hrefAttr.valueEnd), + ), + t("xi:include target not found: {0}", hrefAttr.value), + vscode.DiagnosticSeverity.Warning, + "include-not-found", + ), + ); + } + private checkCrossFileDuplicate( type: string, id: string, diff --git a/src/indexer/logicalTree.ts b/src/indexer/logicalTree.ts index bcc13df..090fc51 100644 --- a/src/indexer/logicalTree.ts +++ b/src/indexer/logicalTree.ts @@ -169,12 +169,26 @@ async function expandXi( if (!target?.parse?.root) return; const xpointer = xi.attrs.find((a) => a.name === "xpointer")?.value ?? ""; - const selected = xpointer - ? findXPointerContainer(target.parse, xpointer)?.children ?? [] - : target.parse.root.children; - - for (const sel of selected) { - await handleChild(sel, logicalParent, resolved, depth + 1, ctx, elements, stack); + if (xpointer) { + const container = findXPointerContainer(target.parse, xpointer); + if (!container) return; + for (const sel of container.children) { + await handleChild(sel, logicalParent, resolved, depth + 1, ctx, elements, stack); + } + } else { + // XInclude semantics: without an xpointer the whole target document is + // included, i.e. its root element replaces the node. + // RA3 fragments such as GenericCelestialBuildingSuicide.xml rely on this + // to splice the module element itself (CreateObjectDie) into the parent. + await handleChild( + target.parse.root, + logicalParent, + resolved, + depth + 1, + ctx, + elements, + stack, + ); } } finally { stack.delete(key); diff --git a/test/contentFeatures.test.mjs b/test/contentFeatures.test.mjs index 37c3637..ad5d884 100644 --- a/test/contentFeatures.test.mjs +++ b/test/contentFeatures.test.mjs @@ -452,3 +452,89 @@ test("diagnostics report unresolved typed content references only", async () => "untyped WeakReference content is not diagnosed as a global ref", ); }); + +test("fragment diagnostics skip document-level checks but keep subtree validation", async () => { + const text = + `\n` + + ` \n` + + ``; + const scope = await makeScope(text, makeIdx([])); + const collection = new FakeDiagnosticCollection(); + const provider = new Ra3Diagnostics({ + isRa3Workspace: () => true, + getScope: async () => scope, + settings: { + diagnoseUnknownElements: true, + reportUnresolvedReferences: "warning", + }, + }); + provider["collection"] = collection; + await provider.update(makeDocument(text)); + const codes = collection.last.diags.map((d) => d.code); + assert.ok( + !codes.includes("missing-id"), + "fragment children are not treated as top-level assets", + ); + assert.ok( + !codes.some((c) => c.startsWith("unresolved-reference")), + "fragment references are deferred to the includer context", + ); + assert.ok( + codes.includes("unknown-attribute"), + "a known fragment root still validates its subtree attributes", + ); +}); + +test("fragment diagnostics ignore unknown wrapper roots and still report missing xi:include", async () => { + const text = + `\n` + + ` \n` + + ` \n` + + ``; + const scope = await makeScope(text, makeIdx([])); + const collection = new FakeDiagnosticCollection(); + const provider = new Ra3Diagnostics({ + isRa3Workspace: () => true, + getScope: async () => scope, + settings: { + diagnoseUnknownElements: true, + reportUnresolvedReferences: "warning", + }, + }); + provider["collection"] = collection; + await provider.update(makeDocument(text)); + const codes = collection.last.diags.map((d) => d.code); + assert.ok( + !codes.includes("unknown-element"), + "wrapper roots are not validated as standalone documents", + ); + assert.ok( + !codes.includes("unknown-attribute"), + "unknown wrapper roots do not trigger subtree attribute guessing", + ); + assert.ok( + codes.includes("include-not-found"), + "missing xi:include targets inside fragments are still reported", + ); +}); + +test("full documents still require ids on top-level assets", async () => { + const text = `\n \n`; + const scope = await makeScope(text, makeIdx([])); + const collection = new FakeDiagnosticCollection(); + const provider = new Ra3Diagnostics({ + isRa3Workspace: () => true, + getScope: async () => scope, + settings: { + diagnoseUnknownElements: true, + reportUnresolvedReferences: "warning", + }, + }); + provider["collection"] = collection; + await provider.update(makeDocument(text)); + const codes = collection.last.diags.map((d) => d.code); + assert.ok( + codes.includes("missing-id"), + "AssetDeclaration documents keep top-level id checks", + ); +}); diff --git a/test/localScope.test.mjs b/test/localScope.test.mjs index 55b1ca3..2c254f5 100644 --- a/test/localScope.test.mjs +++ b/test/localScope.test.mjs @@ -99,6 +99,54 @@ test("logical xi:include expansion gives included modules their Draws context", assert.ok(localIds.includes("ModuleTag_Headlight")); }); +test("xi:include without xpointer splices the target root element itself", async (t) => { + const tmp = await mkdtemp(join(tmpdir(), "ra3-local-noxpointer-")); + t.after(() => rm(tmp, { recursive: true, force: true })); + const dataDir = join(tmp, "Data"); + const includesDir = join(dataDir, "Includes"); + await mkdir(includesDir, { recursive: true }); + const mainPath = join(dataDir, "Main.xml"); + const fragmentPath = join(includesDir, "Fragment.xml"); + await writeFile( + fragmentPath, + '', + "utf8", + ); + await writeFile( + mainPath, + '' + + '' + + "", + "utf8", + ); + const searchPaths = buildSearchPaths(sdk, tmp); + const text = await readFile(mainPath, "utf8"); + const scope = await buildDocumentScope(mainPath, text, 1, { + projectDir: tmp, + sdkDir: sdk, + searchPaths, + readRecords: readParsed, + readDom: readParsed, + }); + const behaviors = scope.expanded.elements.find((e) => e.name === "Behaviors"); + assert.ok(behaviors, "Behaviors exists"); + assert.equal(behaviors.children.length, 1); + const module = behaviors.children[0]; + assert.equal(module.name, "CreateObjectDie"); + assert.equal( + module.attrs.find((a) => a.name === "id")?.value, + "ModuleTag_X", + ); + assert.match(module.sourceFile, /Fragment\.xml$/i); + const dieMux = scope.expanded.elements.find((e) => e.name === "DieMuxData"); + assert.ok(dieMux, "DieMuxData is expanded"); + assert.equal( + dieMux.parent, + module, + "DieMuxData stays inside the included CreateObjectDie module", + ); +}); + test("local overlay wins over a global definition with the same id", async () => { const scope = await makeScope(); const global = {