find asset

This commit is contained in:
2026-09-10 23:16:36 +02:00
parent a25adacaee
commit 4f422a748e
13 changed files with 1036 additions and 13 deletions
+17
View File
@@ -44,6 +44,23 @@
- Repository and homepage links now point to the GitHub mirror; README links to both GitHub and Gitea.
- Extension now activates in untrusted (Restricted Mode) workspaces; workspace-defined `ra3modxml.sdkPath` and `ra3modxml.additionalDataSearchPaths` are ignored until the workspace is trusted.
- **Find asset.** `RA3 Mod XML: Find asset (id or Type:Id)…` opens a search
picker over the whole index: type `Type:Id`, an exact id or a partial id and
the result list updates as you type (`Type:` alone lists every asset of that
type). Rows show the asset type, origin (project / SDK / manifest), reference
count and defining file; accepting one jumps to the definition. When a
`type:id` is defined in several places (mod override + vanilla source +
compiled manifest) a second picker chooses which definition to open. The
query can also be prefilled from the current selection through the editor
context menu entry.
### Fixed
- The editor context-menu entries were declared under a top-level `menus` key
instead of `contributes.menus`, so VS Code never showed them. Adding the new
search entry moved the block where it belongs; `Find unreferenced assets of
this type` now appears in the editor context menu as documented.
## 0.1.24 — 2026-08-11
### Fixed
+9
View File
@@ -86,6 +86,14 @@ Catch common modding mistakes while you edit.
The extension can analyze the entire workspace rather than only the file currently open.
**Find asset** searches the index for an asset by `Type:Id`, by exact id, or by a partial id — typed directly into the picker, with results updating as you type. The result list shows each asset's type, origin (project / SDK / manifest), reference count and defining file; accepting a result jumps to its definition. When the same `type:id` exists in several places (for example a mod override of a vanilla asset), a second picker asks which definition to open.
Run:
`RA3 Mod XML: Find asset (id or Type:Id)…`
You can also select an id in the editor and use the context menu entry to search for it.
**Find unreferenced assets** lists project assets that are not referenced anywhere in the workspace, helping identify obsolete or accidentally unused definitions.
Run:
@@ -153,6 +161,7 @@ to an empty string opts out of SDK features permanently.
* `RA3 Mod XML: Clear caches and rebuild`
* `RA3 Mod XML: Configure SDK path…`
* `RA3 Mod XML: Show cache report`
* `RA3 Mod XML: Find asset (id or Type:Id)…`
* `RA3 Mod XML: Find unreferenced assets…`
* `RA3 Mod XML: Find unreferenced assets of this type`
* `RA3 Mod XML: Enable AI Agent access…`
+9
View File
@@ -86,6 +86,14 @@
扩展可以分析整个工作区,而不仅仅是当前打开的文件。
**查找资产** 可以在索引中按 `类型:ID`、精确 ID 或部分 ID 搜索资产——直接在输入框中输入,结果随输入实时更新。结果列表显示资产的类型、来源(项目 / SDK / manifest)、引用次数与所在文件;选中后会跳转到其定义。当同一个 `类型:ID` 存在多处定义时(例如 mod 覆盖了原版资产),会再弹出一次选择框让你选择打开哪一处定义。
运行:
`RA3 Mod XML: Find asset (id or Type:Id)…`
你也可以在编辑器中选中一个 ID,然后用右键菜单直接搜索它。
**查找未引用的资产** 会列出工作区中任何地方都未被引用的项目资产,帮助识别过时或意外未使用的定义。
运行:
@@ -150,6 +158,7 @@
* `RA3 Mod XML: Clear caches and rebuild`(清除缓存并重建)
* `RA3 Mod XML: Configure SDK path…`(配置 SDK 路径)
* `RA3 Mod XML: Show cache report`(显示缓存报告)
* `RA3 Mod XML: Find asset (id or Type:Id)…`(查找资产(ID 或 类型:ID)…)
* `RA3 Mod XML: Find unreferenced assets…`(查找未引用的资产…)
* `RA3 Mod XML: Find unreferenced assets of this type`(查找此类型的未引用资产)
* `RA3 Mod XML: Enable AI Agent access…`(启用 AI Agent 访问…)
+13 -1
View File
@@ -212,5 +212,17 @@
"RA3 Mod XML: this version can expose the project's asset index to AI agents (MCP + Agent Skill). Enable it?": "RA3 Mod XML: this version can expose the project's asset index to AI agents (MCP + Agent Skill). Enable it?",
"Enable AI Agent access": "Enable AI Agent access",
"Learn more": "Learn more",
"Don't show again": "Don't show again"
"Don't show again": "Don't show again",
"RA3 Mod XML: Find asset": "RA3 Mod XML: Find asset",
"Id, partial id or Type:Id (e.g. WeaponTemplate:AssaultRifle)": "Id, partial id or Type:Id (e.g. WeaponTemplate:AssaultRifle)",
"Type an id, a partial id or Type:Id to search.": "Type an id, a partial id or Type:Id to search.",
"No asset matches \"{0}\".": "No asset matches \"{0}\".",
"{0} of {1} matches — keep typing to narrow the list.": "{0} of {1} matches — keep typing to narrow the list.",
"1 asset found.": "1 asset found.",
"{0} assets found.": "{0} assets found.",
"{0} definitions": "{0} definitions",
"Select the definition to open": "Select the definition to open",
"RA3 Mod XML: no XML source location for {0} ({1}).": "RA3 Mod XML: no XML source location for {0} ({1}).",
"RA3 Mod XML: the index has no assets yet — wait for indexing to finish.": "RA3 Mod XML: the index has no assets yet — wait for indexing to finish.",
"local": "local"
}
+13 -1
View File
@@ -212,5 +212,17 @@
"RA3 Mod XML: this version can expose the project's asset index to AI agents (MCP + Agent Skill). Enable it?": "RA3 Mod XML:此版本可以把项目资产索引提供给 AI AgentMCP + Agent Skill)。要启用吗?",
"Enable AI Agent access": "启用 AI Agent 访问",
"Learn more": "了解更多",
"Don't show again": "不再提示"
"Don't show again": "不再提示",
"RA3 Mod XML: Find asset": "RA3 Mod XML:查找资产",
"Id, partial id or Type:Id (e.g. WeaponTemplate:AssaultRifle)": "ID、部分 ID 或 类型:ID(例如 WeaponTemplate:AssaultRifle",
"Type an id, a partial id or Type:Id to search.": "输入 ID、部分 ID 或 类型:ID 进行搜索。",
"No asset matches \"{0}\".": "没有匹配 \"{0}\" 的资产。",
"{0} of {1} matches — keep typing to narrow the list.": "{0}/{1} 个匹配——继续输入可缩小范围。",
"1 asset found.": "找到 1 个资产。",
"{0} assets found.": "找到 {0} 个资产。",
"{0} definitions": "{0} 处定义",
"Select the definition to open": "选择要打开的定义",
"RA3 Mod XML: no XML source location for {0} ({1}).": "RA3 Mod XML{0}{1})没有可打开的 XML 源位置。",
"RA3 Mod XML: the index has no assets yet — wait for indexing to finish.": "RA3 Mod XML:索引中还没有资产——请等待索引完成。",
"local": "本地"
}
+11 -2
View File
@@ -150,6 +150,10 @@
"command": "ra3modxml.exportIndexSnapshot",
"title": "%ra3modxml.command.exportIndexSnapshot.title%"
},
{
"command": "ra3modxml.findAsset",
"title": "%ra3modxml.command.findAsset.title%"
},
{
"command": "ra3modxml.findUnreferencedAssets",
"title": "%ra3modxml.command.findUnreferencedAssets.title%"
@@ -158,16 +162,21 @@
"command": "ra3modxml.findUnreferencedAssetsOfType",
"title": "%ra3modxml.command.findUnreferencedAssetsOfType.title%"
}
]
},
],
"menus": {
"editor/context": [
{
"command": "ra3modxml.findAsset",
"when": "editorLangId == xml && ra3modxml.active",
"group": "navigation@45"
},
{
"command": "ra3modxml.findUnreferencedAssetsOfType",
"when": "editorLangId == xml && ra3modxml.active",
"group": "navigation@50"
}
]
}
},
"scripts": {
"build": "node esbuild.mjs",
+1
View File
@@ -19,6 +19,7 @@
"ra3modxml.command.uninstallAgentSkill.title": "RA3 Mod XML: Uninstall Agent Skill…",
"ra3modxml.command.uninstallAgentIntegration.title": "RA3 Mod XML: Uninstall AI Agent integration…",
"ra3modxml.command.exportIndexSnapshot.title": "RA3 Mod XML: Export AI Agent index snapshot",
"ra3modxml.command.findAsset.title": "RA3 Mod XML: Find asset (id or Type:Id)…",
"ra3modxml.command.findUnreferencedAssets.title": "RA3 Mod XML: Find unreferenced assets…",
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: Find unreferenced assets of this type"
}
+1
View File
@@ -19,6 +19,7 @@
"ra3modxml.command.uninstallAgentSkill.title": "RA3 Mod XML: 卸载 Agent Skill…",
"ra3modxml.command.uninstallAgentIntegration.title": "RA3 Mod XML: 卸载 AI Agent 集成…",
"ra3modxml.command.exportIndexSnapshot.title": "RA3 Mod XML: 导出 AI Agent 索引快照",
"ra3modxml.command.findAsset.title": "RA3 Mod XML: 查找资产(ID 或 类型:ID)…",
"ra3modxml.command.findUnreferencedAssets.title": "RA3 Mod XML: 查找未引用的资产…",
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: 查找该类型的未引用资产"
}
+16
View File
@@ -18,6 +18,7 @@ import {
findUnreferencedAssets,
findUnreferencedAssetsOfType,
} from "./features/unreferenced";
import { findAsset } from "./features/findAsset";
import { Ra3Diagnostics } from "./features/diagnostics";
import {
Ra3SemanticTokensProvider,
@@ -966,6 +967,21 @@ export function activate(context: vscode.ExtensionContext): void {
void showReferencesForDef(ws, args),
),
);
context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.findAsset", () => {
// Prefill with a single-line selection so the editor context menu entry
// can search for the id the user highlighted.
const editor = vscode.window.activeTextEditor;
const selected = editor
? editor.document.getText(editor.selection).trim()
: "";
const initial =
selected && !/[\r\n]/.test(selected) && selected.length <= 200
? selected
: "";
return findAsset(ws, initial);
}),
);
context.subscriptions.push(
vscode.commands.registerCommand(
"ra3modxml.findUnreferencedAssets",
+283
View File
@@ -0,0 +1,283 @@
/**
* Pure asset-search core for the "Find asset…" command.
*
* A query is matched against the live `ModIndex` maps (type -> id ->
* definitions) plus the document-local overlay, so assets that only exist in
* the current file's include chain (including unsaved edits) are searchable
* too. Keeping the parsing/matching/ranking logic free of the VS Code API
* makes it unit-testable and reusable by other surfaces (CLI, tools).
*
* Supported query forms (all case-insensitive):
* - `Type:Id` — type filter plus id filter (both may be partial);
* - `Id` — exact / prefix / substring id match; type names match as
* well, so `GameObject` also lists the assets of that type;
* - `Type:` — every asset of that type.
*
* Manifest-style qualified names can carry several colon segments
* (`W3DContainer:W3DContainer:AUGunship`). Asset ids never contain ":", so the
* segment after the last colon is the id, exactly like `normalizeReferenceId`.
*/
import type { AssetDef, ModIndex } from "../indexer/types";
/** Default result cap; the UI reports how many matches were hidden. */
export const ASSET_SEARCH_LIMIT = 200;
export interface AssetSearchQuery {
/** Trimmed original text (used in messages). */
raw: string;
/** Lowercased type filter, or null when the query has no `Type:` part. */
type: string | null;
/** Lowercased id filter; "" when the query only filters by type. */
id: string;
}
/**
* One searchable asset (a `type:id` pair). The same id can be defined in
* several places (project + SDK + manifest, or duplicated files); the pair is
* what users search for, so the list is de-duplicated and the best-ranked
* definition is kept while `definitionCount` records the other sites.
*/
export interface AssetSearchCandidate {
def: AssetDef;
/** Distinct definition sites (file + line) for this type:id. */
definitionCount: number;
}
export interface AssetSearchResult {
matches: AssetSearchCandidate[];
/** Total matches before the limit was applied. */
total: number;
}
/** `0` exact, `1` prefix, `2` substring, `3` no match. */
const RANK_NONE = 3;
/** Parses a user query into a type filter and an id filter. */
export function parseAssetSearchQuery(rawInput: string): AssetSearchQuery {
const raw = rawInput.trim();
const colon = raw.indexOf(":");
if (colon < 0) return { raw, type: null, id: raw.toLowerCase() };
const type = raw.slice(0, colon).trim().toLowerCase();
const rest = raw.slice(colon + 1).trim();
const lastColon = rest.lastIndexOf(":");
const id = (lastColon >= 0 ? rest.slice(lastColon + 1) : rest)
.trim()
.toLowerCase();
return { raw, type: type || null, id };
}
/** True when the query asks for nothing (the UI shows only the placeholder). */
export function isEmptyAssetSearchQuery(query: AssetSearchQuery): boolean {
return !query.type && !query.id;
}
/**
* Lower rank = better. Document-local (unsaved) definitions win, then mod
* definitions, then SDK sources, then compiled manifests.
*/
export function assetOriginRank(def: AssetDef): number {
if (def.stream === "local") return 0;
switch (def.origin) {
case "project":
return 1;
case "sdk":
return 2;
default:
return 3;
}
}
/**
* Flattens the index (and the optional local overlay) into one de-duplicated
* candidate per `type:id`, keeping the best-ranked definition for each pair.
*/
export function collectAssetSearchCandidates(
index: ModIndex,
): AssetSearchCandidate[] {
const byKey = new Map<
string,
{ def: AssetDef; sites: Set<string>; count: number }
>();
const add = (def: AssetDef): void => {
const key = `${def.type}\u0000${def.id.toLowerCase()}`;
const site = `${def.file.toLowerCase()}\u0000${def.line}`;
let entry = byKey.get(key);
if (!entry) {
entry = { def, sites: new Set(), count: 0 };
byKey.set(key, entry);
}
if (entry.sites.has(site)) return;
entry.sites.add(site);
entry.count++;
if (assetOriginRank(def) < assetOriginRank(entry.def)) entry.def = def;
};
// Local overlay first: on equal origin rank the first definition wins, and
// the overlay carries the freshest text of the current file chain.
if (index.local) {
for (const byId of index.local.assets.values()) {
for (const defs of byId.values()) for (const def of defs) add(def);
}
}
for (const byId of index.assets.values()) {
for (const defs of byId.values()) for (const def of defs) add(def);
}
return [...byKey.values()].map((entry) => ({
def: entry.def,
definitionCount: entry.count,
}));
}
interface ScoredCandidate {
candidate: AssetSearchCandidate;
/** Lower is better: how strongly the query matched. */
tier: number;
/** id match rank (RANK_NONE when the query has no id part). */
idRank: number;
/** type match rank (RANK_NONE when the query has no type part). */
typeRank: number;
originRank: number;
}
/**
* Ranks the candidates for one query and returns the best `limit` matches
* plus the total number of matches (so the UI can say "N of M").
*/
export function searchAssetCandidates(
candidates: readonly AssetSearchCandidate[],
query: AssetSearchQuery,
limit: number = ASSET_SEARCH_LIMIT,
): AssetSearchResult {
if (isEmptyAssetSearchQuery(query)) return { matches: [], total: 0 };
const scored: ScoredCandidate[] = [];
for (const candidate of candidates) {
const score = scoreCandidate(candidate, query);
if (score) scored.push(score);
}
scored.sort(compareScoredCandidates);
const capped = Math.max(0, limit);
return {
matches: scored.slice(0, capped).map((entry) => entry.candidate),
total: scored.length,
};
}
function scoreCandidate(
candidate: AssetSearchCandidate,
query: AssetSearchQuery,
): ScoredCandidate | null {
const id = candidate.def.id.toLowerCase();
const type = candidate.def.type.toLowerCase();
const idRank = query.id ? rankMatch(id, query.id) : RANK_NONE;
// With a bare query the same text also matches type names, so
// `GameObject` lists that type's assets as well as every id containing
// "gameobject".
const typeRank = query.type
? rankMatch(type, query.type)
: query.id
? rankMatch(type, query.id)
: RANK_NONE;
if (query.id && query.type) {
if (idRank === RANK_NONE || typeRank === RANK_NONE) return null;
} else if (query.id) {
if (idRank === RANK_NONE && typeRank === RANK_NONE) return null;
} else if (typeRank === RANK_NONE) {
return null;
}
return {
candidate,
tier: bestTier(idRank, typeRank, query.type != null),
idRank,
typeRank,
originRank: assetOriginRank(candidate.def),
};
}
/** 0 exact, 1 prefix, 2 substring, 3 no match. */
function rankMatch(value: string, filter: string): number {
if (value === filter) return 0;
if (value.startsWith(filter)) return 1;
return value.includes(filter) ? 2 : RANK_NONE;
}
/**
* Match strength tiers. When the user filtered by type explicitly, only the
* id quality ranks the results (the type part is a filter, not a signal).
* Otherwise the strongest of the two matches wins.
*/
function bestTier(
idRank: number,
typeRank: number,
typeFiltered: boolean,
): number {
if (typeFiltered) {
if (idRank === 0) return 0;
if (idRank === 1) return 2;
if (idRank === 2) return 4;
return 6; // `Type:` alone: list the whole type
}
if (idRank === 0) return 0;
if (typeRank === 0) return 1;
if (idRank === 1) return 2;
if (typeRank === 1) return 3;
if (idRank === 2) return 4;
return 5;
}
function compareScoredCandidates(a: ScoredCandidate, b: ScoredCandidate): number {
return (
a.tier - b.tier ||
a.originRank - b.originRank ||
a.idRank - b.idRank ||
a.typeRank - b.typeRank ||
compareText(a.candidate.def.id, b.candidate.def.id) ||
compareText(a.candidate.def.type, b.candidate.def.type)
);
}
/**
* Plain code-unit comparison: asset ids and type names are ASCII, and this is
* much cheaper than `localeCompare` when a short query matches thousands of
* candidates (the list is re-ranked on every keystroke).
*/
function compareText(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
/**
* Every definition site of one search result, local overlay first and then by
* origin rank. Used by the UI when several definitions share a `type:id`
* (e.g. a mod override of a vanilla asset) so the user can pick which one to
* open.
*/
export function assetDefsForCandidate(
index: ModIndex,
candidate: AssetSearchCandidate,
): AssetDef[] {
const { type, id } = candidate.def;
const key = id.toLowerCase();
const out: AssetDef[] = [];
const seen = new Set<string>();
const push = (defs: readonly AssetDef[] | undefined): void => {
if (!defs) return;
for (const def of defs) {
const site = `${def.file.toLowerCase()}\u0000${def.line}`;
if (seen.has(site)) continue;
seen.add(site);
out.push(def);
}
};
push(index.local?.assets.get(type)?.get(key));
push(index.assets.get(type)?.get(key));
out.sort((a, b) => assetOriginRank(a) - assetOriginRank(b));
return out;
}
+243
View File
@@ -0,0 +1,243 @@
/**
* "RA3 Mod XML: Find asset…" — quick-pick search over indexed assets.
*
* One picker does both the typing and the selection: results are filtered by
* the extension itself rather than by VS Code's built-in label filter, so the
* `Type:Id` form (where the type never appears in the visible label) keeps
* working. Accepting a result jumps to its definition; when the same
* `type:id` exists in several places (mod override + SDK + compiled
* manifest) a second picker chooses which definition to open.
*/
import * as vscode from "vscode";
import { relative } from "node:path";
import {
ASSET_SEARCH_LIMIT,
assetDefsForCandidate,
collectAssetSearchCandidates,
parseAssetSearchQuery,
searchAssetCandidates,
type AssetSearchCandidate,
type AssetSearchQuery,
} from "./assetSearch";
import { assetDefinitionLocation } from "./navigation";
import { referenceSitesForDef } from "../indexer/referenceIndex";
import type { AssetDef, ModIndex } from "../indexer/types";
import type { ModWorkspace } from "../workspace";
import { t } from "../localize";
interface AssetPickItem extends vscode.QuickPickItem {
candidate: AssetSearchCandidate;
}
interface DefinitionPickItem extends vscode.QuickPickItem {
def: AssetDef;
}
/** Entry point for the `ra3modxml.findAsset` command. */
export async function findAsset(
ws: ModWorkspace,
initialQuery = "",
): Promise<void> {
const index = await searchIndexFor(ws);
if (!index) {
void vscode.window.showInformationMessage(
t("RA3 Mod XML: no index available yet."),
);
return;
}
const candidates = collectAssetSearchCandidates(index);
if (!candidates.length) {
void vscode.window.showInformationMessage(
t("RA3 Mod XML: the index has no assets yet — wait for indexing to finish."),
);
return;
}
const picked = await pickAsset(index, candidates, initialQuery.trim());
if (!picked) return;
await revealAsset(ws, index, picked);
}
/**
* Index used for searching: the active XML document's project index with its
* local overlay attached when possible (so unsaved edits are searchable),
* otherwise the active project's last published snapshot.
*/
async function searchIndexFor(ws: ModWorkspace): Promise<ModIndex | null> {
const editor = vscode.window.activeTextEditor;
if (editor && editor.document.languageId === "xml" && ws.isRa3Workspace()) {
try {
const scope = await ws.getScope(editor.document);
if (scope.merged) return scope.merged;
} catch {
// Fall through to the plain snapshot.
}
}
return ws.activeIndex();
}
function pickAsset(
index: ModIndex,
candidates: readonly AssetSearchCandidate[],
initialQuery: string,
): Promise<AssetSearchCandidate | undefined> {
return new Promise<AssetSearchCandidate | undefined>((resolve) => {
const picker = vscode.window.createQuickPick<AssetPickItem>();
picker.title = t("RA3 Mod XML: Find asset");
picker.placeholder = t(
"Id, partial id or Type:Id (e.g. WeaponTemplate:AssaultRifle)",
);
let settled = false;
const finish = (value: AssetSearchCandidate | undefined): void => {
if (settled) return;
settled = true;
resolve(value);
picker.dispose();
};
const refresh = (value: string): void => {
const query = parseAssetSearchQuery(value);
const { matches, total } = searchAssetCandidates(
candidates,
query,
ASSET_SEARCH_LIMIT,
);
picker.items = matches.map((candidate) => toPickItem(index, candidate));
picker.prompt = promptFor(query, matches.length, total);
if (picker.items.length) picker.activeItems = [picker.items[0]];
};
picker.onDidChangeValue((value) => refresh(value));
picker.onDidAccept(() => {
const item = picker.activeItems[0] ?? picker.items[0];
if (item) finish(item.candidate);
});
picker.onDidHide(() => finish(undefined));
picker.value = initialQuery;
refresh(initialQuery);
picker.show();
});
}
function toPickItem(
index: ModIndex,
candidate: AssetSearchCandidate,
): AssetPickItem {
const def = candidate.def;
const refs = referenceSitesForDef(index, def).length;
const description = [def.type, originLabel(def)];
if (refs > 0) {
description.push(refs === 1 ? t("1 reference") : t("{0} references", refs));
}
const detail = [`${displayPath(index.projectDir, def.file)}:${def.line}`];
if (candidate.definitionCount > 1) {
detail.push(t("{0} definitions", candidate.definitionCount));
}
return {
label: def.id,
description: description.join(" · "),
detail: detail.join(" · "),
// The picker filters the list itself: a `Type:Id` query never appears in
// the visible label, so VS Code's built-in filter must not hide it.
alwaysShow: true,
candidate,
};
}
function promptFor(
query: AssetSearchQuery,
shown: number,
total: number,
): string {
if (!query.type && !query.id) {
return t("Type an id, a partial id or Type:Id to search.");
}
if (total === 0) return t('No asset matches "{0}".', query.raw);
if (total > shown) {
return t(
"{0} of {1} matches — keep typing to narrow the list.",
shown,
total,
);
}
return total === 1 ? t("1 asset found.") : t("{0} assets found.", total);
}
/**
* Opens a search result. Definitions of the same `type:id` are offered when
* there is more than one (e.g. the mod override and the vanilla source).
*/
async function revealAsset(
ws: ModWorkspace,
index: ModIndex,
candidate: AssetSearchCandidate,
): Promise<void> {
const defs = assetDefsForCandidate(index, candidate);
let def = defs[0] ?? candidate.def;
if (defs.length > 1) {
const picked = await vscode.window.showQuickPick(
defs.map((d) => toDefinitionItem(index, d)),
{
title: `${def.type}:${def.id}`,
placeHolder: t("Select the definition to open"),
matchOnDescription: true,
},
);
if (!picked) return;
def = picked.def;
}
const location = await assetDefinitionLocation(ws, def, index);
if (!location) {
void vscode.window.showInformationMessage(
t(
"RA3 Mod XML: no XML source location for {0} ({1}).",
`${def.type}:${def.id}`,
displayPath(index.projectDir, def.file),
),
);
return;
}
const document = await vscode.workspace.openTextDocument(location.uri);
await vscode.window.showTextDocument(document, {
selection: location.range,
preview: true,
});
}
function toDefinitionItem(
index: ModIndex,
def: AssetDef,
): DefinitionPickItem {
return {
label: `${displayPath(index.projectDir, def.file)}:${def.line}`,
description: originLabel(def),
detail: `${def.type}:${def.id}`,
def,
};
}
function originLabel(def: AssetDef): string {
if (def.stream === "local") return t("local");
switch (def.origin) {
case "sdk":
return t("SDK");
case "manifest":
return t("manifest");
default:
return t("project");
}
}
function displayPath(projectDir: string, file: string): string {
const rel = relative(projectDir, file);
return rel && !rel.startsWith("..") ? rel : file;
}
+94 -1
View File
@@ -1,6 +1,11 @@
import * as vscode from "vscode";
import { dirname } from "node:path";
import { findElementAt, parseXml, textContentTokenAt } from "../language/xmlParser";
import {
findElementAt,
LineMap,
parseXml,
textContentTokenAt,
} from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import {
buildSearchPaths,
@@ -220,6 +225,94 @@ async function assetDefLocation(
);
}
/**
* Location of one asset definition for the asset search command.
*
* Unlike {@link assetDefLocation} this needs no document scope: an already
* open (possibly unsaved) document wins, then manifest sources are resolved
* with the SDK-only search paths, then the owning indexer's cached DOM is
* asked for the precise `id` attribute range. Returns null only when a
* manifest definition has no resolvable XML source at all.
*/
export async function assetDefinitionLocation(
ws: ModWorkspace,
def: AssetDef,
idx: ModIndex,
): Promise<vscode.Location | null> {
const open = vscode.workspace.textDocuments.find(
(doc) => scopePathKey(doc.uri.fsPath) === scopePathKey(def.file),
);
if (open) {
const precise = locationInText(open.uri, open.getText(), def.id);
if (precise) return precise;
}
// 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) return lineLocation(def);
if (def.origin === "manifest") {
const src = def.manifestSource;
if (!src) return null;
// manifestSource is a vanilla build path: resolve it with SDK-only search
// paths so a mod file shadowing the same DATA: path cannot hijack the
// jump. Opening the binary manifest itself would not help the user, so a
// missing SDK source stays unresolved instead.
const resolved = resolveSource(
src,
null,
buildVanillaSearchPaths(idx.sdkDir),
).path;
if (!resolved) return null;
return (
(await locationInDocument(ws, resolved, def.id)) ??
new vscode.Location(vscode.Uri.file(resolved), new vscode.Position(0, 0))
);
}
return (await locationInDocument(ws, def.file, def.id)) ?? lineLocation(def);
}
function lineLocation(def: AssetDef): vscode.Location {
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)),
);
}
/** Precise `id` range inside arbitrary (possibly unsaved) document text. */
function locationInText(
uri: vscode.Uri,
text: string,
id: string,
): vscode.Location | null {
const parsed = parseXml(text);
const wanted = id.toLowerCase();
const el = parsed.elements.find((e) =>
e.attrs.some((a) => a.name === "id" && a.value.toLowerCase() === wanted),
);
if (!el) return null;
const lineMap = new LineMap(text);
const idAttr = el.attrs.find((a) => a.name === "id");
if (idAttr?.hasValue) {
return new vscode.Location(
uri,
new vscode.Range(
toVscodePosition(lineMap.positionAt(idAttr.valueStart)),
toVscodePosition(lineMap.positionAt(idAttr.valueEnd)),
),
);
}
return new vscode.Location(
uri,
new vscode.Range(
toVscodePosition(lineMap.positionAt(el.start)),
toVscodePosition(lineMap.positionAt(el.startTagEnd)),
),
);
}
function locationInCurrentDocument(
scope: DocumentScope,
id: string,
+318
View File
@@ -0,0 +1,318 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
assetDefsForCandidate,
assetOriginRank,
collectAssetSearchCandidates,
isEmptyAssetSearchQuery,
parseAssetSearchQuery,
searchAssetCandidates,
} from "../out/features/assetSearch.js";
function makeDef(type, id, file, line, extra = {}) {
return { type, id, file, line, origin: "project", ...extra };
}
function put(map, d) {
let byId = map.get(d.type);
if (!byId) {
byId = new Map();
map.set(d.type, byId);
}
const key = d.id.toLowerCase();
const arr = byId.get(key);
if (arr) arr.push(d);
else byId.set(key, [d]);
}
/** Minimal ModIndex surface used by the search core. */
function makeIndex({ assets = [], local = [] } = {}) {
const index = { assets: new Map() };
for (const d of assets) put(index.assets, d);
if (local.length) {
const localAssets = new Map();
for (const d of local) put(localAssets, d);
index.local = {
assets: localAssets,
assetsById: new Map(),
defines: new Map(),
};
}
return index;
}
const PROJECT = "D:/Mods/Corona";
const SDK = "C:/Apps/RA3-MODSDK-X";
test("parseAssetSearchQuery splits Type:Id, bare id and type-only queries", () => {
assert.deepEqual(parseAssetSearchQuery(" Assault "), {
raw: "Assault",
type: null,
id: "assault",
});
assert.deepEqual(parseAssetSearchQuery("WeaponTemplate:AssaultRifle"), {
raw: "WeaponTemplate:AssaultRifle",
type: "weapontemplate",
id: "assaultrifle",
});
assert.deepEqual(parseAssetSearchQuery("Weapon:"), {
raw: "Weapon:",
type: "weapon",
id: "",
});
// A bare leading colon is just an id search.
assert.deepEqual(parseAssetSearchQuery(":Assault"), {
raw: ":Assault",
type: null,
id: "assault",
});
// Manifest-style qualified names: type = first segment, id = last segment.
assert.deepEqual(parseAssetSearchQuery("W3DContainer:W3DContainer:AU"), {
raw: "W3DContainer:W3DContainer:AU",
type: "w3dcontainer",
id: "au",
});
});
test("parseAssetSearchQuery recognises empty queries", () => {
assert.ok(isEmptyAssetSearchQuery(parseAssetSearchQuery("")));
assert.ok(isEmptyAssetSearchQuery(parseAssetSearchQuery(" ")));
assert.ok(isEmptyAssetSearchQuery(parseAssetSearchQuery(":")));
assert.ok(!isEmptyAssetSearchQuery(parseAssetSearchQuery("Weapon:")));
});
test("collectAssetSearchCandidates dedupes type:id and counts definition sites", () => {
const index = makeIndex({
assets: [
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/weapons.xml`, 4),
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/weapons.xml`, 9),
makeDef("WeaponTemplate", "AssaultRifle", `${SDK}/builtmods/static.manifest`, 0, {
origin: "manifest",
manifestSource: "DATA:globaldata/weapon.xml",
}),
makeDef("GameObject", "AssaultRifle", `${PROJECT}/Data/units.xml`, 2),
],
});
const candidates = collectAssetSearchCandidates(index);
assert.equal(candidates.length, 2);
const weapon = candidates.find((c) => c.def.type === "WeaponTemplate");
assert.equal(weapon.definitionCount, 3);
// The mod definition wins over the manifest one.
assert.equal(weapon.def.origin, "project");
assert.equal(weapon.def.line, 4);
const gameObject = candidates.find((c) => c.def.type === "GameObject");
assert.equal(gameObject.definitionCount, 1);
});
test("collectAssetSearchCandidates merges the local overlay (unsaved wins)", () => {
const index = makeIndex({
assets: [makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 12)],
local: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 12, {
stream: "local",
}),
makeDef("GameObject", "TankPrototype", `${PROJECT}/Data/units.xml`, 40, {
stream: "local",
}),
],
});
const byId = new Map(
collectAssetSearchCandidates(index).map((c) => [c.def.id, c]),
);
assert.equal(byId.size, 2);
assert.equal(byId.get("Tank").def.stream, "local");
// The same file+line from overlay and global index is one definition.
assert.equal(byId.get("Tank").definitionCount, 1);
assert.equal(byId.get("TankPrototype").definitionCount, 1);
});
test("assetOriginRank orders local, project, SDK and manifest definitions", () => {
assert.equal(assetOriginRank(makeDef("A", "a", "f", 1, { stream: "local" })), 0);
assert.equal(assetOriginRank(makeDef("A", "a", "f", 1)), 1);
assert.equal(assetOriginRank(makeDef("A", "a", "f", 1, { origin: "sdk" })), 2);
assert.equal(
assetOriginRank(makeDef("A", "a", "f", 1, { origin: "manifest" })),
3,
);
});
test("searchAssetCandidates finds exact, prefix and partial ids", () => {
const index = makeIndex({
assets: [
makeDef("GameObject", "CrateDebris_01", `${PROJECT}/Data/a.xml`, 1),
makeDef("GameObject", "CrateDebris_02", `${PROJECT}/Data/a.xml`, 5),
makeDef("GameObject", "MyCrateDebris", `${PROJECT}/Data/a.xml`, 9),
makeDef("GameObject", "Unrelated", `${PROJECT}/Data/a.xml`, 13),
],
});
const candidates = collectAssetSearchCandidates(index);
const exact = searchAssetCandidates(
candidates,
parseAssetSearchQuery("CrateDebris_02"),
);
assert.equal(exact.total, 1);
assert.equal(exact.matches[0].def.id, "CrateDebris_02");
const partial = searchAssetCandidates(
candidates,
parseAssetSearchQuery("cratedebris"),
);
assert.equal(partial.total, 3);
// Prefix matches rank before substring matches.
assert.deepEqual(
partial.matches.map((m) => m.def.id),
["CrateDebris_01", "CrateDebris_02", "MyCrateDebris"],
);
const mid = searchAssetCandidates(
candidates,
parseAssetSearchQuery("Debris"),
);
assert.equal(mid.total, 3);
});
test("searchAssetCandidates is case-insensitive and type-aware for bare queries", () => {
const index = makeIndex({
assets: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/a.xml`, 1),
makeDef("WeaponTemplate", "TankGun", `${PROJECT}/Data/b.xml`, 1),
makeDef("GameObject", "OxTank", `${PROJECT}/Data/a.xml`, 5),
],
});
const candidates = collectAssetSearchCandidates(index);
const upper = searchAssetCandidates(candidates, parseAssetSearchQuery("TANK"));
assert.equal(upper.total, 3);
// A bare query that matches a type name lists that type's assets too.
const byType = searchAssetCandidates(
candidates,
parseAssetSearchQuery("GameObject"),
);
assert.equal(byType.total, 2);
assert.deepEqual(
byType.matches.map((m) => m.def.id).sort(),
["OxTank", "Tank"],
);
});
test("searchAssetCandidates honours Type:Id, Type: and prefixed queries", () => {
const index = makeIndex({
assets: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/a.xml`, 1),
makeDef("GameObject", "TankPrototype", `${PROJECT}/Data/a.xml`, 5),
makeDef("WeaponTemplate", "TankGun", `${PROJECT}/Data/b.xml`, 1),
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/b.xml`, 9),
],
});
const candidates = collectAssetSearchCandidates(index);
const typed = searchAssetCandidates(
candidates,
parseAssetSearchQuery("GameObject:Tank"),
);
assert.deepEqual(
typed.matches.map((m) => m.def.id),
["Tank", "TankPrototype"],
);
// Partial type names work as a filter as well.
const partialType = searchAssetCandidates(
candidates,
parseAssetSearchQuery("weapontemplate:assault"),
);
assert.equal(partialType.total, 1);
assert.equal(partialType.matches[0].def.id, "AssaultRifle");
// `Type:` lists every asset of that type.
const typeOnly = searchAssetCandidates(
candidates,
parseAssetSearchQuery("WeaponTemplate:"),
);
assert.equal(typeOnly.total, 2);
// A mismatching type excludes otherwise matching ids.
const mismatch = searchAssetCandidates(
candidates,
parseAssetSearchQuery("WeaponTemplate:Tank"),
);
assert.equal(mismatch.total, 1); // TankGun only (prefix), not GameObject Tank
assert.equal(mismatch.matches[0].def.id, "TankGun");
});
test("searchAssetCandidates ranks mod definitions before vanilla ones", () => {
const index = makeIndex({
assets: [
makeDef("WeaponTemplate", "AssaultRifle", `${SDK}/builtmods/static.manifest`, 0, {
origin: "manifest",
}),
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/weapon.xml`, 7),
],
});
const candidates = collectAssetSearchCandidates(index);
const result = searchAssetCandidates(
candidates,
parseAssetSearchQuery("AssaultRifle"),
);
assert.equal(result.total, 1);
assert.equal(result.matches[0].def.origin, "project");
assert.equal(result.matches[0].definitionCount, 2);
});
test("searchAssetCandidates returns no rows for an empty query and caps results", () => {
const assets = [];
for (let i = 0; i < 25; i++) {
assets.push(
makeDef("GameObject", `Target_${String(i).padStart(2, "0")}`, "f", i + 1),
);
}
const candidates = collectAssetSearchCandidates(makeIndex({ assets }));
const empty = searchAssetCandidates(candidates, parseAssetSearchQuery(""));
assert.equal(empty.total, 0);
assert.equal(empty.matches.length, 0);
const all = searchAssetCandidates(candidates, parseAssetSearchQuery("Target"));
assert.equal(all.total, 25);
assert.equal(all.matches.length, 25);
const capped = searchAssetCandidates(
candidates,
parseAssetSearchQuery("Target"),
10,
);
assert.equal(capped.total, 25);
assert.equal(capped.matches.length, 10);
});
test("assetDefsForCandidate lists distinct sites, local overlay first", () => {
const index = makeIndex({
assets: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 3),
makeDef("GameObject", "Tank", `${SDK}/SageXml/units.xml`, 1, {
origin: "sdk",
}),
makeDef("GameObject", "Tank", `${SDK}/SageXml/units.xml`, 1, {
origin: "sdk",
}),
],
local: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 3, {
stream: "local",
}),
],
});
const candidate = collectAssetSearchCandidates(index).find(
(c) => c.def.id === "Tank",
);
const defs = assetDefsForCandidate(index, candidate);
assert.equal(defs.length, 2);
assert.equal(defs[0].stream, "local");
assert.equal(defs[1].origin, "sdk");
});