/* * Permission to use, copy, modify, and/or distribute this software for any * purpose with or without fee is herby granted. * * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF * OR IN CONNECTION WITH THE USE OR PEFORMANCE OF THIS SOFTWARE. */ /** * An incomplete single-file INI parser for D. * * The API should be similar to python's configparse module. Internally it * uses the standard D associative array. * * Example: * --- * import configparser; * * auto config = new ConfigParser(); * // no sections initially * assert(config.sections.length == 0); * // Section names ("Program Settings") are case-sensitive * conf.addSection("Storage Paths"); * // Option names ("CONFIG_PATH") are case-insensitive * // (internally, they are all converted to lower-case) * conf.set("Program Settings", "CONFIG_PATH", "/home/user/.local/config"); * --- * * Authors: nemophila * Date: 2023-03-19 * Homepage: https://osdn.net/users/nemophila/pf/mlib * License: 0BSD * Version: 0.4 * * History: * 0.4 Add .write() * 0.3 Fix option values not always being treated as lowercase. * 0.2 Add .getBool() * 0.1 Initial release */ module mlib.configparser; private { import std.conv : ConvException; import std.stdio : File; } public class DuplicateSectionException : Exception { private string m_section; this(string section) { string msg = "Section " ~ section ~ " already exists."; m_section = section; super(msg); } string section() { return m_section; } } /// /// An exception that is thrown by a strict parser which indicates /// that an option appears twice within any one section. /// public class DuplicateOptionException : Exception { private string m_option; private string m_section; this(string option, string section) { string msg = "Option " ~ option ~ " in section " ~ section ~ " already exists."; m_option = option; m_section = section; super(msg); } string option() { return m_option; } string section() { return m_section; } } public class NoSectionException : Exception { private string m_section; this(string section) { string msg = "Section '" ~ section ~ "' does not exist."; m_section = section; super(msg); } string section() { return m_section; } } public class NoOptionException : Exception { private string m_section; private string m_option; this(string section, string option) { string msg = "Section '" ~ section ~ "' does not have option '" ~ option ~ "'."; m_section = section; m_option = option; super(msg); } string section() { return m_section; } string option() { return m_option; } } /** * The main configuration parser. */ public class ConfigParser { private char[] m_delimiters; private char[] m_commentPrefixes; private bool m_strict; /** current section for parsing */ private string m_currentSection; private string[string][string] m_sections; /** * Creates a new instance of ConfigParser. */ this(char[] delimiters = ['=', ':'], char[] commentPrefixes = ['#', ';'], bool strict = true) { m_delimiters = delimiters; m_commentPrefixes = commentPrefixes; m_strict = strict; } /** * Return an array containing the available sections. */ string[] sections() { return m_sections.keys(); } /// unittest { auto conf = new ConfigParser(); assert(0 == conf.sections().length); conf.addSection("Section"); assert(1 == conf.sections().length); } /** * Add a section named `section` to the instance. * * Throws: * - DuplicateSectionError if a section by the given name already * exists. */ void addSection(string section) { if (section in m_sections) throw new DuplicateSectionException(section); m_sections[section] = null; } /// unittest { import std.exception : assertNotThrown, assertThrown; auto conf = new ConfigParser(); /* doesn't yet exist */ assertNotThrown!DuplicateSectionException(conf.addSection("sample")); /* already exists */ assertThrown!DuplicateSectionException(conf.addSection("sample")); } /** * Indicates whether the named `section` is present in the configuration. * * Params: * section = The section to check for in the configuration. * * Returns: `true` if the section exists, `false` otherwise. */ bool hasSection(string section) { auto exists = (section in m_sections); return (exists !is null); } /// unittest { auto conf = new ConfigParser(); conf.addSection("nExt"); assert(true == conf.hasSection("nExt"), "Close the world."); assert(false == conf.hasSection("world"), "Open the nExt."); } string[] options(string section) { if (false == this.hasSection(section)) throw new NoSectionException(section); return m_sections[section].keys(); } /// unittest { import std.exception : assertNotThrown, assertThrown; auto conf = new ConfigParser(); conf.addSection("Settings"); assertNotThrown!NoSectionException(conf.options("Settings")); assertThrown!NoSectionException(conf.options("void")); string[] options = conf.options("Settings"); assert(0 == options.length, "More keys than we need"); } bool hasOption(string section, string option) { import std.string : toLower; if (false == this.hasSection(section)) return false; scope lowercaseOption = toLower(option); auto exists = (lowercaseOption in m_sections[section]); return (exists !is null); } /* string[] read(string[] filenames) { return null; }*/ void read(string filename) { File file = File(filename, "r"); scope(exit) { file.close(); } read(file, false); } /// unittest { import std.file : remove; import std.stdio : File; auto configFile = File("test.conf", "w+"); configFile.writeln("[Section 1]"); configFile.writeln("key=value"); configFile.writeln("\n[Section 2]"); configFile.writeln("key2 = value"); configFile.close(); auto conf = new ConfigParser(); conf.read("test.conf"); assert(2 == conf.sections.length, "Incorrect Sections length"); assert(true == conf.hasSection("Section 1"), "Config file doesn't have Section 1"); assert(true == conf.hasOption("Section 1", "key"), "Config file doesn't have 'key' in 'Section 1'"); remove("test.conf"); } /** * Parse a config file. * * Params: * file = Reference to the file from which to read. * close = Close the file when finished parsing. */ void read(ref File file, bool close = true) { import std.array : array; import std.algorithm.searching : canFind; import std.string : strip; scope(exit) { if (close) file.close(); } string[] lines = file.byLineCopy.array; for (auto i = 0; i < lines.length; i++) { string line = lines[i].strip(); if (line == "") continue; if ('[' == lines[i][0]) { parseSectionHeader(lines[i]); } else if (false == canFind(m_commentPrefixes, lines[i][0])) { parseLine(lines[i]); } /* ignore comments */ } } /*void readString(string str) { }*/ /** * Get an `option` value for the named `section`. * * Params: * section = The section to look for the given `option`. * option = The option to return the value of * fallback = Fallback value if the `option` is not found. Can be null. * * Returns: * - The value for `option` if it is found. * - `null` if the `option` is not found and `fallback` is not provided. * - `fallback` if the `option` is not found and `fallback` is provided. * * Throws: * - NoSectionException if the `section` does not exist and no fallback is provided. * - NoOptionException if the `option` does not exist and no fallback is provided. */ string get(string section, string option) { import std.string : toLower; scope lowercaseOption = toLower(option); if (false == this.hasSection(section)) throw new NoSectionException(section); if (false == this.hasOption(section, lowercaseOption)) throw new NoOptionException(section, lowercaseOption); return m_sections[section][lowercaseOption]; } /// unittest { import std.exception : assertThrown; auto conf = new ConfigParser(); conf.addSection("Section"); conf.set("Section", "option", "value"); assert(conf.get("Section", "option") == "value"); assertThrown!NoSectionException(conf.get("section", "option")); assertThrown!NoOptionException(conf.get("Section", "void")); } /// Ditto string get(string section, string option, string fallback) { string res = fallback; try { res = get(section, option); } catch (NoSectionException e) { return res; } catch (NoOptionException e) { return res; } return res; } /// unittest { import std.exception : assertThrown; auto conf = new ConfigParser(); conf.addSection("Section"); conf.set("Section", "option", "value"); assert("value" == conf.get("Section", "option")); assert("fallback" == conf.get("section", "option", "fallback")); assert("fallback" == conf.get("Section", "void", "fallback")); /* can use null for fallback */ assert(null == conf.get("section", "option", null)); assert(null == conf.get("Section", "void", null)); } /** * A convenience method which casts the value of `option` in `section` * to an integer. * * Params: * section = The section to look for the given `option`. * option = The option to return the value for. * fallback = The fallback value to use if `option` isn't found. * * Returns: * * * Throws: * - NoSectionFoundException if `section` doesn't exist. * - NoOptionFoundException if the `section` doesn't contain `option`. * - ConvException if it failed to parse the value to an int. * - ConvOverflowException if the value would overflow an int. * * See_Also: get() */ int getInt(string section, string option) { import std.conv : parse; string res; res = get(section, option); return parse!int(res); } /// Ditto int getInt(string section, string option, int fallback) { int res = fallback; try { res = getInt(section, option); } catch (Exception e) { return res; } return res; } /* double getDouble(string section, string option) { } double getDouble(string section, string option, double fallback) { } float getFloat(string section, string option) { } float getFloat(string section, string option, float fallback) { }*/ /** * A convenience method which coerces the $(I option) in the * specified $(I section) to a boolean value. * * Note that the accepted values for the option are "1", "yes", * "true", and "on", which cause this method to return `true`, and * "0", "no", "false", and "off", which cause it to return `false`. * * These string values are checked in a case-insensitive manner. * * Params: * section = The section to look for the given option. * option = The option to return the value for. * fallback = The fallback value to use if the option was not found. * * Throws: * - NoSectionFoundException if `section` doesn't exist. * - NoOptionFoundException if the `section` doesn't contain `option`. * - ConvException if any other value was found. */ bool getBool(string section, string option) { import std.string : toLower; string value = get(section, option); switch (value.toLower) { case "1": case "yes": case "true": case "on": return true; case "0": case "no": case "false": case "off": return false; default: throw new ConvException("No valid boolean value found"); } } /// Ditto bool getBool(string section, string option, bool fallback) { try { return getBool(section, option); } catch (Exception e) { return fallback; } } /* string[string] items(string section) { }*/ /** * Remove the specified `option` from the specified `section`. * * Params: * section = The section to remove from. * option = The option to remove from section. * * Retruns: * `true` if option existed, false otherwise. * * Throws: * - NoSectionException if the specified section doesn't exist. */ bool removeOption(string section, string option) { if ((section in m_sections) is null) { throw new NoSectionException(section); } if (option in m_sections[section]) { m_sections[section].remove(option); return true; } return false; } /// unittest { import std.exception : assertThrown; auto conf = new ConfigParser(); conf.addSection("Default"); conf.set("Default", "exists", "true"); assertThrown!NoSectionException(conf.removeOption("void", "false")); assert(false == conf.removeOption("Default", "void")); assert(true == conf.removeOption("Default", "exists")); } /** * Remove the specified `section` from the config. * * Params: * section = The section to remove. * * Returns: * `true` if the section existed, `false` otherwise. */ bool removeSection(string section) { if (section in m_sections) { m_sections.remove(section); return true; } return false; } /// unittest { auto conf = new ConfigParser(); conf.addSection("Exists"); assert(false == conf.removeSection("DoesNotExist")); assert(true == conf.removeSection("Exists")); } void set(string section, string option, string value) { import std.string : toLower; if (false == this.hasSection(section)) throw new NoSectionException(section); scope lowercaseOption = toLower(option); m_sections[section][lowercaseOption] = value; } /// unittest { import std.exception : assertThrown; auto conf = new ConfigParser(); assertThrown!NoSectionException(conf.set("Section", "option", "value")); conf.addSection("Section"); conf.set("Section", "option", "value"); assert(conf.get("Section", "option") == "value"); } /// /// Write a representation of the configuration to the /// provided *file*. /// /// This representation can be parsed by future calls to /// `read`. This does **not** close the file after writing. /// /// Params: /// file = An open file which was opened in text mode. /// spaceAroundDelimiters = The delimiters between keys and /// values are surrounded by spaces. /// /// Note: Comments from the original file are not preserved when /// writing the configuration back. /// void write(ref File file, bool spaceAroundDelimiters = true) { string del = spaceAroundDelimiters ? " = " : "="; foreach(string section, string[string] options; m_sections) { file.writefln("[%s]", section); foreach(string option, string value; options) { file.writefln("%s%s%s", option, del, value); } } } /// unittest { import std.file : remove; import std.stdio : File; auto writer = new ConfigParser(); writer.addSection("general"); writer.addSection("GUI"); writer.set("GUI", "WINDOW_WIDTH", "848"); writer.set("GUI", "WINDOW_HEIGHT", "480"); auto file = File("test.ini", "w+"); scope(exit) remove(file.name); writer.write(file); file.rewind(); auto reader = new ConfigParser(); reader.read(file); assert(reader.hasSection("general"), "reader does not contain general section"); assert(reader.hasSection("GUI"), "reader does not contain GUI section"); assert(reader.get("GUI", "WINDOW_WIDTH") == "848", "reader GUI.WINDOW_WIDTH is not 848"); assert(reader.getInt("GUI", "WINDOW_WIDTH") == 848, "reader GUI.WINDOW_WIDTH is not 848 (int)"); assert(reader.get("GUI", "WINDOW_HEIGHT") == "480", "reader GUI.WINDOW_HEIGHT is not 480"); assert(reader.getInt("GUI", "WINDOW_HEIGHT") == 480, "reader GUI.WINDOW_HEIGHT is not 480 (int)"); } private: void parseSectionHeader(ref string line) { import std.array : appender, assocArray; auto sectionHeader = appender!string; /* presume that the last character is ] */ sectionHeader.reserve(line.length - 1); string popped = line[1 .. $]; foreach(c; popped) { if (c != ']') sectionHeader.put(c); else break; } version (DigitalMars) { m_currentSection = sectionHeader[]; } else { /* LDC / GNU */ m_currentSection = sectionHeader.data; } if (m_currentSection in m_sections && m_strict) throw new DuplicateSectionException(m_currentSection); try { this.addSection(m_currentSection); } catch (DuplicateSectionException) { } } void parseLine(ref string line) { import std.string : indexOfAny, toLower, strip; ptrdiff_t idx = line.indexOfAny(m_delimiters); if (-1 == idx) return; string option = line[0 .. idx].dup.strip.toLower; string value = line[idx + 1 .. $].dup.strip; if (option in m_sections[m_currentSection] && m_strict) throw new DuplicateOptionException(option, m_currentSection); m_sections[m_currentSection][option] = value; } unittest { import std.exception : assertThrown, assertNotThrown; import std.file : remove; auto f = File("config.cfg", "w+"); f.writeln("[section]"); f.writeln("option = value"); f.writeln("Option = value"); f.close(); scope(exit) remove("config.cfg"); // Duplicate option scope parser = new ConfigParser(); assertThrown!DuplicateOptionException(parser.read("config.cfg")); // Duplicate section f = File("config.cfg", "w+"); f.writeln("[section]"); f.writeln("option = value"); f.writeln("[section]"); f.close(); assertThrown!DuplicateSectionException(parser.read("config.cfg")); // not strict scope relaxedParser = new ConfigParser(['='], [], false); assertNotThrown!DuplicateSectionException(relaxedParser.read("config.cfg")); assert(relaxedParser.hasSection("section")); f = File("config.cfg", "a+"); f.writeln("option = newValue"); f.close(); assertNotThrown!DuplicateOptionException(relaxedParser.read("config.cfg")); assert(relaxedParser.get("section", "option") == "newValue"); } }