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"); 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 }; 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("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("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", ); });