diff --git a/.gitmodules b/.gitmodules index 1785101..d1d9440 100644 --- a/.gitmodules +++ b/.gitmodules @@ -22,3 +22,6 @@ [submodule "frontend/lib/bootstrap"] path = frontend/lib/bootstrap url = git@github.com:twbs/bootstrap.git +[submodule "frontend/lib/doctrine"] + path = frontend/lib/doctrine + url = git@github.com:Constellation/doctrine.git diff --git a/Gruntfile.js b/Gruntfile.js index e0a670d..944d2a5 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -31,6 +31,8 @@ var JS_LIB = [ LIB_DIR + 'codemirror/lib/codemirror.js', LIB_DIR + 'codemirror/mode/javascript/javascript.js', LIB_DIR + 'codemirror/addon/edit/matchbrackets.js', + LIB_DIR + 'codemirror/addon/hint/show-hint.js', + LIB_DIR + 'doctrine/doctrine.js', LIB_DIR + 'suspend.js/dist/suspend.js' ]; @@ -163,6 +165,7 @@ module.exports = function (grunt) { 'frontend/dist/mongoWebShell.min.css': [ LIB_DIR + 'codemirror/lib/codemirror.css', LIB_DIR + 'codemirror/theme/solarized.css', + LIB_DIR + 'codemirror/addon/hint/show-hint.css', FRONTEND_DIR + 'mongoWebShell.css' ] } diff --git a/frontend/lib/codemirror b/frontend/lib/codemirror index 5b011c7..0271023 160000 --- a/frontend/lib/codemirror +++ b/frontend/lib/codemirror @@ -1 +1 @@ -Subproject commit 5b011c7db143b85b255e9363745c95a38f70f619 +Subproject commit 0271023f20b085a0029aceb1d9e1afdd01574515 diff --git a/frontend/lib/doctrine b/frontend/lib/doctrine new file mode 160000 index 0000000..9705e95 --- /dev/null +++ b/frontend/lib/doctrine @@ -0,0 +1 @@ +Subproject commit 9705e95d5c86250f6b3338483c4bbcc304182042 diff --git a/frontend/mongoWebShell.css b/frontend/mongoWebShell.css index 904187c..8ea7d7f 100644 --- a/frontend/mongoWebShell.css +++ b/frontend/mongoWebShell.css @@ -111,3 +111,41 @@ div.CodeMirror-vscrollbar { .mws-cm-plain-text, .mws-cm-plain-text span { color: inherit !important; } + + +.CodeMirror-hint-styled { + max-width: none; +} + +.CodeMirror-hint-styled .return-type { + font-size: 12px; + padding-left: 5px; + padding-right: 3px; + text-align: right; + float: left; + width: 110px; +} + +.CodeMirror-hint-styled:not(.CodeMirror-hint-active) .return-type { + color: rgb(74, 132, 204); +} + +.CodeMirror-hint-styled .signature { + padding-left: 3px; + padding-right: 5px; + text-align: left; + min-width: 100px; +} + +.CodeMirror-hint-styled .autocompletion-type { + +} + +.CodeMirror-hint-autocompletion-description { + line-height: 25px; + border-top: 10px; + border-top-color: rgb(200, 200, 200); + font-size: 11px; + font-family: Helvetica, Arial, sans-serif; +} + diff --git a/frontend/src/mws/Coll.js b/frontend/src/mws/Coll.js index 8089c51..0d67ca9 100644 --- a/frontend/src/mws/Coll.js +++ b/frontend/src/mws/Coll.js @@ -47,6 +47,10 @@ mongo.Coll.prototype.toString = function () { /** * Makes a Cursor that is the result of a find request on the mongod backing * server. + * @name find + * @param {object} query + * @param {object} projection + * @returns {mongo.Cursor} */ mongo.Coll.prototype.find = function (query, projection) { mongo.events.functionTrigger(this.shell, 'db.collection.find', arguments, @@ -54,6 +58,13 @@ mongo.Coll.prototype.find = function (query, projection) { return new mongo.Cursor(this, query, projection); }; +/** + * Finds a single document based on the query + * @name findOne + * @param {object} query + * @param {object} projection + * @returns {object} + */ mongo.Coll.prototype.findOne = function (query, projection) { mongo.events.functionTrigger(this.shell, 'db.collection.findOne', arguments, {collection: this.name}); @@ -70,12 +81,24 @@ mongo.Coll.prototype.findOne = function (query, projection) { }.bind(this)); }; +/** + * Count number of matching documents in the db to a query. + * @name count + * @param {object} query + * @param {object} projection + * @returns {number} + */ mongo.Coll.prototype.count = function (query, projection) { mongo.events.functionTrigger(this.shell, 'db.collection.count', arguments, {collection: this.name}); return new mongo.Cursor(this, query, projection).count(); }; +/** + * Inserts a single document into MongoDB. + * @name insert + * @param {object} doc + */ mongo.Coll.prototype.insert = function (doc) { var url = this.urlBase + 'insert'; var params = {document: doc}; @@ -84,6 +107,11 @@ mongo.Coll.prototype.insert = function (doc) { mongo.request.makeRequest(url, params, 'POST', 'dbCollectionInsert', this.shell); }; +/** + * Save a document. Simple full document replacement function. + * @name save + * @param {object} doc + */ mongo.Coll.prototype.save = function (doc) { var url = this.urlBase + 'save'; var params = {document: doc}; @@ -96,6 +124,9 @@ mongo.Coll.prototype.save = function (doc) { * Makes a remove request to the mongod instance on the backing server. On * success, the item(s) are removed from the collection, otherwise a failure * message is printed and an error is thrown. + * @name remove + * @param {object} constraint + * @param {boolean} justOne */ mongo.Coll.prototype.remove = function (constraint, justOne) { var url = this.urlBase + 'remove'; @@ -113,6 +144,12 @@ mongo.Coll.prototype.remove = function (constraint, justOne) { * Optionally, an object which specifies whether to perform an upsert and/or * a multiple update may be used instead of the individual upsert and multi * parameters. + * + * @name update + * @param {object} query + * @param update + * @param {object} upsert + * @param {object} multi */ mongo.Coll.prototype.update = function (query, update, upsert, multi) { var url = this.urlBase + 'update'; @@ -139,6 +176,8 @@ mongo.Coll.prototype.update = function (query, update, upsert, multi) { * Makes a drop request to the mongod instance on the backing server. On * success, the collection is dropped from the database, otherwise a failure * message is printed and an error is thrown. + * + * @name drop */ mongo.Coll.prototype.drop = function () { var url = this.urlBase + 'drop'; @@ -151,6 +190,9 @@ mongo.Coll.prototype.drop = function () { * Makes an aggregation request to the mongod instance on the backing server. * On success, the result of the aggregation is returned, otherwise a failure * message is printed and an error is thrown. + * + * @name aggregate + * @param {object} query */ mongo.Coll.prototype.aggregate = function() { var query; diff --git a/frontend/src/mws/Readline.js b/frontend/src/mws/Readline.js index 698edbc..2334b21 100644 --- a/frontend/src/mws/Readline.js +++ b/frontend/src/mws/Readline.js @@ -28,20 +28,24 @@ mongo.Readline = function (codemirror, submitFunction) { }; mongo.Readline.prototype.keydown = function (event) { + // check if the autocomplete window is open, if it is, just return, that gets priority + if (this.inputBox.state.completionActive) { + return; + } var key = mongo.config.keycodes; var line; switch (event.keyCode) { - case key.up: - line = this.getOlderHistoryEntry(); - break; - case key.down: - line = this.getNewerHistoryEntry(); - break; - case key.enter: - this.submit(this.inputBox.getValue()); - break; - default: - return; + case key.up: + line = this.getOlderHistoryEntry(); + break; + case key.down: + line = this.getNewerHistoryEntry(); + break; + case key.enter: + this.submit(this.inputBox.getValue()); + break; + default: + return; } if (line !== undefined && line !== null) { diff --git a/frontend/src/mws/Shell.js b/frontend/src/mws/Shell.js index 62deaa9..6061677 100644 --- a/frontend/src/mws/Shell.js +++ b/frontend/src/mws/Shell.js @@ -30,6 +30,281 @@ mongo.Shell = function (rootElement, shellID) { this.attachClickListener(); }; + +mongo.Shell.autocomplete = { + /** + * Cached version of each class's mapping from method name to JSDOC info, keyed by the path to the file + */ + jsDocs: {}, + /** + * Parse the JSDoc for the given file, and return a list of all available autocompletions. The JSDoc will be used + * as contextual information in the autocomplete menu. + * @param pathToFile + * @param context_object the validated context object, that is an instance of the class declared in pathToFile + * @param callback the function to pass the returned list of the autocompletion objects to, to then pass to CodeMirror, + * with custom render functions + */ + autocompletionsForObject: function(pathToFile, context_object, startsWith, callback) { + var proto = Object.getPrototypeOf(context_object); + var method_list = $.grep(Object.keys(proto), function(fn, i) { + return fn !== "toString" && fn.indexOf(startsWith) === 0 && fn.indexOf("__") !== 0; + }); + + var render = function(Element, self, data) { + var signature = document.createElement('span'); + signature.className = 'signature'; + + var obj = data.object; + if (obj !== undefined) { + var return_type = document.createElement('span'); + return_type.className = 'return-type'; + + return_type.textContent = obj.returnType; + signature.textContent = obj.signature || data.displayText || data.text; + Element.appendChild(return_type); + } else { + signature.textContent = data.displayText || data.text; + } + Element.appendChild(signature); + if (data.description !== undefined) { + Element.setAttribute('title', data.description); + } + }; + + var autocompletionObject = function(text, obj) { + var ret = {render: render, className: 'CodeMirror-hint-styled'}; + + ret.text = text; + if (obj !== undefined) { + ret.object = obj; + if (obj.description !== undefined) { + ret.description = obj.description; + } + } + + return ret; + }; + + // a function that takes in the method docs, which is a mapping from method name to documentation info + // and takes care of completing the list of autocompletions by matching up method_list to the methods listed in the + // docs + var handleMethodDocs = function(method_docs) { + var autocompletion_hints = []; + + for (var idx in method_list) { + var method = method_list[idx]; + var documentation = method_docs[method]; + if (documentation !== undefined) { + + var params = documentation.params; + var signature = method + '(' + params.join(', ') + ')'; + var text = method + '('; + autocompletion_hints.push(autocompletionObject(text, {description: documentation.description, returnType: documentation.returnType, signature: signature})); + } else { + autocompletion_hints.push(method); + } + } + callback(autocompletion_hints); + }; + + // first check to see if its cached + var cachedDocumentation = mongo.Shell.autocomplete.jsDocs[pathToFile]; + if (cachedDocumentation === undefined) { + $.get(pathToFile, function (data) { + + var method_docs = {}; + // using falafel doesn't work because it conveniently ignore comments + // so we use esprima's comment option + var result = esprima.parse(data, {comment: true}); + for (var idx in result.comments) { + var comment_info = result.comments[idx]; + if (comment_info.type !== 'Block') { + continue; + } + var doc = comment_info.value; + var jsdoc = doctrine.parse(doc, {unwrap: true, recoverable: true}); + var description = jsdoc.description || doc; // favor just the description, but if there isn't anything take everything + var method_name = null; + var return_type = 'void'; + var params = []; // a list of the types of all the parameters, as strings + for (var tag_idx in jsdoc.tags) { + var tag = jsdoc.tags[tag_idx]; + if (method_name === null && tag.title === "name") { + method_name = tag.name; + } else if(tag.title === "param") { + var type = tag.type; + if (type == null) { + params.push(tag.name); + } else { + params.push('{' + tag.type.name + '} ' + tag.name); + } + } else if(tag.title === "return" || tag.title === "returns") { + return_type = tag.type.name; + } + } + + if (method_name != null) { + // as long as we found a method name for this JSDOC, then we're good + method_docs[method_name] = {returnType: return_type, params: params, description: description}; + } + } + mongo.Shell.autocomplete.jsDocs[pathToFile] = method_docs; + handleMethodDocs(method_docs); + + }); + } else { + // the docs are cached, so we just need to do this: + handleMethodDocs(cachedDocumentation); + } + + + }, + /** + * Rules follow the following form: + * each entry is an object with two properties: + * the first: canAutocomplete is a function that takes in an object and returns whether or not the rule can handle this + * object + * the second: executeAutocomplete is a function that takes in the same object, the string that has been typed so far, and + * the callback function, and expects that the callback function is called once the autocomplete is done. + * The callback has one parameter, the list of options, as strings, to be presented to the user as completion options + * Rules are evaluated in order they are listed here, and once one rule returns true for canAutocomplete, no further + * rules will be evaluated. + * @type {Array} + */ + rules: [ + { + identifier: "DB --> collections", + canAutocomplete: function(context_object, shell) { + return context_object === shell.db; + }, + executeAutocomplete: function(context_object, shell, startsWith, callback) { + // check one more time + if (this.canAutocomplete(context_object, shell)) { + shell.db.getCollectionNames(function(obj){ + var list = $.grep(obj.result, function(name, i) { + return name.indexOf(startsWith) === 0; + }); + + callback(list); + }); + } + } + }, + { + identifier: "collection --> commands", + canAutocomplete: function(context_object, shell) { + return context_object.constructor === mongo.Coll; + }, + executeAutocomplete: function(context_object, shell, startsWith, callback) { + // check one more time + if (this.canAutocomplete(context_object, shell)) { + mongo.Shell.autocomplete.autocompletionsForObject('src/mws/Coll.js', context_object, startsWith, callback); + // TODO: we could potentially not only list the name of the function, but also present its method signature + // or autocomplete with blank parameters to begin with + // todo: list autocomplete in gray text as scrolling through + } + } + } + ], + // this is the autocomplete function that gets triggered on the tab keystroke, where cm is the relevant codemirror object + fn: function(shell, cm) { + var rules = this.rules; + var autocomplete = function(editor, callback, options) { + // this callback needs to be called regardless of if any autocompletions are available to make sure not to + // break anything. if it isn't called, the autocomplete window is assumed to be up, even though it isn't, and + // then the shell doesn't receive the correct keystrokes for submitting commands, etc + var claimedCallbackResponsibility = false; + var ret = function(list) { + if (list === undefined) { + list = []; + } + callback({list: list, from: CodeMirror.Pos(cur.line, token.start), to: CodeMirror.Pos(cur.line, token.end)}); + }; + + // it is surprisingly hard to be careful to call the autocomplete callback in every single return path + // no matter how many times I thought I got it right, I still messed up. So, we wrap the entire thing + // in a try .. finally clause, and call the callback in the finally clause. + // the only exception is when find a rule that matches and give responsibility to that function to call the callback + // in this case, we flip the claimedCallbackResponsibility boolean to true, which will verify that the callback isn't + // called in the finally clause. + try { + var cur = editor.getCursor(); + var token = editor.getTokenAt(cur); + var tprop = token; + if (token.type === "string" || token.type === "comment") { + return; + } + + // If it's not a 'word-style' token, ignore the token. + // The reg-ex matches anything that starts with a character that matches \w, $, or _ and that there are + // 0 or more of these in the entire string from start to end of the string in the token + // if the token's string does not match this, then we ignore the token altogether, unless the token's string + // was just a period, then we set its type to a property, which facilitates the context parsing to skip + // the last token + if (!/^[\w$_]*$/.test(token.string)) { + token = tprop = {start: cur.ch, end: cur.ch, string: "", state: token.state, + type: token.string == "." ? "property" : null}; + } + + var context = []; + // TODO: doesn't work with bracket notation (properties or object at index) + // Theoretically, we should be able to parse any composition of properties, brackets [], (including array indices) + // Functions, however, we won't try to eval, because those could modify state which we don't want + // If it is a property, find out what it is a property of. + while (tprop.type == "property") { + tprop = editor.getTokenAt(CodeMirror.Pos(cur.line, tprop.start)); + if (tprop.string != ".") return; + tprop = editor.getTokenAt(CodeMirror.Pos(cur.line, tprop.start)); + context.push(tprop); + } + if (context.length === 0) { + return; + } + context.reverse(); + var stringToEval; + for (var idx in context) { + var tok = context[idx]; + if (idx == 0) { + stringToEval = tok.string; + } else { + // Note, dot notation is needed because collection names are lazily evaluated + stringToEval += "." + tok.string; //"[\"" + tok.string + "\"]"; + } + } + + try { + shell.evaluator.eval(stringToEval, function(out, isError){ + if (isError) { + return; + } + for (var idx in rules) { + var rule = rules[idx]; + if (rule.canAutocomplete !== undefined) { + if (rule.canAutocomplete(out, shell) && rule.executeAutocomplete !== undefined) { + claimedCallbackResponsibility = true; + rule.executeAutocomplete(out, shell, token.string, ret); + return; + } + } + } + }); + } catch (err) { + // esprima might throw an except when parsing the stringToEval, which means there definitely isn't an + // autocompletion to do. This would happen for any token that ends in ) or ], since codemirror isn't smart + // enough to pick these up + } + } finally { + if (!claimedCallbackResponsibility) { + ret(); + } + } + }; + + CodeMirror.showHint(cm, autocomplete, {async: true}); + } +}; + + mongo.Shell.prototype.injectHTML = function () { // TODO: Use client-side templating instead. this.$rootElement.addClass('cm-s-solarized').addClass('cm-s-dark'); @@ -51,15 +326,19 @@ mongo.Shell.prototype.injectHTML = function () { lineWrapping: true, theme: 'solarized dark' }); + // We want the response box to be hidden until there is a response to show // (it gets shown in insertResponseLine). this.$responseWrapper.css({display: 'none'}); - this.inputBox = CodeMirror(this.$rootElement.find('.mws-input').get(0), { matchBrackets: true, lineWrapping: true, readOnly: 'nocursor', - theme: 'solarized dark' + theme: 'solarized dark', + extraKeys: { + "Tab": function(cm) { return mongo.Shell.autocomplete.fn(this, cm);}.bind(this), + "Ctrl-U": function(cm) { cm.setValue(''); } + } }); $(this.inputBox.getWrapperElement()).css({background: 'transparent'}); diff --git a/frontend/src/mws/keyword.js b/frontend/src/mws/keyword.js index a5a2f5b..7faebe1 100644 --- a/frontend/src/mws/keyword.js +++ b/frontend/src/mws/keyword.js @@ -93,6 +93,8 @@ mongo.keyword = (function () { shell.insertResponseLine(' List objects in collection foo.'); shell.insertResponseLine('db.foo.find(query)', []); shell.insertResponseLine(' List objects in foo matching query.'); + shell.insertResponseLine('db.foo.insert(object)', []); + shell.insertResponseLine(' Insert a new object into the collection foo'); shell.insertResponseLine('db.foo.update(query, update, upsert, multi)', []); shell.insertResponseLine(' Updates an object matching query with the given ' + 'update if no documents match and upsert is true, update is inserted if ' +