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 CompletionList { constructor(items, isIncomplete) { this.items = items; this.isIncomplete = isIncomplete; } } 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, CompletionList, CompletionItemKind, Position, Range, MarkdownString, SnippetString, }, }; const { Ra3CompletionProvider } = require("../out/features/completion.js"); const { parseXml, LineMap } = require("../out/language/xmlParser.js"); const { expandDocument } = require("../out/indexer/logicalTree.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]); }, }; } async function makeScope(text, idx) { const lineMap = new LineMap(text); const parse = parseXml(text); const expanded = await expandDocument("test.xml", parse, { resolve: () => null, readDom: async () => null, }); return { expanded, merged: idx, overlay: {} }; } // Enum completions do not consult the index, and value contexts now work // even before the workspace index exists. const makeProvider = (idx) => new Ra3CompletionProvider({ index: idx, isRa3Workspace: () => true, getScope: async (document) => makeScope(document.getText(), idx), log: () => {}, }); 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 = `\n 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 = `\n 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("list completion after a space offers only unused flags", async () => { const text = `\n \n \n\n`; const line1 = text.split("\n")[1]; const pos = new Position(1, line1.indexOf("GROUND ") + "GROUND ".length); const items = await provider.provideCompletionItems(makeDocument(text), pos, token); const labels = items.map((i) => i.label); assert.equal(items.length, 10); assert.ok(labels.includes("WATER")); assert.ok(!labels.includes("GROUND")); // The replacement range is empty at the cursor: existing flags are kept. const water = items.find((i) => i.label === "WATER"); assert.equal(water.range.start.character, pos.character); assert.equal(water.range.end.character, pos.character); }); test("inserting a flag in the middle of a list does not delete trailing flags", async () => { const text = `\n \n \n\n`; const line1 = text.split("\n")[1]; // Cursor right after the space between GROUND and WATER. const pos = new Position(1, line1.indexOf("GROUND ") + "GROUND ".length); 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("GROUND")); const water = items.find((i) => i.label === "WATER"); assert.equal(water.insertText, "WATER"); // The range must end at the cursor, not at the end of the whole value // (which would replace the trailing "WATER" when accepting a suggestion). assert.equal(water.range.start.character, pos.character); assert.equal(water.range.end.character, pos.character); }); test("complete flag at the end of a closed value appends the remaining flags", async () => { const text = `\n \n \n\n`; const line1 = text.split("\n")[1]; const pos = new Position(1, line1.indexOf("GROUND") + "GROUND".length); const items = await provider.provideCompletionItems(makeDocument(text), pos, token); const labels = items.map((i) => i.label); assert.equal(items.length, 10); assert.ok(!labels.includes("GROUND")); const water = items.find((i) => i.label === "WATER"); assert.ok(water, "remaining flags are offered at the end of a complete flag"); assert.equal(water.insertText, " WATER"); assert.equal(water.range.start.character, pos.character); assert.equal(water.range.end.character, pos.character); }); test("prefix-extended flags keep prefix filtering instead of append mode", async () => { const text = `\n \n \n\n`; const line1 = text.split("\n")[1]; const pos = new Position(1, line1.indexOf("CAN_ATTACK") + "CAN_ATTACK".length); const items = await provider.provideCompletionItems(makeDocument(text), pos, token); const labels = items.map((i) => i.label); assert.ok(labels.includes("CAN_ATTACK_WALLS")); assert.ok(labels.includes("CAN_ATTACK_STEALTHED")); assert.ok(!labels.includes("WATER")); const walls = items.find((i) => i.label === "CAN_ATTACK_WALLS"); assert.equal(walls.insertText, "CAN_ATTACK_WALLS"); // The range replaces the typed token instead of inserting after it. assert.equal(walls.range.start.character, pos.character - "CAN_ATTACK".length); assert.equal(walls.range.end.character, pos.character); }); test("multi-line unterminated Disposition value offers remaining flags", async () => { const text = `\n` + ` \n` + ` i.label); assert.ok(labels.includes("DISPOSITION_NONE")); assert.ok(labels.includes("FLOATING")); assert.ok(!labels.includes("RANDOM_FORCE")); }); test("empty unterminated value offers all enum values", async () => { const text = `\n i.label); assert.equal(items.length, 11); assert.ok(labels.includes("GROUND")); assert.ok(labels.includes("WATER")); assert.ok(labels.includes("CRUSHABLE_WALL")); }); test("enum completions work without an index", async () => { const text = `\n i.label); assert.ok(labels.includes("GROUND"), "enum value offered without an index"); assert.ok(!labels.includes("WATER")); }); test("element and attribute name completions work without an index", async () => { const text = `\n i.label); assert.ok(labels.includes("id")); assert.ok(labels.includes("Surfaces")); }); test("universal inheritFrom is offered on asset attribute completion", async () => { const text = `\n i.label); assert.ok(labels.includes("inheritFrom"), "FXList offers universal inheritFrom"); assert.ok(labels.includes("id")); }); test("attribute completion after a closed quote inserts a space", async () => { const text = `\n` + ` \n` + ` `; const line2 = text.split("\n")[2]; const pos = new Position( 2, line2.indexOf('RELATIVE_ANGLE"') + 'RELATIVE_ANGLE"'.length, ); const items = await provider.provideCompletionItems(makeDocument(text), pos, token); const count = items.find((i) => i.label === "Count"); assert.ok(count, "Count is a valid next attribute"); assert.equal(count.insertText.value, ' Count="1"'); // The replacement range is empty at the cursor; the space comes from the // insert text so the attribute never glues to the closing quote. assert.equal(count.range.start.character, pos.character); assert.equal(count.range.end.character, pos.character); }); test("attribute completion on one-per-line elements adds a newline", async () => { const text = `\n` + ` \n` + ` `; const line4 = text.split("\n")[4]; const pos = new Position( 4, line4.indexOf('RELATIVE_ANGLE"') + 'RELATIVE_ANGLE"'.length, ); const items = await provider.provideCompletionItems(makeDocument(text), pos, token); const count = items.find((i) => i.label === "Count"); assert.ok(count); // The editor adds the new line's base indentation itself; embedding our // own indent here would be added on top of it and compound line by line. assert.equal(count.insertText.value, '\nCount="1"'); assert.equal(count.range.start.character, pos.character); assert.equal(count.range.end.character, pos.character); }); test("attribute completion on a new line aligns with the neighbor indent", async () => { const text = `\n` + ` \n` + ` i.label === "Count"); assert.ok(count); assert.equal(count.insertText.value, ' Count="1"'); // The range replaces the whitespace already typed on the new line. assert.equal(count.range.start.character, 0); assert.equal(count.range.end.character, 6); }); test("a half-typed attribute on a new line does not drive the indent", async () => { const text = `\n` + ` \n` + ` i.label === "CreateFX"); assert.ok(createFX); assert.equal(createFX.insertText.value, ' CreateFX="$1"'); // The range covers the auto-indented whitespace and the typed "C". assert.equal(createFX.range.start.character, 0); assert.equal(createFX.range.end.character, 21); }); test("whitespace used to trigger the popup is consumed on newline insert", async () => { const text = `\n` + ` \n` + ` `; const line4 = text.split("\n")[4]; const pos = new Position( 4, line4.indexOf('RELATIVE_ANGLE" ') + 'RELATIVE_ANGLE" '.length, ); const items = await provider.provideCompletionItems(makeDocument(text), pos, token); const count = items.find((i) => i.label === "Count"); assert.ok(count); assert.equal(count.insertText.value, '\nCount="1"'); // The range starts at the previous attribute's closing quote, so the // typed space is replaced by the newline instead of lingering. assert.equal(count.range.start.character, pos.character - 1); assert.equal(count.range.end.character, pos.character); }); test("attribute completion in the middle of a one-per-line start tag does not add a newline", async () => { const text = `\n` + ` \n` + ` `; const pos = new Position(4, 7); const items = await provider.provideCompletionItems(makeDocument(text), pos, token); const count = items.find((i) => i.label === "Count"); assert.ok(count); // The attribute is already on its own line; inserting another newline // would leave a blank line. Only the partial name is replaced. assert.equal(count.insertText.value, ' Count="1"'); assert.equal(count.range.start.character, 0); assert.equal(count.range.end.character, 7); }); test("attribute completion before the first attribute on a new line does not add a newline", async () => { const text = `\n` + ` \n` + ` `; const pos = new Position(3, 7); const items = await provider.provideCompletionItems(makeDocument(text), pos, token); const count = items.find((i) => i.label === "Count"); assert.ok(count); assert.equal(count.insertText.value, ' Count="1"'); assert.equal(count.range.start.character, 0); assert.equal(count.range.end.character, 7); }); test("attribute completion right after the element name still wraps in one-per-line files", async () => { const text = `\n` + ` \n` + ` `; const line3 = text.split("\n")[3]; const pos = new Position(3, line3.length); const items = await provider.provideCompletionItems(makeDocument(text), pos, token); const count = items.find((i) => i.label === "Count"); assert.ok(count); // The new attribute would be the first one on the element-name line, so a // one-per-line file still wraps it onto its own line. assert.equal(count.insertText.value, '\nCount="1"'); assert.equal(count.range.start.character, pos.character); assert.equal(count.range.end.character, pos.character); }); test("scalar attributes get typed default values, suggestion attributes keep $1", async () => { const text = `\n` + ` \n` + ` items.find((i) => i.label === label); // XSD default wins. assert.equal(item("Count").insertText.value, 'Count="1"'); // Type-based examples for scalars without an XSD default. assert.equal(item("FadeTime").insertText.value, 'FadeTime="0s"'); assert.equal(item("DispositionAngle").insertText.value, 'DispositionAngle="0d"'); // Suggestion-driven values keep the $1 placeholder and re-trigger suggest. assert.equal(item("CreateFX").insertText.value, 'CreateFX="$1"'); assert.ok(item("CreateFX").command, "reference values re-trigger suggest"); assert.equal(item("Options").insertText.value, 'Options="$1"'); assert.ok(item("Options").command, "list values re-trigger suggest"); assert.equal(item("DisabledWhileBusy").insertText.value, 'DisabledWhileBusy="$1"'); assert.ok(item("DisabledWhileBusy").command, "boolean values re-trigger suggest"); // Concrete defaults do not pop an empty suggest widget. assert.equal(item("Count").command, undefined); }); test("content (child element) completions work without an index", async () => { const text = `\n \n \n \n`; const pos = new Position(2, 4); const items = await providerNoIndex.provideCompletionItems( makeDocument(text), pos, token, ); const labels = items.map((i) => i.label); assert.ok(labels.length > 0, "child elements offered without an index"); }); test("Poid attributes offer ids from the enclosing GameObject's local scope", async () => { const text = `\n` + ` \n` + ` \n` + ` \n` + ` \n` + ` \n` + ` \n` + ` \n` + ` \n` + ``; const line6 = text.split("\n")[6]; const pos = new Position(6, line6.indexOf("ModuleTag_D") + "ModuleTag_D".length); const items = await provider.provideCompletionItems(makeDocument(text), pos, token); const labels = items.map((i) => i.label); assert.ok(labels.includes("ModuleTag_Draw")); assert.ok(items.every((i) => i.kind === CompletionItemKind.Value)); assert.ok( items.every((i) => i.detail === "local module"), "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 = `\n` + ` \n` + ` \n` + ` \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"); 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], /\$1<\/CreateObject>/, ); }); test("no << when a closing tag follows the typed < (real file shape)", async () => { const text = `\n` + ` \n` + ` \n` + ` \n` + ` <\n` + ` \n` + ` \n` + ``; 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"); 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], /\$1<\/CreateObject>/); }); test("no << when a partial child name was typed before the closing tag", async () => { const text = `\n` + ` \n` + ` \n` + ` \n` + ` \n` + ``; 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"); 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], /\$1<\/CreateObject>/); }); test("content completion without a typed < inserts the full tag", async () => { const text = `\n` + ` \n` + ` \n` + ` \n` + ` \n` + ` \n` + ``; 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, "$1"); 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 = `\n` + ` \n` + ` \n` + ` C\n` + ` \n` + ` \n` + ``; 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 = `\n` + ` \n` + ` \n` + ` 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("simpleContent complex child inserts a value pair and triggers suggest", async () => { const text = `\n` + ` \n` + ` i.label === "Sound"); assert.ok(sound, "Sound child is offered under AudioEvent"); assert.equal(sound.insertText.value, "Sound>$1"); assert.ok(sound.command, "simpleContent child re-triggers value suggest"); }); test("simpleContent complex text offers typed asset ids as the value", async () => { const text = `\n` + ` \n` + ` V\n` + ` \n` + ``; const line = text.split("\n")[2]; const pos = new Position(2, line.indexOf(">V") + 2); const audioFile = { type: "AudioFile", id: "VoiceFile", file: "AudioFiles.xml", line: 1, origin: "project", }; const audioEvent = { type: "AudioEvent", id: "VoiceEvent", file: "Voice.xml", line: 1, origin: "project", }; const idx = { assets: new Map([ ["AudioFile", new Map([["voicefile", [audioFile]]])], ["AudioEvent", new Map([["voiceevent", [audioEvent]]])], ]), assetsById: new Map([ ["voicefile", [audioFile]], ["voiceevent", [audioEvent]], ]), }; const items = await makeProvider(idx).provideCompletionItems( makeDocument(text), pos, token, ); const labels = items.map((i) => i.label); assert.ok(labels.includes("VoiceFile")); assert.ok( !labels.includes("VoiceEvent"), "Sound content is filtered by AudioFileRefWithWeight refType AudioFile", ); const item = items.find((i) => i.label === "VoiceFile"); assert.equal(item.range.start.character, line.indexOf(">V") + 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 = `\n` + ` \n` + ` \n` + ` \n` + ` \n` + ` \n` + ``; 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 = `\n` + ` \n` + ` \n` + ` C\n` + ` \n` + ` \n` + ``; 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 = `\n` + ` \n` + ` \n` + ` C\n` + ` \n` + ` \n` + ``; 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")); }); test("asset-id completion shows one entry per id across local/global/manifest definitions", async () => { const projectDef = { type: "WeaponTemplate", id: "AlliedCommandoDesertEaglesWarhead", file: "C:/mod/Data/GlobalData/Weapon/Weapon_Allied.xml", line: 10, origin: "project", }; const unsavedLocalDef = { type: "WeaponTemplate", id: "AlliedCommandoDesertEaglesWarhead", file: "C:/mod/Data/GlobalData/Weapon/Weapon_Allied.xml", line: 14, origin: "project", stream: "local", }; const manifestDef = { type: "WeaponTemplate", id: "AlliedCommandoDesertEaglesWarhead", file: "C:/sdk/builtmods/static.manifest", line: 0, origin: "manifest", manifestSource: "DATA:static.xml", }; const idKey = "alliedcommandodeserteagleswarhead"; const idx = { assets: new Map([ ["WeaponTemplate", new Map([[idKey, [projectDef, manifestDef]]])], ]), assetsById: new Map([[idKey, [projectDef, manifestDef]]]), local: { assets: new Map([["WeaponTemplate", new Map([[idKey, [unsavedLocalDef]]])]]), assetsById: new Map([[idKey, [unsavedLocalDef]]]), defines: new Map(), }, }; const text = `\n` + ` \n` + ` \n` + ``; const line = text.split("\n")[1]; const pos = new Position(1, line.indexOf('"A') + 2); const result = await makeProvider(idx).provideCompletionItems( makeDocument(text), pos, token, ); const items = listItems(result); const matches = items.filter((i) => i.label === "AlliedCommandoDesertEaglesWarhead"); assert.equal(matches.length, 1, "same id from local/global/manifest is offered once"); assert.ok( matches[0].documentation.value.includes("Also defined"), "additional definitions are listed in the item documentation", ); });