Update keyseq

- Add and build grammars
 - Add to content.ts for interactive use
 - Add tests
 - Change bracketExpr parser
 - Improve comments
 - Apply prettier
This commit is contained in:
Colin Caine 2017-11-08 23:44:32 +00:00
parent e107fa5eb1
commit 35bcde6627
7 changed files with 701 additions and 86 deletions

View file

@ -12,6 +12,7 @@ scripts/excmds_macros.py
scripts/newtab.md.sh
scripts/make_tutorial.sh
scripts/make_docs.sh &
nearleyc src/grammars/bracketexpr.ne > src/grammars/bracketexpr.ts
(webpack --display errors-only && scripts/git_version.sh)&

View file

@ -26,6 +26,7 @@ import * as msgsafe from "./msgsafe"
import state from "./state"
import * as webext from "./lib/webext"
import Mark from "mark.js"
import * as keyseq from "./keyseq"
;(window as any).tri = Object.assign(Object.create(null), {
browserBg: webext.browserBg,
commandline_content,
@ -38,6 +39,7 @@ import Mark from "mark.js"
itertools,
keydown_content,
Mark,
keyseq,
messaging,
msgsafe,
state,

View file

@ -0,0 +1,34 @@
@preprocessor typescript
BracketExpr -> "<" Modifier ModKey ">" {% bexpr=>bexpr.slice(1,-1) %}
| "<" Key ">" {% bexpr=>[{}].concat(bexpr.slice(1,-1)) %}
Modifier -> [acmsACMS]:? [acmsACMS]:? [acmsACMS]:? [acmsACMS]:? "-" {%
/** For each modifier present,
add its long name as an attribute set to true to an object */
(mods, _, reject) => {
const longNames = new Map([
["A", "altKey"],
["C", "ctrlKey"],
["M", "metaKey"],
["S", "shiftKey"],
])
let modifiersObj = {}
for (let mod of mods) {
if (mod === null || mod === "-") continue
let longName = longNames.get(mod.toUpperCase())
if (longName) {
// Reject if the same name is used twice.
if (longName in modifiersObj) return reject
else modifiersObj[longName] = true
}
}
return modifiersObj
}
%}
# <>- permitted with modifiers, but not otherwise.
ModKey -> "<" {% id %}
| ">" {% id %}
| "-" {% id %}
| Key {% id %}
Key -> [^\s<>-]:+ {% (key)=>key[0].join("") %}

300
src/keyseq.test.ts Normal file
View file

@ -0,0 +1,300 @@
import { testAll, testAllNoError, testAllObject } from "./test_utils"
import * as ks from "./keyseq"
import { mapstrToKeyseq as mks } from "./keyseq"
function mk(k, mod?: ks.KeyModifiers) {
return new ks.MinimalKey(k, mod)
}
{
// {{{ parse and completions
const keymap = new Map([
[[mk("u", { ctrlKey: true }), mk("j")], "scrollline 10"],
[[mk("g"), mk("g")], "scrolltop"],
[mks("<SA-Escape>"), "rarelyusedcommand"],
])
// This one actually found a bug once!
testAllObject(ks.parse, [
[[[mk("g")], keymap], { keys: [mk("g")] }],
[[[mk("g"), mk("g")], keymap], { exstr: "scrolltop" }],
[
[[mk("Escape", { shiftKey: true, altKey: true })], keymap],
{ exstr: "rarelyusedcommand" },
],
])
testAllObject(ks.completions, [
[[[mk("g")], keymap], new Map([[[mk("g"), mk("g")], "scrolltop"]])],
[[mks("<C-u>j"), keymap], new Map([[mks("<C-u>j"), "scrollline 10"]])],
])
} // }}}
// {{{ mapstr -> keysequence
testAll(ks.bracketexprToKey, [
["<C-a><CR>", [mk("a", { ctrlKey: true }), "<CR>"]],
["<M-<>", [mk("<", { metaKey: true }), ""]],
["<M-<>Foo", [mk("<", { metaKey: true }), "Foo"]],
["<M-a>b", [mk("a", { metaKey: true }), "b"]],
["<S-Escape>b", [mk("Escape", { shiftKey: true }), "b"]],
["<Tab>b", [mk("Tab"), "b"]],
["<>b", [mk("<"), ">b"]],
["<tag >", [mk("<"), "tag >"]],
])
testAllObject(ks.mapstrMapToKeyMap, [
[
new Map([["j", "scrollline 10"], ["gg", "scrolltop"]]),
new Map([
[[mk("j")], "scrollline 10"],
[[mk("g"), mk("g")], "scrolltop"],
]),
],
[
new Map([["<C-u>j", "scrollline 10"], ["gg", "scrolltop"]]),
new Map([
[[mk("u", { ctrlKey: true }), mk("j")], "scrollline 10"],
[[mk("g"), mk("g")], "scrolltop"],
]),
],
])
testAllObject(ks.mapstrToKeyseq, [
[
"Some string",
[
{
altKey: false,
ctrlKey: false,
key: "S",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "o",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "m",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "e",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: " ",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "s",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "t",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "r",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "i",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "n",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "g",
metaKey: false,
shiftKey: false,
},
],
],
[
"hi<c-u>t<A-Enter>here",
[
{
altKey: false,
ctrlKey: false,
key: "h",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "i",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: true,
key: "u",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "t",
metaKey: false,
shiftKey: false,
},
{
altKey: true,
ctrlKey: false,
key: "Enter",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "h",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "e",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "r",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "e",
metaKey: false,
shiftKey: false,
},
],
],
[
"wat's up <s-Escape>",
[
{
altKey: false,
ctrlKey: false,
key: "w",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "a",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "t",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "'",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "s",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: " ",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "u",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "p",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: " ",
metaKey: false,
shiftKey: false,
},
{
altKey: false,
ctrlKey: false,
key: "Escape",
metaKey: false,
shiftKey: true,
},
],
],
["wat's up <s-Escape>", mks("wat's up <s-Escape>")],
])
// Check order of modifiers doesn't matter
// Check aliases
testAllObject(mks, [
["<SAC-cr>", mks("<ASC-return>")],
["<ACM-lt>", mks("<CAM-<>")],
])
// }}}

View file

@ -1,18 +1,29 @@
/** Key-sequence parser
*
* Given an iterable of keys and a mapping of keys to objects, return:
*
* - parser(keyseq, map):
* the mapped object and a count
* OR a suffix of keys[] that, if more keys are pressed, could map to an object.
* - completions(keyseq, map):
* an array of keysequences in map that keyseq is a valid prefix of.
*
* No key sequence in map may be a prefix of another key sequence in map. This
* is a point of difference from Vim that removes any time-dependence in the
* parser.
*
*/
If `map` is a Map of `MinimalKey[]` to objects (exstrs or callbacks)
and `keyseq` is an array of [[MinimalKey]] compatible objects...
- `parse(keyseq, map)` returns the mapped object and a count OR a prefix
of `MinimalKey[]` (possibly empty) that, if more keys are pressed, could
map to an object.
- `completions(keyseq, map)` returns the fragment of `map` that keyseq is
a valid prefix of.
- `mapstrToKeySeq` generates KeySequences for the rest of the API.
No key sequence in a `map` may be a prefix of another key sequence in that
map. This is a point of difference from Vim that removes any time-dependence
in the parser. Vimperator, Pentadactyl, saka-key, etc, all share this
limitation.
*/
/** */
import { izip } from "./itertools"
import { Parser } from "./nearley_utils"
import * as bracketexpr_grammar from "./grammars/bracketexpr"
const bracketexpr_parser = new Parser(bracketexpr_grammar)
// {{{ General types
export type KeyModifiers = {
altKey?: boolean
@ -34,64 +45,276 @@ export class MinimalKey {
}
/** Does this key match a given MinimalKey extending object? */
match(keyevent) {
public match(keyevent) {
// 'in' doesn't include prototypes, so it's safe for this object.
for (let attr in this) {
if (this[attr] !== keyevent[attr]) return false
}
return true
}
public toMapstr() {
let str = ""
let needsBrackets = this.key.length > 1
// Format modifiers
const modifiers = new Map([
["A", "altKey"],
["C", "ctrlKey"],
["M", "metaKey"],
["S", "shiftKey"],
])
for (const [letter, attr] of modifiers.entries()) {
if (this[attr]) {
str += letter
needsBrackets = true
}
}
if (str) {
str += "-"
}
// Format the rest
str += this.key
if (needsBrackets) {
str = "<" + str + ">"
}
return str
}
}
/** String starting with a bracket expr or a literal < to MinimalKey and remainder.
import { MsgSafeKeyboardEvent } from "./msgsafe"
Bracket expressions generally start with a < contain no angle brackets or
whitespace and end with a >. These special-cased expressions are also
permitted: <{optional modifier}<> or <{optional modifier}>>
type KeyEventLike = MinimalKey | MsgSafeKeyboardEvent | KeyboardEvent
// }}}
// {{{ parser and completions
type MapTarget = string | Function
type KeyMap = Map<MinimalKey[], MapTarget>
export type ParserResponse = {
keys?: KeyEventLike[]
exstr?: string
action?: Function
}
export function parse(keyseq: KeyEventLike[], map: KeyMap): ParserResponse {
// If keyseq is a prefix of a key in map, proceed, else try dropping keys
// from keyseq until it is empty or is a prefix.
let possibleMappings = completions(keyseq, map)
while (possibleMappings.size === 0 && keyseq.length > 0) {
keyseq.shift()
possibleMappings = completions(keyseq, map)
}
if (possibleMappings.size === 1) {
const map = possibleMappings.keys().next().value
if (map.length === keyseq.length) {
const target = possibleMappings.values().next().value
if (typeof target === "string") {
return { exstr: target }
} else {
return { action: target }
}
}
}
// else
return { keys: keyseq }
}
/** True if seq1 is a prefix or equal to seq2 */
function prefixes(seq1: KeyEventLike[], seq2: MinimalKey[]) {
for (const [key1, key2] of izip(seq1, seq2)) {
if (!key2.match(key1)) return false
}
return true
}
/** returns the fragment of `map` that keyseq is a valid prefix of. */
export function completions(keyseq: KeyEventLike[], map: KeyMap): KeyMap {
const possibleMappings = new Map() as KeyMap
for (const [ks, maptarget] of map.entries()) {
if (prefixes(keyseq, ks)) {
possibleMappings.set(ks, maptarget)
}
}
return possibleMappings
}
// }}}
// {{{ mapStrToKeySeq stuff
/** Expand special key aliases that Vim provides to canonical values
Vim aliases are case insensitive.
*/
function expandAliases(key: string) {
// Vim compatibility aliases
const aliases = {
cr: "Enter",
return: "Enter",
enter: "Enter",
space: " ",
bar: "|",
del: "Delete",
bs: "Backspace",
lt: "<",
}
if (key.toLowerCase() in aliases) return aliases[key.toLowerCase()]
else return key
}
/** String starting with a `<` to MinimalKey and remainder.
Bracket expressions generally start with a `<` contain no angle brackets or
whitespace and end with a `>.` These special-cased expressions are also
permitted: `<{modifier}<>`, `<{modifier}>>`, and `<{modifier}->`.
If the string passed does not match this definition, it is treated as a
literal <.
literal `<.`
In sort of Backus Naur:
Backus Naur approximation:
```
- bracketexpr ::= '<' modifier? key '>'
- modifier ::= 'm'|'s'|'a'|'c' '-'
- key ::= '<'|'>'|/[^\s<>]+/
- key ::= '<'|'>'|/[^\s<>-]+/
```
See `src/grammars/bracketExpr.ne` for the canonical definition.
Modifiers are case insensitive.
The following case insensitive vim compatibility aliases are also defined:
cr: 'Enter',
return: 'Enter',
space: 'Enter',
bar: '|',
del: 'Delete',
bs: 'Backspace',
lt: '<',
Some case insensitive vim compatibility aliases are also defined, see
[[expandAliases]].
Compatibility breaks:
Shift + key must use the correct capitalisation of key: <S-j> != J, <S-J> == J.
Shift + key must use the correct capitalisation of key:
`<S-j> != J, <S-J> == J`.
In Vim <A-x> == <M-x> on most systems. Not so here: we can't detect
In Vim `<A-x> == <M-x>` on most systems. Not so here: we can't detect
platform, so just have to use what the browser gives us.
Vim has a predefined list of special key sequences, we don't: there are too
many (and they're non-standard).[1].
many (and they're non-standard) [1].
In Vim, you're still allowed to use <lt> within angled brackets:
<M-<> == <M-lt> == <M-<lt>>
In the future, we may just use the names as defined in keyNameList.h [2].
In Vim, you're still allowed to use `<lt>` within angled brackets:
`<M-<> == <M-lt> == <M-<lt>>`
Here only the first two will work.
Restrictions:
It is not possible to map to a keyevent that actually sends the key value
of any of the aliases or to any multi-character sequence containing a space
or >. It is unlikely that browsers will ever do either of those things.
or `>.` It is unlikely that browsers will ever do either of those things.
[1]: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
[2]: https://searchfox.org/mozilla-central/source/dom/events/KeyNameList.h
*/
export function bracketexprToKey(inputStr) {
if (inputStr.indexOf(">") > 0) {
try {
const [
[modifiers, key],
remainder,
] = bracketexpr_parser.feedUntilError(inputStr)
return [new MinimalKey(expandAliases(key), modifiers), remainder]
} catch (e) {
// No valid bracketExpr
return [new MinimalKey("<"), inputStr.slice(1)]
}
} else {
// No end bracket to match == no valid bracketExpr
return [new MinimalKey("<"), inputStr.slice(1)]
}
}
/** Generate KeySequences for the rest of the API.
A map expression is something like:
```
j scrollline 10
<C-f> scrollpage 0.5
<C-d> scrollpage 0.5
<C-/><C-n> mode normal
```
A mapstr is the bit before the space.
mapstrToKeyseq turns a mapstr into a keySequence that looks like this:
```
[MinimalKey {key: 'j'}]
[MinimalKey {key: 'f', ctrlKey: true}]
[MinimalKey {key: 'd', ctrlKey: true}]
[MinimalKey {key: '/', ctrlKey: true}, MinimalKey {key: 'n', ctrlKey: true}]
```
(All four {modifier}Key flags are actually provided on all MinimalKeys)
*/
export function mapstrToKeyseq(mapstr: string): MinimalKey[] {
const keyseq: MinimalKey[] = []
let key: MinimalKey
while (mapstr.length) {
if (mapstr[0] === "<") {
;[key, mapstr] = bracketexprToKey(mapstr)
keyseq.push(key)
} else {
keyseq.push(new MinimalKey(mapstr[0]))
mapstr = mapstr.slice(1)
}
}
return keyseq
}
/** Convert a map of mapstrs (e.g. from config) to a KeyMap */
export function mapstrMapToKeyMap(mapstrMap: Map<string, MapTarget>): KeyMap {
const newKeyMap = new Map()
for (const [mapstr, target] of mapstrMap.entries()) {
newKeyMap.set(mapstrToKeyseq(mapstr), target)
}
return newKeyMap
}
// }}}
// {{{ Utility functions for dealing with KeyboardEvents
export function hasModifiers(keyEvent: KeyEventLike) {
return (
keyEvent.ctrlKey ||
keyEvent.altKey ||
keyEvent.metaKey ||
keyEvent.shiftKey
)
}
/** shiftKey is true for any capital letter, most numbers, etc. Generally care about other modifiers. */
export function hasNonShiftModifiers(keyEvent: KeyEventLike) {
return keyEvent.ctrlKey || keyEvent.altKey || keyEvent.metaKey
}
export function isSimpleKey(keyEvent: KeyEventLike) {
return !(keyEvent.key.length > 1 || hasNonShiftModifiers(keyEvent))
}
// }}}
/* {{{ Deprecated
// OLD IMPLEMENTATION! See below for a simpler-looking one powered by nearley.
// It's probably slower, but it supports multiple modifiers and will be easier
// to understand and extend.
export function bracketexprToKey(be: string): [MinimalKey, string] {
function extractModifiers(be: string): [string, any] {
const modifiers = new Map([
@ -125,16 +348,6 @@ export function bracketexprToKey(be: string): [MinimalKey, string] {
// General case:
const beRegex = /<[^\s]+?>/u
// Vim compatibility aliases
const aliases = {
cr: "Enter",
return: "Enter",
space: "Enter",
bar: "|",
del: "Delete",
bs: "Backspace",
lt: "<",
}
if (beRegex.exec(be) !== null) {
// Extract complete bracket expression and remove
let bracketedBit = beRegex.exec(be)[0]
@ -142,7 +355,7 @@ export function bracketexprToKey(be: string): [MinimalKey, string] {
// Extract key and alias if required
let key = beRegex.exec(beWithoutModifiers)[0].slice(1, -1)
if (key.toLowerCase() in aliases) key = aliases[key.toLowerCase()]
key = expandAliases(key)
// Return constructed key and remainder of the string
return [new MinimalKey(key, modifiers), be]
@ -152,41 +365,4 @@ export function bracketexprToKey(be: string): [MinimalKey, string] {
}
}
export function mapstrToKeyseq(mapstr: string): MinimalKey[] {
const keyseq: MinimalKey[] = []
let key: MinimalKey
while (mapstr.length) {
if (mapstr[0] === "<") {
;[key, mapstr] = bracketexprToKey(mapstr)
keyseq.push(key)
} else {
keyseq.push(new MinimalKey(mapstr[0]))
mapstr = mapstr.slice(1)
}
}
return keyseq
}
// {{{ Utility functions for dealing with KeyboardEvents
import { MsgSafeKeyboardEvent } from "./msgsafe"
type KeyEventLike = MinimalKey | MsgSafeKeyboardEvent | KeyboardEvent
export function hasModifiers(keyEvent: KeyEventLike) {
return (
keyEvent.ctrlKey ||
keyEvent.altKey ||
keyEvent.metaKey ||
keyEvent.shiftKey
)
}
/** shiftKey is true for any capital letter, most numbers, etc. Generally care about other modifiers. */
export function hasNonShiftModifiers(keyEvent: KeyEventLike) {
return keyEvent.ctrlKey || keyEvent.altKey || keyEvent.metaKey
}
export function isSimpleKey(keyEvent: KeyEventLike) {
return !(keyEvent.key.length > 1 || hasNonShiftModifiers(keyEvent))
}
}}} */

42
src/nearley_utils.ts Normal file
View file

@ -0,0 +1,42 @@
import * as nearley from "nearley"
/** Friendlier interface around nearley parsers */
export class Parser {
private parser
private initial_state
/* public results */
constructor(grammar) {
this.parser = new nearley.Parser(nearley.Grammar.fromCompiled(grammar))
this.initial_state = this.parser.save()
/* this.results = this.parser.results */
}
feedUntilError(input) {
let lastResult = undefined
let consumedIndex = 0
try {
for (let val of input) {
this.parser.feed(val)
lastResult = this.parser.results[0]
consumedIndex++
}
} catch (e) {
} finally {
this.reset()
if (lastResult === undefined) {
throw "Error: no result!"
} else {
return [lastResult, input.slice(consumedIndex)]
}
}
}
private reset() {
this.parser.restore(this.initial_state)
}
/* feed(input) { */
/* return this.parser.feed(input) */
/* } */
}

60
src/test_utils.ts Normal file
View file

@ -0,0 +1,60 @@
// TODO: There's no reason to wrap the ans.
function wrapPrimitives(testcases) {
// Wrap all args and answers in arrays if they're not already
return testcases.map(argOrAns => {
if (argOrAns instanceof Array) return argOrAns
else return [argOrAns]
})
}
/** Test each case in testcases.
Warning: if your function really accepts an array, that array must be
double wrapped
*/
export function testAll(toTest, testcases) {
testcases = testcases.map(wrapPrimitives)
for (let [args, ans] of testcases) {
test(`${toTest.name}(${args}) == ${JSON.stringify(ans)}`, () =>
expect(
(() => {
let result = toTest(...args)
if (result instanceof Array) return result
else return [result]
})(),
).toEqual(expect.arrayContaining(ans)))
}
}
/** Test each case in testcases against an arbitrary expectAttr and eval'd Arg
For eval:
- `ans` is an array containing the result of the function
- `args` is an array of input arguments to the function
- `toTest` is the function you gave as input
*/
export function testAllCustom(toTest, testcases, expectAttr, expectArg) {
testcases = testcases.map(wrapPrimitives)
for (let [args, ans] of testcases) {
test(`${toTest.name}(${args}) == ${JSON.stringify(ans)}`, () =>
expect(
(() => {
let result = toTest(...args)
if (result instanceof Array) return result
else return [result]
})(),
)[expectAttr](eval(expectArg)))
}
}
/** Call function with each testcase and check it doesn't throw */
export function testAllNoError(toTest, testcases) {
for (let args of wrapPrimitives(testcases)) {
test(`try: ${toTest.name}(${args})`, () =>
expect(() => toTest(...args)).not.toThrow())
}
}
export function testAllObject(toTest, testcases) {
testAllCustom(toTest, testcases, "toMatchObject", "ans")
}