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.
|
- 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
|
||||||
|
|||||||
@@ -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…`
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)。要启用吗?",
|
"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 访问",
|
"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
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 查找该类型的未引用资产"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 * 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,
|
||||||
|
|||||||
@@ -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