151 lines
4.5 KiB
TypeScript

import GObject, { register, signal } from "astal/gobject";
import { readFileAsync, monitorFile } from "astal/file";
import GLib from "gi://GLib";
import Gio from "gi://Gio";
import yaml from "yaml";
import { ConfigFile, Vars } from "./types";
@register({
GTypeName: "ConfigService",
})
export class ConfigService extends GObject.Object {
private get configPath(): string {
const envVar = GLib.getenv("VOIDSHELL_CONFIG");
if (envVar !== null) {
return envVar;
}
const defaultPath = `${GLib.getenv("XDG_CONFIG_HOME") ?? GLib.getenv("HOME") + "/.config"}/voidshell/config.yml`;
return defaultPath;
}
private fileMonitors: [path: string, mon: Gio.FileMonitor][] = [];
/**
* Normalizes a path to be relative to the config directory, unless it is absolute.
*/
private normalizePath(path: string) {
if (path.startsWith("/")) {
return path;
}
if (path.startsWith("~")) {
return `${GLib.getenv("HOME")}/${path.slice(2)}`;
}
const basedir = this.configPath.split("/").slice(0, -1).join("/");
return `${basedir}/${path}`;
}
/**
* Recursively parse config files
*/
private async parseConfig(path: string): Promise<ConfigFile> {
try {
const contents = await readFileAsync(this.normalizePath(path));
const parsed = yaml.parse(contents) as ConfigFile;
const importedFiles =
(await parsed.import?.amap(this.parseConfig.bind(this))) ?? [];
// Merge config files
return importedFiles.reduce((config, imported) => {
// Merge output definitions
if (config.outputs !== undefined) {
Object.entries(imported.outputs ?? {}).forEach(([output, def]) => {
config.outputs![output] = { ...config.outputs?.[output], ...def };
});
} else {
config.outputs = imported.outputs;
}
// Merge workspace definitions
config.workspaces = { ...config.workspaces, ...imported.workspaces };
// Merge layout definitions
config.layouts = { ...config.layouts, ...imported.layouts };
// Merge context definitions
config.contexts = config.contexts ?? {};
Object.entries(imported.contexts ?? {}).forEach(([context, def]) => {
if (context in config.contexts!) {
config.contexts![context].vars = {
...config.contexts![context].vars,
...def.vars,
};
config.contexts![context].groups =
def.groups ?? config.contexts![context].groups;
} else {
config.contexts![context] = def;
}
});
// Merge variables
config.vars = { ...config.vars, ...imported.vars };
return config;
}, parsed);
} catch (e) {
console.warn(e);
return {};
}
}
/**
* Format a string, usually taken from the config, using the variables set in the config and extra variables passed in
*/
public formatConfigString(value: string, extraArgs?: Vars): string {
const args = { ...this.config.vars, ...extraArgs };
return value.replace(
/{([a-zA-Z_][a-zA-Z0-9_\-\.]*)}/g,
(match, identifier: string) => {
// @ts-expect-error
const replacer = identifier.split(".").reduce((val, id) => {
if (val === undefined) return undefined;
if (typeof val !== "object") return undefined;
if (id in val) return val[id];
return undefined;
}, args);
if (replacer === undefined) return match;
if (typeof replacer === "object") return match;
return replacer;
},
);
}
/**
* Pass the given string to formatConfigString and then evaluate it as a mathematical expression. Only addition and subtraction are supported.
*/
public doConfigMath(value: string, extraArgs?: Vars): number {
const str = this.formatConfigString(value, extraArgs);
let total = 0;
const matches = str.match(/[+\-]*(\.\d+|\d+(\.\d+)?)/g) ?? [];
while (matches.length) {
total += parseFloat(matches.shift() ?? "0");
}
return total;
}
public get config(): Readonly<ConfigFile> {
return this._config;
}
private _config: ConfigFile = {};
/**
* Fully refresh the config file
*/
async refreshConfig() {
this.fileMonitors.forEach(([_, mon]) => {
mon.cancel();
});
this.fileMonitors = [];
this._config = await this.parseConfig(this.configPath);
this.configReloaded();
}
@signal()
declare configReloaded: () => void;
constructor() {
super();
this.refreshConfig();
}
}