From 3941961f8b10d565c527365f7fbc1971df50fd95 Mon Sep 17 00:00:00 2001 From: Jason Siefken Date: Sat, 29 Oct 2011 01:20:49 -0700 Subject: [PATCH] lookingGlass: Add tab-completion https://bugzilla.gnome.org/show_bug.cgi?id=661054 --- data/theme/gnome-shell.css | 6 + js/Makefile.am | 1 + js/misc/jsParse.js | 246 +++++++++++++++++++++++++++++++++++++ js/ui/lookingGlass.js | 156 ++++++++++++++++++++++- tests/unit/jsParse.js | 194 +++++++++++++++++++++++++++++ 5 files changed, 599 insertions(+), 4 deletions(-) create mode 100644 js/misc/jsParse.js create mode 100644 tests/unit/jsParse.js diff --git a/data/theme/gnome-shell.css b/data/theme/gnome-shell.css index ea8187016..1e876f6d8 100644 --- a/data/theme/gnome-shell.css +++ b/data/theme/gnome-shell.css @@ -858,6 +858,12 @@ StTooltip StLabel { selected-color: #333333; } +.lg-completions-text +{ + font-size: .9em; + font-style: italic; +} + .lg-obj-inspector-title { spacing: 4px; diff --git a/js/Makefile.am b/js/Makefile.am index 58e04892b..ca7756ed8 100644 --- a/js/Makefile.am +++ b/js/Makefile.am @@ -13,6 +13,7 @@ nobase_dist_js_DATA = \ misc/format.js \ misc/gnomeSession.js \ misc/history.js \ + misc/jsParse.js \ misc/modemManager.js \ misc/params.js \ misc/screenSaver.js \ diff --git a/js/misc/jsParse.js b/js/misc/jsParse.js new file mode 100644 index 000000000..7f0c707a1 --- /dev/null +++ b/js/misc/jsParse.js @@ -0,0 +1,246 @@ +/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */ + +// Returns a list of potential completions for text. Completions either +// follow a dot (e.g. foo.ba -> bar) or they are picked from globalCompletionList (e.g. fo -> foo) +// commandHeader is prefixed on any expression before it is eval'ed. It will most likely +// consist of global constants that might not carry over from the calling environment. +// +// This function is likely the one you want to call from external modules +function getCompletions(text, commandHeader, globalCompletionList) { + let methods = []; + let expr, base; + let attrHead = ''; + if (globalCompletionList == null) { + globalCompletionList = []; + } + + let offset = getExpressionOffset(text, text.length - 1); + if (offset >= 0) { + text = text.slice(offset); + + // Look for expressions like "Main.panel.foo" and match Main.panel and foo + let matches = text.match(/(.*)\.(.*)/); + if (matches) { + [expr, base, attrHead] = matches; + + methods = getPropertyNamesFromExpression(base, commandHeader).filter(function(attr) { + return attr.slice(0, attrHead.length) == attrHead; + }); + } + + // Look for the empty expression or partially entered words + // not proceeded by a dot and match them against global constants + matches = text.match(/^(\w*)$/); + if (text == '' || matches) { + [expr, attrHead] = matches; + methods = globalCompletionList.filter(function(attr) { + return attr.slice(0, attrHead.length) == attrHead; + }); + } + } + + return [methods, attrHead]; +} + + +// +// A few functions for parsing strings of javascript code. +// + +// Identify characters that delimit an expression. That is, +// if we encounter anything that isn't a letter, '.', ')', or ']', +// we should stop parsing. +function isStopChar(c) { + return !c.match(/[\w\.\)\]]/); +} + +// Given the ending position of a quoted string, find where it starts +function findMatchingQuote(expr, offset) { + let quoteChar = expr.charAt(offset); + for (let i = offset - 1; i >= 0; --i) { + if (expr.charAt(i) == quoteChar && expr.charAt(i-1) != '\\'){ + return i; + } + } + return -1; +} + +// Given the ending position of a regex, find where it starts +function findMatchingSlash(expr, offset) { + for (let i = offset - 1; i >= 0; --i) { + if (expr.charAt(i) == '/' && expr.charAt(i-1) != '\\'){ + return i; + } + } + return -1; +} + +// If expr.charAt(offset) is ')' or ']', +// return the position of the corresponding '(' or '[' bracket. +// This function does not check for syntactic correctness. e.g., +// findMatchingBrace("[(])", 3) returns 1. +function findMatchingBrace(expr, offset) { + let closeBrace = expr.charAt(offset); + let openBrace = ({')': '(', ']': '['})[closeBrace]; + + function findTheBrace(expr, offset) { + if (offset < 0) { + return -1; + } + + if (expr.charAt(offset) == openBrace) { + return offset; + } + if (expr.charAt(offset).match(/['"]/)) { + return findTheBrace(expr, findMatchingQuote(expr, offset) - 1); + } + if (expr.charAt(offset) == '/') { + return findTheBrace(expr, findMatchingSlash(expr, offset) - 1); + } + if (expr.charAt(offset) == closeBrace) { + return findTheBrace(expr, findTheBrace(expr, offset - 1) - 1); + } + + return findTheBrace(expr, offset - 1); + + } + + return findTheBrace(expr, offset - 1); +} + +// Walk expr backwards from offset looking for the beginning of an +// expression suitable for passing to eval. +// There is no guarantee of correct javascript syntax between the return +// value and offset. This function is meant to take a string like +// "foo(Obj.We.Are.Completing" and allow you to extract "Obj.We.Are.Completing" +function getExpressionOffset(expr, offset) { + while (offset >= 0) { + let currChar = expr.charAt(offset); + + if (isStopChar(currChar)){ + return offset + 1; + } + + if (currChar.match(/[\)\]]/)) { + offset = findMatchingBrace(expr, offset); + } + + --offset; + } + + return offset + 1; +} + +// Things with non-word characters or that start with a number +// are not accessible via .foo notation and so aren't returned +function isValidPropertyName(w) { + return !(w.match(/\W/) || w.match(/^\d/)); +} + +// To get all properties (enumerable and not), we need to walk +// the prototype chain ourselves +function getAllProps(obj) { + if (obj === null || obj === undefined) { + return []; + } + return Object.getOwnPropertyNames(obj).concat( getAllProps(Object.getPrototypeOf(obj)) ); +} + +// Given a string _expr_, returns all methods +// that can be accessed via '.' notation. +// e.g., expr="({ foo: null, bar: null, 4: null })" will +// return ["foo", "bar", ...] but the list will not include "4", +// since methods accessed with '.' notation must star with a letter or _. +function getPropertyNamesFromExpression(expr, commandHeader) { + if (commandHeader == null) { + commandHeader = ''; + } + + let obj = {}; + if (!isUnsafeExpression(expr)) { + try { + obj = eval(commandHeader + expr); + } catch (e) { + return []; + } + } else { + return []; + } + + let propsUnique = {}; + if (typeof obj === 'object'){ + let allProps = getAllProps(obj); + // Get only things we are allowed to complete following a '.' + allProps = allProps.filter( isValidPropertyName ); + + // Make sure propsUnique contains one key for every + // property so we end up with a unique list of properties + allProps.map(function(p){ propsUnique[p] = null; }); + } + return Object.keys(propsUnique).sort(); +} + +// Given a list of words, returns the longest prefix they all have in common +function getCommonPrefix(words) { + let word = words[0]; + for (let i = 0; i < word.length; i++) { + for (let w = 1; w < words.length; w++) { + if (words[w].charAt(i) != word.charAt(i)) + return word.slice(0, i); + } + } + return word; +} + +// Returns true if there is reason to think that eval(str) +// will modify the global scope +function isUnsafeExpression(str) { + // Remove any blocks that are quoted or are in a regex + function removeLiterals(str) { + if (str.length == 0) { + return ''; + } + + let currChar = str.charAt(str.length - 1); + if (currChar == '"' || currChar == '\'') { + return removeLiterals(str.slice(0, findMatchingQuote(str, str.length - 1))); + } else if (currChar == '/') { + return removeLiterals(str.slice(0, findMatchingSlash(str, str.length - 1))); + } + + return removeLiterals(str.slice(0, str.length - 1)) + currChar; + } + + // Check for any sort of assignment + // The strategy used is dumb: remove any quotes + // or regexs and comparison operators and see if there is an '=' character. + // If there is, it might be an unsafe assignment. + + let prunedStr = removeLiterals(str); + prunedStr = prunedStr.replace(/[=!]==/g, ''); //replace === and !== with nothing + prunedStr = prunedStr.replace(/[=<>!]=/g, ''); //replace ==, <=, >=, != with nothing + + if (prunedStr.match(/=/)) { + return true; + } else if (prunedStr.match(/;/)) { + // If we contain a semicolon not inside of a quote/regex, assume we're unsafe as well + return true; + } + + return false; +} + +// Returns a list of global keywords derived from str +function getDeclaredConstants(str) { + let ret = []; + str.split(';').forEach(function(s) { + let base, keyword; + let match = s.match(/const\s+(\w+)\s*=/); + if (match) { + [base, keyword] = match; + ret.push(keyword); + } + }); + + return ret; +} diff --git a/js/ui/lookingGlass.js b/js/ui/lookingGlass.js index 4e383b364..aa7322062 100644 --- a/js/ui/lookingGlass.js +++ b/js/ui/lookingGlass.js @@ -20,6 +20,7 @@ const Link = imports.ui.link; const ShellEntry = imports.ui.shellEntry; const Tweener = imports.ui.tweener; const Main = imports.ui.main; +const JsParse = imports.misc.jsParse; /* Imports...feel free to add here as needed */ var commandHeader = 'const Clutter = imports.gi.Clutter; ' + @@ -41,6 +42,86 @@ var commandHeader = 'const Clutter = imports.gi.Clutter; ' + 'const r = Lang.bind(Main.lookingGlass, Main.lookingGlass.getResult); '; const HISTORY_KEY = 'looking-glass-history'; +// Time between tabs for them to count as a double-tab event +const AUTO_COMPLETE_DOUBLE_TAB_DELAY = 500; +const AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION = 0.2; +const AUTO_COMPLETE_GLOBAL_KEYWORDS = _getAutoCompleteGlobalKeywords(); + +function _getAutoCompleteGlobalKeywords() { + const keywords = ['true', 'false', 'null', 'new']; + // Don't add the private properties of window (i.e., ones starting with '_') + const windowProperties = Object.getOwnPropertyNames(window).filter(function(a){ return a.charAt(0) != '_' }); + const headerProperties = JsParse.getDeclaredConstants(commandHeader); + + return keywords.concat(windowProperties).concat(headerProperties); +} + +function AutoComplete(entry) { + this._init(entry); +} + +AutoComplete.prototype = { + _init: function(entry) { + this._entry = entry; + this._entry.connect('key-press-event', Lang.bind(this, this._entryKeyPressEvent)); + this._lastTabTime = global.get_current_time(); + }, + + _processCompletionRequest: function(event) { + if (event.completions.length == 0) { + return; + } + // Unique match = go ahead and complete; multiple matches + single tab = complete the common starting string; + // multiple matches + double tab = emit a suggest event with all possible options + if (event.completions.length == 1) { + this.additionalCompletionText(event.completions[0], event.attrHead); + this.emit('completion', { completion: event.completions[0], type: 'whole-word' }); + } else if (event.completions.length > 1 && event.tabType === 'single') { + let commonPrefix = JsParse.getCommonPrefix(event.completions); + + if (commonPrefix.length > 0) { + this.additionalCompletionText(commonPrefix, event.attrHead); + this.emit('completion', { completion: commonPrefix, type: 'prefix' }); + this.emit('suggest', { completions: event.completions}); + } + } else if (event.completions.length > 1 && event.tabType === 'double') { + this.emit('suggest', { completions: event.completions}); + } + }, + + _entryKeyPressEvent: function(actor, event) { + let cursorPos = this._entry.clutter_text.get_cursor_position(); + let text = this._entry.get_text(); + if (cursorPos != -1) { + text = text.slice(0, cursorPos); + } + if (event.get_key_symbol() == Clutter.Tab) { + let [completions, attrHead] = JsParse.getCompletions(text, commandHeader, AUTO_COMPLETE_GLOBAL_KEYWORDS); + let currTime = global.get_current_time(); + if ((currTime - this._lastTabTime) < AUTO_COMPLETE_DOUBLE_TAB_DELAY) { + this._processCompletionRequest({ tabType: 'double', + completions: completions, + attrHead: attrHead }); + } else { + this._processCompletionRequest({ tabType: 'single', + completions: completions, + attrHead: attrHead }); + } + this._lastTabTime = currTime; + } + }, + + // Insert characters of text not already included in head at cursor position. i.e., if text="abc" and head="a", + // the string "bc" will be appended to this._entry + additionalCompletionText: function(text, head) { + let additionalCompletionText = text.slice(head.length); + let cursorPos = this._entry.clutter_text.get_cursor_position(); + + this._entry.clutter_text.insert_text(additionalCompletionText, cursorPos); + } +}; +Signals.addSignalMethods(AutoComplete.prototype); + function Notebook() { this._init(); @@ -864,15 +945,15 @@ LookingGlass.prototype = { this._resultsArea = new St.BoxLayout({ name: 'ResultsArea', vertical: true }); this._evalBox.add(this._resultsArea, { expand: true }); - let entryArea = new St.BoxLayout({ name: 'EntryArea' }); - this._evalBox.add_actor(entryArea); + this._entryArea = new St.BoxLayout({ name: 'EntryArea' }); + this._evalBox.add_actor(this._entryArea); let label = new St.Label({ text: 'js>>> ' }); - entryArea.add(label); + this._entryArea.add(label); this._entry = new St.Entry({ can_focus: true }); ShellEntry.addContextMenu(this._entry); - entryArea.add(this._entry, { expand: true }); + this._entryArea.add(this._entry, { expand: true }); this._windowList = new WindowList(); this._windowList.connect('selected', Lang.bind(this, function(list, window) { @@ -891,6 +972,9 @@ LookingGlass.prototype = { notebook.appendPage('Extensions', this._extensions.actor); this._entry.clutter_text.connect('activate', Lang.bind(this, function (o, e) { + // Hide any completions we are currently showing + this._hideCompletions(); + let text = o.get_text(); // Ensure we don't get newlines in the command; the history file is // newline-separated. @@ -906,6 +990,17 @@ LookingGlass.prototype = { this._history = new History.HistoryManager({ gsettingsKey: HISTORY_KEY, entry: this._entry.clutter_text }); + this._autoComplete = new AutoComplete(this._entry); + this._autoComplete.connect('suggest', Lang.bind(this, function(a,e) { + this._showCompletions(e.completions); + })); + // If a completion is completed unambiguously, the currently-displayed completion + // suggestions become irrelevant. + this._autoComplete.connect('completion', Lang.bind(this, function(a,e) { + if (e.type == 'whole-word') + this._hideCompletions(); + })); + this._resize(); }, @@ -950,6 +1045,59 @@ LookingGlass.prototype = { this._notebook.scrollToBottom(0); }, + _showCompletions: function(completions) { + if (!this._completionActor) { + let actor = new St.BoxLayout({ vertical: true }); + + this._completionText = new St.Label({ name: 'LookingGlassAutoCompletionText', style_class: 'lg-completions-text' }); + this._completionText.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._completionText.clutter_text.line_wrap = true; + actor.add(this._completionText); + + let line = new Clutter.Rectangle(); + let padBin = new St.Bin({ x_fill: true, y_fill: true }); + padBin.add_actor(line); + actor.add(padBin); + + this._completionActor = actor; + this._evalBox.insert_before(this._completionActor, this._entryArea); + } + + this._completionText.set_text(completions.join(', ')); + + // Setting the height to -1 allows us to get its actual preferred height rather than + // whatever was last given in set_height by Tweener. + this._completionActor.set_height(-1); + let [minHeight, naturalHeight] = this._completionText.get_preferred_height(this._resultsArea.get_width()); + + // Don't reanimate if we are already visible + if (this._completionActor.visible) { + this._completionActor.height = naturalHeight; + } else { + this._completionActor.show(); + Tweener.removeTweens(this._completionActor); + Tweener.addTween(this._completionActor, { time: AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION / St.get_slow_down_factor(), + transition: 'easeOutQuad', + height: naturalHeight, + opacity: 255 + }); + } + }, + + _hideCompletions: function() { + if (this._completionActor) { + Tweener.removeTweens(this._completionActor); + Tweener.addTween(this._completionActor, { time: AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION / St.get_slow_down_factor(), + transition: 'easeOutQuad', + height: 0, + opacity: 0, + onComplete: Lang.bind(this, function () { + this._completionActor.hide(); + }) + }); + } + }, + _evaluate : function(command) { this._history.addItem(command); diff --git a/tests/unit/jsParse.js b/tests/unit/jsParse.js new file mode 100644 index 000000000..ed550c9b9 --- /dev/null +++ b/tests/unit/jsParse.js @@ -0,0 +1,194 @@ +/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */ + +// Test cases for MessageTray URLification + +const JsUnit = imports.jsUnit; + +const Environment = imports.ui.environment; +Environment.init(); + +const JsParse = imports.misc.jsParse; + +const HARNESS_COMMAND_HEADER = "let imports = obj;" + + "let global = obj;" + + "let Main = obj;" + + "let foo = obj;" + + "let r = obj;"; + +const testsFindMatchingQuote = [ + { input: '"double quotes"', + output: 0 }, + { input: '\'single quotes\'', + output: 0 }, + { input: 'some unquoted "some quoted"', + output: 14 }, + { input: '"mixed \' quotes\'"', + output: 0 }, + { input: '"escaped \\" quote"', + output: 0 } +]; +const testsFindMatchingSlash = [ + { input: '/slash/', + output: 0 }, + { input: '/slash " with $ funny ^\' stuff/', + output: 0 }, + { input: 'some unslashed /some slashed/', + output: 15 }, + { input: '/escaped \\/ slash/', + output: 0 } +]; +const testsFindMatchingBrace = [ + { input: '[square brace]', + output: 0 }, + { input: '(round brace)', + output: 0 }, + { input: '([()][nesting!])', + output: 0 }, + { input: '[we have "quoted [" braces]', + output: 0 }, + { input: '[we have /regex [/ braces]', + output: 0 }, + { input: '([[])[] mismatched braces ]', + output: 1 } +]; +const testsGetExpressionOffset = [ + { input: 'abc.123', + output: 0 }, + { input: 'foo().bar', + output: 0 }, + { input: 'foo(bar', + output: 4 }, + { input: 'foo[abc.match(/"/)]', + output: 0 } +]; +const testsGetDeclaredConstants = [ + { input: 'const foo = X; const bar = Y;', + output: ['foo', 'bar'] }, + { input: 'const foo=X; const bar=Y', + output: ['foo', 'bar'] } +]; +const testsIsUnsafeExpression = [ + { input: 'foo.bar', + output: false }, + { input: 'foo[\'bar\']', + output: false }, + { input: 'foo["a=b=c".match(/=/)', + output: false }, + { input: 'foo[1==2]', + output: false }, + { input: '(x=4)', + output: true }, + { input: '(x = 4)', + output: true }, + { input: '(x;y)', + output: true } +]; +const testsModifyScope = [ + "foo['a", + "foo()['b'", + "obj.foo()('a', 1, 2, 'b')().", + "foo.[.", + "foo]]]()))].", + "123'ab\"", + "Main.foo.bar = 3; bar.", + "(Main.foo = 3).", + "Main[Main.foo+=-1]." +]; + + + +// Utility function for comparing arrays +function assertArrayEquals(errorMessage, array1, array2) { + JsUnit.assertEquals(errorMessage + ' length', + array1.length, array2.length); + for (let j = 0; j < array1.length; j++) { + JsUnit.assertEquals(errorMessage + ' item ' + j, + array1[j], array2[j]); + } +} + +// +// Test javascript parsing +// + +for (let i = 0; i < testsFindMatchingQuote.length; i++) { + let text = testsFindMatchingQuote[i].input; + let match = JsParse.findMatchingQuote(text, text.length - 1); + + JsUnit.assertEquals('Test testsFindMatchingQuote ' + i, + match, testsFindMatchingQuote[i].output); +} + +for (let i = 0; i < testsFindMatchingSlash.length; i++) { + let text = testsFindMatchingSlash[i].input; + let match = JsParse.findMatchingSlash(text, text.length - 1); + + JsUnit.assertEquals('Test testsFindMatchingSlash ' + i, + match, testsFindMatchingSlash[i].output); +} + +for (let i = 0; i < testsFindMatchingBrace.length; i++) { + let text = testsFindMatchingBrace[i].input; + let match = JsParse.findMatchingBrace(text, text.length - 1); + + JsUnit.assertEquals('Test testsFindMatchingBrace ' + i, + match, testsFindMatchingBrace[i].output); +} + +for (let i = 0; i < testsGetExpressionOffset.length; i++) { + let text = testsGetExpressionOffset[i].input; + let match = JsParse.getExpressionOffset(text, text.length - 1); + + JsUnit.assertEquals('Test testsGetExpressionOffset ' + i, + match, testsGetExpressionOffset[i].output); +} + +for (let i = 0; i < testsGetDeclaredConstants.length; i++) { + let text = testsGetDeclaredConstants[i].input; + let match = JsParse.getDeclaredConstants(text); + + assertArrayEquals('Test testsGetDeclaredConstants ' + i, + match, testsGetDeclaredConstants[i].output); +} + +for (let i = 0; i < testsIsUnsafeExpression.length; i++) { + let text = testsIsUnsafeExpression[i].input; + let unsafe = JsParse.isUnsafeExpression(text); + + JsUnit.assertEquals('Test testsIsUnsafeExpression ' + i, + unsafe, testsIsUnsafeExpression[i].output); +} + +// +// Test safety of eval to get completions +// + +for (let i = 0; i < testsModifyScope.length; i++) { + let text = testsModifyScope[i]; + // We need to use var here for the with statement + var obj = {}; + + // Just as in JsParse.getCompletions, we will find the offset + // of the expression, test whether it is unsafe, and then eval it. + let offset = JsParse.getExpressionOffset(text, text.length - 1); + if (offset >= 0) { + text = text.slice(offset); + + let matches = text.match(/(.*)\.(.*)/); + if (matches) { + [expr, base, attrHead] = matches; + + if (!JsParse.isUnsafeExpression(base)) { + with (obj) { + try { + eval(HARNESS_COMMAND_HEADER + base); + } catch (e) { + JsUnit.assertNotEquals("Code '" + base + "' is valid code", e.constructor, SyntaxError); + } + } + } + } + } + let propertyNames = Object.getOwnPropertyNames(obj); + JsUnit.assertEquals("The context '" + JSON.stringify(obj) + "' was not modified", propertyNames.length, 0); +}