0.1.13 improve completion
This commit is contained in:
+249
-26
@@ -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",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user