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. - 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. - 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 ## 0.1.24 — 2026-08-11
### Fixed ### 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. 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. **Find unreferenced assets** lists project assets that are not referenced anywhere in the workspace, helping identify obsolete or accidentally unused definitions.
Run: 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: Clear caches and rebuild`
* `RA3 Mod XML: Configure SDK path…` * `RA3 Mod XML: Configure SDK path…`
* `RA3 Mod XML: Show cache report` * `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…`
* `RA3 Mod XML: Find unreferenced assets of this type` * `RA3 Mod XML: Find unreferenced assets of this type`
* `RA3 Mod XML: Enable AI Agent access…` * `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: Clear caches and rebuild`(清除缓存并重建)
* `RA3 Mod XML: Configure SDK path…`(配置 SDK 路径) * `RA3 Mod XML: Configure SDK path…`(配置 SDK 路径)
* `RA3 Mod XML: Show cache report`(显示缓存报告) * `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…`(查找未引用的资产…)
* `RA3 Mod XML: Find unreferenced assets of this type`(查找此类型的未引用资产) * `RA3 Mod XML: Find unreferenced assets of this type`(查找此类型的未引用资产)
* `RA3 Mod XML: Enable AI Agent access…`(启用 AI Agent 访问…) * `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?", "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", "Enable AI Agent access": "Enable AI Agent access",
"Learn more": "Learn more", "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)。要启用吗?", "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 访问", "Enable AI Agent access": "启用 AI Agent 访问",
"Learn more": "了解更多", "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": "本地"
} }
+19 -10
View File
@@ -150,6 +150,10 @@
"command": "ra3modxml.exportIndexSnapshot", "command": "ra3modxml.exportIndexSnapshot",
"title": "%ra3modxml.command.exportIndexSnapshot.title%" "title": "%ra3modxml.command.exportIndexSnapshot.title%"
}, },
{
"command": "ra3modxml.findAsset",
"title": "%ra3modxml.command.findAsset.title%"
},
{ {
"command": "ra3modxml.findUnreferencedAssets", "command": "ra3modxml.findUnreferencedAssets",
"title": "%ra3modxml.command.findUnreferencedAssets.title%" "title": "%ra3modxml.command.findUnreferencedAssets.title%"
@@ -158,16 +162,21 @@
"command": "ra3modxml.findUnreferencedAssetsOfType", "command": "ra3modxml.findUnreferencedAssetsOfType",
"title": "%ra3modxml.command.findUnreferencedAssetsOfType.title%" "title": "%ra3modxml.command.findUnreferencedAssetsOfType.title%"
} }
] ],
}, "menus": {
"menus": { "editor/context": [
"editor/context": [ {
{ "command": "ra3modxml.findAsset",
"command": "ra3modxml.findUnreferencedAssetsOfType", "when": "editorLangId == xml && ra3modxml.active",
"when": "editorLangId == xml && ra3modxml.active", "group": "navigation@45"
"group": "navigation@50" },
} {
] "command": "ra3modxml.findUnreferencedAssetsOfType",
"when": "editorLangId == xml && ra3modxml.active",
"group": "navigation@50"
}
]
}
}, },
"scripts": { "scripts": {
"build": "node esbuild.mjs", "build": "node esbuild.mjs",
+1
View File
@@ -19,6 +19,7 @@
"ra3modxml.command.uninstallAgentSkill.title": "RA3 Mod XML: Uninstall Agent Skill…", "ra3modxml.command.uninstallAgentSkill.title": "RA3 Mod XML: Uninstall Agent Skill…",
"ra3modxml.command.uninstallAgentIntegration.title": "RA3 Mod XML: Uninstall AI Agent integration…", "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.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.findUnreferencedAssets.title": "RA3 Mod XML: Find unreferenced assets…",
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: Find unreferenced assets of this type" "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.uninstallAgentSkill.title": "RA3 Mod XML: 卸载 Agent Skill…",
"ra3modxml.command.uninstallAgentIntegration.title": "RA3 Mod XML: 卸载 AI Agent 集成…", "ra3modxml.command.uninstallAgentIntegration.title": "RA3 Mod XML: 卸载 AI Agent 集成…",
"ra3modxml.command.exportIndexSnapshot.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.findUnreferencedAssets.title": "RA3 Mod XML: 查找未引用的资产…",
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: 查找该类型的未引用资产" "ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: 查找该类型的未引用资产"
} }
+16
View File
@@ -18,6 +18,7 @@ import {
findUnreferencedAssets, findUnreferencedAssets,
findUnreferencedAssetsOfType, findUnreferencedAssetsOfType,
} from "./features/unreferenced"; } from "./features/unreferenced";
import { findAsset } from "./features/findAsset";
import { Ra3Diagnostics } from "./features/diagnostics"; import { Ra3Diagnostics } from "./features/diagnostics";
import { import {
Ra3SemanticTokensProvider, Ra3SemanticTokensProvider,
@@ -966,6 +967,21 @@ export function activate(context: vscode.ExtensionContext): void {
void showReferencesForDef(ws, args), 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( context.subscriptions.push(
vscode.commands.registerCommand( vscode.commands.registerCommand(
"ra3modxml.findUnreferencedAssets", "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 * as vscode from "vscode";
import { dirname } from "node:path"; 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 { resolveElementType } from "../language/typeContext";
import { import {
buildSearchPaths, 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( function locationInCurrentDocument(
scope: DocumentScope, scope: DocumentScope,
id: string, 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");
});