0.1.15 继续优化补全
This commit is contained in:
+250
-30
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user