find asset
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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…`
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 Agent(MCP + 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": "本地"
|
||||
}
|
||||
|
||||
+19
-10
@@ -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.findUnreferencedAssetsOfType",
|
||||
"when": "editorLangId == xml && ra3modxml.active",
|
||||
"group": "navigation@50"
|
||||
}
|
||||
]
|
||||
],
|
||||
"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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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: 查找该类型的未引用资产"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user