Merge: modelize_property: Promote `refine-type` to an error
authorJean Privat <jean@pryen.org>
Fri, 26 Aug 2016 16:42:24 +0000 (12:42 -0400)
committerJean Privat <jean@pryen.org>
Fri, 26 Aug 2016 16:42:24 +0000 (12:42 -0400)
Signed-off-by: Jean-Christophe Beaupré <jcbrinfo@users.noreply.github.com>

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

60 files changed:
contrib/github_merge.nit
contrib/shibuqam/examples/shibuqamoauth.nit [new file with mode: 0644]
contrib/shibuqam/shibuqam.nit
lib/core/README.md
lib/github/README.md
lib/github/api.nit
lib/json/serialization.nit
lib/mongodb/queries.nit
lib/nlp/README.md
lib/pipeline.nit
lib/popcorn/pop_handlers.nit
lib/popcorn/pop_logging.nit
lib/popcorn/pop_tracker.nit [new file with mode: 0644]
lib/trees/trie.nit
share/nitweb/javascripts/nitweb.js
share/nitweb/views/error.html [new file with mode: 0644]
src/doc/doc_phases/doc_console.nit
src/doc/doc_phases/doc_readme.nit
src/model/model_index.nit [new file with mode: 0644]
src/model/model_views.nit
src/modelize/modelize_property.nit
src/nitunit.nit
src/nitweb.nit
src/test_model_index.nit [new file with mode: 0644]
src/web/api_catalog.nit
src/web/api_docdown.nit
src/web/api_feedback.nit
src/web/api_graph.nit
src/web/api_metrics.nit
src/web/api_model.nit
src/web/web.nit
src/web/web_base.nit
tests/nitcg.skip
tests/sav/nitunit_args9.res
tests/sav/nitx_args1.res
tests/sav/nitx_args2.res
tests/sav/nitx_args3.res
tests/sav/test_model_index.res [new file with mode: 0644]
tests/sav/test_model_index_args1.res [new file with mode: 0644]
tests/sav/test_model_index_args10.res [new file with mode: 0644]
tests/sav/test_model_index_args11.res [new file with mode: 0644]
tests/sav/test_model_index_args12.res [new file with mode: 0644]
tests/sav/test_model_index_args13.res [new file with mode: 0644]
tests/sav/test_model_index_args14.res [new file with mode: 0644]
tests/sav/test_model_index_args15.res [new file with mode: 0644]
tests/sav/test_model_index_args16.res [new file with mode: 0644]
tests/sav/test_model_index_args17.res [new file with mode: 0644]
tests/sav/test_model_index_args18.res [new file with mode: 0644]
tests/sav/test_model_index_args19.res [new file with mode: 0644]
tests/sav/test_model_index_args2.res [new file with mode: 0644]
tests/sav/test_model_index_args20.res [new file with mode: 0644]
tests/sav/test_model_index_args21.res [new file with mode: 0644]
tests/sav/test_model_index_args3.res [new file with mode: 0644]
tests/sav/test_model_index_args4.res [new file with mode: 0644]
tests/sav/test_model_index_args5.res [new file with mode: 0644]
tests/sav/test_model_index_args6.res [new file with mode: 0644]
tests/sav/test_model_index_args7.res [new file with mode: 0644]
tests/sav/test_model_index_args8.res [new file with mode: 0644]
tests/sav/test_model_index_args9.res [new file with mode: 0644]
tests/test_model_index.args [new file with mode: 0644]

index 52afa49..ca61f39 100644 (file)
@@ -17,6 +17,7 @@ module github_merge
 
 import github::github_curl
 import template
+import opts
 
 redef class Object
        # Factorize cast
@@ -27,12 +28,16 @@ end
 
 redef class GithubCurl
        # Get a given pull request (PR)
-       fun getpr(number: Int): JsonObject
+       fun getpr(repo: String, number: Int): nullable JsonObject
        do
-               var pr = get_and_check("https://api.github.com/repos/nitlang/nit/pulls/{number}")
+               var ir = get_and_check("https://api.github.com/repos/{repo}/issues/{number}")
+               var irm = ir.json_as_map
+               if not irm.has_key("pull_request") then return null
+               var pr = get_and_check("https://api.github.com/repos/{repo}/pulls/{number}")
                var prm = pr.json_as_map
                var sha = prm["head"].json_as_map["sha"].to_s
-               var statuses = get_and_check("https://api.github.com/repos/nitlang/nit/statuses/{sha}")
+               var statuses = get_and_check("https://api.github.com/repos/{repo}/commits/{sha}/status")
+               statuses = statuses.json_as_map
                prm["statuses"] = statuses
                print "{prm["title"].to_s}: by {prm["user"].json_as_map["login"].to_s} (# {prm["number"].to_s})"
                var mergeable = prm["mergeable"]
@@ -41,23 +46,31 @@ redef class GithubCurl
                else
                        print "\tmergeable: unknown"
                end
-               var st = prm["statuses"].json_as_a
-               if not st.is_empty then
-                       print "\tstatus: {st[0].json_as_map["state"].to_s}"
-               else
+               var state = statuses["state"]
+               if state == null then
                        print "\tstatus: not tested"
+               else
+                       print "\tstatus: {state}"
+                       var sts = statuses["statuses"].json_as_a
+                       for st in sts do
+                               st = st.json_as_map
+                               var ctx = st["context"].to_s
+                               state = st["state"].to_s
+                               print "\tstatus {ctx}: {state}"
+                               prm["status-{ctx}"] = state
+                       end
                end
                return prm
        end
 
        # Get reviewers of a PR
-       fun getrev(pr: JsonObject): Array[String]
+       fun getrev(repo: String, pr: JsonObject): Array[String]
        do
                var number = pr["number"].as(Int)
                var user = pr["user"].json_as_map["login"].as(String)
                var comments = new Array[nullable Object]
-               comments.add_all(get_and_check("https://api.github.com/repos/nitlang/nit/issues/{number}/comments").json_as_a)
-               comments.add_all(get_and_check("https://api.github.com/repos/nitlang/nit/pulls/{number}/comments").json_as_a)
+               comments.add_all(get_and_check("https://api.github.com/repos/{repo}/issues/{number}/comments").json_as_a)
+               comments.add_all(get_and_check("https://api.github.com/repos/{repo}/pulls/{number}/comments").json_as_a)
                var logins = new Array[String]
                for c in comments do
                        var cm = c.json_as_map
@@ -83,27 +96,61 @@ end
 
 if "NIT_TESTING".environ == "true" then exit 0
 
-var auth = get_github_oauth
+var opt_repo = new OptionString("Repository (e.g. nitlang/nit)", "-r", "--repo")
+var opt_auth = new OptionString("OAuth token", "--auth")
+var opt_query = new OptionString("Query to get issues (e.g. label=ok_will_merge)", "-q", "--query")
+var opt_keepgoing = new OptionBool("Skip merge conflicts", "-k", "--keep-going")
+var opt_all = new OptionBool("Merge all", "-a", "--all")
+var opt_status = new OptionArray("A status context that must be \"success\" (e.g. default)", "--status")
+var opts = new OptionContext
+opts.add_option(opt_repo, opt_auth, opt_query, opt_status, opt_all, opt_keepgoing)
+
+opts.parse(sys.args)
+var args = opts.rest
 
+var auth = opt_auth.value or else ""
+if auth == "" then auth = get_github_oauth
 if auth == "" then
        print "Warning: no github oauth token, you can configure one with"
        print "    git config --add github.oauthtoken MYOAUTHTOKEN"
 end
 
+var repo = opt_repo.value or else "nitlang/nit"
+
+var query = opt_query.value or else "labels=ok_will_merge"
+
 var curl = new GithubCurl(auth, "Merge-o-matic (nitlang/nit)")
 
-if args.length != 1 then
+if args.is_empty then
        # Without args, list `ok_will_merge`
-       var x = curl.get_and_check("https://api.github.com/repos/nitlang/nit/issues?labels=ok_will_merge")
+       var x = curl.get_and_check("https://api.github.com/repos/{repo}/issues?{query}")
+       var list = new Array[String]
        for y in x.json_as_a do
                var number = y.json_as_map["number"].as(Int)
-               curl.getpr(number)
-       end
-else
+               var pr = curl.getpr(repo, number)
+               if pr == null then continue
+               for ctx in opt_status.value do
+                       if pr.get_or_null("status-{ctx}") != "success" then
+                               print "No \"success\" for {ctx}. Skip."
+                               continue label
+                       end
+               end
+               list.add number.to_s
+       end label
+
+       if not opt_all.value then return
+       args = list
+end
+
+for arg in args do
        # With a arg, merge the PR
-       var number = args.first.to_i
-       var pr = curl.getpr(number)
-       var revs = curl.getrev(pr)
+       var number = arg.to_i
+       var pr = curl.getpr(repo, number)
+       if pr == null then
+               print "Not a PR: {number}"
+               return
+       end
+       var revs = curl.getrev(repo, pr)
 
        var mergemsg = new Template
        mergemsg.add "Merge: {pr["title"].to_s}\n\n"
@@ -119,7 +166,15 @@ else
                print "Commit {sha} not in local repository; did you fetch github?"
                return
        end
+       if system("git merge-base --is-ancestor {sha} HEAD") == 0 then
+               print "Is already merged."
+               continue
+       end
        if system("git merge --no-ff --no-commit {sha}") != 0 then
+               if opt_keepgoing.value then
+                        system("git reset --merge")
+                        continue
+               end
                system("cp mergemsg `git rev-parse --git-dir`/MERGE_MSG")
                print "Problem during merge... Let's do the commit manually."
                return
diff --git a/contrib/shibuqam/examples/shibuqamoauth.nit b/contrib/shibuqam/examples/shibuqamoauth.nit
new file mode 100644 (file)
index 0000000..2a52be9
--- /dev/null
@@ -0,0 +1,339 @@
+# 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.
+
+# Server that implements an OAuth2-like authentication bound to a `shibuqam` valid server.
+#
+# The protocol is based on [OAuth2](https://tools.ietf.org/html/rfc6749),
+# especially the [Authorization Code Grant](https://tools.ietf.org/html/rfc6749#section-4.1).
+#
+# # Use as a web service
+#
+# * User: the human user and its user-agent (browser) that use the client website.
+# * Client: the 3rd party web site that need authentication. It owns `https://client.example.com`
+# * Server: the public OAuth server. It owns `https://server.example.com`
+#
+# From the user & client point of view, the process is the following:
+#
+# 1. The client redirects the user to the server.
+# 2. The user authenticates on the server.
+# 3. The server redirects the user to the client with a token.
+# 4. The client sends the token to the server.
+# 5. The server responds with the user information.
+#
+# ## 1. User Request
+#
+# Two GET fields
+#
+# * `redirect_uri`: A full URI that will be used to redirect the user once the authentication is done.
+# * `state`: A temporary string used to check that the callback is legitimate.
+#
+# On request, the service authenticate the user then redirect to the callback with the user informations.
+#
+# Example:
+#
+# ~~~raw
+# GET https://server.example.com/login?redirect_uri=https://client.example.com/callback&state=secret
+# ~~~
+#
+# ## 3. Response
+#
+# After the authentication, the response is a 303 redirection to `redirect_uri`
+# with the following GET arguments:
+#
+# * `code:` a temporary token to send-back to the server
+# * `state`: the same state to check that the response is not forged.
+#
+# Example:
+#
+# ~~~raw
+# HTTP/1.1 303 See Other
+# Location: https://client.example.com/callback?code=something&state=secret
+# ~~~
+#
+# ## 3b. Errors
+#
+# If the request is badly formed or if the `redirect_uri` is not authorized,
+# then there is no redirection and a text error message is send to the user.
+#
+# If there is a problem during the authentication, then there is a redirection
+# but the GET fields are only `state` and `error=access_denied`.
+#
+# ## 4. Client request
+#
+# A single POST field
+#
+# * `code`: The one you get as the user response.
+#
+# Example:
+#
+# ~~~raw
+# POST https://server.example.com/info
+# code=something
+# ~~~
+#
+# ## 5. Response
+#
+# The response is a JSON object that is the plain serialization of a `User` instance.
+#
+# ~~~json
+# HTTP/1.1 200 OK
+# Content-Type: application/json;charset=UTF-8
+#
+# {
+#      "id": "jdoe",
+#      "given_name": "John",
+#      "display_name": "John Doe",
+#      "email": "doe.john@example.com",
+#      "avatar": "https://www.gravatar.com/avatar/4fe50a575e1c28773800a0aa03c62dbe?d=retro"
+# }
+# ~~~
+#
+# # To configure and execute
+#
+# ## Run the server
+#
+#    shibuqamoauth authorized_list [host] [port]
+#
+# `authorized_list` is a textfile that registers the list of accepted `redirect_uri`.
+#
+# Example:
+#
+# ~~~raw
+# https://example.com/foo
+# https://other.example.com/
+# ~~~
+#
+# To be authorized, a `redirect_uri` must have one of the line of `authorized_list` as a prefix.
+# For instance, the previous `authorized_list` accepts `https://example.com/foo` and `https://example.com/foo/bar`
+# but not `https://example.com/bar` nor `https://sub.example.com/foo`.
+#
+#
+# ## Install as a server
+#
+# A correct `shibuqam` reverse proxy must be configured (see `shibuqam` for details).
+# In a full scenario, the server is replaced by:
+#
+# * Proxy: the public reverse proxy that can do the genuine Shibboleth authentication.
+#   It owns `https://server.example.com`
+# * Service: the private shibuqamoauth service. It owns a NATed `https://shibuqamoauth.example.com/`
+#
+# On the first access to the server (user request):
+#
+# 1. Proxy gets the request
+# 2. Proxy does the Shibboleth authentication.
+# 3. Proxy enhances the request header.
+# 4. Proxy forwards the request to service.
+# 5. Service checks that `redir` is authorized.
+# 6. Service digs in the enhanced request header to generate a token and associate it to the user.
+# 7. Redirect the user to client with the callback and the token.
+#
+# On the second access to the server (client request):
+#
+# 1. Proxy gets the request
+# 2. Proxy forwards the request to service without authentification (it is not a user).
+# 3. Service checks that the token existe and is not expired.
+# 4. Service serialize and send the user as in the
+#
+# The Proxy should only do the authentication on the user request.
+# The simplest way is to configure two routes that reverse proxy on the same server.
+#
+# # Why not using real OAuth2?
+#
+# OAuth2 is centered about *access_tokens* that allow clients to performs
+# a (possibly scoped) set of actions/queries on the behalf of an authenticated user.
+#
+# In our scenario, there is no action/queries to do once an user is authenticated.
+# So we do not delivers *access_tokens* since there is nothing to access once the user is known.
+#
+# We need only the third-party authentication part of the protocol.
+#
+# Since we do not have the same goals than the RFC, we also have a simplified protocol.
+# Noways, OAuth is mainly a framework and the implementations are very diverse and unfortunately no
+# not interoperable anyway.
+#
+# Here is the specific changes we have:
+#
+# * no `response_type`: there is a single kind of grant and the two steps are identified by the http method (GET or POST) and the configured routes on the server.
+# * no `client_id`: because we do not want to code a db of clients. The `redirect_uri` can be seen as a simple client_id. We have also no way to present the client to the user since we do not control the authorization page of shibboleth since is done by the reverse proxy.
+# * no `scope` and no `authorisation_code` since since there is nothing to access. We just get minimal information after the successful shibboleth login. Thus we have nothing more to authorise once the user is authenticated.
+# * no `client_secret`: the user information are already public. There is no need to make the code more complex to protect public information.
+module shibuqamoauth
+
+import popcorn
+import shibuqam
+import json::serialization
+
+redef class HttpRequest
+       # percent decoded get or post parameter.
+       fun dec_param(name: String): nullable String
+       do
+               var res = get_args.get_or_null(name)
+               if res == null then res = post_args.get_or_null(name)
+               if res == null then return null
+               return res.from_percent_encoding
+       end
+end
+
+class AuthHandler
+       super Handler
+
+       # List of prefixes of authorized redirections.
+       var authorized: Array[String]
+
+       # Check is `redir` is an authorized redirection (client)
+       fun is_authorized(redir: String): Bool
+       do
+               for r in authorized do if redir.has_prefix(r) then return true
+               return false
+       end
+
+
+       # Associate each `code` issued to the user, to the info intended to the client.
+       var infos = new HashMap[String, Info]
+
+       # Remove expired keys
+       fun expiration
+       do
+               var now = get_time
+               for k, i in infos do
+                       if i.expiration < now then
+                               infos.keys.remove(k)
+                               print "{i.user.id} -> expired"
+                       end
+               end
+       end
+
+       # Generate a new usable token
+       #
+       # Not thread safe!
+       fun new_token: String
+       do
+               var token
+               loop
+                       token = generate_token
+                       if not infos.has_key(token) then break
+               end
+               return token
+       end
+
+       redef fun get(http_request, response)
+       do
+               # GET means a Authorization Request from the user-agent
+
+               # Get the `redirect_uri` parameter, we use it to identify the client
+               var redir = http_request.dec_param("redir")
+               if redir == null then redir = http_request.dec_param("redirect_uri")
+               if redir == null then
+                       response.send("No redirect_uri.", 400)
+                       return
+               end
+
+               # Check if the client is authorized
+               if not is_authorized(redir) then
+                       response.send("Site not authorized.", 403)
+                       return
+               end
+
+               # Get the state, we use it to avoid CSRF attacks
+               var state = http_request.dec_param("state")
+               var res = redir + "?"
+
+               # If we are here, the reverse proxy did the authentication
+               # Is there an user?
+               var user = http_request.user
+               if user == null then
+                       print "no user -> {redir}"
+                       res += "error=access_denied"
+               else
+                       # The user is authenticated.
+                       print "{user.id} -> {redir}"
+
+                       # We prepare a token (code) that the client will use to get the information.
+                       expiration
+                       var token = new_token
+
+                       res += "code={token.to_percent_encoding}"
+
+                       var ttl = 10*60*60 # 10 minutes
+                       var info = new Info(get_time + ttl, user)
+                       infos[token] = info
+               end
+               if state != null then res += "&state={state.to_percent_encoding}"
+               response.redirect res
+       end
+
+       redef fun post(http_request, response)
+       do
+               # POST means an Access Token Request from the client.
+               # Unfortunately, we have no access to grant, only informations.
+               print http_request.post_args.join(" ; ", ": ")
+
+               expiration
+
+               var code = http_request.dec_param("code")
+               if code == null then
+                       print "POST: no code"
+                       return
+               end
+               var info = infos.get_or_null(code)
+               if info == null then
+                       print "POST: bad code {code}"
+                       return
+               end
+
+               print "{info.user.id} -> retrieved"
+
+               # Drop the code as it is a single use
+               infos.keys.remove(code)
+
+               # Send the requested information.
+               response.json(info.user)
+       end
+end
+
+redef class User
+       super Jsonable
+       redef fun to_json do return serialize_to_json(plain=true)
+end
+
+# Information about an authenticated used stored on the server to be given to the client.
+class Info
+       # Time to live
+       var expiration: Int
+
+       # The identified user
+       var user: User
+end
+
+var host = "localhost"
+var port = 3000
+
+if args.is_empty then
+       print "usage: shibuqamoauth authorized_list [host] [port]"
+       return
+end
+
+var list = args[0]
+if args.length > 1 then host = args[1]
+if args.length > 2 then port = args[2].to_i
+
+var authorized = list.to_path.read_lines
+if authorized.is_empty then
+       print_error "{list}: not found or empty"
+       exit 1
+end
+
+var app = new App
+app.use("/*", new AuthHandler(authorized))
+app.listen(host, port)
index e8da9a5..a6bf333 100644 (file)
@@ -21,9 +21,12 @@ module shibuqam
 
 import nitcorn
 private import md5
+import serialization
 
 # Information on a user from Shibboleth/UQAM
 class User
+       serialize
+
        # The *code permanent* (or the uid for non student)
        var id: String
 
index 7833f37..f394594 100644 (file)
@@ -4,43 +4,43 @@ Core classes and methods used by default by Nit programs and libraries.
 
 ## Core Basic Types and Operations
 
-[[doc:kernel]]
+[[doc: kernel]]
 
 ### Object
 
-[[doc:Object]]
+[[doc: Object]]
 
 #### Equality
 
-[[doc:Object::==]]
-[[doc:Object::!=]]
-[[doc:Object::hash]]
-[[doc:Object::is_same_instance]]
-[[doc:Object::object_id]]
+[[doc: core::Object::==]]
+[[doc: core::Object::!=]]
+[[doc: core::Object::hash]]
+[[doc: core::Object::is_same_instance]]
+[[doc: core::Object::object_id]]
 
 #### Debuging
 
-[[doc:Object::output]]
-[[doc:Object::output_class_name]]
-[[doc:Object::is_same_type]]
+[[doc: core::Object::output]]
+[[doc: core::Object::output_class_name]]
+[[doc: core::Object::is_same_type]]
 
 ### Sys
 
-[[doc:Sys]]
+[[doc: Sys]]
 
 #### Program Execution
 
-[[doc:Sys::main]]
-[[doc:Sys::run]]
+[[doc: core::Sys::main]]
+[[doc: core::Sys::run]]
 
 ### Other
 
-[[list:kernel]]
+[[list: kernel]]
 
 ## Core Collections
 
-[[doc:collection]]
+[[doc: collection]]
 
 ## String and Text manipulation
 
-[[doc:text]]
+[[doc: text]]
index 64a7e98..041e753 100644 (file)
@@ -8,7 +8,7 @@ This module provides a Nit object oriented interface to access the Github api.
 
 ### Authentification
 
-[[doc: GithubAPI::auth]]
+[[doc: auth]]
 
 Token can also be recovered from user config with `get_github_oauth`.
 
@@ -28,7 +28,7 @@ Token can also be recovered from user config with `get_github_oauth`.
 
 ### Other data
 
-[[list: api]]
+[[list: github::api]]
 
 ### Advanced uses
 
@@ -38,11 +38,11 @@ Token can also be recovered from user config with `get_github_oauth`.
 
 #### Custom requests
 
-[[doc: GithubAPI::get]]
+[[doc: github::GithubAPI::get]]
 
 #### Change the user agent
 
-[[doc: GithubAPI::user_agent]]
+[[doc: github::GithubAPI::user_agent]]
 
 #### Debugging
 
index a1dcad4..7c85422 100644 (file)
@@ -365,9 +365,9 @@ class GithubAPI
                var count = issue.comments or else 0
                var page = 1
                loop
-                       var array = get("/repos/{repo.full_name}/comments?page={page}")
+                       var array = get("/repos/{repo.full_name}/issues/{issue.number}/comments?page={page}")
                        if not array isa JsonArray then break
-                       if array.is_empty or res.length < count then break
+                       if array.is_empty then break
                        for obj in array do
                                if not obj isa JsonObject then continue
                                var id = obj["id"].as(Int)
@@ -375,6 +375,7 @@ class GithubAPI
                                if comment == null then continue
                                res.add(comment)
                        end
+                       if res.length >= count then break
                        page += 1
                end
                return res
@@ -689,7 +690,7 @@ class Issue
        var closed_by: nullable User is writable
 
        # Is this issue linked to a pull request?
-       var is_pull_request: Bool is noserialize, writable
+       var is_pull_request: Bool = false is writable, noserialize
 end
 
 # A Github pull request.
@@ -767,8 +768,10 @@ class PullRef
        # User pointed by `self`.
        var user: User is writable
 
-       # Repo pointed by `self`.
-       var repo: Repo is writable
+       # Repo pointed by `self` (if any).
+       #
+       # A `null` value means the `repo` was deleted.
+       var repo: nullable Repo is writable
 end
 
 # A Github label.
@@ -1097,4 +1100,15 @@ class GithubDeserializer
                end
                return null
        end
+
+       redef fun deserialize_class(name) do
+               if name == "Issue" then
+                       var issue = super.as(Issue)
+                       if path.last.has_key("pull_request") then
+                               issue.is_pull_request = true
+                       end
+                       return issue
+               end
+               return super
+       end
 end
index 8861e19..e26b8ec 100644 (file)
@@ -290,7 +290,14 @@ class JsonDeserializer
 
        redef fun deserialize_attribute(name, static_type)
        do
-               assert not path.is_empty # This is an internal error, abort
+               if path.is_empty then
+                       # The was a parsing error or the root is not an object
+                       if not root isa Error then
+                               errors.add new Error("Deserialization Error: parsed JSON value is not an object.")
+                       end
+                       return null
+               end
+
                var current = path.last
 
                if not current.keys.has(name) then
index 26d7375..10037db 100644 (file)
@@ -70,10 +70,25 @@ import mongodb
 class MongoMatch
        super JsonObject
 
-       private fun op(name: String, field: String, value: nullable Jsonable): MongoMatch do
-               var q = new JsonObject
-               q["${name}"] = value
-               self[field] = q
+       # Define a custom operaton for `field`
+       #
+       # If no `field` is specified, append the operator the the root object:
+       # ~~~json
+       # {$<name>: <value>}
+       # ~~~
+       #
+       # Else, append the operator to the field:
+       # ~~~json
+       # {field: {$<name>: <value>} }
+       # ~~~
+       fun op(name: String, field: nullable String, value: nullable Jsonable): MongoMatch do
+               if field != null then
+                       var q = new JsonObject
+                       q["${name}"] = value
+                       self[field] = q
+               else
+                       self[name] = value
+               end
                return self
        end
 
@@ -166,6 +181,25 @@ class MongoMatch
                return self
        end
 
+       # Match documents where `field` matches `pattern`
+       #
+       # To read more about the available options, see:
+       # https://docs.mongodb.com/manual/reference/operator/query/regex/#op._S_regex
+       #
+       # ~~~json
+       # {field: {$regex: 'pattern', $options: '<options>'} }
+       # ~~~
+       #
+       # Provides regular expression capabilities for pattern matching strings in queries.
+       # MongoDB uses Perl compatible regular expressions (i.e. "PCRE" ).
+       fun regex(field: String, pattern: String, options: nullable String): MongoMatch do
+               var q = new JsonObject
+               q["$regex"] = pattern
+               if options != null then q["$options"] = options
+               self[field] = q
+               return self
+       end
+
        # Match documents where `field` is in `values`
        #
        # https://docs.mongodb.com/manual/reference/operator/query/in/
@@ -196,6 +230,77 @@ class MongoMatch
                op("$nin", field, new JsonArray.from(values))
                return self
        end
+
+       # Logical `or`
+       #
+       # https://docs.mongodb.com/manual/reference/operator/query/or/#op._S_or
+       #
+       # The `$or` operator performs a logical OR operation on an array of two or
+       # more `expressions` and selects the documents that satisfy at least one of
+       # the `expressions`.
+       #
+       # The `$or` has the following syntax:
+       #
+       # ~~~json
+       # { field: { $or: [ { <expression1> }, { <expression2> }, ... , { <expressionN> } ] } }
+       # ~~~
+       fun lor(field: nullable String, expressions: Array[Jsonable]): MongoMatch do
+               op("$or", field, new JsonArray.from(expressions))
+               return self
+       end
+
+       # Logical `and`
+       #
+       # https://docs.mongodb.com/manual/reference/operator/query/and/#op._S_and
+       #
+       # The `$and` operator performs a logical AND operation on an array of two or
+       # more `expressions` and selects the documents that satisfy all of the `expressions`.
+       #
+       # The `$and` has the following syntax:
+       #
+       # ~~~json
+       # { field: { $and: [ { <expression1> }, { <expression2> }, ... , { <expressionN> } ] } }
+       # ~~~
+       fun land(field: nullable String, expressions: Array[Jsonable]): MongoMatch do
+               op("$and", field, new JsonArray.from(expressions))
+               return self
+       end
+
+       # Logical `not`
+       #
+       # https://docs.mongodb.com/manual/reference/operator/query/not/#op._S_not
+       #
+       # `$not` performs a logical NOT operation on the specified `expression` and
+       # selects the documents that do not match the `expression`.
+       # This includes documents that do not contain the field.
+       #
+       # The $not has the following syntax:
+       #
+       # ~~~json
+       # { field: { $not: { <expression> } } }
+       # ~~~
+       fun lnot(field: nullable String, expression: Jsonable): MongoMatch do
+               op("$not", field, expression)
+               return self
+       end
+
+       # Logical `nor`
+       #
+       # https://docs.mongodb.com/manual/reference/operator/query/nor/#op._S_nor
+       #
+       # `$nor` performs a logical NOR operation on an array of one or more query
+       # expression and selects the documents that fail all the query expressions
+       # in the array.
+       #
+       # The $nor has the following syntax:
+       #
+       # ~~~json
+       # { field: { $nor: [ { <expression1> }, { <expression2> }, ... , { <expressionN> } ] } }
+       # ~~~
+       fun lnor(field: nullable String, expressions: Array[Jsonable]): MongoMatch do
+               op("$nor", field, new JsonArray.from(expressions))
+               return self
+       end
 end
 
 # Mongo pipelines are arrays of aggregation stages
index 6545537..c7196c4 100644 (file)
@@ -33,41 +33,41 @@ For ease of use, this wrapper introduce a Nit model to handle CoreNLP XML result
 
 [[doc: NLPDocument]]
 
-[[doc: NLPDocument::from_xml]]
-[[doc: NLPDocument::from_xml_file]]
-[[doc: NLPDocument::sentences]]
+[[doc: nlp::NLPDocument::from_xml]]
+[[doc: nlp::NLPDocument::from_xml_file]]
+[[doc: nlp::NLPDocument::sentences]]
 
 ### NLPSentence
 
 [[doc: NLPSentence]]
 
-[[doc: NLPSentence::tokens]]
+[[doc: nlp::NLPSentence::tokens]]
 
 ### NLPToken
 
 [[doc: NLPToken]]
 
-[[doc: NLPToken::word]]
-[[doc: NLPToken::lemma]]
-[[doc: NLPToken::pos]]
+[[doc: nlp::NLPToken::word]]
+[[doc: nlp::NLPToken::lemma]]
+[[doc: nlp::NLPToken::pos]]
 
 ### NLP Processor
 
 [[doc: NLPProcessor]]
 
-[[doc: NLPProcessor::java_cp]]
+[[doc: nlp::NLPProcessor::java_cp]]
 
-[[doc: NLPProcessor::process]]
-[[doc: NLPProcessor::process_file]]
-[[doc: NLPProcessor::process_files]]
+[[doc: nlp::NLPProcessor::process]]
+[[doc: nlp::NLPProcessor::process_file]]
+[[doc: nlp::NLPProcessor::process_files]]
 
 ## Vector Space Model
 
 [[doc: NLPVector]]
 
-[[doc: NLPDocument::vector]]
+[[doc: vector]]
 
-[[doc: NLPVector::cosine_similarity]]
+[[doc: nlp::NLPVector::cosine_similarity]]
 
 ## NitNLP binary
 
index d3428f0..54be22b 100644 (file)
@@ -73,6 +73,8 @@ redef interface Iterator[E]
        # When the first iterator is terminated, the second is started.
        #
        #     assert ([1..20[.iterator + [20..40[.iterator).to_a             ==  ([1..40[).to_a
+       #
+       # SEE: `Iterator2`
        fun +(other: Iterator[E]): Iterator[E]
        do
                return new PipeJoin[E](self, other)
@@ -159,6 +161,95 @@ redef interface Iterator[E]
        end
 end
 
+# Concatenates a sequence of iterators.
+#
+# Wraps an iterator of sub-iterators and iterates over the elements of the
+# sub-iterators.
+#
+# ~~~nit
+# var i: Iterator[Int]
+# var empty = new Array[Int]
+#
+# i = new Iterator2[Int]([
+#      [1, 2, 3].iterator,
+#      empty.iterator,
+#      [4, 5].iterator
+# ].iterator)
+# assert i.to_a == [1, 2, 3, 4, 5]
+#
+# i = new Iterator2[Int]([
+#      empty.iterator,
+#      [42].iterator,
+#      empty.iterator
+# ].iterator)
+# assert i.to_a == [42]
+# ~~~
+#
+# SEE: `Iterator::+`
+class Iterator2[E]
+       super Iterator[E]
+
+       # The inner iterator over sub-iterators.
+       var inner: Iterator[Iterator[E]]
+
+       redef fun finish
+       do
+               var i = current_iterator
+               if i != null then i.finish
+       end
+
+       redef fun is_ok
+       do
+               var i = current_iterator
+               if i == null then return false
+               return i.is_ok
+       end
+
+       redef fun item
+       do
+               var i = current_iterator
+               assert i != null
+               return i.item
+       end
+
+       redef fun next
+       do
+               var i = current_iterator
+               assert i != null
+               i.next
+       end
+
+       redef fun start
+       do
+               var i = current_iterator
+               if i != null then i.start
+       end
+
+       private var previous_iterator: nullable Iterator[E] = null
+
+       private fun current_iterator: nullable Iterator[E]
+       do
+               if previous_iterator == null then
+                       # Get the first sub-iterator.
+                       if inner.is_ok then
+                               previous_iterator = inner.item
+                               previous_iterator.start
+                               inner.next
+                       else
+                               return null
+                       end
+               end
+               # Get the first sub-iterator that has a current item.
+               while inner.is_ok and not previous_iterator.is_ok do
+                       previous_iterator.finish
+                       previous_iterator = inner.item
+                       previous_iterator.start
+                       inner.next
+               end
+               return previous_iterator
+       end
+end
+
 # Wraps an iterator to skip nulls.
 #
 # ~~~nit
index 1a22db6..e4da4a5 100644 (file)
@@ -464,12 +464,15 @@ redef class HttpResponse
        end
 
        # Redirect response to `location`
+       #
+       # Use by default 303 See Other as it is the RFC
+       # way to redirect web applications to a new URI.
        fun redirect(location: String, status: nullable Int) do
                header["Location"] = location
                if status != null then
                        status_code = status
                else
-                       status_code = 302
+                       status_code = 303
                end
                check_sent
                sent = true
index e23fb4b..ba3418f 100644 (file)
@@ -50,9 +50,9 @@ class ConsoleLog
        redef fun all(req, res) do
                var clock = req.clock
                if clock != null then
-                       log "{req.method} {req.uri} {status(res)} ({clock.total}s)"
+                       log "{req.method} {req.url} {status(res)} ({clock.total}s)"
                else
-                       log "{req.method} {req.uri} {status(res)}"
+                       log "{req.method} {req.url} {status(res)}"
                end
        end
 
diff --git a/lib/popcorn/pop_tracker.nit b/lib/popcorn/pop_tracker.nit
new file mode 100644 (file)
index 0000000..048509e
--- /dev/null
@@ -0,0 +1,238 @@
+# 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.
+
+# Popcorn web tracker
+#
+# Easy and ready to use web tracker you can apply to your popcorn application.
+#
+# The only thing you have to do is to use the tracker in your app routes:
+
+# ~~~nitish
+# var config = new AppConfig
+# var app = new App
+# app.use("/", new HomeHandler)
+# app.use("/products", new ProductsHandler)
+# app.use("customers/", new CustomersHandler)
+#
+# app.use_after("/*", new PopTracker(config)) # tracker listens to /*
+# ~~~
+#
+# You can also use multiple tracker at once on different route.
+# All the data will be aggregated for you.
+
+# ~~~nitish
+# app.use_after("/api/*", new PopTracker(config))
+# app.use_after("/admin/*", new PopTracker(config))
+# ~~~
+#
+# To retrieve your tracker data use the `PopTrackerAPI` which serves the tracker
+# data in JSON format.
+#
+# ~~~nitish
+# app.use("/api/tracker_data", new PopTrackerAPI(config))
+# ~~~
+module pop_tracker
+
+import popcorn
+import popcorn::pop_config
+import popcorn::pop_logging
+import popcorn::pop_repos
+
+redef class AppConfig
+
+       # Logs collection
+       var tracker_logs = new TrackerRepo(db.collection("tracker_logs"))
+
+       # Tracker handler
+       var tracker = new PopTracker(self)
+end
+
+# JSON API of the PopTracker
+#
+# Serves the collected data in JSON format.
+class PopTrackerAPI
+       super Router
+
+       # Config used to access tracker db
+       var config: AppConfig
+
+       init do
+               use("/entries", new PopTrackerEntries(config))
+               use("/queries", new PopTrackerQueries(config))
+               use("/browsers", new PopTrackerBrowsers(config))
+               use("/times", new PopTrackerResponseTime(config))
+       end
+end
+
+# Base tracker handler
+abstract class TrackerHandler
+       super Handler
+
+       # Config used to access tracker db
+       var config: AppConfig
+
+       # Get the `limit` GET argument from `req`
+       #
+       # Return `10` by default.
+       fun limit(req: HttpRequest): Int do return req.int_arg("limit") or else 10
+end
+
+# Saves logs into a MongoDB collection
+class PopTracker
+       super ConsoleLog
+       super TrackerHandler
+
+       redef fun all(req, res) do
+               config.tracker_logs.save new LogEntry(req, res)
+       end
+end
+
+# List all tracker log entries
+class PopTrackerEntries
+       super TrackerHandler
+
+       redef fun get(req, res) do
+               res.json new JsonArray.from(config.tracker_logs.find_all)
+       end
+end
+
+# Group and count entries by query string
+class PopTrackerQueries
+       super TrackerHandler
+
+       redef fun get(req, res) do
+               var pipe = new MongoPipeline
+               pipe.group((new MongoGroup("$request.uri")).
+                       sum("visits", 1).
+                       avg("response_time", "$response_time").
+                       addToSet("uniq", "$session"))
+               pipe.sort((new MongoMatch).eq("visits", -1))
+               pipe.limit(limit(req))
+               res.json new JsonArray.from(config.tracker_logs.collection.aggregate(pipe))
+       end
+end
+
+# Group and count entries by browser
+class PopTrackerBrowsers
+       super TrackerHandler
+
+       # MongoMatch query used for each browser key
+       #
+       # Because parsing user agent string is a pain in the nit, we go lazy on this
+       # one. We associate each broswer key like `Chromium` to the query that allows
+       # us to count the number of visits.
+       var browser_queries: HashMap[String, MongoMatch] do
+               var map = new HashMap[String, MongoMatch]
+               map["Chromium"] = (new MongoMatch).regex("user_agent", "Chromium")
+               map["Edge"] = (new MongoMatch).regex("user_agent", "Edge")
+               map["Firefox"] = (new MongoMatch).regex("user_agent", "Firefox")
+               map["IE"] = (new MongoMatch).regex("user_agent", "(MSIE)|(Trident)")
+               map["Netscape"] = (new MongoMatch).regex("user_agent", "Netscape")
+               map["Opera"] = (new MongoMatch).regex("user_agent", "Opera")
+               map["Safari"] = (new MongoMatch).land(null, [
+                               (new MongoMatch).regex("user_agent", "Safari"),
+                               (new MongoMatch).regex("user_agent", "^((?!Chrome).)*$")])
+               map["Chrome"] = (new MongoMatch).land(null, [
+                               (new MongoMatch).regex("user_agent", "Chrome"),
+                               (new MongoMatch).regex("user_agent", "^((?!Edge).)*$")])
+
+               return map
+       end
+
+       # Apply the `query` on `TrackerRepo::count`
+       fun browser_count(query: MongoMatch): Int do return config.tracker_logs.count(query)
+
+       redef fun get(req, res) do
+               var browsers = new Array[BrowserCount]
+               for browser, query in self.browser_queries do
+                       var count = new BrowserCount(browser, browser_count(query))
+                       if count.count > 0 then browsers.add count
+               end
+               var sum = 0
+               for browser in browsers do sum += browser.count
+               var other = config.tracker_logs.count - sum
+               if other > 0 then browsers.add new BrowserCount("Other", other)
+               default_comparator.sort(browsers)
+               res.json new JsonArray.from(browsers)
+       end
+end
+
+# Associate each browser to its count.
+#
+# Only used to serialize the results.
+private class BrowserCount
+       super Comparable
+       super RepoObject
+       serialize
+
+       redef type OTHER: SELF
+
+       var browser: String
+       var count: Int
+
+       redef fun <=>(o) do return o.count <=> count
+end
+
+# Return last month response time
+class PopTrackerResponseTime
+       super TrackerHandler
+
+       redef fun get(req, res) do
+               var limit = get_time - (3600 * 24 * 30)
+               var pipe = new MongoPipeline
+               pipe.match((new MongoMatch).gte("timestamp", limit))
+               pipe.group((new MongoGroup("$timestamp")).
+                       sum("visits", 1).
+                       avg("response_time", "$response_time"))
+               pipe.sort((new MongoMatch).eq("_id", -1))
+               res.json new JsonArray.from(config.tracker_logs.collection.aggregate(pipe))
+       end
+end
+
+# A tracker log entry used to store HTTP requests and their given HTTP responses
+class LogEntry
+       super RepoObject
+       serialize
+
+       # HTTP request that triggered that log entry
+       var request: HttpRequest
+
+       # HTTP response returned by the serveur
+       var response: HttpResponse
+
+       # Request user-agent shortcut (easier for db requests
+       var user_agent: nullable String is lazy do return request.header["User-Agent"]
+
+       # Processing time in miliseconds (or null if no clock was found in request)
+       var response_time: nullable Int is lazy do
+               var clock = request.clock
+               if clock == null then return null
+               return (clock.total * 1000.0).to_i
+       end
+
+       # Log entry timestamp
+       var timestamp: Int = get_time
+
+       # Session ID associated to this entry
+       var session: nullable String is lazy do
+               var session = request.session
+               if session == null then return null
+               return session.id_hash
+       end
+end
+
+# Repository to store track logs.
+class TrackerRepo
+       super MongoRepository[LogEntry]
+end
index 73c5e0a..e11b765 100644 (file)
@@ -56,7 +56,7 @@ import trees
 # assert trie["foo"] == 1
 #
 # # Search by prefix
-# assert trie.find_by_prefix("") == [1, 2, 3]
+# assert trie.find_by_prefix("") == [1, 3, 2]
 # assert trie.find_by_prefix("foo") == [1, 2]
 # assert trie.find_by_prefix("baz").is_empty
 # ~~~
@@ -124,23 +124,20 @@ class Trie[E]
        # trie["foooo"] = 3
        # trie["bar"] = 4
        #
-       # assert trie.find_by_prefix("") == [1, 2, 3, 4]
+       # assert trie.find_by_prefix("") == [1, 4, 2, 3]
        # assert trie.find_by_prefix("foo") == [1, 2, 3]
        # assert trie.find_by_prefix("bar") == [4]
        # assert trie.find_by_prefix("baz").is_empty
        # ~~~
        fun find_by_prefix(prefix: String): Array[E] do
-               var res = new Array[E]
                var node
                if prefix == "" then
                        node = root
                else
                        node = search_node(prefix)
                end
-               if node != null then
-                       node.collect_values(res)
-               end
-               return res
+               if node == null then return new Array[E]
+               return node.collect_values
        end
 
        # Find values stored under `prefix`
@@ -190,11 +187,19 @@ private class TrieNode[E]
        var children = new HashMap[Char, TrieNode[E]]
        var is_leaf: Bool = false
 
-       fun collect_values(values: Array[E]) do
-               var value = self.value
-               if value != null then values.add value
-               for child in children.values do
-                       child.collect_values(values)
+       fun collect_values: Array[E] do
+               var values = new Array[E]
+
+               var todo = new List[TrieNode[E]]
+               todo.add self
+               while todo.not_empty do
+                       var node = todo.shift
+                       var value = node.value
+                       if value != null then values.add value
+                       for child in node.children.values do
+                               todo.push child
+                       end
                end
+               return values
        end
 end
index 21df4bf..6cac103 100644 (file)
@@ -54,7 +54,7 @@
                                controllerAs: 'entityCtrl'
                        })
                        .otherwise({
-                               redirectTo: '/'
+                               templateUrl: 'views/error.html'
                        });
                $locationProvider.html5Mode(true);
        });
diff --git a/share/nitweb/views/error.html b/share/nitweb/views/error.html
new file mode 100644 (file)
index 0000000..0b5252f
--- /dev/null
@@ -0,0 +1,6 @@
+<div class='container-fluid'>
+       <div class='page-header'>
+               <h2>404 Not found</h2>
+               <p>The page you requested does not exist.</p>
+       </div>
+</div>
index 56fbc74..e8cb281 100644 (file)
@@ -22,6 +22,7 @@ import semantize
 import doc_commands
 import doc_poset
 import doc::console_templates
+import model::model_index
 
 # Nitx handles console I/O.
 #
@@ -95,7 +96,11 @@ class Nitx
                        return
                end
                var res = query.perform(self, doc)
-               var page = query.make_results(self, res)
+               var suggest = null
+               if res.is_empty then
+                       suggest = query.suggest(self, doc)
+               end
+               var page = query.make_results(self, res, suggest)
                print page.write_to_string
        end
 end
@@ -118,12 +123,44 @@ redef interface DocCommand
        # Looks up the `doc` model and returns possible matches.
        fun perform(nitx: Nitx, doc: DocModel): Array[NitxMatch] is abstract
 
+       # Looks up the `doc` model and returns possible suggestions.
+       fun suggest(nitx: Nitx, doc: DocModel): nullable Array[MEntity] do
+               return find_suggestions(doc, args.first)
+       end
+
        # Pretty prints the results for the console.
-       fun make_results(nitx: Nitx, results: Array[NitxMatch]): DocPage do
+       fun make_results(nitx: Nitx, results: Array[NitxMatch], suggest: nullable Array[MEntity]): DocPage do
                var page = new DocPage("results", "Results")
-               page.root.add_child(new QueryResultArticle("results", "Results", self, results))
+               page.root.add_child(new QueryResultArticle("results", "Results", self, results, suggest))
                return page
        end
+
+       # Lookup mentities based on a `query` string.
+       #
+       # 1- lookup by first name (returns always one value)
+       # 2- lookup by name (can return conflicts)
+       fun find_mentities(doc: DocModel, query: String): Array[MEntityMatch] do
+               var res = new Array[MEntityMatch]
+
+               # First lookup by full_name
+               var mentity = doc.mentity_by_full_name(query)
+               if mentity != null then
+                       res.add new MEntityMatch(self, mentity)
+                       return res
+               end
+
+               # If no results, lookup by name
+               for m in doc.mentities_by_name(query) do
+                       res.add new MEntityMatch(self, m)
+               end
+
+               return res
+       end
+
+       # Suggest some mentities based on a `query` string.
+       fun find_suggestions(doc: DocModel, query: String): Array[MEntity] do
+               return doc.find(query, 3)
+       end
 end
 
 # Something that matches a `DocCommand`.
@@ -147,16 +184,9 @@ class MEntityMatch
 end
 
 redef class CommentCommand
-       redef fun perform(nitx, doc) do
-               var name = args.first
-               var res = new Array[NitxMatch]
-               for mentity in doc.mentities_by_name(name) do
-                       res.add new MEntityMatch(self, mentity)
-               end
-               return res
-       end
+       redef fun perform(nitx, doc) do return find_mentities(doc, args.first)
 
-       redef fun make_results(nitx, results) do
+       redef fun make_results(nitx, results, suggest) do
                var len = results.length
                if len == 1 then
                        var res = results.first.as(MEntityMatch)
@@ -266,7 +296,7 @@ redef class ArticleCommand
                return res
        end
 
-       redef fun make_results(nitx, results) do
+       redef fun make_results(nitx, results, suggest) do
                var len = results.length
                # FIXME how to render the pager for one worded namespaces like "core"?
                if len == 1 then
@@ -304,7 +334,7 @@ end
 abstract class HierarchiesQuery
        super DocCommand
 
-       redef fun make_results(nitx, results) do
+       redef fun make_results(nitx, results, suggest) do
                var page = new DocPage("hierarchy", "Hierarchy")
                for result in results do
                        if not result isa PageMatch then continue
@@ -382,15 +412,15 @@ redef class CodeCommand
                        return res
                end
                # else, lookup the model by name
-               for mentity in doc.mentities_by_name(name) do
-                       if mentity isa MClass then continue
-                       if mentity isa MProperty then continue
-                       res.add new CodeMatch(self, mentity.cs_location, mentity.cs_source_code)
+               for match in find_mentities(doc, name) do
+                       if match.mentity isa MClass then continue
+                       if match.mentity isa MProperty then continue
+                       res.add new CodeMatch(self, match.mentity.cs_location, match.mentity.cs_source_code)
                end
                return res
        end
 
-       redef fun make_results(nitx, results) do
+       redef fun make_results(nitx, results, suggest) do
                var page = new DocPage("results", "Code Results")
                for res in results do
                        page.add new CodeQueryArticle("results", "Results", self, res.as(CodeMatch))
@@ -487,10 +517,25 @@ private class QueryResultArticle
        # Results to display.
        var results: Array[NitxMatch]
 
+       # Optional suggestion when no matches where found
+       var suggest: nullable Array[MEntity] = null is optional
+
        redef fun render_title do
                var len = results.length
                if len == 0 then
-                       add "No result found for '{query.string}'..."
+                       addn "No result found for '{query.string}'..."
+                       var suggest = self.suggest
+                       if suggest != null and suggest.not_empty then
+                               add "\nDid you mean "
+                               var i = 0
+                               for s in suggest do
+                                       add "`{s.full_name}`"
+                                       if i == suggest.length - 2 then add ", "
+                                       if i == suggest.length - 1 then add " or "
+                                       i += 1
+                               end
+                               add "?"
+                       end
                else
                        add "# {len} result(s) for '{query.string}'".green.bold
                end
index b34fc7f..c026b77 100644 (file)
@@ -20,6 +20,7 @@ intrude import markdown::wikilinks
 import doc_commands
 import doc_down
 import doc_intros_redefs
+import model::model_index
 
 # Generate content of `ReadmePage`.
 #
@@ -146,6 +147,37 @@ class ReadmeMdEmitter
                context.pop
                pop_buffer
        end
+
+       # Find mentities matching `query`.
+       fun find_mentities(query: String): Array[MEntity] do
+               # search MEntities by full_name
+               var mentity = phase.doc.mentity_by_full_name(query)
+               if mentity != null then return [mentity]
+               # search MEntities by name
+               return phase.doc.mentities_by_name(query)
+       end
+
+       # Suggest mentities based on `query`.
+       fun suggest_mentities(query: String): Array[MEntity] do
+               return phase.doc.find(query, 3)
+       end
+
+       # Display a warning message with suggestions.
+       fun warn(token: TokenWikiLink, message: String, suggest: nullable Array[MEntity]) do
+               var msg = new Buffer
+               msg.append message
+               if suggest != null and suggest.not_empty then
+                       msg.append " (suggestions: "
+                       var i = 0
+                       for s in suggest do
+                               msg.append "`{s.full_name}`"
+                               if i < suggest.length - 1 then msg.append ", "
+                               i += 1
+                       end
+                       msg.append ")"
+               end
+               phase.warning(token.location, page, msg.write_to_string)
+       end
 end
 
 # MarkdownDecorator used to decorated the Readme file with links between doc entities.
@@ -173,10 +205,10 @@ class ReadmeDecorator
                var cmd = new DocCommand(link)
                if cmd isa UnknownCommand then
                        # search MEntities by name
-                       var res = v.phase.doc.mentities_by_name(link.to_s)
+                       var res = v.find_mentities(link.to_s)
                        # no match, print warning and display wikilink as is
                        if res.is_empty then
-                               v.phase.warning(token.location, v.page, "Link to unknown entity `{link}`")
+                               v.warn(token, "Link to unknown entity `{link}`", v.suggest_mentities(link.to_s))
                                super
                        else
                                add_mentity_link(v, res.first, token.name, token.comment)
@@ -202,57 +234,34 @@ redef interface DocCommand
 
        # Render the content of the doc command.
        fun render(v: ReadmeMdEmitter, token: TokenWikiLink) is abstract
-
-       # Search `doc` model for mentities match `string`.
-       fun search_model(doc: DocModel, string: String): Array[MEntity] do
-               var res
-               if string.has("::") then
-                       res = doc.mentities_by_namespace(string).to_a
-               else
-                       res = doc.mentities_by_name(string).to_a
-               end
-               return res
-       end
 end
 
 redef class ArticleCommand
        redef fun render(v, token) do
                var string = args.first
-               var res = search_model(v.phase.doc, string)
-               res = filter_results(res)
+               var res = v.find_mentities(string)
                if res.is_empty then
-                       v.phase.warning(
-                               token.location, v.page,
-                               "Try to include documentation of unknown entity `{args.first}`")
+                       v.warn(token,
+                               "Try to include documentation of unknown entity `{string}`",
+                               v.suggest_mentities(string))
                        return
                end
-               if res.length > 1 then
-                       v.phase.warning(token.location, v.page, "conflicting article for `{args.first}` (choices : {res.join(", ")})")
-               end
                v.add_article new DocumentationArticle("readme", "Readme", res.first)
        end
-
-       private fun filter_results(res: Array[MEntity]): Array[MEntity] do
-               var out = new Array[MEntity]
-               for e in res do
-                       if e isa MPackage then continue
-                       if e isa MGroup then continue
-                       out.add e
-               end
-               return out
-       end
 end
 
 redef class ListCommand
        redef fun render(v, token) do
                var string = args.first
-               var res = search_model(v.phase.doc, string)
+               var res = v.find_mentities(string)
                if res.is_empty then
-                       v.phase.warning(token.location, v.page, "include article for unknown entity `{args.first}`")
+                       v.warn(token,
+                               "Try to include article of unknown entity `{string}`",
+                               v.suggest_mentities(string))
                        return
                end
                if res.length > 1 then
-                       v.phase.warning(token.location, v.page, "conflicting article for `{args.first}` (choices : {res.join(", ")})")
+                       v.warn(token, "Conflicting article for `{args.first}`", res)
                end
                var mentity = res.first
                if mentity isa MModule then
diff --git a/src/model/model_index.nit b/src/model/model_index.nit
new file mode 100644 (file)
index 0000000..4136243
--- /dev/null
@@ -0,0 +1,644 @@
+# 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.
+
+# Search things from the Model
+#
+# ModelIndex allows you to index mentities then retrieve them by their `name`
+# or `full_name`.
+# It offers a set of `find_*` methods that can be used to match queries
+# against entities name.
+#
+# Because each use is different, ModelIndex only provide base raw search services.
+# All of them return IndexMatches that can be ordered and filtered by the client.
+#
+# ## Indexing mentities
+#
+# Before searching something from the ModelIndex, you have to index it.
+# Use the `ModelIndex::index` method to do that:
+#
+# ~~~nitish
+# var index = new ModelIndex
+#
+# for mentity in model.private_view.mentities do
+#      index.index(mentity)
+# end
+# ~~~
+#
+# ## Search mentities
+#
+# You can then run queries on the ModelIndex:
+#
+# ~~~nitish
+# for res in index.find("Array").limit(10) do
+#    print res
+# end
+# ~~~
+#
+# ## Examples
+#
+# Here some examples of how you can use the ModelIndex.
+#
+# ### Smart type prediction
+#
+# Use ModelIndex to implement a smart prediction system based on the typed prefix:
+#
+# ~~~nitish
+# var index = new ModelIndex
+#
+# for mentity in model.private_view.mentities do
+#      # We don't really care about definitions
+#      if mentity isa MClassDef or mentity isa MPropDef then continue
+#      index.index(mentity)
+# end
+#
+# var typed_prefix = "Arr"
+# for res in index.find_by_name_prefix(typed_prefix).
+#      uniq. # keep only the best ranked mentity
+#      limit(5). # limit to ten results
+#      sort(new VisibilityComparator, new NameComparator) do # order by visibility then name
+#    print res
+# end
+# ~~~
+#
+# Will output something like:
+#
+# ~~~raw
+# Array (1)
+# ArraySet (2)
+# ArrayMap (3)
+# ArrayCmp (4)
+# ArrayMapKeys (5)
+# ~~~
+#
+# ### Method autocompletion
+#
+# Find methods from a class full_name:
+#
+# ~~~nitish
+# var class_full_name = "core::Array"
+# for res in index.find_by_full_name_prefix("{class_full_name}::").
+#      uniq. # keep only the best ranked mentity
+#      sort(new VisibilityComparator). # put private in the bottom of the list
+#      limit(5). # limit to ten results
+#      sort(new FullNameComparator) do # order by lexicographic order
+#    print res
+# end
+# ~~~
+#
+# Will output something like:
+#
+# ~~~raw
+# * (2)
+# + (1)
+# filled_with (5)
+# from (3)
+# with_items (4)
+# ~~~
+#
+# ### Name typo detection and suggestion
+#
+# Detect unknown names and suggest corrections:
+#
+# ~~~nitish
+# var name = "Zrray"
+#
+# if index.find_by_name_prefix(name).is_empty then
+#      printn "`{name}` not found, did you mean: "
+#      printn index.find_by_name_similarity(name).sort(new ScoreComparator).limit(2).join(" or ")
+#      print "?"
+# end
+# ~~~
+#
+# Will output something like:
+#
+# ~~~raw
+# `Zrray` not found, did you mean: Array (1) or array (1)?
+# ~~~
+module model_index
+
+import model::model_views
+import trees::trie
+
+redef class ModelView
+
+       # Keep a direct link to mentities by full name to speed up `mentity_from_uri`
+       var mentities_by_full_name: HashMap[String, MEntity] is lazy do
+               var mentities_by_full_name = new HashMap[String, MEntity]
+               for mentity in model.private_view.mentities do
+                       mentities_by_full_name[mentity.full_name] = mentity
+               end
+               return mentities_by_full_name
+       end
+
+       # ModelIndex used to perform searches
+       var index: ModelIndex is lazy do
+               var index = new ModelIndex
+               for mentity in model.private_view.mentities do
+                       if mentity isa MClassDef or mentity isa MPropDef then continue
+                       index.index mentity
+               end
+               return index
+       end
+
+       # Find mentities by their `name`
+       fun mentities_by_name(name: String): Array[MEntity] do
+               if index.name_prefixes.has_key(name) then
+                       return index.name_prefixes[name]
+               end
+               return new Array[MEntity]
+       end
+
+       redef fun mentity_by_full_name(full_name) do
+               if mentities_by_full_name.has_key(full_name) then
+                       return mentities_by_full_name[full_name]
+               end
+               return null
+       end
+
+       private var score_sorter = new ScoreComparator
+       private var vis_sorter = new VisibilityComparator
+       private var kind_sorter = new MEntityComparator
+       private var name_sorter = new NameComparator
+       private var lname_sorter = new NameLengthComparator
+       private var fname_sorter = new FullNameComparator
+       private var lfname_sorter = new FullNameLengthComparator
+
+       # Search mentities based on a `query` string
+       #
+       # Lookup the view index for anything matching `query` and return `limit` results.
+       #
+       # The algorithm used is the following:
+       # 1- lookup by name prefix
+       # 2- lookup by full_name prefix
+       # 3- loopup by levenshtein distance
+       #
+       # At each step if the `limit` is reached, the algorithm stops and returns the results.
+       fun find(query: String, limit: nullable Int): Array[MEntity] do
+               # Find, lookup by name prefix
+               var matches = index.find_by_name_prefix(query).uniq.
+                       sort(lname_sorter, name_sorter, kind_sorter)
+               if limit != null and matches.length >= limit then
+                       return matches.limit(limit).rerank.sort(vis_sorter, score_sorter).mentities
+               end
+               matches = matches.rerank.sort(vis_sorter, score_sorter)
+
+               # If limit not reached, lookup by full_name prefix
+               var malus = matches.length
+               var full_matches = new IndexMatches
+               for match in index.find_by_full_name_prefix(query).
+                       sort(kind_sorter, lfname_sorter, fname_sorter) do
+                       match.score += malus
+                       full_matches.add match
+               end
+               matches = matches.uniq
+               if limit != null and matches.length + full_matches.length >= limit then
+                       matches.add_all full_matches
+                       matches = matches.uniq.limit(limit).rerank.sort(vis_sorter, score_sorter)
+                       return matches.mentities
+               end
+
+               # If limit not reached, lookup by similarity
+               malus = matches.length
+               var sim_matches = new IndexMatches
+               for match in index.find_by_similarity(query).sort(score_sorter, kind_sorter, lname_sorter, name_sorter) do
+                       match.score += malus
+                       sim_matches.add match
+               end
+               matches.add_all sim_matches
+               matches = matches.uniq
+               if limit != null then matches = matches.limit(limit)
+               return matches.rerank.sort(vis_sorter, score_sorter).mentities
+       end
+end
+
+# ModelIndex indexes mentities by their name and full name
+#
+# It provides methods to find mentities based on a prefix or string similarity.
+#
+# ~~~nitish
+# # Build index
+# var index = new ModelIndex
+# for mentity in model.private_view.mentities do
+#      if mentity isa MClassDef or mentity isa MPropDef then continue
+#      index.index(mentity)
+# end
+#
+# for e in index.find("Foo").uniq.sort(new ScoreComparator).limit(10) do
+#      print " * {e.score}: {e.mentity.name} ({e.mentity.full_name})"
+# end
+# ~~~
+class ModelIndex
+
+       # List of all indexed mentities.
+       #
+       # Faster than traversing the tries.
+       var mentities = new Array[MEntity]
+
+       # Prefix tree for mentities `name`
+       #
+       # Because multiple mentities can share the same `name`, we use a Trie of
+       # arrays of mentities.
+       #
+       # As for now, we do not index class and property definitions.
+       # TODO add an option.
+       var name_prefixes = new Trie[Array[MEntity]]
+
+       # Prefix tree for mentities `full_name`
+       #
+       # Even if two mentities cannot share the same `full_name`, we use a Trie of
+       # arrays of mentities to be consistent with `name_prefixes`.
+       var full_name_prefixes = new Trie[Array[MEntity]]
+
+       # Index `mentity` by it's `MEntity::name`
+       #
+       # See `name_prefixes`.
+       private fun index_by_name(mentity: MEntity) do
+               var name = mentity.name
+               if not name_prefixes.has_key(name) then
+                       name_prefixes[name] = new Array[MEntity]
+               end
+               name_prefixes[name].add mentity
+       end
+
+       # Index `mentity` by its `MEntity::full_name`
+       private fun index_by_full_name(mentity: MEntity) do
+               var name = mentity.full_name
+               if not full_name_prefixes.has_key(name) then
+                       full_name_prefixes[name] = new Array[MEntity]
+               end
+               full_name_prefixes[name].add mentity
+       end
+
+       # Index `mentity` so it can be retrieved by a find query
+       #
+       # MEntities are indexed by both name and full_name.
+       fun index(mentity: MEntity) do
+               mentities.add mentity
+               index_by_name mentity
+               index_by_full_name mentity
+       end
+
+       # Translate Trie results to `SearchResult`
+       #
+       # This method is used internally to translate each mentity returned by a prefix
+       # match in a Trie into a SearchResult that can be ranked by score.
+       #
+       # Results from the Trie are returned in a breadth first manner so we get the
+       # matches ordered by prefix.
+       # We preserve that order by giving an incremental score to the `array` items.
+       private fun score_results_incremental(array: Array[Array[MEntity]]): IndexMatches do
+               var results = new IndexMatches
+               var score = 1
+               for mentities in array do
+                       for mentity in mentities do
+                               results.add new IndexMatch(mentity, score)
+                       end
+                       score += 1
+               end
+               return results
+       end
+
+       # Find all mentities where `MEntity::name` matches the `prefix`
+       fun find_by_name_prefix(prefix: String): IndexMatches do
+               return score_results_incremental(name_prefixes.find_by_prefix(prefix))
+       end
+
+       # Find all mentities where `MEntity::full_name` matches the `prefix`
+       fun find_by_full_name_prefix(prefix: String): IndexMatches do
+               return score_results_incremental(full_name_prefixes.find_by_prefix(prefix))
+       end
+
+       # Rank all mentities by the distance between `MEntity::name` and `name`
+       #
+       # Use the Levenshtein algorithm on all the indexed mentities `name`.
+       # Warning: may not scale to large indexes.
+       fun find_by_name_similarity(name: String): IndexMatches do
+               var results = new IndexMatches
+               for mentity in mentities do
+                       if mentity isa MClassDef or mentity isa MPropDef then continue
+                       results.add new IndexMatch(mentity, name.levenshtein_distance(mentity.name))
+               end
+               return results
+       end
+
+       # Rank all mentities by the distance between `MEntity::full_name` and `full_name`
+       #
+       # Use the Levenshtein algorithm on all the indexed mentities `full_name`.
+       # Warning: may not scale to large indexes.
+       fun find_by_full_name_similarity(name: String): IndexMatches do
+               var results = new IndexMatches
+               for mentity in mentities do
+                       if mentity isa MClassDef or mentity isa MPropDef then continue
+                       results.add new IndexMatch(mentity, name.levenshtein_distance(mentity.full_name))
+               end
+               return results
+       end
+
+       # Rank all mentities by the distance between `name` and both the mentity name and full name
+       fun find_by_similarity(name: String): IndexMatches do
+               var results = new IndexMatches
+               for mentity in mentities do
+                       if mentity isa MClassDef or mentity isa MPropDef then continue
+                       results.add new IndexMatch(mentity, name.levenshtein_distance(mentity.name))
+                       results.add new IndexMatch(mentity, name.levenshtein_distance(mentity.full_name))
+               end
+               return results
+       end
+
+       # Find mentities by name trying first by prefix then by similarity
+       fun find_by_name(name: String): IndexMatches do
+               var results = find_by_name_prefix(name)
+               for mentity in mentities do
+                       if mentity isa MClassDef or mentity isa MPropDef then continue
+                       results.add new IndexMatch(mentity, name.levenshtein_distance(mentity.name))
+               end
+               return results
+       end
+
+       # Find mentities by full name trying firt by prefix then by similarity
+       fun find_by_full_name(name: String): IndexMatches do
+               var results = find_by_full_name_prefix(name)
+               for mentity in mentities do
+                       if mentity isa MClassDef or mentity isa MPropDef then continue
+                       results.add new IndexMatch(mentity, name.levenshtein_distance(mentity.full_name))
+               end
+               return results
+       end
+
+       # Find all mentities that matches `name`
+       #
+       # 1. try by name prefix
+       # 2. add full name prefix matches
+       # 3. try similarity by name
+       # 4. try similarity by full_name
+       fun find(name: String): IndexMatches do
+               var results = find_by_name_prefix(name)
+
+               for result in find_by_full_name_prefix(name) do
+                       results.add result
+               end
+
+               for mentity in mentities do
+                       if mentity isa MClassDef or mentity isa MPropDef then continue
+                       results.add new IndexMatch(mentity, name.levenshtein_distance(mentity.name))
+                       results.add new IndexMatch(mentity, name.levenshtein_distance(mentity.full_name))
+               end
+               return results
+       end
+end
+
+# An array of IndexMatch instances returned by the ModelIndex
+#
+# Index matches can be sorted, filtered and truncated.
+#
+# Thanks to the fluent interface, the index matches can be manipulated in chain
+# from a model index query:
+#
+# ~~~nitish
+# var res = index.find("Foo").
+#   uniq.
+#      sort(new ScoreComparator, new MEntityComparator).
+#      limit(10).
+#      sort(new VisibilityComparator)
+# ~~~
+class IndexMatches
+       super Array[IndexMatch]
+
+       # Create a new ModelMatches from an array of matches
+       #
+       # Elements are copied.
+       init from_matches(matches: Array[IndexMatch]) do self.add_all matches
+
+       # Sort the matches with `comparator` (or a list of comparators)
+       #
+       # Return a new IndexMatches instance with the sorted results.
+       #
+       # When more than one comparator is given, the comparators are applied in a
+       # pipeline where the `n`th comparator is applied only if the `n-1`th comparator
+       # returned 0.
+       fun sort(comparator: ScoreComparator...): IndexMatches do
+               var res = to_a
+               if comparator.length == 1 then
+                       comparator.first.sort res
+               else
+                       var comparators = new MatchComparators(comparator)
+                       comparators.sort res
+               end
+               return new IndexMatches.from_matches(res)
+       end
+
+       # Limit the matches with `limit`
+       #
+       # Return a new IndexMatches instance with only the `limit` first matches.
+       fun limit(limit: Int): IndexMatches do
+               var res = new Array[IndexMatch]
+               for match in self do
+                       if res.length >= limit then break
+                       res.add match
+               end
+               return new IndexMatches.from_matches(res)
+       end
+
+       # Remove doublons from the matches
+       #
+       # Preverse the lowest score of all the matches for a MEntity.
+       fun uniq: IndexMatches do
+               var scores = new HashMap[MEntity, IndexMatch]
+               var res = new Array[IndexMatch]
+               for match in self do
+                       var mentity = match.mentity
+                       if scores.has_key(mentity) then
+                               var older = scores[mentity]
+                               if match.score < older.score then older.score = match.score
+                       else
+                               scores[mentity] = match
+                               res.add match
+                       end
+               end
+               return new IndexMatches.from_matches(res)
+       end
+
+       # Reset score of each matches to follow `self` order
+       #
+       # Usefull when you need to apply one sorter over another.
+       fun rerank: IndexMatches do
+               var res = new IndexMatches
+               for match in self do
+                       res.add match
+                       match.score = res.length
+               end
+               return res
+       end
+
+       # Aggregate the mentities for all the matches
+       #
+       # Preserve the match order.
+       fun mentities: Array[MEntity] do
+               var res = new Array[MEntity]
+               for match in self do res.add match.mentity
+               return res
+       end
+end
+
+# An MEntity matched from a ModelIndex
+#
+# Each match has a `score`. The score should be seen as the distance of
+# the match from the query. In other words, the lowest is the score, the more
+# relevant is the match.
+class IndexMatch
+       super Comparable
+
+       redef type OTHER: IndexMatch
+
+       # MEntity matches
+       var mentity: MEntity
+
+       # Score allocated by the search method
+       #
+       # A lowest score means a more relevant match.
+       #
+       # Scores values are arbitrary, the meaning of `10` vs `2000` really depends
+       # on the search method producing the match and the comparators used to sort
+       # the matches.
+       # The only universal rule is: low score = relevance.
+       var score: Int is writable
+
+       # By default matches are compared only on their score
+       redef fun <=>(o) do return score <=> o.score
+
+       redef fun to_s do return "{mentity} ({score})"
+end
+
+# Compare two matches by their score
+#
+# Since the score can be seen as a distance, we want the lowest score first.
+class ScoreComparator
+       super Comparator
+
+       redef type COMPARED: IndexMatch
+
+       redef fun compare(o1, o2) do return o1.score <=> o2.score
+end
+
+# A pipeline of comparators executed in inclusion order
+#
+# This class is used internally to agregate the behaviors of multiple comparators.
+# Use `IndexMatches::sort(comparator...)` instead.
+private class MatchComparators
+       super ScoreComparator
+
+       # Comparator to use in the array order
+       var comparators: Array[ScoreComparator]
+
+       # Compare with each comparator
+       #
+       # Return the compare value of the first one to return anything else than 0.
+       redef fun compare(o1, o2) do
+               for comparator in comparators do
+                       var c = comparator.compare(o1, o2)
+                       if c != 0 then return c
+               end
+               return 0
+       end
+end
+
+# Compare two matches by their MEntity kind
+#
+# Usefull to order the mentities by kind in this order:
+# packages, groups, modules and classes, properties.
+class MEntityComparator
+       super ScoreComparator
+
+       # See `MEntity::compare_mentity`
+       redef fun compare(o1, o2) do
+               return o1.mentity.mentity_kind_rank <=> o2.mentity.mentity_kind_rank
+       end
+end
+
+# Compare two matches by their MEntity visibility
+#
+# We reverse the original order so private is at the end of the list.
+class VisibilityComparator
+       super ScoreComparator
+
+       redef fun compare(o1, o2) do return o2.mentity.visibility <=> o1.mentity.visibility
+end
+
+# Compare two matches by their name in lexicographic order
+#
+# Generally, for a same score, we want to put A before Z.
+class NameComparator
+       super ScoreComparator
+
+       redef fun compare(o1, o2) do return o1.mentity.name <=> o2.mentity.name
+end
+
+# Compare two matches by their name length
+class NameLengthComparator
+       super ScoreComparator
+
+       redef fun compare(o1, o2) do return o1.mentity.name.length <=> o2.mentity.name.length
+end
+
+# Compare two matches by their full_name in lexicographic order
+#
+# Generally, for a same score, we want to put A before Z.
+class FullNameComparator
+       super ScoreComparator
+
+       redef fun compare(o1, o2) do return o1.mentity.full_name <=> o2.mentity.full_name
+end
+
+# Compare two matches by their full name length
+class FullNameLengthComparator
+       super ScoreComparator
+
+       redef fun compare(o1, o2) do return o1.mentity.full_name.length <=> o2.mentity.full_name.length
+end
+
+redef class MEntity
+
+       # Compare MEntity class kind
+       #
+       # Unknown kind have a virtually high score.
+       private fun mentity_kind_rank: Int do return 10
+end
+
+redef class MPackage
+       redef fun mentity_kind_rank do return 1
+end
+
+redef class MGroup
+       redef fun mentity_kind_rank do return 2
+end
+
+redef class MModule
+       redef fun mentity_kind_rank do return 3
+end
+
+redef class MClass
+       redef fun mentity_kind_rank do return 4
+end
+
+redef class MClassDef
+       redef fun mentity_kind_rank do return 5
+end
+
+redef class MProperty
+       redef fun mentity_kind_rank do return 6
+end
+
+redef class MPropDef
+       redef fun mentity_kind_rank do return 7
+end
index 566a5bd..2eec952 100644 (file)
@@ -125,13 +125,6 @@ class ModelView
                v.include_test_suite = self.include_test_suite
        end
 
-       # Searches MEntities that match `name`.
-       fun mentities_by_name(name: String): Array[MEntity] do
-               var res = new Array[MEntity]
-               for mentity in mentities do if mentity.name == name then res.add mentity
-               return res
-       end
-
        # Searches the MEntity that matches `full_name`.
        fun mentity_by_full_name(full_name: String): nullable MEntity do
                for mentity in mentities do
@@ -140,20 +133,6 @@ class ModelView
                return null
        end
 
-       # Looks up a MEntity by its full `namespace`.
-       #
-       # Usefull when `mentities_by_name` returns conflicts.
-       #
-       # Namespaces must be of the form `package::core::module::Class::prop`.
-       fun mentities_by_namespace(namespace: String): Array[MEntity] do
-               var v = new LookupNamespaceVisitor(namespace)
-               init_visitor(v)
-               for mpackage in mpackages do
-                       v.enter_visit(mpackage)
-               end
-               return v.results
-       end
-
        # Build an concerns tree with from `self`
        fun to_tree: MEntityTree do
                var v = new ModelTreeVisitor
index d0059fe..9465dbb 100644 (file)
@@ -80,7 +80,6 @@ redef class ModelBuilder
        end
 
        # Build the properties of `nclassdef`.
-       # REQUIRE: all superclasses are built.
        private fun build_properties(nclassdef: AClassdef)
        do
                # Force building recursively
index f58b916..fe8fb7b 100644 (file)
 # see `testing/README`
 module nitunit
 
+import frontend
 import testing
 
 var toolcontext = new ToolContext
+toolcontext.keep_going = true
 
 toolcontext.option_context.add_option(toolcontext.opt_full, toolcontext.opt_output, toolcontext.opt_dir, toolcontext.opt_noact, toolcontext.opt_pattern, toolcontext.opt_autosav, toolcontext.opt_gen_unit, toolcontext.opt_gen_force, toolcontext.opt_gen_private, toolcontext.opt_gen_show, toolcontext.opt_nitc)
 toolcontext.tooldescription = "Usage: nitunit [OPTION]... <file.nit>...\nExecutes the unit tests from Nit source files."
index 9484425..071a2af 100644 (file)
@@ -77,38 +77,15 @@ private class NitwebPhase
                return config
        end
 
-       # Build the nit catalog used in homepage.
-       fun build_catalog(model: Model, modelbuilder: ModelBuilder): Catalog do
-               var catalog = new Catalog(modelbuilder)
-               for mpackage in model.mpackages do
-                       catalog.deps.add_node(mpackage)
-                       for mgroup in mpackage.mgroups do
-                               for mmodule in mgroup.mmodules do
-                                       for imported in mmodule.in_importation.direct_greaters do
-                                               var ip = imported.mpackage
-                                               if ip == null or ip == mpackage then continue
-                                               catalog.deps.add_edge(mpackage, ip)
-                                       end
-                               end
-                       end
-                       catalog.git_info(mpackage)
-                       catalog.package_page(mpackage)
-               end
-               return catalog
-       end
-
        redef fun process_mainmodule(mainmodule, mmodules)
        do
-               var model = mainmodule.model
-               var modelbuilder = toolcontext.modelbuilder
                var config = build_config(toolcontext, mainmodule)
-               var catalog = build_catalog(model, modelbuilder)
 
                var app = new App
 
                app.use_before("/*", new SessionInit)
                app.use_before("/*", new RequestClock)
-               app.use("/api", new NitwebAPIRouter(config, catalog))
+               app.use("/api", new APIRouter(config))
                app.use("/login", new GithubLogin(config.github_client_id))
                app.use("/oauth", new GithubOAuthCallBack(config.github_client_id, config.github_client_secret))
                app.use("/logout", new GithubLogout)
@@ -119,32 +96,6 @@ private class NitwebPhase
        end
 end
 
-# Group all api handlers in one router.
-class NitwebAPIRouter
-       super APIRouter
-
-       # Catalog to pass to handlers.
-       var catalog: Catalog
-
-       init do
-               use("/catalog", new APICatalogRouter(config, catalog))
-               use("/list", new APIList(config))
-               use("/search", new APISearch(config))
-               use("/random", new APIRandom(config))
-               use("/entity/:id", new APIEntity(config))
-               use("/code/:id", new APIEntityCode(config))
-               use("/uml/:id", new APIEntityUML(config))
-               use("/linearization/:id", new APIEntityLinearization(config))
-               use("/defs/:id", new APIEntityDefs(config))
-               use("/feedback/", new APIFeedbackRouter(config))
-               use("/inheritance/:id", new APIEntityInheritance(config))
-               use("/graph/", new APIGraphRouter(config))
-               use("/docdown/", new APIDocdown(config))
-               use("/metrics/", new APIMetricsRouter(config))
-               use("/user", new GithubUser)
-       end
-end
-
 # build toolcontext
 var toolcontext = new ToolContext
 var tpl = new Template
diff --git a/src/test_model_index.nit b/src/test_model_index.nit
new file mode 100644 (file)
index 0000000..38f72c8
--- /dev/null
@@ -0,0 +1,105 @@
+# 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.
+
+import frontend
+import model_index
+import console
+
+redef class ToolContext
+       var opt_name_prefix = new OptionBool("", "--name-prefix")
+       var opt_full_name_prefix = new OptionBool("", "--full-name-prefix")
+       var opt_name_similarity = new OptionBool("", "--name-similarity")
+       var opt_full_name_similarity = new OptionBool("", "--full-name-similarity")
+       var opt_name = new OptionBool("", "--name")
+       var opt_full_name = new OptionBool("", "--full-name")
+
+       redef init do
+               option_context.add_option(opt_name_prefix, opt_full_name_prefix)
+               option_context.add_option(opt_name_similarity, opt_full_name_similarity)
+               option_context.add_option(opt_name, opt_full_name)
+       end
+end
+
+redef class MEntity
+       fun color: String do
+               if visibility == public_visibility then
+                       return name.green
+               else if visibility == protected_visibility then
+                       return name.yellow
+               else
+                       return name.red
+               end
+       end
+end
+
+# build toolcontext
+var toolcontext = new ToolContext
+toolcontext.process_options(args)
+var args = toolcontext.option_context.rest
+
+if not args.length == 2 then
+       print "usage: test_model_index <nitfile> <search_query>"
+       exit 1
+end
+
+# build model
+var model = new Model
+var mbuilder = new ModelBuilder(model, toolcontext)
+var mmodules = mbuilder.parse_full([args.first])
+
+# process
+if mmodules.is_empty then return
+mbuilder.run_phases
+toolcontext.run_global_phases(mmodules)
+
+# Build index
+var index = new ModelIndex
+for mentity in model.private_view.mentities do
+       if mentity isa MClassDef or mentity isa MPropDef then continue
+       index.index(mentity)
+end
+
+var q = args[1]
+
+print "# {q}\n"
+
+var res
+if toolcontext.opt_name_prefix.value then
+       res = index.find_by_name_prefix(q)
+else if toolcontext.opt_full_name_prefix.value then
+       res = index.find_by_full_name_prefix(q)
+else if toolcontext.opt_name_similarity.value then
+       res = index.find_by_name_similarity(q)
+else if toolcontext.opt_full_name_similarity.value then
+       res = index.find_by_full_name_similarity(q)
+else if toolcontext.opt_name.value then
+       res = index.find_by_name(q)
+else if toolcontext.opt_full_name.value then
+       res = index.find_by_full_name(q)
+else
+       res = index.find(q)
+end
+
+res = res.sort(new ScoreComparator, new MEntityComparator).
+               uniq.
+               limit(10).
+               sort(new VisibilityComparator, new NameComparator)
+
+for e in res do
+       if toolcontext.opt_no_color.value then
+               print " * {e.score}: {e.mentity.name} ({e.mentity.full_name})"
+       else
+               print " * {e.score}: {e.mentity.color} ({e.mentity.full_name})"
+       end
+end
index f0c70c6..30b8757 100644 (file)
@@ -17,27 +17,43 @@ module api_catalog
 import web_base
 import catalog
 
-# Group all api handlers in one router.
-class APICatalogRouter
-       super APIRouter
+redef class NitwebConfig
 
        # Catalog to pass to handlers.
-       var catalog: Catalog
-
-       init do
-               use("/highlighted", new APICatalogHighLighted(config, catalog))
-               use("/required", new APICatalogMostRequired(config, catalog))
-               use("/bytags", new APICatalogByTags(config, catalog))
-               use("/contributors", new APICatalogContributors(config, catalog))
-               use("/stats", new APICatalogStats(config, catalog))
+       var catalog: Catalog is lazy do
+               var catalog = new Catalog(modelbuilder)
+               for mpackage in model.mpackages do
+                       catalog.deps.add_node(mpackage)
+                       for mgroup in mpackage.mgroups do
+                               for mmodule in mgroup.mmodules do
+                                       for imported in mmodule.in_importation.direct_greaters do
+                                               var ip = imported.mpackage
+                                               if ip == null or ip == mpackage then continue
+                                               catalog.deps.add_edge(mpackage, ip)
+                                       end
+                               end
+                       end
+                       catalog.git_info(mpackage)
+                       catalog.package_page(mpackage)
+               end
+               return catalog
+       end
+end
+
+redef class APIRouter
+       redef init do
+               super
+               use("/catalog/highlighted", new APICatalogHighLighted(config))
+               use("/catalog/required", new APICatalogMostRequired(config))
+               use("/catalog/bytags", new APICatalogByTags(config))
+               use("/catalog/contributors", new APICatalogContributors(config))
+               use("/catalog/stats", new APICatalogStats(config))
        end
 end
 
 abstract class APICatalogHandler
        super APIHandler
 
-       var catalog: Catalog
-
        # List the 10 best packages from `cpt`
        fun list_best(cpt: Counter[MPackage]): JsonArray do
                var res = new JsonArray
@@ -69,12 +85,12 @@ class APICatalogStats
        redef fun get(req, res) do
                var obj = new JsonObject
                obj["packages"] = config.model.mpackages.length
-               obj["maintainers"] = catalog.maint2proj.length
-               obj["contributors"] = catalog.contrib2proj.length
-               obj["modules"] = catalog.mmodules.sum
-               obj["classes"] = catalog.mclasses.sum
-               obj["methods"] = catalog.mmethods.sum
-               obj["loc"] = catalog.loc.sum
+               obj["maintainers"] = config.catalog.maint2proj.length
+               obj["contributors"] = config.catalog.contrib2proj.length
+               obj["modules"] = config.catalog.mmodules.sum
+               obj["classes"] = config.catalog.mclasses.sum
+               obj["methods"] = config.catalog.mmethods.sum
+               obj["loc"] = config.catalog.loc.sum
                res.json obj
        end
 end
@@ -82,17 +98,17 @@ end
 class APICatalogHighLighted
        super APICatalogHandler
 
-       redef fun get(req, res) do res.json list_best(catalog.score)
+       redef fun get(req, res) do res.json list_best(config.catalog.score)
 end
 
 class APICatalogMostRequired
        super APICatalogHandler
 
        redef fun get(req, res) do
-               if catalog.deps.not_empty then
+               if config.catalog.deps.not_empty then
                        var reqs = new Counter[MPackage]
                        for p in config.model.mpackages do
-                               reqs[p] = catalog.deps[p].smallers.length - 1
+                               reqs[p] = config.catalog.deps[p].smallers.length - 1
                        end
                        res.json list_best(reqs)
                        return
@@ -104,7 +120,7 @@ end
 class APICatalogByTags
        super APICatalogHandler
 
-       redef fun get(req, res) do res.json list_by(catalog.tag2proj)
+       redef fun get(req, res) do res.json list_by(config.catalog.tag2proj)
 end
 
 class APICatalogContributors
@@ -112,8 +128,8 @@ class APICatalogContributors
 
        redef fun get(req, res) do
                var obj = new JsonObject
-               obj["maintainers"] = new JsonArray.from(catalog.maint2proj.keys)
-               obj["contributors"] = new JsonArray.from(catalog.contrib2proj.keys)
+               obj["maintainers"] = new JsonArray.from(config.catalog.maint2proj.keys)
+               obj["contributors"] = new JsonArray.from(config.catalog.contrib2proj.keys)
                res.json obj
        end
 end
index bf4c80f..02d6b22 100644 (file)
@@ -19,6 +19,14 @@ import api_graph
 intrude import doc_down
 intrude import markdown::wikilinks
 import doc_commands
+import model::model_index
+
+redef class APIRouter
+       redef init do
+               super
+               use("/docdown/", new APIDocdown(config))
+       end
+end
 
 # Docdown handler accept docdown as POST data and render it as HTML
 class APIDocdown
@@ -81,10 +89,49 @@ redef interface DocCommand
                write_error(v, "Not yet implemented command `{token.link or else "null"}`")
        end
 
-       # Find the MEntity ` with `full_name`.
-       fun find_mentity(model: ModelView, full_name: nullable String): nullable MEntity do
-               if full_name == null then return null
-               return model.mentity_by_full_name(full_name.from_percent_encoding)
+       # Find the MEntity that matches `name`.
+       #
+       # Write an error if the entity is not found
+       fun find_mentity(v: MarkdownEmitter, model: ModelView, name: nullable String): nullable MEntity do
+               if name == null then
+                       write_error(v, "No MEntity found")
+                       return null
+               end
+               # Lookup by full name
+               var mentity = model.mentity_by_full_name(name)
+               if mentity != null then return mentity
+
+               var mentities = model.mentities_by_name(name)
+               if mentities.is_empty then
+                       var suggest = model.find(name, 3)
+                       var msg = new Buffer
+                       msg.append "No MEntity found for name `{name}`"
+                       if suggest.not_empty then
+                               msg.append " (suggestions: "
+                               var i = 0
+                               for s in suggest do
+                                       msg.append "`{s.full_name}`"
+                                       if i < suggest.length - 1 then msg.append ", "
+                                       i += 1
+                               end
+                               msg.append ")"
+                       end
+                       write_error(v, msg.write_to_string)
+                       return null
+               else if mentities.length > 1 then
+                       var msg = new Buffer
+                       msg.append "Conflicts for name `{name}`"
+                       msg.append " (conflicts: "
+                       var i = 0
+                       for s in mentities do
+                               msg.append "`{s.full_name}`"
+                               if i < mentities.length - 1 then msg.append ", "
+                               i += 1
+                       end
+                       msg.append ")"
+                       write_warning(v, msg.write_to_string)
+               end
+               return mentities.first
        end
 
        # Write a warning in the output
@@ -116,11 +163,8 @@ redef class UnknownCommand
                        return
                end
                var full_name = link.write_to_string
-               var mentity = find_mentity(model, full_name)
-               if mentity == null then
-                       write_error(v, "Unknown command `{link}`")
-                       return
-               end
+               var mentity = find_mentity(v, model, full_name)
+               if mentity == null then return
                write_mentity_link(v, mentity)
        end
 end
@@ -132,11 +176,8 @@ redef class ArticleCommand
                        return
                end
                var name = args.first
-               var mentity = find_mentity(model, name)
-               if mentity == null then
-                       write_error(v, "No MEntity found for name `{name}`")
-                       return
-               end
+               var mentity = find_mentity(v, model, name)
+               if mentity == null then return
                var mdoc = mentity.mdoc_or_fallback
                if mdoc == null then
                        write_warning(v, "No MDoc for mentity `{name}`")
@@ -158,11 +199,8 @@ redef class CommentCommand
                        return
                end
                var name = args.first
-               var mentity = find_mentity(model, name)
-               if mentity == null then
-                       write_error(v, "No MEntity found for name `{name}`")
-                       return
-               end
+               var mentity = find_mentity(v, model, name)
+               if mentity == null then return
                var mdoc = mentity.mdoc_or_fallback
                if mdoc == null then
                        write_warning(v, "No MDoc for mentity `{name}`")
@@ -179,7 +217,8 @@ redef class ListCommand
                        return
                end
                var name = args.first
-               var mentity = find_mentity(model, name)
+               var mentity = find_mentity(v, model, name)
+               if mentity == null then return
                if mentity isa MPackage then
                        write_list(v, mentity.mgroups)
                else if mentity isa MGroup then
@@ -224,11 +263,8 @@ redef class CodeCommand
                        return
                end
                var name = args.first
-               var mentity = find_mentity(model, name)
-               if mentity == null then
-                       write_error(v, "No MEntity found for name `{name}`")
-                       return
-               end
+               var mentity = find_mentity(v, model, name)
+               if mentity == null then return
                if mentity isa MClass then mentity = mentity.intro
                if mentity isa MProperty then mentity = mentity.intro
                var source = render_source(mentity, v.decorator.as(NitwebDecorator).modelbuilder)
@@ -258,11 +294,8 @@ redef class GraphCommand
                        return
                end
                var name = args.first
-               var mentity = find_mentity(model, name)
-               if mentity == null then
-                       write_error(v, "No MEntity found for name `{name}`")
-                       return
-               end
+               var mentity = find_mentity(v, model, name)
+               if mentity == null then return
                var g = new InheritanceGraph(mentity, model)
                v.add g.draw(3, 3).to_svg
        end
index 5db1d04..e2bfa9a 100644 (file)
@@ -23,12 +23,10 @@ redef class NitwebConfig
        var stars: MongoCollection is lazy do return db.collection("stars")
 end
 
-# Group all api handlers in one router
-class APIFeedbackRouter
-       super APIRouter
-
-       init do
-               use("/stars/:id", new APIStars(config))
+redef class APIRouter
+       redef init do
+               super
+               use("/feedback/stars/:id", new APIStars(config))
        end
 end
 
@@ -38,28 +36,21 @@ class APIStars
 
        redef fun get(req, res) do
                var mentity = mentity_from_uri(req, res)
-               if mentity == null then
-                       res.error 404
-                       return
-               end
-
+               if mentity == null then return
                res.json mentity_ratings(mentity)
        end
 
        redef fun post(req, res) do
                var mentity = mentity_from_uri(req, res)
-               if mentity == null then
-                       res.error 404
-                       return
-               end
+               if mentity == null then return
                var obj = req.body.parse_json
                if not obj isa JsonObject then
-                       res.error 400
+                       res.api_error(400, "Expected a JSON object")
                        return
                end
                var rating = obj["rating"]
                if not rating isa Int then
-                       res.error 400
+                       res.api_error(400, "Expected a key `rating`")
                        return
                end
 
index d58031d..cf6e105 100644 (file)
@@ -19,12 +19,10 @@ import web_base
 import dot
 import uml
 
-# Group all api handlers in one router.
-class APIGraphRouter
-       super APIRouter
-
+redef class APIRouter
        init do
-               use("/inheritance/:id", new APIInheritanceGraph(config))
+               super
+               use("/graph/inheritance/:id", new APIInheritanceGraph(config))
        end
 end
 
@@ -33,13 +31,10 @@ class APIInheritanceGraph
        super APIHandler
 
        redef fun get(req, res) do
+               var mentity = mentity_from_uri(req, res)
+               if mentity == null then return
                var pdepth = req.int_arg("pdepth")
                var cdepth = req.int_arg("cdepth")
-               var mentity = mentity_from_uri(req, res)
-               if mentity == null then
-                       res.error 404
-                       return
-               end
                var g = new InheritanceGraph(mentity, view)
                res.send g.draw(pdepth, cdepth).to_svg
        end
index 8d64c04..1683cbb 100644 (file)
@@ -17,12 +17,10 @@ module api_metrics
 import web_base
 import metrics
 
-# Group all api handlers in one router.
-class APIMetricsRouter
-       super APIRouter
-
-       init do
-               use("/structural/:id", new APIStructuralMetrics(config))
+redef class APIRouter
+       redef init do
+               super
+               use("/metrics/structural/:id", new APIStructuralMetrics(config))
        end
 end
 
@@ -71,13 +69,10 @@ class APIStructuralMetrics
 
        redef fun get(req, res) do
                var mentity = mentity_from_uri(req, res)
-               if mentity == null then
-                       res.error 404
-                       return
-               end
+               if mentity == null then return
                var metrics = mentity.collect_metrics(self)
                if metrics == null then
-                       res.error 404
+                       res.api_error(404, "No metric for mentity `{mentity.full_name}`")
                        return
                end
                res.json metrics
index 63fd636..4c9fb8e 100644 (file)
@@ -18,6 +18,21 @@ import web_base
 import highlight
 import uml
 
+redef class APIRouter
+       redef init do
+               super
+               use("/list", new APIList(config))
+               use("/search", new APISearch(config))
+               use("/random", new APIRandom(config))
+               use("/entity/:id", new APIEntity(config))
+               use("/code/:id", new APIEntityCode(config))
+               use("/uml/:id", new APIEntityUML(config))
+               use("/linearization/:id", new APIEntityLinearization(config))
+               use("/defs/:id", new APIEntityDefs(config))
+               use("/inheritance/:id", new APIEntityInheritance(config))
+       end
+end
+
 # List all mentities.
 #
 # MEntities can be filtered on their kind using the `k` parameter.
@@ -128,10 +143,7 @@ class APIEntityInheritance
 
        redef fun get(req, res) do
                var mentity = mentity_from_uri(req, res)
-               if mentity == null then
-                       res.error 404
-                       return
-               end
+               if mentity == null then return
                res.json mentity.hierarchy_poset(view)[mentity]
        end
 end
@@ -144,13 +156,10 @@ class APIEntityLinearization
 
        redef fun get(req, res) do
                var mentity = mentity_from_uri(req, res)
-               if mentity == null then
-                       res.error 404
-                       return
-               end
+               if mentity == null then return
                var lin = mentity.collect_linearization(config.mainmodule)
                if lin == null then
-                       res.error 404
+                       res.api_error(404, "No linearization for mentity `{mentity.full_name}`")
                        return
                end
                res.json new JsonArray.from(lin)
@@ -165,6 +174,7 @@ class APIEntityDefs
 
        redef fun get(req, res) do
                var mentity = mentity_from_uri(req, res)
+               if mentity == null then return
                var arr = new JsonArray
                if mentity isa MModule then
                        for mclassdef in mentity.mclassdefs do arr.add mclassdef
@@ -175,7 +185,7 @@ class APIEntityDefs
                else if mentity isa MProperty then
                        for mpropdef in mentity.mpropdefs do arr.add mpropdef
                else
-                       res.error 404
+                       res.api_error(404, "No definition list for mentity `{mentity.full_name}`")
                        return
                end
                res.json arr
@@ -203,6 +213,7 @@ class APIEntityUML
 
        redef fun get(req, res) do
                var mentity = mentity_from_uri(req, res)
+               if mentity == null then return
                var dot
                if mentity isa MClassDef then mentity = mentity.mclass
                if mentity isa MClass then
@@ -212,7 +223,7 @@ class APIEntityUML
                        var uml = new UMLModel(view, mentity)
                        dot = uml.generate_package_uml.write_to_string
                else
-                       res.error 404
+                       res.api_error(404, "No UML for mentity `{mentity.full_name}`")
                        return
                end
                res.send render_dot(dot)
@@ -230,7 +241,7 @@ class APIEntityCode
                if mentity == null then return
                var source = render_source(mentity)
                if source == null then
-                       res.error 404
+                       res.api_error(404, "No code for mentity `{mentity.full_name}`")
                        return
                end
                res.send source
index 63571c0..2e5294e 100644 (file)
@@ -21,3 +21,21 @@ import api_graph
 import api_docdown
 import api_metrics
 import api_feedback
+
+redef class APIRouter
+       redef init do
+               super
+               use("/*", new APIErrorHandler(config)) # catch 404 errors
+       end
+end
+
+# Error handler user to catch non resolved request by the API
+#
+# Displays a JSON formatted 404 error.
+class APIErrorHandler
+       super APIHandler
+
+       redef fun all(req, res) do
+               res.api_error(404, "Not found")
+       end
+end
index 26bd55e..043bd6c 100644 (file)
@@ -38,8 +38,8 @@ class NitwebConfig
        var modelbuilder: ModelBuilder
 end
 
-# Specific nitcorn Action that uses a Model
-class ModelHandler
+# Specific handler for the nitweb API.
+abstract class APIHandler
        super Handler
 
        # App config.
@@ -51,25 +51,6 @@ class ModelHandler
                return model.mentity_by_full_name(full_name.from_percent_encoding)
        end
 
-       # Init the model view from the `req` uri parameters.
-       fun init_model_view(req: HttpRequest): ModelView do
-               var view = new ModelView(config.model)
-               var show_private = req.bool_arg("private") or else false
-               if not show_private then view.min_visibility = protected_visibility
-
-               view.include_fictive = req.bool_arg("fictive") or else false
-               view.include_empty_doc = req.bool_arg("empty-doc") or else true
-               view.include_test_suite = req.bool_arg("test-suite") or else false
-               view.include_attribute = req.bool_arg("attributes") or else true
-
-               return view
-       end
-end
-
-# Specific handler for nitweb API.
-abstract class APIHandler
-       super ModelHandler
-
        # The JSON API does not filter anything by default.
        #
        # So we can cache the model view.
@@ -91,12 +72,12 @@ abstract class APIHandler
        fun mentity_from_uri(req: HttpRequest, res: HttpResponse): nullable MEntity do
                var id = req.param("id")
                if id == null then
-                       res.error 400
+                       res.api_error(400, "Expected mentity full name")
                        return null
                end
                var mentity = find_mentity(view, id)
                if mentity == null then
-                       res.error 404
+                       res.api_error(404, "MEntity `{id}` not found")
                end
                return mentity
        end
@@ -106,10 +87,46 @@ end
 class APIRouter
        super Router
 
-       # App config.
+       # App config
        var config: NitwebConfig
 end
 
+redef class HttpResponse
+
+       # Return an HTTP error response with `status`
+       #
+       # Like the rest of the API, errors are formated as JSON:
+       # ~~~json
+       # { "status": 404, "message": "Not found" }
+       # ~~~
+       fun api_error(status: Int, message: String) do
+               json(new APIError(status, message), status)
+       end
+end
+
+# An error returned by the API.
+#
+# Can be serialized to json.
+class APIError
+       super Jsonable
+
+       # Reponse status
+       var status: Int
+
+       # Response error message
+       var message: String
+
+       # Json Object for this error
+       var json: JsonObject is lazy do
+               var obj = new JsonObject
+               obj["status"] = status
+               obj["message"] = message
+               return obj
+       end
+
+       redef fun to_json do return json.to_json
+end
+
 redef class MEntity
 
        # URL to `self` within the web interface.
@@ -126,7 +143,7 @@ redef class MEntity
        end
 
        # Get the full json repesentation of `self` with MEntityRefs resolved.
-       fun api_json(handler: ModelHandler): JsonObject do return json
+       fun api_json(handler: APIHandler): JsonObject do return json
 end
 
 redef class MEntityRef
index cc9df0a..12e56b7 100644 (file)
@@ -5,4 +5,5 @@ test_phase
 test_parser
 test_highlight
 test_model_visitor
+test_model_index
 ^nit
index 72ec224..bb114f6 100644 (file)
@@ -1,3 +1,4 @@
+test_nitunit4/test_bad_comp.nit:27,10--19: Error: method or variable `bad_method` unknown in `TestSuiteBadComp`.
 test_nitunit4/test_bad_comp2.nit:19,7--22: Error: a class named `test_nitunit4::TestSuiteBadComp` is already defined in module `test_bad_comp` at test_nitunit4/test_bad_comp.nit:19,1--29,3.
 ==== Test-suite of module test_nitunit4::test_bad_comp | tests: 2
 [KO] test_nitunit4$TestSuiteBadComp$test_good
index f22bfee..d9ab058 100644 (file)
@@ -1,13 +1,9 @@
 
-\e[1m\e[32m# 2 result(s) for 'comment: A'\e[m\e[m
+\e[1m\e[34m# \e[m\e[m\e[1m\e[34mA\e[m\e[m
+\e[1m\e[30mclass A\e[m\e[m
+
+    \e[1m\e[32mclass A\e[m\e[m
+    \e[1m\e[30mbase_simple3.nit:29,1--32,3\e[m\e[m
 
\e[1m\e[32mC\e[m\e[m \e[1m\e[34mA\e[m\e[m
-   \e[1m\e[30mbase_simple3::A\e[m\e[m
-   class A
-   \e[30mbase_simple3.nit:29,1--32,3\e[m
 
\e[1m\e[32mC\e[m\e[m \e[1m\e[34mA\e[m\e[m
-   \e[1m\e[30mbase_simple3::base_simple3::A\e[m\e[m
-   class A
-   \e[30mbase_simple3.nit:29,1--32,3\e[m
 
index b7ca320..3f6b1f1 100644 (file)
@@ -1,13 +1,9 @@
 
-\e[1m\e[32m# 2 result(s) for 'comment: foo'\e[m\e[m
+\e[1m\e[34m# \e[m\e[m\e[1m\e[34mfoo\e[m\e[m
+\e[1m\e[30mfun foo\e[m\e[m
+
+    \e[1m\e[32mfun foo\e[m\e[m
+    \e[1m\e[30mbase_simple3.nit:49,1--19\e[m\e[m
 
\e[1m\e[32mF\e[m\e[m \e[1m\e[34mfoo\e[m\e[m
-   \e[1m\e[30mbase_simple3::Sys::foo\e[m\e[m
-   fun foo
-   \e[30mbase_simple3.nit:49,1--19\e[m
 
\e[1m\e[32mF\e[m\e[m \e[1m\e[34mfoo\e[m\e[m
-   \e[1m\e[30mbase_simple3::base_simple3::Sys::foo\e[m\e[m
-   fun foo
-   \e[30mbase_simple3.nit:49,1--19\e[m
 
index 6bf30a6..9d71d91 100644 (file)
@@ -1,18 +1,9 @@
 
-\e[1m\e[32m# 3 result(s) for 'comment: base_simple3'\e[m\e[m
+\e[1m\e[34m# \e[m\e[m\e[1m\e[34mbase_simple3\e[m\e[m
+\e[1m\e[30mpackage base_simple3\e[m\e[m
 
\e[1m\e[32mP\e[m\e[m \e[1m\e[34mbase_simple3\e[m\e[m
-   \e[1m\e[30mbase_simple3\e[m\e[m
-   package base_simple3
-   \e[30mbase_simple3.nit:17,1--66,13\e[m
+    \e[1m\e[32mpackage base_simple3\e[m\e[m
+    \e[1m\e[30mbase_simple3.nit:17,1--66,13\e[m\e[m
 
\e[1m\e[32mG\e[m\e[m \e[1m\e[34mbase_simple3\e[m\e[m
-   \e[1m\e[30mbase_simple3\e[m\e[m
-   group base_simple3
-   \e[30mbase_simple3.nit:17,1--66,13\e[m
 
\e[1m\e[32mM\e[m\e[m \e[1m\e[34mbase_simple3\e[m\e[m
-   \e[1m\e[30mbase_simple3::base_simple3\e[m\e[m
-   module base_simple3
-   \e[30mbase_simple3.nit:17,1--66,13\e[m
 
diff --git a/tests/sav/test_model_index.res b/tests/sav/test_model_index.res
new file mode 100644 (file)
index 0000000..e50ad70
--- /dev/null
@@ -0,0 +1,2 @@
+Usage: [OPTION]... [ARG]...
+Use --help for help
diff --git a/tests/sav/test_model_index_args1.res b/tests/sav/test_model_index_args1.res
new file mode 100644 (file)
index 0000000..62bdafb
--- /dev/null
@@ -0,0 +1,3 @@
+# Obj
+
+ * 1: Object (test_prog::Object)
diff --git a/tests/sav/test_model_index_args10.res b/tests/sav/test_model_index_args10.res
new file mode 100644 (file)
index 0000000..982c125
--- /dev/null
@@ -0,0 +1,12 @@
+# elfves
+
+ * 4: Elf (test_prog::Elf)
+ * 5: Float (test_prog::Float)
+ * 5: Race (test_prog::Race)
+ * 5: careers (test_prog::careers)
+ * 5: excluded (excluded>)
+ * 5: excluded (excluded::excluded)
+ * 5: excluded (excluded)
+ * 5: game (test_prog>game>)
+ * 5: game (test_prog::game)
+ * 4: races (test_prog::races)
diff --git a/tests/sav/test_model_index_args11.res b/tests/sav/test_model_index_args11.res
new file mode 100644 (file)
index 0000000..510eaba
--- /dev/null
@@ -0,0 +1,12 @@
+# Dwarves
+
+ * 13: List (test_prog::List)
+ * 12: Sys (test_prog::Sys)
+ * 8: excluded (excluded>)
+ * 7: excluded (excluded)
+ * 13: game (test_prog>game>)
+ * 13: races (test_prog::races)
+ * 13: rpg (test_prog>rpg>)
+ * 13: rpg (test_prog::rpg)
+ * 9: test_prog (test_prog>)
+ * 9: test_prog (test_prog)
diff --git a/tests/sav/test_model_index_args12.res b/tests/sav/test_model_index_args12.res
new file mode 100644 (file)
index 0000000..b1ae3db
--- /dev/null
@@ -0,0 +1,12 @@
+# test_prof::Dwarves
+
+ * 5: Career (test_prog::Career)
+ * 4: Dwarf (test_prog::Dwarf)
+ * 6: Game (test_prog::Game)
+ * 6: Race (test_prog::Race)
+ * 5: Starter (test_prog::Starter)
+ * 5: careers (test_prog::careers)
+ * 7: character (test_prog::character)
+ * 6: game (test_prog::game)
+ * 5: races (test_prog::races)
+ * 7: rpg (test_prog::rpg)
diff --git a/tests/sav/test_model_index_args13.res b/tests/sav/test_model_index_args13.res
new file mode 100644 (file)
index 0000000..cb5e115
--- /dev/null
@@ -0,0 +1,12 @@
+# Obj
+
+ * 3: * (test_prog::Float::*)
+ * 3: / (test_prog::Int::/)
+ * 3: == (test_prog::Object::==)
+ * 3: Elf (test_prog::Elf)
+ * 3: Int (test_prog::Int)
+ * 1: Object (test_prog::Object)
+ * 3: Sys (test_prog::Sys)
+ * 3: age (test_prog::Character::age)
+ * 3: rpg (test_prog>rpg>)
+ * 3: rpg (test_prog::rpg)
diff --git a/tests/sav/test_model_index_args14.res b/tests/sav/test_model_index_args14.res
new file mode 100644 (file)
index 0000000..3fa271e
--- /dev/null
@@ -0,0 +1,12 @@
+# C
+
+ * 1: * (test_prog::Float::*)
+ * 1: * (test_prog::Int::*)
+ * 1: + (test_prog::Float::+)
+ * 1: - (test_prog::Int::-)
+ * 1: - (test_prog::Float::-)
+ * 1: / (test_prog::Float::/)
+ * 1: / (test_prog::Int::/)
+ * 1: > (test_prog::Int::>)
+ * 1: > (test_prog::Float::>)
+ * 1: Career (test_prog::Career)
diff --git a/tests/sav/test_model_index_args15.res b/tests/sav/test_model_index_args15.res
new file mode 100644 (file)
index 0000000..65b6c73
--- /dev/null
@@ -0,0 +1,12 @@
+# to
+
+ * 2: * (test_prog::Float::*)
+ * 2: + (test_prog::Float::+)
+ * 2: - (test_prog::Float::-)
+ * 2: / (test_prog::Float::/)
+ * 2: / (test_prog::Int::/)
+ * 2: == (test_prog::Object::==)
+ * 2: > (test_prog::Int::>)
+ * 2: > (test_prog::Float::>)
+ * 1: to_f (test_prog::Int::to_f)
+ * 2: total_strengh (test_prog::Character::total_strengh)
diff --git a/tests/sav/test_model_index_args16.res b/tests/sav/test_model_index_args16.res
new file mode 100644 (file)
index 0000000..998a270
--- /dev/null
@@ -0,0 +1,12 @@
+# Dwa
+
+ * 13: Dwarf (test_prog::Dwarf)
+ * 14: Game (test_prog::Game)
+ * 9: excluded (excluded>)
+ * 8: excluded (excluded)
+ * 14: game (test_prog>game>)
+ * 14: game (test_prog::game)
+ * 14: rpg (test_prog::rpg)
+ * 14: rpg (test_prog>rpg>)
+ * 10: test_prog (test_prog>)
+ * 9: test_prog (test_prog)
diff --git a/tests/sav/test_model_index_args17.res b/tests/sav/test_model_index_args17.res
new file mode 100644 (file)
index 0000000..89e9bc8
--- /dev/null
@@ -0,0 +1,12 @@
+# test_prog::C
+
+ * 1: Career (test_prog::Career)
+ * 2: Character (test_prog::Character)
+ * 3: Combatable (test_prog::Combatable)
+ * 3: Elf (test_prog::Elf)
+ * 3: Int (test_prog::Int)
+ * 3: Sys (test_prog::Sys)
+ * 4: game (test_prog::game)
+ * 3: rpg (test_prog::rpg)
+ * 3: test_prog (test_prog)
+ * 3: test_prog (test_prog>)
diff --git a/tests/sav/test_model_index_args18.res b/tests/sav/test_model_index_args18.res
new file mode 100644 (file)
index 0000000..3fa271e
--- /dev/null
@@ -0,0 +1,12 @@
+# C
+
+ * 1: * (test_prog::Float::*)
+ * 1: * (test_prog::Int::*)
+ * 1: + (test_prog::Float::+)
+ * 1: - (test_prog::Int::-)
+ * 1: - (test_prog::Float::-)
+ * 1: / (test_prog::Float::/)
+ * 1: / (test_prog::Int::/)
+ * 1: > (test_prog::Int::>)
+ * 1: > (test_prog::Float::>)
+ * 1: Career (test_prog::Career)
diff --git a/tests/sav/test_model_index_args19.res b/tests/sav/test_model_index_args19.res
new file mode 100644 (file)
index 0000000..d2cf349
--- /dev/null
@@ -0,0 +1,12 @@
+# to
+
+ * 2: * (test_prog::Int::*)
+ * 2: * (test_prog::Float::*)
+ * 2: + (test_prog::Float::+)
+ * 2: - (test_prog::Float::-)
+ * 2: / (test_prog::Float::/)
+ * 2: / (test_prog::Int::/)
+ * 2: > (test_prog::Float::>)
+ * 2: > (test_prog::Int::>)
+ * 1: to_f (test_prog::Int::to_f)
+ * 2: total_strengh (test_prog::Character::total_strengh)
diff --git a/tests/sav/test_model_index_args2.res b/tests/sav/test_model_index_args2.res
new file mode 100644 (file)
index 0000000..b094747
--- /dev/null
@@ -0,0 +1,5 @@
+# C
+
+ * 1: Career (test_prog::Career)
+ * 2: Character (test_prog::Character)
+ * 3: Combatable (test_prog::Combatable)
diff --git a/tests/sav/test_model_index_args20.res b/tests/sav/test_model_index_args20.res
new file mode 100644 (file)
index 0000000..1e858ea
--- /dev/null
@@ -0,0 +1,12 @@
+# Foo
+
+ * 3: != (test_prog::Object::!=)
+ * 3: + (test_prog::Int::+)
+ * 2: Bool (test_prog::Bool)
+ * 3: Elf (test_prog::Elf)
+ * 3: Float (test_prog::Float)
+ * 3: Int (test_prog::Int)
+ * 3: Sys (test_prog::Sys)
+ * 3: dps (test_prog::Weapon::dps)
+ * 3: rpg (test_prog::rpg)
+ * 3: rpg (test_prog>rpg>)
diff --git a/tests/sav/test_model_index_args21.res b/tests/sav/test_model_index_args21.res
new file mode 100644 (file)
index 0000000..c06776c
--- /dev/null
@@ -0,0 +1,12 @@
+# test_prog::C
+
+ * 1: Career (test_prog::Career)
+ * 2: Character (test_prog::Character)
+ * 3: Combatable (test_prog::Combatable)
+ * 3: Elf (test_prog::Elf)
+ * 3: Int (test_prog::Int)
+ * 3: Sys (test_prog::Sys)
+ * 3: rpg (test_prog::rpg)
+ * 3: test_prog (test_prog::test_prog)
+ * 3: test_prog (test_prog)
+ * 3: test_prog (test_prog>)
diff --git a/tests/sav/test_model_index_args3.res b/tests/sav/test_model_index_args3.res
new file mode 100644 (file)
index 0000000..c3ac095
--- /dev/null
@@ -0,0 +1,7 @@
+# e
+
+ * 2: endurance_bonus (test_prog::Career::endurance_bonus)
+ * 1: excluded (excluded>)
+ * 1: excluded (excluded::excluded)
+ * 1: excluded (excluded)
+ * 3: endurance_bonus= (test_prog::Career::endurance_bonus=)
diff --git a/tests/sav/test_model_index_args4.res b/tests/sav/test_model_index_args4.res
new file mode 100644 (file)
index 0000000..dda5763
--- /dev/null
@@ -0,0 +1,6 @@
+# to
+
+ * 1: to_f (test_prog::Int::to_f)
+ * 3: total_endurance (test_prog::Character::total_endurance)
+ * 4: total_intelligence (test_prog::Character::total_intelligence)
+ * 2: total_strengh (test_prog::Character::total_strengh)
diff --git a/tests/sav/test_model_index_args5.res b/tests/sav/test_model_index_args5.res
new file mode 100644 (file)
index 0000000..05661e5
--- /dev/null
@@ -0,0 +1,2 @@
+# C
+
diff --git a/tests/sav/test_model_index_args6.res b/tests/sav/test_model_index_args6.res
new file mode 100644 (file)
index 0000000..490c343
--- /dev/null
@@ -0,0 +1,12 @@
+# test_prog::
+
+ * 6: Bool (test_prog::Bool)
+ * 4: Elf (test_prog::Elf)
+ * 9: Game (test_prog::Game)
+ * 2: Int (test_prog::Int)
+ * 7: List (test_prog::List)
+ * 8: Race (test_prog::Race)
+ * 3: Sys (test_prog::Sys)
+ * 5: game (test_prog::game)
+ * 10: races (test_prog::races)
+ * 1: rpg (test_prog::rpg)
diff --git a/tests/sav/test_model_index_args7.res b/tests/sav/test_model_index_args7.res
new file mode 100644 (file)
index 0000000..5ee70f9
--- /dev/null
@@ -0,0 +1,12 @@
+# test_prog::C
+
+ * 1: Career (test_prog::Career)
+ * 2: Character (test_prog::Character)
+ * 3: Combatable (test_prog::Combatable)
+ * 4: age (test_prog::Character::age)
+ * 8: name (test_prog::Character::name)
+ * 7: quit (test_prog::Character::quit)
+ * 6: race (test_prog::Character::race)
+ * 5: sex (test_prog::Character::sex)
+ * 9: age= (test_prog::Character::age=)
+ * 10: sex= (test_prog::Character::sex=)
diff --git a/tests/sav/test_model_index_args8.res b/tests/sav/test_model_index_args8.res
new file mode 100644 (file)
index 0000000..fc9d8e2
--- /dev/null
@@ -0,0 +1,12 @@
+# A
+
+ * 1: * (test_prog::Int::*)
+ * 1: * (test_prog::Float::*)
+ * 1: + (test_prog::Int::+)
+ * 1: + (test_prog::Float::+)
+ * 1: - (test_prog::Int::-)
+ * 1: - (test_prog::Float::-)
+ * 1: / (test_prog::Float::/)
+ * 1: / (test_prog::Int::/)
+ * 1: > (test_prog::Float::>)
+ * 1: > (test_prog::Int::>)
diff --git a/tests/sav/test_model_index_args9.res b/tests/sav/test_model_index_args9.res
new file mode 100644 (file)
index 0000000..bb6e9e1
--- /dev/null
@@ -0,0 +1,12 @@
+# Foo
+
+ * 3: * (test_prog::Int::*)
+ * 3: / (test_prog::Int::/)
+ * 2: Bool (test_prog::Bool)
+ * 3: Elf (test_prog::Elf)
+ * 3: Float (test_prog::Float)
+ * 3: Int (test_prog::Int)
+ * 3: Sys (test_prog::Sys)
+ * 3: rpg (test_prog::rpg)
+ * 3: rpg (test_prog>rpg>)
+ * 3: sex (test_prog::Character::sex)
diff --git a/tests/test_model_index.args b/tests/test_model_index.args
new file mode 100644 (file)
index 0000000..ac86448
--- /dev/null
@@ -0,0 +1,21 @@
+test_prog Obj --name-prefix --no-color
+test_prog C --name-prefix --no-color
+test_prog e --name-prefix --no-color
+test_prog to --name-prefix --no-color
+test_prog C --full-name-prefix --no-color
+test_prog test_prog:: --full-name-prefix --no-color
+test_prog test_prog::C --full-name-prefix --no-color
+test_prog A --name-similarity --no-color
+test_prog Foo --name-similarity --no-color
+test_prog elfves --name-similarity --no-color
+test_prog Dwarves --full-name-similarity --no-color
+test_prog test_prof::Dwarves --full-name-similarity --no-color
+test_prog Obj --name --no-color
+test_prog C --name --no-color
+test_prog to --name --no-color
+test_prog Dwa --full-name --no-color
+test_prog test_prog::C --full-name --no-color
+test_prog C --no-color
+test_prog to --no-color
+test_prog Foo --no-color
+test_prog test_prog::C --no-color