@ts-for-gir/cli
Version:
TypeScript type definition generator for GObject introspection GIR files
330 lines (295 loc) • 11.7 kB
text/typescript
/**
* The ModuleLoader is used for reading gir modules from the file system and to solve conflicts (e.g. Gtk-3.0 and Gtk-4.0 would be a conflict)
*/
import type {
AnswerVersion,
Dependency,
GirModuleResolvedBy,
GirModulesGroupedMap,
NSRegistry,
OptionsGeneration,
} from "@ts-for-gir/lib";
import {
DependencyManager,
GirModule,
Logger,
ResolveType,
union,
WARN_NO_GIR_FILE_FOUND_FOR_PACKAGE,
} from "@ts-for-gir/lib";
import { DependencyResolver, FileFinder, ModuleGrouper, PromptHandler } from "./module-loader/index.ts";
export class ModuleLoader {
private readonly log: Logger;
private readonly dependencyManager: DependencyManager;
private readonly dependencyResolver: DependencyResolver;
private readonly fileFinder: FileFinder;
private readonly moduleGrouper: ModuleGrouper;
private readonly promptHandler: PromptHandler;
constructor(
private readonly config: OptionsGeneration,
private readonly registry: NSRegistry,
) {
this.log = new Logger(config.verbose, "ModuleLoader");
this.dependencyManager = DependencyManager.getInstance(config);
this.dependencyResolver = new DependencyResolver();
this.fileFinder = new FileFinder(config.girDirectories, this.dependencyManager);
this.moduleGrouper = new ModuleGrouper();
this.promptHandler = new PromptHandler(config.verbose);
}
/**
* Sets the traverse dependencies for the current girModule,
* is required so that all dependencies can be found internally when generating the dependency imports for the module .d.ts file
*/
private async initGirModules(girModules: GirModuleResolvedBy[]): Promise<void> {
for (const girModule of girModules) {
const dependencies = this.dependencyResolver.getTransitiveDependencies(girModule.packageName);
await girModule.module.initTransitiveDependencies(dependencies);
}
}
/**
* Reads a gir xml module file and creates an object of GirModule.
* Also sets the setDependencyMap
*/
private async loadAndCreateGirModule(dependency: Dependency): Promise<GirModule | null> {
if (!dependency.exists || dependency.path === null) {
return null;
}
this.log.log(`Loading ${dependency.packageName}...`);
const girModule = await GirModule.load(dependency, this.config, this.registry);
// Figure out transitive module dependencies
this.dependencyResolver.extendDependencyMapByGirModule(girModule);
return girModule;
}
/**
* If multiple versions of the same module are found, this will ask the user with input prompts for the version they wish to use.
* Ignores also modules that depend on a module that should be ignored
*/
private async askForEachConflictVersionsPrompt(
girModulesGroupedMap: GirModulesGroupedMap,
ignore: string[],
): Promise<{ keep: Set<GirModuleResolvedBy>; ignore: string[] }> {
let keep = new Set<GirModuleResolvedBy>();
for (const girModulesGrouped of Object.values(girModulesGroupedMap)) {
// Remove ignored modules from group
girModulesGrouped.modules = girModulesGrouped.modules.filter(
(girGroup) => !ignore.includes(girGroup.packageName),
);
girModulesGrouped.hasConflict = girModulesGrouped.modules.length >= 2;
if (girModulesGrouped.modules.length <= 0) {
continue;
}
// Ask for version if there is a conflict
if (!girModulesGrouped.hasConflict) {
keep = union<GirModuleResolvedBy>(keep, girModulesGrouped.modules);
} else {
let goBack = true;
let versionAnswer: AnswerVersion | null = null;
let ignoreDepsAnswer: "Yes" | "No" | "Go back" | null = null;
let wouldIgnoreDeps: GirModuleResolvedBy[] = [];
while (goBack) {
versionAnswer = await this.promptHandler.askForVersionsPrompt(girModulesGrouped);
// Check modules that depend on the unchosen modules
wouldIgnoreDeps = this.dependencyResolver.findModulesDependingOnPackages(
girModulesGroupedMap,
versionAnswer.unselected,
);
// Do not check dependencies that have already been ignored
wouldIgnoreDeps = wouldIgnoreDeps.filter((dep) => !ignore.includes(dep.packageName));
ignoreDepsAnswer = await this.promptHandler.askIgnoreDepsPrompt(wouldIgnoreDeps);
goBack = ignoreDepsAnswer === "Go back";
}
if (!versionAnswer) {
throw new Error("Error in processing the prompt versionAnswer");
}
if (ignoreDepsAnswer === "Yes") {
// Also ignore the dependencies of the unselected version
ignore = ignore.concat(wouldIgnoreDeps.map((dep) => dep.packageName));
}
const unionMe = this.moduleGrouper.sortVersionsByAnswer(girModulesGrouped, versionAnswer.selected);
// Do not ignore the selected package version
keep = union<GirModuleResolvedBy>(keep, unionMe.keep);
// Ignore the unchosen package versions
ignore = ignore.concat(unionMe.ignore);
}
}
if (ignore && ignore.length > 0) {
this.promptHandler.showIgnoredModules(ignore);
await this.promptHandler.askAddToIgnoreToConfigPrompt(ignore);
}
return {
keep,
ignore,
};
}
/**
* Reads the gir xml module files and creates an object of GirModule for each module
*/
private async loadGirModules(
dependencies: Dependency[],
ignoreDependencies: string[] = [],
girModules: GirModuleResolvedBy[] = [],
resolvedBy = ResolveType.BY_HAND,
failedGirModules = new Set<string>(),
): Promise<{ loaded: GirModuleResolvedBy[]; failed: Set<string> }> {
let newModuleFound = false;
// Clone array and filter out ignored dependencies
dependencies = [...dependencies].filter((dep) => {
const packageName = dep.packageName;
return !ignoreDependencies.some((ignored) => {
// Remove */ prefix if present (e.g., "*/Gtk-4.0" -> "Gtk-4.0")
const cleanIgnored = ignored.startsWith("*/") ? ignored.slice(2) : ignored;
return packageName === cleanIgnored;
});
});
while (dependencies.length > 0) {
const dependency = dependencies.shift();
if (!dependency?.packageName) continue;
// If module has not already been loaded
if (!this.dependencyResolver.existsGirModules(girModules, dependency.packageName)) {
const girModule = await this.loadAndCreateGirModule(dependency);
if (!girModule) {
if (!failedGirModules.has(dependency.packageName)) {
// In external-deps mode the strict check after loading turns missing
// transitive deps into a hard error; suppress the per-dep warn here so
// the user sees one consolidated error instead of a wall of warnings.
// `--allow-missing-deps` opts into the same suppression with a soft fail.
if (!this.config.externalDeps) {
this.log.warn(WARN_NO_GIR_FILE_FOUND_FOR_PACKAGE(dependency.packageName));
}
failedGirModules.add(dependency.packageName);
}
} else if (girModule?.packageName) {
const addModule = {
namespace: dependency.namespace,
version: dependency.version,
packageName: girModule.packageName,
module: girModule,
resolvedBy,
path: dependency.path,
};
girModules.push(addModule);
newModuleFound = true;
}
}
}
if (!newModuleFound) {
return {
loaded: girModules,
failed: failedGirModules,
};
}
// Figure out transitive module dependencies
await this.initGirModules(girModules);
// Load girModules for dependencies
for (const girModule of girModules) {
// Load dependencies
const transitiveDependencies = girModule.module.transitiveDependencies;
if (transitiveDependencies.length > 0) {
await this.loadGirModules(
transitiveDependencies,
ignoreDependencies,
girModules,
ResolveType.DEPENDENCE,
failedGirModules,
);
}
}
return {
loaded: girModules,
failed: failedGirModules,
};
}
/**
* Loads all found `packageNames`
* @param packageNames Module names to load
* @param ignore Modules to ignore
* @param doNotAskForVersionOnConflict Set this to false if you want to get a prompt for each version conflict
*/
public async getModulesResolved(
packageNames: string[],
ignore: string[] = [],
doNotAskForVersionOnConflict = true,
): Promise<{ keep: GirModuleResolvedBy[]; grouped: GirModulesGroupedMap; ignore: string[]; failed: Set<string> }> {
const girFiles = await this.fileFinder.findGirFiles([...packageNames], ignore);
// Always require these because GJS does...
const GLib = await this.dependencyManager.get("GLib", "2.0");
const Gio = await this.dependencyManager.get("Gio", "2.0");
const GObject = await this.dependencyManager.get("GObject", "2.0");
const Cairo = await this.dependencyManager.get("cairo", "1.0");
// Update library versions for GObject and Gio to use GLib's version
const dependencies = await this.fileFinder.girFilePathToDependencies(girFiles);
// Load GJS force-loads as DEPENDENCE so they are never treated as user-requested
// modules. This ensures --external-deps mode doesn't emit output files for them.
const { loaded: forceLoaded, failed: forceFailed } = await this.loadGirModules(
[GLib, Gio, GObject, Cairo],
ignore,
[],
ResolveType.DEPENDENCE,
);
// Load user-requested modules (and their transitive deps) starting from the
// already-loaded force-load registry so they are not double-loaded.
const { loaded, failed } = await this.loadGirModules(
dependencies.filter(
(dep) =>
dep.namespace !== "GLib" &&
dep.namespace !== "Gio" &&
dep.namespace !== "GObject" &&
dep.namespace !== "cairo",
),
ignore,
forceLoaded,
ResolveType.BY_HAND,
forceFailed,
);
// External-deps mode is strict by default: any transitive dep GIR that couldn't be
// loaded would silently degrade type quality. The hardcoded GLib/Gio/GObject/cairo
// force-loads above are exempt (they exist for runtime convenience, not because the
// input GIR references them).
if (this.config.externalDeps && !this.config.allowMissingDeps && failed.size > 0) {
const exempt = new Set(["GLib-2.0", "Gio-2.0", "GObject-2.0", "cairo-1.0"]);
const critical = Array.from(failed).filter((pkg) => !exempt.has(pkg));
if (critical.length > 0) {
throw new Error(
`Missing GIR files for transitive dependencies in --external-deps mode:\n` +
critical.map((pkg) => ` - ${pkg}`).join("\n") +
`\n\nInstall the corresponding -devel packages, add their directories to ` +
`--girDirectories, or pass --allow-missing-deps to generate anyway ` +
`(warning: degraded type quality).`,
);
}
}
let keep: GirModuleResolvedBy[] = [];
if (doNotAskForVersionOnConflict) {
keep = loaded;
} else {
const girModulesGrouped = this.moduleGrouper.groupGirFiles(loaded);
const filtered = await this.askForEachConflictVersionsPrompt(girModulesGrouped, ignore);
keep = Array.from(filtered.keep);
}
const grouped = this.moduleGrouper.groupGirFiles(keep);
return { keep, grouped, ignore, failed };
}
/**
* Find modules
* @param modules Module names to find
* @param ignore Modules to ignore
*/
public async getModules(
modules: string[],
ignore: string[] = [],
): Promise<{ grouped: GirModulesGroupedMap; loaded: GirModuleResolvedBy[]; failed: string[] }> {
const girFiles = await this.fileFinder.findGirFiles(modules, ignore);
const dependencies = await this.fileFinder.girFilePathToDependencies(girFiles);
const { loaded, failed } = await this.loadGirModules(dependencies, ignore);
const grouped = this.moduleGrouper.groupGirFiles(loaded);
return { grouped, loaded, failed: Array.from(failed) };
}
/**
* Start parsing the gir modules
*/
public parse(girModules: GirModuleResolvedBy[]): void {
for (const girModule of girModules) {
girModule.module.parse();
}
}
}