improve codelens
This commit is contained in:
@@ -8,6 +8,12 @@ import {
|
||||
Ra3DocumentSymbolProvider,
|
||||
Ra3ReferenceProvider,
|
||||
} from "./features/navigation";
|
||||
import { Ra3CodeLensProvider } from "./features/codeLens";
|
||||
import { showReferencesForDef } from "./features/references";
|
||||
import {
|
||||
findUnreferencedAssets,
|
||||
findUnreferencedAssetsOfType,
|
||||
} from "./features/unreferenced";
|
||||
import { Ra3Diagnostics } from "./features/diagnostics";
|
||||
import {
|
||||
Ra3SemanticTokensProvider,
|
||||
@@ -60,6 +66,12 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
new Ra3DocumentSymbolProvider(ws),
|
||||
),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerCodeLensProvider(
|
||||
XML_SELECTOR,
|
||||
new Ra3CodeLensProvider(ws),
|
||||
),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerDocumentSemanticTokensProvider(
|
||||
XML_SELECTOR,
|
||||
@@ -73,6 +85,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
// Refresh diagnostics for every open XML document whenever a new index
|
||||
// snapshot is published (XML phase, art phase, stale/final rebuild).
|
||||
ws.onIndexUpdate = () => {
|
||||
void vscode.commands.executeCommand("editor.action.codeLens.refresh");
|
||||
for (const doc of vscode.workspace.textDocuments) {
|
||||
if (doc.languageId === "xml") void diagnostics.update(doc);
|
||||
}
|
||||
@@ -177,6 +190,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
`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` +
|
||||
@@ -186,6 +200,25 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
);
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"ra3modxml.showReferences",
|
||||
(args: Parameters<typeof showReferencesForDef>[1]) =>
|
||||
void showReferencesForDef(ws, args),
|
||||
),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"ra3modxml.findUnreferencedAssets",
|
||||
() => void findUnreferencedAssets(ws),
|
||||
),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"ra3modxml.findUnreferencedAssetsOfType",
|
||||
() => void findUnreferencedAssetsOfType(ws),
|
||||
),
|
||||
);
|
||||
|
||||
void ws.initialize();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as vscode from "vscode";
|
||||
import { LineMap, parseXml } from "../language/xmlParser";
|
||||
import { resolveElementType } from "../language/typeContext";
|
||||
import { isReferenceTargetType } from "../indexer/refs";
|
||||
import {
|
||||
referenceSitesForDefinition,
|
||||
scheduleRebuildIfRecordsDesync,
|
||||
} from "../indexer/referenceIndex";
|
||||
import type { ShowReferencesArgs } from "./references";
|
||||
import type { ModWorkspace } from "../workspace";
|
||||
|
||||
/** Never build a DOM for huge files just to show counts (w3x safety). */
|
||||
const MAX_CODELENS_TEXT = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* CodeLens reference counts on top-level assets.
|
||||
*
|
||||
* Only types that are reference targets by design get a lens (settings, map
|
||||
* metadata, w3x sub-assets etc. would otherwise show a permanent, misleading
|
||||
* "0 references"). Zero is still shown for the meaningful types: that is the
|
||||
* signal users can click to inspect an unused asset.
|
||||
*/
|
||||
export class Ra3CodeLensProvider implements vscode.CodeLensProvider {
|
||||
constructor(private ws: ModWorkspace) {}
|
||||
|
||||
provideCodeLenses(
|
||||
document: vscode.TextDocument,
|
||||
_token: vscode.CancellationToken,
|
||||
): vscode.CodeLens[] {
|
||||
if (!this.ws.isRa3Workspace()) return [];
|
||||
const idx = this.ws.index;
|
||||
if (!idx) return [];
|
||||
const text = document.getText();
|
||||
if (text.length > MAX_CODELENS_TEXT) return [];
|
||||
scheduleRebuildIfRecordsDesync(this.ws, document);
|
||||
const doc = parseXml(text);
|
||||
const root = doc.root;
|
||||
if (!root) return [];
|
||||
const lineMap = new LineMap(text);
|
||||
const lenses: vscode.CodeLens[] = [];
|
||||
|
||||
for (const child of root.children) {
|
||||
const local = localName(child.name);
|
||||
if (local === "Tags" || local === "Includes" || local === "Defines") continue;
|
||||
const idAttr = child.attrs.find((a) => a.name === "id");
|
||||
if (!idAttr?.hasValue) continue;
|
||||
const elType = resolveElementType(child);
|
||||
if (!isReferenceTargetType(elType)) continue;
|
||||
|
||||
const id = idAttr.value;
|
||||
const line = lineMap.positionAt(idAttr.valueStart).line + 1;
|
||||
const count = referenceSitesForDefinition(idx, {
|
||||
type: local,
|
||||
id,
|
||||
file: document.uri.fsPath,
|
||||
line,
|
||||
}).length;
|
||||
const range = new vscode.Range(
|
||||
document.positionAt(child.start),
|
||||
document.positionAt(child.startTagEnd),
|
||||
);
|
||||
const args: ShowReferencesArgs = {
|
||||
uri: document.uri,
|
||||
position: document.positionAt(idAttr.valueStart),
|
||||
id,
|
||||
type: local,
|
||||
file: document.uri.fsPath,
|
||||
line,
|
||||
};
|
||||
lenses.push(
|
||||
new vscode.CodeLens(range, {
|
||||
title:
|
||||
count === 0
|
||||
? "0 references"
|
||||
: count === 1
|
||||
? "1 reference"
|
||||
: `${count} references`,
|
||||
command: "ra3modxml.showReferences",
|
||||
arguments: [args],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return lenses;
|
||||
}
|
||||
}
|
||||
|
||||
function localName(tag: string): string {
|
||||
const idx = tag.lastIndexOf(":");
|
||||
return idx >= 0 ? tag.slice(idx + 1) : tag;
|
||||
}
|
||||
+14
-87
@@ -14,6 +14,7 @@ import {
|
||||
resolveReferenceTargetsForType,
|
||||
type ReferenceTarget,
|
||||
} from "../indexer/refs";
|
||||
import { findReferenceLocations } from "./references";
|
||||
import {
|
||||
findContainingGameObject,
|
||||
findLocalId,
|
||||
@@ -163,6 +164,18 @@ async function assetDefLocation(
|
||||
scope: DocumentScope,
|
||||
currentDocument: vscode.TextDocument,
|
||||
): Promise<vscode.Location | null> {
|
||||
// While a rebuild is running, avoid readDom() mutating the live indexer's
|
||||
// caches mid-build; a line-based location is a fine temporary fallback.
|
||||
if (ws.isBuilding) {
|
||||
const line = Math.max(0, def.line - 1);
|
||||
return new vscode.Location(
|
||||
vscode.Uri.file(def.file),
|
||||
new vscode.Range(
|
||||
new vscode.Position(line, 0),
|
||||
new vscode.Position(line, 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (def.origin === "manifest") {
|
||||
const src = def.manifestSource;
|
||||
if (src?.toUpperCase().startsWith("DATA:")) {
|
||||
@@ -278,56 +291,7 @@ export class Ra3ReferenceProvider implements vscode.ReferenceProvider {
|
||||
_context: vscode.ReferenceContext,
|
||||
_token: vscode.CancellationToken,
|
||||
): Promise<vscode.Location[] | null> {
|
||||
if (!this.ws.isRa3Workspace()) return null;
|
||||
const text = document.getText();
|
||||
const offset = document.offsetAt(position);
|
||||
const doc = parseXml(text);
|
||||
const el = findElementAt(doc, offset);
|
||||
if (!el) return null;
|
||||
const attr = el.attrs.find(
|
||||
(a) =>
|
||||
(a.hasValue && offset >= a.valueStart && offset <= a.valueEnd) ||
|
||||
(offset >= a.nameStart && offset <= a.nameEnd),
|
||||
);
|
||||
let id: string | null = null;
|
||||
if (attr?.hasValue) {
|
||||
id = attr.value;
|
||||
} else {
|
||||
// Element text content (e.g. <CreateObject>CrateDebris_01</CreateObject>).
|
||||
const elType = resolveElementType(el);
|
||||
const token = textContentTokenAt(text, el, offset);
|
||||
if (token && isReferenceContentType(elType) && !token.value.startsWith("$")) {
|
||||
id = token.value;
|
||||
}
|
||||
}
|
||||
if (!id || id.startsWith("$")) return null;
|
||||
|
||||
const locations: vscode.Location[] = [];
|
||||
// Matches both attribute values ("id" / 'id') and simple-content
|
||||
// references (>id<); the outer delimiters are stripped from the result
|
||||
// range below so the returned locations cover just the id.
|
||||
const pattern = `(?:["']|>)[ \\t]*${escapeRegExp(id)}[ \\t]*(?:["']|<)`;
|
||||
await findTextInWorkspace(
|
||||
{ pattern, isRegExp: true },
|
||||
{ include: "**/*.xml", maxResults: 2000 },
|
||||
(result: { uri: vscode.Uri; matches: { range: vscode.Range }[] }) => {
|
||||
if (!result.uri) return;
|
||||
for (const m of result.matches) {
|
||||
const start = m.range.start;
|
||||
const end = m.range.end;
|
||||
locations.push(
|
||||
new vscode.Location(
|
||||
result.uri,
|
||||
new vscode.Range(
|
||||
new vscode.Position(start.line, start.character + 1),
|
||||
new vscode.Position(end.line, end.character - 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
return locations.length ? locations : null;
|
||||
return findReferenceLocations(this.ws, document, position);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,40 +399,3 @@ function localName(tag: string): string {
|
||||
const idx = tag.lastIndexOf(":");
|
||||
return idx >= 0 ? tag.slice(idx + 1) : tag;
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* `workspace.findTextInFiles` is a stable VS Code API (since 1.66) but is
|
||||
* missing from the published typings, so we declare the subset we need and
|
||||
* call it via a safe cast.
|
||||
*/
|
||||
interface TextSearchQuery {
|
||||
pattern: string;
|
||||
isRegExp?: boolean;
|
||||
isCaseSensitive?: boolean;
|
||||
isWordMatch?: boolean;
|
||||
}
|
||||
|
||||
interface TextSearchOptions {
|
||||
include?: string;
|
||||
exclude?: string;
|
||||
maxResults?: number;
|
||||
}
|
||||
|
||||
function findTextInWorkspace(
|
||||
query: TextSearchQuery,
|
||||
options: TextSearchOptions,
|
||||
callback: (result: { uri: vscode.Uri; matches: { range: vscode.Range }[] }) => void,
|
||||
): Promise<void> {
|
||||
const api = vscode.workspace as unknown as {
|
||||
findTextInFiles(
|
||||
query: TextSearchQuery,
|
||||
options: TextSearchOptions,
|
||||
callback: (result: { uri: vscode.Uri; matches: { range: vscode.Range }[] }) => void,
|
||||
): Promise<unknown>;
|
||||
};
|
||||
return api.findTextInFiles(query, options, callback).then(() => undefined);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Shared semantic reference logic used by Find All References, the CodeLens
|
||||
* "N references" command and (indirectly) the unreferenced-assets report.
|
||||
*
|
||||
* Unlike the old text-search implementation, every result here comes from
|
||||
* the reverse reference index built during indexing, so the count shown on a
|
||||
* top-level asset always matches the references peek opened by clicking it.
|
||||
*/
|
||||
|
||||
import * as vscode from "vscode";
|
||||
import {
|
||||
findElementAt,
|
||||
parseXml,
|
||||
textContentTokenAt,
|
||||
} from "../language/xmlParser";
|
||||
import { resolveElementType } from "../language/typeContext";
|
||||
import { attributesOfType, typeInfo } from "../model/schemaModel";
|
||||
import {
|
||||
filterAndScoreDefs,
|
||||
isReferenceAttributeOfType,
|
||||
isReferenceContentType,
|
||||
mergeLocalAndGlobalDefs,
|
||||
} from "../indexer/refs";
|
||||
import {
|
||||
referenceSitesForDef,
|
||||
scheduleRebuildIfRecordsDesync,
|
||||
} from "../indexer/referenceIndex";
|
||||
import type { AssetDef, ModIndex, ReferenceSite } from "../indexer/types";
|
||||
import type { ModWorkspace } from "../workspace";
|
||||
|
||||
export interface ReferenceContext {
|
||||
id: string;
|
||||
/** XSD refType when the cursor is on a typed reference; null otherwise. */
|
||||
refType: string | null;
|
||||
/** Element type for inheritFrom filtering; null otherwise. */
|
||||
selfType: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the referenced id and its XSD context from the cursor position.
|
||||
* Works on reference attribute values/names, simple-content text and on an
|
||||
* asset's own `id` definition (where every same-id definition is a target).
|
||||
*/
|
||||
export function referenceContextAt(
|
||||
document: vscode.TextDocument,
|
||||
offset: number,
|
||||
): ReferenceContext | null {
|
||||
const text = document.getText();
|
||||
const doc = parseXml(text);
|
||||
const el = findElementAt(doc, offset);
|
||||
if (!el) return null;
|
||||
const elType = resolveElementType(el);
|
||||
const attr = el.attrs.find(
|
||||
(a) =>
|
||||
(a.hasValue && offset >= a.valueStart && offset <= a.valueEnd) ||
|
||||
(offset >= a.nameStart && offset <= a.nameEnd),
|
||||
);
|
||||
|
||||
if (attr?.hasValue) {
|
||||
const value = attr.value;
|
||||
if (!value || value.startsWith("$") || value.startsWith("=")) return null;
|
||||
const nameLower = attr.name.toLowerCase();
|
||||
if (nameLower === "id") {
|
||||
return { id: value, refType: null, selfType: null };
|
||||
}
|
||||
if (!isReferenceAttributeOfType(elType, attr.name)) return null;
|
||||
if (nameLower === "inheritfrom") {
|
||||
return { id: value, refType: null, selfType: elType };
|
||||
}
|
||||
const attrInfo = elType
|
||||
? attributesOfType(elType).find((a) => a.name === attr.name)
|
||||
: undefined;
|
||||
return { id: value, refType: attrInfo?.refType ?? null, selfType: null };
|
||||
}
|
||||
|
||||
if (elType && isReferenceContentType(elType)) {
|
||||
const token = textContentTokenAt(text, el, offset);
|
||||
if (token && !token.value.startsWith("$") && !token.value.startsWith("=")) {
|
||||
const info = typeInfo(elType);
|
||||
return {
|
||||
id: token.value,
|
||||
refType: info?.kind === "simple" ? info.refType : null,
|
||||
selfType: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Definitions matching a reference context (strict type filtering). */
|
||||
export function definitionsForReference(
|
||||
idx: ModIndex,
|
||||
ctx: ReferenceContext,
|
||||
): AssetDef[] {
|
||||
const defs = mergeLocalAndGlobalDefs(
|
||||
idx.local?.assetsById.get(ctx.id.toLowerCase()),
|
||||
idx.assetsById.get(ctx.id.toLowerCase()),
|
||||
);
|
||||
return filterAndScoreDefs(defs, ctx.refType, ctx.selfType).map((t) => t.def);
|
||||
}
|
||||
|
||||
/** Union of reference sites for a set of definitions, de-duplicated. */
|
||||
export function collectReferenceSites(
|
||||
idx: ModIndex,
|
||||
defs: readonly AssetDef[],
|
||||
): ReferenceSite[] {
|
||||
const sites: ReferenceSite[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const def of defs) {
|
||||
for (const site of referenceSitesForDef(idx, def)) {
|
||||
const key = `${site.file}\u0000${site.start}\u0000${site.end}\u0000${site.kind}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
sites.push(site);
|
||||
}
|
||||
}
|
||||
return sites;
|
||||
}
|
||||
|
||||
/** Converts stored offsets to precise editor locations (fallback: line). */
|
||||
export async function sitesToLocations(
|
||||
ws: ModWorkspace,
|
||||
sites: readonly ReferenceSite[],
|
||||
): Promise<vscode.Location[]> {
|
||||
const byFile = new Map<string, ReferenceSite[]>();
|
||||
for (const site of sites) {
|
||||
let list = byFile.get(site.file);
|
||||
if (!list) {
|
||||
list = [];
|
||||
byFile.set(site.file, list);
|
||||
}
|
||||
list.push(site);
|
||||
}
|
||||
|
||||
const locations: vscode.Location[] = [];
|
||||
for (const [file, fileSites] of byFile) {
|
||||
const parsed = await ws.indexer?.readDom(file);
|
||||
const lineMap = parsed?.lineMap ?? null;
|
||||
for (const site of fileSites) {
|
||||
if (lineMap) {
|
||||
const start = lineMap.positionAt(site.start);
|
||||
const end = lineMap.positionAt(site.end);
|
||||
locations.push(
|
||||
new vscode.Location(
|
||||
vscode.Uri.file(file),
|
||||
new vscode.Range(
|
||||
new vscode.Position(start.line, start.character),
|
||||
new vscode.Position(end.line, end.character),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const line = Math.max(0, site.line - 1);
|
||||
locations.push(
|
||||
new vscode.Location(
|
||||
vscode.Uri.file(file),
|
||||
new vscode.Range(
|
||||
new vscode.Position(line, 0),
|
||||
new vscode.Position(line, 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return locations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic Find All References (used by the reference provider).
|
||||
*
|
||||
* Only real reference sites are returned — the asset's own `id` definition is
|
||||
* never included, regardless of VS Code's `includeDeclaration` flag, so the
|
||||
* result set matches the CodeLens reference count exactly.
|
||||
*/
|
||||
export async function findReferenceLocations(
|
||||
ws: ModWorkspace,
|
||||
document: vscode.TextDocument,
|
||||
position: vscode.Position,
|
||||
): Promise<vscode.Location[] | null> {
|
||||
if (!ws.isRa3Workspace()) return null;
|
||||
scheduleRebuildIfRecordsDesync(ws, document);
|
||||
const scope = await ws.getScope(document);
|
||||
const idx = scope.merged;
|
||||
if (!idx) return null;
|
||||
const ctx = referenceContextAt(document, document.offsetAt(position));
|
||||
if (!ctx) return null;
|
||||
const defs = definitionsForReference(idx, ctx);
|
||||
if (!defs.length) return null;
|
||||
|
||||
const locations = await sitesToLocations(ws, collectReferenceSites(idx, defs));
|
||||
return locations.length ? locations : null;
|
||||
}
|
||||
|
||||
/** Arguments passed from the CodeLens to the showReferences command. */
|
||||
export interface ShowReferencesArgs {
|
||||
uri: vscode.Uri;
|
||||
position: vscode.Position;
|
||||
id: string;
|
||||
type: string;
|
||||
file: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
/** Opens the references peek for one specific asset definition. */
|
||||
export async function showReferencesForDef(
|
||||
ws: ModWorkspace,
|
||||
args: ShowReferencesArgs,
|
||||
): Promise<void> {
|
||||
const idx = ws.index;
|
||||
if (!idx) return;
|
||||
const def: AssetDef = {
|
||||
type: args.type,
|
||||
id: args.id,
|
||||
file: args.file,
|
||||
line: args.line,
|
||||
origin: "project",
|
||||
};
|
||||
const sites = referenceSitesForDef(idx, def);
|
||||
const locations = await sitesToLocations(ws, sites);
|
||||
await vscode.commands.executeCommand(
|
||||
"editor.action.showReferences",
|
||||
args.uri,
|
||||
args.position,
|
||||
locations,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import * as vscode from "vscode";
|
||||
import { relative } from "node:path";
|
||||
import { findElementAt, parseXml } from "../language/xmlParser";
|
||||
import { unreferencedByType } from "../indexer/referenceIndex";
|
||||
import type { ModWorkspace } from "../workspace";
|
||||
|
||||
interface TypePickItem extends vscode.QuickPickItem {
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface AssetPickItem extends vscode.QuickPickItem {
|
||||
file: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Palette command: pick an asset type, then jump to any project asset of
|
||||
* that type that has zero incoming references. Only types that are reference
|
||||
* targets by design are offered, so auto-registered data (settings, map
|
||||
* metadata, w3x sub-assets...) is not reported as "unused".
|
||||
*/
|
||||
export async function findUnreferencedAssets(
|
||||
ws: ModWorkspace,
|
||||
args?: { type?: string },
|
||||
): Promise<void> {
|
||||
if (!ws.isRa3Workspace() || !ws.index) {
|
||||
void vscode.window.showInformationMessage(
|
||||
"RA3 Mod XML: no index available yet.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const idx = ws.index;
|
||||
const byType = unreferencedByType(idx);
|
||||
|
||||
let type = args?.type;
|
||||
if (!type) {
|
||||
if (!byType.size) {
|
||||
void vscode.window.showInformationMessage(
|
||||
"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,
|
||||
})),
|
||||
{
|
||||
placeHolder: "Select an asset type",
|
||||
matchOnDescription: true,
|
||||
},
|
||||
);
|
||||
if (!pickedType) return;
|
||||
type = pickedType.type;
|
||||
}
|
||||
|
||||
const defs = byType.get(type) ?? [];
|
||||
if (!defs.length) {
|
||||
void vscode.window.showInformationMessage(
|
||||
`RA3 Mod XML: no unreferenced ${type} assets found.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pickedAsset = await vscode.window.showQuickPick<AssetPickItem>(
|
||||
defs.map((d) => ({
|
||||
label: d.id,
|
||||
description: `${displayPath(idx.projectDir, d.file)}:${d.line}`,
|
||||
file: d.file,
|
||||
line: d.line,
|
||||
})),
|
||||
{
|
||||
placeHolder: `${type}: ${defs.length} unreferenced`,
|
||||
matchOnDescription: true,
|
||||
},
|
||||
);
|
||||
if (!pickedAsset) return;
|
||||
|
||||
const uri = vscode.Uri.file(pickedAsset.file);
|
||||
const document = await vscode.workspace.openTextDocument(uri);
|
||||
const line = Math.max(0, pickedAsset.line - 1);
|
||||
await vscode.window.showTextDocument(document, {
|
||||
selection: new vscode.Range(
|
||||
new vscode.Position(line, 0),
|
||||
new vscode.Position(line, 1),
|
||||
),
|
||||
preview: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor context-menu entry: pre-selects the asset type under the cursor.
|
||||
* Falls back to the type picker when the cursor is not on a top-level asset.
|
||||
*/
|
||||
export async function findUnreferencedAssetsOfType(
|
||||
ws: ModWorkspace,
|
||||
): Promise<void> {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (editor && ws.isRa3Workspace()) {
|
||||
const document = editor.document;
|
||||
const offset = document.offsetAt(editor.selection.active);
|
||||
const doc = parseXml(document.getText());
|
||||
const el = findElementAt(doc, offset);
|
||||
if (el && el.parent === doc.root) {
|
||||
const local = localName(el.name);
|
||||
const isStructural = local === "Tags" || local === "Includes" || local === "Defines";
|
||||
const hasId = el.attrs.some((a) => a.name === "id" && a.hasValue);
|
||||
if (!isStructural && hasId) {
|
||||
return findUnreferencedAssets(ws, { type: local });
|
||||
}
|
||||
}
|
||||
}
|
||||
return findUnreferencedAssets(ws);
|
||||
}
|
||||
|
||||
function localName(tag: string): string {
|
||||
const idx = tag.lastIndexOf(":");
|
||||
return idx >= 0 ? tag.slice(idx + 1) : tag;
|
||||
}
|
||||
|
||||
function displayPath(projectDir: string, file: string): string {
|
||||
const rel = relative(projectDir, file);
|
||||
return rel && !rel.startsWith("..") ? rel : file;
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { resolve } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { IndexedFile, ParsedFile } from "./types";
|
||||
import type { IndexRecords } from "./records";
|
||||
import type { ResolveResult } from "./includeResolver";
|
||||
@@ -19,6 +20,28 @@ export function normKey(path: string): string {
|
||||
return resolve(path).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-1 of the BOM-stripped text a file's records were extracted from.
|
||||
* Lets force rebuilds verify that a stat-matching cache entry is not stale
|
||||
* (FAT32/exFAT timestamps can match after a rewrite), and lets features
|
||||
* detect "open document != indexed snapshot" without reading the whole index.
|
||||
*/
|
||||
export function contentHash(text: string): string {
|
||||
return createHash("sha1").update(text, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash of a file's compact index records. Semantic records (assets, defines,
|
||||
* includes, references) are what the index actually consumes, so comparing
|
||||
* records hashes ignores cosmetic text changes (line endings, whitespace)
|
||||
* and only fires the self-heal when the index would really be out of date.
|
||||
*/
|
||||
export function recordsHash(records: IndexRecords): string {
|
||||
return createHash("sha1")
|
||||
.update(JSON.stringify(records), "utf8")
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* LRU cache for fully parsed XML documents.
|
||||
*
|
||||
@@ -119,6 +142,12 @@ export interface IndexRecordsCacheEntry {
|
||||
records: IndexRecords;
|
||||
/** "shallow" for art-asset scans (.w3x), "full" for parsed XML. */
|
||||
kind: "shallow" | "full";
|
||||
/**
|
||||
* Hash of the BOM-stripped file text (full parses only). Absent for
|
||||
* shallow scans (avoid hashing multi-MB model files) and for cache entries
|
||||
* produced before this field existed.
|
||||
*/
|
||||
contentHash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,7 +34,14 @@ import type { IndexedFile } from "./types";
|
||||
const gzipAsync = promisify(gzip);
|
||||
const gunzipAsync = promisify(gunzip);
|
||||
|
||||
export const DISK_CACHE_VERSION = 1;
|
||||
/**
|
||||
* v2: per-file records now carry typed reference records (`references`),
|
||||
* so caches produced by v1 (assets/defines/includes only) are stale.
|
||||
* v3: full XML records carry `contentHash`, and snapshots publish per-file
|
||||
* `recordsHashes` for the desync self-heal; caches without hashes cannot be
|
||||
* verified, so v2 files are regenerated once.
|
||||
*/
|
||||
export const DISK_CACHE_VERSION = 3;
|
||||
/** How many stat validations run concurrently on load. */
|
||||
const VALIDATE_CONCURRENCY = 32;
|
||||
|
||||
@@ -53,6 +60,8 @@ export interface DiskCacheRecord {
|
||||
stat: NonNullable<IndexedFile["stat"]>;
|
||||
records: IndexRecords;
|
||||
kind: "full" | "shallow";
|
||||
/** Content hash for full XML parses (see `IndexRecordsCacheEntry`). */
|
||||
contentHash?: string;
|
||||
}
|
||||
|
||||
interface DiskCacheFile {
|
||||
@@ -173,6 +182,7 @@ export class DiskRecordsCache {
|
||||
stat: entry.stat,
|
||||
records: entry.records,
|
||||
kind: entry.kind,
|
||||
contentHash: entry.contentHash,
|
||||
});
|
||||
}
|
||||
const payload: DiskCacheFile = {
|
||||
|
||||
+120
-43
@@ -40,14 +40,26 @@ import {
|
||||
} from "./manifestParser";
|
||||
import { canonicalTypeName } from "../model/schemaModel";
|
||||
import { collectSourceCandidates } from "./fileScanner";
|
||||
import { DocumentCache, IncludeResolveCache, IndexRecordsCache, normKey } from "./caches";
|
||||
import {
|
||||
contentHash,
|
||||
DocumentCache,
|
||||
IncludeResolveCache,
|
||||
IndexRecordsCache,
|
||||
normKey,
|
||||
recordsHash,
|
||||
} from "./caches";
|
||||
import type { IndexRecordsCacheEntry } from "./caches";
|
||||
import { scanXmlShallow } from "./shallowScan";
|
||||
import {
|
||||
extractIndexRecords,
|
||||
recordsFromShallow,
|
||||
type IndexRecords,
|
||||
type IndexRecordXi,
|
||||
} from "./records";
|
||||
import {
|
||||
buildReferenceIndex,
|
||||
type ReferenceRecordSource,
|
||||
} from "./referenceIndex";
|
||||
import type {
|
||||
AssetDef,
|
||||
DefineDef,
|
||||
@@ -55,6 +67,7 @@ import type {
|
||||
IndexedFile,
|
||||
ModIndex,
|
||||
ParsedFile,
|
||||
ReferenceSite,
|
||||
SourceCandidate,
|
||||
StreamInfo,
|
||||
} from "./types";
|
||||
@@ -110,6 +123,16 @@ export class ModIndexer {
|
||||
private assetsById = new Map<string, AssetDef[]>();
|
||||
private defines = new Map<string, DefineDef[]>();
|
||||
private files = new Map<string, IndexedFile>();
|
||||
/**
|
||||
* Records exactly as the current build's walk saw them (path -> records +
|
||||
* content hash). The reverse reference index is built from this map, never
|
||||
* from the shared records cache, so a watcher invalidation or a feature
|
||||
* re-read (readDom) mid-build cannot desync references from assets.
|
||||
*/
|
||||
private buildRecords = new Map<
|
||||
string,
|
||||
{ file: string; records: IndexRecords; recordsHash: string }
|
||||
>();
|
||||
private streams: StreamInfo[] = [];
|
||||
private manifests = new Map<string, ManifestInfo>();
|
||||
private sourceCandidates: SourceCandidate[] = [];
|
||||
@@ -171,6 +194,20 @@ export class ModIndexer {
|
||||
rec.stat.birthtimeMs === st.birthtimeMs &&
|
||||
rec.stat.ctimeMs === st.ctimeMs
|
||||
) {
|
||||
// Force rebuilds (Re-index workspace) verify full-XML content even
|
||||
// when every stat signal matches: external drives (FAT32/exFAT) can
|
||||
// rewrite a file with the same size and coarse timestamps.
|
||||
if (
|
||||
this.opts.trustUnchanged === false &&
|
||||
rec.kind === "full" &&
|
||||
rec.contentHash
|
||||
) {
|
||||
const text = stripBom(await readFile(path, "utf8"));
|
||||
if (contentHash(text) === rec.contentHash) {
|
||||
return this.recordsParsed(path, rec);
|
||||
}
|
||||
return this.parseFullXml(path, st, text);
|
||||
}
|
||||
return this.recordsParsed(path, rec);
|
||||
}
|
||||
const hit = this.docs.get(key);
|
||||
@@ -235,27 +272,7 @@ export class ModIndexer {
|
||||
return parsed;
|
||||
}
|
||||
const text = stripBom(await readFile(path, "utf8"));
|
||||
const lineMap = new LineMap(text);
|
||||
const parse = parseXml(text);
|
||||
const records = extractIndexRecords(parse, lineMap);
|
||||
const parsed: ParsedFile = {
|
||||
file: {
|
||||
path: resolve(path),
|
||||
stat: {
|
||||
mtimeMs: st.mtimeMs,
|
||||
size: st.size,
|
||||
birthtimeMs: st.birthtimeMs,
|
||||
ctimeMs: st.ctimeMs,
|
||||
},
|
||||
},
|
||||
parse,
|
||||
records,
|
||||
lineMap,
|
||||
};
|
||||
this.docs.set(parsed);
|
||||
this.recordsCache.set(key, { stat: parsed.file.stat, records, kind: "full" });
|
||||
this.files.set(key, parsed.file);
|
||||
return parsed;
|
||||
return this.parseFullXml(path, st, text);
|
||||
} catch {
|
||||
const parsed: ParsedFile = {
|
||||
file: { path: resolve(path), stat: null },
|
||||
@@ -313,6 +330,40 @@ export class ModIndexer {
|
||||
return { file, parse: null, records: entry.records, lineMap: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a full XML document from its text, caches records (with a content
|
||||
* hash) and the DOM, and registers the file in this build.
|
||||
*/
|
||||
private parseFullXml(path: string, st: Stats, text: string): ParsedFile {
|
||||
const key = normKey(path);
|
||||
const lineMap = new LineMap(text);
|
||||
const parse = parseXml(text);
|
||||
const records = extractIndexRecords(parse, lineMap, text);
|
||||
const parsed: ParsedFile = {
|
||||
file: {
|
||||
path: resolve(path),
|
||||
stat: {
|
||||
mtimeMs: st.mtimeMs,
|
||||
size: st.size,
|
||||
birthtimeMs: st.birthtimeMs,
|
||||
ctimeMs: st.ctimeMs,
|
||||
},
|
||||
},
|
||||
parse,
|
||||
records,
|
||||
lineMap,
|
||||
};
|
||||
this.docs.set(parsed);
|
||||
this.recordsCache.set(key, {
|
||||
stat: parsed.file.stat,
|
||||
records,
|
||||
kind: "full",
|
||||
contentHash: contentHash(text),
|
||||
});
|
||||
this.files.set(key, parsed.file);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a document and guarantees a DOM parse tree. Used for root-level
|
||||
* <xi:include> xpointer selection (rare) and by the document-local scope
|
||||
@@ -341,27 +392,7 @@ export class ModIndexer {
|
||||
}
|
||||
if (st.size > MAX_PARSE_BYTES) return null;
|
||||
const text = stripBom(await readFile(path, "utf8"));
|
||||
const lineMap = new LineMap(text);
|
||||
const parse = parseXml(text);
|
||||
const records = extractIndexRecords(parse, lineMap);
|
||||
const parsed: ParsedFile = {
|
||||
file: {
|
||||
path: resolve(path),
|
||||
stat: {
|
||||
mtimeMs: st.mtimeMs,
|
||||
size: st.size,
|
||||
birthtimeMs: st.birthtimeMs,
|
||||
ctimeMs: st.ctimeMs,
|
||||
},
|
||||
},
|
||||
parse,
|
||||
records,
|
||||
lineMap,
|
||||
};
|
||||
this.docs.set(parsed);
|
||||
this.recordsCache.set(key, { stat: parsed.file.stat, records, kind: "full" });
|
||||
this.files.set(key, parsed.file);
|
||||
return parsed;
|
||||
return this.parseFullXml(path, st, text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -424,6 +455,7 @@ export class ModIndexer {
|
||||
|
||||
async build(onPhase?: (index: ModIndex) => void | Promise<void>): Promise<ModIndex> {
|
||||
const start = Date.now();
|
||||
this.buildRecords.clear();
|
||||
// Root list only; directories are listed lazily on first query, so the
|
||||
// XML phase does not pay an upfront recursive enumeration of the SDK.
|
||||
this.existence = buildExistenceSnapshot(this.searchPaths);
|
||||
@@ -561,6 +593,15 @@ export class ModIndexer {
|
||||
for (const [id, defs] of this.assetsById) assetsById.set(id, defs.slice());
|
||||
const defines = new Map<string, DefineDef[]>();
|
||||
for (const [name, defs] of this.defines) defines.set(name, defs.slice());
|
||||
const references = this.buildReferences();
|
||||
const referenceCount = [...references.values()].reduce(
|
||||
(sum, sites) => sum + sites.length,
|
||||
0,
|
||||
);
|
||||
const recordsHashes = new Map<string, string>();
|
||||
for (const [key, entry] of this.buildRecords) {
|
||||
recordsHashes.set(key, entry.recordsHash);
|
||||
}
|
||||
|
||||
return {
|
||||
projectDir: resolve(this.opts.projectDir),
|
||||
@@ -575,6 +616,8 @@ export class ModIndexer {
|
||||
manifests: new Map(this.manifests),
|
||||
sourceCandidates: this.sourceCandidates.slice(),
|
||||
diagnostics: this.diagnostics.slice(),
|
||||
references,
|
||||
recordsHashes,
|
||||
stats: {
|
||||
projectDir: resolve(this.opts.projectDir),
|
||||
sdkDir: resolve(this.opts.sdkDir),
|
||||
@@ -596,6 +639,7 @@ export class ModIndexer {
|
||||
walkMs: this.timings.walkMs,
|
||||
artScanMs: this.timings.artScanMs,
|
||||
assetCount: [...this.assets.values()].reduce((sum, byId) => sum + byId.size, 0),
|
||||
referenceCount,
|
||||
defineCount: this.defines.size,
|
||||
manifestFiles: this.manifests.size,
|
||||
manifestAssetCount,
|
||||
@@ -606,6 +650,38 @@ export class ModIndexer {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the per-file reference records collected during this build
|
||||
* against the current asset maps. Only files touched by this build are
|
||||
* included, so stale cache entries for files that left the include graph
|
||||
* never leak into the reverse index.
|
||||
*/
|
||||
private buildReferences(): Map<string, ReferenceSite[]> {
|
||||
const sources: ReferenceRecordSource[] = [];
|
||||
for (const { file, records } of this.buildRecords.values()) {
|
||||
sources.push({ file, records });
|
||||
}
|
||||
return buildReferenceIndex(sources, {
|
||||
assets: this.assets,
|
||||
assetsById: this.assetsById,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the records exactly as this build saw them (plus the content
|
||||
* hash when the file was fully parsed / cached with one), so snapshots can
|
||||
* build references from the same source of truth as the asset maps.
|
||||
*/
|
||||
private noteBuildRecords(parsed: ParsedFile): void {
|
||||
if (!parsed.records) return;
|
||||
const key = normKey(parsed.file.path);
|
||||
this.buildRecords.set(key, {
|
||||
file: parsed.file.path,
|
||||
records: parsed.records,
|
||||
recordsHash: recordsHash(parsed.records),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Include walk ──────────────────────────────────────────────────
|
||||
|
||||
private async walk(
|
||||
@@ -670,6 +746,7 @@ export class ModIndexer {
|
||||
): Promise<void> {
|
||||
const records = parsed.records;
|
||||
if (!records) return;
|
||||
this.noteBuildRecords(parsed);
|
||||
const file = parsed.file.path;
|
||||
const origin = this.originOf(file);
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export async function buildDocumentScope(
|
||||
const lineMap = new LineMap(text);
|
||||
const parse = parseXml(text);
|
||||
const builder = new OverlayBuilder(ctx);
|
||||
await builder.addEntry(uri, parse, lineMap);
|
||||
await builder.addEntry(uri, parse, lineMap, text);
|
||||
|
||||
const expanded = await expandDocument(uri, parse, {
|
||||
resolve: (source, currentDir) =>
|
||||
@@ -115,6 +115,8 @@ export function withLocalOverlay(
|
||||
manifests: new Map(),
|
||||
sourceCandidates: [],
|
||||
diagnostics: [],
|
||||
references: new Map(),
|
||||
recordsHashes: new Map(),
|
||||
stats: {
|
||||
projectDir,
|
||||
sdkDir,
|
||||
@@ -134,6 +136,7 @@ export function withLocalOverlay(
|
||||
walkMs: 0,
|
||||
artScanMs: 0,
|
||||
assetCount: 0,
|
||||
referenceCount: 0,
|
||||
defineCount: 0,
|
||||
manifestFiles: 0,
|
||||
manifestAssetCount: 0,
|
||||
@@ -164,12 +167,13 @@ class OverlayBuilder {
|
||||
path: string,
|
||||
parse: XmlDocument,
|
||||
lineMap: LineMap,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
this.lineMaps.set(scopePathKey(path), lineMap);
|
||||
await this.addParsed({
|
||||
file: { path: resolve(path), stat: null },
|
||||
parse,
|
||||
records: extractIndexRecords(parse, lineMap),
|
||||
records: extractIndexRecords(parse, lineMap, text),
|
||||
lineMap,
|
||||
}, 0);
|
||||
}
|
||||
|
||||
+119
-3
@@ -12,6 +12,12 @@
|
||||
|
||||
import type { LineMap, XmlDocument } from "../language/xmlParser";
|
||||
import type { ShallowDocument } from "./shallowScan";
|
||||
import { attributesOfType, typeInfo } from "../model/schemaModel";
|
||||
import { resolveElementType } from "../language/typeContext";
|
||||
import {
|
||||
isReferenceAttributeOfType,
|
||||
isReferenceContentType,
|
||||
} from "./refs";
|
||||
|
||||
export interface IndexRecordAsset {
|
||||
/** Top-level element name, e.g. "W3DContainer". */
|
||||
@@ -42,6 +48,27 @@ export interface IndexRecordXi {
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface IndexRecordReference {
|
||||
/** "attr" for attribute values, "content" for simple-content text. */
|
||||
kind: "attr" | "content";
|
||||
/**
|
||||
* XSD reference target type (from `xas:refType`), or null for untyped
|
||||
* `isRef` references and `inheritFrom` (which filters by the element's own
|
||||
* type via `selfType`).
|
||||
*/
|
||||
refType: string | null;
|
||||
/** Element type used by `inheritFrom` filtering; null otherwise. */
|
||||
selfType: string | null;
|
||||
/** The referenced id text (whole attribute value / trimmed content). */
|
||||
value: string;
|
||||
/** 1-based line of the value. */
|
||||
line: number;
|
||||
/** Character offset of the value start (relative to the file text). */
|
||||
start: number;
|
||||
/** Character offset one past the value end. */
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface IndexRecords {
|
||||
assets: IndexRecordAsset[];
|
||||
defines: IndexRecordDefine[];
|
||||
@@ -50,6 +77,8 @@ export interface IndexRecords {
|
||||
rootXiIncludes: IndexRecordXi[];
|
||||
/** <xi:include> elements nested anywhere else in the document. */
|
||||
nestedXiIncludes: IndexRecordXi[];
|
||||
/** Typed global-asset references (attribute values + simple content). */
|
||||
references: IndexRecordReference[];
|
||||
}
|
||||
|
||||
const INCLUDE_TYPES = new Set(["all", "instance", "reference"]);
|
||||
@@ -69,14 +98,21 @@ function localName(tag: string): string {
|
||||
* Tags/Includes/Defines), $DEFINE constants, the top-level <Includes> block
|
||||
* and root/nested <xi:include> elements.
|
||||
*/
|
||||
export function extractIndexRecords(parse: XmlDocument, lineMap: LineMap): IndexRecords {
|
||||
export function extractIndexRecords(
|
||||
parse: XmlDocument,
|
||||
lineMap: LineMap,
|
||||
text: string,
|
||||
): IndexRecords {
|
||||
const assets: IndexRecordAsset[] = [];
|
||||
const defines: IndexRecordDefine[] = [];
|
||||
const includes: IndexRecordInclude[] = [];
|
||||
const rootXiIncludes: IndexRecordXi[] = [];
|
||||
const nestedXiIncludes: IndexRecordXi[] = [];
|
||||
const references: IndexRecordReference[] = [];
|
||||
const root = parse.root;
|
||||
if (!root) return { assets, defines, includes, rootXiIncludes, nestedXiIncludes };
|
||||
if (!root) {
|
||||
return { assets, defines, includes, rootXiIncludes, nestedXiIncludes, references };
|
||||
}
|
||||
|
||||
for (const child of root.children) {
|
||||
const local = localName(child.name);
|
||||
@@ -142,7 +178,86 @@ export function extractIndexRecords(parse: XmlDocument, lineMap: LineMap): Index
|
||||
});
|
||||
}
|
||||
|
||||
return { assets, defines, includes, rootXiIncludes, nestedXiIncludes };
|
||||
collectReferenceRecords(parse, lineMap, text, references);
|
||||
|
||||
return { assets, defines, includes, rootXiIncludes, nestedXiIncludes, references };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks every element of a fully parsed document and records typed
|
||||
* global-asset references: reference attributes, `inheritFrom` and
|
||||
* simple-content reference text. Local `id` definitions, Poid pipeline-local
|
||||
* references and `$DEFINE`/`=` values are intentionally skipped (the same
|
||||
* semantics as diagnostics / hover / navigation).
|
||||
*
|
||||
* The stored `refType` / `selfType` pair is exactly what
|
||||
* `resolveReferenceTargetsForType` derives from the element context, so the
|
||||
* reverse reference index can resolve these records after the whole index is
|
||||
* built without re-walking the document or re-resolving element types.
|
||||
*/
|
||||
function collectReferenceRecords(
|
||||
parse: XmlDocument,
|
||||
lineMap: LineMap,
|
||||
text: string,
|
||||
out: IndexRecordReference[],
|
||||
): void {
|
||||
for (const el of parse.elements) {
|
||||
const elType = resolveElementType(el);
|
||||
|
||||
for (const attr of el.attrs) {
|
||||
if (!attr.hasValue) continue;
|
||||
if (!isReferenceAttributeOfType(elType, attr.name)) continue;
|
||||
const value = attr.value;
|
||||
if (!value || value.startsWith("$") || value.startsWith("=")) continue;
|
||||
let refType: string | null = null;
|
||||
let selfType: string | null = null;
|
||||
if (attr.name.toLowerCase() === "inheritfrom") {
|
||||
selfType = elType;
|
||||
} else if (elType) {
|
||||
refType =
|
||||
attributesOfType(elType).find((a) => a.name === attr.name)?.refType ??
|
||||
null;
|
||||
}
|
||||
out.push({
|
||||
kind: "attr",
|
||||
refType,
|
||||
selfType,
|
||||
value,
|
||||
line: lineOf(lineMap, attr.valueStart),
|
||||
start: attr.valueStart,
|
||||
end: attr.valueEnd,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
elType &&
|
||||
isReferenceContentType(elType) &&
|
||||
!el.selfClosing &&
|
||||
el.closeTagStart >= 0
|
||||
) {
|
||||
const raw = text.slice(el.startTagEnd, el.closeTagStart);
|
||||
const value = raw.trim();
|
||||
if (
|
||||
!value ||
|
||||
value.startsWith("$") ||
|
||||
value.startsWith("=") ||
|
||||
value.includes("<")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const start = el.startTagEnd + raw.indexOf(value);
|
||||
const info = typeInfo(elType);
|
||||
out.push({
|
||||
kind: "content",
|
||||
refType: info?.kind === "simple" ? info.refType : null,
|
||||
selfType: null,
|
||||
value,
|
||||
line: lineOf(lineMap, start),
|
||||
start,
|
||||
end: start + value.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts a shallow scan (offsets) into index records (1-based lines). */
|
||||
@@ -173,5 +288,6 @@ export function recordsFromShallow(scan: ShallowDocument, lineMap: LineMap): Ind
|
||||
xpointer: x.xpointer,
|
||||
line: lineOf(lineMap, x.start),
|
||||
})),
|
||||
references: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Reverse reference index built from per-file reference records.
|
||||
*
|
||||
* The indexer stores compact reference records per file (attribute values,
|
||||
* simple-content text and inheritFrom, with XSD `refType`/`selfType` context
|
||||
* captured at parse time). After the include walk, this module resolves every
|
||||
* record against the final asset maps and produces:
|
||||
*
|
||||
* definition key -> reference sites
|
||||
*
|
||||
* which powers CodeLens reference counts, semantic Find All References and
|
||||
* the "unreferenced assets" report. Pure TypeScript: no vscode dependency.
|
||||
*/
|
||||
|
||||
import { extractIndexRecords, type IndexRecords } from "./records";
|
||||
import {
|
||||
filterAndScoreDefs,
|
||||
isReferenceTargetType,
|
||||
type ReferenceLookup,
|
||||
} from "./refs";
|
||||
import { buildSearchPaths, resolveSource } from "./includeResolver";
|
||||
import { normKey, recordsHash } from "./caches";
|
||||
import { LineMap, parseXml } from "../language/xmlParser";
|
||||
import type { AssetDef, ModIndex, ReferenceSite } from "./types";
|
||||
|
||||
/** A file whose reference records should be resolved. */
|
||||
export interface ReferenceRecordSource {
|
||||
/** Absolute path of the referencing file. */
|
||||
file: string;
|
||||
records: IndexRecords;
|
||||
}
|
||||
|
||||
/** Stable key identifying one specific asset definition. */
|
||||
export function assetDefKey(
|
||||
def: Pick<AssetDef, "type" | "id" | "file" | "line">,
|
||||
): string {
|
||||
return `${def.type}\u0000${def.id.toLowerCase()}\u0000${def.file.toLowerCase()}\u0000${def.line}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves per-file reference records against the asset lookup and returns
|
||||
* the reverse map. A record resolves to every definition that satisfies its
|
||||
* `refType` / `selfType` context (same strict filtering as go-to-definition),
|
||||
* so same-name ids of different types never share reference counts.
|
||||
*/
|
||||
export function buildReferenceIndex(
|
||||
sources: Iterable<ReferenceRecordSource>,
|
||||
lookup: ReferenceLookup,
|
||||
): Map<string, ReferenceSite[]> {
|
||||
const map = new Map<string, ReferenceSite[]>();
|
||||
for (const { file, records } of sources) {
|
||||
for (const ref of records.references) {
|
||||
const defs = lookup.assetsById.get(ref.value.toLowerCase());
|
||||
if (!defs?.length) continue;
|
||||
const targets = filterAndScoreDefs(defs, ref.refType, ref.selfType);
|
||||
for (const target of targets) {
|
||||
const key = assetDefKey(target.def);
|
||||
let sites = map.get(key);
|
||||
if (!sites) {
|
||||
sites = [];
|
||||
map.set(key, sites);
|
||||
}
|
||||
sites.push({
|
||||
file,
|
||||
line: ref.line,
|
||||
start: ref.start,
|
||||
end: ref.end,
|
||||
kind: ref.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Reference sites for one definition (empty when the index has none). */
|
||||
export function referenceSitesForDef(
|
||||
idx: Pick<ModIndex, "references"> | null | undefined,
|
||||
def: Pick<AssetDef, "type" | "id" | "file" | "line">,
|
||||
): ReferenceSite[] {
|
||||
return idx?.references?.get(assetDefKey(def)) ?? [];
|
||||
}
|
||||
|
||||
function normFileKey(path: string): string {
|
||||
return path.replace(/\\/g, "/").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference sites that belong to a definition opened in the editor.
|
||||
*
|
||||
* Besides the definition's own reverse-index bucket, this unions the sites
|
||||
* of manifest definitions that map back to the same XML source file via
|
||||
* `manifestSource`. A manifest asset with a resolvable SageXml source is
|
||||
* semantically the same asset as that XML definition, so references to it
|
||||
* should show up on the source file's CodeLens too (Find All References
|
||||
* already sees them because it unions every same-id/type definition).
|
||||
*/
|
||||
export function referenceSitesForDefinition(
|
||||
idx: ModIndex,
|
||||
def: Pick<AssetDef, "type" | "id" | "file" | "line">,
|
||||
): ReferenceSite[] {
|
||||
const sites = referenceSitesForDef(idx, def);
|
||||
const byId = idx.assets.get(def.type)?.get(def.id.toLowerCase());
|
||||
if (!byId?.length) return sites;
|
||||
|
||||
const defFile = normFileKey(def.file);
|
||||
const searchPaths = buildSearchPaths(idx.sdkDir, idx.projectDir);
|
||||
const seen = new Set(
|
||||
sites.map((s) => `${s.file}\u0000${s.start}\u0000${s.end}\u0000${s.kind}`),
|
||||
);
|
||||
for (const other of byId) {
|
||||
if (other.origin !== "manifest" || !other.manifestSource) continue;
|
||||
const resolved = resolveSource(other.manifestSource, null, searchPaths).path;
|
||||
if (!resolved || normFileKey(resolved) !== defFile) continue;
|
||||
for (const site of referenceSitesForDef(idx, other)) {
|
||||
const key = `${site.file}\u0000${site.start}\u0000${site.end}\u0000${site.kind}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
sites.push(site);
|
||||
}
|
||||
}
|
||||
return sites;
|
||||
}
|
||||
|
||||
export interface UnreferencedOptions {
|
||||
/**
|
||||
* When true (default), only report types that are reference targets by
|
||||
* design. Auto-registered / structural types (settings, map metadata,
|
||||
* w3x sub-assets...) are excluded because zero references is their normal
|
||||
* state.
|
||||
*/
|
||||
onlyReferenceTargetTypes?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project asset definitions with zero incoming references, grouped by type.
|
||||
* Manifest / SDK definitions and `instance`-only assets are never reported
|
||||
* (they are not part of the compiled stream in the same way).
|
||||
*/
|
||||
export function unreferencedByType(
|
||||
idx: ModIndex,
|
||||
options: UnreferencedOptions = {},
|
||||
): Map<string, AssetDef[]> {
|
||||
const onlyReferenceTargets = options.onlyReferenceTargetTypes ?? true;
|
||||
const out = new Map<string, AssetDef[]>();
|
||||
for (const [type, byId] of idx.assets) {
|
||||
if (onlyReferenceTargets && !isReferenceTargetType(type)) continue;
|
||||
const defs: AssetDef[] = [];
|
||||
for (const arr of byId.values()) {
|
||||
for (const def of arr) {
|
||||
if (def.origin !== "project" || def.viaInstance) continue;
|
||||
if (referenceSitesForDef(idx, def).length > 0) continue;
|
||||
defs.push(def);
|
||||
}
|
||||
}
|
||||
if (defs.length) {
|
||||
defs.sort((a, b) => a.id.localeCompare(b.id));
|
||||
out.set(type, defs);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Minimal workspace surface needed by the records-desync self-heal. */
|
||||
export interface RecordsSyncWorkspace {
|
||||
index: ModIndex | null;
|
||||
invalidate(path: string): void;
|
||||
scheduleRebuild(reason: string): void;
|
||||
}
|
||||
|
||||
/** Minimal document surface (vscode.TextDocument subset, no vscode dep). */
|
||||
export interface RecordsSyncDocument {
|
||||
uri: { fsPath: string; scheme?: string };
|
||||
isDirty?: boolean;
|
||||
getText(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a clean (saved) document's text no longer matches the records the
|
||||
* published snapshot was built from. This catches cache entries that slipped
|
||||
* through stat validation (e.g. a rewrite with preserved timestamps on an
|
||||
* external drive) or watcher events lost during a drive reconnect.
|
||||
*/
|
||||
export function documentRecordsDesynced(
|
||||
idx: ModIndex,
|
||||
fsPath: string,
|
||||
text: string,
|
||||
): boolean {
|
||||
const expected = idx.recordsHashes?.get(normKey(fsPath));
|
||||
if (expected == null) return false;
|
||||
const lineMap = new LineMap(text);
|
||||
const records = extractIndexRecords(parseXml(text), lineMap, text);
|
||||
return recordsHash(records) !== expected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-heal: when the open, saved document's content differs from the
|
||||
* snapshot's records hash, invalidate exactly that file and schedule a
|
||||
* rebuild. Returns true when a rebuild was scheduled. Unsaved (dirty)
|
||||
* documents are skipped — the editor text is intentionally ahead of disk.
|
||||
*/
|
||||
export function scheduleRebuildIfRecordsDesync(
|
||||
ws: RecordsSyncWorkspace,
|
||||
document: RecordsSyncDocument,
|
||||
): boolean {
|
||||
if (
|
||||
document.isDirty ||
|
||||
(document.uri.scheme != null && document.uri.scheme !== "file")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const idx = ws.index;
|
||||
if (!idx) return false;
|
||||
const fsPath = document.uri.fsPath;
|
||||
if (!documentRecordsDesynced(idx, fsPath, document.getText())) return false;
|
||||
ws.invalidate(fsPath);
|
||||
ws.scheduleRebuild("records-desync");
|
||||
return true;
|
||||
}
|
||||
+67
-5
@@ -1,17 +1,33 @@
|
||||
import {
|
||||
allTypeNames,
|
||||
attributesOfType,
|
||||
canonicalTypeName,
|
||||
elementTypeName,
|
||||
isAssignableTo,
|
||||
typeChain,
|
||||
typeInfo,
|
||||
} from "../model/schemaModel";
|
||||
import type { AssetDef, ModIndex } from "./types";
|
||||
import type { AssetDef, LocalOverlay } from "./types";
|
||||
|
||||
export interface ReferenceTarget {
|
||||
def: AssetDef;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of `ModIndex` that reference resolution needs. Kept narrow so
|
||||
* the reverse reference index can resolve records against the indexer's live
|
||||
* maps without constructing a full index snapshot.
|
||||
*/
|
||||
export interface ReferenceLookup {
|
||||
/** type -> id -> definitions. */
|
||||
assets: Map<string, Map<string, AssetDef[]>>;
|
||||
/** id -> definitions across all types. */
|
||||
assetsById: Map<string, AssetDef[]>;
|
||||
/** Optional document-local overlay (consulted first). */
|
||||
local?: LocalOverlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an attribute is a "pipeline-local" reference that the global
|
||||
* asset index cannot judge:
|
||||
@@ -78,7 +94,7 @@ export function isReferenceAttributeOfType(
|
||||
* Returns [] when the attribute is not a typed reference or nothing matches.
|
||||
*/
|
||||
export function resolveReferenceTargets(
|
||||
idx: ModIndex,
|
||||
idx: ReferenceLookup,
|
||||
elementType: string,
|
||||
attrName: string,
|
||||
id: string,
|
||||
@@ -93,7 +109,7 @@ export function resolveReferenceTargets(
|
||||
|
||||
/** Same resolution, driven by a resolved XSD type name. */
|
||||
export function resolveReferenceTargetsForType(
|
||||
idx: ModIndex,
|
||||
idx: ReferenceLookup,
|
||||
typeName: string | null,
|
||||
attrName: string,
|
||||
id: string,
|
||||
@@ -148,7 +164,7 @@ export function isReferenceContentType(typeName: string | null): boolean {
|
||||
* (e.g. `GameObjectWeakRef` -> `GameObject`).
|
||||
*/
|
||||
export function resolveContentReferenceTargets(
|
||||
idx: ModIndex,
|
||||
idx: ReferenceLookup,
|
||||
typeName: string | null,
|
||||
id: string,
|
||||
): ReferenceTarget[] {
|
||||
@@ -164,7 +180,7 @@ export function resolveContentReferenceTargets(
|
||||
return filterAndScoreDefs(defs, refType, null);
|
||||
}
|
||||
|
||||
function filterAndScoreDefs(
|
||||
export function filterAndScoreDefs(
|
||||
defs: readonly AssetDef[],
|
||||
refType: string | null,
|
||||
selfType: string | null,
|
||||
@@ -204,3 +220,49 @@ export function mergeLocalAndGlobalDefs(
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let referenceTargetTypeSet: Set<string> | null = null;
|
||||
|
||||
/**
|
||||
* The set of XSD types that are "reference targets by design": at least one
|
||||
* typed reference attribute / simple-content reference points at them, or
|
||||
* they are inheritable (`inheritFrom`). Types outside this set are
|
||||
* auto-registered / structural (settings, map metadata, w3x sub-assets...),
|
||||
* so a zero reference count is their normal state and counts would only be
|
||||
* noise.
|
||||
*/
|
||||
export function referenceTargetTypes(): ReadonlySet<string> {
|
||||
if (referenceTargetTypeSet) return referenceTargetTypeSet;
|
||||
const set = new Set<string>();
|
||||
const add = (t: string | null) => {
|
||||
if (!t) return;
|
||||
set.add(canonicalTypeName(t) ?? t);
|
||||
};
|
||||
for (const typeName of allTypeNames()) {
|
||||
const info = typeInfo(typeName);
|
||||
if (!info) continue;
|
||||
if (info.kind === "complex") {
|
||||
for (const attr of info.attributes) {
|
||||
if (isLocalReferenceAttribute(typeName, attr.name)) continue;
|
||||
if (attr.refType) add(attr.refType);
|
||||
}
|
||||
if (info.attributes.some((a) => a.name.toLowerCase() === "inheritfrom")) {
|
||||
add(typeName);
|
||||
}
|
||||
} else if (
|
||||
info.kind === "simple" &&
|
||||
info.refType &&
|
||||
!typeChain(typeName).includes("Poid")
|
||||
) {
|
||||
add(info.refType);
|
||||
}
|
||||
}
|
||||
referenceTargetTypeSet = set;
|
||||
return set;
|
||||
}
|
||||
|
||||
/** True when the type is a designed reference target (see above). */
|
||||
export function isReferenceTargetType(typeName: string | null): boolean {
|
||||
if (!typeName) return false;
|
||||
return referenceTargetTypes().has(canonicalTypeName(typeName) ?? typeName);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,22 @@ export interface AssetDef {
|
||||
manifestSource?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One resolved reference occurrence pointing at an asset definition.
|
||||
* Produced by `buildReferenceIndex` from per-file reference records.
|
||||
*/
|
||||
export interface ReferenceSite {
|
||||
/** Absolute path of the referencing file. */
|
||||
file: string;
|
||||
/** 1-based line of the reference value. */
|
||||
line: number;
|
||||
/** Character offset of the value start (relative to the file text). */
|
||||
start: number;
|
||||
/** Character offset one past the value end. */
|
||||
end: number;
|
||||
kind: "attr" | "content";
|
||||
}
|
||||
|
||||
export interface DefineDef {
|
||||
name: string;
|
||||
value: string;
|
||||
@@ -121,6 +137,8 @@ export interface IndexStats {
|
||||
/** Time spent shallow-scanning deferred art assets (ms). */
|
||||
artScanMs: number;
|
||||
assetCount: number;
|
||||
/** Total resolved reference sites in the reverse index. */
|
||||
referenceCount: number;
|
||||
defineCount: number;
|
||||
manifestFiles: number;
|
||||
manifestAssetCount: number;
|
||||
@@ -159,6 +177,20 @@ export interface ModIndex {
|
||||
sourceCandidates: SourceCandidate[];
|
||||
/** Problems found while indexing (unresolved includes, cycles, ...). */
|
||||
diagnostics: IndexerDiagnostic[];
|
||||
/**
|
||||
* Reverse reference index: asset definition key (see
|
||||
* `referenceIndex.assetDefKey`) -> reference sites. Built from the compact
|
||||
* per-file reference records when a snapshot is published, so counts and
|
||||
* Find All References share one semantic source of truth.
|
||||
*/
|
||||
references: Map<string, ReferenceSite[]>;
|
||||
/**
|
||||
* normKey(file) -> SHA-1 of the file's compact index records as this
|
||||
* snapshot consumed them. Lets open documents detect "my file changed on
|
||||
* disk but the index still uses older records" and trigger a targeted
|
||||
* rebuild, while ignoring cosmetic text changes (line endings, whitespace).
|
||||
*/
|
||||
recordsHashes: Map<string, string>;
|
||||
stats: IndexStats;
|
||||
/**
|
||||
* Document-local overlay (when the index was obtained through the
|
||||
|
||||
@@ -92,6 +92,11 @@ export const modelMeta = {
|
||||
typeCount: Object.keys(model.types).length,
|
||||
};
|
||||
|
||||
/** All type names in the XSD model (complex + simple), in model order. */
|
||||
export function allTypeNames(): string[] {
|
||||
return Object.keys(model.types);
|
||||
}
|
||||
|
||||
export function topLevelElements(): string[] {
|
||||
return model.topLevelElements;
|
||||
}
|
||||
|
||||
+9
-2
@@ -92,7 +92,7 @@ export class ModWorkspace {
|
||||
this.settings = readSettings();
|
||||
const storageUri = context.storageUri ?? context.globalStorageUri;
|
||||
if (storageUri) {
|
||||
this.diskCachePath = join(storageUri.fsPath, "index-records-v1.json.gz");
|
||||
this.diskCachePath = join(storageUri.fsPath, "index-records-v3.json.gz");
|
||||
}
|
||||
this.output = vscode.window.createOutputChannel("RA3 Mod XML");
|
||||
this.statusBar = vscode.window.createStatusBarItem(
|
||||
@@ -145,6 +145,11 @@ export class ModWorkspace {
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.projectRoot = this.detectProjectRoot();
|
||||
void vscode.commands.executeCommand(
|
||||
"setContext",
|
||||
"ra3modxml.active",
|
||||
this.projectRoot != null,
|
||||
);
|
||||
if (!this.projectRoot) {
|
||||
this.statusBar.hide();
|
||||
return;
|
||||
@@ -346,6 +351,7 @@ export class ModWorkspace {
|
||||
stat: rec.stat,
|
||||
records: rec.records,
|
||||
kind: rec.kind,
|
||||
contentHash: rec.contentHash,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -451,6 +457,7 @@ export class ModWorkspace {
|
||||
`${s.projectDir}\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}`;
|
||||
}
|
||||
@@ -589,7 +596,7 @@ export class ModWorkspace {
|
||||
return {
|
||||
file: { path: resolve(path), stat: null },
|
||||
parse,
|
||||
records: extractIndexRecords(parse, lineMap),
|
||||
records: extractIndexRecords(parse, lineMap, text),
|
||||
lineMap,
|
||||
};
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user