0.1.13 improve completion

This commit is contained in:
2026-08-04 17:53:11 +02:00
parent 6794896ac7
commit f8187da611
12 changed files with 844 additions and 36 deletions
+249 -26
View File
@@ -1,5 +1,5 @@
import * as vscode from "vscode";
import type { XmlElement } from "../language/xmlParser";
import type { XmlAttribute, XmlElement } from "../language/xmlParser";
import {
analyzeContext,
splitListValuePrefix,
@@ -7,6 +7,7 @@ import {
} from "../language/context";
import { resolveElementType } from "../language/typeContext";
import * as model from "../model/schemaModel";
import type { AttributeInfo } from "../model/schemaModel";
import { isLocalReferenceAttribute } from "../indexer/refs";
import {
findContainingGameObject,
@@ -128,8 +129,15 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
const used = new Set(ctx.existingAttrs.map((a) => a.toLowerCase()));
const items: vscode.CompletionItem[] = [];
const wordStart = findAttributeWordStart(document, position, el);
const range = new vscode.Range(document.positionAt(wordStart), position);
const text = document.getText();
const offset = document.offsetAt(position);
const layout = attributeInsertLayout(text, el, offset);
const range = new vscode.Range(document.positionAt(layout.rangeStart), position);
this.ws.log(
`[completion] attr-name ${el.name} existing=[${ctx.existingAttrs.join(", ")}] ` +
`range=${layout.rangeStart}..${offset} prefix=${JSON.stringify(layout.prefix)}`,
);
for (const attr of attrs) {
if (used.has(attr.name.toLowerCase())) continue;
@@ -145,15 +153,16 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
if (attr.default != null) md.appendMarkdown(`Default: \`${attr.default}\` \n`);
md.appendMarkdown(`Type: \`${attr.type ?? "string"}\``);
item.documentation = md;
if (attr.required) {
item.insertText = attr.name === "id" ? 'id="$1"' : `${attr.name}="$1"`;
} else {
item.insertText = `${attr.name}="$1"`;
const value = this.attributeValuePlaceholder(attr, el);
item.insertText = new vscode.SnippetString(
layout.prefix + `${attr.name}="${value.snippet}"`,
);
if (value.trigger) {
item.command = {
command: "editor.action.triggerSuggest",
title: "Suggest attribute values",
};
}
item.command = {
command: "editor.action.triggerSuggest",
title: "Suggest attribute values",
};
items.push(item);
}
@@ -161,23 +170,67 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
if (!used.has("xai:joinaction")) {
const j = new vscode.CompletionItem("xai:joinAction", vscode.CompletionItemKind.Property);
j.range = range;
j.insertText = 'xai:joinAction="$1"';
j.insertText = new vscode.SnippetString(
layout.prefix + 'xai:joinAction="$1"',
);
j.detail = "Instance join action";
j.documentation = new vscode.MarkdownString(
"Controls how this element merges with the inherited definition: `Replace` or `Remove`.",
);
j.command = {
command: "editor.action.triggerSuggest",
title: "Suggest attribute values",
};
items.push(j);
}
if (!used.has("xmlns:xai")) {
const ns = new vscode.CompletionItem("xmlns:xai", vscode.CompletionItemKind.Property);
ns.range = range;
ns.insertText = 'xmlns:xai="uri:ea.com:eala:asset:instance"';
ns.insertText = new vscode.SnippetString(
layout.prefix + 'xmlns:xai="uri:ea.com:eala:asset:instance"',
);
ns.detail = "xai namespace";
items.push(ns);
}
return items;
}
/**
* Chooses the placeholder/value inserted for a completed attribute:
* attributes whose value is picked from suggestions (references, enums,
* lists, booleans, defines, include sources, local ids) keep a `$1`
* placeholder and re-trigger the value popup; scalar attributes get the XSD
* default (or a type-appropriate example such as `0d` for angles or `0s`
* for times) so the completed value shows the expected format immediately.
*/
private attributeValuePlaceholder(
attr: AttributeInfo,
el: XmlElement,
): { snippet: string; trigger: boolean } {
if (
attr.isBoolean ||
attr.isList ||
attr.enumValues.length > 0 ||
attr.refType != null ||
attr.isRef ||
attr.name === "inheritFrom" ||
(el.name === "Include" && attr.name === "source")
) {
return { snippet: "$1", trigger: true };
}
if (attr.name === "id") {
// `id` is the element's own definition point; nothing to suggest, but
// keep the placeholder for the user to type the id.
return { snippet: "$1", trigger: false };
}
if (attr.default != null && attr.default !== "") {
return { snippet: attr.default, trigger: false };
}
const example = DEFAULT_VALUE_BY_TYPE[(attr.type ?? "").toLowerCase()];
if (example != null) return { snippet: example, trigger: false };
return { snippet: "$1", trigger: false };
}
// ── Attribute value ───────────────────────────────────────────────
private valueItems(
@@ -201,15 +254,23 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
// whitespace-separated: only the token currently being edited is used for
// filtering, and the replacement range covers that token instead of the
// whole value.
const seg = attrInfo?.isList
const isList = attrInfo?.isList === true;
const seg = isList
? splitListValuePrefix(rawPrefix)
: { token: rawPrefix, start: 0 };
const prefix = seg.token;
const valueStartOffset =
attr.valueStart >= 0 ? attr.valueStart : document.offsetAt(position);
const endOffset =
attr.quoteEnd > attr.valueEnd ? attr.valueEnd : document.offsetAt(position);
const cursorOffset = document.offsetAt(position);
// For list values the replacement must cover only the segment being
// edited; extending to the end of the whole value would delete the flags
// after the cursor when inserting in the middle of an existing list.
const endOffset = isList
? Math.min(cursorOffset, attr.valueEnd >= 0 ? attr.valueEnd : cursorOffset)
: attr.quoteEnd > attr.valueEnd
? attr.valueEnd
: cursorOffset;
const rangeStart = valueStartOffset + seg.start;
const valueRange = new vscode.Range(
document.positionAt(rangeStart),
@@ -221,10 +282,12 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
kind: vscode.CompletionItemKind,
detail: string,
doc?: string,
range?: vscode.Range,
insertText?: string,
) => {
const item = new vscode.CompletionItem(label, kind);
item.range = valueRange;
item.insertText = label;
item.range = range ?? valueRange;
item.insertText = insertText ?? label;
item.detail = detail;
if (doc) item.documentation = new vscode.MarkdownString(doc);
return item;
@@ -266,6 +329,9 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
return this.assetIdItems(idx, null, attrInfo.refType, prefix, make);
}
if (attrInfo?.enumValues?.length) {
if (attrInfo.isList) {
return this.listEnumItems(attrInfo, rawPrefix, seg, valueRange, make);
}
return attrInfo.enumValues
.filter((v) => v.toLowerCase().startsWith(prefix.toLowerCase()))
.map((v) => make(v, vscode.CompletionItemKind.EnumMember, attrInfo.type ?? "enum"));
@@ -281,6 +347,65 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
return [];
}
/**
* Completions for xs:list enum values (whitespace-separated bit flags).
*
* Only the segment being edited is used for filtering, and values already
* present earlier in the list are excluded so adding a flag never re-offers
* an existing one. When the current segment is already a complete flag and
* no other flag extends it (e.g. "GROUND" -> "GROUND_EDGE" would disable
* this), the remaining flags are offered as insertions after the cursor
* (" FLAG") so flags can be appended to an already-closed value.
*/
private listEnumItems(
attrInfo: AttributeInfo,
rawPrefix: string,
seg: { token: string; start: number },
valueRange: vscode.Range,
make: (
label: string,
kind: vscode.CompletionItemKind,
detail: string,
doc?: string,
range?: vscode.Range,
insertText?: string,
) => vscode.CompletionItem,
): vscode.CompletionItem[] {
const used = new Set(
rawPrefix
.slice(0, seg.start)
.split(/\s+/)
.map((t) => t.toLowerCase())
.filter(Boolean),
);
const token = seg.token.toLowerCase();
const exact = token !== "" && attrInfo.enumValues.some((v) => v.toLowerCase() === token);
const extendable = attrInfo.enumValues.some(
(v) => v.toLowerCase().startsWith(token) && v.toLowerCase() !== token,
);
const append = exact && !extendable;
const range = append
? new vscode.Range(valueRange.end, valueRange.end)
: valueRange;
const filtered = attrInfo.enumValues.filter((v) => {
const lower = v.toLowerCase();
if (used.has(lower)) return false;
if (append) return lower !== token;
if (exact && lower === token) return false;
return lower.startsWith(token);
});
return filtered.map((v) =>
make(
v,
vscode.CompletionItemKind.EnumMember,
attrInfo.type ?? "enum",
undefined,
range,
append ? ` ${v}` : v,
),
);
}
private includeSourceItems(
idx: ModIndex,
prefix: string,
@@ -439,15 +564,93 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
}
}
function findAttributeWordStart(
document: vscode.TextDocument,
position: vscode.Position,
el: { start: number },
): number {
const offset = document.offsetAt(position);
const tagStart = el.start;
interface AttributeInsertLayout {
/** Offset where the completed attribute name starts replacing the text. */
rangeStart: number;
/** Text to insert before the attribute name (space / newline + indent). */
prefix: string;
}
/**
* Computes the insertion layout for an attribute-name completion:
* - a leading space when the cursor sits directly against a closing quote;
* - a plain newline when the element's attributes are laid out one per line
* (the editor supplies the new line's base indentation itself; embedding
* our own indent here would be ADDED on top of it, e.g. 3+3=6, 6+3=9);
* - replacing the current line's whitespace with that indentation when the
* user already started a new line.
*
* The indentation anchor is deliberately NOT the last parsed attribute:
* a half-typed attribute name on the cursor's new line has no value yet, and
* using its line indentation would copy the editor's own auto-indent (which
* can grow line by line) into every inserted attribute. Instead we use the
* first complete attribute that starts on its own line, which is stable and
* pre-existing, and only fall back to the last complete attribute when the
* whole element is inline.
*/
function attributeInsertLayout(
text: string,
el: XmlElement,
offset: number,
): AttributeInsertLayout {
const wordStart = findAttributeWordStart(text, offset, el.start);
const attrs = el.attrs;
const complete = attrs.filter((a) => a.hasValue);
const last = complete.length ? complete[complete.length - 1] : null;
const lastEnd = last ? attributeEndOffset(last) : -1;
const alreadyOnNewLine = lastEnd >= 0 && text.slice(lastEnd, offset).includes("\n");
// Canonical indent anchor: the first complete attribute that starts on its
// own line. Fall back to the last complete attribute for inline elements.
let anchor: XmlAttribute | null = null;
let onePerLine = false;
let prevEnd = el.start + 1 + el.name.length;
for (const a of complete) {
if (text.slice(prevEnd, a.nameStart).includes("\n")) {
anchor = a;
onePerLine = true;
break;
}
prevEnd = attributeEndOffset(a);
}
if (!anchor && complete.length) anchor = complete[complete.length - 1];
const indent = anchor
? text.slice(0, anchor.nameStart).match(/[ \t]*$/)?.[0] ?? ""
: "";
if (!onePerLine) {
if (alreadyOnNewLine) {
// Inline-style file, but the user started a new line: keep whatever
// indentation they already typed.
return { rangeStart: wordStart, prefix: "" };
}
const needsSpace = wordStart > el.start + 1 && !/\s/.test(text[wordStart - 1]);
return { rangeStart: wordStart, prefix: needsSpace ? " " : "" };
}
if (alreadyOnNewLine) {
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
return { rangeStart: lineStart, prefix: indent };
}
// Insert on a new line. The editor adds the current line's indentation to
// the new line, so we must NOT embed our own indent here (it would
// compound). If whitespace was typed between the previous attribute and
// the cursor (e.g. a space used to trigger the suggestion popup), consume
// it so it does not linger as a trailing space.
const wsStart =
lastEnd >= 0 &&
wordStart > lastEnd &&
/^[ \t]*$/.test(text.slice(lastEnd, wordStart))
? lastEnd
: wordStart;
return { rangeStart: wsStart, prefix: "\n" };
}
function attributeEndOffset(attr: XmlAttribute): number {
return attr.quoteEnd >= 0 ? attr.quoteEnd : attr.nameEnd;
}
function findAttributeWordStart(text: string, offset: number, tagStart: number): number {
let i = offset;
const text = document.getText();
while (i > tagStart) {
const c = text[i - 1];
if (/[\s=<>"/]/.test(c)) break;
@@ -455,3 +658,23 @@ function findAttributeWordStart(
}
return i;
}
/** Type-appropriate example values for common RA3 XSD scalar types. */
const DEFAULT_VALUE_BY_TYPE: Record<string, string> = {
angle: "0d",
time: "0s",
velocity: "0.0",
percentage: "100%",
sagereal: "0.0",
sageint: "0",
sageunsignedint: "0",
float: "0.0",
double: "0.0",
int: "0",
unsignedint: "0",
unsignedbyte: "0",
byte: "0",
short: "0",
long: "0",
decimal: "0.0",
};