0.1.15 继续优化补全

This commit is contained in:
2026-08-04 19:14:12 +02:00
parent f8187da611
commit 47807f9fed
17 changed files with 1762 additions and 87 deletions
+250 -30
View File
@@ -1,5 +1,6 @@
import * as vscode from "vscode";
import type { XmlAttribute, XmlElement } from "../language/xmlParser";
import { textContentTokenAt } from "../language/xmlParser";
import {
analyzeContext,
splitListValuePrefix,
@@ -7,7 +8,7 @@ import {
} from "../language/context";
import { resolveElementType } from "../language/typeContext";
import * as model from "../model/schemaModel";
import type { AttributeInfo } from "../model/schemaModel";
import type { AttributeInfo, SimpleTypeInfo } from "../model/schemaModel";
import { isLocalReferenceAttribute } from "../indexer/refs";
import {
findContainingGameObject,
@@ -26,7 +27,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
document: vscode.TextDocument,
position: vscode.Position,
_token: vscode.CancellationToken,
): Promise<vscode.CompletionItem[]> {
): Promise<vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem>> {
if (!this.ws.isRa3Workspace()) return [];
const text = document.getText();
const offset = document.offsetAt(position);
@@ -61,10 +62,12 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
const names = this.childrenOf(parent);
if (!names.length) return [];
// The "<" already exists at the element's start: replace only the name
// area and insert the tag body WITHOUT a leading "<", otherwise the
// range text ("<") would also be used as the filter prefix and hide
// every suggestion.
const start = ctx.element
? document.offsetAt(
document.positionAt(ctx.element.start + (ctx.closing ? 2 : 1)),
)
? ctx.element.start + (ctx.closing ? 2 : 1)
: document.offsetAt(position);
const range = new vscode.Range(document.positionAt(start), position);
const items: vscode.CompletionItem[] = [];
@@ -79,7 +82,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
(type ? `Type: ${type}` : "");
item.documentation = docText ? new vscode.MarkdownString(docText) : undefined;
item.detail = type ? `RA3 XML · ${type}` : "RA3 XML";
item.insertText = this.elementSnippet(child.name, type);
item.insertText = this.elementSnippet(child.name, type, ctx.element == null);
items.push(item);
}
return items;
@@ -103,16 +106,27 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
return [];
}
private elementSnippet(name: string, type: string | null): vscode.SnippetString {
private elementSnippet(
name: string,
type: string | null,
includeOpenBracket = true,
): vscode.SnippetString {
const open = includeOpenBracket ? "<" : "";
if (model.isTopLevelElement(name)) {
return new vscode.SnippetString(`<${name} id="$1">\n\t$0\n</${name}>`);
return new vscode.SnippetString(`${open}${name} id="$1">\n\t$0\n</${name}>`);
}
const info = type ? model.typeInfo(type) : undefined;
// Simple types hold text content (asset id / enum / define / string), so
// they need an explicit closing tag and a value placeholder instead of a
// self-closing tag that can never contain a value.
if (info?.kind === "simple") {
return new vscode.SnippetString(`${open}${name}>$1</${name}>`);
}
const hasChildren = info?.kind === "complex" && info.children.length > 0;
if (hasChildren) {
return new vscode.SnippetString(`<${name}>\n\t$0\n</${name}>`);
return new vscode.SnippetString(`${open}${name}>\n\t$0\n</${name}>`);
}
return new vscode.SnippetString(`<${name} />`);
return new vscode.SnippetString(`${open}${name} />`);
}
// ── Attribute name ────────────────────────────────────────────────
@@ -238,7 +252,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
document: vscode.TextDocument,
position: vscode.Position,
idx: ModIndex | null,
): vscode.CompletionItem[] {
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const el = ctx.element;
const attr = ctx.attr;
if (!el || !attr) return [];
@@ -358,7 +372,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
* (" FLAG") so flags can be appended to an already-closed value.
*/
private listEnumItems(
attrInfo: AttributeInfo,
attrInfo: { enumValues: string[]; type?: string | null },
rawPrefix: string,
seg: { token: string; start: number },
valueRange: vscode.Range,
@@ -410,18 +424,18 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
idx: ModIndex,
prefix: string,
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] {
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const lower = prefix.toLowerCase();
const candidates = idx.sourceCandidates
.filter((c) => c.source.toLowerCase().includes(lower))
.slice(0, MAX_VALUE_ITEMS);
const candidates = idx.sourceCandidates.filter((c) =>
c.source.toLowerCase().includes(lower),
);
const priority: Record<string, number> = { "": 0, DATA: 1, ART: 2, AUDIO: 3 };
candidates.sort(
(a, b) =>
(priority[a.prefix ?? ""] ?? 4) - (priority[b.prefix ?? ""] ?? 4) ||
a.source.localeCompare(b.source),
);
return candidates.map((c) => {
const items = candidates.map((c) => {
const item = make(c.source, vscode.CompletionItemKind.File, "Include source");
item.detail = c.path;
item.documentation = new vscode.MarkdownString(
@@ -429,6 +443,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
);
return item;
});
return this.limitItems(items, items.length);
}
private assetIdItems(
@@ -437,7 +452,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
refType: string | null,
prefix: string,
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] {
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const lower = prefix.toLowerCase();
const scored: { def: AssetDef; score: number }[] = [];
const seen = new Set<string>();
@@ -451,6 +466,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
if (refType && model.isAssignableTo(def.type, refType)) score = 1;
if (selfType && model.isAssignableTo(def.type, selfType)) score = 0;
if (def.origin === "project") score -= 0.2;
if (def.stream === "local") score -= 0.4;
scored.push({ def, score });
};
@@ -476,8 +492,8 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
}
}
scored.sort((a, b) => a.score - b.score || a.def.id.localeCompare(b.def.id));
return scored.slice(0, MAX_VALUE_ITEMS).map(({ def }) => {
const top = topScoredDefs(scored, MAX_VALUE_ITEMS);
const items = top.map(({ def }) => {
const origin = def.origin === "manifest" ? `manifest (${def.manifestSource ?? ""})` : def.origin;
const doc = new vscode.MarkdownString();
doc.appendCodeblock(def.id);
@@ -486,13 +502,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
doc.appendMarkdown(`**Origin**: ${origin}`);
return make(def.id, vscode.CompletionItemKind.Value, `${def.type} · ${origin}`, doc.value);
});
return this.limitItems(items, scored.length);
}
private defineItems(
idx: ModIndex,
prefix: string,
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] {
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const lower = prefix.replace(/^[=$]*/, "").toLowerCase();
const items: vscode.CompletionItem[] = [];
const seen = new Set<string>();
@@ -510,14 +527,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
items.push(item);
}
}
return items.slice(0, MAX_VALUE_ITEMS);
return this.limitItems(items, items.length);
}
private localIdItems(
el: LogicalElement,
prefix: string,
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] {
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const root = findContainingGameObject(el);
if (!root) return [];
const lower = prefix.toLowerCase();
@@ -533,35 +550,238 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
),
);
}
return items;
return this.limitItems(items, items.length);
}
/**
* VS Code filters the returned items client-side while the user keeps
* typing. Once the result is capped, the list must be marked incomplete so
* the provider is asked again with the narrower prefix; otherwise a wanted
* id (e.g. CrateDebris_01) can be silently cut off behind the first 400
* alphabetically-earlier candidates and never reappear.
*/
private limitItems<T extends vscode.CompletionItem>(
items: T[],
total: number,
): T[] | vscode.CompletionList<T> {
if (total <= MAX_VALUE_ITEMS) return items;
return new vscode.CompletionList(items.slice(0, MAX_VALUE_ITEMS), true);
}
// ── Element content ───────────────────────────────────────────────
private contentItems(
ctx: CompletionContext,
_document: vscode.TextDocument,
_position: vscode.Position,
_idx: ModIndex | null,
): vscode.CompletionItem[] {
document: vscode.TextDocument,
position: vscode.Position,
idx: ModIndex | null,
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const el = ctx.element;
if (!el) return [];
// Reuse element-name suggestions with a plain replacement range.
const elType = resolveElementType(el);
const info = elType ? model.typeInfo(elType) : undefined;
// Simple-content element: the text between the tags is the value itself
// (e.g. <CreateObject>CrateDebris_01</CreateObject>), so offer value
// completions (asset ids / enums / defines) instead of child elements.
if (info?.kind === "simple") {
return this.simpleContentItems(el, elType, info, document, position, idx);
}
return this.contentChildItems(el, document, position);
}
private simpleContentItems(
el: XmlElement,
elType: string | null,
info: SimpleTypeInfo,
document: vscode.TextDocument,
position: vscode.Position,
idx: ModIndex | null,
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const text = document.getText();
const offset = document.offsetAt(position);
const token = textContentTokenAt(text, el, offset);
const prefix = token ? text.slice(token.start, Math.min(offset, token.end)) : "";
const rawPrefix = text.slice(el.startTagEnd, offset);
const isList = info.isList === true;
const seg = isList
? splitListValuePrefix(rawPrefix)
: { token: prefix, start: token ? token.start - el.startTagEnd : 0 };
const rangeStart = token ? token.start : offset;
const valueRange = new vscode.Range(
document.positionAt(rangeStart),
document.positionAt(Math.max(rangeStart, offset)),
);
const make = (
label: string,
kind: vscode.CompletionItemKind,
detail: string,
doc?: string,
range?: vscode.Range,
insertText?: string,
) => {
const item = new vscode.CompletionItem(label, kind);
item.range = range ?? valueRange;
item.insertText = insertText ?? label;
item.detail = detail;
if (doc) item.documentation = new vscode.MarkdownString(doc);
return item;
};
// Typed asset references only (isRef without refType is used by real
// data for shader constants / mesh sub-object names, not global ids).
if (info.refType) {
if (!idx) return [];
return this.assetIdItems(idx, null, info.refType, seg.token, make);
}
if (info.enumValues.length) {
if (isList) return this.listEnumItems(info, rawPrefix, seg, valueRange, make);
return info.enumValues
.filter((v) => v.toLowerCase().startsWith(seg.token.toLowerCase()))
.map((v) => make(v, vscode.CompletionItemKind.EnumMember, elType ?? "enum"));
}
if (idx && info.allowsDefine) {
return this.defineItems(idx, seg.token, make);
}
return [];
}
private contentChildItems(
el: XmlElement,
document: vscode.TextDocument,
position: vscode.Position,
): vscode.CompletionItem[] {
const elType = resolveElementType(el);
const names = elType ? model.childrenOfType(elType) : model.childrenOfElement(el.name);
const items: vscode.CompletionItem[] = [];
if (!names.length) return items;
const text = document.getText();
const offset = document.offsetAt(position);
// When the user already typed "<" (optionally followed by a partial
// name), keep that "<" and replace only the name area; the inserted
// snippet then has no leading "<" so the range text stays a valid
// filter prefix ("", "Cr", ...) instead of "<" (which would hide every
// item). Without a typed "<" the full "<Name>…</Name>" is inserted.
const { rangeStart, typedOpen } = contentElementRange(text, offset);
const range = new vscode.Range(document.positionAt(rangeStart), position);
for (const child of names) {
const item = new vscode.CompletionItem(child.name, vscode.CompletionItemKind.Field);
item.insertText = this.elementSnippet(child.name, child.type);
item.range = range;
item.insertText = this.elementSnippet(child.name, child.type, !typedOpen);
const type = child.type;
const info = type ? model.typeInfo(type) : undefined;
item.detail = type ? `RA3 XML · ${type}` : "RA3 XML";
const doc = child.doc || (info?.kind === "complex" ? info.doc : "");
if (doc) item.documentation = new vscode.MarkdownString(doc);
if (info?.kind === "simple" && this.simpleContentValueKind(info)) {
item.command = {
command: "editor.action.triggerSuggest",
title: "Suggest content value",
};
}
items.push(item);
}
return items;
}
private simpleContentValueKind(info: SimpleTypeInfo): boolean {
return (
info.refType != null ||
info.enumValues.length > 0 ||
info.allowsDefine
);
}
}
interface ScoredDef {
def: AssetDef;
score: number;
}
function compareScoredDefs(a: ScoredDef, b: ScoredDef): number {
return a.score - b.score || a.def.id.localeCompare(b.def.id);
}
/**
* Returns the best `limit` scored definitions without sorting the whole
* candidate list. A max-heap keeps the worst item of the current top set at
* the root, so every additional candidate only needs an O(log limit) check.
*/
function topScoredDefs(scored: ScoredDef[], limit: number): ScoredDef[] {
if (scored.length <= limit) {
scored.sort(compareScoredDefs);
return scored;
}
const better = (a: ScoredDef, b: ScoredDef) => compareScoredDefs(a, b) < 0;
const heap: ScoredDef[] = [];
const swap = (i: number, j: number) => {
const t = heap[i];
heap[i] = heap[j];
heap[j] = t;
};
const siftUp = (i: number) => {
while (i > 0) {
const parent = (i - 1) >> 1;
if (better(heap[i], heap[parent])) {
swap(i, parent);
i = parent;
} else {
break;
}
}
};
const siftDown = (i: number) => {
for (;;) {
const left = i * 2 + 1;
const right = left + 1;
let worst = i;
if (left < heap.length && better(heap[left], heap[worst])) worst = left;
if (right < heap.length && better(heap[right], heap[worst])) worst = right;
if (worst === i) break;
swap(i, worst);
i = worst;
}
};
for (const entry of scored) {
if (heap.length < limit) {
heap.push(entry);
siftUp(heap.length - 1);
} else if (better(entry, heap[0])) {
heap[0] = entry;
siftDown(0);
}
}
heap.sort(compareScoredDefs);
return heap;
}
/**
* Replacement range for a child-element completion in element content:
* - when the user typed "<" (optionally followed by a partial name), the
* "<" is kept and the range covers the name area after it;
* - a partial name typed without "<" is replaced as a word;
* - whitespace-only content leaves the range empty at the cursor.
* Typing a closing tag ("</" / "</Name") never suggests child elements.
*/
function contentElementRange(
text: string,
offset: number,
): { rangeStart: number; typedOpen: boolean } {
let j = offset;
while (j > 0 && /[ \t]/.test(text[j - 1])) j--;
let i = j;
while (i > 0 && /[A-Za-z0-9_:.-]/.test(text[i - 1])) i--;
if (i >= 2 && text.slice(i - 2, i) === "</") {
return { rangeStart: offset, typedOpen: false };
}
if (i > 0 && text[i - 1] === "<") {
return { rangeStart: i, typedOpen: true };
}
if (i < j) return { rangeStart: i, typedOpen: false };
return { rangeStart: offset, typedOpen: false };
}
interface AttributeInsertLayout {
+79
View File
@@ -7,8 +7,10 @@ import * as model from "../model/schemaModel";
import type { ModWorkspace } from "../workspace";
import type { ModIndex } from "../indexer/types";
import {
isReferenceContentType,
isReferenceAttributeOfType,
mergeLocalAndGlobalDefs,
resolveContentReferenceTargets,
resolveReferenceTargetsForType,
} from "../indexer/refs";
import type { LogicalElement } from "../indexer/logicalTree";
@@ -198,6 +200,7 @@ export class Ra3Diagnostics {
provisional,
);
}
this.checkContentReferences(el, elType, document, idx, diags, provisional);
}
// Include-specific checks.
@@ -326,6 +329,82 @@ export class Ra3Diagnostics {
);
}
private checkContentReferences(
el: XmlElement,
elType: string | null,
document: vscode.TextDocument,
idx: ModIndex | null,
diags: vscode.Diagnostic[],
provisional: boolean,
): void {
// Only simple-content elements carry a text value; complex elements'
// "content" is child markup and must not be scanned for value refs.
const info = elType ? model.typeInfo(elType) : undefined;
if (info?.kind !== "simple") return;
if (el.selfClosing || el.closeTagStart < 0) return;
const text = document.getText();
const raw = text.slice(el.startTagEnd, el.closeTagStart);
const value = raw.trim();
if (!value) return;
const valueStart = el.startTagEnd + raw.indexOf(value);
const range = new vscode.Range(
document.positionAt(valueStart),
document.positionAt(valueStart + value.length),
);
// Undefined $DEFINE references.
const defineRe = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
let m: RegExpExecArray | null;
while ((m = defineRe.exec(value)) !== null) {
if (
idx &&
!(
idx.local?.defines.has(m[1].toLowerCase()) ??
idx.defines.has(m[1].toLowerCase())
)
) {
const code = provisional ? "undefined-define-indexing" : "undefined-define";
diags.push(
this.diag(
range,
`Undefined define "$${m[1]}"` +
(provisional ? " (index incomplete — may be a false positive)" : ""),
vscode.DiagnosticSeverity.Warning,
code,
),
);
}
}
if (value.startsWith("$") || value.startsWith("=")) return;
const severity = this.ws.settings.reportUnresolvedReferences;
if (severity === "none" || !idx) return;
if (!isReferenceContentType(elType)) return;
const targets = resolveContentReferenceTargets(idx, elType, value);
if (targets.length) return;
const anyDef =
(idx.local?.assetsById.has(value.toLowerCase()) ?? false) ||
idx.assetsById.has(value.toLowerCase());
const refType = info.refType;
const expected = refType ? `of type \`${refType}\`` : "of the expected declared type";
const code = provisional ? "unresolved-reference-indexing" : "unresolved-reference";
const baseMessage = anyDef
? `Reference "${value}" has no definition ${expected} (ids with the same name exist for other types)`
: `Unresolved reference "${value}" (not found in the current index)`;
diags.push(
this.diag(
range,
provisional
? `${baseMessage} (index incomplete — may be a false positive)`
: baseMessage,
severity === "warning"
? vscode.DiagnosticSeverity.Warning
: vscode.DiagnosticSeverity.Information,
code,
),
);
}
private checkInclude(
el: XmlElement,
document: vscode.TextDocument,
+87 -25
View File
@@ -1,12 +1,15 @@
import * as vscode from "vscode";
import { findElementAt } from "../language/xmlParser";
import { findElementAt, textContentTokenAt } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import * as model from "../model/schemaModel";
import type { ModWorkspace } from "../workspace";
import {
isLocalReferenceAttribute,
isReferenceAttributeOfType,
isReferenceContentType,
resolveContentReferenceTargets,
resolveReferenceTargetsForType,
type ReferenceTarget,
} from "../indexer/refs";
import {
findContainingGameObject,
@@ -27,6 +30,7 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
): Promise<vscode.Hover | null> {
if (!this.ws.isRa3Workspace()) return null;
const offset = document.offsetAt(position);
const text = document.getText();
const scope = await this.ws.getScope(document);
const doc = scope.expanded;
const el = findElementAt(doc, offset);
@@ -45,6 +49,12 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
return this.valueHover(el, elType, attr.name, attr.value, document, scope);
}
}
// Element text content (e.g. <CreateObject>CrateDebris_01</CreateObject>).
const contentToken = textContentTokenAt(text, el, offset);
if (contentToken) {
const h = this.contentHover(elType, contentToken.value, document, scope);
if (h) return h;
}
// Element name.
const nameStart = el.start + 1;
if (offset >= nameStart && offset <= nameStart + el.name.length) {
@@ -134,14 +144,8 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
const defs =
idx.local?.defines.get(defineMatch[1].toLowerCase()) ??
idx.defines.get(defineMatch[1].toLowerCase());
if (defs?.length) {
const d = defs[0];
md.appendMarkdown(`**Define** \`$${d.name}\` \n`);
md.appendCodeblock(d.value);
const rel = relativePath(document, d.file);
md.appendMarkdown(`Defined in \`${rel}:${d.line}\``);
return new vscode.Hover(md);
}
const h = this.defineHover(defs, document);
if (h) return h;
}
// Include source.
@@ -196,18 +200,7 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
{
if (!isReferenceAttributeOfType(elType, attrName)) return null;
const targets = resolveReferenceTargetsForType(idx, elType, attrName, value);
if (targets.length) {
const md2 = new vscode.MarkdownString();
md2.appendMarkdown(`**${targets.length} definition${targets.length > 1 ? "s" : ""}** \n`);
for (const { def: d } of targets.slice(0, 8)) {
const loc =
d.origin === "manifest"
? `manifest \`${d.manifestSource ?? d.file}\``
: `\`${relativePath(document, d.file)}:${d.line}\``;
md2.appendMarkdown(`- \`${d.type}\` · ${loc} \n`);
}
return new vscode.Hover(md2);
}
if (targets.length) return this.definitionsHover(targets, document);
const attrRef = model
.attributesOfType(elType)
.find((a) => a.name === attrName);
@@ -216,12 +209,81 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
: attrRef?.isRef
? " of the expected declared type"
: "";
md.appendMarkdown(
`No matching definition${expected} in the current index` +
" (may exist in a compiled manifest or vanilla data).",
);
return this.noDefinitionHover(expected);
}
}
/**
* Hover for text inside a simple-content element whose type is a typed
* asset reference (e.g. <CreateObject> with GameObjectWeakRef).
*/
private contentHover(
elType: string | null,
value: string,
document: vscode.TextDocument,
scope: DocumentScope,
): vscode.Hover | null {
const idx = scope.merged;
const defineMatch = /\$([A-Za-z_][A-Za-z0-9_]*)/.exec(value);
if (defineMatch && idx) {
const defs =
idx.local?.defines.get(defineMatch[1].toLowerCase()) ??
idx.defines.get(defineMatch[1].toLowerCase());
const h = this.defineHover(defs, document);
if (h) return h;
}
if (!isReferenceContentType(elType)) return null;
if (!idx) {
const md = new vscode.MarkdownString();
md.appendMarkdown("Index is still building — references cannot be resolved yet.");
return new vscode.Hover(md);
}
const targets = resolveContentReferenceTargets(idx, elType, value);
if (targets.length) return this.definitionsHover(targets, document);
const info = elType ? model.typeInfo(elType) : undefined;
const refType = info?.kind === "simple" ? info.refType : null;
return this.noDefinitionHover(
refType ? ` of type \`${refType}\`` : " of the expected declared type",
);
}
private defineHover(
defs: { name: string; value: string; file: string; line: number }[] | undefined,
document: vscode.TextDocument,
): vscode.Hover | null {
if (!defs?.length) return null;
const d = defs[0];
const md = new vscode.MarkdownString();
md.appendMarkdown(`**Define** \`$${d.name}\` \n`);
md.appendCodeblock(d.value);
const rel = relativePath(document, d.file);
md.appendMarkdown(`Defined in \`${rel}:${d.line}\``);
return new vscode.Hover(md);
}
private definitionsHover(
targets: ReferenceTarget[],
document: vscode.TextDocument,
): vscode.Hover {
const md2 = new vscode.MarkdownString();
md2.appendMarkdown(`**${targets.length} definition${targets.length > 1 ? "s" : ""}** \n`);
for (const { def: d } of targets.slice(0, 8)) {
const loc =
d.origin === "manifest"
? `manifest \`${d.manifestSource ?? d.file}\``
: `\`${relativePath(document, d.file)}:${d.line}\``;
md2.appendMarkdown(`- \`${d.type}\` · ${loc} \n`);
}
return new vscode.Hover(md2);
}
private noDefinitionHover(expected: string): vscode.Hover {
const md = new vscode.MarkdownString();
md.appendMarkdown(
`No matching definition${expected} in the current index` +
" (may exist in a compiled manifest or vanilla data).",
);
return new vscode.Hover(md);
}
private localIdHover(
+66 -19
View File
@@ -1,6 +1,6 @@
import * as vscode from "vscode";
import { dirname } from "node:path";
import { findElementAt, parseXml } from "../language/xmlParser";
import { findElementAt, parseXml, textContentTokenAt } from "../language/xmlParser";
import { resolveElementType } from "../language/typeContext";
import {
buildSearchPaths,
@@ -9,7 +9,10 @@ import {
} from "../indexer/includeResolver";
import {
isLocalReferenceAttribute,
isReferenceContentType,
resolveContentReferenceTargets,
resolveReferenceTargetsForType,
type ReferenceTarget,
} from "../indexer/refs";
import {
findContainingGameObject,
@@ -46,7 +49,19 @@ export class Ra3DefinitionProvider implements vscode.DefinitionProvider {
const attr = el.attrs.find(
(a) => a.hasValue && offset >= a.valueStart && offset <= a.valueEnd,
);
if (!attr) return null;
if (!attr) {
// Element text content (e.g. <CreateObject>CrateDebris_01</CreateObject>).
if (idx && isReferenceContentType(elType)) {
const token = textContentTokenAt(document.getText(), el, offset);
if (token && !token.value.startsWith("$")) {
const targets = resolveContentReferenceTargets(idx, elType, token.value);
if (targets.length) {
return this.referenceLocations(scope, idx, targets, document);
}
}
}
return null;
}
const value = attr.value;
const nameLower = attr.name.toLowerCase();
@@ -80,24 +95,34 @@ export class Ra3DefinitionProvider implements vscode.DefinitionProvider {
);
if (local) return local;
}
let targets = resolveReferenceTargetsForType(idx, elType, attr.name, value);
const targets = resolveReferenceTargetsForType(idx, elType, attr.name, value);
if (!targets.length) return null;
if (
this.ws.settings.definitionMode === "project-only" &&
targets.some((t) => t.def.origin === "project")
) {
targets = targets.filter((t) => t.def.origin === "project");
}
const locations: vscode.Location[] = [];
for (const { def } of targets.slice(0, 8)) {
const loc = await assetDefLocation(this.ws, def, idx, scope, document);
if (loc) locations.push(loc);
}
return locations.length ? locations : null;
return this.referenceLocations(scope, idx, targets, document);
}
return null;
}
private async referenceLocations(
scope: DocumentScope,
idx: ModIndex,
targets: ReferenceTarget[],
document: vscode.TextDocument,
): Promise<vscode.Location[] | null> {
let filtered = targets;
if (
this.ws.settings.definitionMode === "project-only" &&
targets.some((t) => t.def.origin === "project")
) {
filtered = targets.filter((t) => t.def.origin === "project");
}
const locations: vscode.Location[] = [];
for (const { def } of filtered.slice(0, 8)) {
const loc = await assetDefLocation(this.ws, def, idx, scope, document);
if (loc) locations.push(loc);
}
return locations.length ? locations : null;
}
private localIdLocation(
scope: DocumentScope,
el: LogicalElement,
@@ -264,19 +289,41 @@ export class Ra3ReferenceProvider implements vscode.ReferenceProvider {
(a.hasValue && offset >= a.valueStart && offset <= a.valueEnd) ||
(offset >= a.nameStart && offset <= a.nameEnd),
);
if (!attr?.hasValue) return null;
const id = attr.value;
let id: string | null = null;
if (attr?.hasValue) {
id = attr.value;
} else {
// Element text content (e.g. <CreateObject>CrateDebris_01</CreateObject>).
const elType = resolveElementType(el);
const token = textContentTokenAt(text, el, offset);
if (token && isReferenceContentType(elType) && !token.value.startsWith("$")) {
id = token.value;
}
}
if (!id || id.startsWith("$")) return null;
const locations: vscode.Location[] = [];
const pattern = `["']${escapeRegExp(id)}["']`;
// Matches both attribute values ("id" / 'id') and simple-content
// references (>id<); the outer delimiters are stripped from the result
// range below so the returned locations cover just the id.
const pattern = `(?:["']|>)[ \\t]*${escapeRegExp(id)}[ \\t]*(?:["']|<)`;
await findTextInWorkspace(
{ pattern, isRegExp: true },
{ include: "**/*.xml", maxResults: 2000 },
(result: { uri: vscode.Uri; matches: { range: vscode.Range }[] }) => {
if (!result.uri) return;
for (const m of result.matches) {
locations.push(new vscode.Location(result.uri, m.range));
const start = m.range.start;
const end = m.range.end;
locations.push(
new vscode.Location(
result.uri,
new vscode.Range(
new vscode.Position(start.line, start.character + 1),
new vscode.Position(end.line, end.character - 1),
),
),
);
}
},
);
+52
View File
@@ -2,6 +2,8 @@ import {
attributesOfType,
elementTypeName,
isAssignableTo,
typeChain,
typeInfo,
} from "../model/schemaModel";
import type { AssetDef, ModIndex } from "./types";
@@ -117,6 +119,56 @@ export function resolveReferenceTargetsForType(
refType = attr.refType;
}
return filterAndScoreDefs(defs, refType, selfType);
}
/**
* True when an element's text content is a typed reference to a global
* asset: the element's resolved XSD type is a simple type carrying an
* `xas:refType` (e.g. `<CreateObject>` with `GameObjectWeakRef`).
*
* Only *typed* refs are treated as content references. Generic untyped
* `AssetReference` content is used by real data for shader constants,
* mesh sub-object names and other values that are not global asset ids
* (`FXShaderConstantTexture@Value`, `RenderSubObjectReference@Mesh`), so
* resolving those globally would produce false hover/navigation/diagnostics.
* Poid pipeline-local ids are excluded for the same reason.
*/
export function isReferenceContentType(typeName: string | null): boolean {
if (!typeName) return false;
const info = typeInfo(typeName);
if (info?.kind !== "simple") return false;
if (typeChain(typeName).includes("Poid")) return false;
return info.refType != null;
}
/**
* Resolves the definitions an element's text content should point to,
* filtered by the element type's `xas:refType`
* (e.g. `GameObjectWeakRef` -> `GameObject`).
*/
export function resolveContentReferenceTargets(
idx: ModIndex,
typeName: string | null,
id: string,
): ReferenceTarget[] {
if (!isReferenceContentType(typeName)) return [];
if (!typeName) return [];
const defs = mergeLocalAndGlobalDefs(
idx.local?.assetsById.get(id.toLowerCase()),
idx.assetsById.get(id.toLowerCase()),
);
if (!defs.length) return [];
const info = typeInfo(typeName);
const refType = info?.kind === "simple" ? info.refType : null;
return filterAndScoreDefs(defs, refType, null);
}
function filterAndScoreDefs(
defs: readonly AssetDef[],
refType: string | null,
selfType: string | null,
): ReferenceTarget[] {
const targets: ReferenceTarget[] = [];
for (const def of defs) {
if (refType && !isAssignableTo(def.type, refType)) continue;
+12 -4
View File
@@ -1,5 +1,5 @@
import type { XmlAttribute, XmlDocument, XmlElement } from "./xmlParser";
import { parseTag } from "./xmlParser";
import { elementContainsOffset, parseTag } from "./xmlParser";
export type ContextKind =
| "element-name"
@@ -31,15 +31,23 @@ export function analyzeContext(
let container: XmlElement | null = null;
for (const el of doc.elements) {
if (el.end < 0) continue;
if (offset >= el.start && offset <= el.end) {
if (elementContainsOffset(el, offset)) {
if (!container || el.depth > container.depth) container = el;
}
}
if (!container) return empty("none");
// Inside the start tag of the element.
if (offset >= container.start && offset <= container.startTagEnd) {
// Inside the start tag of the element. The boundary right after `>` is the
// start of the content (e.g. the `$1` cursor in
// `<CreateObject>$1</CreateObject>`), not another attribute slot; only an
// unterminated start tag whose `>` has not been typed yet still belongs to
// the start tag at its recovered end.
const atTagEnd = offset === container.startTagEnd;
const tagClosed =
atTagEnd && container.startTagEnd > container.start &&
text[container.startTagEnd - 1] === ">";
if (offset >= container.start && (offset < container.startTagEnd || (atTagEnd && !tagClosed))) {
return analyzeStartTag(container, text, offset);
}
+76 -1
View File
@@ -420,6 +420,13 @@ function findTagEnd(text: string, from: number): number {
if (c === quote) quote = null;
} else if (c === '"' || c === "'") {
quote = c;
} else if (c === "<") {
// A new tag start before the current tag's ">" means the ">" we would
// find later belongs to that other tag (typically a closing tag after
// a just-typed "<" in element content). Treat the current tag as
// unterminated so the parser recovers at the line break: the context
// stays "content" and the completion range can cover the typed "<".
return -1;
} else if (c === ">") {
return i;
}
@@ -448,7 +455,7 @@ export function findElementAt(doc: XmlDocument, offset: number): XmlElement | nu
let best: XmlElement | null = null;
for (const el of doc.elements) {
if (el.end < 0) continue;
if (offset >= el.start && offset <= el.end) {
if (elementContainsOffset(el, offset)) {
if (!best || el.depth > best.depth) {
best = el;
}
@@ -457,6 +464,74 @@ export function findElementAt(doc: XmlDocument, offset: number): XmlElement | nu
return best;
}
/**
* Whether `offset` belongs to an element's span.
*
* The end offset is exclusive for a completed element (closing tag or
* self-closing tag): a cursor right after `</Name>` belongs to the parent's
* content, not the child. The one exception is an unclosed element whose
* parser-recovered `end` is the document end: a cursor at EOF is still
* inside the element being typed.
*/
export function elementContainsOffset(el: XmlElement, offset: number): boolean {
if (offset < el.start) return false;
if (offset < el.end) return true;
if (offset > el.end) return false;
return !el.selfClosing && el.closeTagStart < 0;
}
export interface TextToken {
value: string;
/** Absolute offset of the first character of the token. */
start: number;
/** Absolute offset one past the last character of the token. */
end: number;
}
/**
* Returns the whitespace-delimited text token inside an element's content
* that contains `offset`, with absolute source offsets. Used for
* simple-content elements (e.g. `<CreateObject>CrateDebris_01</CreateObject>`)
* by completion, hover, navigation and diagnostics. Returns null when the
* offset is not inside text content (start tag, closing tag, self-closing).
*/
export function textContentTokenAt(
text: string,
el: XmlElement,
offset: number,
): TextToken | null {
if (el.selfClosing) return null;
const contentEnd = el.closeTagStart >= 0 ? el.closeTagStart : el.end;
if (contentEnd <= el.startTagEnd) return null;
if (offset <= el.startTagEnd || offset > contentEnd) return null;
const contentStart = el.startTagEnd;
// A cursor right before the closing tag is still inside the content; clamp
// the relative position to the content length in that case.
const rel = Math.min(offset - contentStart, contentEnd - contentStart);
let tokenStart = rel;
while (tokenStart > 0 && !/\s/.test(text[contentStart + tokenStart - 1])) {
tokenStart--;
}
let tokenEnd = rel;
while (
tokenEnd < contentEnd - contentStart &&
!/\s/.test(text[contentStart + tokenEnd])
) {
tokenEnd++;
}
// The cursor may sit on trailing whitespace or at the closing-tag
// boundary; trim whitespace so the token is exactly the value word.
while (tokenEnd > tokenStart && /\s/.test(text[contentStart + tokenEnd - 1])) {
tokenEnd--;
}
if (tokenEnd <= tokenStart) return null;
return {
value: text.slice(contentStart + tokenStart, contentStart + tokenEnd),
start: contentStart + tokenStart,
end: contentStart + tokenEnd,
};
}
/** Finds an element by name that contains the offset (including its start tag). */
export function findOpenTagElementAt(doc: XmlDocument, offset: number): XmlElement | null {
const el = findElementAt(doc, offset);