lookingGlass: Add tab-completion

https://bugzilla.gnome.org/show_bug.cgi?id=661054
This commit is contained in:
Jason Siefken 2011-10-29 01:20:49 -07:00 committed by Jasper St. Pierre
parent d9c6485cbf
commit 3941961f8b
5 changed files with 599 additions and 4 deletions

View File

@ -858,6 +858,12 @@ StTooltip StLabel {
selected-color: #333333;
}
.lg-completions-text
{
font-size: .9em;
font-style: italic;
}
.lg-obj-inspector-title
{
spacing: 4px;

View File

@ -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 \

246
js/misc/jsParse.js Normal file
View File

@ -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;
}

View File

@ -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);

194
tests/unit/jsParse.js Normal file
View File

@ -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);
}