From 4f422a748efbab28d2f1d14535979ec7f437e510 Mon Sep 17 00:00:00 2001 From: lanyizi Date: Thu, 10 Sep 2026 23:16:36 +0200 Subject: [PATCH] find asset --- CHANGELOG.md | 17 ++ README.md | 9 + README.zh-CN.md | 9 + l10n/bundle.l10n.json | 14 +- l10n/bundle.l10n.zh-cn.json | 14 +- package.json | 29 ++-- package.nls.json | 1 + package.nls.zh-cn.json | 1 + src/extension.ts | 16 ++ src/features/assetSearch.ts | 283 ++++++++++++++++++++++++++++++++ src/features/findAsset.ts | 243 +++++++++++++++++++++++++++ src/features/navigation.ts | 95 ++++++++++- test/assetSearch.test.mjs | 318 ++++++++++++++++++++++++++++++++++++ 13 files changed, 1036 insertions(+), 13 deletions(-) create mode 100644 src/features/assetSearch.ts create mode 100644 src/features/findAsset.ts create mode 100644 test/assetSearch.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b45c03..cbdc762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c9f9b91..646da27 100644 --- a/README.md +++ b/README.md @@ -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…` diff --git a/README.zh-CN.md b/README.zh-CN.md index 3e79505..ab17cfe 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 访问…) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 2907f7d..f503993 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -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" } diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index 1c52e59..04d56d5 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -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": "本地" } diff --git a/package.json b/package.json index c3b2c1b..4a63a6b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/package.nls.json b/package.nls.json index ae6bb51..6a2d26d 100644 --- a/package.nls.json +++ b/package.nls.json @@ -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" } diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index f92f8ff..b80d069 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -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: 查找该类型的未引用资产" } diff --git a/src/extension.ts b/src/extension.ts index 1f1fa4f..6756a34 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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", diff --git a/src/features/assetSearch.ts b/src/features/assetSearch.ts new file mode 100644 index 0000000..1473f01 --- /dev/null +++ b/src/features/assetSearch.ts @@ -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; 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(); + + 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; +} diff --git a/src/features/findAsset.ts b/src/features/findAsset.ts new file mode 100644 index 0000000..ced4623 --- /dev/null +++ b/src/features/findAsset.ts @@ -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 { + 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 { + 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 { + return new Promise((resolve) => { + const picker = vscode.window.createQuickPick(); + 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 { + 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; +} diff --git a/src/features/navigation.ts b/src/features/navigation.ts index e5d7cdc..a853a98 100644 --- a/src/features/navigation.ts +++ b/src/features/navigation.ts @@ -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 { + 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, diff --git a/test/assetSearch.test.mjs b/test/assetSearch.test.mjs new file mode 100644 index 0000000..aba3f9d --- /dev/null +++ b/test/assetSearch.test.mjs @@ -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"); +});