#### libremiliacr
#### Copyright(C) 2020-2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or modify
#### it under the terms of the GNU General Public License as published
#### the Free Software Foundation, either version 3 of the License, or
#### (at your option) any later version.
####
#### This program is distributed in the hope that it will be useful,
#### but WITHOUT ANY WARRANTY; without even the implied warranty of
#### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
#### GNU General Public License for more details.
####
#### You should have received a copy of the GNU General Public License
#### along with this program.If not, see<http:####www.gnu.org/licenses/.>
module RemiLib::Config
# The supported types of user configuration directories.
enum DirectoryType
# Follows the [XDG Base Directory
# Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html).
Xdg
# Uses the `%APPDATA%` directory.
Windows
# A custom directory type.
Custom
end
# Represents a situation where the requested data file is not registered with
# the resolver.
class UnknownDataFileError < Exception
end
# The `Resolver` class is used to locate configuration files and user-specific
# data files in a platform-agnostic way.
class Resolver
# The associated type of configuration directory.
getter type : DirectoryType
# The directory name to use when storing configuration or data files. For
# example, if the `#type` is `DirectoryType::Xdg`, and the `#baseName` is
# equal to "test-program", then this `Resolver` will put configuration files
# under `$XDG_CONFIG_HOME/test-program/` and data files under
# `$XDG_DATA_HOME/some-program/`.
getter baseName : String
# The associated data directory. This is determined automatically based on
# the `#baseName`. For example, if the `#type` is `DirectoryType::Xdg`, and
# the `#baseName` is equal to "test-program", then this `Resolver` will put
# data files under `$XDG_DATA_HOME/some-program/`.
getter dataDir : Path
# The associated data directory. This is determined automatically based on
# the `#baseName`. For example, if the `#type` is `DirectoryType::Xdg`, and
# the `#baseName` is equal to "test-program", then this `Resolver` will put
# configuration files under `$XDG_CONFIG_HOME/test-program/`.
getter configDir : Path
# The registered data files. This maps a symbolic name (literally a symbol)
# to a path.
@dataFiles : Hash(Symbol, String|Path) = {} of Symbol => String|Path
protected def initialize(@type, @baseName, @configDir, @dataDir, dontCreateMissingDirs : Bool)
recreateMissing unless dontCreateMissingDirs
end
# Creates a new `Resolver` that understands the [XDG Base Directory
# Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html).
# If `dontCreateMissingDirs` is `true`, then the `#configDir` and `#dataDir`
# are **not** created automatically.
def self.xdg(baseName : String, *, dontCreateMissingDirs : Bool = false)
if baseName.empty?
raise "Resolver base names cannot be empty"
end
configDir = if configHome = ENV["XDG_CONFIG_HOME"]?
Path[configHome, baseName]
else
home = ENV["HOME"]
Path[home, ".config", baseName]
end
dataDir = if dataHome = ENV["XDG_DATA_HOME"]?
Path[dataHome, baseName]
else
home = ENV["HOME"]
Path[home, ".local", "share", baseName]
end
Resolver.new(DirectoryType::Xdg, baseName, configDir, dataDir, dontCreateMissingDirs)
end
# Creates a new `Resolver` that understands Windows `%APPDATA%`. If
# `dontCreateMissingDirs` is `true`, then the `#configDir` and `#dataDir`
# are **not** created automatically.
def self.windows(baseName : String, *, dontCreateMissingDirs : Bool = false)
if baseName.empty?
raise "Resolver base names cannot be empty"
end
configDir = Path[ENV["%APPDATA%"], baseName]
dataDir = Path[ENV["%APPDATA%"], baseName]
Resolver.new(DirectoryType::Xdg, baseName, configDir, dataDir, dontCreateMissingDirs)
end
# Creates a new `Resolver` that uses custom directories. When this is used,
# the `#configDir` and `#dataDir` are not generated based on the
# `#baseName`, and are instead the literal values passed into this method.
#
# If `dontCreateMissingDirs` is `true`, then the `#configDir` and `#dataDir`
# are **not** created automatically.
def self.custom(baseName : String, configDir : String|Path, dataDir : String|Path,
*, dontCreateMissingDirs : Bool = false)
type = DirectoryType::Custom
configDir = configDir.is_a?(Path) ? configDir : Path[configDir]
dataDir = dataDir.is_a?(Path) ? dataDir : Path[dataDir]
Resolver.new(DirectoryType::Custom, baseName, configDir, dataDir, dontCreateMissingDirs)
end
# Creates the `#configDir` and/or `#dataDir` if either are missing.
def recreateMissing
Dir.mkdir_p(@configDir) unless Dir.exists?(@configDir)
Dir.mkdir_p(@dataDir) unless Dir.exists?(@dataDir)
end
# Registers a new data file with this `Resolver` such that a filename
# becomes associated with a `Symbol`. This symbol can later be used to
# reference that file.
#
# The symbol must be unique to this `Resolver` instance.
def defineDataFile(symname : Symbol, filename : String|Path)
if filename.is_a?(Path) && filename.absolute?
raise "Cannot define absolute filenames for data files"
end
raise KeyError.new("Duplicate data file: #{symname}") if @dataFiles.has_key?(symname)
@dataFiles[symname] = filename
end
# Registers a new data file with this `Resolver` such that a filename
# becomes associated with a `Symbol`. This symbol can later be used to
# reference that file.
#
# If the symbol is not unique to this `Resolver` instance, then the old path
# is forgotten and the symbol becomes associated with the new path.
def defineDataFile!(symname : Symbol, filename : String|Path)
if filename.is_a?(Path) && filename.absolute?
raise "Cannot define absolute filenames for data files"
end
@dataFiles[symname] = filename
end
protected def getDataFilePath(symname : Symbol) : Path
Path[@dataDir, @dataFiles[symname]]
end
# Given a symbolic name for a data file, return the `Path` associated with
# it. If the symbol is not registered with this `Resolver`, then this
# raises an `UnknownDataFileError`.
def dataFile(symname : Symbol) : Path
if @dataFiles.has_key?(symname)
getDataFilePath(symname)
else
raise UnknownDataFileError.new("Data file is not registered: #{symname}")
end
end
# Given a symbolic name for a data file, return the `Path` associated with
# it. If the symbol is not registered with this `Resolver`, this returns
# `nil`.
def dataFile?(symname : Symbol) : Path?
if @dataFiles.has_key?(symname)
getDataFilePath(symname)
else
nil
end
end
# Looks up the associated `Path` for a symbolic name for a data file, then
# opens the file using `File.open` and yields the stream. If the symbol is
# not registered with this `Resolver`, then this raises an
# `UnknownDataFileError`.
def dataFile(symname : Symbol, &) : Nil
File.open(self.dataFile(symname)) do |file|
yield file
end
end
# Looks up the associated `Path` for a symbolic name for a data file, then
# checks to see if the file exists. If it does, this opens the file using
# `File.open` and yields the stream, then returns `true`. Otherwise this
# returns `false`.
#
# If the symbol is not registered with this `Resolver`, then this returns
# `false`.
def dataFile?(symname : Symbol, &) : Bool
if filename = self.dataFile?(symname)
if File.exists?(filename)
File.open(filename) do |file|
yield file
end
true
else
false
end
else
false
end
end
protected def dataFiles
@dataFiles
end
end
end