0.1.15 继续优化补全
This commit is contained in:
@@ -20,6 +20,13 @@ class CompletionItem {
|
||||
}
|
||||
}
|
||||
|
||||
class CompletionList {
|
||||
constructor(items, isIncomplete) {
|
||||
this.items = items;
|
||||
this.isIncomplete = isIncomplete;
|
||||
}
|
||||
}
|
||||
|
||||
class Position {
|
||||
constructor(line, character) {
|
||||
this.line = line;
|
||||
@@ -67,6 +74,7 @@ require.cache["vscode-stub"] = {
|
||||
loaded: true,
|
||||
exports: {
|
||||
CompletionItem,
|
||||
CompletionList,
|
||||
CompletionItemKind,
|
||||
Position,
|
||||
Range,
|
||||
@@ -123,6 +131,10 @@ const provider = makeProvider({});
|
||||
const providerNoIndex = makeProvider(null);
|
||||
const token = { isCancellationRequested: false };
|
||||
|
||||
function listItems(result) {
|
||||
return Array.isArray(result) ? result : result.items;
|
||||
}
|
||||
|
||||
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>`;
|
||||
@@ -468,3 +480,382 @@ test("Poid attributes offer ids from the enclosing GameObject's local scope", as
|
||||
"Poid completions are labelled as local-scope ids",
|
||||
);
|
||||
});
|
||||
|
||||
test("accepting a child element after a typed < replaces the < instead of doubling it", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
|
||||
` <CreateObject\n` +
|
||||
` Options="IGNORE_ALL_OBJECTS"\n` +
|
||||
` Disposition="RANDOM_FORCE RELATIVE_ANGLE ABSOLUTE_ANGLE"\n` +
|
||||
` MinForceMagnitude="2.0"\n` +
|
||||
` MaxForceMagnitude="7.0"\n` +
|
||||
` DispositionIntensity="5.0"\n` +
|
||||
` MinLifetime="1.0s"\n` +
|
||||
` MaxLifetime="3.s"\n` +
|
||||
` MinForcePitch="90d"\n` +
|
||||
` MaxForcePitch="75d">\n` +
|
||||
` <Offset x="26.13" y="4.87" z="15.99"></Offset>\n` +
|
||||
` <`;
|
||||
const lines = text.split("\n");
|
||||
const last = lines.length - 1;
|
||||
const pos = new Position(last, lines[last].length);
|
||||
const document = makeDocument(text);
|
||||
|
||||
const items = await providerNoIndex.provideCompletionItems(document, pos, token);
|
||||
const createObject = items.find((i) => i.label === "CreateObject");
|
||||
const offset = items.find((i) => i.label === "Offset");
|
||||
assert.ok(createObject);
|
||||
assert.ok(offset);
|
||||
|
||||
// The typed "<" stays in the document; the range covers only the name
|
||||
// area after it (empty here), and the snippet has no leading "<", so the
|
||||
// final document never contains "<<" and the filter prefix is not "<".
|
||||
const ltOffset = document.offsetAt(createObject.range.start);
|
||||
assert.equal(ltOffset, document.offsetAt(pos));
|
||||
assert.equal(createObject.range.end.line, last);
|
||||
assert.equal(createObject.range.end.character, lines[last].length);
|
||||
assert.equal(offset.range.start.character, createObject.range.start.character);
|
||||
|
||||
// Simple-content children must be open/close pairs with a value
|
||||
// placeholder, never a self-closing tag, and re-trigger value suggest.
|
||||
assert.equal(createObject.insertText.value, "CreateObject>$1</CreateObject>");
|
||||
assert.ok(createObject.command, "simple-content child re-triggers suggest");
|
||||
|
||||
const applied =
|
||||
text.slice(0, document.offsetAt(createObject.range.start)) +
|
||||
createObject.insertText.value +
|
||||
text.slice(document.offsetAt(createObject.range.end));
|
||||
assert.ok(!applied.includes("<<"), "no doubled angle bracket after accepting");
|
||||
assert.match(
|
||||
applied.split("\n")[last],
|
||||
/<CreateObject>\$1<\/CreateObject>/,
|
||||
);
|
||||
});
|
||||
|
||||
test("no << when a closing tag follows the typed < (real file shape)", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
|
||||
` <CreateObject\n` +
|
||||
` Options="IGNORE_ALL_OBJECTS"\n` +
|
||||
` Disposition="RANDOM_FORCE RELATIVE_ANGLE ABSOLUTE_ANGLE">\n` +
|
||||
` <Offset x="26.13" y="4.87" z="15.99"></Offset>\n` +
|
||||
` <\n` +
|
||||
` </CreateObject>\n` +
|
||||
` </ObjectCreationList>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const lines = text.split("\n");
|
||||
const last = 6;
|
||||
const pos = new Position(last, lines[last].length);
|
||||
const document = makeDocument(text);
|
||||
|
||||
const items = await providerNoIndex.provideCompletionItems(document, pos, token);
|
||||
const createObject = items.find((i) => i.label === "CreateObject");
|
||||
assert.ok(createObject, "child element still offered after a lone <");
|
||||
assert.equal(document.offsetAt(createObject.range.start), document.offsetAt(pos));
|
||||
assert.equal(createObject.insertText.value, "CreateObject>$1</CreateObject>");
|
||||
|
||||
const applied =
|
||||
text.slice(0, document.offsetAt(createObject.range.start)) +
|
||||
createObject.insertText.value +
|
||||
text.slice(document.offsetAt(createObject.range.end));
|
||||
assert.ok(!applied.includes("<<"), "no doubled angle bracket");
|
||||
const appliedLines = applied.split("\n");
|
||||
assert.match(appliedLines[6], /<CreateObject>\$1<\/CreateObject>/);
|
||||
});
|
||||
|
||||
test("no << when a partial child name was typed before the closing tag", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
|
||||
` <CreateObject>\n` +
|
||||
` <Cr\n` +
|
||||
` </CreateObject>\n` +
|
||||
` </ObjectCreationList>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const lines = text.split("\n");
|
||||
const last = 3;
|
||||
const pos = new Position(last, lines[last].length);
|
||||
const document = makeDocument(text);
|
||||
|
||||
const items = await providerNoIndex.provideCompletionItems(document, pos, token);
|
||||
const createObject = items.find((i) => i.label === "CreateObject");
|
||||
assert.ok(createObject);
|
||||
// The "<" stays; the range covers only the typed partial name "Cr", and
|
||||
// the snippet has no leading "<".
|
||||
const startOffset = document.offsetAt(createObject.range.start);
|
||||
assert.equal(text[startOffset], "C");
|
||||
assert.equal(createObject.insertText.value, "CreateObject>$1</CreateObject>");
|
||||
const applied =
|
||||
text.slice(0, startOffset) +
|
||||
createObject.insertText.value +
|
||||
text.slice(document.offsetAt(createObject.range.end));
|
||||
assert.ok(!applied.includes("<<"), "no doubled angle bracket for partial names");
|
||||
assert.match(applied.split("\n")[last], /<CreateObject>\$1<\/CreateObject>/);
|
||||
});
|
||||
|
||||
test("content completion without a typed < inserts the full tag", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
|
||||
` <CreateObject>\n` +
|
||||
` \n` +
|
||||
` </CreateObject>\n` +
|
||||
` </ObjectCreationList>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const pos = new Position(3, 6);
|
||||
const items = await providerNoIndex.provideCompletionItems(
|
||||
makeDocument(text),
|
||||
pos,
|
||||
token,
|
||||
);
|
||||
const createObject = items.find((i) => i.label === "CreateObject");
|
||||
assert.ok(createObject);
|
||||
// No "<" was typed: the snippet includes the opening bracket and the
|
||||
// replacement range is empty at the cursor.
|
||||
assert.equal(createObject.insertText.value, "<CreateObject>$1</CreateObject>");
|
||||
assert.equal(createObject.range.start.character, 6);
|
||||
assert.equal(createObject.range.end.character, 6);
|
||||
});
|
||||
|
||||
test("simple-content element offers typed asset ids as the text value", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
|
||||
` <CreateObject>\n` +
|
||||
` <CreateObject>C</CreateObject>\n` +
|
||||
` </CreateObject>\n` +
|
||||
` </ObjectCreationList>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const line = text.split("\n")[3];
|
||||
const pos = new Position(3, line.indexOf(">C") + 2);
|
||||
const go = {
|
||||
type: "GameObject",
|
||||
id: "CrateDebris_01",
|
||||
file: "Crates.xml",
|
||||
line: 1,
|
||||
origin: "project",
|
||||
};
|
||||
const weapon = {
|
||||
type: "WeaponTemplate",
|
||||
id: "CrateWeapon_01",
|
||||
file: "Weapons.xml",
|
||||
line: 1,
|
||||
origin: "project",
|
||||
};
|
||||
const idx = {
|
||||
assets: new Map([
|
||||
["GameObject", new Map([["cratedebris_01", [go]]])],
|
||||
["WeaponTemplate", new Map([["crateweapon_01", [weapon]]])],
|
||||
]),
|
||||
assetsById: new Map([
|
||||
["cratedebris_01", [go]],
|
||||
["crateweapon_01", [weapon]],
|
||||
]),
|
||||
};
|
||||
const items = await makeProvider(idx).provideCompletionItems(
|
||||
makeDocument(text),
|
||||
pos,
|
||||
token,
|
||||
);
|
||||
const labels = items.map((i) => i.label);
|
||||
assert.ok(labels.includes("CrateDebris_01"));
|
||||
assert.ok(
|
||||
!labels.includes("CrateWeapon_01"),
|
||||
"content refs are filtered by the element's refType (GameObject)",
|
||||
);
|
||||
const item = items.find((i) => i.label === "CrateDebris_01");
|
||||
assert.equal(item.range.start.character, line.indexOf(">C") + 1);
|
||||
assert.equal(item.range.end.character, pos.character);
|
||||
assert.equal(item.insertText, "CrateDebris_01");
|
||||
});
|
||||
|
||||
test("simple-content value completion works before the closing tag is typed", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
|
||||
` <CreateObject>\n` +
|
||||
` <CreateObject>C`;
|
||||
const line = text.split("\n")[3];
|
||||
const pos = new Position(3, line.length);
|
||||
const go = {
|
||||
type: "GameObject",
|
||||
id: "CrateDebris_01",
|
||||
file: "Crates.xml",
|
||||
line: 1,
|
||||
origin: "project",
|
||||
};
|
||||
const idx = {
|
||||
assets: new Map([["GameObject", new Map([["cratedebris_01", [go]]])]]),
|
||||
assetsById: new Map([["cratedebris_01", [go]]]),
|
||||
};
|
||||
const items = await makeProvider(idx).provideCompletionItems(
|
||||
makeDocument(text),
|
||||
pos,
|
||||
token,
|
||||
);
|
||||
const labels = items.map((i) => i.label);
|
||||
assert.ok(labels.includes("CrateDebris_01"));
|
||||
const item = items.find((i) => i.label === "CrateDebris_01");
|
||||
// The unclosed element's end is the document end, so the typed "C" is
|
||||
// still a real token and the range covers it.
|
||||
assert.equal(item.range.start.character, line.indexOf(">C") + 1);
|
||||
assert.equal(item.range.end.character, pos.character);
|
||||
});
|
||||
|
||||
test("content start after accepting a simple-content snippet offers values, not attributes", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
|
||||
` <CreateObject>\n` +
|
||||
` <CreateObject></CreateObject>\n` +
|
||||
` </CreateObject>\n` +
|
||||
` </ObjectCreationList>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const line = text.split("\n")[3];
|
||||
const pos = new Position(3, line.indexOf(">") + 1);
|
||||
const go = {
|
||||
type: "GameObject",
|
||||
id: "CrateDebris_01",
|
||||
file: "Crates.xml",
|
||||
line: 1,
|
||||
origin: "project",
|
||||
};
|
||||
const idx = {
|
||||
assets: new Map([["GameObject", new Map([["cratedebris_01", [go]]])]]),
|
||||
assetsById: new Map([["cratedebris_01", [go]]]),
|
||||
};
|
||||
const result = await makeProvider(idx).provideCompletionItems(
|
||||
makeDocument(text),
|
||||
pos,
|
||||
token,
|
||||
);
|
||||
const labels = listItems(result).map((i) => i.label);
|
||||
assert.ok(labels.includes("CrateDebris_01"));
|
||||
assert.ok(!labels.includes("xai:joinAction"));
|
||||
assert.ok(!labels.includes("xmlns:xai"));
|
||||
});
|
||||
|
||||
test("large asset-id lists are incomplete so narrower prefixes can re-request", async () => {
|
||||
const defs = [];
|
||||
for (let i = 0; i < 450; i++) {
|
||||
defs.push({
|
||||
type: "GameObject",
|
||||
id: `C${String(i).padStart(3, "0")}`,
|
||||
file: `C${i}.xml`,
|
||||
line: 1,
|
||||
origin: "project",
|
||||
});
|
||||
}
|
||||
defs.push({
|
||||
type: "GameObject",
|
||||
id: "CrateDebris_01",
|
||||
file: "Crates.xml",
|
||||
line: 1,
|
||||
origin: "project",
|
||||
});
|
||||
const byId = new Map();
|
||||
const gameObjects = new Map();
|
||||
const assets = new Map([["GameObject", gameObjects]]);
|
||||
for (const def of defs) {
|
||||
const key = def.id.toLowerCase();
|
||||
byId.set(key, [def]);
|
||||
gameObjects.set(key, [def]);
|
||||
}
|
||||
const idx = { assets, assetsById: byId };
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
|
||||
` <CreateObject>\n` +
|
||||
` <CreateObject>C</CreateObject>\n` +
|
||||
` </CreateObject>\n` +
|
||||
` </ObjectCreationList>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const line = text.split("\n")[3];
|
||||
const pos = new Position(3, line.indexOf(">C") + 2);
|
||||
|
||||
const first = await makeProvider(idx).provideCompletionItems(
|
||||
makeDocument(text),
|
||||
pos,
|
||||
token,
|
||||
);
|
||||
assert.equal(Array.isArray(first), false);
|
||||
assert.equal(first.isIncomplete, true, "capped list asks VS Code to recompute");
|
||||
assert.equal(first.items.length, 400);
|
||||
assert.ok(
|
||||
!first.items.some((i) => i.label === "CrateDebris_01"),
|
||||
"the target is beyond the initial 400 and must be found by a re-request",
|
||||
);
|
||||
|
||||
// isIncomplete makes VS Code call the provider again as the prefix narrows.
|
||||
const text2 = text.replace(">C<", ">Cr<");
|
||||
const line2 = text2.split("\n")[3];
|
||||
const second = await makeProvider(idx).provideCompletionItems(
|
||||
makeDocument(text2),
|
||||
new Position(3, line2.indexOf(">Cr") + 3),
|
||||
token,
|
||||
);
|
||||
const secondItems = listItems(second);
|
||||
assert.ok(
|
||||
secondItems.some((i) => i.label === "CrateDebris_01"),
|
||||
"narrower prefix re-request reaches the previously cut-off id",
|
||||
);
|
||||
});
|
||||
|
||||
test("current-file local overlay assets survive the global 400 cap", async () => {
|
||||
const defs = [];
|
||||
for (let i = 0; i < 450; i++) {
|
||||
defs.push({
|
||||
type: "GameObject",
|
||||
id: `C${String(i).padStart(3, "0")}`,
|
||||
file: `C${i}.xml`,
|
||||
line: 1,
|
||||
origin: "project",
|
||||
});
|
||||
}
|
||||
const byId = new Map();
|
||||
const gameObjects = new Map();
|
||||
const assets = new Map([["GameObject", gameObjects]]);
|
||||
for (const def of defs) {
|
||||
const key = def.id.toLowerCase();
|
||||
byId.set(key, [def]);
|
||||
gameObjects.set(key, [def]);
|
||||
}
|
||||
const localGo = {
|
||||
type: "GameObject",
|
||||
id: "CrateDebris_01",
|
||||
file: "Crates.xml",
|
||||
line: 1,
|
||||
origin: "project",
|
||||
stream: "local",
|
||||
};
|
||||
const idx = {
|
||||
assets,
|
||||
assetsById: byId,
|
||||
local: {
|
||||
assets: new Map([
|
||||
["GameObject", new Map([["cratedebris_01", [localGo]]])],
|
||||
]),
|
||||
assetsById: new Map([["cratedebris_01", [localGo]]]),
|
||||
defines: new Map(),
|
||||
},
|
||||
};
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
|
||||
` <CreateObject>\n` +
|
||||
` <CreateObject>C</CreateObject>\n` +
|
||||
` </CreateObject>\n` +
|
||||
` </ObjectCreationList>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const line = text.split("\n")[3];
|
||||
const result = await makeProvider(idx).provideCompletionItems(
|
||||
makeDocument(text),
|
||||
new Position(3, line.indexOf(">C") + 2),
|
||||
token,
|
||||
);
|
||||
assert.equal(Array.isArray(result), false);
|
||||
assert.equal(result.isIncomplete, true);
|
||||
assert.ok(result.items.some((i) => i.label === "CrateDebris_01"));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
// Minimal vscode shim for hover / definition / diagnostics providers.
|
||||
const CompletionItemKind = {};
|
||||
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 Hover {
|
||||
constructor(contents) {
|
||||
this.contents = contents;
|
||||
}
|
||||
}
|
||||
class Location {
|
||||
constructor(uri, range) {
|
||||
this.uri = uri;
|
||||
this.range = range;
|
||||
}
|
||||
}
|
||||
class Diagnostic {
|
||||
constructor(range, message, severity) {
|
||||
this.range = range;
|
||||
this.message = message;
|
||||
this.severity = severity;
|
||||
}
|
||||
}
|
||||
class FakeDiagnosticCollection {
|
||||
constructor() {
|
||||
this.last = null;
|
||||
}
|
||||
set(uri, diags) {
|
||||
this.last = { uri, diags };
|
||||
}
|
||||
delete() {}
|
||||
dispose() {}
|
||||
}
|
||||
const Uri = {
|
||||
file: (p) => ({ fsPath: p }),
|
||||
};
|
||||
const workspace = {
|
||||
getWorkspaceFolder: (uri) => ({ uri: { fsPath: "C:/mod" } }),
|
||||
};
|
||||
const languages = {
|
||||
createDiagnosticCollection: () => new FakeDiagnosticCollection(),
|
||||
};
|
||||
|
||||
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: {
|
||||
CompletionItemKind,
|
||||
Position,
|
||||
Range,
|
||||
MarkdownString,
|
||||
Hover,
|
||||
Location,
|
||||
Diagnostic,
|
||||
DiagnosticSeverity: { Error: 0, Warning: 1, Information: 2, Hint: 3 },
|
||||
Uri,
|
||||
workspace,
|
||||
languages,
|
||||
},
|
||||
};
|
||||
|
||||
const { Ra3HoverProvider } = require("../out/features/hover.js");
|
||||
const { Ra3DefinitionProvider } = require("../out/features/navigation.js");
|
||||
const { Ra3Diagnostics } = require("../out/features/diagnostics.js");
|
||||
const { parseXml, LineMap } = require("../out/language/xmlParser.js");
|
||||
const { expandDocument } = require("../out/indexer/logicalTree.js");
|
||||
|
||||
const URI = "C:/mod/Data/Crates.xml";
|
||||
|
||||
function makeDocument(text, uri = URI) {
|
||||
const lineStarts = [0];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 10) lineStarts.push(i + 1);
|
||||
}
|
||||
return {
|
||||
uri: { fsPath: uri },
|
||||
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]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function makeScope(text, idx) {
|
||||
const lineMap = new LineMap(text);
|
||||
const parse = parseXml(text);
|
||||
const expanded = await expandDocument(URI, parse, {
|
||||
resolve: () => null,
|
||||
readDom: async () => null,
|
||||
});
|
||||
return {
|
||||
uri: URI,
|
||||
version: 1,
|
||||
parse,
|
||||
lineMap,
|
||||
expanded,
|
||||
lineMaps: new Map(),
|
||||
overlay: {},
|
||||
merged: idx,
|
||||
};
|
||||
}
|
||||
|
||||
function makeIdx(defs) {
|
||||
const assets = new Map();
|
||||
const assetsById = new Map();
|
||||
for (const def of defs) {
|
||||
const idKey = def.id.toLowerCase();
|
||||
let byId = assets.get(def.type);
|
||||
if (!byId) {
|
||||
byId = new Map();
|
||||
assets.set(def.type, byId);
|
||||
}
|
||||
byId.set(idKey, [def]);
|
||||
assetsById.set(idKey, [def]);
|
||||
}
|
||||
return {
|
||||
assets,
|
||||
assetsById,
|
||||
defines: new Map(),
|
||||
projectDir: "C:/mod",
|
||||
sdkDir: "C:/sdk",
|
||||
files: new Map(),
|
||||
streams: [],
|
||||
manifests: new Map(),
|
||||
sourceCandidates: [],
|
||||
diagnostics: [],
|
||||
stats: {},
|
||||
};
|
||||
}
|
||||
|
||||
const TEXT =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <GameObject id="CrateDebris_01"/>\n` +
|
||||
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
|
||||
` <CreateObject>\n` +
|
||||
` <CreateObject>CrateDebris_01</CreateObject>\n` +
|
||||
` </CreateObject>\n` +
|
||||
` </ObjectCreationList>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
|
||||
test("hover on simple-content text shows the referenced definition", async () => {
|
||||
const def = {
|
||||
type: "GameObject",
|
||||
id: "CrateDebris_01",
|
||||
file: URI,
|
||||
line: 2,
|
||||
origin: "project",
|
||||
};
|
||||
const idx = makeIdx([def]);
|
||||
const scope = await makeScope(TEXT, idx);
|
||||
const provider = new Ra3HoverProvider({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
searchPaths: () => null,
|
||||
});
|
||||
const line = TEXT.split("\n")[4];
|
||||
const pos = new Position(4, line.indexOf("CrateDebris_01") + 3);
|
||||
const hover = await provider.provideHover(makeDocument(TEXT), pos, {});
|
||||
assert.ok(hover, "hover is returned for typed content text");
|
||||
assert.match(hover.contents.value, /1 definition/);
|
||||
assert.match(hover.contents.value, /GameObject/);
|
||||
});
|
||||
|
||||
test("Ctrl+click on simple-content text jumps to the definition", async () => {
|
||||
const def = {
|
||||
type: "GameObject",
|
||||
id: "CrateDebris_01",
|
||||
file: URI,
|
||||
line: 2,
|
||||
origin: "project",
|
||||
};
|
||||
const scope = await makeScope(TEXT, makeIdx([def]));
|
||||
const provider = new Ra3DefinitionProvider({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: { definitionMode: "all" },
|
||||
indexer: null,
|
||||
});
|
||||
const line = TEXT.split("\n")[4];
|
||||
const pos = new Position(4, line.indexOf("CrateDebris_01") + 3);
|
||||
const locations = await provider.provideDefinition(makeDocument(TEXT), pos, {});
|
||||
assert.ok(locations && locations.length === 1, "content definition resolves");
|
||||
const defStart = TEXT.indexOf('id="CrateDebris_01"') + 'id="'.length;
|
||||
const defEnd = defStart + "CrateDebris_01".length;
|
||||
assert.deepEqual(
|
||||
{
|
||||
start: locations[0].range.start,
|
||||
end: locations[0].range.end,
|
||||
},
|
||||
{
|
||||
start: makeDocument(TEXT).positionAt(defStart),
|
||||
end: makeDocument(TEXT).positionAt(defEnd),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("diagnostics report unresolved typed content references only", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <GameObject id="CrateDebris_01"/>\n` +
|
||||
` <ObjectFilter>\n` +
|
||||
` <IncludeThing>SomeValue</IncludeThing>\n` +
|
||||
` </ObjectFilter>\n` +
|
||||
` <ObjectCreationList id="OCL">\n` +
|
||||
` <CreateObject>\n` +
|
||||
` <CreateObject>CrateDebris_01</CreateObject>\n` +
|
||||
` <CreateObject>MissingThing</CreateObject>\n` +
|
||||
` </CreateObject>\n` +
|
||||
` </ObjectCreationList>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const def = {
|
||||
type: "GameObject",
|
||||
id: "CrateDebris_01",
|
||||
file: URI,
|
||||
line: 2,
|
||||
origin: "project",
|
||||
};
|
||||
const scope = await makeScope(text, makeIdx([def]));
|
||||
const collection = new FakeDiagnosticCollection();
|
||||
const provider = new Ra3Diagnostics({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: {
|
||||
diagnoseUnknownElements: false,
|
||||
reportUnresolvedReferences: "warning",
|
||||
},
|
||||
});
|
||||
provider["collection"] = collection;
|
||||
await provider.update(makeDocument(text));
|
||||
const messages = collection.last.diags.map((d) => d.message);
|
||||
assert.ok(
|
||||
messages.some((m) => m.includes('Unresolved reference "MissingThing"')),
|
||||
"typed content refs are diagnosed",
|
||||
);
|
||||
assert.ok(
|
||||
!messages.some((m) => m.includes("SomeValue")),
|
||||
"untyped WeakReference content is not diagnosed as a global ref",
|
||||
);
|
||||
});
|
||||
@@ -74,6 +74,38 @@ test("cursor after a closed quote is an attribute-name context", () => {
|
||||
assert.equal(ctx.attr, null);
|
||||
});
|
||||
|
||||
test("cursor exactly after an opening tag with a closing tag is content", () => {
|
||||
const text = `<CreateObject></CreateObject>`;
|
||||
const cursor = text.indexOf(">") + 1;
|
||||
const doc = parseXml(text);
|
||||
const ctx = analyzeContext(doc, text, cursor);
|
||||
assert.equal(ctx.kind, "content");
|
||||
assert.equal(ctx.element?.name, "CreateObject");
|
||||
});
|
||||
|
||||
test("cursor after a typed > but before the closing tag is content too", () => {
|
||||
const text = `<CreateObject>`;
|
||||
const cursor = text.length;
|
||||
const doc = parseXml(text);
|
||||
const ctx = analyzeContext(doc, text, cursor);
|
||||
assert.equal(ctx.kind, "content");
|
||||
assert.equal(ctx.element?.name, "CreateObject");
|
||||
});
|
||||
|
||||
test("cursor after a closed child element belongs to the parent content", () => {
|
||||
const text =
|
||||
`<AssetDeclaration>` +
|
||||
`<ObjectCreationList>` +
|
||||
`<CreateObject>X</CreateObject>` +
|
||||
`</ObjectCreationList>` +
|
||||
`</AssetDeclaration>`;
|
||||
const cursor = text.indexOf("</CreateObject>") + "</CreateObject>".length;
|
||||
const doc = parseXml(text);
|
||||
const ctx = analyzeContext(doc, text, cursor);
|
||||
assert.equal(ctx.kind, "content");
|
||||
assert.equal(ctx.element?.name, "ObjectCreationList");
|
||||
});
|
||||
|
||||
test("splitListValuePrefix isolates the token being edited", () => {
|
||||
assert.deepEqual(splitListValuePrefix("GROUND WA"), { token: "WA", start: 7 });
|
||||
assert.deepEqual(splitListValuePrefix("GROUND "), { token: "", start: 7 });
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
isLocalReferenceAttribute,
|
||||
isReferenceAttribute,
|
||||
isReferenceAttributeOfType,
|
||||
isReferenceContentType,
|
||||
resolveContentReferenceTargets,
|
||||
resolveReferenceTargets,
|
||||
resolveReferenceTargetsForType,
|
||||
} from "../out/indexer/refs.js";
|
||||
@@ -242,3 +244,59 @@ test("xi:include elements are outside the XSD model and unvalidated", () => {
|
||||
assert.equal(model.isXsdAttributeName(a.name), true, a.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("typed simple content resolves like a typed attribute reference", () => {
|
||||
// <CreateObject>CrateDebris_01</CreateObject> uses GameObjectWeakRef:
|
||||
// the content is a GameObject reference, not a child element.
|
||||
assert.equal(isReferenceContentType("GameObjectWeakRef"), true);
|
||||
const idx = {
|
||||
assetsById: new Map([
|
||||
[
|
||||
"cratedebris_01",
|
||||
[
|
||||
{
|
||||
type: "GameObject",
|
||||
id: "CrateDebris_01",
|
||||
file: "Crates.xml",
|
||||
line: 2,
|
||||
origin: "project",
|
||||
},
|
||||
{
|
||||
type: "WeaponTemplate",
|
||||
id: "CrateDebris_01",
|
||||
file: "Weapons.xml",
|
||||
line: 4,
|
||||
origin: "project",
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
assets: new Map(),
|
||||
projectDir: ".",
|
||||
sdkDir: ".",
|
||||
defines: new Map(),
|
||||
files: new Map(),
|
||||
streams: [],
|
||||
manifests: new Map(),
|
||||
sourceCandidates: [],
|
||||
diagnostics: [],
|
||||
stats: {},
|
||||
};
|
||||
const targets = resolveContentReferenceTargets(idx, "GameObjectWeakRef", "CrateDebris_01");
|
||||
assert.equal(targets.length, 1);
|
||||
assert.equal(targets[0].def.type, "GameObject");
|
||||
});
|
||||
|
||||
test("untyped and pipeline-local content is not a global reference", () => {
|
||||
// Generic AssetReference content is used for shader constants and model
|
||||
// sub-object names, not global asset ids; Poid is pipeline-local.
|
||||
assert.equal(isReferenceContentType("AssetReference"), false);
|
||||
assert.equal(isReferenceContentType("Poid"), false);
|
||||
assert.equal(isReferenceContentType("string"), false);
|
||||
const idx = { assetsById: new Map(), assets: new Map(), defines: new Map() };
|
||||
assert.equal(
|
||||
resolveContentReferenceTargets(idx, "AssetReference", "Anything").length,
|
||||
0,
|
||||
);
|
||||
assert.equal(resolveContentReferenceTargets(idx, "Poid", "Anything").length, 0);
|
||||
});
|
||||
|
||||
+97
-1
@@ -1,6 +1,11 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseXml, findElementAt, stripBom } from "../out/language/xmlParser.js";
|
||||
import {
|
||||
parseXml,
|
||||
findElementAt,
|
||||
stripBom,
|
||||
textContentTokenAt,
|
||||
} from "../out/language/xmlParser.js";
|
||||
|
||||
test("stripBom removes a leading UTF-8 byte-order mark", () => {
|
||||
assert.equal(stripBom("\uFEFF<A/>"), "<A/>");
|
||||
@@ -44,6 +49,27 @@ test("findElementAt returns innermost element", () => {
|
||||
assert.equal(at.name, "C");
|
||||
});
|
||||
|
||||
test("findElementAt treats a completed element's end as exclusive", () => {
|
||||
const text = `<A><B>X</B></A>`;
|
||||
const doc = parseXml(text);
|
||||
const b = doc.elements.find((e) => e.name === "B");
|
||||
const at = findElementAt(doc, b.end);
|
||||
assert.equal(at?.name, "A", "cursor after </B> belongs to the parent");
|
||||
|
||||
const self = `<A><B/></A>`;
|
||||
const selfDoc = parseXml(self);
|
||||
const b2 = selfDoc.elements.find((e) => e.name === "B");
|
||||
const atSelf = findElementAt(selfDoc, b2.end);
|
||||
assert.equal(atSelf?.name, "A", "cursor after <B/> belongs to the parent");
|
||||
});
|
||||
|
||||
test("findElementAt still includes EOF inside an unclosed element", () => {
|
||||
const text = `<A><B>C`;
|
||||
const doc = parseXml(text);
|
||||
const b = doc.elements.find((e) => e.name === "B");
|
||||
assert.equal(findElementAt(doc, text.length)?.name, "B");
|
||||
});
|
||||
|
||||
test("tolerates partial input while typing", () => {
|
||||
const text = `<AssetDeclaration>\n\t<GameObject id="TestTank" Com`;
|
||||
const doc = parseXml(text);
|
||||
@@ -86,3 +112,73 @@ test("reports an unterminated attribute value at EOF", () => {
|
||||
assert.equal(a.attrs[0].value, "abc");
|
||||
assert.equal(a.attrs[0].quoteEnd, -1);
|
||||
});
|
||||
|
||||
test("textContentTokenAt returns the token inside element content", () => {
|
||||
const text =
|
||||
`<AssetDeclaration>` +
|
||||
`<CreateObject> CrateDebris_01 </CreateObject>` +
|
||||
`</AssetDeclaration>`;
|
||||
const doc = parseXml(text);
|
||||
const el = doc.elements.find((e) => e.name === "CreateObject");
|
||||
const tokenStart = text.indexOf("Crate");
|
||||
const cursor = tokenStart + 3;
|
||||
const token = textContentTokenAt(text, el, cursor);
|
||||
assert.deepEqual(token, {
|
||||
value: "CrateDebris_01",
|
||||
start: tokenStart,
|
||||
end: tokenStart + "CrateDebris_01".length,
|
||||
});
|
||||
// The start tag, closing tag and whitespace-only content are not tokens.
|
||||
assert.equal(textContentTokenAt(text, el, el.start + 1), null);
|
||||
assert.equal(textContentTokenAt(text, el, text.indexOf("</CreateObject") + 1), null);
|
||||
const empty = `<A><B/></A><C> </C>`;
|
||||
const c = parseXml(empty).elements.find((e) => e.name === "C");
|
||||
assert.equal(textContentTokenAt(empty, c, c.startTagEnd + 1), null);
|
||||
});
|
||||
|
||||
test("textContentTokenAt works before the closing tag is typed", () => {
|
||||
const text = `<A><B>C`;
|
||||
const doc = parseXml(text);
|
||||
const b = doc.elements.find((e) => e.name === "B");
|
||||
assert.ok(b);
|
||||
assert.equal(b.closeTagStart, -1);
|
||||
const token = textContentTokenAt(text, b, text.length);
|
||||
assert.deepEqual(token, {
|
||||
value: "C",
|
||||
start: text.indexOf("C"),
|
||||
end: text.length,
|
||||
});
|
||||
});
|
||||
|
||||
test("a typed < in content followed by a closing tag does not swallow it", () => {
|
||||
// In a real file the "<" the user just typed is followed by
|
||||
// "</CreateObject>" on the next line. The parser must NOT treat that
|
||||
// closing tag's ">" as the end of the malformed start tag (which would
|
||||
// create a bogus empty-name element and break content completion).
|
||||
const text =
|
||||
`<AssetDeclaration>` +
|
||||
`<ObjectCreationList>` +
|
||||
`<CreateObject>\n\t<Offset/>\n\t<\n</CreateObject>` +
|
||||
`</ObjectCreationList>` +
|
||||
`</AssetDeclaration>`;
|
||||
const doc = parseXml(text);
|
||||
assert.ok(doc.errors.some((e) => /Unterminated start tag/.test(e.message)));
|
||||
assert.ok(!doc.elements.some((e) => e.name === ""), "no bogus empty-name element");
|
||||
const co = doc.elements.find((e) => e.name === "CreateObject");
|
||||
assert.ok(co, "outer CreateObject still parsed");
|
||||
assert.equal(co.end, text.indexOf("</CreateObject>") + "</CreateObject>".length);
|
||||
});
|
||||
|
||||
test("a partial child name in content is recovered, not glued to the closing tag", () => {
|
||||
const text = `<A><B>\n\t<Cr\n</B></A>`;
|
||||
const doc = parseXml(text);
|
||||
const cr = doc.elements.find((e) => e.name === "Cr");
|
||||
assert.ok(cr, "partial name is recovered as an element shell");
|
||||
assert.equal(cr.recoveredStartTag, true);
|
||||
// The mismatched closing tag later closes the recovered shell (parser
|
||||
// recovery), so the element stays a valid container for completion.
|
||||
assert.equal(cr.closeTagStart, text.indexOf("</B>"));
|
||||
assert.ok(cr.end > cr.startTagEnd);
|
||||
const b = doc.elements.find((e) => e.name === "B");
|
||||
assert.equal(b.end, text.length);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user