improve codelens
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user