@ts-for-gir/cli
Version:
TypeScript type definition generator for GObject introspection GIR files
243 lines (214 loc) • 9.01 kB
text/typescript
/**
* Config loader functionality for ts-for-gir
*/
import { dirname, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import type { ConfigFlags, OptionsGeneration, UserConfig, UserConfigLoadResult } from "@ts-for-gir/lib";
import { APP_NAME, isEqual } from "@ts-for-gir/lib";
import { type Options as ConfigSearchOptions, cosmiconfig } from "cosmiconfig";
import { setConfigFilePath } from "./config-writer.ts";
import { defaults } from "./defaults.ts";
import { docOptions, options } from "./options.ts";
/**
* The user can create a `.ts-for-girrc` file for his default configs,
* this method load this config file an returns the user configuration
* @param configName If the user uses a custom config file name
*/
export async function loadConfigFile(configName?: string): Promise<UserConfigLoadResult | null> {
const configSearchOptions: Partial<ConfigSearchOptions> = {
loaders: {
// ESM loader. cosmiconfig hands us an absolute filesystem path; Node's import()
// tolerates that as a non-spec extension, but spec-compliant runtimes (GJS /
// SpiderMonkey) reject it with "Module not found: <abs-path>". Convert to a
// file:// URL so the loader works in both runtimes.
".js": async (filepath) => {
const file = await import(pathToFileURL(filepath).href);
// Files with `exports.default = { ... }`
if (file?.default?.default) {
return file.default.default as Partial<UserConfig>;
}
// Files with `export default { ... }`
if (file?.default) {
return file.default as Partial<UserConfig>;
}
// Files with `export { ... }`
return file as Partial<UserConfig>;
},
},
};
if (configName) {
configSearchOptions.searchPlaces = [configName];
}
const configFile: UserConfigLoadResult | null = await cosmiconfig(APP_NAME, configSearchOptions).search();
if (configFile?.filepath) {
setConfigFilePath(configFile.filepath);
}
return configFile;
}
/**
* Convert UserConfig to OptionsGeneration
*/
export function getOptionsGeneration(config: UserConfig): OptionsGeneration {
const generateConfig: OptionsGeneration = {
...config,
};
return generateConfig;
}
/**
* Parse `Namespace=npm-package` strings (from repeatable `--external-package` flag) into a
* map. Silently drops entries that don't contain `=`. Empty input returns undefined so the
* field stays absent in the merged config (rather than `{}`, which would shadow rc values).
*/
function parseExternalPackagePairs(pairs: string[] | undefined): Record<string, string> | undefined {
if (!pairs || pairs.length === 0) return undefined;
const map: Record<string, string> = {};
for (const pair of pairs) {
const eq = pair.indexOf("=");
if (eq < 1) continue;
const ns = pair.slice(0, eq).trim();
const pkg = pair.slice(eq + 1).trim();
if (ns && pkg) map[ns] = pkg;
}
return Object.keys(map).length > 0 ? map : undefined;
}
/**
* Validate the configuration
*/
export function validate(config: UserConfig): UserConfig {
return config;
}
/**
* Merge a single config value from file config to user config
* @param userConfig The user config object to update
* @param configFileData The config file data to merge from
* @param key The config key to merge
* @param optionDefault The default value from options
* @param validator Optional validation function
*/
const isBoolean = (v: unknown) => typeof v === "boolean";
function mergeConfigValue<K extends keyof UserConfig>(
userConfig: UserConfig,
configFileData: Partial<UserConfig>,
key: K,
optionDefault: unknown,
validator?: (value: unknown) => boolean,
): void {
const fileValue = configFileData[key];
const userValue = userConfig[key];
// Skip if no file value
if (fileValue === undefined) return;
// Apply validator if provided
if (validator && !validator(fileValue)) return;
// Check if user value is default
const isDefault =
userValue === optionDefault ||
(Array.isArray(userValue) && Array.isArray(optionDefault) && isEqual(userValue, optionDefault));
if (isDefault) {
(userConfig[key] as UserConfig[K]) = fileValue as UserConfig[K];
}
}
/**
* Loads the values of the config file and concatenate them with passed cli flags / arguments.
* The values from config file are preferred if the cli flag value is the default (and so not set / overwritten)
* @param cliOptions CLI options passed by the user
*/
export async function load(cliOptions: ConfigFlags): Promise<UserConfig> {
const configFile = await loadConfigFile(cliOptions.configName);
const configFileData = configFile?.config || {};
// `--external-package GLib=@girs/glib-2.0` arrives as a string[]; collapse to Record.
// Drop the raw array so it doesn't pollute the merged UserConfig surface.
const externalPackagesFromCli = parseExternalPackagePairs(
(cliOptions as { externalPackage?: string[] }).externalPackage,
);
const { externalPackage: _externalPackage, ...cliOptionsClean } = cliOptions as ConfigFlags & {
externalPackage?: string[];
};
const userConfig: UserConfig = {
...cliOptionsClean,
};
if (externalPackagesFromCli) {
userConfig.externalPackages = externalPackagesFromCli;
}
if (configFileData) {
// Boolean options — config file overrides CLI defaults
const booleanKeys: Array<[keyof UserConfig, unknown]> = [
["verbose", options.verbose.default],
["ignoreVersionConflicts", options.ignoreVersionConflicts.default],
["print", options.print.default],
["noNamespace", options.noNamespace.default],
["noComments", options.noComments.default],
["promisify", options.promisify.default],
["workspace", options.workspace.default],
["onlyVersionPrefix", options.onlyVersionPrefix.default],
["noPrettyPrint", options.noPrettyPrint.default],
["noAdvancedVariants", options.noAdvancedVariants.default],
["package", options.package.default],
["reporter", options.reporter.default],
["externalDeps", options.externalDeps.default],
["allowMissingDeps", options.allowMissingDeps.default],
["combined", docOptions.combined.default],
["merge", docOptions.merge.default],
];
for (const [key, defaultVal] of booleanKeys) {
mergeConfigValue(userConfig, configFileData, key, defaultVal, isBoolean);
}
// String options — config file overrides CLI defaults
const stringKeys: Array<[keyof UserConfig, unknown]> = [
["npmScope", options.npmScope.default],
["reporterOutput", options.reporterOutput.default],
["depVersionFormat", undefined],
["theme", docOptions.theme.default],
["sourceLinkTemplate", undefined],
["readme", undefined],
["jsonDir", undefined],
];
for (const [key, defaultVal] of stringKeys) {
mergeConfigValue(userConfig, configFileData, key, defaultVal);
}
// Array options — config file overrides CLI defaults
const arrayKeys: Array<[keyof UserConfig, unknown]> = [
["ignore", options.ignore.default],
["modules", options.modules.default],
];
for (const [key, defaultVal] of arrayKeys) {
mergeConfigValue(userConfig, configFileData, key, defaultVal);
}
// girDirectories: rc-file entries are prepended to the current dirs (CLI-provided or
// system defaults) rather than replacing them. This lets projects add local GIR dirs
// (e.g. a Vala build output) without having to enumerate all system paths in the rc.
// To use ONLY the specified dirs (no system fallback), pass --girDirectories on the CLI.
if (configFileData.girDirectories?.length) {
const current = userConfig.girDirectories as string[];
const toAdd = (configFileData.girDirectories as string[]).filter((d) => !current.includes(d));
if (toAdd.length > 0) {
userConfig.girDirectories = [...toAdd, ...current];
}
}
// Special handling for root
if (userConfig.root === options.root.default && (configFileData.root || configFile?.filepath)) {
userConfig.root =
configFileData.root || (configFile?.filepath ? dirname(configFile.filepath) : (options.root.default as string));
}
// Special handling for outdir (override with config file value if still at a default)
const isDefaultOutdir = userConfig.outdir === options.outdir.default || userConfig.outdir === defaults.docOutdir;
if (isDefaultOutdir && configFileData.outdir) {
userConfig.outdir = userConfig.print ? null : configFileData.outdir;
}
// externalPackages is a Record<string, string> in rc files; CLI overrides take precedence.
if (!externalPackagesFromCli && configFileData.externalPackages) {
userConfig.externalPackages = configFileData.externalPackages;
}
}
// Make paths absolute relative to root
const resolveToRoot = (path: string) => (path.startsWith("/") ? path : resolve(userConfig.root, path));
if (userConfig.outdir) {
userConfig.outdir = resolveToRoot(userConfig.outdir);
}
if (userConfig.jsonDir) {
userConfig.jsonDir = resolveToRoot(userConfig.jsonDir);
}
if (userConfig.girDirectories) {
userConfig.girDirectories = userConfig.girDirectories.map(resolveToRoot);
}
return validate(userConfig);
}