578 lines
18 KiB
TypeScript
578 lines
18 KiB
TypeScript
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<void> {
|
|
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 </{0}>",
|
|
err.params?.name ?? "",
|
|
);
|
|
case "mismatched-closing-tag":
|
|
return t(
|
|
"Mismatched closing tag: expected </{0}>, found </{1}>",
|
|
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<string, { line: number }>();
|
|
|
|
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 <xi:include>)
|
|
// 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),
|
|
);
|
|
}
|