diff options
-rw-r--r-- | .gitignore | 9 | ||||
-rw-r--r-- | LICENSE | 21 | ||||
-rw-r--r-- | README.md | 110 | ||||
-rw-r--r-- | shard.yml | 9 | ||||
-rw-r--r-- | spec/spec_helper.cr | 2 | ||||
-rw-r--r-- | spec/xdg_basedir_spec.cr | 50 | ||||
-rw-r--r-- | src/xdg_basedir.cr | 299 |
7 files changed, 500 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e29dae7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/docs/ +/lib/ +/bin/ +/.shards/ +*.dwarf + +# Libraries don't need dependency lock +# Dependencies will be locked in application that uses them +/shard.lock @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2018 katherine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a72a85b --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +xdg_basedir +=========== + +This is a simple crystal interface to the XDG Base Directories. It is based on +the XDG Base Directory Specification, the latest version of which (0.7) can be +found [here](https://specifications.freedesktop.org/basedir-spec/0.7/). + +The XDG Base Directories Specification is a definition of the directories where +things like a program's configuration files and stored data ought to be written +to and read from, along with the order of precedence to be used when searching +for those kinds of files. If you've ever seen a program that stores its +configurations in the `.config` directory, that program is, at least in part, +following this specification. + +This crystal interface to the specification provides methods for simply listing +the base directories, as well as a helper method for easily building file paths +relative to them. + + +installation +------------ + +First, add the dependency to your project's `shard.yml` file: + +```yaml +dependencies: + xdg_basedir: + github: shmibs/xdg_basedir +``` + +and then run `shards install`. + + +usage +----- + +Suppose you're writing a program called `program_name`, and you want to read +one of its configuration files, `file_name.conf`. After reading, you want to +perform some operation on the contents of the file, and then write the new +contents back to `file_name.conf`. Using this module, that might look something +like the following: + +```crystal +require "xdg_basedir" + +# Note: for simplicity's sake, exception handling has been ignored for the +# calls to File.read and File.write + +# typically, files in the XDG Base Directories will be first sorted into +# directories, based on the program that uses them. This isn't always the case +# however, and so it is not enforced +read_path = XDGBasedir.full_path("program_name/file_name.conf", :config, :read) + +# the specification dictates that base directory locations be determined using +# both the state of the filesystem and the state of certain environment +# variables. it's thus possible that an appropriate base directory won't be +# found, so a nil check is required +if read_path + contents = File.read(read_path) + + # ...do something with the contents here... + + # write_path here is not necessarily the same as read_path above. full_path + # above will check through the hierarchy of fallback base directories and, if + # it finds the target, return the path into the directory where it was found. + # there is only one base directory for writing, however, and so it is always + # returned here. this means that the first time program_name is run, it might + # read in some system-wide config, write back a user-specific config, and + # then read the user-specific version thereafter + write_path = XDGBasedir.full_path("program_name/file_name.conf", :config, + :write) + + # again, nil check necessary... + if write_path + File.write(write_path, contents) + end +end +``` + +The `full_path` method takes an argument *type*, which was set above to +`:config`. This argument indicates the type of files that are stored in the +base directory that should be selected. There are four possible types: + +- `: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) + +Every method defined under `XDGBasedir` takes one of these types as an +argument. + +In addition to `full_path`, two lower-level methods are also provided: + +- `write_dir`, which returns the single directory where files of a given type + should be written +- `read_dirs`, which returns a hierarchical list of base directories from which + files of a given type should be read + +However, these two methods will probably be less useful. + + +license +------- + +This library is licensed under [The MIT +License](https://opensource.org/licenses/MIT). diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..8146c4c --- /dev/null +++ b/shard.yml @@ -0,0 +1,9 @@ +name: xdg_basedir +version: 1.0.0 + +authors: + - katherine <shmibs@shmibbles.me> + +crystal: 0.27.0 + +license: MIT diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..38f3f4a --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,2 @@ +require "spec" +require "../src/xdg_basedir" diff --git a/spec/xdg_basedir_spec.cr b/spec/xdg_basedir_spec.cr new file mode 100644 index 0000000..1895439 --- /dev/null +++ b/spec/xdg_basedir_spec.cr @@ -0,0 +1,50 @@ +require "./spec_helper" + +# NOTE: these tests are non-exhaustive and may fail, as they depend on the +# state of the filesystem and environment +describe XDGBasedir do + + it "finds previously written files using full_path" do + p = XDGBasedir.full_path "xdg_basedir_test_file_1", :data, "w" + unless p + next + end + File.write p, "content" + + p = XDGBasedir.full_path "xdg_basedir_test_file_1", :data, "r" + unless p + next + end + + s = File.read p + f = File.new p + f.delete + + s.should eq("content") + end + + it "finds previously written files using dir methods" do + d = XDGBasedir.write_dir :config + unless d + next + end + File.write "#{d}xdg_basedir_test_file_2" , "content" + + l = XDGBasedir.read_dirs :config + unless l + next + end + + s = File.read "#{l[0]}xdg_basedir_test_file_2" + f = File.new "#{d}xdg_basedir_test_file_2" + f.delete + + s.should eq("content") + end + + it "fails on bad arguments" do + expect_raises(ArgumentError) { XDGBasedir.write_dir :bad } + expect_raises(ArgumentError) { XDGBasedir.read_dirs :bad } + expect_raises(ArgumentError) { XDGBasedir.full_path "rel_path", :bad } + end +end 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 |