import { loadIndex } from "./loader.js";
import type { ClassInfo, MethodInfo, EnumInfo, PropertyInfo, WikiPage, GroupInfo } from "./types.js";
export interface MethodSearchResult {
className: string;
classSource: "enfusion" | "arma";
classGroup: string;
method: MethodInfo;
}
export interface EnumSearchResult {
className: string;
classSource: "enfusion" | "arma";
classGroup: string;
enumInfo: EnumInfo;
}
export interface PropertySearchResult {
className: string;
classSource: "enfusion" | "arma";
classGroup: string;
property: PropertyInfo;
}
export interface SearchResult {
type: "class" | "method" | "enum" | "property";
score: number;
classInfo?: ClassInfo;
methodResult?: MethodSearchResult;
enumResult?: EnumSearchResult;
propertyResult?: PropertySearchResult;
}
export class SearchEngine {
private classByName: Map<string, ClassInfo> = new Map();
private classNames: string[] = [];
private methodIndex: Map<string, MethodSearchResult[]> = new Map();
private enumIndex: Map<string, EnumSearchResult[]> = new Map();
private propertyIndex: Map<string, PropertySearchResult[]> = new Map();
private wikiPages: WikiPage[] = [];
private groups: GroupInfo[] = [];
private loaded = false;
constructor(private dataDir: string) {
this.load();
}
private load(): void {
const data = loadIndex(this.dataDir);
this.wikiPages = data.wikiPages;
this.groups = data.groups;
const allClasses = [...data.enfusionClasses, ...data.armaClasses];
for (const cls of allClasses) {
const key = cls.name.toLowerCase();
this.classByName.set(key, cls);
this.classNames.push(cls.name);
// Index methods (public + protected + static)
const allMethods = [
...(cls.methods || []),
...(cls.protectedMethods || []),
...(cls.staticMethods || []),
];
for (const method of allMethods) {
const methodKey = method.name.toLowerCase();
let entries = this.methodIndex.get(methodKey);
if (!entries) {
entries = [];
this.methodIndex.set(methodKey, entries);
}
entries.push({
className: cls.name,
classSource: cls.source,
classGroup: cls.group,
method,
});
}
// Index enums
for (const enumInfo of cls.enums || []) {
const enumKey = enumInfo.name.toLowerCase();
let entries = this.enumIndex.get(enumKey);
if (!entries) {
entries = [];
this.enumIndex.set(enumKey, entries);
}
entries.push({
className: cls.name,
classSource: cls.source,
classGroup: cls.group,
enumInfo,
});
// Also index by enum value names for searching
for (const val of enumInfo.values) {
const valKey = val.name.toLowerCase();
let valEntries = this.enumIndex.get(valKey);
if (!valEntries) {
valEntries = [];
this.enumIndex.set(valKey, valEntries);
}
// Only add if not already referencing same enum
if (!valEntries.some((e) => e.enumInfo.name === enumInfo.name && e.className === cls.name)) {
valEntries.push({
className: cls.name,
classSource: cls.source,
classGroup: cls.group,
enumInfo,
});
}
}
}
// Index properties (public + protected)
for (const prop of [...(cls.properties || []), ...(cls.protectedProperties || [])]) {
const propKey = prop.name.toLowerCase();
let entries = this.propertyIndex.get(propKey);
if (!entries) {
entries = [];
this.propertyIndex.set(propKey, entries);
}
entries.push({
className: cls.name,
classSource: cls.source,
classGroup: cls.group,
property: prop,
});
}
}
this.loaded = true;
}
getClass(name: string): ClassInfo | undefined {
return this.classByName.get(name.toLowerCase());
}
searchClasses(
query: string,
source: "enfusion" | "arma" | "all" = "all",
limit = 10
): ClassInfo[] {
const q = query.toLowerCase();
const results: Array<{ cls: ClassInfo; score: number }> = [];
for (const cls of this.classByName.values()) {
if (source !== "all" && cls.source !== source) continue;
const nameLower = cls.name.toLowerCase();
let score = 0;
// Exact match
if (nameLower === q) {
score = 100;
}
// Prefix match
else if (nameLower.startsWith(q)) {
score = 80;
}
// Substring in name
else if (nameLower.includes(q)) {
score = 60;
}
// Match in brief description
else if (cls.brief.toLowerCase().includes(q)) {
score = 30;
}
// Match in full description
else if (cls.description.toLowerCase().includes(q)) {
score = 20;
}
if (score > 0) {
results.push({ cls, score });
}
}
results.sort((a, b) => b.score - a.score);
return results.slice(0, limit).map((r) => r.cls);
}
searchMethods(
query: string,
source: "enfusion" | "arma" | "all" = "all",
limit = 10
): MethodSearchResult[] {
const q = query.toLowerCase();
const results: Array<{ result: MethodSearchResult; score: number }> = [];
for (const [methodName, entries] of this.methodIndex) {
let score = 0;
if (methodName === q) {
score = 100;
} else if (methodName.startsWith(q)) {
score = 80;
} else if (methodName.includes(q)) {
score = 60;
}
if (score > 0) {
for (const entry of entries) {
if (source !== "all" && entry.classSource !== source) continue;
results.push({ result: entry, score });
}
}
}
results.sort((a, b) => b.score - a.score);
return results.slice(0, limit).map((r) => r.result);
}
searchEnums(
query: string,
source: "enfusion" | "arma" | "all" = "all",
limit = 10
): EnumSearchResult[] {
const q = query.toLowerCase();
const results: Array<{ result: EnumSearchResult; score: number }> = [];
const seen = new Set<string>();
for (const [enumKey, entries] of this.enumIndex) {
let score = 0;
if (enumKey === q) {
score = 100;
} else if (enumKey.startsWith(q)) {
score = 80;
} else if (enumKey.includes(q)) {
score = 60;
}
if (score > 0) {
for (const entry of entries) {
if (source !== "all" && entry.classSource !== source) continue;
// Deduplicate by className+enumName
const dedup = `${entry.className}::${entry.enumInfo.name}`;
if (seen.has(dedup)) continue;
seen.add(dedup);
results.push({ result: entry, score });
}
}
}
results.sort((a, b) => b.score - a.score);
return results.slice(0, limit).map((r) => r.result);
}
searchProperties(
query: string,
source: "enfusion" | "arma" | "all" = "all",
limit = 10
): PropertySearchResult[] {
const q = query.toLowerCase();
const results: Array<{ result: PropertySearchResult; score: number }> = [];
for (const [propName, entries] of this.propertyIndex) {
let score = 0;
if (propName === q) {
score = 100;
} else if (propName.startsWith(q)) {
score = 80;
} else if (propName.includes(q)) {
score = 60;
}
if (score > 0) {
for (const entry of entries) {
if (source !== "all" && entry.classSource !== source) continue;
results.push({ result: entry, score });
}
}
}
results.sort((a, b) => b.score - a.score);
return results.slice(0, limit).map((r) => r.result);
}
searchAny(
query: string,
source: "enfusion" | "arma" | "all" = "all",
limit = 10
): SearchResult[] {
const classResults = this.searchClasses(query, source, limit).map(
(cls) =>
({
type: "class" as const,
score: cls.name.toLowerCase() === query.toLowerCase() ? 100 : 50,
classInfo: cls,
}) satisfies SearchResult
);
const methodResults = this.searchMethods(query, source, limit).map(
(mr) =>
({
type: "method" as const,
score:
mr.method.name.toLowerCase() === query.toLowerCase() ? 90 : 40,
methodResult: mr,
}) satisfies SearchResult
);
const enumResults = this.searchEnums(query, source, limit).map(
(er) =>
({
type: "enum" as const,
score:
er.enumInfo.name.toLowerCase() === query.toLowerCase() ? 95 : 45,
enumResult: er,
}) satisfies SearchResult
);
const propertyResults = this.searchProperties(query, source, limit).map(
(pr) =>
({
type: "property" as const,
score:
pr.property.name.toLowerCase() === query.toLowerCase() ? 85 : 35,
propertyResult: pr,
}) satisfies SearchResult
);
const combined = [...classResults, ...methodResults, ...enumResults, ...propertyResults];
combined.sort((a, b) => b.score - a.score);
return combined.slice(0, limit);
}
searchWiki(query: string, limit = 5): WikiPage[] {
const tokens = query.toLowerCase().split(/\s+/);
const results: Array<{ page: WikiPage; score: number }> = [];
for (const page of this.wikiPages) {
const titleLower = page.title.toLowerCase();
const contentLower = page.content.toLowerCase();
let score = 0;
for (const token of tokens) {
if (titleLower.includes(token)) score += 10;
if (contentLower.includes(token)) score += 1;
}
if (score > 0) {
results.push({ page, score });
}
}
results.sort((a, b) => b.score - a.score);
return results.slice(0, limit).map((r) => r.page);
}
getGroups(): GroupInfo[] {
return this.groups;
}
getGroup(name: string): GroupInfo | undefined {
return this.groups.find((g) => g.name.toLowerCase() === name.toLowerCase());
}
/** Get all class names (for resource listing) */
getAllClassNames(): string[] {
return this.classNames;
}
/**
* Get the full inheritance tree for a class.
* Walks up through parents[] and down through children[].
*/
getClassTree(name: string): { ancestors: string[]; descendants: string[] } {
const ancestors: string[] = [];
const descendants: string[] = [];
// Walk up to ancestors
const visited = new Set<string>();
const walkUp = (className: string) => {
const cls = this.getClass(className);
if (!cls) return;
for (const parent of cls.parents) {
if (visited.has(parent.toLowerCase())) continue;
visited.add(parent.toLowerCase());
ancestors.push(parent);
walkUp(parent);
}
};
walkUp(name);
// Walk down to descendants
visited.clear();
const walkDown = (className: string) => {
const cls = this.getClass(className);
if (!cls) return;
for (const child of cls.children) {
if (visited.has(child.toLowerCase())) continue;
visited.add(child.toLowerCase());
descendants.push(child);
walkDown(child);
}
};
walkDown(name);
return { ancestors, descendants };
}
/**
* Get the ordered inheritance chain from root to the given class.
* Returns [root, ..., parent, className].
*/
getInheritanceChain(name: string): string[] {
const chain: string[] = [name];
const visited = new Set<string>([name.toLowerCase()]);
let current = name;
while (true) {
const cls = this.getClass(current);
if (!cls || cls.parents.length === 0) break;
const parent = cls.parents[0];
if (visited.has(parent.toLowerCase())) break; // cycle protection
visited.add(parent.toLowerCase());
chain.unshift(parent);
current = parent;
}
return chain;
}
/**
* Get all inherited members by walking the inheritance chain.
* Returns methods, properties, and enums from all ancestor classes.
*/
getInheritedMembers(name: string): {
methods: MethodSearchResult[];
properties: PropertySearchResult[];
enums: EnumSearchResult[];
} {
const methods: MethodSearchResult[] = [];
const properties: PropertySearchResult[] = [];
const enums: EnumSearchResult[] = [];
const chain = this.getInheritanceChain(name);
// Skip the class itself — only include ancestors
for (const ancestorName of chain.slice(0, -1)) {
const cls = this.getClass(ancestorName);
if (!cls) continue;
for (const method of [...(cls.methods || []), ...(cls.protectedMethods || []), ...(cls.staticMethods || [])]) {
methods.push({
className: cls.name,
classSource: cls.source,
classGroup: cls.group,
method,
});
}
for (const prop of [...(cls.properties || []), ...(cls.protectedProperties || [])]) {
properties.push({
className: cls.name,
classSource: cls.source,
classGroup: cls.group,
property: prop,
});
}
for (const enumInfo of cls.enums || []) {
enums.push({
className: cls.name,
classSource: cls.source,
classGroup: cls.group,
enumInfo,
});
}
}
return { methods, properties, enums };
}
/**
* Get all classes that inherit from ScriptComponent (directly or indirectly).
* Useful for finding available components to attach to entities.
*/
getComponents(): ClassInfo[] {
const tree = this.getClassTree("ScriptComponent");
const results: ClassInfo[] = [];
for (const name of tree.descendants) {
const cls = this.getClass(name);
if (cls) results.push(cls);
}
return results;
}
/**
* Check if a class name exists in the index (case-insensitive).
*/
hasClass(name: string): boolean {
return this.classByName.has(name.toLowerCase());
}
isLoaded(): boolean {
return this.loaded;
}
getStats(): {
totalClasses: number;
totalMethods: number;
totalEnums: number;
totalProperties: number;
totalWikiPages: number;
} {
return {
totalClasses: this.classByName.size,
totalMethods: this.methodIndex.size,
totalEnums: this.enumIndex.size,
totalProperties: this.propertyIndex.size,
totalWikiPages: this.wikiPages.length,
};
}
}