improve codelens
This commit is contained in:
@@ -52,7 +52,7 @@ test("IndexRecordsCache stores and invalidates entries", () => {
|
||||
const cache = new IndexRecordsCache();
|
||||
const entry = {
|
||||
stat,
|
||||
records: { assets: [], defines: [], includes: [], rootXiIncludes: [], nestedXiIncludes: [] },
|
||||
records: { assets: [], defines: [], includes: [], rootXiIncludes: [], nestedXiIncludes: [], references: [] },
|
||||
kind: "full",
|
||||
};
|
||||
cache.set("a.xml", entry);
|
||||
@@ -65,7 +65,7 @@ test("IndexRecordsCache exposes entries for disk persistence", () => {
|
||||
const cache = new IndexRecordsCache();
|
||||
const entry = {
|
||||
stat,
|
||||
records: { assets: [], defines: [], includes: [], rootXiIncludes: [], nestedXiIncludes: [] },
|
||||
records: { assets: [], defines: [], includes: [], rootXiIncludes: [], nestedXiIncludes: [], references: [] },
|
||||
kind: "full",
|
||||
};
|
||||
cache.set("a.xml", entry);
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
// Minimal vscode shim: the CodeLens provider only constructs CodeLens/Range.
|
||||
class Range {
|
||||
constructor(start, end) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
}
|
||||
}
|
||||
class CodeLens {
|
||||
constructor(range, command) {
|
||||
this.range = range;
|
||||
this.command = command;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
CodeLens,
|
||||
},
|
||||
};
|
||||
|
||||
const { Ra3CodeLensProvider } = require("../out/features/codeLens.js");
|
||||
const { assetDefKey } = require("../out/indexer/referenceIndex.js");
|
||||
const { normKey } = require("../out/indexer/caches.js");
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const PROJECT = join(root, "test", "fixtures", "minimod");
|
||||
const SDK = join(root, "test", "fixtures", "fakesdk");
|
||||
// A real file whose path matches what DATA:Includes/Units.xml resolves to.
|
||||
const FILE = resolve(join(PROJECT, "Data", "Includes", "Units.xml"));
|
||||
const TEXT = `<AssetDeclaration>
|
||||
<GameObject id="TestTank"/>
|
||||
<GameObject id="BaseVehicle"/>
|
||||
<CameraSettings id="S"/>
|
||||
</AssetDeclaration>`;
|
||||
|
||||
function makeDocument(text = TEXT) {
|
||||
const lineStarts = [0];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 10) lineStarts.push(i + 1);
|
||||
}
|
||||
return {
|
||||
uri: { fsPath: FILE },
|
||||
getText: () => text,
|
||||
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] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeIndex() {
|
||||
const tankSite = {
|
||||
file: "C:/mod/Data/Other.xml",
|
||||
line: 7,
|
||||
start: 40,
|
||||
end: 48,
|
||||
kind: "content",
|
||||
};
|
||||
const secondSite = {
|
||||
file: "C:/mod/Data/Third.xml",
|
||||
line: 2,
|
||||
start: 12,
|
||||
end: 20,
|
||||
kind: "attr",
|
||||
};
|
||||
const references = new Map();
|
||||
references.set(
|
||||
assetDefKey({
|
||||
type: "GameObject",
|
||||
id: "TestTank",
|
||||
file: FILE,
|
||||
line: 2,
|
||||
}),
|
||||
[tankSite, secondSite],
|
||||
);
|
||||
references.set(
|
||||
assetDefKey({
|
||||
type: "GameObject",
|
||||
id: "BaseVehicle",
|
||||
file: FILE,
|
||||
line: 3,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
return {
|
||||
references,
|
||||
assets: new Map(),
|
||||
sdkDir: SDK,
|
||||
projectDir: PROJECT,
|
||||
};
|
||||
}
|
||||
|
||||
test("CodeLens shows counts on reference-target types only, including zero", () => {
|
||||
const provider = new Ra3CodeLensProvider({
|
||||
isRa3Workspace: () => true,
|
||||
index: makeIndex(),
|
||||
});
|
||||
const lenses = provider.provideCodeLenses(makeDocument(), {});
|
||||
|
||||
assert.equal(lenses.length, 2, "no lens for auto-registered CameraSettings");
|
||||
const tank = lenses.find((l) => l.command.arguments[0].id === "TestTank");
|
||||
const base = lenses.find((l) => l.command.arguments[0].id === "BaseVehicle");
|
||||
|
||||
assert.ok(tank, "GameObject TestTank gets a lens");
|
||||
assert.equal(tank.command.title, "2 references");
|
||||
assert.equal(tank.command.command, "ra3modxml.showReferences");
|
||||
assert.equal(tank.command.arguments[0].type, "GameObject");
|
||||
assert.equal(tank.command.arguments[0].line, 2);
|
||||
|
||||
assert.ok(base, "zero is still displayed for reference-target types");
|
||||
assert.equal(base.command.title, "0 references");
|
||||
|
||||
// Lenses anchor on the element start tag.
|
||||
assert.equal(tank.range.start.line, 1);
|
||||
assert.ok(tank.range.start.character < tank.range.end.character);
|
||||
});
|
||||
|
||||
test("CodeLens returns nothing without a workspace or index", () => {
|
||||
const noWorkspace = new Ra3CodeLensProvider({
|
||||
isRa3Workspace: () => false,
|
||||
index: makeIndex(),
|
||||
});
|
||||
assert.deepEqual(noWorkspace.provideCodeLenses(makeDocument(), {}), []);
|
||||
|
||||
const noIndex = new Ra3CodeLensProvider({
|
||||
isRa3Workspace: () => true,
|
||||
index: null,
|
||||
});
|
||||
assert.deepEqual(noIndex.provideCodeLenses(makeDocument(), {}), []);
|
||||
});
|
||||
|
||||
test("CodeLens counts references attached to a manifest definition with the same SageXml source", () => {
|
||||
const manifestDef = {
|
||||
type: "GameObject",
|
||||
id: "TestTank",
|
||||
file: resolve(join(SDK, "builtmods", "static.manifest")),
|
||||
line: 0,
|
||||
origin: "manifest",
|
||||
manifestSource: "DATA:Includes/Units.xml",
|
||||
};
|
||||
const site = {
|
||||
file: "C:/mod/Data/Other.xml",
|
||||
line: 7,
|
||||
start: 40,
|
||||
end: 48,
|
||||
kind: "content",
|
||||
};
|
||||
const references = new Map();
|
||||
references.set(assetDefKey(manifestDef), [site]);
|
||||
const idx = {
|
||||
references,
|
||||
assets: new Map([
|
||||
["GameObject", new Map([["testtank", [manifestDef]]])],
|
||||
]),
|
||||
sdkDir: SDK,
|
||||
projectDir: PROJECT,
|
||||
};
|
||||
const provider = new Ra3CodeLensProvider({
|
||||
isRa3Workspace: () => true,
|
||||
index: idx,
|
||||
});
|
||||
const lenses = provider.provideCodeLenses(makeDocument(), {});
|
||||
const tank = lenses.find((l) => l.command.arguments[0].id === "TestTank");
|
||||
assert.ok(tank, "lens is shown for the SageXml-backed definition");
|
||||
assert.equal(tank.command.title, "1 reference");
|
||||
});
|
||||
|
||||
test("CodeLens schedules a targeted rebuild when the open document desyncs from the snapshot", () => {
|
||||
const idx = makeIndex();
|
||||
idx.recordsHashes = new Map([[normKey(FILE), "stale-hash"]]);
|
||||
const calls = [];
|
||||
const provider = new Ra3CodeLensProvider({
|
||||
isRa3Workspace: () => true,
|
||||
index: idx,
|
||||
invalidate: (p) => calls.push(["invalidate", p]),
|
||||
scheduleRebuild: (r) => calls.push(["schedule", r]),
|
||||
});
|
||||
provider.provideCodeLenses(makeDocument(), {});
|
||||
assert.ok(
|
||||
calls.some(([kind]) => kind === "invalidate"),
|
||||
"the stale file is invalidated",
|
||||
);
|
||||
assert.ok(
|
||||
calls.some(([kind, reason]) => kind === "schedule" && reason === "records-desync"),
|
||||
"a targeted rebuild is scheduled",
|
||||
);
|
||||
});
|
||||
@@ -19,6 +19,7 @@ const sampleRecords = {
|
||||
includes: [{ type: "all", source: "Units.xml", line: 4 }],
|
||||
rootXiIncludes: [],
|
||||
nestedXiIncludes: [],
|
||||
references: [],
|
||||
};
|
||||
|
||||
function stampOf(file) {
|
||||
@@ -47,7 +48,12 @@ test("disk cache roundtrip keeps records and leaves no temp file", async (t) =>
|
||||
await cache.save([
|
||||
[
|
||||
file.toLowerCase(),
|
||||
{ stat: stampOf(file), records: sampleRecords, kind: "full" },
|
||||
{
|
||||
stat: stampOf(file),
|
||||
records: sampleRecords,
|
||||
kind: "full",
|
||||
contentHash: "abc123",
|
||||
},
|
||||
],
|
||||
]);
|
||||
assert.equal(fs.existsSync(`${filePath}.tmp`), false, "atomic write leaves no temp");
|
||||
@@ -60,6 +66,7 @@ test("disk cache roundtrip keeps records and leaves no temp file", async (t) =>
|
||||
assert.equal(stats.dropped, 0);
|
||||
assert.equal(records.length, 1);
|
||||
assert.deepEqual(records[0].records, sampleRecords);
|
||||
assert.equal(records[0].contentHash, "abc123");
|
||||
});
|
||||
|
||||
test("stat mismatch drops the cached entry", async (t) => {
|
||||
|
||||
@@ -31,7 +31,7 @@ async function readParsed(path) {
|
||||
return {
|
||||
file: { path, stat: null },
|
||||
parse,
|
||||
records: extractIndexRecords(parse, lineMap),
|
||||
records: extractIndexRecords(parse, lineMap, text),
|
||||
lineMap,
|
||||
};
|
||||
}
|
||||
|
||||
+51
-1
@@ -24,7 +24,7 @@ test("extractIndexRecords mirrors the walk semantics", () => {
|
||||
</GameObject>
|
||||
</AssetDeclaration>`;
|
||||
const lineMap = new LineMap(text);
|
||||
const records = extractIndexRecords(parseXml(text), lineMap);
|
||||
const records = extractIndexRecords(parseXml(text), lineMap, text);
|
||||
assert.deepEqual(
|
||||
records.assets.map((a) => [a.type, a.id, a.line]),
|
||||
[
|
||||
@@ -62,4 +62,54 @@ test("recordsFromShallow converts offsets to 1-based lines", () => {
|
||||
assert.equal(records.assets[0].type, "W3DContainer");
|
||||
assert.equal(records.assets[0].id, "A");
|
||||
assert.equal(records.assets[0].line, 2);
|
||||
assert.deepEqual(records.references, []);
|
||||
});
|
||||
|
||||
test("extractIndexRecords records typed references and skips non-references", () => {
|
||||
const text = `<AssetDeclaration>
|
||||
<GameObject id="Tank" CommandSet="CS" inheritFrom="Base" KindOf="SELECTABLE"/>
|
||||
<ObjectCreationList id="OCL">
|
||||
<CreateObject>
|
||||
<CreateObject>Tank</CreateObject>
|
||||
</CreateObject>
|
||||
</ObjectCreationList>
|
||||
<CameraSettings id="S"/>
|
||||
</AssetDeclaration>`;
|
||||
const lineMap = new LineMap(text);
|
||||
const records = extractIndexRecords(parseXml(text), lineMap, text);
|
||||
|
||||
const attrs = records.references.filter((r) => r.kind === "attr");
|
||||
const content = records.references.filter((r) => r.kind === "content");
|
||||
|
||||
// Typed attribute reference keeps the XSD refType.
|
||||
const cs = attrs.find((r) => r.value === "CS");
|
||||
assert.ok(cs, "CommandSet reference is recorded");
|
||||
assert.equal(cs.refType, "LogicCommandSet");
|
||||
assert.equal(cs.selfType, null);
|
||||
assert.equal(cs.line, 2);
|
||||
assert.equal(records.references.some((r) => r.start === cs.start && r.end === cs.end), true);
|
||||
|
||||
// inheritFrom records the element type as selfType instead of refType.
|
||||
const base = attrs.find((r) => r.value === "Base");
|
||||
assert.ok(base, "inheritFrom reference is recorded");
|
||||
assert.equal(base.refType, null);
|
||||
assert.equal(base.selfType, "GameObject");
|
||||
|
||||
// Enums and non-reference values are not references.
|
||||
assert.equal(attrs.some((r) => r.value === "SELECTABLE"), false);
|
||||
|
||||
// Simple-content text is recorded with its content refType and offsets.
|
||||
const tank = content.find((r) => r.value === "Tank");
|
||||
assert.ok(tank, "content reference is recorded");
|
||||
assert.equal(tank.refType, "GameObject");
|
||||
const line = text.split("\n")[4];
|
||||
assert.equal(line.slice(tank.start - text.indexOf(line), tank.end - text.indexOf(line)), "Tank");
|
||||
|
||||
// The id definition itself is never recorded as a reference.
|
||||
assert.equal(
|
||||
records.references.some(
|
||||
(r) => r.value === "Tank" && r.kind === "attr",
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { ModIndexer } from "../out/indexer/indexer.js";
|
||||
import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js";
|
||||
import {
|
||||
IndexRecordsCache,
|
||||
contentHash,
|
||||
normKey,
|
||||
recordsHash,
|
||||
} from "../out/indexer/caches.js";
|
||||
import {
|
||||
assetDefKey,
|
||||
buildReferenceIndex,
|
||||
documentRecordsDesynced,
|
||||
referenceSitesForDef,
|
||||
referenceSitesForDefinition,
|
||||
scheduleRebuildIfRecordsDesync,
|
||||
unreferencedByType,
|
||||
} from "../out/indexer/referenceIndex.js";
|
||||
import { LineMap, parseXml } from "../out/language/xmlParser.js";
|
||||
import { extractIndexRecords } from "../out/indexer/records.js";
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const project = join(root, "test", "fixtures", "minimod");
|
||||
const sdk = join(root, "test", "fixtures", "fakesdk");
|
||||
|
||||
async function buildIndex() {
|
||||
const indexer = new ModIndexer({
|
||||
projectDir: project,
|
||||
sdkDir: sdk,
|
||||
builtmodsDirs: [join(sdk, "builtmods")],
|
||||
indexSageXml: true,
|
||||
additionalDataSearchPaths: [],
|
||||
walker: new CachedDirectoryWalker(),
|
||||
});
|
||||
return indexer.build();
|
||||
}
|
||||
|
||||
function makeDef(type, id, file, line, extra = {}) {
|
||||
return {
|
||||
type,
|
||||
id,
|
||||
file,
|
||||
line,
|
||||
origin: "project",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("buildReferenceIndex resolves records with strict type filtering", () => {
|
||||
const tank = makeDef("GameObject", "Tank", "C:/mod/A.xml", 2);
|
||||
const gun = makeDef("WeaponTemplate", "Tank", "C:/mod/B.xml", 1);
|
||||
const cs = makeDef("LogicCommandSet", "CS", "C:/mod/C.xml", 4);
|
||||
const lookup = {
|
||||
assets: new Map(),
|
||||
assetsById: new Map([
|
||||
["tank", [tank, gun]],
|
||||
["cs", [cs]],
|
||||
]),
|
||||
};
|
||||
const recordsA = {
|
||||
assets: [],
|
||||
defines: [],
|
||||
includes: [],
|
||||
rootXiIncludes: [],
|
||||
nestedXiIncludes: [],
|
||||
references: [
|
||||
{
|
||||
kind: "content",
|
||||
refType: "GameObject",
|
||||
selfType: null,
|
||||
value: "Tank",
|
||||
line: 5,
|
||||
start: 40,
|
||||
end: 44,
|
||||
},
|
||||
],
|
||||
};
|
||||
const recordsB = {
|
||||
assets: [],
|
||||
defines: [],
|
||||
includes: [],
|
||||
rootXiIncludes: [],
|
||||
nestedXiIncludes: [],
|
||||
references: [
|
||||
{
|
||||
kind: "attr",
|
||||
refType: "LogicCommandSet",
|
||||
selfType: null,
|
||||
value: "CS",
|
||||
line: 3,
|
||||
start: 10,
|
||||
end: 12,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const map = buildReferenceIndex(
|
||||
[
|
||||
{ file: "C:/mod/A.xml", records: recordsA },
|
||||
{ file: "C:/mod/B.xml", records: recordsB },
|
||||
],
|
||||
lookup,
|
||||
);
|
||||
|
||||
// The content reference resolves only to the GameObject definition, never
|
||||
// to the same-name WeaponTemplate.
|
||||
const tankSites = map.get(assetDefKey(tank));
|
||||
assert.equal(tankSites.length, 1);
|
||||
assert.equal(tankSites[0].file, "C:/mod/A.xml");
|
||||
assert.equal(tankSites[0].kind, "content");
|
||||
assert.equal(map.get(assetDefKey(gun)), undefined);
|
||||
assert.equal(map.get(assetDefKey(cs)).length, 1);
|
||||
});
|
||||
|
||||
test("records extracted from XML resolve through the reference index", () => {
|
||||
const text = `<AssetDeclaration>
|
||||
<GameObject id="Tank" CommandSet="CS"/>
|
||||
<LogicCommandSet id="CS"/>
|
||||
<ObjectCreationList id="OCL">
|
||||
<CreateObject>
|
||||
<CreateObject>Tank</CreateObject>
|
||||
</CreateObject>
|
||||
</ObjectCreationList>
|
||||
</AssetDeclaration>`;
|
||||
const lineMap = new LineMap(text);
|
||||
const records = extractIndexRecords(parseXml(text), lineMap, text);
|
||||
const file = "C:/mod/D.xml";
|
||||
const tank = makeDef("GameObject", "Tank", file, 2);
|
||||
const cs = makeDef("LogicCommandSet", "CS", file, 3);
|
||||
const lookup = {
|
||||
assets: new Map(),
|
||||
assetsById: new Map([
|
||||
["tank", [tank]],
|
||||
["cs", [cs]],
|
||||
]),
|
||||
};
|
||||
const map = buildReferenceIndex([{ file, records }], lookup);
|
||||
|
||||
const tankSites = map.get(assetDefKey(tank));
|
||||
assert.equal(tankSites.length, 1);
|
||||
assert.equal(tankSites[0].kind, "content");
|
||||
assert.equal(records.references.find((r) => r.value === "Tank").start, tankSites[0].start);
|
||||
|
||||
const csSites = map.get(assetDefKey(cs));
|
||||
assert.equal(csSites.length, 1);
|
||||
assert.equal(csSites[0].kind, "attr");
|
||||
});
|
||||
|
||||
test("referenceSitesForDefinition unions manifest-source sites onto the SageXml source file", () => {
|
||||
const sourceFile = join(project, "Data", "Includes", "Units.xml");
|
||||
const manifestDef = {
|
||||
type: "GameObject",
|
||||
id: "Tank",
|
||||
file: join(sdk, "builtmods", "static.manifest"),
|
||||
line: 0,
|
||||
origin: "manifest",
|
||||
manifestSource: "DATA:Includes/Units.xml",
|
||||
};
|
||||
const site = {
|
||||
file: "C:/mod/ref.xml",
|
||||
line: 3,
|
||||
start: 10,
|
||||
end: 14,
|
||||
kind: "attr",
|
||||
};
|
||||
const idx = {
|
||||
assets: new Map([["GameObject", new Map([["tank", [manifestDef]]])]]),
|
||||
assetsById: new Map([["tank", [manifestDef]]]),
|
||||
references: new Map([[assetDefKey(manifestDef), [site]]]),
|
||||
projectDir: project,
|
||||
sdkDir: sdk,
|
||||
};
|
||||
|
||||
const sites = referenceSitesForDefinition(idx, {
|
||||
type: "GameObject",
|
||||
id: "Tank",
|
||||
file: sourceFile,
|
||||
line: 4,
|
||||
});
|
||||
assert.equal(sites.length, 1);
|
||||
assert.equal(sites[0].file, "C:/mod/ref.xml");
|
||||
|
||||
// A different file does not inherit the manifest definition's sites.
|
||||
const other = referenceSitesForDefinition(idx, {
|
||||
type: "GameObject",
|
||||
id: "Tank",
|
||||
file: "C:/mod/elsewhere.xml",
|
||||
line: 4,
|
||||
});
|
||||
assert.equal(other.length, 0);
|
||||
});
|
||||
|
||||
test("the minimod indexer publishes a semantic reverse reference index", async () => {
|
||||
const idx = await buildIndex();
|
||||
assert.ok(idx.stats.referenceCount > 0, "reverse index is populated");
|
||||
|
||||
// Units.xml has CommandSet="TestTankCommandSet" and inheritFrom="BaseVehicle".
|
||||
const lcs = idx.assets.get("LogicCommandSet").get("testtankcommandset")[0];
|
||||
const lcsSites = idx.references.get(assetDefKey(lcs));
|
||||
assert.ok(lcsSites && lcsSites.length >= 1);
|
||||
assert.ok(lcsSites.some((s) => s.kind === "attr"));
|
||||
|
||||
const baseVehicle = idx.assets.get("GameObject").get("basevehicle")[0];
|
||||
const bvSites = idx.references.get(assetDefKey(baseVehicle));
|
||||
assert.ok(bvSites && bvSites.length >= 1);
|
||||
});
|
||||
|
||||
test("references survive a records-cache invalidation during the build", async () => {
|
||||
const recordsCache = new IndexRecordsCache();
|
||||
const indexer = new ModIndexer({
|
||||
projectDir: project,
|
||||
sdkDir: sdk,
|
||||
builtmodsDirs: [join(sdk, "builtmods")],
|
||||
indexSageXml: true,
|
||||
additionalDataSearchPaths: [],
|
||||
walker: new CachedDirectoryWalker(),
|
||||
recordsCache,
|
||||
});
|
||||
const unitsPath = join(project, "Data", "Includes", "Units.xml");
|
||||
const idx = await indexer.build((phaseIndex) => {
|
||||
if (!phaseIndex.complete) recordsCache.invalidate(unitsPath);
|
||||
});
|
||||
const lcs = idx.assets.get("LogicCommandSet").get("testtankcommandset")[0];
|
||||
const sites = idx.references.get(assetDefKey(lcs));
|
||||
assert.ok(
|
||||
sites && sites.length >= 1,
|
||||
"final snapshot keeps the walk-time reference records",
|
||||
);
|
||||
});
|
||||
|
||||
test("force rebuild verifies content even when every stat signal matches", async (t) => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "ra3-refidx-"));
|
||||
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
const file = join(tmp, "units.xml");
|
||||
const diskText = `<AssetDeclaration><GameObject id="Tank"/></AssetDeclaration>`;
|
||||
const cachedText = `<AssetDeclaration><GameObject id="Cached"/></AssetDeclaration>`;
|
||||
writeFileSync(file, diskText);
|
||||
const st = statSync(file);
|
||||
const stamp = {
|
||||
mtimeMs: st.mtimeMs,
|
||||
size: st.size,
|
||||
birthtimeMs: st.birthtimeMs,
|
||||
ctimeMs: st.ctimeMs,
|
||||
};
|
||||
const cache = new IndexRecordsCache();
|
||||
cache.set(file, {
|
||||
stat: stamp,
|
||||
records: extractIndexRecords(
|
||||
parseXml(cachedText),
|
||||
new LineMap(cachedText),
|
||||
cachedText,
|
||||
),
|
||||
kind: "full",
|
||||
contentHash: contentHash(cachedText),
|
||||
});
|
||||
const opts = {
|
||||
projectDir: tmp,
|
||||
sdkDir: tmp,
|
||||
builtmodsDirs: [],
|
||||
indexSageXml: false,
|
||||
additionalDataSearchPaths: [],
|
||||
walker: new CachedDirectoryWalker(),
|
||||
recordsCache: cache,
|
||||
};
|
||||
|
||||
const trusted = new ModIndexer({ ...opts, trustUnchanged: true });
|
||||
const trustedParsed = await trusted.readDocument(file);
|
||||
assert.equal(
|
||||
trustedParsed.records.assets[0].id,
|
||||
"Cached",
|
||||
"trusted rebuilds reuse the cached records without reading",
|
||||
);
|
||||
|
||||
const forced = new ModIndexer({ ...opts, trustUnchanged: false });
|
||||
const forcedParsed = await forced.readDocument(file);
|
||||
assert.equal(
|
||||
forcedParsed.records.assets[0].id,
|
||||
"Tank",
|
||||
"force rebuild re-reads a stat-matching but content-stale entry",
|
||||
);
|
||||
assert.equal(cache.get(file).contentHash, contentHash(diskText));
|
||||
});
|
||||
|
||||
test("records-desync self-heal schedules a targeted rebuild only for clean files", () => {
|
||||
const file = join(project, "Data", "Includes", "Units.xml");
|
||||
const emptyRecords = extractIndexRecords(
|
||||
parseXml("<AssetDeclaration/>"),
|
||||
new LineMap("<AssetDeclaration/>"),
|
||||
"<AssetDeclaration/>",
|
||||
);
|
||||
const idx = {
|
||||
recordsHashes: new Map([[normKey(file), recordsHash(emptyRecords)]]),
|
||||
references: new Map(),
|
||||
assets: new Map(),
|
||||
sdkDir: sdk,
|
||||
projectDir: project,
|
||||
};
|
||||
assert.equal(
|
||||
documentRecordsDesynced(idx, file, "<AssetDeclaration/>"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
documentRecordsDesynced(
|
||||
idx,
|
||||
file,
|
||||
"<AssetDeclaration><GameObject id=\"Tank\"/></AssetDeclaration>",
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
let invalidated = null;
|
||||
let scheduled = null;
|
||||
const ws = {
|
||||
index: idx,
|
||||
invalidate: (p) => {
|
||||
invalidated = p;
|
||||
},
|
||||
scheduleRebuild: (r) => {
|
||||
scheduled = r;
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
scheduleRebuildIfRecordsDesync(ws, {
|
||||
uri: { fsPath: file, scheme: "file" },
|
||||
isDirty: false,
|
||||
getText: () => "<AssetDeclaration><GameObject id=\"Tank\"/></AssetDeclaration>",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(invalidated, file);
|
||||
assert.equal(scheduled, "records-desync");
|
||||
|
||||
invalidated = null;
|
||||
scheduled = null;
|
||||
assert.equal(
|
||||
scheduleRebuildIfRecordsDesync(ws, {
|
||||
uri: { fsPath: file, scheme: "file" },
|
||||
isDirty: true,
|
||||
getText: () => "<AssetDeclaration><GameObject id=\"Tank\"/></AssetDeclaration>",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(scheduled, null);
|
||||
});
|
||||
|
||||
test("unreferencedByType reports only meaningful project assets", async () => {
|
||||
const idx = await buildIndex();
|
||||
const map = unreferencedByType(idx);
|
||||
const all = [...map.values()].flat();
|
||||
|
||||
assert.ok(all.length > 0, "some project assets are unreferenced");
|
||||
assert.ok(
|
||||
all.every((d) => d.origin === "project" && !d.viaInstance),
|
||||
"only compiled-stream project definitions are reported",
|
||||
);
|
||||
assert.ok(
|
||||
all.every((d) => !d.file.toLowerCase().includes("fakesdk")),
|
||||
"SDK/manifest definitions are never reported",
|
||||
);
|
||||
|
||||
// WeaponTemplate TestTankCannon is defined but never referenced.
|
||||
const weapons = map.get("WeaponTemplate");
|
||||
assert.ok(weapons.some((d) => d.id === "TestTankCannon"));
|
||||
|
||||
// LogicCommandSet TestTankCommandSet is referenced, so it must not appear.
|
||||
const commandSets = map.get("LogicCommandSet");
|
||||
assert.ok(!commandSets?.some((d) => d.id === "TestTankCommandSet"));
|
||||
|
||||
// referenceSitesForDef is stable across lookups and safe without a map.
|
||||
const tankCannon = idx.assets.get("WeaponTemplate").get("testtankcannon")[0];
|
||||
assert.deepEqual(referenceSitesForDef(idx, tankCannon), []);
|
||||
assert.deepEqual(referenceSitesForDef(null, tankCannon), []);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
// Minimal vscode shim for the semantic reference provider.
|
||||
class Position {
|
||||
constructor(line, character) {
|
||||
this.line = line;
|
||||
this.character = character;
|
||||
}
|
||||
}
|
||||
class Range {
|
||||
constructor(start, end) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
}
|
||||
}
|
||||
class Location {
|
||||
constructor(uri, range) {
|
||||
this.uri = uri;
|
||||
this.range = range;
|
||||
}
|
||||
}
|
||||
const Uri = {
|
||||
file: (p) => ({ fsPath: p }),
|
||||
};
|
||||
|
||||
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: {
|
||||
Position,
|
||||
Range,
|
||||
Location,
|
||||
Uri,
|
||||
SymbolKind: {},
|
||||
DocumentSymbol: class {},
|
||||
},
|
||||
};
|
||||
|
||||
const { Ra3ReferenceProvider } = require("../out/features/navigation.js");
|
||||
const { LineMap, parseXml } = require("../out/language/xmlParser.js");
|
||||
const { extractIndexRecords } = require("../out/indexer/records.js");
|
||||
const { buildReferenceIndex } = require("../out/indexer/referenceIndex.js");
|
||||
|
||||
const FILE = "C:/mod/Data/Units.xml";
|
||||
const TEXT = `<AssetDeclaration>
|
||||
<GameObject id="Tank"/>
|
||||
<ObjectCreationList id="OCL">
|
||||
<CreateObject>
|
||||
<CreateObject>Tank</CreateObject>
|
||||
</CreateObject>
|
||||
</ObjectCreationList>
|
||||
</AssetDeclaration>`;
|
||||
|
||||
function makeDocument(text = TEXT) {
|
||||
const lineStarts = [0];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 10) lineStarts.push(i + 1);
|
||||
}
|
||||
return {
|
||||
uri: { fsPath: FILE },
|
||||
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]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeScope() {
|
||||
const parse = parseXml(TEXT);
|
||||
const lineMap = new LineMap(TEXT);
|
||||
const records = extractIndexRecords(parse, lineMap, TEXT);
|
||||
const def = {
|
||||
type: "GameObject",
|
||||
id: "Tank",
|
||||
file: FILE,
|
||||
line: 2,
|
||||
origin: "project",
|
||||
};
|
||||
const lookup = {
|
||||
assets: new Map([["GameObject", new Map([["tank", [def]]])]]),
|
||||
assetsById: new Map([["tank", [def]]]),
|
||||
};
|
||||
const references = buildReferenceIndex([{ file: FILE, records }], lookup);
|
||||
const idx = {
|
||||
...lookup,
|
||||
references,
|
||||
complete: true,
|
||||
phase: "art",
|
||||
projectDir: "C:/mod",
|
||||
sdkDir: "C:/sdk",
|
||||
defines: new Map(),
|
||||
files: new Map(),
|
||||
streams: [],
|
||||
manifests: new Map(),
|
||||
sourceCandidates: [],
|
||||
diagnostics: [],
|
||||
stats: {},
|
||||
};
|
||||
return {
|
||||
merged: idx,
|
||||
};
|
||||
}
|
||||
|
||||
function makeWs(scope) {
|
||||
const parse = parseXml(TEXT);
|
||||
const lineMap = new LineMap(TEXT);
|
||||
return {
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
indexer: {
|
||||
readDom: async (path) =>
|
||||
path === FILE
|
||||
? { file: { path: FILE }, parse, lineMap, records: null }
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("semantic Find All References excludes the definition even when includeDeclaration is set", async () => {
|
||||
const scope = makeScope();
|
||||
const provider = new Ra3ReferenceProvider(makeWs(scope));
|
||||
const document = makeDocument();
|
||||
const defLine = TEXT.split("\n")[1];
|
||||
const defPos = new Position(1, defLine.indexOf('id="') + 4);
|
||||
|
||||
const refs = await provider.provideReferences(document, defPos, {
|
||||
includeDeclaration: true,
|
||||
}, {});
|
||||
assert.ok(refs, "references are returned");
|
||||
assert.equal(refs.length, 1, "only the typed content reference is returned");
|
||||
assert.equal(refs[0].uri.fsPath, FILE);
|
||||
assert.equal(refs[0].range.start.line, 4);
|
||||
assert.equal(
|
||||
TEXT.split("\n")[4].slice(refs[0].range.start.character, refs[0].range.end.character),
|
||||
"Tank",
|
||||
);
|
||||
});
|
||||
|
||||
test("FAR from the reference site itself returns the same result", async () => {
|
||||
const scope = makeScope();
|
||||
const provider = new Ra3ReferenceProvider(makeWs(scope));
|
||||
const document = makeDocument();
|
||||
const contentLine = TEXT.split("\n")[4];
|
||||
const contentPos = new Position(4, contentLine.indexOf("Tank") + 1);
|
||||
|
||||
const refs = await provider.provideReferences(document, contentPos, {
|
||||
includeDeclaration: false,
|
||||
}, {});
|
||||
assert.equal(refs.length, 1);
|
||||
assert.equal(refs[0].range.start.line, 4);
|
||||
});
|
||||
Reference in New Issue
Block a user