ini: Rewrite lib so it follows the INI spec
authorAlexandre Terrasa <alexandre@moz-code.org>
Tue, 18 Jun 2019 23:13:01 +0000 (19:13 -0400)
committerAlexandre Terrasa <alexandre@moz-code.org>
Tue, 18 Jun 2019 23:13:25 +0000 (19:13 -0400)
Signed-off-by: Alexandre Terrasa <alexandre@moz-code.org>

lib/ini/ini.nit

index 815995f..e965323 100644 (file)
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-# Handle ini config files.
+# Read and write INI configuration files
 module ini
 
-# A configuration tree that can read and store data in ini format
+import core
+intrude import core::collection::hash_collection
+
+# Read and write INI configuration files
+#
+# In an INI file, properties (or keys) are associated to values thanks to the
+# equals symbol (`=`).
+# Properties may be grouped into section marked between brackets (`[` and `]`).
+#
+# ~~~
+# var ini_string = """
+# ; Example INI
+# key=value1
+# [section1]
+# key=value2
+# [section2]
+# key=value3
+# """
+# ~~~
+#
+# The main class, `IniFile`, can be created from an INI string and allows easy
+# access to its content.
+#
+# ~~~
+# # Read INI from string
+# var ini = new IniFile.from_string(ini_string)
+#
+# # Check keys presence
+# assert ini.has_key("key")
+# assert ini.has_key("section1.key")
+# assert not ini.has_key("not.found")
+#
+# # Access values
+# assert ini["key"] == "value1"
+# assert ini["section2.key"] == "value3"
+# assert ini["not.found"] == null
+#
+# # Access sections
+# assert ini.sections.length == 2
+# assert ini.section("section1")["key"] == "value2"
+# ~~~
 #
-# Write example:
+# `IniFile` can also be used to create new INI files from scratch, or edit
+# existing ones through its API.
 #
-#     var config = new ConfigTree("config.ini")
-#     config["goo"] = "goo"
-#     config["foo.bar"] = "foobar"
-#     config["foo.baz"] = "foobaz"
-#     config.save
-#     assert config.to_map.length == 3
+# ~~~
+# # Create a new INI file and write it to disk
+# ini = new IniFile
+# ini["key"] = "value1"
+# ini["section1.key"] = "value2"
+# ini["section2.key"] = "value3"
+# ini.write_to_file("my_config.ini")
 #
-# Read example:
+# # Load the INI file from disk
+# ini = new IniFile.from_file("my_config.ini")
+# assert ini["key"] == "value1"
+# assert ini["section1.key"] == "value2"
+# assert ini["section2.key"] == "value3"
 #
-#     config = new ConfigTree("config.ini")
-#     assert config.has_key("foo.bar")
-#     assert config["foo.bar"] == "foobar"
-class ConfigTree
+# "my_config.ini".to_path.delete
+# ~~~
+class IniFile
        super Writable
+       super HashMap[String, nullable String]
 
-       # The ini file used to read/store data
-       var ini_file: String
-
-       init do if ini_file.file_exists then load
+       # Create a IniFile from a `string` content
+       #
+       # ~~~
+       # var ini = new IniFile.from_string("""
+       # key1=value1
+       # [section1]
+       # key2=value2
+       # """)
+       # assert ini["key1"] == "value1"
+       # assert ini["section1.key2"] == "value2"
+       # ~~~
+       #
+       # See also `stop_on_first_error` and `errors`.
+       init from_string(string: String, stop_on_first_error: nullable Bool) do
+               init stop_on_first_error or else false
+               load_string(string)
+       end
 
-       # Get the config value for `key`
-       #
-       #     var config = new ConfigTree("config.ini")
-       #     assert config["goo"] == "goo"
-       #     assert config["foo.bar"] == "foobar"
-       #     assert config["foo.baz"] == "foobaz"
-       #     assert config["fail.fail"] == null
-       fun [](key: String): nullable String do
-               var node = get_node(key)
-               if node == null then return null
-               return node.value
+       # Create a IniFile from a `file` content
+       #
+       # ~~~
+       # """
+       # key1=value1
+       # [section1]
+       # key2=value2
+       # """.write_to_file("my_config.ini")
+       #
+       # var ini = new IniFile.from_file("my_config.ini")
+       # assert ini["key1"] == "value1"
+       # assert ini["section1.key2"] == "value2"
+       #
+       # "my_config.ini".to_path.delete
+       # ~~~
+       #
+       # See also `stop_on_first_error` and `errors`.
+       init from_file(file: String, stop_on_first_error: nullable Bool) do
+               init stop_on_first_error or else false
+               load_file(file)
        end
 
-       # Get the config values under `key`
+       # Sections composing this IniFile
        #
-       #     var config = new ConfigTree("config.ini")
-       #     var values = config.at("foo")
-       #     assert values.has_key("bar")
-       #     assert values.has_key("baz")
-       #     assert not values.has_key("goo")
+       # ~~~
+       # var ini = new IniFile.from_string("""
+       # [section1]
+       # key1=value1
+       # [ section 2 ]
+       # key2=value2
+       # """)
+       # assert ini.sections.length == 2
+       # assert ini.sections.first.name == "section1"
+       # assert ini.sections.last.name == "section 2"
+       # ~~~
+       var sections = new Array[IniSection]
+
+       # Get a section by its `name`
        #
-       # Return null if the key does not exists.
+       # Returns `null` if the section is not found.
        #
-       #     assert config.at("fail.fail") == null
-       fun at(key: String): nullable Map[String, String] do
-               var node = get_node(key)
-               if node == null then return null
-               var map = new HashMap[String, String]
-               for k, child in node.children do
-                       var value = child.value
-                       if value == null then continue
-                       map[k] = value
+       # ~~~
+       # var ini = new IniFile.from_string("""
+       # [section1]
+       # key1=value1
+       # [section2]
+       # key2=value2
+       # """)
+       # assert ini.section("section1") isa IniSection
+       # assert ini.section("section2").name == "section2"
+       # assert ini.section("not.found") == null
+       # ~~~
+       fun section(name: String): nullable IniSection do
+               for section in sections do
+                       if section.name == name then return section
                end
-               return map
+               return null
        end
 
-       # Set `value` at `key`
-       #
-       #     var config = new ConfigTree("config.ini")
-       #     assert config["foo.bar"] == "foobar"
-       #     config["foo.bar"] = "baz"
-       #     assert config["foo.bar"] == "baz"
-       fun []=(key: String, value: nullable String) do
-               set_node(key, value)
+       # Does this file contains no properties and no sections?
+       #
+       # ~~~
+       # var ini = new IniFile.from_string("")
+       # assert ini.is_empty
+       #
+       # ini = new IniFile.from_string("""
+       # key=value
+       # """)
+       # assert not ini.is_empty
+       #
+       # ini = new IniFile.from_string("""
+       # [section]
+       # """)
+       # assert not ini.is_empty
+       # ~~~
+       redef fun is_empty do return super and sections.is_empty
+
+       # Is there a property located at `key`?
+       #
+       # Returns `true` if the `key` is not found of if its associated value is `null`.
+       #
+       # ~~~
+       # var ini = new IniFile.from_string("""
+       # key=value1
+       # [section1]
+       # key=value2
+       # [section2]
+       # key=value3
+       # """)
+       # assert ini.has_key("key")
+       # assert ini.has_key("section1.key")
+       # assert ini.has_key("section2.key")
+       # assert not ini.has_key("section1")
+       # assert not ini.has_key("not.found")
+       # ~~~
+       redef fun has_key(key) do return self[key] != null
+
+       # Get the value associated with a property (`key`)
+       #
+       # Returns `null` if the key is not found.
+       # Section properties can be accessed with the `.` notation.
+       #
+       # ~~~
+       # var ini = new IniFile.from_string("""
+       # key=value1
+       # [section1]
+       # key=value2
+       # [section2]
+       # key=value3
+       # """)
+       # assert ini["key"] == "value1"
+       # assert ini["section1.key"] == "value2"
+       # assert ini["section2.key"] == "value3"
+       # assert ini["section1"] == null
+       # assert ini["not.found"] == null
+       # ~~~
+       redef fun [](key) do
+               if key == null then return null
+               key = key.to_s.trim
+
+               # Look in root
+               var node = node_at(key)
+               if node != null then return node.value
+
+               # Look in sections
+               for section in sections do
+                       # Matched if the section name is a prefix of the key
+                       if not key.has_prefix(section.name) then continue
+                       var skey = key.substring(section.name.length + 1, key.length)
+                       if section.has_key(skey) then return section[skey]
+               end
+               return null
        end
 
-       # Is `key` in the config?
-       #
-       #     var config = new ConfigTree("config.ini")
-       #     assert config.has_key("goo")
-       #     assert config.has_key("foo.bar")
-       #     assert not config.has_key("zoo")
-       fun has_key(key: String): Bool do
-               var parts = key.split(".").reversed
-               var node = get_root(parts.pop)
-               if node == null then return false
-               while not parts.is_empty do
-                       node = node.get_child(parts.pop)
-                       if node == null then return false
+       # Set the `value` for the property locaated at `key`
+       #
+       # ~~~
+       # var ini = new IniFile
+       # ini["key"] = "value1"
+       # ini["section1.key"] = "value2"
+       # ini["section2.key"] = "value3"
+       #
+       # assert ini["key"] == "value1"
+       # assert ini["section1.key"] == "value2"
+       # assert ini["section2.key"] == "value3"
+       # assert ini.section("section1").name == "section1"
+       # assert ini.section("section2")["key"] == "value3"
+       # ~~~
+       redef fun []=(key, value) do
+               if value == null then return
+               var parts = key.split_once_on(".")
+
+               # No dot notation, store value in root
+               if parts.length == 1 then
+                       super(key.trim, value.trim)
+                       return
+               end
+
+               # First part matches a section, store value in it
+               var section = self.section(parts.first.trim)
+               if section != null then
+                       section[parts.last.trim] = value.trim
+                       return
                end
-               return true
+
+               # No section matched, create a new one and store value in it
+               section = new IniSection(parts.first.trim)
+               section[parts.last.trim] = value.trim
+               sections.add section
        end
 
-       # Get `self` as a Map of `key`, `value`
-       #
-       #     var config = new ConfigTree("config.ini")
-       #     var map = config.to_map
-       #     assert map.has_key("goo")
-       #     assert map.has_key("foo.bar")
-       #     assert map.has_key("foo.baz")
-       #     assert map.length == 3
-       fun to_map: Map[String, String] do
+       # Flatten `self` and its subsection in a `Map` of keys => values
+       #
+       # Properties from section are prefixed with their section names with the
+       # dot (`.`) notation.
+       #
+       # ~~~
+       # var ini = new IniFile.from_string("""
+       # key=value1
+       # [section]
+       # key=value2
+       # """)
+       # assert ini.flatten.join(", ", ": ") == "key: value1, section.key: value2"
+       # ~~~
+       fun flatten: Map[String, String] do
                var map = new HashMap[String, String]
-               for node in leaves do
-                       var value = node.value
+               for key, value in self do
                        if value == null then continue
-                       map[node.key] = value
+                       map[key] = value
+               end
+               for section in sections do
+                       for key, value in section do
+                               if value == null then continue
+                               map["{section.name}.{key}"] = value
+                       end
                end
                return map
        end
 
-       redef fun to_s do return to_map.join(", ", ":")
-
-       # Write `self` in `stream`
-       #
-       #     var config = new ConfigTree("config.ini")
-       #     var out = new StringWriter
-       #     config.write_to(out)
-       #     assert out.to_s == """
-       #     goo=goo
-       #     [foo]
-       #     bar=foobar
-       #     baz=foobaz
-       #     """
+       # Write `self` to a `stream`
+       #
+       # Key with `null` values are ignored.
+       # The empty string can be used to represent an empty value.
+       #
+       # ~~~
+       # var ini = new IniFile
+       # ini["key"] = "value1"
+       # ini["key2"] = null
+       # ini["key3"] = ""
+       # ini["section1.key"] = "value2"
+       # ini["section1.key2"] = null
+       # ini["section2.key"] = "value3"
+       #
+       # var stream = new StringWriter
+       # ini.write_to(stream)
+       #
+       # assert stream.to_s == """
+       # key=value1
+       # key3=
+       # [section1]
+       # key=value2
+       # [section2]
+       # key=value3
+       # """
+       # ~~~
        redef fun write_to(stream) do
-               var todo = new Array[ConfigNode].from(roots.reversed)
-               while not todo.is_empty do
-                       var node = todo.pop
-                       if node.children.not_empty then
-                               todo.add_all node.children.values.to_a.reversed
-                       end
-                       if node.children.not_empty and node.parent == null then
-                               stream.write("[{node.name}]\n")
-                       end
-                       var value = node.value
+               for key, value in self do
                        if value == null then continue
-                       var path = node.path
-                       if path.length > 1 then path.shift
-                       stream.write("{path.join(".")}={value}\n")
+                       stream.write "{key}={value}\n"
+               end
+               for section in sections do
+                       stream.write "[{section.name}]\n"
+                       for key, value in section do
+                               if value == null then continue
+                               stream.write "{key}={value}\n"
+                       end
                end
        end
 
-       # Reload config from file
-       # Done automatically at init
-       #
-       # Example with hierarchical ini file:
-       #
-       #     # init file
-       #     var str = """
-       #     foo.bar=foobar
-       #     foo.baz=foobaz
-       #     goo=goo"""
-       #     str.write_to_file("config1.ini")
-       #     # load file
-       #     var config = new ConfigTree("config1.ini")
-       #     assert config["foo.bar"] == "foobar"
-       #
-       # Example with sections:
-       #
-       #     # init file
-       #     str = """
-       #     goo=goo
-       #     [foo]
-       #     bar=foobar
-       #     baz=foobaz
-       #     [boo]
-       #     bar=boobar"""
-       #     str.write_to_file("config2.ini")
-       #     # load file
-       #     config = new ConfigTree("config2.ini")
-       #     assert config["foo.bar"] == "foobar"
-       #     assert config["boo.bar"] == "boobar"
-       #
-       # Example with both hierarchy and section:
-       #
-       #     # init file
-       #     str = """
-       #     goo=goo
-       #     [foo]
-       #     bar.baz=foobarbaz
-       #     [goo.boo]
-       #     bar=gooboobar
-       #     baz.bar=gooboobazbar"""
-       #     str.write_to_file("config3.ini")
-       #     # load file
-       #     config = new ConfigTree("config3.ini")
-       #     assert config["goo"] == "goo"
-       #     assert config["foo.bar.baz"] == "foobarbaz"
-       #     assert config["goo.boo.bar"] == "gooboobar"
-       #     assert config["goo.boo.baz.bar"] == "gooboobazbar"
-       #
-       # Using the array notation
-       #
-       #     str = """
-       #     foo[]=a
-       #     foo[]=b
-       #     foo[]=c"""
-       #     str.write_to_file("config4.ini")
-       #     # load file
-       #     config = new ConfigTree("config4.ini")
-       #     print config.to_map.join(":", ",")
-       #     assert config["foo.0"] == "a"
-       #     assert config["foo.1"] == "b"
-       #     assert config["foo.2"] == "c"
-       #     assert config.at("foo").values.join(",") == "a,b,c"
-       fun load do
-               roots.clear
-               var stream = new FileReader.open(ini_file)
-               var path: nullable String = null
-               var line_number = 0
+       # Read INI content from `string`
+       #
+       # ~~~
+       # var ini = new IniFile
+       # ini.load_string("""
+       # section1.key1=value1
+       # section1.key2=value2
+       # [section2]
+       # key=value3
+       # """)
+       # assert ini["section1.key1"] == "value1"
+       # assert ini["section1.key2"] == "value2"
+       # assert ini["section2.key"] == "value3"
+       # ~~~
+       #
+       # Returns `true` if the parsing finished correctly.
+       #
+       # See also `stop_on_first_error` and `errors`.
+       fun load_string(string: String): Bool do
+               var stream = new StringReader(string)
+               var last_section = null
+               var was_error = false
+               var i = 0
                while not stream.eof do
-                       var line = stream.read_line
-                       line_number += 1
+                       i += 1
+                       var line = stream.read_line.trim
                        if line.is_empty then
                                continue
                        else if line.has_prefix(";") then
                                continue
+                       else if line.has_prefix("#") then
+                               continue
                        else if line.has_prefix("[") then
-                               line = line.trim
-                               var key = line.substring(1, line.length - 2)
-                               path = key
-                               set_node(path, null)
+                               var section = new IniSection(line.substring(1, line.length - 2).trim)
+                               sections.add section
+                               last_section = section
+                               continue
                        else
                                var parts = line.split_once_on("=")
-                               if parts.length == 1 then
+                               if parts.length != 2 then
+                                       # FIXME silent skip?
+                                       # we definitely need exceptions...
+                                       was_error = true
+                                       errors.add new IniError("Unexpected string `{line}` at line {i}.")
+                                       if stop_on_first_error then return was_error
                                        continue
                                end
                                var key = parts[0].trim
-                               var val = parts[1].trim
-                               if path != null then key = "{path}.{key}"
-                               if key.has_suffix("[]") then
-                                       set_array(key, val)
+                               var value = parts[1].trim
+
+                               if last_section != null then
+                                       last_section[key] = value
                                else
-                                       set_node(key,val)
+                                       self[key] = value
                                end
                        end
                end
                stream.close
+               return was_error
        end
 
-       # Save config to file
-       fun save do write_to_file(ini_file)
-
-       private var roots = new Array[ConfigNode]
-
-       # Append `value` to array at `key`
-       private fun set_array(key: String, value: nullable String) do
-               key = key.substring(0, key.length - 2)
-               var len = 0
-               var node = get_node(key)
-               if node != null then len = node.children.length
-               set_node("{key}.{len.to_s}", value)
-       end
-
-       private fun set_node(key: String, value: nullable String) do
-               var parts = key.split(".").reversed
-               var k = parts.pop
-               var root = get_root(k)
-               if root == null then
-                       root = new ConfigNode(k)
-                       if parts.is_empty then
-                               root.value = value
-                       end
-                       roots.add root
-               end
-               while not parts.is_empty do
-                       k = parts.pop
-                       var node = root.get_child(k)
-                       if node == null then
-                               node = new ConfigNode(k)
-                               node.parent = root
-                               root.children[node.name] = node
-                       end
-                       if parts.is_empty then
-                               node.value = value
-                       end
-                       root = node
-               end
-       end
-
-       private fun get_node(key: String): nullable ConfigNode do
-               var parts = key.split(".").reversed
-               var node = get_root(parts.pop)
-               while node != null and not parts.is_empty do
-                       node = node.get_child(parts.pop)
-               end
-               return node
-       end
+       # Load a `file` content as INI
+       #
+       # New properties will be appended to the `self`, existing properties will be
+       # overwrote by the values contained in `file`.
+       #
+       # ~~~
+       # var ini = new IniFile
+       # ini["key1"] = "value1"
+       # ini["key2"] = "value2"
+       #
+       # """
+       # key2=changed
+       # key3=added
+       # """.write_to_file("load_config.ini")
+       #
+       # ini.load_file("load_config.ini")
+       # assert ini["key1"] == "value1"
+       # assert ini["key2"] == "changed"
+       # assert ini["key3"] == "added"
+       #
+       # "load_config.ini".to_path.delete
+       # ~~~
+       #
+       # The process fails silently if the file does not exist.
+       #
+       # ~~~
+       # ini = new IniFile
+       # ini.load_file("ini_not_found.ini")
+       # assert ini.is_empty
+       # ~~~
+       #
+       # Returns `true` if the parsing finished correctly.
+       #
+       # See also `stop_on_first_error` and `errors`.
+       fun load_file(file: String): Bool do return load_string(file.to_path.read_all)
 
-       private fun get_root(name: String): nullable ConfigNode do
-               for root in roots do
-                       if root.name == name then return root
-               end
-               return null
-       end
+       # Stop parsing on the first error
+       #
+       # By default, `load_string` will skip unparsable properties so the string can
+       # be loaded.
+       #
+       # ~~~
+       # var ini = new IniFile.from_string("""
+       # key1=value1
+       # key2
+       # key3=value3
+       # """)
+    #
+       # assert ini.length == 2
+       # assert ini["key1"] == "value1"
+       # assert ini["key2"] == null
+       # assert ini["key3"] == "value3"
+       # ~~~
+       #
+       # Set `stop_on_first_error` to `true` to force the parsing to stop.
+       #
+       # ~~~
+       # ini = new IniFile
+       # ini.stop_on_first_error = true
+       # ini.load_string("""
+       # key1=value1
+       # key2
+       # key3=value3
+       # """)
+    #
+       # assert ini.length == 1
+       # assert ini["key1"] == "value1"
+       # assert ini["key2"] == null
+       # assert ini["key3"] == null
+       # ~~~
+       #
+       # See also `errors`.
+       var stop_on_first_error = false is optional, writable
 
-       private fun leaves: Array[ConfigNode] do
-               var res = new Array[ConfigNode]
-               var todo = new Array[ConfigNode]
-               todo.add_all roots
-               while not todo.is_empty do
-                       var node = todo.pop
-                       if node.children.is_empty then
-                               res.add node
-                       else
-                               todo.add_all node.children.values
-                       end
-               end
-               return res
-       end
+       # Errors found during parsing
+       #
+       # Wathever the value of `stop_on_first_error`, errors from parsing a string
+       # or a file are logged into `errors`.
+       #
+       # ~~~
+       # var ini = new IniFile.from_string("""
+       # key1=value1
+       # key2
+       # key3=value3
+       # """)
+    #
+       # assert ini.errors.length == 1
+       # assert ini.errors.first.message == "Unexpected string `key2` at line 2."
+       # ~~~
+       #
+       # `errors` is not cleared between two parsing:
+       #
+       # ~~~
+       # ini.load_string("""
+       # key4
+       # key5=value5
+       # """)
+    #
+       # assert ini.errors.length == 2
+       # assert ini.errors.last.message == "Unexpected string `key4` at line 1."
+       # ~~~
+       #
+       # See also `stop_on_first_error`.
+       var errors = new Array[IniError]
 end
 
-private class ConfigNode
-
-       var parent: nullable ConfigNode = null
-       var children = new HashMap[String, ConfigNode]
-       var name: String is writable
-       var value: nullable String = null
+# A section in a IniFile
+#
+# Section properties values are strings associated keys.
+# Sections cannot be nested.
+#
+# ~~~
+# var section = new IniSection("section")
+# section["key1"] = "value1"
+# section["key2"] = "value2"
+#
+# assert section.length == 2
+# assert section["key1"] == "value1"
+# assert section["not.found"] == null
+# assert section.join(", ", ": ") == "key1: value1, key2: value2"
+#
+# var i = 0
+# for key, value in section do
+#      assert key.has_prefix("key")
+#      assert value.has_prefix("value")
+#      i += 1
+# end
+# assert i == 2
+# ~~~
+class IniSection
+       super HashMap[String, nullable String]
 
-       fun key: String do
-               var parent = self.parent
-               if parent == null then
-                       return name
-               end
-               return "{parent.key}.{name}"
-       end
+       # Section name
+       var name: String
 
-       fun path: Array[String] do
-               var parent = self.parent
-               if parent == null then
-                       return [name]
-               end
-               var res = new Array[String].from(parent.path)
-               res.add name
-               return res
+       # Get the value associated with `key`
+       #
+       # Returns `null` if the `key` is not found.
+       #
+       # ~~~
+       # var section = new IniSection("section")
+       # section["key"] = "value1"
+       # section["sub.key"] = "value2"
+       #
+       # assert section["key"] == "value1"
+       # assert section["sub.key"] == "value2"
+       # assert section["not.found"] == null
+       # ~~~
+       redef fun [](key) do
+               if not has_key(key) then return null
+               return super
        end
+end
 
-       fun get_child(name: String): nullable ConfigNode do
-               if children.has_key(name) then
-                       return children[name]
-               end
-               return null
-       end
+# Error for `IniFile` parsing
+class IniError
+       super Error
 end