src/doc_commands: rewrite doc commands parser
authorAlexandre Terrasa <alexandre@moz-code.org>
Tue, 29 Aug 2017 22:22:15 +0000 (18:22 -0400)
committerAlexandre Terrasa <alexandre@moz-code.org>
Tue, 26 Sep 2017 20:26:34 +0000 (16:26 -0400)
Also add tests

Signed-off-by: Alexandre Terrasa <alexandre@moz-code.org>

src/doc/doc_commands.nit
src/doc/test_doc_commands.nit [new file with mode: 0644]

index 1537704..e276057 100644 (file)
 # * `nitdoc` wikilinks like `[[doc: MEntity::name]]`
 module doc_commands
 
-# A command aimed at a documentation tool like `nitdoc` or `nitx`.
 #
-# `DocCommand` are generally of the form `command: args`.
-interface DocCommand
-
-       # Original command string.
-       fun string: String is abstract
+class DocCommandParser
 
-       # Command name.
-       fun name: String is abstract
+       # List of allowed command names for this parser
+       var allowed_commands: Array[String] = [ "doc", "comment", "list", "param",
+               "return", "new", "call", "code", "graph"] is writable
 
-       # Command arguments.
+       # Parse `string` as a DocCommand
        #
-       # FIXME: define a syntax
-       fun args: Array[String] is abstract
-
-       # Command factory.
+       # Returns `null` if the string cannot be parsed.
+       #
+       # ~~~
+       # var parser = new DocCommandParser
+       #
+       # var command = parser.parse("comment: core::Array")
+       # assert command isa CommentCommand
+       # assert command.arg == "core::Array"
        #
-       # Returns a concrete instance of `DocCommand` depending on the string.
-       new(command_string: String) do
-               if command_string.has_prefix("doc:") then
-                       return new ArticleCommand(command_string)
-               else if command_string.has_prefix("comment:") then
-                       return new CommentCommand(command_string)
-               else if command_string.has_prefix("list:") then
-                       return new ListCommand(command_string)
-               else if command_string.has_prefix("param:") then
-                       return new ParamCommand(command_string)
-               else if command_string.has_prefix("return:") then
-                       return new ReturnCommand(command_string)
-               else if command_string.has_prefix("new:") then
-                       return new NewCommand(command_string)
-               else if command_string.has_prefix("call:") then
-                       return new CallCommand(command_string)
-               else if command_string.has_prefix("code:") then
-                       return new CodeCommand(command_string)
-               else if command_string.has_prefix("graph:") then
-                       return new GraphCommand(command_string)
+       # command = parser.parse(":") # syntax error
+       # assert command == null
+       # assert parser.errors.not_empty
+       # ~~~
+       fun parse(string: String): nullable DocCommand do
+               var pos = 0
+               var tmp = new FlatBuffer
+               errors.clear
+
+               # Parse command name
+               pos = string.read_until(tmp, pos, ':')
+               var name = tmp.write_to_string.trim
+
+               # Check allowed commands
+               if name.is_empty then
+                       error("empty command name", 0)
+                       return null
+               end
+               if not allowed_commands.has(name) then
+                       error("unknown command name", 0)
+                       return null
+               end
+
+               # Build the command
+               var command = new_command(name, string)
+               if command == null then
+                       error("unknown command name", 0)
+                       return null
+               end
+
+               # Parse the argument
+               tmp.clear
+               pos = string.read_until(tmp, pos + 1, '|')
+               var arg = tmp.write_to_string.trim
+               if arg.is_empty then
+                       error("empty command arg", pos)
+                       return null
+               end
+               command.arg = arg
+
+               # Parse command options
+               while pos < string.length do
+                       # Parse option name
+                       tmp.clear
+                       pos = string.read_until(tmp, pos + 1, ':', ',')
+                       var oname = tmp.write_to_string.trim
+                       var oval = ""
+                       if oname.is_empty then break
+                       # Parse option value
+                       if pos < string.length and string[pos] == ':' then
+                               tmp.clear
+                               pos = string.read_until(tmp, pos + 1, ',')
+                               oval = tmp.write_to_string.trim
+                       end
+                       command.opts[oname] = oval
+                       # TODO Check options
                end
-               return new UnknownCommand(command_string)
+
+               return command
+       end
+
+       # Init a new DocCommand from its `name`
+       #
+       # You must redefine this method to add new custom commands.
+       fun new_command(name, string: String): nullable DocCommand do
+               if name == "doc" then return new ArticleCommand(string)
+               if name == "comment" then return new CommentCommand(string)
+               if name == "list" then return new ListCommand(string)
+               if name == "param" then return new ParamCommand(string)
+               if name == "return" then return new ReturnCommand(string)
+               if name == "new" then return new NewCommand(string)
+               if name == "call" then return new CallCommand(string)
+               if name == "code" then return new CodeCommand(string)
+               if name == "graph" then return new GraphCommand(string)
+               return null
+       end
+
+       # Errors and warnings from last call to `parse`
+       var errors = new Array[DocMessage]
+
+       # Generate an error
+       fun error(message: String, col: nullable Int) do
+               errors.add new DocMessage(1, message, col)
        end
 
-       redef fun to_s do return string
+       # Generate a warning
+       fun warning(message: String, col: nullable Int) do
+               errors.add new DocMessage(2, message, col)
+       end
 end
 
-# Used to factorize initialization of DocCommands.
-abstract class AbstractDocCommand
-       super DocCommand
+# A message generated by the DocCommandParser
+class DocMessage
+
+       # Message severity
+       #
+       # 1- Error
+       # 2- Warning
+       var level: Int
 
-       redef var string
-       redef var name is noinit
-       redef var args = new Array[String]
+       # Message explanatory string
+       var message: String
 
-       init do
-               # parse command
+       # Related column in original string if any
+       var col: nullable Int
+
+       redef fun to_s do
                var str = new FlatBuffer
-               var i = 0
-               while i < string.length do
-                       var c = string[i]
-                       i += 1
-                       if c == ':' then break
-                       str.add c
+               if level == 1 then
+                       str.append "Error: "
+               else
+                       str.append "Warning: "
+               end
+               str.append message
+               var col = self.col
+               if col != null then
+                       str.append " (col: {col})"
+               end
+               return str.write_to_string
+       end
+end
+
+redef class Text
+       # Read `self` as raw text until `nend` and append it to the `out` buffer.
+       private fun read_until(out: FlatBuffer, start: Int, nend: Char...): Int do
+               var pos = start
+               while pos < length do
+                       var c = self[pos]
+                       var end_reached = false
+                       for n in nend do
+                               if c == n then
+                                       end_reached = true
+                                       break
+                               end
+                       end
+                       if end_reached then break
+                       out.add c
+                       pos += 1
                end
-               name = str.write_to_string
-               # parse args
-               args.add string.substring_from(i).trim
+               return pos
        end
 end
 
-# A `DocCommand` not recognized by documentation tools.
+# A command aimed at a documentation tool like `nitdoc` or `nitx`.
 #
-# Used to provide warnings or any other behavior for unexisting commands.
-class UnknownCommand
-       super AbstractDocCommand
+# `DocCommand` are generally of the form `command: arg | opt1: val1, opt2: val2`.
+abstract class DocCommand
+
+       # Original command string.
+       var string: String
+
+       # Command name.
+       var name: String is noinit
+
+       # Command arguments.
+       var arg: String is noinit, writable
+
+       # Command options.
+       var opts = new HashMap[String, String] is writable
+
+       redef fun to_s do
+               if opts.is_empty then
+                       return "{name}: {arg}"
+               end
+               return "{name}: {arg} | {opts.join(", ", ": ")}"
+       end
 end
 
 # A `DocCommand` that includes the documentation article of a `MEntity`.
 #
 # Syntax: `doc: MEntity::name`.
 class ArticleCommand
-       super AbstractDocCommand
+       super DocCommand
+
+       redef var name = "doc"
 end
 
 # A `DocCommand` that includes the MDoc of a `MEntity`.
 #
 # Syntax: `comment: MEntity::name`.
 class CommentCommand
-       super AbstractDocCommand
+       super DocCommand
+
+       redef var name = "comment"
 end
 
 # A `DocCommand` that includes a list of something.
 #
 # Syntax: `list:kind: <arg>`.
 class ListCommand
-       super AbstractDocCommand
+       super DocCommand
+
+       redef var name = "list"
 end
 
 # A `DocCommand` that includes the list of methods tanking a `MType` as parameter.
 #
 # Syntax: `param: MType`.
 class ParamCommand
-       super AbstractDocCommand
+       super DocCommand
+
+       redef var name = "param"
 end
 
 # A `DocCommand` that includes the list of methods returning a `MType` as parameter.
 #
-# Syntax: `param: MType`.
+# Syntax: `return: MType`.
 class ReturnCommand
-       super AbstractDocCommand
+       super DocCommand
+
+       redef var name = "return"
 end
 
 # A `DocCommand` that includes the list of methods creating new instances of a specific `MType`
 #
 # Syntax: `new: MType`.
 class NewCommand
-       super AbstractDocCommand
+       super DocCommand
+
+       redef var name = "new"
 end
 
 # A `DocCommand` that includes the list of methods calling a specific `MProperty`.
 #
 # Syntax: `call: MEntity::name`.
 class CallCommand
-       super AbstractDocCommand
+       super DocCommand
+
+       redef var name = "call"
 end
 
 # A `DocCommand` that includes the source code of a `MEntity`.
@@ -151,7 +275,9 @@ end
 # * `./src/file.nit` to include source code from a file.
 # * `./src/file.nit:1,2--3,4` to select code between positions.
 class CodeCommand
-       super AbstractDocCommand
+       super DocCommand
+
+       redef var name = "code"
 end
 
 # A `DocCommand` that display an graph for a `MEntity`.
@@ -159,5 +285,7 @@ end
 # Syntax:
 # * `graph: MEntity::name`
 class GraphCommand
-       super AbstractDocCommand
+       super DocCommand
+
+       redef var name = "graph"
 end
diff --git a/src/doc/test_doc_commands.nit b/src/doc/test_doc_commands.nit
new file mode 100644 (file)
index 0000000..712d4cd
--- /dev/null
@@ -0,0 +1,143 @@
+# 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.
+
+module test_doc_commands is test
+
+import doc_commands
+
+class TestDocCommandParser
+       test
+
+       var parser: DocCommandParser
+
+       fun init_parser is before do
+               parser = new DocCommandParser
+       end
+
+       fun test_empty_string is test do
+               var command = parser.parse("")
+               assert command == null
+               assert parser.errors.length == 1
+               assert parser.errors.first.to_s == "Error: empty command name (col: 0)"
+       end
+
+       fun test_bad_string is test do
+               var command = parser.parse(":")
+               assert command == null
+               assert parser.errors.length == 1
+               assert parser.errors.first.to_s == "Error: empty command name (col: 0)"
+       end
+
+       fun test_unknown_command is test do
+               var command = parser.parse("foo: foo")
+               assert command == null
+               assert parser.errors.length == 1
+               assert parser.errors.first.to_s == "Error: unknown command name (col: 0)"
+       end
+
+       fun test_unallowed_command is test do
+               parser.allowed_commands.clear
+               var command = parser.parse("comment: core::Array")
+               assert command == null
+               assert parser.errors.length == 1
+               assert parser.errors.first.to_s == "Error: unknown command name (col: 0)"
+       end
+
+       fun test_no_arg is test do
+               var command = parser.parse("doc:")
+               assert command == null
+               assert parser.errors.length == 1
+               print parser.errors.first
+               assert parser.errors.first.to_s == "Error: empty command arg (col: 4)"
+       end
+
+       fun test_no_opts is test do
+               var command = parser.parse("doc: core::Array")
+               assert command isa ArticleCommand
+               assert command.name == "doc"
+               assert command.arg == "core::Array"
+               assert parser.errors.is_empty
+       end
+
+       fun test_opts_empty is test do
+               var command = parser.parse("doc: core::Array | ")
+               assert command isa ArticleCommand
+               assert command.name == "doc"
+               assert command.arg == "core::Array"
+               assert parser.errors.is_empty
+       end
+
+       fun test_1_opt is test do
+               var command = parser.parse("doc: core::Array | opt1: val1 ")
+               assert command isa ArticleCommand
+               assert command.name == "doc"
+               assert command.arg == "core::Array"
+               assert command.opts.length == 1
+               assert command.opts["opt1"] == "val1"
+               assert parser.errors.is_empty
+       end
+
+       fun test_2_opts is test do
+               var command = parser.parse("doc: core::Array | opt1: val1 , opt2: val2,  ")
+               assert command isa ArticleCommand
+               assert command.name == "doc"
+               assert command.arg == "core::Array"
+               assert command.opts.length == 2
+               assert command.opts["opt1"] == "val1"
+               assert command.opts["opt2"] == "val2"
+               assert parser.errors.is_empty
+       end
+
+       fun test_empty_opt_name is test do
+               var command = parser.parse("doc: core::Array | opt1: val1  , :")
+               assert command isa ArticleCommand
+               assert command.name == "doc"
+               assert command.arg == "core::Array"
+               assert command.opts.length == 1
+               assert command.opts["opt1"] == "val1"
+               assert parser.errors.is_empty
+       end
+
+       fun test_empty_opt_value is test do
+               var command = parser.parse("doc: core::Array | opt1:  , opt2: val2,  ")
+               assert command isa ArticleCommand
+               assert command.name == "doc"
+               assert command.arg == "core::Array"
+               assert command.opts.length == 2
+               assert command.opts["opt1"] == ""
+               assert command.opts["opt2"] == "val2"
+               assert parser.errors.is_empty
+       end
+
+       fun test_empty_opt_value2 is test do
+               var command = parser.parse("doc: core::Array | opt1")
+               assert command isa ArticleCommand
+               assert command.name == "doc"
+               assert command.arg == "core::Array"
+               assert command.opts.length == 1
+               assert command.opts["opt1"] == ""
+               assert parser.errors.is_empty
+       end
+
+       fun test_empty_opt_value3 is test do
+               var command = parser.parse("doc: core::Array | opt1, opt2: val2")
+               assert command isa ArticleCommand
+               assert command.name == "doc"
+               assert command.arg == "core::Array"
+               assert command.opts.length == 2
+               assert command.opts["opt1"] == ""
+               assert command.opts["opt2"] == "val2"
+               assert parser.errors.is_empty
+       end
+end