Login
Artifact [311e3ebc4c]
Login

Artifact 311e3ebc4c02d74718a4729adf16cd83530933d4a296e2924a451940bc7d45c4:


#### 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