#### 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/.>
require "./resolver"
require "json"
require "yaml"
module RemiLib::Config
annotation ConfigOpts
end
# The `ConfigFile` module is included in classes that represent a
# configuration file. This module must be included for the library to handle
# the configuration (de)serialization correctly.
#
# Any class that includes `ConfigFile` must also be annotated with
# `ConfigOpts` to declare the associated configuration filename and format.
# For example:
#
# ```crystal
# require "libremiliacr"
#
# module SomeProgram
# # Define a class to represent a configuration file named "test.json".
# @[RemiConf::ConfigOpts(filename: "test.json", format: :json)]
# class Config
# include ::RemiConf::ConfigFile
# property username : String = ""
# end
#
# # Define a resolver that will automatically locate config files.
# res = RemiConf::Resolver.xdg("some-program")
#
# # Read the config. Since we're using an xdg resolver, this will look for the
# # file at $XDG_CONFIG_HOME/some-program/test.json
# config = Config.readConfig(res)
# puts config.username
# end
# ```
module ConfigFile
# :nodoc:
macro included
{% ann = @type.annotation(::RemiLib::Config::ConfigOpts) %}
{% if ann %}
class_getter filename : String =
{% if ann[:filename] %}
{{ann[:filename]}}
{% else %}
{% raise "#{@type} included RemiLib::Config::ConfigFile, but does not have a filename in its annotation" %}
{% end %}
{% if ann[:format] %}
{% if ann[:format] == :json %}
include JSON::Serializable
{% elsif ann[:format] == :yaml %}
include YAML::Serializable
{% elsif ann[:format] == :hjson %}
{% if flag?(:remiconf_no_hjson) %}
{% raise "A config type of hjson was requested, but remiconf_no_hjson was used" %}
{% else %}
include RemiHjson::Serializable
{% end %}
{% else %}
{%
raise "#{@type} included RemiLib::Config::ConfigFile, but the format '#{ann[:format]} is not valid"
%}
{% end %}
{% else %}
{% raise "#{@type} included RemiLib::Config::ConfigFile, but does not have a format in its annotation" %}
{% end %}
{% else %}
{% raise "#{@type} included RemiLib::Config::ConfigFile, but has no annotation" %}
{% end %}
# Returns the path associated with this configuration file. For example,
# if the `Resolver` has a `Resolver#configDir` of `test-program`, and the
# including class is associated with a configuration file named
# "settings.yaml", this will return `<system-specific config
# dir>/test-program/settings.yaml`.
def self.configPath(res : RemiLib::Config::Resolver) : Path
Path[res.configDir, {{@type}}.filename]
end
# Deserializes a new instance of the including class from `io` and returns
# that new instance.
def self.readConfig(io : IO) : {{@type}}
{% if ann[:format] == :json %}
{{@type}}.from_json(io)
{% elsif ann[:format] == :yaml %}
{{@type}}.from_yaml(io)
{% elsif ann[:format] == :hjson %}
{% if flag?(:remiconf_no_hjson) %}
{% raise "A config type of hjson was requested, but remiconf_no_hjson was used" %}
{% else %}
%parser = RemiHjson::Parser.new(io.gets_to_end)
{% end %}
{{@type}}.new(%parser)
{% else %}
{% raise "#{@type} included RemiLib::Config::ConfigFile, but the format '#{ann[:format]} is not valid" %}
{% end %}
end
# Looks for the associated configuration file using the given `Resolver`,
# then deserializes it and returns a new instance of the including class.
# This will raise an exception if the file does not exist (see
# `File.open`).
def self.readConfig(res : RemiLib::Config::Resolver) : {{@type}}
File.open({{@type}}.configPath(res)) do |file|
return {{@type}}.readConfig(file)
end
end
# Looks for the associated configuration file using the given `Resolver`.
# If the file is found, this deserializes it to a new instance of the
# including class, then returns the new instance and `true`. If the file
# was not found, this returns a new instance by calling `#new` and
# `false`.
def self.readConfig?(res : RemiLib::Config::Resolver) : Tuple({{@type}}, Bool)
filename = {{@type}}.configPath(res)
if File.exists?(filename)
File.open(filename) do |file|
return { {{@type}}.readConfig(file), true }
end
else
return { {{@type}}.new, false }
end
end
# Looks for the associated configuration file using the given
# `ResolverSet`. If the file is found, this deserializes it to a new
# instance of the including class, then returns the new instance and
# `true`. If the file was not found, this returns a new instance by
# calling `#new` and `false`.
def self.readConfig?(res : RemiLib::Config::ResolverSet) : Tuple({{@type}}, Bool)
ret = {{@type}}.new
found = false
res.each do |resolv|
ret, found = readConfig?(resolv)
break if found
end
return { ret, found }
end
end
# Serializes this instance to `io` using the associated format.
def write(io : IO)
{% begin %}
{% ann = @type.annotation(::RemiLib::Config::ConfigOpts) %}
{% if ann[:format] == :json %}
self.to_json(io)
{% elsif ann[:format] == :yaml %}
self.to_yaml(io)
{% elsif ann[:format] == :hjson %}
{% if flag?(:remiconf_no_hjson) %}
{% raise "A config type of hjson was requested, but remiconf_no_hjson was used" %}
{% else %}
self.toHjson(io)
{% end %}
{% else %}
{% raise "#{@type} included RemiLib::Config::ConfigFile, but the format '#{ann[:format]} is not valid" %}
{% end %}
{% end %}
end
# Serializes this instance using the associated format, then writes the
# serialized data to `filename`. Any existing file will be overwritten.
def write(filename : Path)
File.open(filename, "w") do |file|
write(file)
end
end
# Serializes this instance using the associated format, then writes the
# serialized data to the associated configuration file. Any existing file
# will be overwritten.
def write(res : Resolver)
{% begin %}
write({{@type}}.configPath(res))
{% end %}
end
end
end
|