@ts-for-gir/lib
Version:
Typescript .d.ts generator from GIR for gjs
464 lines (404 loc) • 14.5 kB
text/typescript
import { readFile } from "node:fs/promises";
import { type GirInclude, type GirNamespace, type GirRepository, type GirXML, parser } from "@gi.ts/parser";
import { APP_VERSION } from "./constants.ts";
import type { GirModule } from "./gir-module.ts";
import { LibraryVersion } from "./library-version.ts";
import { Logger } from "./logger.ts";
import type { Dependency, FileInfo, OptionsGeneration } from "./types/index.ts";
import { findFilesInDirs } from "./utils/files.ts";
import { splitModuleName } from "./utils/girs.ts";
import { sanitizeNamespace, transformImportName, transformModuleNamespaceName } from "./utils/naming.ts";
import { pascalCase } from "./utils/strings.ts";
export class DependencyManager {
protected log: Logger;
protected readonly config: OptionsGeneration;
protected _cache: { [packageName: string]: Dependency } = {};
static instances: { [key: string]: DependencyManager } = {};
protected constructor(config: OptionsGeneration) {
this.config = config;
this.log = new Logger(config.verbose, "DependencyManager");
}
/**
* Get the DependencyManager singleton instance
*/
static getInstance(config?: OptionsGeneration): DependencyManager {
const configKey = config ? JSON.stringify(config) : Object.keys(DependencyManager.instances)[0];
if (DependencyManager.instances[configKey]) {
return DependencyManager.instances[configKey];
}
if (!config) {
throw new Error("config parameter is required to initialize DependencyManager");
}
const instance = new DependencyManager(config);
DependencyManager.instances[configKey] = instance;
return instance;
}
protected parsePackageName(namespaceOrPackageName: string, version?: string) {
let packageName: string;
let namespace: string;
if (version) {
namespace = namespaceOrPackageName;
packageName = `${namespace}-${version}`;
} else {
packageName = namespaceOrPackageName;
const { namespace: _namespace, version: _version } = splitModuleName(packageName);
namespace = _namespace;
version = _version;
}
return { packageName, namespace, version };
}
protected parseArgs(namespaceOrPackageNameOrRepo: string | GirRepository, version?: string, noOverride?: boolean) {
let packageName: string;
let namespace: string;
let repo: GirRepository | null = null;
if (typeof namespaceOrPackageNameOrRepo === "string") {
// Special case for Gjs
if (!noOverride && namespaceOrPackageNameOrRepo === "Gjs") {
return { ...this.getGjs(), repo: null };
}
const args = this.parsePackageName(namespaceOrPackageNameOrRepo, version);
version = args.version;
packageName = args.packageName;
namespace = args.namespace;
} else {
repo = namespaceOrPackageNameOrRepo;
const ns = repo.namespace?.[0];
if (!ns) {
throw new Error("Invalid GirRepository");
}
version = ns.$.version;
namespace = ns.$.name;
packageName = `${namespace}-${version}`;
}
return { packageName, namespace, version, repo };
}
/**
* Get all dependencies in the cache
* @returns All dependencies in the cache
*/
all(): Dependency[] {
return Object.values(this._cache);
}
getAllPackageNames(): string[] {
return Object.keys(this._cache);
}
/**
* Get the core dependencies
* @returns
*/
async core(): Promise<Dependency[]> {
return [
await this.get("GObject", "2.0"),
await this.get("GLib", "2.0"),
await this.get("Gio", "2.0"),
await this.get("cairo", "1.0"),
];
}
createImportProperties(namespace: string, packageName: string, version: string, libraryVersion?: LibraryVersion) {
const importPath = this.createImportPath(packageName, namespace, version);
const importDef = this.createImportDef(namespace, importPath);
// For GObject and Gio, use GLib's library version if available
let effectiveLibraryVersion = libraryVersion;
if ((namespace === "GObject" || namespace === "Gio") && this._cache["GLib-2.0"]) {
const glibDep = this._cache["GLib-2.0"];
if (glibDep.libraryVersion.toString() !== "0.0.0") {
effectiveLibraryVersion = glibDep.libraryVersion;
}
}
const packageJsonImport = this.createPackageJsonImport(importPath, effectiveLibraryVersion);
return {
importPath,
importDef,
packageJsonImport,
};
}
createImportPath(packageName: string, namespace: string, version: string): string {
// In external-deps mode every dep import is resolved against an installed npm package
// (e.g. `@girs/glib-2.0`), regardless of `package` mode. User-supplied overrides win
// for namespaces with non-default scopes/versions (e.g. `Soup → @girs/soup-3.0`).
if (this.config.externalDeps) {
const override = this.config.externalPackages?.[namespace];
if (override) return override;
const importName = transformImportName(packageName);
return `${this.config.npmScope}/${importName}`;
}
if (!this.config.package) {
return `gi://${namespace}?version=${version}`;
}
const importName = transformImportName(packageName);
const importPath = `${this.config.npmScope}/${importName}`;
return importPath;
}
createImportDef(namespace: string, importPath: string): string {
return this.config.noNamespace
? `import type * as ${namespace} from '${importPath}'`
: `import type ${namespace} from '${importPath}';`;
}
createPackageJsonImport(importPath: string, libraryVersion?: LibraryVersion): string {
const pinnedVersion = libraryVersion ? `${libraryVersion.toString()}-${APP_VERSION}` : APP_VERSION;
const format = this.config.depVersionFormat ?? (this.config.workspace ? "workspace" : "exact");
let depVersion: string;
switch (format) {
case "workspace":
depVersion = "workspace:^";
break;
case "caret":
depVersion = `^${pinnedVersion}`;
break;
case "any":
depVersion = "*";
break;
default:
depVersion = pinnedVersion;
break;
}
return `"${importPath}": "${depVersion}"`;
}
protected async parseGir(path: string) {
const girXML = parser.parseGir(await readFile(path, "utf8"));
const repo = girXML.repository[0];
const ns = repo?.namespace?.[0];
const version = ns?.$.version;
return { girXML, repo, ns, version };
}
protected async parseGirAndReturnLatestVersion(filesInfo: FileInfo[]) {
const libraryVersions: { libraryVersion: LibraryVersion; girXML: GirXML; fileInfo: FileInfo }[] = [];
if (filesInfo.length > 1) {
this.log.warn(`Multiple paths found for ${filesInfo[0].filename}`);
}
for (const fileInfo of filesInfo) {
if (!fileInfo.exists || !fileInfo.path) {
continue;
}
const { girXML, ns, version } = await this.parseGir(fileInfo.path);
if (!version || !ns) {
continue;
}
const libraryVersion = new LibraryVersion(ns?.constant, version);
if (filesInfo.length > 1) {
this.log.muted(` - ${fileInfo.path} (${libraryVersion.toString()})`);
}
libraryVersions.push({
libraryVersion,
girXML,
fileInfo,
});
}
// Compare all library versions and return the latest version
const latestLibraryVersion = libraryVersions.sort((a, b) => a.libraryVersion.compare(b.libraryVersion))[0];
if (!latestLibraryVersion) {
this.log.warn("No latest library version found", {
libraryVersions,
filesInfo,
});
return {
libraryVersion: new LibraryVersion(),
girXML: null,
fileInfo: filesInfo[0],
};
}
if (filesInfo.length > 1) {
this.log.muted(
`Use latest version ${latestLibraryVersion.libraryVersion.toString()} from ${latestLibraryVersion.fileInfo.path}`,
);
}
return latestLibraryVersion;
}
/**
* Get the dependency object by packageName
* @param packageName The package name (with version affix) of the dependency
* @returns The dependency object
*/
async get(packageName: string): Promise<Dependency>;
/**
* Get the dependency object by namespace and version
* @param namespace The namespace of the dependency
* @param version The version of the dependency
* @returns The dependency object
*/
async get(namespace: string, version: string, noOverride?: boolean): Promise<Dependency>;
/**
* Get the dependency object by {@link GirRepository}
* @param namespace The namespace of the dependency
* @param version The version of the dependency
* @returns The dependency object
*/
async get(repo: GirRepository, version?: string, noOverride?: boolean): Promise<Dependency>;
async get(
namespaceOrPackageNameOrRepo: string | GirRepository,
_version?: string,
noOverride?: boolean,
): Promise<Dependency> {
const parsedArgs = this.parseArgs(namespaceOrPackageNameOrRepo, _version, noOverride);
const { packageName, repo } = parsedArgs;
let { namespace, version } = parsedArgs;
namespace = sanitizeNamespace(namespace);
if (this._cache[packageName]) {
const dep = this._cache[packageName];
return dep;
}
const filename = `${packageName}.gir`;
const filesInfo = await findFilesInDirs(this.config.girDirectories, filename);
const { libraryVersion, girXML, fileInfo } = await this.parseGirAndReturnLatestVersion(filesInfo);
const ns = (girXML?.repository[0]?.namespace?.[0] || repo?.namespace?.[0] || null) as GirNamespace | null;
// Use the version from the gir file if it exists
if (ns?.$.version) {
version = ns?.$.version;
}
if (ns?.$.name) {
namespace = ns?.$.name;
}
const dependency: Dependency = {
...fileInfo,
namespace,
packageName,
importName: transformImportName(packageName),
importNamespace: transformModuleNamespaceName(packageName),
version,
libraryVersion,
girXML,
...this.createImportProperties(namespace, packageName, version, libraryVersion),
};
// Special case for Cairo
// This is a special case for Cairo because Cairo in GJS is provided as a built-in module that doesn't
// follow the standard GI repository pattern.
// So we need to special case it and redirect to the 'cairo' package.
// This changes the typescript import definition to use the internal 'cairo' package instead of the 'cairo-1.0' Gir package.
if (!noOverride && namespace === "cairo" && version === "1.0") {
dependency.importDef = this.createImportDef("cairo", "cairo");
}
this._cache[packageName] = dependency;
return dependency;
}
/**
* Get all dependencies with the given namespace
* @param namespace The namespace of the dependency
* @returns All dependencies with the given namespace
*/
list(namespace: string): Dependency[] {
const packageNames = this.all();
const candidates = packageNames.filter((dep) => {
return dep.namespace === namespace && dep.exists;
});
return candidates;
}
/**
* Get girModule for dependency
* @param girModules
* @param packageName
*/
getModule(girModules: GirModule[], dep: Dependency): GirModule | undefined {
return girModules.find(
(m) => m.packageName === dep.packageName && m.namespace === dep.namespace && m.version === dep.version,
);
}
/**
* Add all dependencies from an array of gir modules
* @param girModules
*/
async addAll(girModules: GirModule[]): Promise<Dependency[]> {
for (const girModule of girModules) {
await this.get(girModule.namespace, girModule.version || "0.0");
}
return this.all();
}
/**
* Transforms a gir include object array to a dependency object array
* @param girIncludes - Array of gir includes
* @returns Array of dependencies
*/
async fromGirIncludes(girIncludes: GirInclude[]): Promise<Dependency[]> {
const dependencies: Dependency[] = [];
for (const i of girIncludes) {
dependencies.unshift(await this.get(i.$.name, i.$.version || "0.0"));
}
return dependencies;
}
/**
* Check if multiple dependencies with the given namespace exist in the cache
* @param namespace The namespace of the dependency
* @returns
*/
hasConflict(namespace: string): boolean {
const packageNames = this.getAllPackageNames();
const candidates = packageNames.filter((packageName) => {
return packageName.startsWith(`${namespace}-`) && this._cache[packageName].namespace === namespace;
});
return candidates.length > 1;
}
/**
* get the latest version of the dependency with the given namespace
* @param namespace The namespace of the dependency
* @returns The latest version of the dependency
*/
getLatestVersion(namespace: string): Dependency | undefined {
const candidates = this.list(namespace);
const latestVersion = candidates
.sort((a, b) => {
return a.version.localeCompare(b.version);
})
.pop();
return latestVersion;
}
/**
* Check if the given version is the latest version of the dependency
* @param namespace The namespace of the dependency
* @param version The version of the dependency
* @returns
*/
isLatestVersion(namespace: string, version: string): boolean {
const latestVersion = this.getLatestVersion(namespace);
return latestVersion?.version === version;
}
/**
* Find a dependency by it's namespace from the cache, if multiple versions are found, the latest version is returned
* @param namespace The namespace of the dependency
* @returns The dependency object or null if not found
*/
find(namespace: string): Dependency | null {
// Special case for Gjs
if (namespace === "Gjs") {
return this.getGjs();
}
const packageNames = this.getAllPackageNames();
const candidates = packageNames.filter((packageName) => {
return packageName.startsWith(`${namespace}-`) && this._cache[packageName].namespace === namespace;
});
if (candidates.length > 1) {
this.log.warn(`Found multiple versions of ${namespace}: ${candidates.join(", ")}`);
}
const latestVersion = candidates.sort().pop();
if (latestVersion && this._cache[latestVersion]) {
const dep = this._cache[latestVersion];
return dep;
}
return null;
}
protected getPseudoPackage(
packageName: string,
namespace: string = pascalCase(packageName),
version = "2.0",
): Dependency {
if (this._cache[`${packageName}_pseudo`]) {
return this._cache[`${packageName}_pseudo`];
}
const dep: Dependency = {
namespace,
exists: true,
filename: "",
path: "",
packageName: packageName,
importName: transformImportName(packageName),
importNamespace: transformModuleNamespaceName(packageName),
version,
libraryVersion: new LibraryVersion(),
girXML: null,
...this.createImportProperties(packageName, packageName, version),
};
this._cache[`${packageName}_pseudo`] = dep;
return dep;
}
getGjs(): Dependency {
return this.getPseudoPackage("Gjs");
}
}