Files
Ra3ModXmlExt/src/language/context.ts
T
2026-08-01 15:47:51 +02:00

156 lines
4.0 KiB
TypeScript

import type { XmlAttribute, XmlDocument, XmlElement } from "./xmlParser";
export type ContextKind =
| "element-name"
| "attribute-name"
| "attribute-value"
| "content"
| "none";
export interface CompletionContext {
kind: ContextKind;
/** The element whose start tag the cursor is in (or whose content). */
element: XmlElement | null;
/** Set when the cursor is inside a closing tag name ("</..."). */
closing: boolean;
/** The attribute being edited (attribute-value context). */
attr: XmlAttribute | null;
/** Text between the opening quote and the cursor. */
valuePrefix: string;
/** Names of attributes already present on the element. */
existingAttrs: string[];
}
export function analyzeContext(
doc: XmlDocument,
text: string,
offset: number,
): CompletionContext {
// Find the innermost element whose span contains the offset.
let container: XmlElement | null = null;
for (const el of doc.elements) {
if (el.end < 0) continue;
if (offset >= el.start && offset <= el.end) {
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) {
return analyzeStartTag(container, text, offset);
}
// Otherwise the cursor is in element content.
return {
kind: "content",
element: container,
closing: false,
attr: null,
valuePrefix: "",
existingAttrs: [],
};
}
function analyzeStartTag(
el: XmlElement,
text: string,
offset: number,
): CompletionContext {
const closing = text.startsWith("</", el.start);
const nameStart = el.start + (closing ? 2 : 1);
const nameEnd = nameStart + el.name.length;
const existingAttrs = el.attrs.map((a) => a.name);
if (offset <= nameEnd) {
return {
kind: "element-name",
element: el,
closing,
attr: null,
valuePrefix: "",
existingAttrs,
};
}
// Inside an attribute value?
for (const attr of el.attrs) {
// An unterminated value (quoteEnd < 0) happens while the user is typing
// the opening quote of a new attribute value; it must still be treated as
// an attribute-value context so enum/ref/define completions show up.
if (
attr.hasValue &&
attr.quoteStart >= 0 &&
offset >= attr.quoteStart &&
(attr.quoteEnd < 0 || offset <= attr.quoteEnd)
) {
const start = attr.valueStart;
const prefix = offset > start ? text.slice(start, offset) : "";
return {
kind: "attribute-value",
element: el,
closing: false,
attr,
valuePrefix: prefix,
existingAttrs,
};
}
}
// Right after "=" (no quotes yet) or between attributes.
const before = text.slice(el.start, offset);
const trimmed = before.replace(/\s+$/, "");
if (trimmed.endsWith("=")) {
return {
kind: "attribute-value",
element: el,
closing: false,
attr: lastAttrOf(el),
valuePrefix: "",
existingAttrs,
};
}
return {
kind: "attribute-name",
element: el,
closing: false,
attr: null,
valuePrefix: "",
existingAttrs,
};
}
function lastAttrOf(el: XmlElement): XmlAttribute | null {
return el.attrs.length ? el.attrs[el.attrs.length - 1] : null;
}
function empty(kind: ContextKind): CompletionContext {
return {
kind,
element: null,
closing: false,
attr: null,
valuePrefix: "",
existingAttrs: [],
};
}
/**
* Splits the typed prefix of a whitespace-separated list value (xs:list, e.g.
* bit flags such as Surfaces="GROUND WATER") into the token being edited and
* the offset of that token inside the prefix.
*
* "GROUND WA" -> { token: "WA", start: 7 }
* "GROUND " -> { token: "", start: 7 }
* "WA" -> { token: "WA", start: 0 }
*/
export function splitListValuePrefix(prefix: string): { token: string; start: number } {
let start = prefix.length;
while (start > 0 && !/\s/.test(prefix[start - 1])) {
start--;
}
return { token: prefix.slice(start), start };
}