From 9ccfe0b121d2ea72a8524c84a9bda5abaa68d45a Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Thu, 5 Apr 2018 19:59:27 +0200 Subject: [PATCH] Add support for inputrc parsing, fixes #230 --- .../main/java/org/jline/builtins/InputRC.java | 387 ++++++++++++++++++ .../java/org/jline/builtins/InputRCTest.java | 60 +++ .../resources/org/jline/builtins/config-bad | 7 + .../test/resources/org/jline/builtins/config1 | 10 + .../test/resources/org/jline/builtins/config2 | 98 +++++ 5 files changed, 562 insertions(+) create mode 100644 builtins/src/main/java/org/jline/builtins/InputRC.java create mode 100644 builtins/src/test/java/org/jline/builtins/InputRCTest.java create mode 100644 builtins/src/test/resources/org/jline/builtins/config-bad create mode 100644 builtins/src/test/resources/org/jline/builtins/config1 create mode 100644 builtins/src/test/resources/org/jline/builtins/config2 diff --git a/builtins/src/main/java/org/jline/builtins/InputRC.java b/builtins/src/main/java/org/jline/builtins/InputRC.java new file mode 100644 index 000000000..65794269e --- /dev/null +++ b/builtins/src/main/java/org/jline/builtins/InputRC.java @@ -0,0 +1,387 @@ +/* + * Copyright (c) 2002-2018, the original author or authors. + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * http://www.opensource.org/licenses/bsd-license.php + */ +package org.jline.builtins; + + +import org.jline.reader.LineReader; +import org.jline.reader.Macro; +import org.jline.reader.Reference; +import org.jline.utils.Log; + +import java.io.*; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public final class InputRC { + + public static void configure(LineReader reader, String appName, URL url) throws IOException { + try (InputStream is = url.openStream()) { + configure(reader, appName, is); + } + } + + public static void configure(LineReader reader, String appName, InputStream is) throws IOException { + try (InputStreamReader r = new InputStreamReader(is)) { + configure(reader, appName, r); + } + } + + public static void configure(LineReader reader, String appName, Reader r) throws IOException { + BufferedReader br; + if (r instanceof BufferedReader) { + br = (BufferedReader) r; + } else { + br = new BufferedReader(r); + } + reader.getVariables().putIfAbsent(LineReader.EDITING_MODE, "emacs"); + reader.setKeyMap(LineReader.MAIN); + if ("vi".equals(reader.getVariable(LineReader.EDITING_MODE))) { + reader.getKeyMaps().put(LineReader.MAIN, reader.getKeyMaps().get(LineReader.VIINS)); + } else if ("emacs".equals(reader.getVariable(LineReader.EDITING_MODE))) { + reader.getKeyMaps().put(LineReader.MAIN, reader.getKeyMaps().get(LineReader.EMACS)); + } + new InputRC(reader, appName).parse(br); + if ("vi".equals(reader.getVariable(LineReader.EDITING_MODE))) { + reader.getKeyMaps().put(LineReader.MAIN, reader.getKeyMaps().get(LineReader.VIINS)); + } else if ("emacs".equals(reader.getVariable(LineReader.EDITING_MODE))) { + reader.getKeyMaps().put(LineReader.MAIN, reader.getKeyMaps().get(LineReader.EMACS)); + } + } + + private final LineReader reader; + private final String appName; + + private InputRC(LineReader reader, String appName) { + this.reader = reader; + this.appName = appName; + } + + private void parse(BufferedReader br) throws IOException, IllegalArgumentException { + String line; + boolean parsing = true; + List ifsStack = new ArrayList<>(); + while ((line = br.readLine()) != null) { + try { + line = line.trim(); + if (line.length() == 0) { + continue; + } + if (line.charAt(0) == '#') { + continue; + } + int i = 0; + if (line.charAt(i) == '$') { + String cmd; + String args; + ++i; + while (i < line.length() && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { + i++; + } + int s = i; + while (i < line.length() && (line.charAt(i) != ' ' && line.charAt(i) != '\t')) { + i++; + } + cmd = line.substring(s, i); + while (i < line.length() && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { + i++; + } + s = i; + while (i < line.length() && (line.charAt(i) != ' ' && line.charAt(i) != '\t')) { + i++; + } + args = line.substring(s, i); + if ("if".equalsIgnoreCase(cmd)) { + ifsStack.add(parsing); + if (!parsing) { + continue; + } + if (args.startsWith("term=")) { + // TODO + } else if (args.startsWith("mode=")) { + String mode = (String) reader.getVariable(LineReader.EDITING_MODE); + parsing = args.substring("mode=".length()).equalsIgnoreCase(mode); + } else { + parsing = args.equalsIgnoreCase(appName); + } + } else if ("else".equalsIgnoreCase(cmd)) { + if (ifsStack.isEmpty()) { + throw new IllegalArgumentException("$else found without matching $if"); + } + boolean invert = true; + for (boolean b : ifsStack) { + if (!b) { + invert = false; + break; + } + } + if (invert) { + parsing = !parsing; + } + } else if ("endif".equalsIgnoreCase(cmd)) { + if (ifsStack.isEmpty()) { + throw new IllegalArgumentException("endif found without matching $if"); + } + parsing = ifsStack.remove(ifsStack.size() - 1); + } else if ("include".equalsIgnoreCase(cmd)) { + // TODO + } + continue; + } + if (!parsing) { + continue; + } + if (line.charAt(i++) == '"') { + boolean esc = false; + for (; ; i++) { + if (i >= line.length()) { + throw new IllegalArgumentException("Missing closing quote on line '" + line + "'"); + } + if (esc) { + esc = false; + } else if (line.charAt(i) == '\\') { + esc = true; + } else if (line.charAt(i) == '"') { + break; + } + } + } + while (i < line.length() && line.charAt(i) != ':' + && line.charAt(i) != ' ' && line.charAt(i) != '\t') { + i++; + } + String keySeq = line.substring(0, i); + boolean equivalency = i + 1 < line.length() && line.charAt(i) == ':' && line.charAt(i + 1) == '='; + i++; + if (equivalency) { + i++; + } + if (keySeq.equalsIgnoreCase("set")) { + String key; + String val; + while (i < line.length() && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { + i++; + } + int s = i; + while (i < line.length() && (line.charAt(i) != ' ' && line.charAt(i) != '\t')) { + i++; + } + key = line.substring(s, i); + while (i < line.length() && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { + i++; + } + s = i; + while (i < line.length() && (line.charAt(i) != ' ' && line.charAt(i) != '\t')) { + i++; + } + val = line.substring(s, i); + setVar(reader, key, val); + } else { + while (i < line.length() && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) { + i++; + } + int start = i; + if (i < line.length() && (line.charAt(i) == '\'' || line.charAt(i) == '\"')) { + char delim = line.charAt(i++); + boolean esc = false; + for (; ; i++) { + if (i >= line.length()) { + break; + } + if (esc) { + esc = false; + } else if (line.charAt(i) == '\\') { + esc = true; + } else if (line.charAt(i) == delim) { + break; + } + } + } + for (; i < line.length() && line.charAt(i) != ' ' && line.charAt(i) != '\t'; i++) ; + String val = line.substring(Math.min(start, line.length()), Math.min(i, line.length())); + if (keySeq.charAt(0) == '"') { + keySeq = translateQuoted(keySeq); + } else { + // Bind key name + String keyName = keySeq.lastIndexOf('-') > 0 ? keySeq.substring(keySeq.lastIndexOf('-') + 1) : keySeq; + char key = getKeyFromName(keyName); + keyName = keySeq.toLowerCase(); + keySeq = ""; + if (keyName.contains("meta-") || keyName.contains("m-")) { + keySeq += "\u001b"; + } + if (keyName.contains("control-") || keyName.contains("c-") || keyName.contains("ctrl-")) { + key = (char) (Character.toUpperCase(key) & 0x1f); + } + keySeq += key; + } + if (val.length() > 0 && (val.charAt(0) == '\'' || val.charAt(0) == '\"')) { + reader.getKeys().bind(new Macro(translateQuoted(val)), keySeq); + } else { + reader.getKeys().bind(new Reference(val), keySeq); + } + } + } catch (IllegalArgumentException e) { + Log.warn("Unable to parse user configuration: ", e); + } + } + } + + private static String translateQuoted(String keySeq) { + int i; + String str = keySeq.substring(1, keySeq.length() - 1); + StringBuilder sb = new StringBuilder(); + for (i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if (c == '\\') { + boolean ctrl = str.regionMatches(i, "\\C-", 0, 3) || str.regionMatches(i, "\\M-\\C-", 0, 6); + boolean meta = str.regionMatches(i, "\\M-", 0, 3) || str.regionMatches(i, "\\C-\\M-", 0, 6); + i += (meta ? 3 : 0) + (ctrl ? 3 : 0) + (!meta && !ctrl ? 1 : 0); + if (i >= str.length()) { + break; + } + c = str.charAt(i); + if (meta) { + sb.append("\u001b"); + } + if (ctrl) { + c = c == '?' ? 0x7f : (char) (Character.toUpperCase(c) & 0x1f); + } + if (!meta && !ctrl) { + switch (c) { + case 'a': + c = 0x07; + break; + case 'b': + c = '\b'; + break; + case 'd': + c = 0x7f; + break; + case 'e': + c = 0x1b; + break; + case 'f': + c = '\f'; + break; + case 'n': + c = '\n'; + break; + case 'r': + c = '\r'; + break; + case 't': + c = '\t'; + break; + case 'v': + c = 0x0b; + break; + case '\\': + c = '\\'; + break; + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + c = 0; + for (int j = 0; j < 3; j++, i++) { + if (i >= str.length()) { + break; + } + int k = Character.digit(str.charAt(i), 8); + if (k < 0) { + break; + } + c = (char) (c * 8 + k); + } + c &= 0xFF; + break; + case 'x': + i++; + c = 0; + for (int j = 0; j < 2; j++, i++) { + if (i >= str.length()) { + break; + } + int k = Character.digit(str.charAt(i), 16); + if (k < 0) { + break; + } + c = (char) (c * 16 + k); + } + c &= 0xFF; + break; + case 'u': + i++; + c = 0; + for (int j = 0; j < 4; j++, i++) { + if (i >= str.length()) { + break; + } + int k = Character.digit(str.charAt(i), 16); + if (k < 0) { + break; + } + c = (char) (c * 16 + k); + } + break; + } + } + sb.append(c); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static char getKeyFromName(String name) { + if ("DEL".equalsIgnoreCase(name) || "Rubout".equalsIgnoreCase(name)) { + return 0x7f; + } else if ("ESC".equalsIgnoreCase(name) || "Escape".equalsIgnoreCase(name)) { + return '\033'; + } else if ("LFD".equalsIgnoreCase(name) || "NewLine".equalsIgnoreCase(name)) { + return '\n'; + } else if ("RET".equalsIgnoreCase(name) || "Return".equalsIgnoreCase(name)) { + return '\r'; + } else if ("SPC".equalsIgnoreCase(name) || "Space".equalsIgnoreCase(name)) { + return ' '; + } else if ("Tab".equalsIgnoreCase(name)) { + return '\t'; + } else { + return name.charAt(0); + } + } + + private static void setVar(LineReader reader, String key, String val) { + if (LineReader.KEYMAP.equalsIgnoreCase(key)) { + reader.setKeyMap(val); + return; + } + + for (LineReader.Option option : LineReader.Option.values()) { + if (option.name().toLowerCase(Locale.ENGLISH).replace('_', '-').equals(val)) { + if ("on".equalsIgnoreCase(val)) { + reader.setOpt(option); + } else if ("off".equalsIgnoreCase(val)) { + reader.unsetOpt(option); + } + return; + } + } + + reader.setVariable(key, val); + } + +} diff --git a/builtins/src/test/java/org/jline/builtins/InputRCTest.java b/builtins/src/test/java/org/jline/builtins/InputRCTest.java new file mode 100644 index 000000000..2084fa676 --- /dev/null +++ b/builtins/src/test/java/org/jline/builtins/InputRCTest.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2002-2018, the original author or authors. + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * http://www.opensource.org/licenses/bsd-license.php + */ +package org.jline.builtins; + +import org.jline.reader.LineReader; +import org.jline.reader.LineReaderBuilder; +import org.jline.reader.Macro; +import org.jline.reader.Reference; +import org.jline.terminal.TerminalBuilder; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +public class InputRCTest { + + @Test + public void testInput() throws Exception { + LineReader lr = createReader(null, "config1"); + assertEquals(new Reference("universal-argument"), lr.getKeys().getBound("" + ((char)('U' - 'A' + 1)))); + assertEquals(new Macro("Function Key \u2671"), lr.getKeys().getBound("\u001b[11~")); + assertNull(lr.getKeys().getBound(((char) ('X' - 'A' + 1)) + "q")); + + lr = createReader("Bash", "config1"); + assertEquals(new Macro("\u001bb\"\u001bf\""), lr.getKeys().getBound(((char)('X' - 'A' + 1)) + "q")); + } + + @Test + public void testInput2() throws Exception { + LineReader lr = createReader("Bash", "config2"); + assertNotNull(lr.getKeys().getBound("\u001b" + ((char) ('V' - 'A' + 1)))); + } + + @Test + public void testInputBadConfig() throws Exception { + LineReader lr = createReader("Bash", "config-bad"); + assertEquals(new Macro("\u001bb\"\u001bf\""), lr.getKeys().getBound(((char) ('X' - 'A' + 1)) + "q")); + } + + private LineReader createReader(String appName, String config) throws IOException { + LineReader lr = LineReaderBuilder.builder() + .terminal(TerminalBuilder.builder().streams(new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream()).build()) + .appName(appName) + .build(); + InputRC.configure(lr, appName, getClass().getResource(config)); + return lr; + } + +} diff --git a/builtins/src/test/resources/org/jline/builtins/config-bad b/builtins/src/test/resources/org/jline/builtins/config-bad new file mode 100644 index 000000000..54979f32d --- /dev/null +++ b/builtins/src/test/resources/org/jline/builtins/config-bad @@ -0,0 +1,7 @@ +# Don't bomb on unsupported operations +"\C-fake": some-fake-operation + +"\C-bad: nothing + +"\C-xq": "\eb\"\ef\"" + diff --git a/builtins/src/test/resources/org/jline/builtins/config1 b/builtins/src/test/resources/org/jline/builtins/config1 new file mode 100644 index 000000000..6c27352b7 --- /dev/null +++ b/builtins/src/test/resources/org/jline/builtins/config1 @@ -0,0 +1,10 @@ +Control-u: universal-argument +Meta-Rubout: backward-kill-word +Control-o: "> output" +"\C-u": universal-argument +"\C-x\C-r": re-read-init-file +"\e[11~": "Function Key \u2671" +$if Bash +# Quote the current or previous word +"\C-xq": "\eb\"\ef\"" +$endif diff --git a/builtins/src/test/resources/org/jline/builtins/config2 b/builtins/src/test/resources/org/jline/builtins/config2 new file mode 100644 index 000000000..093c2c589 --- /dev/null +++ b/builtins/src/test/resources/org/jline/builtins/config2 @@ -0,0 +1,98 @@ + +# This file controls the behaviour of line input editing for +# programs that use the Gnu Readline library. Existing programs +# include FTP, Bash, and Gdb. +# +# You can re-read the inputrc file with C-x C-r. +# Lines beginning with '#' are comments. +# +# First, include any systemwide bindings and variable assignments from +# /etc/Inputrc +$include /etc/Inputrc + +# +# Set various bindings for emacs mode. + +set editing-mode emacs + +$if mode=emacs + +Meta-Control-h: backward-kill-word Text after the function name is ignored + +# +# Arrow keys in keypad mode +# +#"\M-OD": backward-char +#"\M-OC": forward-char +#"\M-OA": previous-history +#"\M-OB": next-history +# +# Arrow keys in ANSI mode +# +"\M-[D": backward-char +"\M-[C": forward-char +"\M-[A": previous-history +"\M-[B": next-history +# +# Arrow keys in 8 bit keypad mode +# +#"\M-\C-OD": backward-char +#"\M-\C-OC": forward-char +#"\M-\C-OA": previous-history +#"\M-\C-OB": next-history +# +# Arrow keys in 8 bit ANSI mode +# +#"\M-\C-[D": backward-char +#"\M-\C-[C": forward-char +#"\M-\C-[A": previous-history +#"\M-\C-[B": next-history + +C-q: quoted-insert + +$endif + +# An old-style binding. This happens to be the default. +TAB: complete + +# Macros that are convenient for shell interaction +$if Bash +# edit the path +"\C-xp": "PATH=${PATH}\e\C-e\C-a\ef\C-f" +# prepare to type a quoted word -- insert open and close double quotes +# and move to just after the open quote +"\C-x\"": "\"\"\C-b" +# insert a backslash (testing backslash escapes in sequences and macros) +"\C-x\\": "\\" +# Quote the current or previous word +"\C-xq": "\eb\"\ef\"" +# Add a binding to refresh the line, which is unbound +"\C-xr": redraw-current-line +# Edit variable on current line. +"\M-\C-v": "\C-a\C-k$\C-y\M-\C-e\C-a\C-y=" +$endif + +# use a visible bell if one is available +set bell-style visible + +# don't strip characters to 7 bits when reading +set input-meta on + +# allow iso-latin1 characters to be inserted rather than converted to +# prefix-meta sequences +set convert-meta off + +# display characters with the eighth bit set directly rather than +# as meta-prefixed characters +set output-meta on + +# if there are more than 150 possible completions for a word, ask the +# user if he wants to see all of them +set completion-query-items 150 + +# For FTP +$if Ftp +"\C-xg": "get \M-?" +"\C-xt": "put \M-?" +"\M-.": yank-last-arg +$endif \ No newline at end of file