Merge: Mock Github API tests
authorJean Privat <jean@pryen.org>
Wed, 3 Jul 2019 18:50:53 +0000 (14:50 -0400)
committerJean Privat <jean@pryen.org>
Wed, 3 Jul 2019 18:50:53 +0000 (14:50 -0400)
This PR adds a mock to GithubAPI so we can avoid sending requests to the API on CI.

For each API call we save the actual Github response body from the API and reuse it during the tests.
The attribute `update_responses_cache` can be set to `true` so the cache files are updated from the API when `nitunit` is called.

Pull-Request: #2753
Reviewed-by: Jean Privat <jean@pryen.org>

27 files changed:
.gitlab-ci.yml
contrib/nitiwiki/src/wiki_base.nit
lib/config/config.nit
lib/github/loader.nit
lib/ini/README.md [new file with mode: 0644]
lib/ini/ini.nit
lib/ini/package.ini
lib/logger/logger.nit [new file with mode: 0644]
lib/logger/package.ini [new file with mode: 0644]
lib/popcorn/README.md
lib/popcorn/pop_logging.nit
lib/popcorn/pop_tracker.nit
misc/docker/ci/Dockerfile
src/doc/commands/commands_ini.nit
src/loader.nit
src/nitpackage.nit
src/nitpm.nit
src/nitunit.nit
src/nitweb.nit
tests/sav/nitunit_args1.res
tests/sav/nitunit_args10.res
tests/sav/nitunit_args11.res
tests/sav/nitunit_args12.res
tests/sav/nitunit_args13.res
tests/sav/nitunit_args14.res
tests/sav/nitunit_args9.res
tests/tests.sh

index a0711b3..70b91e1 100644 (file)
@@ -77,7 +77,7 @@ test_some: &test_some
   artifacts:
     paths:
       - tests/errlist
-      - tests/*.xml
+      - tests/*.xml*
     when: always
     reports:
       junit: tests/*.xml
@@ -90,6 +90,7 @@ nitunit_some:
     - git diff --name-only origin/master..HEAD -- "*.nit" "*.res" "README.*" | grep -v "^tests/" > list0.txt || true
     - xargs nitls -pP < list0.txt > list.txt
     - xargs nitunit < list.txt
+    - junit2html nitunit.xml
   artifacts:
     paths:
       - nitunit.xml*
@@ -221,6 +222,7 @@ nitunit_lib:
     - xargs nitunit -v < list.txt| tee log.txt
     - grep -e KO log.txt > status.txt || true
     - tail -3 log.txt >> status.txt
+    - junit2html nitunit.xml
   artifacts:
     paths:
       - nitunit.xml*
@@ -238,6 +240,7 @@ nitunit_src:
     - xargs nitunit -v < list.txt| tee log.txt
     - grep -e KO log.txt > status.txt || true
     - tail -3 log.txt >> status.txt
+    - junit2html nitunit.xml
   artifacts:
     paths:
       - nitunit.xml*
@@ -329,6 +332,21 @@ build_more_tools:
       - src/version.nit
       - src/nitc_0
 
+valgrind:
+  stage: more_test
+  dependencies:
+    - build_more_tools
+  script:
+    - mkdir -p valgrind.out
+    - nitc src/nitc.nit # To warm-up the cache
+    - src/valgrind.sh --callgrind-out-file=valgrind.out/nitc.nitc.out nitc src/nitc.nit -vv
+    - callgrind_annotate valgrind.out/nitc.nitc.out > valgrind.out/nitc.nitc.txt
+    - src/valgrind.sh --callgrind-out-file=valgrind.out/niti.niti.out nit -- src/nit.nit tests/base_simple3.nit -vv
+    - callgrind_annotate valgrind.out/niti.niti.out > valgrind.out/niti.niti.txt
+  artifacts:
+    paths:
+      - valgrind.out
+
 build_doc:
   stage: more_test
   dependencies:
@@ -340,6 +358,16 @@ build_doc:
     paths:
       - nitdoc.out
 
+nitmetrics:
+  stage: more_test
+  dependencies:
+    - build_more_tools
+  script:
+    - nitmetrics --all --log --log-dir nitmetrics.out --dir nitmetrics.out --keep-going lib src
+  artifacts:
+    paths:
+      - nitmetrics.out
+
 build_catalog:
   stage: more_test
   dependencies:
index 2e93a74..f1fb6ae 100644 (file)
@@ -612,7 +612,13 @@ end
 #
 # This class provides services that ensure static typing when accessing the `config.ini` file.
 class WikiConfig
-       super ConfigTree
+       super IniFile
+       autoinit ini_file
+
+       # Path to this file
+       var ini_file: String
+
+       init do load_file(ini_file)
 
        # Returns the config value at `key` or return `default` if no key was found.
        protected fun value_or_default(key: String, default: String): String do
@@ -779,8 +785,8 @@ class WikiConfig
        var sidebar_blocks: Array[String] is lazy do
                var res = new Array[String]
                if not has_key("wiki.sidebar.blocks") then return res
-               for val in at("wiki.sidebar.blocks").as(not null).values do
-                       res.add val
+               for val in section("wiki.sidebar.blocks").as(not null).values do
+                       res.add val.as(not null)
                end
                return res
        end
@@ -834,7 +840,13 @@ end
 # Each section can provide its own config file to customize
 # appearance or behavior.
 class SectionConfig
-       super ConfigTree
+       super IniFile
+       autoinit ini_file
+
+       # Path to this file
+       var ini_file: String
+
+       init do load_file(ini_file)
 
        # Returns the config value at `key` or `null` if no key was found.
        private fun value_or_null(key: String): nullable String do
index da03f7c..f07ec47 100644 (file)
@@ -299,7 +299,7 @@ class IniConfig
        super Config
 
        # Config tree used to store config options
-       var ini: ConfigTree is noinit
+       var ini: IniFile is noinit
 
        # Path to app config file
        var opt_config = new OptionString("Path to config file", "--config")
@@ -311,7 +311,7 @@ class IniConfig
 
        redef fun parse_options(args) do
                super
-               ini = new ConfigTree(config_file)
+               ini = new IniFile.from_file(config_file)
        end
 
        # Default config file path
index 4ffa501..6a60c5d 100644 (file)
@@ -104,12 +104,18 @@ class LoaderConfig
 
        # Github tokens used to access data.
        var tokens: Array[String] is lazy do
-               var arr = opt_tokens.value
-               if arr.is_empty then
-                       var iarr = ini.at("tokens")
-                       if iarr != null then arr = iarr.values.to_a
+               var opt_tokens = self.opt_tokens.value
+               if opt_tokens.not_empty then return opt_tokens
+
+               var res = new Array[String]
+               var ini_tokens = ini.section("tokens")
+               if ini_tokens == null then return res
+
+               for token in ini_tokens.values do
+                       if token == null then continue
+                       res.add token
                end
-               return arr or else new Array[String]
+               return res
        end
 
        # Github tokens wallet\13
@@ -128,15 +134,19 @@ class LoaderConfig
        # Verbosity level (the higher the more verbose)
        fun verbose_level: Int do
                var opt = opt_start.value
-               if opt > 0 then return opt
+               if opt > 0 then
+                       return info_level
+               end
                var v = ini["loader.verbose"]
-               if v != null then return v.to_i
-               return 4
+               if v != null and v.to_i > 0 then
+                       return info_level
+               end
+               return warn_level
        end
 
        # Logger used to print things
-       var logger: ConsoleLog is lazy do
-               var logger = new ConsoleLog
+       var logger: PopLogger is lazy do
+               var logger = new PopLogger
                logger.level = verbose_level
                return logger
        end
@@ -412,7 +422,7 @@ class Loader
        end
 
        # Logger shortcut
-       fun log: ConsoleLog do return config.logger
+       fun log: PopLogger do return config.logger
 
        # Display a error and exit
        fun error(msg: String) do
diff --git a/lib/ini/README.md b/lib/ini/README.md
new file mode 100644 (file)
index 0000000..4f9a478
--- /dev/null
@@ -0,0 +1,316 @@
+# `ini` - Read and write INI configuration files
+
+[INI files](https://en.wikipedia.org/wiki/INI_file) are simple text files with
+a basic structure composed of sections, properties and values used to store
+configuration parameters.
+
+Here's an example from the `package.ini` of this package:
+
+~~~
+import ini
+
+var package_ini = """
+[package]
+name=ini
+desc=Read and write INI configuration files.
+[upstream]
+git=https://github.com/nitlang/nit.git
+git.directory=lib/ini/
+"""
+~~~
+
+## Basic usage
+
+`IniFile` is used to parse INI strings and access their content:
+
+~~~
+var ini = new IniFile.from_string(package_ini)
+assert ini["package.name"] == "ini"
+assert ini["upstream.git.directory"] == "lib/ini/"
+assert ini["unknown.unknown"] == null
+~~~
+
+`IniFile` can also load INI configuration from a file:
+
+~~~
+package_ini.write_to_file("my_package.ini")
+
+ini = new IniFile.from_file("my_package.ini")
+assert ini["package.name"] == "ini"
+assert ini["upstream.git.directory"] == "lib/ini/"
+
+"my_package.ini".to_path.delete
+~~~
+
+INI content can be added or edited through the `IniFile` API then written to
+a stream or a file.
+
+~~~
+ini["package.name"] = "new name"
+ini["upstream.git.directory"] = "/dev/null"
+ini["section.key"] = "value"
+
+var stream = new StringWriter
+ini.write_to(stream)
+
+assert stream.to_s == """
+[package]
+name=new name
+desc=Read and write INI configuration files.
+[upstream]
+git=https://github.com/nitlang/nit.git
+git.directory=/dev/null
+[section]
+key=value
+"""
+~~~
+
+## INI content
+
+### Properties
+
+Properties are the basic element of the INI format.
+Every property correspond to a *key* associated to a *value* thanks to the equal (`=`) sign.
+
+~~~
+ini = new IniFile.from_string("""
+key1=value1
+key2=value2
+""")
+assert ini["key1"] == "value1"
+assert ini["key2"] == "value2"
+assert ini.length == 2
+~~~
+
+Accessing an unknown property returns `null`:
+
+~~~
+assert ini["unknown"] == null
+~~~
+
+Properties can be iterated over:
+
+~~~
+var i = 1
+for key, value in ini do
+       assert key == "key{i}"
+       assert value == "value{i}"
+       i += 1
+end
+~~~
+
+Property keys cannot contain the character `=`.
+Values can contain any character.
+Spaces are trimmed.
+
+~~~
+ini = new IniFile.from_string("""
+prop=erty1=value1
+ property2 =  value2
+property3=value3 ; with semicolon
+""")
+assert ini[";property1"] == null
+assert ini["prop=erty1"] == null
+assert ini["prop"] == "erty1=value1"
+assert ini["property2"] == "value2"
+assert ini[" property2 "] == "value2"
+assert ini["property3"] == "value3 ; with semicolon"
+~~~
+
+Both keys and values are case sensitive.
+
+~~~
+ini = new IniFile.from_string("""
+Property1=value1
+property2=Value2
+""")
+assert ini["property1"] == null
+assert ini["Property1"] == "value1"
+assert ini["property2"] != "value2"
+assert ini["property2"] == "Value2"
+~~~
+
+### Sections
+
+Properties may be grouped into arbitrary sections.
+The section name appears on a line by itself between square brackets (`[` and `]`).
+
+All keys after the section declaration are associated with that section.
+The is no explicit "end of section" delimiter; sections end at the next section
+declaration or the end of the file.
+Sections cannot be nested.
+
+~~~
+var content = """
+key1=value1
+key2=value2
+[section1]
+key1=value3
+key2=value4
+[section2]
+key1=value5
+"""
+
+ini = new IniFile.from_string(content)
+assert ini["key1"] == "value1"
+assert ini["unknown"] == null
+assert ini["section1.key1"] == "value3"
+assert ini["section1.unknown"] == null
+assert ini["section2.key1"] == "value5"
+~~~
+
+Sections can be iterated over:
+
+~~~
+i = 1
+for section in ini.sections do
+       assert section.name == "section{i}"
+       assert section["key1"].has_prefix("value")
+       i += 1
+end
+~~~
+
+When iterating over a file properties, only properties at root are returned.
+`flatten` can be used to iterate over all properties including the one from
+sections.
+
+~~~
+assert ini.join(", ", ": ") == "key1: value1, key2: value2"
+assert ini.flatten.join(", ", ": ") ==
+       "key1: value1, key2: value2, section1.key1: value3, section1.key2: value4, section2.key1: value5"
+
+i = 0
+for key, value in ini do
+       i += 1
+       assert key == "key{i}" and value == "value{i}"
+end
+assert i == 2
+
+~~~
+
+Sections name may contain any character including brackets (`[` and `]`).
+Spaces are trimmed.
+
+~~~
+ini = new IniFile.from_string("""
+[[section1]]
+key=value1
+[ section 2 ]
+key=value2
+[section1.section3]
+key=value3
+""")
+assert ini.sections.length == 3
+assert ini["[section1].key"] == "value1"
+assert ini["section 2.key"] == "value2"
+assert ini["section1.section3.key"] == "value3"
+assert ini.sections.last.name == "section1.section3"
+~~~
+
+The dot `.` notation is used to create new sections with `[]=`.
+Unknown sections will be created on the fly.
+
+~~~
+ini = new IniFile
+ini["key"] = "value1"
+ini["section1.key"] = "value2"
+ini["section2.key"] = "value3"
+
+stream = new StringWriter
+ini.write_to(stream)
+assert stream.to_s == """
+key=value1
+[section1]
+key=value2
+[section2]
+key=value3
+"""
+~~~
+
+Sections can also be created manually:
+
+~~~
+ini = new IniFile
+ini["key"] = "value1"
+
+var section = new IniSection("section1")
+section["key"] = "value2"
+ini.sections.add section
+
+stream = new StringWriter
+ini.write_to(stream)
+assert stream.to_s == """
+key=value1
+[section1]
+key=value2
+"""
+~~~
+
+### Comments
+
+Comments are indicated by semicolon (`;`) or a number sign (`#`) at the begining
+of the line. Commented lines are ignored as well as empty lines.
+
+~~~
+ini = new IniFile.from_string("""
+; This is a comment.
+; property1=value1
+
+# This is another comment.
+# property2=value2
+""")
+assert ini.is_empty
+~~~
+
+### Unicode support
+
+INI files support Unicode:
+
+~~~
+ini = new IniFile.from_string("""
+property❤=héhé
+""")
+assert ini["property❤"] == "héhé"
+~~~
+
+## Error handling
+
+By default `IniFile` does not stop when it cannot parse a line in a string (loaded
+by `from_string` or `load_string`) or a file (loaded by `from_file` or `load_file`).
+
+~~~
+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"
+~~~
+
+
+This behaviour can be modified by setting `stop_on_first_error` to `true`.
+
+~~~
+ini = new IniFile.from_string("""
+key1=value1
+key2
+key3=value3
+""", stop_on_first_error = true)
+
+assert ini.length == 1
+assert ini["key1"] == "value1"
+assert ini["key2"] == null
+assert ini["key3"] == null
+~~~
+
+Wathever the value set for `stop_on_first_error`, errors can be checked thanks
+to the `errors` array:
+
+~~~
+assert ini.errors.length == 1
+assert ini.errors.first.message == "Unexpected string `key2` at line 2."
+~~~
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
index 9611f06..07864ce 100644 (file)
@@ -3,7 +3,7 @@ name=ini
 tags=format,lib
 maintainer=Alexandre Terrasa <alexandre@moz-code.org>
 license=Apache-2.0
-desc=Handle ini config files
+desc=Read and write INI configuration files
 [upstream]
 browse=https://github.com/nitlang/nit/tree/master/lib/ini/
 git=https://github.com/nitlang/nit.git
diff --git a/lib/logger/logger.nit b/lib/logger/logger.nit
new file mode 100644 (file)
index 0000000..974ff9c
--- /dev/null
@@ -0,0 +1,402 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# A simple logger for Nit
+#
+# ## Basic Usage
+#
+# Create a new `Logger` with a severity level threshold set to `warn_level`:
+#
+# ~~~
+# var logger = new Logger(warn_level)
+# ~~~
+#
+# Messages with a severity equal or higher than `warn_level` will be displayed:
+#
+# ~~~
+# logger.error "Displays an error."
+# logger.warn "Displays a warning."
+# ~~~
+#
+# Messages with a lower severity are silenced:
+#
+# ~~~
+# logger.info "Displays nothing."
+# ~~~
+#
+# `FileLogger` can be used to output the messages into a file:
+#
+# ~~~
+# var log_file = "my.log"
+#
+# logger = new FileLogger(warn_level, log_file, append = false)
+# logger.error("An error")
+# logger.info("Some info")
+# logger.close
+#
+# assert log_file.to_path.read_all == "An error\n"
+# log_file.to_path.delete
+# ~~~
+#
+# ## Severity levels
+#
+# Each message is associated with a level that indicate its severity.
+# Only messages with a severity equal to or higher than the logger `level`
+# threshold will be displayed.
+#
+# Severity levels from the most severe to the least severe:
+#
+# * `unknown_level`: An unknown message that should always be outputted.
+# * `fatal_level`: An unhandleable error that results in a program crash.
+# * `error_level`: A handleable error condition.
+# * `warn_level`: A warning.
+# * `info_level`: Generic (useful) information about system operation.
+# * `debug_level`: Low-level information for developpers.
+#
+# ## Formatting messages
+#
+# You can create custom formatters by implementing the `Formatter` interface.
+#
+# ~~~
+# class MyFormatter
+#      super Formatter
+#
+#      redef fun format(level, message) do
+#              if level < warn_level then return super
+#              return "!!!{message}!!!"
+#      end
+# end
+# ~~~
+#
+# See `DefaultFormatter` for a more advanced implementation example.
+#
+# Each Logger can be given a default formatter used to format the every messages
+# before outputting them:
+#
+# ~~~
+# var formatter = new MyFormatter
+# var stderr = new StringWriter
+# var logger = new Logger(warn_level, stderr, formatter)
+#
+# logger.warn("This is a warning.")
+# assert stderr.to_s.trim.split("\n").last == "!!!This is a warning.!!!"
+# ~~~
+#
+# Optionally, a `Formatter` can be given to replace the `default_formatter`
+# used by default:
+#
+# ~~~
+# # Create a formatter with no default decorator
+# logger = new Logger(warn_level, stderr, null)
+#
+# # Display a message without any formatter
+# logger.warn("This is a warning.")
+# assert stderr.to_s.trim.split("\n").last == "This is a warning."
+#
+# # Display a message with a custom formatter
+# logger.warn("This is a warning.", formatter)
+# assert stderr.to_s.trim.split("\n").last == "!!!This is a warning.!!!"
+# ~~~
+module logger
+
+import console
+
+# A simple logging utility
+#
+# `Logger` provides a simple way to output messages from applications.
+#
+# Each message is associated with a level that indicate its severity.
+# Only messages with a severity equal to or higher than the logger `level`
+# threshold will be displayed.
+#
+# ~~~
+# var logger = new Logger(warn_level)
+# assert logger.unknown("unkown")
+# assert logger.fatal("fatal")
+# assert logger.error("error")
+# assert logger.warn("warn")
+# assert not logger.info("info")
+# assert not logger.debug("debug")
+# ~~~
+class Logger
+
+       # Severity threshold
+       #
+       # Messages with a severity level greater than or equal to `level` will be displayed.
+       # Default is `warn_level`.
+       #
+       # See `unknown_level`, `fatal_level`, error_level``, `warn_level`,
+       # `info_level` and `debug_level`.
+       var level: Int = warn_level is optional, writable
+
+       # Kind of `Writer` used to output messages
+       type OUT: Writer
+
+       # Writer used to output messages
+       #
+       # Default is `stderr`.
+       var out: OUT = stderr is optional
+
+       # Formatter used to format messages before outputting them
+       #
+       # By default no formatter is used.
+       #
+       # See `DefaultFormatter`.
+       var default_formatter: nullable Formatter = null is optional, writable
+
+       # Output a message with `level` severity
+       #
+       # Only output messages with `level` severity greater than of equal to `self.level`.
+       #
+       # ~~~
+       # var stderr = new StringWriter
+       # var logger = new Logger(warn_level, stderr, null)
+       #
+       # # This message will be displayed:
+       # assert logger.warn("This is a warning.")
+       # assert stderr.to_s.trim.split("\n").last == "This is a warning."
+       #
+       # # This message will not:
+       # assert not logger.info("This is some info.")
+       # assert stderr.to_s.trim.split("\n").last == "This is a warning."
+       # ~~~
+       #
+       # Each logger can be given a default formatter used to format the messages
+       # before outputting them:
+       #
+       # ~~~
+       # var formatter = new DefaultFormatter(no_color = true)
+       # logger = new Logger(warn_level, stderr, formatter)
+       # logger.warn("This is a warning.")
+       # assert stderr.to_s.trim.split("\n").last == "Warning: This is a warning."
+       # ~~~
+       #
+       # Optionally, a `Formatter` can be given to replace the `default_formatter`
+       # used by default.
+       #
+       # ~~~
+       # # Create a formatter with no default decorator
+       # logger = new Logger(warn_level, stderr, null)
+       #
+       # # Display a message without any formatter
+       # logger.warn("This is a warning.")
+       # assert stderr.to_s.trim.split("\n").last == "This is a warning."
+       #
+       # # Display a message with a custom formatter
+       # logger.warn("This is a warning.", formatter)
+       # assert stderr.to_s.trim.split("\n").last == "Warning: This is a warning."
+       # ~~~
+       fun add(level: Int, message: Writable, formatter: nullable Formatter): Bool do
+               var format = formatter or else default_formatter
+               if format == null then
+                       return add_raw(level, message)
+               end
+               return add_raw(level, format.format(level, message))
+       end
+
+       # Output a message with `level` severity without formatting it
+       #
+       # Only output messages with `level` severity greater than of equal to `self.level`.
+       #
+       # ~~~
+       # var stderr = new StringWriter
+       # var logger = new Logger(warn_level, stderr, null)
+       #
+       # # This message will be displayed:
+       # assert logger.add_raw(warn_level, "This is a warning.")
+       # assert stderr.to_s.trim.split("\n").last == "This is a warning."
+       #
+       # # This message will not:
+       # assert not logger.add_raw(info_level, "This is some info.")
+       # assert stderr.to_s.trim.split("\n").last == "This is a warning."
+       # ~~~
+       fun add_raw(level: Int, message: Writable): Bool do
+               if level < self.level then return false
+               out.write(message.write_to_string)
+               out.write("\n")
+               return true
+       end
+
+       # Output a message with `unknown_level` severity
+       #
+       # Unkown severity messages are always outputted.
+       fun unknown(message: String, formatter: nullable Formatter): Bool do
+               return add(unknown_level, message, formatter)
+       end
+
+       # Output a message with `fatal_level` severity
+       fun fatal(message: String, formatter: nullable Formatter): Bool do
+               return add(fatal_level, message, formatter)
+       end
+
+       # Output a message with `error_level` severity
+       fun error(message: String, formatter: nullable Formatter): Bool do
+               return add(error_level, message, formatter)
+       end
+
+       # Output a message with `warn_level` severity
+       fun warn(message: String, formatter: nullable Formatter): Bool do
+               return add(warn_level, message, formatter)
+       end
+
+       # Output a message with `info_level` severity
+       fun info(message: String, formatter: nullable Formatter): Bool do
+               return add(info_level, message, formatter)
+       end
+
+       # Output a message with `debug` severity
+       fun debug(message: String, formatter: nullable Formatter): Bool do
+               return add(debug_level, message, formatter)
+       end
+end
+
+# Log messages to a file
+#
+# ~~~
+# var log_file = "my_file.log"
+# var logger = new FileLogger(warn_level, log_file, append = false)
+# logger.error("An error")
+# logger.info("Some info")
+# logger.close
+# assert log_file.to_path.read_all == "An error\n"
+#
+# logger = new FileLogger(warn_level, log_file, append = true)
+# logger.error("Another error")
+# logger.close
+# assert log_file.to_path.read_all == "An error\nAnother error\n"
+#
+# log_file.to_path.delete
+# ~~~
+class FileLogger
+       super Logger
+       autoinit level, file, append, default_formatter
+
+       redef type OUT: FileWriter
+
+       # File where messages will be written
+       var file: String
+
+       # Append messages to `file`
+       #
+       # If `append` is `false`, the `file` will be overwritten.
+       var append: Bool = true is optional
+
+       init do
+               var old = null
+               if append then
+                       old = file.to_path.read_all
+               end
+               out = new FileWriter.open(file)
+               out.set_buffering_mode(0, buffer_mode_line)
+               if old != null then
+                       out.write(old)
+               end
+       end
+
+       # Close the logger and its `file`
+       fun close do out.close
+end
+
+# Format messages before outputing them
+#
+# A `Logger` can use a `Formatter` to format the messages before outputting them.
+#
+# See `DefaultFormatter`.
+interface Formatter
+
+       # Format `message` depending of its severity `level`
+       fun format(level: Int, message: Writable): Writable do return message
+end
+
+# Default `Logger` formatter
+#
+# The default formatter decorates the messages with severity labels and colors.
+class DefaultFormatter
+       super Formatter
+
+       # Do not decorate messages with colors
+       #
+       # ~~~
+       # var formatter = new DefaultFormatter(no_color = true)
+       # assert formatter.format(error_level, "My message.") == "Error: My message."
+       # ~~~
+       var no_color = false is optional, writable
+
+       redef fun format(level, message) do
+               var string = message.write_to_string
+
+               if level == fatal_level then
+                       string = "Fatal: {string}"
+               else if level == error_level then
+                       string = "Error: {string}"
+               else if level == warn_level then
+                       string = "Warning: {string}"
+               else if level == info_level then
+                       string = "Info: {string}"
+               else if level == debug_level then
+                       string = "Debug: {string}"
+               end
+
+               if no_color then return string
+
+               if level == fatal_level then
+                       return string.red
+               else if level == error_level then
+                       return string.red
+               else if level == warn_level then
+                       return string.yellow
+               else if level == info_level then
+                       return string.purple
+               else if level == debug_level then
+                       return string.blue
+               end
+
+               return string
+       end
+end
+
+redef class Sys
+
+       # Unknown severity level
+       #
+       # These messages are always displayed.
+       #
+       # See `Logger`.
+       var unknown_level = 5
+
+       # Fatal severity level
+       #
+       # See `Logger`.
+       var fatal_level = 4
+
+       # Error severity level
+       #
+       # See `Logger`.
+       var error_level = 3
+
+       # Warning severity level
+       #
+       # See `Logger`.
+       var warn_level = 2
+
+       # Info severity level
+       #
+       # See `Logger`.
+       var info_level = 1
+
+       # Debug severity level
+       #
+       # See `Logger`.
+       var debug_level = 0
+end
diff --git a/lib/logger/package.ini b/lib/logger/package.ini
new file mode 100644 (file)
index 0000000..f4ba7bd
--- /dev/null
@@ -0,0 +1,12 @@
+[package]
+name=logger
+tags=logging,lib
+maintainer=Alexandre Terrasa <alexandre@moz-code.org>
+license=Apache-2.0
+desc=A simple logger for Nit
+[upstream]
+browse=https://github.com/nitlang/nit/tree/master/lib/logger/
+git=https://github.com/nitlang/nit.git
+git.directory=lib/logger/
+homepage=http://nitlanguage.org
+issues=https://github.com/nitlang/nit/issues
index f7c6346..50a0de6 100644 (file)
@@ -511,7 +511,7 @@ with the `use_before` method.
 Next, we’ll create a middleware handler called “LogHandler” that prints the requested
 uri, the response status and the time it took to Popcorn to process the request.
 
-This example gives a simplified version of the `RequestClock` and `ConsoleLog` middlewares.
+This example gives a simplified version of the `RequestClock` and `PopLogger` middlewares.
 
 ~~~
 import popcorn
@@ -584,7 +584,7 @@ Starting with version 0.1, Popcorn provide a set of built-in middleware that can
 be used to develop your app faster.
 
 * `RequestClock`: initializes requests clock.
-* `ConsoleLog`: displays resquest and response status in console (can be used with `RequestClock`).
+* `PopLogger`: displays resquest and response status in console (can be used with `RequestClock`).
 * `SessionInit`: initializes requests session (see the `Sessions` section).
 * `StaticServer`: serves static files (see the `Serving static files with Popcorn` section).
 * `Router`: a mountable mini-app (see the `Mountable routers` section).
index ba3418f..6f0d154 100644 (file)
@@ -17,7 +17,7 @@
 module pop_logging
 
 import pop_handlers
-import console
+import logger
 import realtime
 
 # Initialize a clock for the resquest.
@@ -30,73 +30,71 @@ class RequestClock
 end
 
 # Display log info about request processing.
-class ConsoleLog
+class PopLogger
+       super Logger
        super Handler
 
-       # Logger level
-       #
-       # * `0`: silent
-       # * `1`: errors
-       # * `2`: warnings
-       # * `3`: info
-       # * `4`: debug
-       #
-       # Request status are always logged, whatever the logger level is.
-       var level = 4 is writable
-
        # Do we want colors in the console output?
-       var no_colors = false
+       var no_color = false is optional
+
+       redef var default_formatter = new PopFormatter(no_color) is optional
 
        redef fun all(req, res) do
                var clock = req.clock
                if clock != null then
-                       log "{req.method} {req.url} {status(res)} ({clock.total}s)"
+                       add_raw(info_level, "{req.method} {req.url} {status(res)} ({clock.total}s)")
                else
-                       log "{req.method} {req.url} {status(res)}"
+                       add_raw(info_level, "{req.method} {req.url} {status(res)}")
                end
        end
 
        # Colorize the request status.
        private fun status(res: HttpResponse): String do
-               if no_colors then return res.status_code.to_s
+               if no_color then return res.status_code.to_s
                return res.color_status
        end
+end
 
-       # Display a `message` with `level`.
-       #
-       # Message will only be displayed if `level <= self.level`.
-       # Colors will be used depending on `colors`.
-       #
-       # Use `0` for no coloration.
-       private fun display(level: Int, message: String) do
-               if level > self.level then return
-               if no_colors then
-                       print message
-                       return
+class PopFormatter
+       super Formatter
+
+       # Do not decorate messages with colors
+       var no_color = false is optional, writable
+
+       redef fun format(level, message) do
+               var string = message.write_to_string
+
+               if level == fatal_level then
+                       string = "[FATAL] {string}"
+               else if level == error_level then
+                       string = "[ERROR] {string}"
+               else if level == warn_level then
+                       string = "[WARN] {string}"
+               else if level == info_level then
+                       string = "[INFO] {string}"
+               else if level == debug_level then
+                       string = "[DEBUG] {string}"
                end
-               if level == 0 then print message
-               if level == 1 then print message.red
-               if level == 2 then print message.yellow
-               if level == 3 then print message.blue
-               if level == 4 then print message.gray
-       end
-
-       # Display a message wathever the `level`
-       fun log(message: String) do display(0, message)
 
-       # Display a red error `message`.
-       fun error(message: String) do display(1, "[ERROR] {message}")
-
-       # Display a yellow warning `message`.
-       fun warning(message: String) do display(2, "[WARN] {message}")
-
-       # Display a blue info `message`.
-       fun info(message: String) do display(3, "[INFO] {message}")
+               if no_color then return string
+
+               if level == fatal_level then
+                       return string.red
+               else if level == error_level then
+                       return string.red
+               else if level == warn_level then
+                       return string.yellow
+               else if level == info_level then
+                       return string.blue
+               else if level == debug_level then
+                       return string.gray
+               end
 
-       # Display a gray debug `message`.
-       fun debug(message: String) do display(4, "[DEBUG] {message}")
+               return string
+       end
 end
 
+
 redef class HttpRequest
        # Time that request was received by the Popcorn app.
        var clock: nullable Clock = null
index 9ecf5b7..f43b100 100644 (file)
@@ -46,7 +46,6 @@ module pop_tracker
 
 import popcorn
 import popcorn::pop_config
-import popcorn::pop_logging
 import popcorn::pop_json
 import popcorn::pop_repos
 
@@ -91,7 +90,6 @@ end
 
 # Saves logs into a MongoDB collection
 class PopTracker
-       super ConsoleLog
        super TrackerHandler
 
        redef fun all(req, res) do
index e4ba62b..69dbc45 100644 (file)
@@ -13,6 +13,7 @@ RUN dpkg --add-architecture i386 \
                graphviz \
                libunwind-dev \
                pkg-config \
+               libicu-dev \
                # Get the code!
                git \
                ca-certificates \
index df312c4..d7c3457 100644 (file)
@@ -21,7 +21,7 @@ abstract class CmdIni
        super CmdEntity
 
        # Ini file
-       var ini: nullable ConfigTree = null
+       var ini: nullable IniFile = null
 
        redef fun init_command do
                var res = super
index 8bdfcb2..169d52e 100644 (file)
@@ -477,7 +477,7 @@ redef class ModelBuilder
                        # Attach homonymous `ini` file to the package
                        var inipath = path.dirname / "{pn}.ini"
                        if inipath.file_exists then
-                               var ini = new ConfigTree(inipath)
+                               var ini = new IniFile.from_file(inipath)
                                mpackage.ini = ini
                        end
                end
@@ -543,7 +543,7 @@ redef class ModelBuilder
                var parent = null
                var inipath = dirpath / "package.ini"
                if inipath.file_exists then
-                       ini = new ConfigTree(inipath)
+                       ini = new IniFile.from_file(inipath)
                end
 
                if ini == null then
@@ -1178,7 +1178,7 @@ redef class MPackage
        # The `ini` file is given as is and might contain invalid or missing information.
        #
        # Some packages, like stand-alone packages or virtual packages have no `ini` file associated.
-       var ini: nullable ConfigTree = null
+       var ini: nullable IniFile = null
 
        # Array of relative source paths excluded according to the `source.exclude` key of the `ini`
        var excludes: nullable Array[String] is lazy do
index a81ded9..7f4c5f2 100644 (file)
@@ -253,14 +253,14 @@ redef class MPackage
                var ini_path = ini_path
                if ini_path == null then return
 
-               var ini = new ConfigTree(ini_path)
+               var ini = new IniFile.from_file(ini_path)
 
-               ini.check_key(toolcontext, self, "package.name", name)
-               ini.check_key(toolcontext, self, "package.desc")
-               ini.check_key(toolcontext, self, "package.tags")
+               ini.check_key(ini_path, toolcontext, self, "package.name", name)
+               ini.check_key(ini_path, toolcontext, self, "package.desc")
+               ini.check_key(ini_path, toolcontext, self, "package.tags")
 
                # FIXME since `git reflog --follow` seems bugged
-               ini.check_key(toolcontext, self, "package.maintainer")
+               ini.check_key(ini_path, toolcontext, self, "package.maintainer")
                # var maint = mpackage.maintainer
                # if maint != null then
                        # ini.check_key(toolcontext, self, "package.maintainer", maint)
@@ -272,24 +272,24 @@ redef class MPackage
                        # ini.check_key(toolcontext, self, "package.more_contributors", contribs.join(", "))
                # end
 
-               ini.check_key(toolcontext, self, "package.license", license)
-               ini.check_key(toolcontext, self, "upstream.browse", browse_url)
-               ini.check_key(toolcontext, self, "upstream.git", git_url)
-               ini.check_key(toolcontext, self, "upstream.git.directory", git_dir)
-               ini.check_key(toolcontext, self, "upstream.homepage", homepage_url)
-               ini.check_key(toolcontext, self, "upstream.issues", issues_url)
+               ini.check_key(ini_path, toolcontext, self, "package.license", license)
+               ini.check_key(ini_path, toolcontext, self, "upstream.browse", browse_url)
+               ini.check_key(ini_path, toolcontext, self, "upstream.git", git_url)
+               ini.check_key(ini_path, toolcontext, self, "upstream.git.directory", git_dir)
+               ini.check_key(ini_path, toolcontext, self, "upstream.homepage", homepage_url)
+               ini.check_key(ini_path, toolcontext, self, "upstream.issues", issues_url)
 
-               for key in ini.to_map.keys do
+               for key in ini.flatten.keys do
                        if not allowed_ini_keys.has(key) then
                                toolcontext.warning(location, "unknown-ini-key",
-                                       "Warning: ignoring unknown `{key}` key in `{ini.ini_file}`")
+                                       "Warning: ignoring unknown `{key}` key in `{ini_path}`")
                        end
                end
        end
 
        private fun gen_ini: String do
                var ini_path = self.ini_path.as(not null)
-               var ini = new ConfigTree(ini_path)
+               var ini = new IniFile.from_file(ini_path)
 
                ini.update_value("package.name", name)
                ini.update_value("package.desc", "")
@@ -304,7 +304,7 @@ redef class MPackage
                ini.update_value("upstream.homepage", homepage_url)
                ini.update_value("upstream.issues", issues_url)
 
-               ini.save
+               ini.write_to_file(ini_path)
                return ini_path
        end
 
@@ -528,8 +528,8 @@ redef class MModule
        end
 end
 
-redef class ConfigTree
-       private fun check_key(toolcontext: ToolContext, mpackage: MPackage, key: String, value: nullable String) do
+redef class IniFile
+       private fun check_key(ini_file: String, toolcontext: ToolContext, mpackage: MPackage, key: String, value: nullable String) do
                if not has_key(key) then
                        toolcontext.warning(mpackage.location, "missing-ini-key",
                                "Warning: missing `{key}` key in `{ini_file}`")
index 1e9de9c..0c6ed5f 100644 (file)
@@ -78,7 +78,7 @@ class CommandInstall
                                exit 1
                        end
 
-                       var ini = new ConfigTree(ini_path)
+                       var ini = new IniFile.from_file(ini_path)
                        var import_line = ini["package.import"]
                        if import_line == null then
                                print_error "The local `package.ini` declares no external dependencies."
@@ -135,7 +135,7 @@ class CommandInstall
                                print ini_path.to_path.read_all
                        end
 
-                       var ini = new ConfigTree(ini_path)
+                       var ini = new IniFile.from_file(ini_path)
                        var git_repo = ini["upstream.git"]
                        if git_repo == null then
                                print_error "Package description invalid, or it does not declare a Git repository"
@@ -195,7 +195,7 @@ class CommandInstall
                end
 
                # Recursive install
-               var ini = new ConfigTree(target_dir/"package.ini")
+               var ini = new IniFile.from_file(target_dir/"package.ini")
                var import_line = ini["package.import"]
                if import_line != null then
                        install_packages import_line
@@ -320,7 +320,7 @@ class CommandList
                for file in files do
                        var ini_path = nitpm_lib_dir / file / "package.ini"
                        if verbose then print "- Reading ini file at {ini_path}"
-                       var ini = new ConfigTree(ini_path)
+                       var ini = new IniFile.from_file(ini_path)
                        var tags = ini["package.tags"]
 
                        name_to_desc[file] = tags
index fe8fb7b..125c514 100644 (file)
@@ -84,19 +84,23 @@ for a in args do
        end
        # Try to load the file as a markdown document
        var mdoc = modelbuilder.load_markdown(a)
-       page.add modelbuilder.test_mdoc(mdoc)
+       var ts = modelbuilder.test_mdoc(mdoc)
+       if not ts.children.is_empty then page.add ts
 end
 
 for a in module_files do
        var g = modelbuilder.identify_group(a)
        if g == null then continue
-       page.add modelbuilder.test_group(g)
+       var ts = modelbuilder.test_group(g)
+       if not ts.children.is_empty then page.add ts
 end
 
 for m in mmodules do
-       page.add modelbuilder.test_markdown(m)
-       var ts = modelbuilder.test_unit(m)
-       if ts != null then page.add ts
+       var ts
+       ts = modelbuilder.test_markdown(m)
+       if not ts.children.is_empty then page.add ts
+       ts = modelbuilder.test_unit(m)
+       if ts != null and not ts.children.is_empty then page.add ts
 end
 
 var file = toolcontext.opt_output.value
index 9e9d54f..d3c8c63 100644 (file)
@@ -99,7 +99,7 @@ private class NitwebPhase
                app.use("/oauth", new GithubOAuthCallBack(config.github_client_id, config.github_client_secret))
                app.use("/logout", new GithubLogout)
                app.use("/*", new StaticHandler(toolcontext.share_dir / "nitweb", "index.html"))
-               app.use_after("/*", new ConsoleLog)
+               app.use_after("/*", new PopLogger(info_level))
 
                app.listen(config.app_host, config.app_port)
        end
index f1851ee..7cec735 100644 (file)
@@ -35,5 +35,5 @@ Test suites: Classes: 1; Test Cases: 3; Failures: 1
 </system-out></testcase><testcase classname="nitunit.test_nitunit.X" name="foo1" time="0.0"><failure message="Syntax Error: unexpected operator &#39;!&#39;."></failure><system-out>assert !@#$%^&amp;*()
 </system-out></testcase><testcase classname="nitunit.test_nitunit.X" name="foo2" time="0.0"><system-err></system-err><system-out>var x = new X
 assert x.foo2
-</system-out></testcase></testsuite><testsuite package="test_test_nitunit::test_test_nitunit"></testsuite><testsuite package="test_test_nitunit"><testcase classname="nitunit.test_test_nitunit.TestX" name="test_foo" time="0.0"><system-err></system-err></testcase><testcase classname="nitunit.test_test_nitunit.TestX" name="test_foo1" time="0.0"><error message="Runtime Error in file nitunit.out&#47;gen_test_test_nitunit.nit">Runtime error: Assert failed (test_test_nitunit.nit:38)
+</system-out></testcase></testsuite><testsuite package="test_test_nitunit"><testcase classname="nitunit.test_test_nitunit.TestX" name="test_foo" time="0.0"><system-err></system-err></testcase><testcase classname="nitunit.test_test_nitunit.TestX" name="test_foo1" time="0.0"><error message="Runtime Error in file nitunit.out&#47;gen_test_test_nitunit.nit">Runtime error: Assert failed (test_test_nitunit.nit:38)
 </error></testcase><testcase classname="nitunit.test_test_nitunit.TestX" name="test_foo2" time="0.0"><system-err></system-err></testcase></testsuite></testsuites>
\ No newline at end of file
index 5fdb314..cbfc0cd 100644 (file)
@@ -5,4 +5,4 @@
 Docunits: Entities: 4; Documented ones: 0; With nitunits: 0
 Test suites: Classes: 1; Test Cases: 2; Failures: 0
 [SUCCESS] All 2 tests passed.
-<testsuites><testsuite package="test_nitunit5::test_nitunit5"></testsuite><testsuite package="test_nitunit5"><testcase classname="nitunit.test_nitunit5.TestNitunit5" name="test_path_is_set" time="0.0"><system-err></system-err></testcase><testcase classname="nitunit.test_nitunit5.TestNitunit5" name="test_path_is_suite_path" time="0.0"><system-err></system-err></testcase></testsuite></testsuites>
\ No newline at end of file
+<testsuites><testsuite package="test_nitunit5"><testcase classname="nitunit.test_nitunit5.TestNitunit5" name="test_path_is_set" time="0.0"><system-err></system-err></testcase><testcase classname="nitunit.test_nitunit5.TestNitunit5" name="test_path_is_suite_path" time="0.0"><system-err></system-err></testcase></testsuite></testsuites>
\ No newline at end of file
index 0d1829d..a640323 100644 (file)
@@ -13,4 +13,4 @@ Docunits: Entities: 5; Documented ones: 0; With nitunits: 0
 Test suites: Classes: 1; Test Cases: 3; Failures: 3
 [FAILURE] 3/3 tests failed.
 `nitunit.out` is not removed for investigation.
-<testsuites><testsuite package="test_nitunit6::test_nitunit6"></testsuite><testsuite package="test_nitunit6"><testcase classname="nitunit.test_nitunit6.TestNitunit6" name="test_foo" time="0.0"><failure message="Nitunit Error: before module test failed"></failure></testcase></testsuite></testsuites>
\ No newline at end of file
+<testsuites><testsuite package="test_nitunit6"><testcase classname="nitunit.test_nitunit6.TestNitunit6" name="test_foo" time="0.0"><failure message="Nitunit Error: before module test failed"></failure></testcase></testsuite></testsuites>
\ No newline at end of file
index 9015b4a..4932785 100644 (file)
@@ -11,4 +11,4 @@ Docunits: Entities: 5; Documented ones: 0; With nitunits: 0
 Test suites: Classes: 1; Test Cases: 3; Failures: 1
 [FAILURE] 1/3 tests failed.
 `nitunit.out` is not removed for investigation.
-<testsuites><testsuite package="test_nitunit7::test_nitunit7"></testsuite><testsuite package="test_nitunit7"><testcase classname="nitunit.test_nitunit7.TestNitunit7" name="test_foo" time="0.0"><system-err></system-err></testcase></testsuite></testsuites>
\ No newline at end of file
+<testsuites><testsuite package="test_nitunit7"><testcase classname="nitunit.test_nitunit7.TestNitunit7" name="test_foo" time="0.0"><system-err></system-err></testcase></testsuite></testsuites>
\ No newline at end of file
index 8c8d3f9..84dbc07 100644 (file)
@@ -11,4 +11,4 @@ Docunits: Entities: 3; Documented ones: 0; With nitunits: 0
 Test suites: Classes: 1; Test Cases: 3; Failures: 1
 [FAILURE] 1/3 tests failed.
 `nitunit.out` is not removed for investigation.
-<testsuites><testsuite package="test_nitunit8::test_nitunit8"></testsuite><testsuite package="test_nitunit8"><testcase classname="nitunit.test_nitunit8.TestNitunit8" name="test_foo" time="0.0"><system-err></system-err></testcase></testsuite></testsuites>
\ No newline at end of file
+<testsuites><testsuite package="test_nitunit8"><testcase classname="nitunit.test_nitunit8.TestNitunit8" name="test_foo" time="0.0"><system-err></system-err></testcase></testsuite></testsuites>
\ No newline at end of file
index c0f1e23..3cb2d30 100644 (file)
@@ -15,4 +15,4 @@ Docunits: Entities: 7; Documented ones: 0; With nitunits: 0
 Test suites: Classes: 1; Test Cases: 7; Failures: 1
 [FAILURE] 1/7 tests failed.
 `nitunit.out` is not removed for investigation.
-<testsuites><testsuite package="test_nitunit11::test_nitunit11"></testsuite><testsuite package="test_nitunit11"><testcase classname="nitunit.test_nitunit11.TestNitunit11" name="test_baz" time="0.0"><system-err></system-err></testcase></testsuite></testsuites>
\ No newline at end of file
+<testsuites><testsuite package="test_nitunit11"><testcase classname="nitunit.test_nitunit11.TestNitunit11" name="test_baz" time="0.0"><system-err></system-err></testcase></testsuite></testsuites>
\ No newline at end of file
index 404c270..7682e35 100644 (file)
@@ -59,11 +59,11 @@ Docunits: Entities: 22; Documented ones: 0; With nitunits: 0
 Test suites: Classes: 3; Test Cases: 8; Failures: 7
 [FAILURE] 7/8 tests failed.
 `nitunit.out` is not removed for investigation.
-<testsuites><testsuite package="test_nitunit4&gt;"></testsuite><testsuite package="test_nitunit4::nitunit4"></testsuite><testsuite package="test_nitunit4::test_bad_comp"></testsuite><testsuite package="test_bad_comp"><testcase classname="nitunit.test_nitunit4.TestSuiteBadComp" name="test_good" time="0.0"><failure message="Compilation Error">test_nitunit4&#47;test_bad_comp.nit:25,10--19: Error: method or variable `bad_method` unknown in `TestSuiteBadComp`.
+<testsuites><testsuite package="test_bad_comp"><testcase classname="nitunit.test_nitunit4.TestSuiteBadComp" name="test_good" time="0.0"><failure message="Compilation Error">test_nitunit4&#47;test_bad_comp.nit:25,10--19: Error: method or variable `bad_method` unknown in `TestSuiteBadComp`.
 </failure></testcase><testcase classname="nitunit.test_nitunit4.TestSuiteBadComp" name="test_bad" time="0.0"><failure message="Compilation Error">test_nitunit4&#47;test_bad_comp.nit:25,10--19: Error: method or variable `bad_method` unknown in `TestSuiteBadComp`.
-</failure></testcase></testsuite><testsuite package="test_nitunit4::test_bad_comp2"></testsuite><testsuite package="test_bad_comp2"><testcase classname="nitunit.test_nitunit4.TestSuiteBadComp" name="test_good" time="0.0"><failure message="Compilation Error">nitunit.out&#47;gen_test_bad_comp2.nit:11,10--17: Error: expected 1 argument(s) for `test_bad(param: Bool)`; got 0. See introduction at `test_nitunit4::TestSuiteBadComp::test_bad`.
+</failure></testcase></testsuite><testsuite package="test_bad_comp2"><testcase classname="nitunit.test_nitunit4.TestSuiteBadComp" name="test_good" time="0.0"><failure message="Compilation Error">nitunit.out&#47;gen_test_bad_comp2.nit:11,10--17: Error: expected 1 argument(s) for `test_bad(param: Bool)`; got 0. See introduction at `test_nitunit4::TestSuiteBadComp::test_bad`.
 </failure></testcase><testcase classname="nitunit.test_nitunit4.TestSuiteBadComp" name="test_bad" time="0.0"><failure message="Compilation Error">nitunit.out&#47;gen_test_bad_comp2.nit:11,10--17: Error: expected 1 argument(s) for `test_bad(param: Bool)`; got 0. See introduction at `test_nitunit4::TestSuiteBadComp::test_bad`.
-</failure></testcase></testsuite><testsuite package="test_nitunit4::test_nitunit4"></testsuite><testsuite package="test_nitunit4"><testcase classname="nitunit.test_nitunit4.TestTestSuite" name="test_foo" time="0.0"><error message="Runtime Error in file nitunit.out&#47;gen_test_nitunit4.nit">Before Test
+</failure></testcase></testsuite><testsuite package="test_nitunit4"><testcase classname="nitunit.test_nitunit4.TestTestSuite" name="test_foo" time="0.0"><error message="Runtime Error in file nitunit.out&#47;gen_test_nitunit4.nit">Before Test
 Tested method
 After Test
 Runtime assert: &lt;TestTestSuite&gt;.before
@@ -82,4 +82,4 @@ After Test
 </error></testcase><testcase classname="nitunit.test_nitunit4.TestTestSuite" name="test_sav_conflict" time="0.0"><error message="Conflicting expected output: test_nitunit4&#47;test_nitunit4.sav&#47;test_sav_conflict.res, test_nitunit4&#47;sav&#47;test_sav_conflict.res and test_nitunit4&#47;test_sav_conflict.res all exist">Before Test
 Tested method
 After Test
-</error></testcase></testsuite><testsuite package="test_nitunit4::test_nitunit4_base"></testsuite></testsuites>
\ No newline at end of file
+</error></testcase></testsuite></testsuites>
\ No newline at end of file
index 721171d..c19a9a8 100755 (executable)
@@ -846,6 +846,10 @@ fi
 
 echo >>$xml "</testsuite></testsuites>"
 
+if type junit2html >/dev/null; then
+       junit2html "$xml"
+fi
+
 if [ -n "$nok" ]; then
        exit 1
 else