Login
resolver.cr at tip
Login

File src/remilib/config/resolver.cr from the latest check-in


     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
   100
   101
   102
   103
   104
   105
   106
   107
   108
   109
   110
   111
   112
   113
   114
   115
   116
   117
   118
   119
   120
   121
   122
   123
   124
   125
   126
   127
   128
   129
   130
   131
   132
   133
   134
   135
   136
   137
   138
   139
   140
   141
   142
   143
   144
   145
   146
   147
   148
   149
   150
   151
   152
   153
   154
   155
   156
   157
   158
   159
   160
   161
   162
   163
   164
   165
   166
   167
   168
   169
   170
   171
   172
   173
   174
   175
   176
   177
   178
   179
   180
   181
   182
   183
   184
   185
   186
   187
   188
   189
   190
   191
   192
   193
   194
   195
   196
   197
   198
   199
   200
   201
   202
   203
   204
   205
   206
   207
   208
   209
   210
   211
   212
   213
   214
   215
#### 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