This commit is contained in:
2026-08-11 12:01:25 +02:00
parent dbc2c99d8d
commit 2a049c7e92
20 changed files with 1045 additions and 227 deletions
+45 -16
View File
@@ -20,6 +20,7 @@ import {
Ra3SemanticTokensProvider,
RA3_SEMANTIC_TOKENS_LEGEND,
} from "./features/semanticTokens";
import { t } from "./localize";
const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }];
/** Safety-net refresh interval while a rebuild is running. */
@@ -203,7 +204,7 @@ export function activate(context: vscode.ExtensionContext): void {
vscode.commands.registerCommand("ra3modxml.clearCache", () => {
ws.clearCaches();
void vscode.window.showInformationMessage(
"RA3 Mod XML: caches cleared; rebuilding from scratch…",
t("RA3 Mod XML: caches cleared; rebuilding from scratch…"),
);
}),
);
@@ -220,35 +221,63 @@ export function activate(context: vscode.ExtensionContext): void {
if (!idx) {
if (ws.isBuilding) {
void vscode.window.showInformationMessage(
"RA3 Mod XML: index is still building — check the status bar. " +
"Most features become available after the XML phase.",
t(
"RA3 Mod XML: index is still building — check the status bar. Most features become available after the XML phase.",
),
);
return;
}
if (ws.getProjectRoots().length) {
void vscode.window.showInformationMessage(
"RA3 Mod XML: no index for the active project yet — open a mod XML document to start indexing.",
t(
"RA3 Mod XML: no index for the active project yet — open a mod XML document to start indexing.",
),
);
return;
}
void vscode.window.showInformationMessage(
"RA3 Mod XML: no index available. Open a workspace that contains Data/Mod.xml, Data/additionalmaps/mapmetadata_*.xml or a mod folder.",
t(
"RA3 Mod XML: no index available. Open a workspace that contains Data/Mod.xml, Data/additionalmaps/mapmetadata_*.xml or a mod folder.",
),
);
return;
}
const s = idx.stats;
const stale = idx.stale ? " (stale)" : "";
const stale = idx.stale ? ` ${t("(stale)")}` : "";
void vscode.window.showInformationMessage(
`RA3 Mod XML index\n` +
`Project: ${s.projectDir}\n` +
`Files: ${s.indexedFiles} (${s.parsedFiles} parsed, ${s.shallowScannedFiles} shallow-scanned, ${s.shallowCacheHits + s.recordsCacheHits} cache hits)\n` +
`Assets: ${s.assetCount} (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` +
`References: ${s.referenceCount}\n` +
`Defines: ${s.defineCount} · Streams: ${s.streams} · Candidates: ${s.sourceCandidates}\n` +
`Phase: ${s.phase} · Complete: ${s.complete}${stale}\n` +
`Build #${ws.buildCount} (trigger: ${ws.lastTrigger})\n` +
`Indexed in ${(s.elapsedMs / 1000).toFixed(1)}s\n` +
`XML walk: ${(s.walkMs / 1000).toFixed(1)}s · Candidates: ${(s.candidatesMs / 1000).toFixed(1)}s · Art scan: ${(s.artScanMs / 1000).toFixed(1)}s`,
[
t("RA3 Mod XML index"),
t("Project: {0}", s.projectDir),
t(
"Files: {0} ({1} parsed, {2} shallow-scanned, {3} cache hits)",
s.indexedFiles,
s.parsedFiles,
s.shallowScannedFiles,
s.shallowCacheHits + s.recordsCacheHits,
),
t(
"Assets: {0} ({1} from {2} manifests)",
s.assetCount,
s.manifestAssetCount,
s.manifestFiles,
),
t("References: {0}", s.referenceCount),
t(
"Defines: {0} · Streams: {1} · Candidates: {2}",
s.defineCount,
s.streams,
s.sourceCandidates,
),
t("Phase: {0} · Complete: {1}{2}", s.phase, s.complete, stale),
t("Build #{0} (trigger: {1})", ws.buildCount, ws.lastTrigger),
t("Indexed in {0}s", (s.elapsedMs / 1000).toFixed(1)),
t(
"XML walk: {0}s · Candidates: {1}s · Art scan: {2}s",
(s.walkMs / 1000).toFixed(1),
(s.candidatesMs / 1000).toFixed(1),
(s.artScanMs / 1000).toFixed(1),
),
].join("\n"),
{ modal: false },
);
}),
+4 -3
View File
@@ -10,6 +10,7 @@ import {
type ShowReferencesArgs,
} from "./references";
import type { ModWorkspace } from "../workspace";
import { t } from "../localize";
/** Never build a DOM for huge files just to show counts (w3x safety). */
const MAX_CODELENS_TEXT = 4 * 1024 * 1024;
@@ -124,10 +125,10 @@ export class Ra3CodeLensProvider implements vscode.CodeLensProvider {
new vscode.CodeLens(range, {
title:
count === 0
? "0 references"
? t("0 references")
: count === 1
? "1 reference"
: `${count} references`,
? t("1 reference")
: t("{0} references", count),
command: "ra3modxml.showReferences",
arguments: [args],
}),
+90 -32
View File
@@ -17,6 +17,7 @@ import {
} from "../indexer/logicalTree";
import type { ModWorkspace } from "../workspace";
import type { ModIndex, AssetDef } from "../indexer/types";
import { t } from "../localize";
const MAX_VALUE_ITEMS = 400;
@@ -79,9 +80,9 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
const docText =
child.doc ||
(info?.kind === "complex" ? info.doc : "") ||
(type ? `Type: ${type}` : "");
(type ? t("Type: {0}", type) : "");
item.documentation = docText ? new vscode.MarkdownString(docText) : undefined;
item.detail = type ? `RA3 XML · ${type}` : "RA3 XML";
item.detail = type ? t("RA3 XML · {0}", type) : t("RA3 XML");
item.insertText = this.elementSnippet(child.name, type, ctx.element == null);
items.push(item);
}
@@ -93,7 +94,11 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
): { name: string; type: string | null; doc: string }[] {
if (!parent) {
return [
{ name: "AssetDeclaration", type: null, doc: "Root element of every RA3 asset file" },
{
name: "AssetDeclaration",
type: null,
doc: t("Root element of every RA3 asset file"),
},
];
}
const parentType = resolveElementType(parent);
@@ -160,12 +165,16 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
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.required) md.appendMarkdown(`${t("**Required**")} \n`);
if (attr.refType) {
md.appendMarkdown(`${t("References: `{0}`", 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"}\``);
md.appendMarkdown(`${t("Values: {0}", attr.enumValues.join(", "))} \n`);
if (attr.default != null) {
md.appendMarkdown(`${t("Default: `{0}`", attr.default)} \n`);
}
md.appendMarkdown(t("Type: `{0}`", attr.type ?? "string"));
item.documentation = md;
const value = this.attributeValuePlaceholder(attr, el);
item.insertText = new vscode.SnippetString(
@@ -174,7 +183,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
if (value.trigger) {
item.command = {
command: "editor.action.triggerSuggest",
title: "Suggest attribute values",
title: t("Suggest attribute values"),
};
}
items.push(item);
@@ -187,13 +196,15 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
j.insertText = new vscode.SnippetString(
layout.prefix + 'xai:joinAction="$1"',
);
j.detail = "Instance join action";
j.detail = t("Instance join action");
j.documentation = new vscode.MarkdownString(
"Controls how this element merges with the inherited definition: `Replace` or `Remove`.",
t(
"Controls how this element merges with the inherited definition: `Replace` or `Remove`.",
),
);
j.command = {
command: "editor.action.triggerSuggest",
title: "Suggest attribute values",
title: t("Suggest attribute values"),
};
items.push(j);
}
@@ -203,7 +214,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
ns.insertText = new vscode.SnippetString(
layout.prefix + 'xmlns:xai="uri:ea.com:eala:asset:instance"',
);
ns.detail = "xai namespace";
ns.detail = t("xai namespace");
items.push(ns);
}
return items;
@@ -312,7 +323,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
// Include type / source
if (isInclude && attrName === "type") {
return ["reference", "instance", "all"].map((v) =>
make(v, vscode.CompletionItemKind.EnumMember, "Include type"),
make(v, vscode.CompletionItemKind.EnumMember, t("Include type")),
);
}
if (isInclude && attrName === "source") {
@@ -321,7 +332,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
}
if (attrName === "xai:joinaction" || attrName === "joinaction") {
return ["Replace", "Remove"].map((v) =>
make(v, vscode.CompletionItemKind.EnumMember, "xai:joinAction"),
make(v, vscode.CompletionItemKind.EnumMember, t("xai:joinAction")),
);
}
@@ -348,12 +359,18 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
}
return attrInfo.enumValues
.filter((v) => v.toLowerCase().startsWith(prefix.toLowerCase()))
.map((v) => make(v, vscode.CompletionItemKind.EnumMember, attrInfo.type ?? "enum"));
.map((v) =>
make(
v,
vscode.CompletionItemKind.EnumMember,
attrInfo.type ?? t("enum"),
),
);
}
if (attrInfo?.isBoolean) {
return ["true", "false"]
.filter((v) => v.startsWith(prefix.toLowerCase()))
.map((v) => make(v, vscode.CompletionItemKind.Value, "boolean"));
.map((v) => make(v, vscode.CompletionItemKind.Value, t("boolean")));
}
if (idx && attrInfo?.allowsDefine) {
return this.defineItems(idx, prefix, make);
@@ -412,7 +429,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
make(
v,
vscode.CompletionItemKind.EnumMember,
attrInfo.type ?? "enum",
attrInfo.type ?? t("enum"),
undefined,
range,
append ? ` ${v}` : v,
@@ -436,10 +453,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
a.source.localeCompare(b.source),
);
const items = candidates.map((c) => {
const item = make(c.source, vscode.CompletionItemKind.File, "Include source");
const item = make(
c.source,
vscode.CompletionItemKind.File,
t("Include source"),
);
item.detail = c.path;
item.documentation = new vscode.MarkdownString(
`\`${c.prefix ?? "relative"}\` · ${c.path}`,
t("`{0}` · {1}", c.prefix ?? t("relative"), c.path),
);
return item;
});
@@ -520,19 +541,30 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
);
const items = top.map(({ def }) => {
const originLabel = (d: AssetDef) =>
d.origin === "manifest" ? `manifest (${d.manifestSource ?? ""})` : d.origin;
d.origin === "manifest"
? d.manifestSource
? t("manifest ({0})", d.manifestSource)
: t("manifest")
: originLabelText(d.origin);
const origin = originLabel(def);
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}`);
doc.appendMarkdown(`${t("**Type**: {0}", def.type)} \n`);
if (def.manifestSource) {
doc.appendMarkdown(`${t("**Source**: {0}", def.manifestSource)} \n`);
}
doc.appendMarkdown(t("**Origin**: {0}", origin));
for (const extra of byId.get(def.id.toLowerCase())?.extras ?? []) {
doc.appendMarkdown(
`\n\nAlso defined as **${extra.type}** · ${originLabel(extra)}`,
`\n\n${t("Also defined as **{0}** · {1}", extra.type, originLabel(extra))}`,
);
}
return make(def.id, vscode.CompletionItemKind.Value, `${def.type} · ${origin}`, doc.value);
return make(
def.id,
vscode.CompletionItemKind.Value,
t("{0} · {1}", def.type, origin),
doc.value,
);
});
return this.limitItems(items, byId.size);
}
@@ -557,7 +589,12 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
if (seen.has(dedupe)) continue;
seen.add(dedupe);
const label = `$${def.name}`;
const item = make(label, vscode.CompletionItemKind.Constant, "Define", def.value);
const item = make(
label,
vscode.CompletionItemKind.Constant,
t("Define"),
def.value,
);
item.insertText = label;
items.push(item);
}
@@ -580,8 +617,10 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
make(
id,
vscode.CompletionItemKind.Value,
"local module",
"Pipeline-local id in the enclosing GameObject (includes xi:include targets).",
t("local module"),
t(
"Pipeline-local id in the enclosing GameObject (includes xi:include targets).",
),
),
);
}
@@ -674,7 +713,13 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
if (isList) return this.listEnumItems(info, rawPrefix, seg, valueRange, make);
return info.enumValues
.filter((v) => v.toLowerCase().startsWith(seg.token.toLowerCase()))
.map((v) => make(v, vscode.CompletionItemKind.EnumMember, elType ?? "enum"));
.map((v) =>
make(
v,
vscode.CompletionItemKind.EnumMember,
elType ?? t("enum"),
),
);
}
if (idx && info.allowsDefine) {
return this.defineItems(idx, seg.token, make);
@@ -708,13 +753,13 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
item.insertText = this.elementSnippet(child.name, child.type, !typedOpen);
const type = child.type;
const info = type ? model.typeInfo(type) : undefined;
item.detail = type ? `RA3 XML · ${type}` : "RA3 XML";
item.detail = type ? t("RA3 XML · {0}", type) : t("RA3 XML");
const doc = child.doc || (info?.kind === "complex" ? info.doc : "");
if (doc) item.documentation = new vscode.MarkdownString(doc);
if (info?.kind === "simple" && this.simpleContentValueKind(info)) {
item.command = {
command: "editor.action.triggerSuggest",
title: "Suggest content value",
title: t("Suggest content value"),
};
}
items.push(item);
@@ -736,6 +781,19 @@ interface ScoredDef {
score: number;
}
function originLabelText(origin: AssetDef["origin"]): string {
switch (origin) {
case "project":
return t("project");
case "sdk":
return t("SDK");
case "manifest":
return t("manifest");
default:
return origin;
}
}
function compareScoredDefs(a: ScoredDef, b: ScoredDef): number {
return a.score - b.score || a.def.id.localeCompare(b.def.id);
}
+117 -36
View File
@@ -1,6 +1,10 @@
import * as vscode from "vscode";
import { dirname } from "node:path";
import { LineMap, type XmlElement } from "../language/xmlParser";
import {
LineMap,
type XmlElement,
type XmlParseError,
} from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import { resolveSource, buildSearchPaths } from "../indexer/includeResolver";
import { validateSdkPath } from "../sdk";
@@ -16,6 +20,7 @@ import {
} 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;
@@ -57,7 +62,7 @@ export class Ra3Diagnostics {
new vscode.Position(err.line, err.character),
new vscode.Position(err.line, err.character + 1),
),
err.message,
this.parseErrorMessage(err),
vscode.DiagnosticSeverity.Error,
"xml-syntax",
),
@@ -79,6 +84,42 @@ export class Ra3Diagnostics {
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);
}
@@ -120,7 +161,7 @@ export class Ra3Diagnostics {
diags.push(
this.diag(
range,
`Top-level asset <${local}> requires an id attribute`,
t("Top-level asset <{0}> requires an id attribute", local),
vscode.DiagnosticSeverity.Error,
"missing-id",
),
@@ -136,7 +177,12 @@ export class Ra3Diagnostics {
diags.push(
this.diag(
where,
`Duplicate id "${idAttr.value}" for <${local}> (also defined on line ${prev.line})`,
t(
'Duplicate id "{0}" for <{1}> (also defined on line {2})',
idAttr.value,
local,
prev.line,
),
vscode.DiagnosticSeverity.Error,
"duplicate-id",
),
@@ -166,7 +212,7 @@ export class Ra3Diagnostics {
diags.push(
this.diag(
range,
`Unknown element <${local}> (not in the RA3 XSD model)`,
t("Unknown element <{0}> (not in the RA3 XSD model)", local),
vscode.DiagnosticSeverity.Warning,
"unknown-element",
),
@@ -187,16 +233,16 @@ export class Ra3Diagnostics {
continue;
}
if (settings.diagnoseUnknownElements && !knownNames.has(aName)) {
diags.push(
this.diag(
new vscode.Range(
document.positionAt(attr.nameStart),
document.positionAt(attr.nameEnd),
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",
),
`Unknown attribute "${aName}" for <${local}>`,
vscode.DiagnosticSeverity.Warning,
"unknown-attribute",
),
);
}
@@ -258,8 +304,12 @@ export class Ra3Diagnostics {
diags.push(
this.diag(
range,
`Duplicate id "${id}" for <${type}> (also defined in ${other.file})` +
(provisional ? " (based on a partial index)" : ""),
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",
),
@@ -296,8 +346,8 @@ export class Ra3Diagnostics {
diags.push(
this.diag(
range,
`Undefined define "$${m[1]}"` +
(provisional ? " (index incomplete — may be a false positive)" : ""),
t('Undefined define "${0}"', m[1]) +
(provisional ? t(" (index incomplete — may be a false positive)") : ""),
vscode.DiagnosticSeverity.Warning,
code,
),
@@ -318,20 +368,18 @@ export class Ra3Diagnostics {
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";
const code = provisional ? "unresolved-reference-indexing" : "unresolved-reference";
const baseMessage = 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)`;
const baseMessage = unresolvedReferenceMessage(
value,
anyDef,
attrRef?.refType ?? null,
attrRef?.isRef ?? false,
);
diags.push(
this.diag(
range,
provisional
? `${baseMessage} (index incomplete — may be a false positive)`
? baseMessage + t(" (index incomplete — may be a false positive)")
: baseMessage,
severity === "warning"
? vscode.DiagnosticSeverity.Warning
@@ -379,8 +427,8 @@ export class Ra3Diagnostics {
diags.push(
this.diag(
range,
`Undefined define "$${m[1]}"` +
(provisional ? " (index incomplete — may be a false positive)" : ""),
t('Undefined define "${0}"', m[1]) +
(provisional ? t(" (index incomplete — may be a false positive)") : ""),
vscode.DiagnosticSeverity.Warning,
code,
),
@@ -398,16 +446,18 @@ export class Ra3Diagnostics {
(idx.local?.assetsById.has(value.toLowerCase()) ?? false) ||
idx.assetsById.has(value.toLowerCase());
const refType = info.refType;
const expected = refType ? `of type \`${refType}\`` : "of the expected declared type";
const code = provisional ? "unresolved-reference-indexing" : "unresolved-reference";
const baseMessage = 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)`;
const baseMessage = unresolvedReferenceMessage(
value,
anyDef,
refType ?? null,
!refType,
);
diags.push(
this.diag(
range,
provisional
? `${baseMessage} (index incomplete — may be a false positive)`
? baseMessage + t(" (index incomplete — may be a false positive)")
: baseMessage,
severity === "warning"
? vscode.DiagnosticSeverity.Warning
@@ -432,7 +482,10 @@ export class Ra3Diagnostics {
document.positionAt(typeAttr.valueStart),
document.positionAt(typeAttr.valueEnd),
),
`Invalid Include type "${typeAttr.value}" (expected reference, instance or all)`,
t(
'Invalid Include type "{0}" (expected reference, instance or all)',
typeAttr.value,
),
vscode.DiagnosticSeverity.Error,
"include-type",
),
@@ -462,7 +515,7 @@ export class Ra3Diagnostics {
document.positionAt(sourceAttr.valueStart),
document.positionAt(sourceAttr.valueEnd),
),
`Include target not found: ${sourceAttr.value}`,
t("Include target not found: {0}", sourceAttr.value),
vscode.DiagnosticSeverity.Warning,
"include-not-found",
),
@@ -488,6 +541,34 @@ function localName(tag: string): string {
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),
+89 -43
View File
@@ -19,6 +19,7 @@ import {
import { scopePathKey, type DocumentScope } from "../indexer/localScope";
import { dirname } from "node:path";
import { buildSearchPaths, resolveSource } from "../indexer/includeResolver";
import { t } from "../localize";
export class Ra3HoverProvider implements vscode.HoverProvider {
constructor(private ws: ModWorkspace) {}
@@ -68,7 +69,9 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
const md = new vscode.MarkdownString();
md.appendCodeblock(`<${name}>`, "xml");
md.appendMarkdown(
"XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.",
t(
"XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.",
),
);
return new vscode.Hover(md);
}
@@ -76,19 +79,25 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
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 (model.isTopLevelElement(name)) {
md.appendMarkdown(`${t("**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`,
`${t(
"Attributes: {0} · Children: {1}",
info.attributes.length,
info.children.length,
)} \n`,
);
if (info.base) md.appendMarkdown(`Extends: \`${info.base}\``);
if (info.base) md.appendMarkdown(t("Extends: `{0}`", info.base));
} else if (info?.kind === "simple") {
md.appendMarkdown(`Simple type: \`${type}\``);
md.appendMarkdown(t("Simple type: `{0}`", type ?? ""));
} else if (type) {
md.appendMarkdown(`Type: \`${type}\``);
md.appendMarkdown(t("Type: `{0}`", type));
} else {
md.appendMarkdown("Not found in the bundled XSD model.");
md.appendMarkdown(t("Not found in the bundled XSD model."));
}
return new vscode.Hover(md);
}
@@ -104,26 +113,38 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
md.appendCodeblock(`${attrName}=""`, "xml");
if (!attr) {
if (/^(xmlns|xai:)/.test(attrName)) {
md.appendMarkdown(`Namespace/instance attribute.`);
md.appendMarkdown(t("Namespace/instance attribute."));
return new vscode.Hover(md);
}
if (!model.isXsdElementName(el.name)) {
md.appendMarkdown(
`XInclude attribute (W3C XInclude namespace) — not part of the RA3 XSD model.`,
t(
"XInclude attribute (W3C XInclude namespace) — not part of the RA3 XSD model.",
),
);
return new vscode.Hover(md);
}
md.appendMarkdown("Unknown attribute for this element.");
md.appendMarkdown(t("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.required) md.appendMarkdown(`${t("**Required**")} \n`);
if (attr.refType) {
md.appendMarkdown(
`${t("References assets of type `{0}`", 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"}\``);
md.appendMarkdown(
`${t("Values: `{0}`", attr.enumValues.join("`, `"))} \n`,
);
if (attr.default != null) {
md.appendMarkdown(`${t("Default: `{0}`", attr.default)} \n`);
}
if (attr.allowsDefine) {
md.appendMarkdown(`${t("May use `$DEFINE` constants")} \n`);
}
md.appendMarkdown(t("Type: `{0}`", attr.type ?? "string"));
return new vscode.Hover(md);
}
@@ -164,17 +185,19 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
).path
: null;
if (resolved) {
md.appendMarkdown(`**Include source** \n`);
md.appendMarkdown(`${t("**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.appendMarkdown(`${t("**Include source**")} \n`);
md.appendCodeblock(cand.path);
return new vscode.Hover(md);
}
md.appendMarkdown(`Include source: \`${value}\` (not in candidate index)`);
md.appendMarkdown(
t("Include source: `{0}` (not in candidate index)", value),
);
return new vscode.Hover(md);
}
@@ -191,7 +214,7 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
if (!idx) {
if (isReferenceAttributeOfType(elType, attrName)) {
md.appendMarkdown(
"Index is still building — references cannot be resolved yet.",
t("Index is still building — references cannot be resolved yet."),
);
return new vscode.Hover(md);
}
@@ -204,12 +227,14 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
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"
: "";
return this.noDefinitionHover(expected);
return this.noDefinitionHover(
attrRef?.refType
? "typed"
: attrRef?.isRef
? "untyped"
: "generic",
attrRef?.refType ?? undefined,
);
}
}
@@ -235,16 +260,16 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
if (!isReferenceContentType(elType)) return null;
if (!idx) {
const md = new vscode.MarkdownString();
md.appendMarkdown("Index is still building — references cannot be resolved yet.");
md.appendMarkdown(
t("Index is still building — references cannot be resolved yet."),
);
return new vscode.Hover(md);
}
const targets = resolveContentReferenceTargets(idx, elType, value);
if (targets.length) return this.definitionsHover(targets, document);
const info = elType ? model.typeInfo(elType) : undefined;
const refType = info?.kind === "simple" ? info.refType : null;
return this.noDefinitionHover(
refType ? ` of type \`${refType}\`` : " of the expected declared type",
);
return this.noDefinitionHover(refType ? "typed" : "untyped", refType ?? undefined);
}
private defineHover(
@@ -254,10 +279,10 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
if (!defs?.length) return null;
const d = defs[0];
const md = new vscode.MarkdownString();
md.appendMarkdown(`**Define** \`$${d.name}\` \n`);
md.appendMarkdown(`${t("**Define** `{0}`", `$${d.name}`)} \n`);
md.appendCodeblock(d.value);
const rel = relativePath(document, d.file);
md.appendMarkdown(`Defined in \`${rel}:${d.line}\``);
md.appendMarkdown(t("Defined in `{0}:{1}`", rel, d.line));
return new vscode.Hover(md);
}
@@ -266,23 +291,44 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
document: vscode.TextDocument,
): vscode.Hover {
const md2 = new vscode.MarkdownString();
md2.appendMarkdown(`**${targets.length} definition${targets.length > 1 ? "s" : ""}** \n`);
md2.appendMarkdown(
`${targets.length === 1 ? t("**1 definition**") : t("**{0} definitions**", targets.length)} \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`);
? t("manifest `{0}`", d.manifestSource ?? d.file)
: t("`{0}:{1}`", relativePath(document, d.file), d.line);
md2.appendMarkdown(`${t("- `{0}` · {1}", d.type, loc)} \n`);
}
return new vscode.Hover(md2);
}
private noDefinitionHover(expected: string): vscode.Hover {
private noDefinitionHover(
kind: "typed" | "untyped" | "generic",
refType?: string,
): vscode.Hover {
const md = new vscode.MarkdownString();
md.appendMarkdown(
`No matching definition${expected} in the current index` +
" (may exist in a compiled manifest or vanilla data).",
);
if (kind === "typed" && refType) {
md.appendMarkdown(
t(
"No matching definition of type `{0}` in the current index (may exist in a compiled manifest or vanilla data).",
refType,
),
);
} else if (kind === "untyped") {
md.appendMarkdown(
t(
"No matching definition of the expected declared type in the current index (may exist in a compiled manifest or vanilla data).",
),
);
} else {
md.appendMarkdown(
t(
"No matching definition in the current index (may exist in a compiled manifest or vanilla data).",
),
);
}
return new vscode.Hover(md);
}
@@ -301,10 +347,10 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
const lineMap = scope.lineMaps.get(scopePathKey(target.sourceFile));
const line = lineMap ? lineMap.positionAt(idAttr.valueStart).line + 1 : 0;
const md = new vscode.MarkdownString();
md.appendMarkdown(`**Local pipeline id** \`${value}\` \n`);
md.appendMarkdown(`${t("**Local pipeline id** `{0}`", value)} \n`);
md.appendCodeblock(`<${target.name}>`);
const rel = relativePath(document, target.sourceFile);
md.appendMarkdown(`Defined in \`${rel}:${line}\``);
md.appendMarkdown(t("Defined in `{0}:{1}`", rel, line));
return new vscode.Hover(md);
}
}
+8 -1
View File
@@ -24,6 +24,7 @@ import {
import { scopePathKey, type DocumentScope } from "../indexer/localScope";
import type { ModWorkspace } from "../workspace";
import type { AssetDef, ModIndex } from "../indexer/types";
import { t } from "../localize";
function searchPathsFor(idx: ModIndex): SearchPaths {
return buildSearchPaths(idx.sdkDir, idx.projectDir);
@@ -404,7 +405,13 @@ export class Ra3DocumentSymbolProvider implements vscode.DocumentSymbolProvider
document.positionAt(define.end),
);
symbols.push(
new vscode.DocumentSymbol(`$${name}`, "Define", vscode.SymbolKind.Constant, range, range),
new vscode.DocumentSymbol(
`$${name}`,
t("Define"),
vscode.SymbolKind.Constant,
range,
range,
),
);
}
}
+10 -9
View File
@@ -3,6 +3,7 @@ import { relative } from "node:path";
import { findElementAt, parseXml } from "../language/xmlParser";
import { unreferencedByType } from "../indexer/referenceIndex";
import type { ModWorkspace } from "../workspace";
import { t } from "../localize";
interface TypePickItem extends vscode.QuickPickItem {
type: string;
@@ -26,7 +27,7 @@ export async function findUnreferencedAssets(
const idx = ws.activeIndex();
if (!ws.isRa3Workspace() || !idx) {
void vscode.window.showInformationMessage(
"RA3 Mod XML: no index available yet.",
t("RA3 Mod XML: no index available yet."),
);
return;
}
@@ -36,18 +37,18 @@ export async function findUnreferencedAssets(
if (!type) {
if (!byType.size) {
void vscode.window.showInformationMessage(
"RA3 Mod XML: no unreferenced assets found.",
t("RA3 Mod XML: no unreferenced assets found."),
);
return;
}
const pickedType = await vscode.window.showQuickPick<TypePickItem>(
[...byType.entries()].map(([t, defs]) => ({
label: t,
description: `${defs.length} unreferenced`,
type: t,
[...byType.entries()].map(([typeName, defs]) => ({
label: typeName,
description: t("{0} unreferenced", defs.length),
type: typeName,
})),
{
placeHolder: "Select an asset type",
placeHolder: t("Select an asset type"),
matchOnDescription: true,
},
);
@@ -58,7 +59,7 @@ export async function findUnreferencedAssets(
const defs = byType.get(type) ?? [];
if (!defs.length) {
void vscode.window.showInformationMessage(
`RA3 Mod XML: no unreferenced ${type} assets found.`,
t("RA3 Mod XML: no unreferenced {0} assets found.", type),
);
return;
}
@@ -70,7 +71,7 @@ export async function findUnreferencedAssets(
line: d.line,
})),
{
placeHolder: `${type}: ${defs.length} unreferenced`,
placeHolder: t("{0}: {1} unreferenced", type, defs.length),
matchOnDescription: true,
},
);
+48 -12
View File
@@ -53,6 +53,10 @@ export interface XmlElement {
export interface XmlParseError {
message: string;
/** Stable machine-readable id used by the UI layer for localization. */
code: string;
/** Dynamic values referenced by the localized message. */
params?: Record<string, string>;
offset: number;
line: number;
character: number;
@@ -249,9 +253,21 @@ export function parseXml(text: string): XmlDocument {
let i = 0;
const n = text.length;
const err = (message: string, offset: number) => {
const err = (
code: string,
message: string,
offset: number,
params?: Record<string, string>,
) => {
const pos = lineMap.positionAt(offset);
errors.push({ message, offset, line: pos.line, character: pos.character });
errors.push({
code,
message,
params,
offset,
line: pos.line,
character: pos.character,
});
};
while (i < n) {
@@ -261,7 +277,11 @@ export function parseXml(text: string): XmlDocument {
// 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);
err(
"content-before-root",
"Content is not allowed before the root element",
i,
);
}
}
i = lt;
@@ -270,7 +290,7 @@ export function parseXml(text: string): XmlDocument {
if (text.startsWith("<!--", i)) {
const close = text.indexOf("-->", i + 4);
if (close < 0) {
err("Unterminated comment", i);
err("unterminated-comment", "Unterminated comment", i);
break;
}
i = close + 3;
@@ -280,7 +300,7 @@ export function parseXml(text: string): XmlDocument {
if (text.startsWith("<![CDATA[", i)) {
const close = text.indexOf("]]>", i + 9);
if (close < 0) {
err("Unterminated CDATA section", i);
err("unterminated-cdata", "Unterminated CDATA section", i);
break;
}
i = close + 3;
@@ -290,7 +310,7 @@ export function parseXml(text: string): XmlDocument {
if (text.startsWith("<!DOCTYPE", i) || text.startsWith("<!doctype", i)) {
const close = text.indexOf(">", i);
if (close < 0) {
err("Unterminated DOCTYPE", i);
err("unterminated-doctype", "Unterminated DOCTYPE", i);
break;
}
i = close + 1;
@@ -300,7 +320,11 @@ export function parseXml(text: string): XmlDocument {
if (text.startsWith("<?", i)) {
const close = text.indexOf("?>", i + 2);
if (close < 0) {
err("Unterminated processing instruction", i);
err(
"unterminated-processing-instruction",
"Unterminated processing instruction",
i,
);
break;
}
if (i === 0 && /^<\?xml\s/i.test(text.slice(i, close + 2))) {
@@ -313,15 +337,25 @@ export function parseXml(text: string): XmlDocument {
if (text.startsWith("</", i)) {
const gt = text.indexOf(">", i + 2);
if (gt < 0) {
err("Unterminated closing tag", i);
err("unterminated-closing-tag", "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);
err(
"unexpected-closing-tag",
`Unexpected closing tag </${name}>`,
i,
{ name },
);
} else if (top.name !== name) {
err(`Mismatched closing tag: expected </${top.name}>, found </${name}>`, i);
err(
"mismatched-closing-tag",
`Mismatched closing tag: expected </${top.name}>, found </${name}>`,
i,
{ expected: top.name, found: name },
);
// recover: find the matching element on the stack if possible
let idx = stack.length - 1;
while (idx >= 0 && stack[idx].name !== name) idx--;
@@ -343,13 +377,13 @@ export function parseXml(text: string): XmlDocument {
}
// opening tag
if (text[i + 1] === "!" || text[i + 1] === "?") {
err("Malformed markup", i);
err("malformed-markup", "Malformed markup", i);
i++;
continue;
}
const gt = findTagEnd(text, i + 1);
if (gt < 0) {
err("Unterminated start tag", i);
err("unterminated-start-tag", "Unterminated start tag", i);
// Recovery while typing: an attribute value whose closing quote has not
// been typed yet makes the scanner run to EOF. End the malformed start
// tag at the first line break (or EOF) so the rest of the document is
@@ -399,7 +433,9 @@ export function parseXml(text: string): XmlDocument {
for (const el of stack) {
const pos = lineMap.positionAt(el.start);
errors.push({
code: "element-never-closed",
message: `Element <${el.name}> is never closed`,
params: { name: el.name },
offset: el.start,
line: pos.line,
character: pos.character,
+63
View File
@@ -0,0 +1,63 @@
import * as vscode from "vscode";
type L10n = {
t(message: string, ...args: Array<string | number | boolean>): string;
t(
message: string,
args: Record<string, string | number | boolean>,
): string;
};
/**
* Thin wrapper around VS Code's built-in `l10n.t`.
*
* The fallback keeps the message unchanged when no bundle is loaded (for
* example in unit tests or when the extension runs with the default English
* locale), so the same call sites work in production and tests.
*/
export function t(
message: string,
...args: Array<string | number | boolean>
): string {
const l10n = (vscode as unknown as { l10n?: L10n }).l10n;
if (!l10n?.t) return formatIndexed(message, args);
try {
return l10n.t(message, ...args);
} catch {
return formatIndexed(message, args);
}
}
/** Named-placeholder variant of {@link t}. */
export function tN(
message: string,
args: Record<string, string | number | boolean>,
): string {
const l10n = (vscode as unknown as { l10n?: L10n }).l10n;
if (!l10n?.t) return formatNamed(message, args);
try {
return l10n.t(message, args);
} catch {
return formatNamed(message, args);
}
}
function formatIndexed(
message: string,
args: Array<string | number | boolean>,
): string {
return message.replace(/\{(\d+)\}/g, (match, index: string) => {
const value = args[Number(index)];
return value === undefined ? match : String(value);
});
}
function formatNamed(
message: string,
args: Record<string, string | number | boolean>,
): string {
return message.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, name: string) => {
const value = args[name];
return value === undefined ? match : String(value);
});
}
+50 -25
View File
@@ -5,6 +5,7 @@ import {
validateSdkPath,
type SdkValidation,
} from "./sdk";
import { t } from "./localize";
/**
* Non-intrusive SDK path guidance: a status-bar hint plus a one-time prompt
@@ -32,7 +33,7 @@ export class SdkSetup {
const ws = this.getWs();
if (!ws) {
void vscode.window.showInformationMessage(
"RA3 Mod XML: 打开 RA3 Mod 项目后即可配置 SDK 路径。",
t("RA3 Mod XML: open an RA3 mod project first to configure the SDK path."),
);
return;
}
@@ -79,26 +80,35 @@ export class SdkSetup {
(detectedValidation.status === "ok" ||
detectedValidation.status === "partial")
) {
const useDetected = t("Use detected path");
const chooseManually = t("Choose manually…");
const notNow = t("Not now");
const pick = await vscode.window.showWarningMessage(
`RA3 Mod XML 未找到有效的 SDK 路径。检测到已安装的 SDK:${detectedValidation.path}`,
"使用检测到的路径",
"手动选择…",
"暂时不用",
t(
"RA3 Mod XML could not find a valid SDK path. Detected installed SDK: {0}",
detectedValidation.path,
),
useDetected,
chooseManually,
notNow,
);
if (pick === "使用检测到的路径") {
if (pick === useDetected) {
await applySdkPath(detectedValidation.path);
} else if (pick === "手动选择…") {
} else if (pick === chooseManually) {
await this.chooseAndApply();
}
return;
}
const chooseSdkFolder = t("Choose SDK folder…");
const pick = await vscode.window.showWarningMessage(
"RA3 Mod XML 需要 RA3 Mod SDK 路径才能启用原版数据、manifest 与跨文件补全/跳转功能。未设置时插件将以项目模式运行。",
"选择 SDK 文件夹…",
"暂时不用",
t(
"RA3 Mod XML needs the RA3 Mod SDK path to enable vanilla data, manifests and cross-file completion/navigation. Without it, the extension runs in project-only mode.",
),
chooseSdkFolder,
t("Not now"),
);
if (pick === "选择 SDK 文件夹…") await this.chooseAndApply();
if (pick === chooseSdkFolder) await this.chooseAndApply();
}
private async chooseAndApply(): Promise<void> {
@@ -106,17 +116,20 @@ export class SdkSetup {
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: "选择 SDK 根目录",
title: "选择 RA3 Mod SDK 根目录(应包含 Schemas/xsd/CnC3Types.xsd",
openLabel: t("Choose SDK root"),
title: t(
"Choose the RA3 Mod SDK root (should contain Schemas/xsd/CnC3Types.xsd)",
),
});
const dir = picked?.[0]?.fsPath;
if (!dir) return;
const validation = validateSdkPath(dir);
if (validation.status === "missing" || validation.status === "not-sdk") {
void vscode.window.showErrorMessage(
`所选目录不是可用的 RA3 Mod SDK(缺少 ${
validation.missing.join("、") || "该目录"
})。请重新选择。`,
t(
"The selected directory is not a usable RA3 Mod SDK (missing {0}). Please choose again.",
validation.missing.join(", ") || t("that directory"),
),
);
return;
}
@@ -127,23 +140,35 @@ export class SdkSetup {
function statusBarText(validation: SdkValidation): string {
if (validation.status === "missing") {
return validation.path
? "$(warning) RA3 XML: SDK 路径不存在"
: "$(warning) RA3 XML: 未设置 SDK";
? t("$(warning) RA3 XML: SDK path does not exist")
: t("$(warning) RA3 XML: SDK not configured");
}
if (validation.status === "not-sdk") return "$(warning) RA3 XML: SDK 路径无效";
return "$(warning) RA3 XML: SDK 不完整";
if (validation.status === "not-sdk") {
return t("$(warning) RA3 XML: SDK path is invalid");
}
return t("$(warning) RA3 XML: SDK is incomplete");
}
function describeSdkValidation(validation: SdkValidation): string {
if (validation.status === "missing") {
return validation.path
? `ra3modxml.sdkPath 指向的目录不存在:${validation.path}。点击重新设置;或将 ra3modxml.sdkPath 清空以禁用原版数据功能。`
: "未配置 RA3 Mod SDK 路径。点击设置;或将 ra3modxml.sdkPath 清空以禁用原版数据功能。";
? t(
"The directory configured in ra3modxml.sdkPath does not exist: {0}. Click to reconfigure, or clear ra3modxml.sdkPath to disable vanilla data features.",
validation.path,
)
: t(
"RA3 Mod SDK path is not configured. Click to set it, or clear ra3modxml.sdkPath to disable vanilla data features.",
);
}
if (validation.status === "not-sdk") {
return "ra3modxml.sdkPath 指向的目录不是 RA3 Mod SDK 根目录(缺少 Schemas/xsd/CnC3Types.xsd)。点击重新设置。";
return t(
"The directory configured in ra3modxml.sdkPath is not an RA3 Mod SDK root (missing Schemas/xsd/CnC3Types.xsd). Click to reconfigure.",
);
}
return `RA3 Mod SDK 缺少:${validation.missing.join("、")}。manifest / 原版源码 / SDK 搜索路径等功能不可用。`;
return t(
"RA3 Mod SDK is missing: {0}. Manifest / vanilla source / SDK search path features are unavailable.",
validation.missing.join(", "),
);
}
function isExplicitlyConfigured(
@@ -163,6 +188,6 @@ async function applySdkPath(path: string): Promise<void> {
.getConfiguration("ra3modxml")
.update("sdkPath", path, vscode.ConfigurationTarget.Global);
void vscode.window.showInformationMessage(
`RA3 Mod XML: SDK 路径已设置为 ${path},正在重建索引…`,
t("RA3 Mod XML: SDK path set to {0}; rebuilding the index…", path),
);
}
+99 -28
View File
@@ -38,6 +38,7 @@ import {
findProjectRootForFile,
findProjectRootUpward,
} from "./projectRoot";
import { t } from "./localize";
const REBUILD_DEBOUNCE_MS = 1500;
/** Log a disk-cache validation progress line every N validated records. */
@@ -463,7 +464,7 @@ export class ModWorkspace {
// stamp matches the current disk state, otherwise the fast build
// could publish an index built from stale records.
const pendingArt = await this.seedRecordsFromDisk(state);
this.statusBar.text = "$(sync~spin) RA3 XML: indexing…";
this.statusBar.text = t("$(sync~spin) RA3 XML: indexing…");
this.statusBar.show();
const indexer = new ModIndexer({
projectDir: state.root,
@@ -488,7 +489,7 @@ export class ModWorkspace {
// records before phase B so it can trust the cache instead of
// re-scanning 2.6 GB of models.
if (!phaseIndex.complete && pendingArt.length) {
await this.validateAndSeedCache(state, pendingArt, "art cache");
await this.validateAndSeedCache(state, pendingArt, t("art cache"));
}
});
this.publishIndex(state, finalIndex, epochAtStart);
@@ -504,12 +505,11 @@ export class ModWorkspace {
// Keep the last good snapshot (marked stale) instead of disabling
// the extension entirely; a later rebuild can recover.
state.index.stale = true;
this.statusBar.text =
"$(error) RA3 XML: indexing failed (stale index kept)";
this.statusBar.text = t("$(error) RA3 XML: indexing failed (stale index kept)");
this.statusBar.tooltip = err instanceof Error ? err.message : String(err);
this.onIndexUpdate?.();
} else {
this.statusBar.text = "$(error) RA3 XML: indexing failed";
this.statusBar.text = t("$(error) RA3 XML: indexing failed");
this.statusBar.tooltip = err instanceof Error ? err.message : String(err);
}
} finally {
@@ -612,7 +612,12 @@ export class ModWorkspace {
const start = Date.now();
let lastLogCount = 0;
const onProgress = (done: number): void => {
this.statusBar.text = `$(sync~spin) RA3 XML: validating ${label} ${done}/${total}`;
this.statusBar.text = t(
"$(sync~spin) RA3 XML: validating {0} {1}/{2}…",
label,
done,
total,
);
this.statusBar.show();
if (done - lastLogCount >= CACHE_PROGRESS_LOG_EVERY) {
lastLogCount = done;
@@ -699,31 +704,66 @@ export class ModWorkspace {
/** Human-readable cache status for the `ra3modxml.showCacheReport` command. */
async cacheReport(): Promise<string> {
const lines: string[] = ["RA3 Mod XML cache report"];
lines.push(`Projects: ${this.states.size}`);
const lines: string[] = [t("RA3 Mod XML cache report")];
lines.push(t("Projects: {0}", this.states.size));
for (const st of this.states.values()) {
lines.push(`Project: ${st.root}`);
lines.push(` builds: #${st.buildCount} (last trigger: ${st.lastTrigger})`);
lines.push(` disk cache: ${st.diskCache?.path ?? "not loaded"}`);
lines.push(t("Project: {0}", st.root));
lines.push(
t(" builds: #{0} (last trigger: {1})", st.buildCount, st.lastTrigger),
);
lines.push(t(" disk cache: {0}", st.diskCache?.path ?? t("not loaded")));
if (st.diskCache) {
const status = await st.diskCache.status();
lines.push(
` file: ${status?.exists ? `${(status.sizeBytes / 1024).toFixed(1)} KB` : "missing"}`,
t(
" file: {0}",
status?.exists
? t("{0} KB", (status.sizeBytes / 1024).toFixed(1))
: t("missing"),
),
);
lines.push(
` last load: file=${st.diskCacheStats.fileExists} keyMatched=${st.diskCacheStats.keyMatched} loaded=${st.diskCacheStats.loaded} validated=${st.diskCacheStats.validated} dropped=${st.diskCacheStats.dropped} (load ${st.diskCacheStats.loadMs}ms, validate ${st.diskCacheStats.validateMs}ms)`,
t(
" last load: file={0} keyMatched={1} loaded={2} validated={3} dropped={4} (load {5}ms, validate {6}ms)",
st.diskCacheStats.fileExists,
st.diskCacheStats.keyMatched,
st.diskCacheStats.loaded,
st.diskCacheStats.validated,
st.diskCacheStats.dropped,
st.diskCacheStats.loadMs,
st.diskCacheStats.validateMs,
),
);
lines.push(
t(
" saved after last build: {0}",
st.diskSaved ? t("yes") : t("no"),
),
);
lines.push(` saved after last build: ${st.diskSaved}`);
}
if (st.index) {
const s = st.index.stats;
lines.push(
` last build: phase=${s.phase} assets=${s.assetCount} snapshotHits=${s.snapshotHits} snapshotFallbacks=${s.snapshotFallbacks} recordsCacheHits=${s.recordsCacheHits} shallowCacheHits=${s.shallowCacheHits}`,
t(
" last build: phase={0} assets={1} snapshotHits={2} snapshotFallbacks={3} recordsCacheHits={4} shallowCacheHits={5}",
s.phase,
s.assetCount,
s.snapshotHits,
s.snapshotFallbacks,
s.recordsCacheHits,
s.shallowCacheHits,
),
);
}
}
lines.push(
`Shared in-memory: ${this.recordsCache.size} record entries · ${this.documentCache.size} documents (${this.documentCache.elements} elements) · ${this.resolveCache.size} include resolutions`,
t(
"Shared in-memory: {0} record entries · {1} documents ({2} elements) · {3} include resolutions",
this.recordsCache.size,
this.documentCache.size,
this.documentCache.elements,
this.resolveCache.size,
),
);
return lines.join("\n");
}
@@ -760,14 +800,17 @@ export class ModWorkspace {
}
const building = [...this.states.values()].find((s) => s.building);
if (building) {
this.statusBar.text = "$(sync~spin) RA3 XML: indexing…";
this.statusBar.text = t("$(sync~spin) RA3 XML: indexing…");
this.statusBar.show();
return;
}
const st = this.activeState();
const idx = st?.index;
if (!idx || !st) {
this.statusBar.text = `$(symbol-misc) RA3 XML: ${this.states.size} project(s) — open a mod XML to index`;
this.statusBar.text = t(
"$(symbol-misc) RA3 XML: {0} project(s) — open a mod XML to index",
this.states.size,
);
this.statusBar.tooltip = [...this.states.values()]
.map((s) => s.root)
.join("\n");
@@ -775,16 +818,44 @@ export class ModWorkspace {
return;
}
const s = idx.stats;
const stale = idx.stale ? " (stale)" : "";
this.statusBar.text = `$(symbol-misc) RA3 XML: ${basename(st.root)} · ${formatCount(s.assetCount)} assets${stale}`;
this.statusBar.tooltip =
`${st.root}\n` +
`${s.indexedFiles} files indexed (${s.parsedFiles} parsed, ${s.shallowScannedFiles} art assets shallow-scanned, ${(s.elapsedMs / 1000).toFixed(1)}s)\n` +
`${s.assetCount} assets (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` +
`${s.referenceCount} reference sites\n` +
`${s.defineCount} defines, ${s.streams} streams, ${s.sourceCandidates} include candidates\n` +
`Phase: ${s.phase} · Complete: ${s.complete}${stale}\n` +
`Disk cache: load ${(st.diskCacheStats.loadMs / 1000).toFixed(1)}s, validate ${(st.diskCacheStats.validateMs / 1000).toFixed(1)}s (${st.diskCacheStats.validated}/${st.diskCacheStats.loaded} ok)`;
const stale = idx.stale ? ` ${t("(stale)")}` : "";
this.statusBar.text = t(
"$(symbol-misc) RA3 XML: {0} · {1} assets{2}",
basename(st.root),
formatCount(s.assetCount),
stale,
);
this.statusBar.tooltip = [
st.root,
t(
"{0} files indexed ({1} parsed, {2} art assets shallow-scanned, {3}s)",
s.indexedFiles,
s.parsedFiles,
s.shallowScannedFiles,
(s.elapsedMs / 1000).toFixed(1),
),
t(
"{0} assets ({1} from {2} manifests)",
s.assetCount,
s.manifestAssetCount,
s.manifestFiles,
),
t("{0} reference sites", s.referenceCount),
t(
"{0} defines, {1} streams, {2} include candidates",
s.defineCount,
s.streams,
s.sourceCandidates,
),
t("Phase: {0} · Complete: {1}{2}", s.phase, s.complete, stale),
t(
"Disk cache: load {0}s, validate {1}s ({2}/{3} ok)",
(st.diskCacheStats.loadMs / 1000).toFixed(1),
(st.diskCacheStats.validateMs / 1000).toFixed(1),
st.diskCacheStats.validated,
st.diskCacheStats.loaded,
),
].join("\n");
this.statusBar.show();
}