bug 568649 - JSTerm keybindings - tab completion. r=dietrich

This commit is contained in:
Julian Viereck 2010-07-19 11:38:07 -03:00
parent d36a8cb1ee
commit 20dff20b68
4 changed files with 456 additions and 11 deletions

View File

@ -2091,6 +2091,220 @@ function NodeFactory(aFactoryType, aNameSpace, aDocument)
}
}
//////////////////////////////////////////////////////////////////////////
// JS Completer
//////////////////////////////////////////////////////////////////////////
const STATE_NORMAL = 0;
const STATE_QUOTE = 2;
const STATE_DQUOTE = 3;
const OPEN_BODY = '{[('.split('');
const CLOSE_BODY = '}])'.split('');
const OPEN_CLOSE_BODY = {
'{': '}',
'[': ']',
'(': ')'
};
/**
* Analyses a given string to find the last statement that is interesting for
* later completion.
*
* @param string aStr
* A string to analyse.
*
* @returns object
* If there was an error in the string detected, then a object like
*
* { err: "ErrorMesssage" }
*
* is returned, otherwise a object like
*
* {
* state: STATE_NORMAL|STATE_QUOTE|STATE_DQUOTE,
* startPos: index of where the last statement begins
* }
*/
function findCompletionBeginning(aStr)
{
let bodyStack = [];
let state = STATE_NORMAL;
let start = 0;
let c;
for (let i = 0; i < aStr.length; i++) {
c = aStr[i];
switch (state) {
// Normal JS state.
case STATE_NORMAL:
if (c == '"') {
state = STATE_DQUOTE;
}
else if (c == '\'') {
state = STATE_QUOTE;
}
else if (c == ';') {
start = i + 1;
}
else if (c == ' ') {
start = i + 1;
}
else if (OPEN_BODY.indexOf(c) != -1) {
bodyStack.push({
token: c,
start: start
});
start = i + 1;
}
else if (CLOSE_BODY.indexOf(c) != -1) {
var last = bodyStack.pop();
if (OPEN_CLOSE_BODY[last.token] != c) {
return {
err: "syntax error"
};
}
if (c == '}') {
start = i + 1;
}
else {
start = last.start;
}
}
break;
// Double quote state > " <
case STATE_DQUOTE:
if (c == '\\') {
i ++;
}
else if (c == '\n') {
return {
err: "unterminated string literal"
};
}
else if (c == '"') {
state = STATE_NORMAL;
}
break;
// Single quoate state > ' <
case STATE_QUOTE:
if (c == '\\') {
i ++;
}
else if (c == '\n') {
return {
err: "unterminated string literal"
};
return;
}
else if (c == '\'') {
state = STATE_NORMAL;
}
break;
}
}
return {
state: state,
startPos: start
};
}
/**
* Provides a list of properties, that are possible matches based on the passed
* scope and inputValue.
*
* @param object aScope
* Scope to use for the completion.
*
* @param string aInputValue
* Value that should be completed.
*
* @returns null or object
* If no completion valued could be computed, null is returned,
* otherwise a object with the following form is returned:
* {
* matches: [ string, string, string ],
* matchProp: Last part of the inputValue that was used to find
* the matches-strings.
* }
*/
function JSPropertyProvider(aScope, aInputValue)
{
let obj = aScope;
// Analyse the aInputValue and find the beginning of the last part that
// should be completed.
let beginning = findCompletionBeginning(aInputValue);
// There was an error analysing the string.
if (beginning.err) {
return null;
}
// If the current state is not STATE_NORMAL, then we are inside of an string
// which means that no completion is possible.
if (beginning.state != STATE_NORMAL) {
return null;
}
let completionPart = aInputValue.substring(beginning.startPos);
// Don't complete on just an empty string.
if (completionPart.trim() == "") {
return null;
}
let properties = completionPart.split('.');
let matchProp;
if (properties.length > 1) {
matchProp = properties[properties.length - 1].trimLeft();
properties.pop();
for each (var prop in properties) {
prop = prop.trim();
// If obj is undefined or null, then there is no change to run
// completion on it. Exit here.
if (typeof obj === "undefined" || obj === null) {
return null;
}
// Check if prop is a getter function on obj. Functions can change other
// stuff so we can't execute them to get the next object. Stop here.
if (obj.__lookupGetter__(prop)) {
return null;
}
obj = obj[prop];
}
}
else {
matchProp = properties[0].trimLeft();
}
// If obj is undefined or null, then there is no change to run
// completion on it. Exit here.
if (typeof obj === "undefined" || obj === null) {
return null;
}
let matches = [];
for (var prop in obj) {
matches.push(prop);
}
matches = matches.filter(function(item) {
return item.indexOf(matchProp) == 0;
}).sort();
return {
matchProp: matchProp,
matches: matches
};
}
//////////////////////////////////////////////////////////////////////////
// JSTerm
//////////////////////////////////////////////////////////////////////////
@ -2140,6 +2354,13 @@ function JSTerm(aContext, aParentNode, aMixin)
}
JSTerm.prototype = {
propertyProvider: JSPropertyProvider,
COMPLETE_FORWARD: 0,
COMPLETE_BACKWARD: 1,
COMPLETE_HINT_ONLY: 2,
init: function JST_init()
{
this.createSandbox();
@ -2262,11 +2483,11 @@ JSTerm.prototype = {
case 97:
// control-a
tmp = self.codeInputString;
setTimeout(function() {
self.inputNode.value = tmp;
self.inputNode.setSelectionRange(0, 0);
},0);
break;
setTimeout(function() {
self.inputNode.value = tmp;
self.inputNode.setSelectionRange(0, 0);
}, 0);
break;
case 101:
// control-e
tmp = self.codeInputString;
@ -2274,7 +2495,7 @@ JSTerm.prototype = {
setTimeout(function(){
var endPos = tmp.length + 1;
self.inputNode.value = tmp;
},0);
}, 0);
break;
default:
return;
@ -2316,8 +2537,15 @@ JSTerm.prototype = {
break;
case 9:
// tab key
// TODO: this.tabComplete();
// see bug 568649
// If there are more than one possible completion, pressing tab
// means taking the next completion, shift_tab means taking
// the previous completion.
if (aEvent.shiftKey) {
self.complete(self.COMPLETE_BACKWARD);
}
else {
self.complete(self.COMPLETE_FORWARD);
}
var bool = aEvent.cancelable;
if (bool) {
aEvent.preventDefault();
@ -2327,7 +2555,24 @@ JSTerm.prototype = {
}
aEvent.target.focus();
break;
case 8:
// backspace key
case 46:
// delete key
// necessary so that default is not reached.
break;
default:
// all not handled keys
// Store the current inputNode value. If the value is the same
// after keyDown event was handled (after 0ms) then the user
// moved the cursor. If the value changed, then call the complete
// function to show completion on new value.
var value = self.inputNode.value;
setTimeout(function() {
if (self.inputNode.value !== value) {
self.complete(self.COMPLETE_HINT_ONLY);
}
}, 0);
break;
}
return;
@ -2392,9 +2637,120 @@ JSTerm.prototype = {
history: [],
tabComplete: function JSTF_tabComplete(aInputValue) {
// parse input value:
// TODO: see bug 568649
// Stores the data for the last completion.
lastCompletion: null,
/**
* Completes the current typed text in the inputNode. Completion is performed
* only if the selection/cursor is at the end of the string. If no completion
* is found, the current inputNode value and cursor/selection stay.
*
* @param int type possible values are
* - this.COMPLETE_FORWARD: If there is more than one possible completion
* and the input value stayed the same compared to the last time this
* function was called, then the next completion of all possible
* completions is used. If the value changed, then the first possible
* completion is used and the selection is set from the current
* cursor position to the end of the completed text.
* If there is only one possible completion, then this completion
* value is used and the cursor is put at the end of the completion.
* - this.COMPLETE_BACKWARD: Same as this.COMPLETE_FORWARD but if the
* value stayed the same as the last time the function was called,
* then the previous completion of all possible completions is used.
* - this.COMPLETE_HINT_ONLY: If there is more than one possible
* completion and the input value stayed the same compared to the
* last time this function was called, then the same completion is
* used again. If there is only one possible completion, then
* the inputNode.value is set to this value and the selection is set
* from the current cursor position to the end of the completed text.
*
* @returns void
*/
complete: function JSTF_complete(type)
{
let inputNode = this.inputNode;
let inputValue = inputNode.value;
let selStart = inputNode.selectionStart, selEnd = inputNode.selectionEnd;
// 'Normalize' the selection so that end is always after start.
if (selStart > selEnd) {
let newSelEnd = selStart;
selStart = selEnd;
selEnd = newSelEnd;
}
// Only complete if the selection is at the end of the input.
if (selEnd != inputValue.length) {
this.lastCompletion = null;
return;
}
// Remove the selected text from the inputValue.
inputValue = inputValue.substring(0, selStart);
let matches;
let matchIndexToUse;
let matchOffset;
let completionStr;
// If there is a saved completion from last time and the used value for
// completion stayed the same, then use the stored completion.
if (this.lastCompletion && inputValue == this.lastCompletion.value) {
matches = this.lastCompletion.matches;
matchOffset = this.lastCompletion.matchOffset;
if (type === this.COMPLETE_BACKWARD) {
this.lastCompletion.index --;
}
else if (type === this.COMPLETE_FORWARD) {
this.lastCompletion.index ++;
}
matchIndexToUse = this.lastCompletion.index;
}
else {
// Look up possible completion values.
let completion = this.propertyProvider(this.sandbox.window, inputValue);
if (!completion) {
return;
}
matches = completion.matches;
matchIndexToUse = 0;
matchOffset = completion.matchProp.length
// Store this match;
this.lastCompletion = {
index: 0,
value: inputValue,
matches: matches,
matchOffset: matchOffset
};
}
if (matches.length != 0) {
// Ensure that the matchIndexToUse is always a valid array index.
if (matchIndexToUse < 0) {
matchIndexToUse = matches.length + (matchIndexToUse % matches.length);
if (matchIndexToUse == matches.length) {
matchIndexToUse = 0;
}
}
else {
matchIndexToUse = matchIndexToUse % matches.length;
}
completionStr = matches[matchIndexToUse].substring(matchOffset);
this.inputNode.value = inputValue + completionStr;
selEnd = inputValue.length + completionStr.length;
// If there is more than one possible completion or the completed part
// should get displayed only without moving the cursor at the end of the
// completion.
if (matches.length > 1 || type === this.COMPLETE_HINT_ONLY) {
inputNode.setSelectionRange(selStart, selEnd);
}
else {
inputNode.setSelectionRange(selEnd, selEnd);
}
}
}
};

View File

@ -55,6 +55,7 @@ _BROWSER_TEST_PAGES = \
test-filter.html \
test-observe-http-ajax.html \
test-data.json \
test-property-provider.html \
$(NULL)
libs:: $(_BROWSER_TEST_FILES)

View File

@ -63,6 +63,8 @@ const TEST_NETWORK_URI = "http://example.com/browser/toolkit/components/console/
const TEST_FILTER_URI = "http://example.com/browser/toolkit/components/console/hudservice/tests/browser/test-filter.html";
const TEST_PROPERTY_PROVIDER_URI = "http://example.com/browser/toolkit/components/console/hudservice/tests/browser/test-property-provider.html";
function noCacheUriSpec(aUriSpec) {
return aUriSpec + "?_=" + Date.now();
}
@ -430,6 +432,77 @@ function testConsoleHistory()
is (input.value, executeList[idxLast], "check history next idx:" + idxLast);
}
// test property provider
function testPropertyProvider()
{
var HUD = HUDService.hudWeakReferences[hudId].get();
var jsterm = HUD.jsterm;
var context = jsterm.sandbox.window;
var completion;
// Test if the propertyProvider can be accessed from the jsterm object.
ok (jsterm.propertyProvider !== undefined, "JSPropertyProvider is defined");
completion = jsterm.propertyProvider(context, "thisIsNotDefined");
is (completion.matches.length, 0, "no match for 'thisIsNotDefined");
// This is a case the PropertyProvider can't handle. Should return null.
completion = jsterm.propertyProvider(context, "window[1].acb");
is (completion, null, "no match for 'window[1].acb");
// A very advanced completion case.
var strComplete =
'function a() { }document;document.getElementById(window.locatio';
completion = jsterm.propertyProvider(context, strComplete);
ok(completion.matches.length == 2, "two matches found");
ok(completion.matchProp == "locatio", "matching part is 'test'");
ok(completion.matches[0] == "location", "the first match is 'location'");
ok(completion.matches[1] == "locationbar", "the second match is 'locationbar'");
}
function testCompletion()
{
var HUD = HUDService.hudWeakReferences[hudId].get();
var jsterm = HUD.jsterm;
var input = jsterm.inputNode;
// Test typing 'docu'.
input.value = "docu";
input.setSelectionRange(4, 4);
jsterm.complete(jsterm.COMPLETE_HINT_ONLY);
is(input.value, "document", "'docu' completion");
is(input.selectionStart, 4, "start selection is alright");
is(input.selectionEnd, 8, "end selection is alright");
// Test typing 'docu' and press tab.
input.value = "docu";
input.setSelectionRange(4, 4);
jsterm.complete(jsterm.COMPLETE_FORWARD);
is(input.value, "document", "'docu' tab completion");
is(input.selectionStart, 8, "start selection is alright");
is(input.selectionEnd, 8, "end selection is alright");
// Test typing 'document.getElem'.
input.value = "document.getElem";
input.setSelectionRange(16, 16);
jsterm.complete(jsterm.COMPLETE_HINT_ONLY);
is(input.value, "document.getElementById", "'document.getElem' completion");
is(input.selectionStart, 16, "start selection is alright");
is(input.selectionEnd, 23, "end selection is alright");
// Test pressing tab another time.
jsterm.complete(jsterm.COMPLETE_FORWARD);
is(input.value, "document.getElementsByClassName", "'document.getElem' another tab completion");
is(input.selectionStart, 16, "start selection is alright");
is(input.selectionEnd, 31, "end selection is alright");
// Test pressing shift_tab.
jsterm.complete(jsterm.COMPLETE_BACKWARD);
is(input.value, "document.getElementById", "'document.getElem' untab completion");
is(input.selectionStart, 16, "start selection is alright");
is(input.selectionEnd, 23, "end selection is alright");
}
function testExecutionScope()
{
content.location.href = TEST_URI;
@ -533,6 +606,8 @@ function test() {
testOutputOrder();
testNullUndefinedOutput();
testExecutionScope();
testCompletion();
testPropertyProvider();
// testUnregister();
executeSoon(function () {

View File

@ -0,0 +1,13 @@
<!DOCTYPE HTML>
<html dir="ltr" xml:lang="en-US" lang="en-US"><head>
<title>Property provider test</title>
<script>
var testObj = {
testProp: 'testValue'
};
</script>
</head>
<body>
<h1>Heads Up Property Provider Test Page</h1>
</body>
</html>