// Copyright (c) 2006-2008 by Martin Stubenschrott // Copyright (c) 2007-2011 by Doug Kearns // Copyright (c) 2008-2015 Kris Maglione // // This work is licensed for reuse under an MIT license. Details are // given in the LICENSE.txt file included with this file. "use strict"; defineModule("completion", { exports: ["CompletionContext", "Completion", "completion"] }, this); lazyRequire("dom", ["DOM"]); lazyRequire("messages", ["_", "messages"]); lazyRequire("template", ["template"]); /** * Creates a new completion context. * * @class A class to provide contexts for command completion. * Manages the filtering and formatting of completions, and keeps * track of the positions and quoting of replacement text. Allows for * the creation of sub-contexts with different headers and quoting * rules. * * @param {nsIEditor} editor The editor for which completion is * intended. May be a {CompletionContext} when forking a context, * or a {string} when creating a new one. * @param {string} name The name of this context. Used when the * context is forked. * @param {number} offset The offset from the parent context. * @author Kris Maglione * @constructor */ var CompletionContext = Class("CompletionContext", { init: function cc_init(editor, name="", offset=0) { let self = this; if (editor instanceof this.constructor) { let parent = editor; name = parent.name + "/" + name; if (this.options) { this.autoComplete = this.options.get("autocomplete").getKey(name); this.sortResults = this.options.get("wildsort").getKey(name); this.wildcase = this.options.get("wildcase").getKey(name); } this.contexts = parent.contexts; if (name in this.contexts) self = this.contexts[name]; else self.contexts[name] = this; /** * @property {CompletionContext} This context's parent. {null} when * this is a top-level context. */ self.parent = parent; ["filters", "keys", "process", "title", "quote"] .forEach(key => self[key] = parent[key] && util.cloneObject(parent[key])); ["anchored", "compare", "editor", "_filter", "filterFunc", "forceAnchored", "top"] .forEach(key => self[key] = parent[key]); Object.defineProperty(self, "value", { get() { return this.top.value; }, enumerable: true, configurable: true }); self.offset = parent.offset; self.advance(offset); /** * @property {boolean} Specifies that this context is not finished * generating results. * @default false */ self.incomplete = false; self.message = null; /** * @property {boolean} Specifies that this context is waiting for the * user to press . Useful when fetching completions could be * dangerous or slow, and the user has enabled autocomplete. */ self.waitingForTab = false; self.hasItems = null; delete self._generate; delete self.ignoreCase; if (self != this) return self; ["_caret", "contextList", "maxItems", "onUpdate", "selectionTypes", "tabPressed", "updateAsync", "value"].forEach(function fe(key) { Object.defineProperty(self, key, { get() { return this.top[key]; }, set(val) { this.top[key] = val; }, enumerable: true, configurable: true }); }); } else { if (typeof editor == "string") this._value = editor; else this.editor = editor; /** * @property {boolean} Specifies whether this context results must * match the filter at the beginning of the string. * @default true */ this.anchored = true; this.forceAnchored = null; this.compare = function compare(a, b) { return String.localeCompare(a.text, b.text); }; /** * @property {function} This function is called when we close * a completion window with Esc or Ctrl-c. Usually this callback * is only needed for long, asynchronous completions */ this.cancel = null; /** * @property {[CompletionContext]} A list of active * completion contexts, in the order in which they were * instantiated. */ this.contextList = []; /** * @property {Object} A map of all contexts, keyed on their names. * Names are assigned when a context is forked, with its specified * name appended, after a '/', to its parent's name. May * contain inactive contexts. For active contexts, see * {@link #contextList}. */ this.contexts = { "": this }; /** * @property {function} The function used to filter the results. * @default Selects all results which match every predicate in the * {@link #filters} array. */ this.filterFunc = function filterFunc(items) { return this.filters .reduce((res, filter) => res.filter(item => filter.call(this, item)), items); }; /** * @property {Array} An array of predicates on which to filter the * results. */ this.filters = [CompletionContext.Filter.text]; /** * @property {Object} A mapping of keys, for {@link #getKey}. Given * { key: value }, getKey(item, key) will return values as such: * if value is a string, it will return item.item[value]. If it's a * function, it will return value(item.item). */ this.keys = { text: 0, description: 1, icon: "icon" }; /** * @property {number} This context's offset from the beginning of * {@link #editor}'s value. */ this.offset = offset; /** * @property {function} A function which is called when any subcontext * changes its completion list. Only called when * {@link #updateAsync} is true. */ this.onUpdate = function onUpdate() { return true; }; this.runCount = 0; /** * @property {CompletionContext} The top-level completion context. */ this.top = this; Object.defineProperties(this, { "incomplete": { get() { return this._incomplete || this.contextList.some(c => c.parent && c.incomplete); }, set(val) { this._incomplete = val; }, enumerable: true, configurable: true }, "waitingForTab": { get() { return this._waitingForTab || this.contextList.some(c => c.parent && c.waitingForTab); }, set(val) { this._waitingForTab = val; }, enumerable: true, configurable: true } }); this.reset(); } /** * @property {Object} A general-purpose store for functions which need to * cache data between calls. */ this.cache = {}; this._cache = {}; /** * @private * @property {Object} A cache for return values of {@link #generate}. */ this.itemCache = {}; /** * @property {string} A key detailing when the cached value of * {@link #generate} may be used. Every call to * {@link #generate} stores its result in {@link #itemCache}. * When itemCache[key] exists, its value is returned, and * {@link #generate} is not called again. */ this.key = ""; /** * @property {string} A message to be shown before any results. */ this.message = null; this.name = name || ""; /** @private */ this._completions = []; // FIXME /** * Returns a key, as detailed in {@link #keys}. * @function */ this.getKey = function getKey(item, key) { if (typeof self.keys[key] == "function") return self.keys[key].call(this, item.item); if (key in self.keys) return item.item[self.keys[key]]; return item.item[key]; }; return this; }, __title: Class.Memoize(function __title() { return this._title.map(s => typeof s == "string" ? messages.get("completion.title." + s, s) : s); }), set title(val) { delete this.__title; return this._title = val; }, get title() { return this.__title; }, get activeContexts() { return this.contextList.filter(function f(c) { return c.items.length; }); }, // Temporary /** * @property {Object} * * An object describing the results from all sub-contexts. Results are * adjusted so that all have the same starting offset. * * @deprecated */ get allItems() { let self = this; try { let allItems = this.contextList.map(function m(context) { return context.hasItems && context.items.length; }); if (this.cache.allItems && Ary.equals(this.cache.allItems, allItems)) return this.cache.allItemsResult; this.cache.allItems = allItems; let minStart = apply(Math, "min", this.activeContexts.map(function m(c) { return c.offset; })); if (minStart == Infinity) minStart = 0; this.cache.allItemsResult = memoize({ start: minStart, get longestSubstring() { return self.longestAllSubstring; }, get items() { return self.activeContexts.flatMap(context => { let prefix = self.value.substring(minStart, context.offset); return context.items.map(item => { return { text: prefix + item.text, result: prefix + item.result, __proto__: item }; }); }); } }); return this.cache.allItemsResult; } catch (e) { util.reportError(e); return { start: 0, items: [], longestAllSubstring: "" }; } }, // Temporary get allSubstrings() { let contexts = this.activeContexts; let minStart = apply(Math, "min", contexts.map(function m(c) { return c.offset; })); let lists = contexts.map(function m(context) { let prefix = context.value.substring(minStart, context.offset); return context.substrings.map(function m(s) { return prefix + s; }); }); /* TODO: Deal with sub-substrings for multiple contexts again. * Possibly. */ let substrings = lists.reduce( (res, list) => { return res.filter(str => { return list.some(s => s.substr(0, str.length) == str); }); }, lists.pop()); if (!substrings) // FIXME: How is this undefined? return []; return Ary.uniq(Array.slice(substrings)); }, // Temporary get longestAllSubstring() { return this.allSubstrings.reduce(function r(a, b) { return a.length > b.length ? a : b; }, ""); }, get caret() { return this._caret - this.offset; }, set caret(val) { this._caret = val + this.offset; }, get compare() { return this._compare || function compare() { return 0; }; }, set compare(val) { this._compare = val; }, get completions() { return this._completions || []; }, set completions(items) { if (items && isArray(items.array)) items = items.array; // Accept a generator if (!isArray(items)) items = Array.from(items || []); if (this._completions !== items) { delete this.cache.filtered; delete this.cache.filter; this.cache.rows = []; this._completions = items; this.itemCache[this.key] = items; } if (this._completions) this.hasItems = this._completions.length > 0; if (this.updateAsync && !this.noUpdate) util.trapErrors("onUpdate", this); }, get createRow() { return this._createRow || template.completionRow; }, // XXX set createRow(createRow) { return this._createRow = createRow; }, get filterFunc() { return this._filterFunc || identity; }, set filterFunc(val) { this._filterFunc = val; }, get filter() { return this._filter != null ? this._filter : this.value.substr(this.offset, this.caret); }, set filter(val) { delete this.ignoreCase; return this._filter = val; }, get format() { return { anchored: this.anchored, title: this.title, keys: this.keys, process: this.process }; }, set format(format) { this.anchored = format.anchored, this.title = format.title || this.title; this.keys = format.keys || this.keys; this.process = format.process || this.process; }, /** * @property {string | xml | null} * The message displayed at the head of the completions for the * current context. */ get message() { return this._message || (this.waitingForTab && this.hasItems !== false ? _("completion.waitingFor", "") : null); }, set message(val) { this._message = val; }, /** * The prototype object for items returned by {@link items}. */ get itemPrototype() { let self = this; let res = { highlight: "" }; function* result(quote) { yield ["context", function p_context() { return self; }]; yield ["result", quote ? function p_result() { return quote[0] + util.trapErrors(1, quote, this.text) + quote[2]; } : function p_result() { return this.text; }]; yield ["texts", function p_texts() { return Array.concat(this.text); }]; }; for (let i of iter(this.keys, result(this.quote))) { let [k, v] = i; if (typeof v == "string" && /^[.[]/.test(v)) // This is only allowed to be a simple accessor, and shouldn't // reference any variables. Don't bother with eval context. v = Function("i", "return i" + v); let descriptor = { enumerable: true, configurable: true }; if (typeof v == "function") descriptor.get = function p_gf() { return Class.replaceProperty(this, k, v.call(this, this.item, self)); }; else descriptor.get = function p_gp() { return Class.replaceProperty(this, k, this.item[v]); }; descriptor.set = function p_s(val) { Class.replaceProperty(this, k, val); }; Object.defineProperty(res, k, descriptor); } return res; }, /** * Returns true when the completions generated by {@link #generate} * must be regenerated. May be set to true to invalidate the current * completions. */ get regenerate() { return this._generate && (!this.completions || !this.itemCache[this.key] || this._cache.offset != this.offset); }, set regenerate(val) { if (val) delete this.itemCache[this.key]; }, /** * A property which may be set to a function to generate the value * of {@link completions} only when necessary. The generated * completions are linked to the value in {@link #key} and may be * invalidated by setting the {@link #regenerate} property. */ get generate() { return this._generate || null; }, set generate(arg) { this.hasItems = true; this._generate = arg; }, /** * Generates the item list in {@link #completions} via the * {@link #generate} method if the previously generated value is no * longer valid. */ generateCompletions: function generateCompletions() { if (this.offset != this._cache.offset || this.lastActivated != this.top.runCount) { this.itemCache = {}; this._cache.offset = this.offset; this.lastActivated = this.top.runCount; } if (!this.itemCache[this.key] && !this.waitingForTab) { try { let res = this._generate(); if (res != null) this.itemCache[this.key] = res; } catch (e) { util.reportError(e); this.message = _("error.error", e); } } // XXX this.noUpdate = true; this.completions = this.itemCache[this.key]; this.noUpdate = false; }, ignoreCase: Class.Memoize(function M() { let mode = this.wildcase; if (mode == "match") return false; else if (mode == "ignore") return true; else return !/[A-Z]/.test(this.filter); }), /** * Returns a list of all completion items which match the current * filter. The items returned are objects containing one property * for each corresponding property in {@link keys}. The returned * list is generated on-demand from the item list in {@link completions} * or generated by {@link generate}, and is cached as long as no * properties which would invalidate the result are changed. */ get items() { // Don't return any items if completions or generator haven't // been set during this completion cycle. if (!this.hasItems) return []; // Regenerate completions if we must if (this.generate) this.generateCompletions(); let items = this.completions; // Check for cache miss if (this._cache.completions !== this.completions) { this._cache.completions = this.completions; this._cache.constructed = null; this.cache.filtered = null; } if (this.cache.filtered && this.cache.filter == this.filter) return this.cache.filtered; this.cache.rows = []; this.cache.filter = this.filter; if (items == null) return null; let self = this; delete this._substrings; if (!this.forceAnchored && this.options) this.anchored = this.options.get("wildanchor").getKey(this.name, this.anchored); // Item matchers if (this.ignoreCase) this.matchString = this.anchored ? function matchString(filter, str) { return String.toLowerCase(str).indexOf(filter.toLowerCase()) == 0; } : function matchString(filter, str) { return String.toLowerCase(str).indexOf(filter.toLowerCase()) >= 0; }; else this.matchString = this.anchored ? function matchString(filter, str) { return String.indexOf(str, filter) == 0; } : function matchString(filter, str) { return String.indexOf(str, filter) >= 0; }; // Item formatters this.processor = Array.slice(this.process); if (!this.anchored) this.processor[0] = function processor_0(item, text) { return self.process[0].call( self, item, template.highlightFilter(item.text, self.filter, null, item.isURI)); }; try { // Item prototypes if (!this._cache.constructed) { let proto = this.itemPrototype; this._cache.constructed = items.map(function m(item) { return { __proto__: proto, item: item }; }); } // Filters let filtered = this.filterFunc(this._cache.constructed); if (this.maxItems) filtered = filtered.slice(0, this.maxItems); // Sorting if (this.sortResults && this.compare) { filtered.sort(this.compare); if (!this.anchored) { let filter = this.filter; filtered.sort(function s(a, b) { return b.text.startsWith(filter) - a.text.startsWith(filter); }); } } return this.cache.filtered = filtered; } catch (e) { this.message = _("error.error", e); util.reportError(e); return []; } }, /** * Returns a list of all substrings common to all items which * include the current filter. */ get substrings() { let items = this.items; if (items.length == 0 || !this.hasItems) return []; if (this._substrings) return this._substrings; let fixCase = this.ignoreCase ? String.toLowerCase : identity; let text = fixCase(items[0].text); let filter = fixCase(this.filter); // Exceedingly long substrings cause Gecko to go into convulsions if (text.length > 100) text = text.substr(0, 100); if (this.anchored) { var compare = function compare(text, s) { return text.substr(0, s.length) == s; }; var substrings = [text]; } else { var compare = function compare(text, s) { return text.includes(s); }; var substrings = []; let start = 0; let idx; let length = filter.length; while ((idx = text.indexOf(filter, start)) > -1 && idx < text.length) { substrings.push(text.substring(idx)); start = idx + 1; } } substrings = items.reduce(function r(res, item) { return res.map(function m(substring) { // A simple binary search to find the longest substring // of the given string which also matches the current // item's text. let len = substring.length; let i = 0, n = len + 1; let result = n && fixCase(item.result); while (n) { let m = Math.floor(n / 2); let keep = compare(result, substring.substring(0, i + m)); if (!keep) len = i + m - 1; if (!keep || m == 0) n = m; else { i += m; n = n - m; } } return len == substring.length ? substring : substring.substr(0, Math.max(len, 0)); }); }, substrings); let quote = this.quote; if (quote) substrings = substrings.map(function m(str) { return quote[0] + quote[1](str); }); return this._substrings = substrings; }, /** * Advances the context *count* characters. {@link #filter} is advanced to * match. If {@link #quote} is non-null, its prefix and suffix are set to * the null-string. * * This function is still imperfect for quoted strings. When * {@link #quote} is non-null, it adjusts the count based on the quoted * size of the *count*-character substring of the filter, which is accurate * so long as unquoting and quoting a string will always map to the * original quoted string, which is often not the case. * * @param {number} count The number of characters to advance the context. */ advance: function advance(count) { delete this.ignoreCase; let advance = count; if (this.quote && count) { advance = this.quote[1](this.filter.substr(0, count)).length; count = this.quote[0].length + advance; this.quote[0] = ""; this.quote[2] = ""; } this.offset += count; if (this._filter) this._filter = this._filter.substr(arguments[0] || 0); }, /** * Calls the {@link #cancel} method of all currently active * sub-contexts. */ cancelAll: function cancelAll() { for (let context of this.contextList) { if (context.cancel) context.cancel(); } }, /** * Gets a key from {@link #cache}, setting it to *defVal* if it doesn't * already exists. * * @param {string} key * @param defVal */ getCache: function getCache(key, defVal) { if (!(key in this.cache)) this.cache[key] = defVal(); return this.cache[key]; }, getItems: function getItems(start, end) { let items = this.items; let step = start > end ? -1 : 1; start = Math.max(0, start || 0); end = Math.min(items.length, end ? end : items.length); return iter.map(util.range(start, end, step), function m(i) { return items[i]; }); }, getRow: function getRow(idx, doc) { let cache = this.cache.rows; if (cache) { if (idx in this.items && !(idx in this.cache.rows)) try { cache[idx] = DOM.fromJSON(this.createRow(this.items[idx]), doc || this.doc); } catch (e) { util.reportError(e); try { util.dump(util.prettifyJSON(this.createRow(this.items[idx]), null, true)); } catch (e) {} cache[idx] = DOM.fromJSON( ["div", { highlight: "CompItem", style: "white-space: nowrap" }, ["li", { highlight: "CompResult" }, this.text + "\u00a0"], ["li", { highlight: "CompDesc ErrorMsg" }, e + "\u00a0"]], doc || this.doc); } return cache[idx]; } }, getRows: function* getRows(start, end, doc) { let items = this.items; let cache = this.cache.rows; let step = start > end ? -1 : 1; start = Math.max(0, start || 0); end = Math.min(items.length, end != null ? end : items.length); this.doc = doc; for (let i of util.range(start, end, step)) yield [i, this.getRow(i)]; }, /** * Forks this completion context to create a new sub-context named * as {this.name}/{name}. The new context is automatically advanced * *offset* characters. If *completer* is provided, it is called * with *self* as its 'this' object, the new context as its first * argument, and any subsequent arguments after *completer* as its * following arguments. * * If *completer* is provided, this function returns its return * value, otherwise it returns the new completion context. * * @param {string} name The name of the new context. * @param {number} offset The offset of the new context relative to * the current context's offset. * @param {object} self *completer*'s 'this' object. @optional * @param {function|string} completer A completer function to call * for the new context. If a string is provided, it is * interpreted as a method to access on *self*. */ fork: function fork(name, offset, self, completer, ...args) { return this.forkapply(name, offset, self, completer, args); }, forkapply: function forkapply(name, offset, self, completer, args) { if (isString(completer)) completer = self[completer]; let context = this.constructor(this, name, offset); if (this.contextList.indexOf(context) < 0) this.contextList.push(context); if (!context.autoComplete && !context.tabPressed && context.editor) context.waitingForTab = true; else if (completer) { let res = completer.apply(self || this, [context].concat(args)); if (res && !isArray(res) && !isArray(res.__proto__)) res = Array.from(res); if (res) context.completions = res; return res; } if (completer) return null; return context; }, split: function split(name, obj, fn, ...args) { let context = this.fork(name); let alias = prop => { Object.defineProperty(context, prop, { get: () => this[prop], set: val => this[prop] = val, enumerable: true, configurable: true, }); }; alias("_cache"); alias("_completions"); alias("_generate"); alias("_regenerate"); alias("itemCache"); alias("lastActivated"); context.hasItems = true; this.hasItems = false; if (fn) return fn.apply(obj || this, [context].concat(args)); return context; }, /** * Highlights text in the nsIEditor associated with this completion * context. *length* characters are highlighted from the position * *start*, relative to the current context's offset, with the * selection type *type* as defined in nsISelectionController. * * When called with no arguments, all highlights are removed. When * called with a 0 length, all highlights of type *type* are * removed. * * @param {number} start The position at which to start * highlighting. * @param {number} length The length of the substring to highlight. * @param {string} type The selection type to highlight with. */ highlight: function highlight(start, length, type) { if (arguments.length == 0) { for (let type in this.selectionTypes) this.highlight(0, 0, type); this.selectionTypes = {}; } try { // Requires Gecko >= 1.9.1 this.selectionTypes[type] = true; const selType = Ci.nsISelectionController["SELECTION_" + type]; let sel = this.editor.selectionController.getSelection(selType); if (length == 0) sel.removeAllRanges(); else { let range = this.editor.selection.getRangeAt(0).cloneRange(); range.setStart(range.startContainer, this.offset + start); range.setEnd(range.startContainer, this.offset + start + length); sel.addRange(range); } } catch (e) {} }, /** * Tests the given string for a match against the current filter, * taking into account anchoring and case sensitivity rules. * * @param {string} str The string to match. * @returns {boolean} True if the string matches, false otherwise. */ match: function match(str) { return this.matchString(this.filter, str); }, /** * Pushes a new output processor onto the processor chain of * {@link #process}. The provided function is called with the item * and text to process along with a reference to the processor * previously installed in the given *index* of {@link #process}. * * @param {number} index The index into {@link #process}. * @param {function(object, string, function)} func The new * processor. */ pushProcessor: function pushProcess(index, func) { let next = this.process[index]; this.process[index] = function process_(item, text) { return func(item, text, next); }; }, /** * Resets this completion context and all sub-contexts for use in a * new completion cycle. May only be called on the top-level * context. */ reset: function reset() { if (this.parent) throw Error(); this.offset = 0; this.process = [template.icon, function process_1(item, k) { return k; }]; this.filters = [CompletionContext.Filter.text]; this.tabPressed = false; this.title = ["Completions"]; this.updateAsync = false; this.cancelAll(); if (this.editor) { this.value = this.editor.selection.focusNode.textContent; this._caret = this.editor.selection.focusOffset; } else { this.value = this._value; this._caret = this.value.length; } //for (let key in (k for ([k, v] in Iterator(this.contexts)) if (v.offset > this.caret))) // delete this.contexts[key]; for (let context of values(this.contexts)) { context.hasItems = false; context.incomplete = false; } this.waitingForTab = false; this.runCount++; for (let context of this.contextList) context.lastActivated = this.runCount; this.contextList = []; }, /** * Wait for all subcontexts to complete. * * @param {number} timeout The maximum time, in milliseconds, to wait. * If 0 or null, wait indefinitely. * @param {boolean} interruptible When true, the call may be interrupted * via , in which case, "Interrupted" may be thrown. */ wait: function wait(timeout, interruptable) { this.allItems; return util.waitFor(function wf() { return !this.incomplete; }, this, timeout, interruptable); } }, { Sort: { number: function S_number(a, b) { return parseInt(a.text) - parseInt(b.text) || String.localeCompare(a.text, b.text); }, unsorted: null }, Filter: { text: function F_text(item) { let text = item.texts; for (let [i, str] of iter(text)) { if (this.match(String(str))) { item.text = String(text[i]); return true; } } return false; }, textDescription: function F_textDescription(item) { return CompletionContext.Filter.text.call(this, item) || this.match(item.description); } } }); /** * @instance completion */ var Completion = Module("completion", { init: function init() { }, get setFunctionCompleter() { return JavaScript.setCompleter; }, // Backward compatibility Local: function Local(dactyl, modules, window) { return { urlCompleters: {}, get modules() { return modules; }, get options() { return modules.options; }, // FIXME _runCompleter: function _runCompleter(name, filter, maxItems, ...args) { let context = modules.CompletionContext(filter); context.maxItems = maxItems; let res = apply(context, "fork", ["run", 0, this, name].concat(args)); if (res) { if (Components.stack.caller.name === "runCompleter") // FIXME return { items: res.map(function m(i) { return { item: i }; }) }; context.contexts["/run"].completions = res; } context.wait(null, true); return context.allItems; }, runCompleter: function runCompleter(name, filter, maxItems) { return apply(this, "_runCompleter", arguments) .items.map(function m(i) { return i.item; }); }, listCompleter: function listCompleter(name, filter, maxItems, ...args) { let context = modules.CompletionContext(filter || ""); context.maxItems = maxItems; apply(context, "fork", ["list", 0, this, name].concat(args)); context = context.contexts["/list"]; context.wait(null, true); let contexts = context.activeContexts; if (!contexts.length) contexts = context.contextList .filter(function f(c) { return c.hasItems; }) .slice(0, 1); if (!contexts.length) contexts = context.contextList.slice(-1); modules.commandline.commandOutput( ["div", { highlight: "Completions" }, template.map(contexts, function m(context) { return [template.completionRow(context.title, "CompTitle"), template.map(context.items, function m(item) { return context.createRow(item); }, null, 100)]; })]); } }; }, //////////////////////////////////////////////////////////////////////////////// ////////////////////// COMPLETION TYPES //////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////{{{ // filter a list of urls // // may consist of search engines, filenames, bookmarks and history, // depending on the 'complete' option // if the 'complete' argument is passed like "h", it temporarily overrides the complete option url: function url(context, complete) { if (/^jar:[^!]*$/.test(context.filter)) { context.advance(4); context.quote = context.quote || ["", identity, ""]; let quote = context.quote[1]; context.quote[1] = function quote_1(str) { return quote(str.replace(/!/g, escape)); }; } if (this.options["urlseparator"]) var skip = util.regexp("^.*" + this.options["urlseparator"] + "\\s*") .exec(context.filter); if (skip) context.advance(skip[0].length); if (/^about:/.test(context.filter)) context.fork("about", 6, this, function fork_(context) { context.title = ["about:"]; context.generate = function generate_() { return Object.keys(Cc).filter(k => k.startsWith(services.ABOUT)) .map(k => [k.substr(services.ABOUT.length), ""]); }; }); if (complete == null) complete = this.options["complete"]; // Will, and should, throw an error if !(c in opts) Array.forEach(complete, function fe(c) { let completer = this.urlCompleters[c] || { args: [], completer: this.autocomplete(c.replace(/^native:/, "")) }; context.forkapply(c, 0, this, completer.completer, completer.args); }, this); }, addUrlCompleter: function addUrlCompleter(name, description, completer, ...args) { completer = Completion.UrlCompleter(name, description, completer); completer.args = args; this.urlCompleters[name] = completer; }, autocomplete: curry(function autocomplete(provider, context) { let running = context.getCache("autocomplete-search-running", Object); let name = "autocomplete:" + provider; if (!services.has(name)) services.add(name, services.AUTOCOMPLETE + provider, "nsIAutoCompleteSearch"); let service = services[name]; util.assert(service, _("autocomplete.noSuchProvider", provider), false); if (running[provider]) { this.completions = this.completions; this.cancel(); } context.anchored = false; context.compare = CompletionContext.Sort.unsorted; context.filterFunc = null; let words = context.filter.toLowerCase().split(/\s+/g); context.hasItems = true; context.completions = context.completions .filter(function f({ url, title }) { return words.every(function e(w) { return (url + " " + title) .toLowerCase().indexOf(w) >= 0; }) }); context.format = this.modules.bookmarks.format; context.keys.extra = function k_extra(item) { try { return bookmarkcache.get(item.url).extra; } catch (e) {} return null; }; context.title = [_("autocomplete.title", provider)]; context.cancel = function cancel_() { this.incomplete = false; if (running[provider]) service.stopSearch(); running[provider] = false; }; if (!context.waitingForTab) { context.incomplete = true; service.startSearch(context.filter, "", context.result, { onSearchResult: util.wrapCallback(function onSearchResult(search, result) { if (result.searchResult <= result.RESULT_SUCCESS) running[provider] = null; context.incomplete = result.searchResult >= result.RESULT_NOMATCH_ONGOING; context.completions = Array.from( util.range(0, result.matchCount), i => ({ url: result.getValueAt(i), title: result.getCommentAt(i), icon: result.getImageAt(i) })); }), get onUpdateSearchResult() { return this.onSearchResult; } }); running[provider] = true; } }), urls: function urls(context, tags) { let compare = String.localeCompare; let contains = String.indexOf; if (context.ignoreCase) { compare = util.compareIgnoreCase; contains = function contains_(a, b) { return a && a.toLowerCase().includes(b.toLowerCase()); }; } if (tags) context.filters.push(function filter_(item) { return tags.every(function e(tag) { return (item.tags || []).some(function s(t) { return !compare(tag, t); }); }); }); context.anchored = false; if (!context.title) context.title = ["URL", "Title"]; context.fork("additional", 0, this, function fork_(context) { context.title[0] += " " + _("completion.additional"); context.filter = context.parent.filter; // FIXME context.completions = context.parent.completions; // For items whose URL doesn't exactly match the filter, // accept them if all tokens match either the URL or the title. // Filter out all directly matching strings. let match = context.filters[0]; context.filters[0] = function filters_0(item) { !match.call(this, item); }; // and all that don't match the tokens. let tokens = context.filter.split(/\s+/); context.filters.push(function filter_(item) { return tokens.every(function e(tok) { return contains(item.url, tok) || contains(item.title, tok); }); }); let re = RegExp(tokens.filter(identity).map(util.regexp.escape).join("|"), "g"); function highlight(item, text, i) { return process[i].call(this, item, template.highlightRegexp(text, re)); } let process = context.process; context.process = [ function process_0(item, text) { return highlight.call(this, item, item.text, 0); }, function process_1(item, text) { return highlight.call(this, item, text, 1); } ]; }); } //}}} }, { UrlCompleter: Struct("name", "description", "completer") }, { init: function init(dactyl, modules, window) { init.superapply(this, arguments); modules.CompletionContext = Class("CompletionContext", CompletionContext, { init: function init() { this.modules = modules; return init.superapply(this, arguments); }, get options() { return this.modules.options; } }); }, commands: function initCommands(dactyl, modules, window) { const { commands, completion } = modules; commands.add(["contexts"], "List the completion contexts used during completion of an Ex command", function () { modules.commandline.commandOutput( ["div", { highlight: "Completions" }, template.completionRow(["Context", "Title"], "CompTitle"), template.map(completion.contextList || [], function m(item) { return template.completionRow(item, "CompItem"); })]); }, { argCount: "*", completer: function (context) { let PREFIX = "/ex/contexts"; context.fork("ex", 0, completion, "ex"); completion.contextList = ( Object.entries(context.contexts) .filter(([k]) => k.substr(0, PREFIX.length) == PREFIX) .map(([k, v]) => [k.substr(PREFIX.length), v.title[0]])); }, literal: 0 }); }, options: function initOptions(dactyl, modules, window) { const { completion, options } = modules; let wildmode = { values: { // Why do we need ""? // Because its description is useful during completion. --Kris "": "Complete only the first match", "full": "Complete the next full match", "longest": "Complete the longest common string", "list": "If more than one match, list all matches", "list:full": "List all and complete first match", "list:longest": "List all and complete the longest common string" }, checkHas: function (value, val) { let [first, second] = value.split(":", 2); return first == val || second == val; }, has: function () { let test = function test(val) { return this.value.some(function s(value) { return this.checkHas(value, val) }, this); }; return Array.some(arguments, test, this); } }; options.add(["altwildmode", "awim"], "Define the behavior of the c_ key in command-line completion", "stringlist", "list:full", wildmode); options.add(["autocomplete", "au"], "Automatically update the completion list on any key press", "regexplist", ".*"); options.add(["complete", "cpt"], "Items which are completed at the :open prompts", "stringlist", "slf", { valueMap: { S: "suggestion", b: "bookmark", f: "file", h: "history", l: "location", s: "search" }, get values() { let builtin = Object.keys(Cc) .filter(k => k.startsWith(services.AUTOCOMPLETE)) .map(key => { let name = key.substr(services.AUTOCOMPLETE.length); return ["native:" + name, _("autocomplete.description", name)]; }); return values(completion.urlCompleters).toArray().concat(builtin); }, setter: function setter(values) { if (values.length == 1 && !hasOwnProp(values[0], this.values) && Array.every(values[0], v => hasOwnProp(this.valueMap, v))) return Array.map(values[0], v => this.valueMap[v]); return values; }, validator: function validator(values) { return validator.supercall(this, this.setter(values)); } }); options.add(["wildanchor", "wia"], "Define which completion groups only match at the beginning of their text", "regexplist", "!/ex/(back|buffer|ext|forward|help|undo)"); options.add(["wildcase", "wic"], "Completion case matching mode", "regexpmap", ".?:smart", { values: { "smart": "Case is significant when capital letters are typed", "match": "Case is always significant", "ignore": "Case is never significant" } }); options.add(["wildmode", "wim"], "Define the behavior of the c_ key in command-line completion", "stringlist", "list:full", wildmode); options.add(["wildsort", "wis"], "Define which completion groups are sorted", "regexplist", ".*"); } }); endModule(); // catch(e){ if (!e.stack) e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); } // vim: set fdm=marker sw=4 sts=4 ts=8 et ft=javascript: