import * as vscode from "vscode"; import { dirname } from "node:path"; import { LineMap, type XmlElement, type XmlParseError, } from "../language/xmlParser"; import { resolveElementType } from "../language/typeContext"; import { resolveSource, buildSearchPaths } from "../indexer/includeResolver"; import { validateSdkPath } from "../sdk"; import * as model from "../model/schemaModel"; import type { ModWorkspace } from "../workspace"; import type { ModIndex } from "../indexer/types"; import { isReferenceContentType, isReferenceAttributeOfType, mergeLocalAndGlobalDefs, resolveContentReferenceTargets, resolveReferenceTargetsForType, } from "../indexer/refs"; import type { LogicalElement } from "../indexer/logicalTree"; import { scopePathKey } from "../indexer/localScope"; import { t } from "../localize"; export class Ra3Diagnostics { private collection: vscode.DiagnosticCollection; private sdkCache: { path: string; unusable: boolean } | null = null; constructor(private ws: ModWorkspace) { this.collection = vscode.languages.createDiagnosticCollection("ra3modxml"); } /** True when the SDK is missing or not an SDK root (project-only mode). */ private sdkUnusable(): boolean { const path = this.ws.settings.sdkPath; if (this.sdkCache?.path === path) return this.sdkCache.unusable; const status = validateSdkPath(path).status; const unusable = status === "missing" || status === "not-sdk"; this.sdkCache = { path, unusable }; return unusable; } async update(document: vscode.TextDocument): Promise { if (!this.ws.isRa3Workspace()) { this.collection.set(document.uri, []); return; } const scope = await this.ws.getScope(document); const idx = scope.merged; const text = document.getText(); const lineMap = new LineMap(text); const doc = scope.parse; const diags: vscode.Diagnostic[] = []; // Reference/duplicate checks are provisional while the index is // incomplete or stale: "not found" may be a false positive. const provisional = idx ? !idx.complete || idx.stale === true : false; 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), ), this.parseErrorMessage(err), vscode.DiagnosticSeverity.Error, "xml-syntax", ), ); } if (doc.root) { this.checkElements( scope.expanded.root, scope.expanded, lineMap, idx, document, diags, provisional, ); } this.collection.set(document.uri, diags); } private parseErrorMessage(err: XmlParseError): string { switch (err.code) { case "content-before-root": return t("Content is not allowed before the root element"); case "unterminated-comment": return t("Unterminated comment"); case "unterminated-cdata": return t("Unterminated CDATA section"); case "unterminated-doctype": return t("Unterminated DOCTYPE"); case "unterminated-processing-instruction": return t("Unterminated processing instruction"); case "unterminated-closing-tag": return t("Unterminated closing tag"); case "unterminated-start-tag": return t("Unterminated start tag"); case "malformed-markup": return t("Malformed markup"); case "unexpected-closing-tag": return t( "Unexpected closing tag ", err.params?.name ?? "", ); case "mismatched-closing-tag": return t( "Mismatched closing tag: expected , found ", err.params?.expected ?? "", err.params?.found ?? "", ); case "element-never-closed": return t("Element <{0}> is never closed", err.params?.name ?? ""); default: return err.message; } } clear(uri: vscode.Uri): void { this.collection.delete(uri); } dispose(): void { this.collection.dispose(); } private checkElements( root: LogicalElement | null, doc: { elements: LogicalElement[] }, lineMap: LineMap, idx: ModIndex | null, document: vscode.TextDocument, diags: vscode.Diagnostic[], provisional: boolean, ): void { const settings = this.ws.settings; const fileDuplicates = new Map(); for (const el of doc.elements) { // Only report diagnostics for nodes that belong to the document being // edited. Nodes spliced in through xi:include keep their own source // file and are diagnosed when that file is opened. if (scopePathKey(el.sourceFile) !== scopePathKey(document.uri.fsPath)) { continue; } const local = localName(el.name); const isTopLevel = root !== null && 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, t("Top-level asset <{0}> requires an id attribute", local), 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, t( 'Duplicate id "{0}" for <{1}> (also defined on line {2})', idAttr.value, local, 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, provisional, ); } } // Elements outside the EA asset namespace (e.g. XInclude ) // are not part of the RA3 XSD model; skip their validation entirely. const isXsdElement = model.isXsdElementName(el.name); // Unknown element. if (settings.diagnoseUnknownElements && isXsdElement) { const knownType = model.elementTypeName(local); if (!knownType) { diags.push( this.diag( range, t("Unknown element <{0}> (not in the RA3 XSD model)", local), vscode.DiagnosticSeverity.Warning, "unknown-element", ), ); } } // Attributes. if (isXsdElement) { 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; // Namespace declarations and prefixed attributes (xai:, xi:, // xlink:, xml:, xsi:, xmlns:*) are not defined by the EA XSD. if (aName.startsWith("xmlns") || !model.isXsdAttributeName(aName)) { continue; } if (settings.diagnoseUnknownElements && !knownNames.has(aName)) { diags.push( this.diag( new vscode.Range( document.positionAt(attr.nameStart), document.positionAt(attr.nameEnd), ), t('Unknown attribute "{0}" for <{1}>', aName, local), vscode.DiagnosticSeverity.Warning, "unknown-attribute", ), ); } if (!attr.hasValue) continue; this.checkValueReferences( elType, attr.name, attr.value, attr, document, idx, diags, provisional, ); } this.checkContentReferences(el, elType, document, idx, diags, provisional); } // Include-specific checks. if (local === "Include") { this.checkInclude(el, document, idx, diags); } } } private checkCrossFileDuplicate( type: string, id: string, document: vscode.TextDocument, idx: ModIndex | null, diags: vscode.Diagnostic[], provisional: boolean, ): void { if (!idx) return; const byType = idx.assets.get(type); const defs = mergeLocalAndGlobalDefs( idx.local?.assets.get(type)?.get(id.toLowerCase()), byType?.get(id.toLowerCase()), ); if (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, t( 'Duplicate id "{0}" for <{1}> (also defined in {2})', id, type, other.file, ) + (provisional ? t(" (based on a partial index)") : ""), vscode.DiagnosticSeverity.Error, "duplicate-id", ), ); } } private checkValueReferences( elType: string | null, attrName: string, value: string, attr: { valueStart: number; valueEnd: number }, document: vscode.TextDocument, idx: ModIndex | null, diags: vscode.Diagnostic[], provisional: boolean, ): 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 && !(idx.local?.defines.has(m[1].toLowerCase()) ?? idx.defines.has(m[1].toLowerCase())) ) { const code = provisional ? "undefined-define-indexing" : "undefined-define"; diags.push( this.diag( range, t('Undefined define "${0}"', m[1]) + (provisional ? t(" (index incomplete — may be a false positive)") : ""), vscode.DiagnosticSeverity.Warning, code, ), ); } } if (value.startsWith("$") || value.startsWith("=")) return; const severity = this.ws.settings.reportUnresolvedReferences; if (severity === "none") return; if (!idx) return; if (!isReferenceAttributeOfType(elType, attrName)) return; const targets = resolveReferenceTargetsForType(idx, elType, attrName, value); if (targets.length) return; const anyDef = (idx.local?.assetsById.has(value.toLowerCase()) ?? false) || idx.assetsById.has(value.toLowerCase()); const attrRef = model .attributesOfType(elType) .find((a) => a.name === attrName); const code = provisional ? "unresolved-reference-indexing" : "unresolved-reference"; const baseMessage = unresolvedReferenceMessage( value, anyDef, attrRef?.refType ?? null, attrRef?.isRef ?? false, ); diags.push( this.diag( range, provisional ? baseMessage + t(" (index incomplete — may be a false positive)") : baseMessage, severity === "warning" ? vscode.DiagnosticSeverity.Warning : vscode.DiagnosticSeverity.Information, code, ), ); } private checkContentReferences( el: XmlElement, elType: string | null, document: vscode.TextDocument, idx: ModIndex | null, diags: vscode.Diagnostic[], provisional: boolean, ): void { // Only simple-content elements carry a text value; complex elements' // "content" is child markup and must not be scanned for value refs. const info = elType ? model.typeInfo(elType) : undefined; if (info?.kind !== "simple") return; if (el.selfClosing || el.closeTagStart < 0) return; const text = document.getText(); const raw = text.slice(el.startTagEnd, el.closeTagStart); const value = raw.trim(); if (!value) return; const valueStart = el.startTagEnd + raw.indexOf(value); const range = new vscode.Range( document.positionAt(valueStart), document.positionAt(valueStart + value.length), ); // 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 && !( idx.local?.defines.has(m[1].toLowerCase()) ?? idx.defines.has(m[1].toLowerCase()) ) ) { const code = provisional ? "undefined-define-indexing" : "undefined-define"; diags.push( this.diag( range, t('Undefined define "${0}"', m[1]) + (provisional ? t(" (index incomplete — may be a false positive)") : ""), vscode.DiagnosticSeverity.Warning, code, ), ); } } if (value.startsWith("$") || value.startsWith("=")) return; const severity = this.ws.settings.reportUnresolvedReferences; if (severity === "none" || !idx) return; if (!isReferenceContentType(elType)) return; const targets = resolveContentReferenceTargets(idx, elType, value); if (targets.length) return; const anyDef = (idx.local?.assetsById.has(value.toLowerCase()) ?? false) || idx.assetsById.has(value.toLowerCase()); const refType = info.refType; const code = provisional ? "unresolved-reference-indexing" : "unresolved-reference"; const baseMessage = unresolvedReferenceMessage( value, anyDef, refType ?? null, !refType, ); diags.push( this.diag( range, provisional ? baseMessage + t(" (index incomplete — may be a false positive)") : baseMessage, severity === "warning" ? vscode.DiagnosticSeverity.Warning : vscode.DiagnosticSeverity.Information, code, ), ); } private checkInclude( el: XmlElement, document: vscode.TextDocument, idx: ModIndex | null, 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), ), t( 'Invalid Include type "{0}" (expected reference, instance or all)', typeAttr.value, ), vscode.DiagnosticSeverity.Error, "include-type", ), ); } if (!sourceAttr?.hasValue) return; const searchPaths = idx ? buildSearchPaths(idx.sdkDir, idx.projectDir) : this.ws.searchPaths(document); if (!searchPaths) return; const resolved = resolveSource( sourceAttr.value, dirname(document.uri.fsPath), searchPaths, ); const candidateHit = idx?.sourceCandidates.some((c) => c.source === sourceAttr.value) ?? false; if (!resolved.path && !candidateHit) { // Without a usable SDK, prefixed includes are expected to be missing; // report one project-level hint instead of warning on every line. if (this.sdkUnusable() && /^(DATA|ART|AUDIO):/i.test(sourceAttr.value.trim())) { return; } diags.push( this.diag( new vscode.Range( document.positionAt(sourceAttr.valueStart), document.positionAt(sourceAttr.valueEnd), ), t("Include target not found: {0}", 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 unresolvedReferenceMessage( value: string, anyDef: boolean, refType: string | null, isRef: boolean, ): string { if (anyDef) { if (refType) { return t( 'Reference "{0}" has no definition of type `{1}` (ids with the same name exist for other types)', value, refType, ); } if (isRef) { return t( 'Reference "{0}" has no definition of the expected declared type (ids with the same name exist for other types)', value, ); } return t( 'Reference "{0}" has no matching definition (ids with the same name exist for other types)', value, ); } return t('Unresolved reference "{0}" (not found in the current index)', value); } function tagRange(document: vscode.TextDocument, el: XmlElement): vscode.Range { return new vscode.Range( document.positionAt(el.start), document.positionAt(el.startTagEnd), ); }