aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/xdg_basedir.cr299
1 files changed, 299 insertions, 0 deletions
diff --git a/src/xdg_basedir.cr b/src/xdg_basedir.cr
new file mode 100644
index 0000000..b406841
--- /dev/null
+++ b/src/xdg_basedir.cr
@@ -0,0 +1,299 @@
+# This module implements an interface to the XDG Base Directories, compliant
+# with version 0.7 of the XDG Base Directory Specification.
+#
+# Provided are methods for locating XDG Base Directories at runtime, as well as
+# a helper method for easily building full file paths relative to them.
+#
+# Each of the operations below can be performed on one of four categories of
+# directories, designated via an argument *type* of value `:data`, `:config`,
+# `:cache`, or `:runtime`.
+#
+# - `:data` directories are used for storing and retrieving persistent files
+# across multiple runs of a program.
+# - `:config` directories are used for storing and retrieving a program's
+# configuration files.
+# - `:cache` directories are used for storing non-essential data which may or
+# may not be retained
+# - `:runtime` directories are used for storing runtime files (e.g. lock files
+# or sockets)
+#
+# For more details, please refer to the specification, which is available
+# online [here](https://specifications.freedesktop.org/basedir-spec/0.7/).
+module XDGBasedir
+ VERSION = "1.0.0"
+
+ # get the base directory into which files of a given *type* should be written
+ #
+ # Given a *type* of `:data`, `:config`, `:cache`, or `:runtime`, this method
+ # returns a single directory into which data of that type should be written,
+ # or else `nil`, if no appropriate candidate is found.
+ #
+ # If a directory is returned, it is guaranteed to always have a terminating
+ # '/' character, meaning that, when trying to write a file, it is safe to
+ # directly concatenate the directory and the file path that is to be written
+ # to within it.
+ #
+ # ### Example
+ #
+ # dir = XDGBasedir.write_dir :config
+ # if dir
+ # File.write "#{dir}created/file.conf", "contents"
+ # end
+ #
+ def self.write_dir(type = :config) : String?
+ case type
+ when :data
+ if ENV["XDG_DATA_HOME"]? && ENV["XDG_DATA_HOME"] != ""
+ s = "#{ENV["XDG_DATA_HOME"]}/"
+ else
+ if ENV["HOME"]?
+ s = "#{ENV["HOME"]}/.local/share/"
+ else
+ return nil
+ end
+ end
+
+ when :config
+ if ENV["XDG_CONFIG_HOME"]? && ENV["XDG_CONFIG_HOME"] != ""
+ s = "#{ENV["XDG_CONFIG_HOME"]}/"
+ else
+ if ENV["HOME"]?
+ s = "#{ENV["HOME"]}/.config/"
+ else
+ return nil
+ end
+ end
+
+ when :cache
+ if ENV["XDG_CACHE_HOME"]? && ENV["XDG_CACHE_HOME"] != ""
+ s = "#{ENV["XDG_CACHE_HOME"]}/"
+ else
+ if ENV["HOME"]?
+ s = "#{ENV["HOME"]}/.cache/"
+ else
+ return nil
+ end
+ end
+
+ when :runtime
+ if ENV["XDG_RUNTIME_DIR"]? && ENV["XDG_RUNTIME_DIR"] != ""
+ s = "#{ENV["XDG_RUNTIME_DIR"]}/"
+ else
+ # runtime dir has no fallback
+ return nil
+ end
+
+ # runtime dir must necessarily have certain permissions
+ unless File.directory?(s) && File.info(s).permissions.value == 0o0700
+ return nil
+ end
+
+ else
+ raise ArgumentError.new(
+ "type must be one of: :data, :config, :cache, :runtime"
+ )
+ end
+
+ # return result with slashes deduplicated. nil check required because the
+ # compiler can't work out that s will have a value here...
+ s ? s.gsub(/\/+/, "/") : nil
+
+ end
+
+ # get a list of base directories from which files of a given *type* can be
+ # read
+ #
+ # Given a type of `:data`, `:config`, `:cache`, or `:runtime`, this method
+ # returns a string array of directories from which data of that type should
+ # be read. If no appropriate candidates are found, it instead returns `nil`.
+ #
+ # The returned list will be ordered according to precedence. That is, given a
+ # returned list `l`, accessing a file should first be attempted from within
+ # `l[0]`, if that fails, be attempted from within `l[1]`, and so on.
+ #
+ # If a directory list is returned, those directories are guaranteed to always
+ # have a terminating '/' character, meaning that, when trying to access a
+ # file, it is safe to directly concatenate the directory and the file path
+ # that is to be accessed within it.
+ #
+ # ### Example
+ #
+ # contents = nil
+ # dir_list = XDGBasedir.read_dirs :config
+ #
+ # if dir_list
+ # dir_list.each { |dir|
+ # if File.file? "#{dir}target/file.conf"
+ # contents = File.read "#{dir}target/file.conf"
+ # break
+ # end
+ # }
+ # end
+ #
+ def self.read_dirs(type = :config) : Array(String)?
+ case type
+ when :data
+ # first entry is the write dir, if it exists
+ if s = self.write_dir(:data)
+ l = [s]
+ else
+ l = [] of String
+ end
+
+ if ENV["XDG_DATA_DIRS"]? && !/^:*$/.match(ENV["XDG_DATA_DIRS"])
+ ENV["XDG_DATA_DIRS"].split(":").reject{|s| s == ""}.each{|s| l << s}
+ else
+ l << "/usr/local/share/"
+ l << "/usr/share/"
+ end
+
+ when :config
+ # first entry is the write dir, if it exists
+ if s = self.write_dir(:config)
+ l = [s]
+ else
+ l = [] of String
+ end
+
+ if ENV["XDG_CONFIG_DIRS"]? && !/^:*$/.match(ENV["XDG_CONFIG_DIRS"])
+ ENV["XDG_CONFIG_DIRS"].split(":").reject{|d| d == ""}.each{|s| l << s}
+ else
+ l << "/etc/xdg/"
+ end
+
+ when :cache
+ if ENV["XDG_CACHE_HOME"]? && ENV["XDG_CACHE_HOME"] != ""
+ l = ["#{ENV["XDG_CACHE_HOME"]}/"]
+ else
+ if ENV["HOME"]?
+ l = ["#{ENV["HOME"]}/.cache/"]
+ else
+ return nil
+ end
+ end
+
+ when :runtime
+ if ENV["XDG_RUNTIME_DIR"]? && ENV["XDG_RUNTIME_DIR"] != ""
+ s = "#{ENV["XDG_RUNTIME_DIR"]}/"
+ else
+ # runtime dir has no fallback
+ return nil
+ end
+
+ # runtime dir must necessarily have certain permissions
+ unless File.directory?(s) && File.info(s).permissions.value == 0o0700
+ return nil
+ else
+ l = [s]
+ end
+
+ else
+ raise ArgumentError.new(
+ "type must be one of: :data, :config, :cache, :runtime"
+ )
+ end
+
+ # return result with slashes deduplicated and a trailing slash present.
+ l ? l.map { |s| s.gsub(/\/+/, "/").sub(/[^\/]$/, "\\0/") } : nil
+
+ end
+
+ # for a given *relative_path*, get a full file path built against an
+ # appropriate base directory
+ #
+ # This method takes a *relative_path*, the *type* of that path, and the
+ # *action* you intend to perform on it, and returns a full path, constructed
+ # by concatenating the *relative_path* to the most appropriate base
+ # directory. If an appropriate directory cannot be found, `nil` is returned.
+ #
+ # The *type* argument indicates what sort of file you intend to access at the
+ # full path (`:config`, `:data` ...), and the *action* argument indicates
+ # what you intend to to do with it (either `:read` or `:write`). Note that
+ # `:write` takes precedence over `:read`, so that if you intend to both read
+ # and write, choose `:write`
+ #
+ # ### Example
+ #
+ # data = nil
+ # path = XDGBasedir.full_path "relative/path.dat", :data, :read
+ #
+ # if path
+ # data = File.read path
+ # end
+ #
+ def self.full_path(relative_path, type = :config,
+ action : Symbol = :read) : String?
+ unless action == :read || action == :write
+ raise ArgumentError.new(
+ "action must be one of: :read, :write"
+ )
+ end
+
+ case type
+ when :data, :config, :cache, :runtime
+ if action == :read
+ unless l = self.read_dirs(type)
+ return nil
+ end
+
+ # search the read dirs until relative_path is found
+ l.each { |d|
+ if File.exists?(d + relative_path)
+ return d + relative_path
+ end
+ }
+
+ # or else just return against the first read dir
+ l[0] + relative_path
+ else
+ d = self.write_dir(type)
+ d ? d + relative_path : nil
+ end
+ else
+ raise ArgumentError.new(
+ "type must be one of: :data, :config, :cache, :runtime"
+ )
+ end
+ end
+
+ # for a given *relative_path*, get a full file path built against an
+ # appropriate base directory
+ #
+ # This method takes a *relative_path*, the *type* of that path, and the
+ # access *mode* with which that file is to be opened, and returns a full
+ # path, constructed by concatenating the *relative_path* to the most
+ # appropriate base directory. If an appropriate directory cannot be found,
+ # `nil` is returned.
+ #
+ # The *type* indicates what sort of file you intend to access at the full
+ # path (`:config`, `:data` ...), and the *mode* argument is a string, in the
+ # common format used by both C's `fopen` and Crystal's `File.open`. For more
+ # information, refer to `man fopen` or the Crystal standard library
+ # documentation for [File](https://crystal-lang.org/api/latest/File.html).
+ #
+ # This overloaded version of `full_path` is added for convenience, as, if you
+ # intend to call `File.open` on the produced full path, it might be easier to
+ # use the same *mode* argument for both methods.
+ #
+ # ### Example
+ #
+ # data = nil
+ # mode = "r"
+ # path = XDGBasedir.full_path "relative/path.dat", :data, mode
+ #
+ # if path
+ # data = File.open(path, mode) do |file|
+ # file.gets_to_end
+ # end
+ # end
+ #
+ def self.full_path(relative_path, type = :config,
+ mode : String = "r") : String?
+ if /^r[^+]*$/.match(mode)
+ self.full_path(relative_path, type, :read)
+ else
+ self.full_path(relative_path, type, :write)
+ end
+ end
+
+end