270 lines
7.8 KiB
TypeScript
270 lines
7.8 KiB
TypeScript
import { dirname, resolve } from "node:path";
|
|
import { LineMap, parseXml, type XmlDocument } from "../language/xmlParser";
|
|
import { extractIndexRecords } from "./records";
|
|
import { resolveSource, type SearchPaths } from "./includeResolver";
|
|
import { expandDocument, type LogicalDocument } from "./logicalTree";
|
|
import type {
|
|
AssetDef,
|
|
DefineDef,
|
|
LocalOverlay,
|
|
ModIndex,
|
|
ParsedFile,
|
|
} from "./types";
|
|
|
|
export interface LocalScopeContext {
|
|
projectDir: string;
|
|
sdkDir: string;
|
|
searchPaths: SearchPaths;
|
|
/** Reads a file's compact index records (full parse or shallow scan). */
|
|
readRecords(path: string): Promise<ParsedFile | null>;
|
|
/** Reads a file and guarantees a DOM parse tree. */
|
|
readDom(path: string): Promise<ParsedFile | null>;
|
|
}
|
|
|
|
/**
|
|
* Everything the features need for the currently open document: the original
|
|
* parse, the expanded logical tree, per-source line maps, the local overlay
|
|
* and the overlay-aware index for lookups.
|
|
*/
|
|
export interface DocumentScope {
|
|
uri: string;
|
|
version: number;
|
|
parse: XmlDocument;
|
|
lineMap: LineMap;
|
|
expanded: LogicalDocument;
|
|
/** scopePathKey(file) -> line map (current file + expanded include targets). */
|
|
lineMaps: Map<string, LineMap>;
|
|
/** Local assets/defines from the document itself and its include chain. */
|
|
overlay: LocalOverlay;
|
|
/** Global index with `local` attached (or a minimal standalone index). */
|
|
merged: ModIndex | null;
|
|
}
|
|
|
|
/** Normalized key used for source-file identity / line-map lookup. */
|
|
export function scopePathKey(path: string): string {
|
|
return path.replace(/\\/g, "/").toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Builds the document scope for the current (possibly unsaved) text:
|
|
* - a local overlay of assets/defines reachable from this file;
|
|
* - a logical tree with supported xi:include targets spliced in place.
|
|
*/
|
|
export async function buildDocumentScope(
|
|
uri: string,
|
|
text: string,
|
|
version: number,
|
|
ctx: LocalScopeContext,
|
|
): Promise<DocumentScope> {
|
|
const lineMap = new LineMap(text);
|
|
const parse = parseXml(text);
|
|
const builder = new OverlayBuilder(ctx);
|
|
await builder.addEntry(uri, parse, lineMap, text);
|
|
|
|
const expanded = await expandDocument(uri, parse, {
|
|
resolve: (source, currentDir) =>
|
|
resolveSource(source, currentDir, ctx.searchPaths).path,
|
|
readDom: async (path) => {
|
|
const parsed = await ctx.readDom(path);
|
|
return parsed?.parse && parsed.lineMap
|
|
? { parse: parsed.parse, lineMap: parsed.lineMap }
|
|
: null;
|
|
},
|
|
});
|
|
|
|
const lineMaps = new Map<string, LineMap>();
|
|
lineMaps.set(scopePathKey(uri), lineMap);
|
|
for (const [path, lm] of builder.lineMaps) {
|
|
if (!lineMaps.has(path)) lineMaps.set(path, lm);
|
|
}
|
|
|
|
return {
|
|
uri,
|
|
version,
|
|
parse,
|
|
lineMap,
|
|
expanded,
|
|
lineMaps,
|
|
overlay: builder.overlay,
|
|
merged: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Attaches a document-local overlay to a global index without copying the
|
|
* global maps. When there is no global index yet, returns a minimal standalone
|
|
* index so the local chain alone can serve completions / references.
|
|
*/
|
|
export function withLocalOverlay(
|
|
global: ModIndex | null,
|
|
overlay: LocalOverlay,
|
|
projectDir: string,
|
|
sdkDir: string,
|
|
): ModIndex {
|
|
if (!global) {
|
|
return {
|
|
projectDir,
|
|
sdkDir,
|
|
complete: false,
|
|
phase: "xml",
|
|
assets: new Map(),
|
|
assetsById: new Map(),
|
|
defines: new Map(),
|
|
files: new Map(),
|
|
streams: [],
|
|
manifests: new Map(),
|
|
sourceCandidates: [],
|
|
diagnostics: [],
|
|
references: new Map(),
|
|
recordsHashes: new Map(),
|
|
stats: {
|
|
projectDir,
|
|
sdkDir,
|
|
phase: "xml",
|
|
complete: false,
|
|
indexedFiles: 0,
|
|
parsedFiles: 0,
|
|
shallowScannedFiles: 0,
|
|
deferredArtFiles: 0,
|
|
shallowCacheHits: 0,
|
|
recordsCacheHits: 0,
|
|
resolveCacheHits: 0,
|
|
resolveCalls: 0,
|
|
snapshotHits: 0,
|
|
snapshotFallbacks: 0,
|
|
candidatesMs: 0,
|
|
walkMs: 0,
|
|
artScanMs: 0,
|
|
assetCount: 0,
|
|
referenceCount: 0,
|
|
defineCount: 0,
|
|
manifestFiles: 0,
|
|
manifestAssetCount: 0,
|
|
streams: 0,
|
|
sourceCandidates: 0,
|
|
elapsedMs: 0,
|
|
},
|
|
local: overlay,
|
|
};
|
|
}
|
|
return { ...global, local: overlay };
|
|
}
|
|
|
|
const MAX_LOCAL_DEPTH = 64;
|
|
|
|
class OverlayBuilder {
|
|
readonly overlay: LocalOverlay = {
|
|
assets: new Map(),
|
|
assetsById: new Map(),
|
|
defines: new Map(),
|
|
};
|
|
readonly lineMaps = new Map<string, LineMap>();
|
|
private visited = new Set<string>();
|
|
|
|
constructor(private ctx: LocalScopeContext) {}
|
|
|
|
async addEntry(
|
|
path: string,
|
|
parse: XmlDocument,
|
|
lineMap: LineMap,
|
|
text: string,
|
|
): Promise<void> {
|
|
this.lineMaps.set(scopePathKey(path), lineMap);
|
|
await this.addParsed({
|
|
file: { path: resolve(path), stat: null },
|
|
parse,
|
|
records: extractIndexRecords(parse, lineMap, text),
|
|
lineMap,
|
|
}, 0);
|
|
}
|
|
|
|
async addFile(path: string, depth: number): Promise<void> {
|
|
if (depth > MAX_LOCAL_DEPTH) return;
|
|
const parsed = await this.ctx.readRecords(path);
|
|
if (parsed) await this.addParsed(parsed, depth);
|
|
}
|
|
|
|
private async addParsed(parsed: ParsedFile, depth: number): Promise<void> {
|
|
const path = parsed.file.path;
|
|
const key = scopePathKey(path);
|
|
if (!parsed.records || this.visited.has(key)) return;
|
|
this.visited.add(key);
|
|
if (parsed.lineMap) this.lineMaps.set(key, parsed.lineMap);
|
|
|
|
const origin = this.originOf(path);
|
|
for (const asset of parsed.records.assets) {
|
|
this.addAsset({
|
|
type: asset.type,
|
|
id: asset.id,
|
|
file: path,
|
|
line: asset.line,
|
|
origin,
|
|
stream: "local",
|
|
});
|
|
}
|
|
for (const define of parsed.records.defines) {
|
|
const entry: DefineDef = {
|
|
name: define.name,
|
|
value: define.value,
|
|
file: path,
|
|
line: define.line,
|
|
origin,
|
|
};
|
|
const arr = this.overlay.defines.get(define.name.toLowerCase());
|
|
if (arr) arr.push(entry);
|
|
else this.overlay.defines.set(define.name.toLowerCase(), [entry]);
|
|
}
|
|
|
|
for (const inc of parsed.records.includes) {
|
|
const resolved = resolveSource(inc.source, dirname(path), this.ctx.searchPaths);
|
|
if (!resolved.path) continue;
|
|
if (inc.type === "all" || inc.type === "instance") {
|
|
await this.addFile(resolved.path, depth + 1);
|
|
}
|
|
// type="reference" points at compiled manifests; their assets are
|
|
// provided by the global index, so the local text overlay skips them.
|
|
}
|
|
|
|
for (const xi of [
|
|
...parsed.records.nestedXiIncludes,
|
|
...parsed.records.rootXiIncludes,
|
|
]) {
|
|
const resolved = resolveSource(xi.href, dirname(path), this.ctx.searchPaths);
|
|
if (resolved.path) await this.addFile(resolved.path, depth + 1);
|
|
}
|
|
}
|
|
|
|
private addAsset(def: AssetDef): void {
|
|
const typeKey = def.type;
|
|
const idKey = def.id.toLowerCase();
|
|
let byId = this.overlay.assets.get(typeKey);
|
|
if (!byId) {
|
|
byId = new Map();
|
|
this.overlay.assets.set(typeKey, byId);
|
|
}
|
|
const arr = byId.get(idKey);
|
|
if (arr) {
|
|
if (arr.some((a) => a.file === def.file && a.line === def.line)) return;
|
|
arr.push(def);
|
|
} else {
|
|
byId.set(idKey, [def]);
|
|
}
|
|
const all = this.overlay.assetsById.get(idKey);
|
|
if (all) {
|
|
if (all.some((a) => a.file === def.file && a.line === def.line)) return;
|
|
all.push(def);
|
|
} else {
|
|
this.overlay.assetsById.set(idKey, [def]);
|
|
}
|
|
}
|
|
|
|
private originOf(path: string): "project" | "sdk" | "manifest" {
|
|
const p = resolve(path).toLowerCase();
|
|
const project = resolve(this.ctx.projectDir).toLowerCase();
|
|
const sdk = this.ctx.sdkDir ? resolve(this.ctx.sdkDir).toLowerCase() : "";
|
|
if (p.startsWith(project + "\\")) return "project";
|
|
if (sdk && p.startsWith(sdk + "\\")) return "sdk";
|
|
return "project";
|
|
}
|
|
}
|