修代码补全
This commit is contained in:
@@ -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"));
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user