修代码补全

This commit is contained in:
2026-08-01 15:47:51 +02:00
parent 429ea367f3
commit 45fa2b9a27
18 changed files with 802 additions and 37 deletions
+157
View File
@@ -0,0 +1,157 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
// Minimal vscode shim so the compiled completion provider can run under plain
// node. Only the APIs used by the completion call path are implemented.
const CompletionItemKind = {
Field: 1,
Property: 2,
EnumMember: 3,
Value: 4,
Constant: 5,
File: 6,
};
class CompletionItem {
constructor(label, kind) {
this.label = label;
this.kind = kind;
}
}
class Position {
constructor(line, character) {
this.line = line;
this.character = character;
}
}
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
}
class MarkdownString {
constructor(value) {
this.value = value ?? "";
}
appendMarkdown(text) {
this.value += text;
return this;
}
appendCodeblock(text) {
this.value += "\n```\n" + text + "\n```\n";
return this;
}
}
class SnippetString {
constructor(value) {
this.value = value;
}
}
const require = createRequire(import.meta.url);
const Module = require("module");
const origResolve = Module._resolveFilename;
Module._resolveFilename = function (request, ...args) {
if (request === "vscode") return "vscode-stub";
return origResolve.call(this, request, ...args);
};
require.cache["vscode-stub"] = {
id: "vscode-stub",
filename: "vscode-stub",
loaded: true,
exports: {
CompletionItem,
CompletionItemKind,
Position,
Range,
MarkdownString,
SnippetString,
},
};
const { Ra3CompletionProvider } = require("../out/features/completion.js");
function makeDocument(text) {
const lineStarts = [0];
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) lineStarts.push(i + 1);
}
return {
getText: () => text,
offsetAt: (pos) => lineStarts[pos.line] + pos.character,
positionAt: (offset) => {
let lo = 0;
let hi = lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lineStarts[mid] <= offset) lo = mid;
else hi = mid - 1;
}
return new Position(lo, offset - lineStarts[lo]);
},
};
}
// Enum completions do not consult the index, but provideCompletionItems only
// routes value contexts when the workspace index is present.
const provider = new Ra3CompletionProvider({ index: {} });
const token = { isCancellationRequested: false };
test("Surfaces enum completion works with an unclosed quote", async () => {
const text =
`<AssetDeclaration>\n <LocomotorTemplate id="x" Surfaces="G>\n <Other/>\n</LocomotorTemplate>\n</AssetDeclaration>`;
const line1 = text.split("\n")[1];
const line1Start = text.indexOf("\n") + 1;
const valueStart = text.indexOf('Surfaces="') + 'Surfaces="'.length;
const pos = new Position(1, line1.indexOf("G") + 1);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const labels = items.map((i) => i.label);
assert.ok(labels.includes("GROUND"));
assert.ok(!labels.includes("WATER"));
assert.ok(items.every((i) => i.kind === CompletionItemKind.EnumMember));
// Replacement range covers only the value (start..cursor).
const ground = items.find((i) => i.label === "GROUND");
assert.equal(ground.range.start.character, valueStart - line1Start);
assert.equal(ground.range.end.character, pos.character);
});
test("list values filter on the token after whitespace", async () => {
const text =
`<AssetDeclaration>\n <LocomotorTemplate id="x" Surfaces="GROUND W>\n <Other/>\n</LocomotorTemplate>\n</AssetDeclaration>`;
const line1 = text.split("\n")[1];
const line1Start = text.indexOf("\n") + 1;
const valueStart = text.indexOf('Surfaces="') + 'Surfaces="'.length;
const pos = new Position(1, line1.indexOf("W") + 1);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const labels = items.map((i) => i.label);
assert.ok(labels.includes("WATER"));
assert.ok(labels.includes("WALL_RAILING"));
assert.ok(!labels.includes("GROUND"));
// Replacement range covers only the second token, not "GROUND ".
const water = items.find((i) => i.label === "WATER");
assert.equal(water.range.start.character, valueStart + "GROUND ".length - line1Start);
});
test("empty unterminated value offers all enum values", async () => {
const text =
`<AssetDeclaration>\n <LocomotorTemplate id="x" Surfaces=">\n <Other/>\n</LocomotorTemplate>\n</AssetDeclaration>`;
const line1 = text.split("\n")[1];
const pos = new Position(1, line1.indexOf('Surfaces="') + 'Surfaces="'.length);
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
const labels = items.map((i) => i.label);
assert.equal(items.length, 11);
assert.ok(labels.includes("GROUND"));
assert.ok(labels.includes("WATER"));
assert.ok(labels.includes("CRUSHABLE_WALL"));
});
+42
View File
@@ -0,0 +1,42 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseXml } from "../out/language/xmlParser.js";
import { analyzeContext, splitListValuePrefix } from "../out/language/context.js";
test("unterminated quote is still an attribute-value context", () => {
const text = `<Locomotor id="x" Surfaces="GROUND>\n <Other/>\n</Locomotor>`;
const cursor = text.indexOf("GROUND") + 6;
const doc = parseXml(text);
const ctx = analyzeContext(doc, text, cursor);
assert.equal(ctx.kind, "attribute-value");
assert.equal(ctx.attr?.name, "Surfaces");
assert.equal(ctx.valuePrefix, "GROUND");
assert.equal(ctx.element?.name, "Locomotor");
});
test("empty unterminated value keeps attribute-value context", () => {
const text = `<Locomotor id="x" Surfaces="\n</Locomotor>`;
const cursor = text.indexOf('Surfaces="') + 'Surfaces="'.length;
const doc = parseXml(text);
const ctx = analyzeContext(doc, text, cursor);
assert.equal(ctx.kind, "attribute-value");
assert.equal(ctx.attr?.name, "Surfaces");
assert.equal(ctx.valuePrefix, "");
});
test("closed quote still resolves value context", () => {
const text = `<Locomotor id="x" Surfaces="GROUND">\n</Locomotor>`;
const cursor = text.indexOf("GROUND") + 6;
const doc = parseXml(text);
const ctx = analyzeContext(doc, text, cursor);
assert.equal(ctx.kind, "attribute-value");
assert.equal(ctx.attr?.name, "Surfaces");
assert.equal(ctx.valuePrefix, "GROUND");
});
test("splitListValuePrefix isolates the token being edited", () => {
assert.deepEqual(splitListValuePrefix("GROUND WA"), { token: "WA", start: 7 });
assert.deepEqual(splitListValuePrefix("GROUND "), { token: "", start: 7 });
assert.deepEqual(splitListValuePrefix("WA"), { token: "WA", start: 0 });
assert.deepEqual(splitListValuePrefix(""), { token: "", start: 0 });
});
+25
View File
@@ -53,6 +53,31 @@ test("Include has reference/instance/all enum", () => {
assert.deepEqual(type?.enumValues, ["reference", "instance", "all"]);
});
test("xs:list types expose their item enum and isList flag", () => {
const expected = [
"GROUND", "WATER", "CLIFF", "AIR", "RUBBLE", "OBSTACLE", "IMPASSABLE",
"DEEP_WATER", "WALL_RAILING", "CRUSHABLE_OBSTACLE", "CRUSHABLE_WALL",
];
const flags = model.typeInfo("LocomotorSurfaceBitFlags");
assert.equal(flags.isList, true);
assert.deepEqual(flags.enumValues, expected);
const surfaces = model
.attributesOfType("LocomotorTemplate")
.find((a) => a.name === "Surfaces");
assert.equal(surfaces.isList, true);
assert.deepEqual(surfaces.enumValues, expected);
});
test("numeric list types do not invent enums", () => {
const list = model.typeInfo("SageUnsignedIntList");
assert.equal(list.isList, true);
assert.deepEqual(list.enumValues, []);
// Plain restriction enums keep isList=false.
const plain = model.typeInfo("Surface");
assert.equal(plain.isList, false);
assert.ok(plain.enumValues.length > 0);
});
test("foreign namespaces are excluded from XSD validation", () => {
// XInclude elements (xi:include / xi:fallback) come from the W3C XInclude
// namespace, not from the EA asset XSD.
+145
View File
@@ -0,0 +1,145 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
// Minimal vscode shim so the compiled semantic tokens provider can run under
// plain node. Only the APIs used by the provider call path are implemented.
class Range {
constructor(startLine, startCharacter, endLine, endCharacter) {
this.start = { line: startLine, character: startCharacter };
this.end = { line: endLine, character: endCharacter };
}
}
class SemanticTokensLegend {
constructor(tokenTypes) {
this.tokenTypes = tokenTypes;
}
}
class SemanticTokens {
constructor(data) {
this.data = data;
}
}
class SemanticTokensBuilder {
constructor(legend) {
this.legend = legend;
this.pushed = [];
}
push(range, tokenType) {
this.pushed.push({ range, tokenType });
}
build() {
return new SemanticTokens(new Uint32Array(this.pushed.length * 5));
}
}
const require = createRequire(import.meta.url);
const Module = require("module");
const origResolve = Module._resolveFilename;
Module._resolveFilename = function (request, ...args) {
if (request === "vscode") return "vscode-stub";
return origResolve.call(this, request, ...args);
};
require.cache["vscode-stub"] = {
id: "vscode-stub",
filename: "vscode-stub",
loaded: true,
exports: {
Range,
SemanticTokens,
SemanticTokensBuilder,
SemanticTokensLegend,
},
};
const { parseXml } = require("../out/language/xmlParser.js");
const { buildSemanticTokenRanges } = require("../out/language/semanticTokens.js");
const { Ra3SemanticTokensProvider } = require("../out/features/semanticTokens.js");
function lineStartsOf(text) {
const starts = [0];
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) starts.push(i + 1);
}
return starts;
}
function sliceAt(text, token) {
const starts = lineStartsOf(text);
const offset = starts[token.line] + token.startChar;
return text.slice(offset, offset + token.length);
}
function makeDocument(text) {
const lineStarts = lineStartsOf(text);
return {
getText: () => text,
offsetAt: (pos) => lineStarts[pos.line] + pos.character,
positionAt: (offset) => {
let lo = 0;
let hi = lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lineStarts[mid] <= offset) lo = mid;
else hi = mid - 1;
}
return { line: lo, character: offset - lineStarts[lo] };
},
};
}
const MALFORMED =
`<AssetDeclaration>\n <LocomotorTemplate id="x" Surfaces="GROUND>\n <Other/>\n</LocomotorTemplate>\n</AssetDeclaration>`;
test("fallback tokens cover names, attributes and values", () => {
const doc = parseXml(MALFORMED);
assert.ok(doc.errors.length > 0);
const tokens = buildSemanticTokenRanges(doc, MALFORMED);
// Element names (opening and closing tags).
assert.ok(tokens.some((t) => t.tokenType === "type" && sliceAt(MALFORMED, t) === "AssetDeclaration"));
assert.ok(tokens.some((t) => t.tokenType === "type" && sliceAt(MALFORMED, t) === "LocomotorTemplate"));
assert.ok(tokens.some((t) => t.tokenType === "type" && sliceAt(MALFORMED, t) === "Other"));
assert.ok(
tokens.some(
(t) => t.tokenType === "type" && sliceAt(MALFORMED, t) === "LocomotorTemplate" && t.line === 3,
),
);
// Attribute names.
assert.ok(tokens.some((t) => t.tokenType === "property" && sliceAt(MALFORMED, t) === "Surfaces"));
assert.ok(tokens.some((t) => t.tokenType === "property" && sliceAt(MALFORMED, t) === "id"));
// Attribute values: closed quotes include both quotes; the unterminated
// Surfaces value keeps the opening quote and everything up to the recovered
// line end (the `>` is still inside the unclosed string).
const strings = tokens.filter((t) => t.tokenType === "string").map((t) => sliceAt(MALFORMED, t));
assert.ok(strings.includes('"x"'));
assert.ok(strings.includes('"GROUND>'));
// Tokens are sorted by position for the vscode encoder.
for (let i = 1; i < tokens.length; i++) {
const a = tokens[i - 1];
const b = tokens[i];
assert.ok(a.line < b.line || (a.line === b.line && a.startChar <= b.startChar));
}
});
test("well-formed XML gets no semantic fallback tokens", async () => {
const text = `<AssetDeclaration>\n <LocomotorTemplate id="x" Surfaces="GROUND"/>\n</AssetDeclaration>`;
const provider = new Ra3SemanticTokensProvider();
const result = await provider.provideDocumentSemanticTokens(makeDocument(text), {});
assert.equal(result.data.length, 0);
});
test("malformed XML gets semantic fallback tokens", async () => {
const provider = new Ra3SemanticTokensProvider();
const result = await provider.provideDocumentSemanticTokens(
makeDocument(MALFORMED),
{},
);
assert.ok(result.data.length > 0);
});
+26
View File
@@ -45,3 +45,29 @@ test("tolerates partial input while typing", () => {
assert.ok(doc.errors.length >= 1); // unclosed
assert.equal(doc.elements.length, 2);
});
test("recovers from an unterminated attribute value at end of line", () => {
// Typing an attribute value quote without its closing quote makes the tag
// malformed; the parser must stop the broken start tag at the line break so
// the rest of the document (and completion for it) keeps working.
const text = `<AssetDeclaration>\n <A x="1" y="abc\n <B/>\n </A>\n</AssetDeclaration>`;
const doc = parseXml(text);
assert.ok(doc.errors.some((e) => e.message === "Unterminated start tag"));
assert.deepEqual(doc.elements.map((e) => e.name), ["AssetDeclaration", "A", "B"]);
const a = doc.elements.find((e) => e.name === "A");
const y = a.attrs.find((at) => at.name === "y");
assert.equal(y.quoteEnd, -1);
assert.equal(text.slice(y.valueStart, y.valueEnd), "abc");
const b = doc.elements.find((e) => e.name === "B");
assert.ok(b.start > a.start);
});
test("reports an unterminated attribute value at EOF", () => {
const text = `<A x="abc`;
const doc = parseXml(text);
assert.ok(doc.errors.some((e) => /Unterminated start tag/.test(e.message)));
assert.equal(doc.elements.length, 1);
const a = doc.elements[0];
assert.equal(a.attrs[0].value, "abc");
assert.equal(a.attrs[0].quoteEnd, -1);
});