package org.sqsh; import org.jline.keymap.KeyMap; import org.jline.reader.Binding; import org.jline.reader.Buffer; import org.jline.reader.LineReader; import org.jline.reader.LineReaderBuilder; import org.jline.reader.Reference; import org.jline.reader.Widget; import org.jline.reader.impl.DefaultParser; import org.jline.terminal.Terminal; import org.jline.terminal.TerminalBuilder; import org.sqsh.jline.JLineCompleter; import java.io.File; import java.io.IOException; import java.util.Map; /** * Wrapper around the JLine LineReader console that provides some * additional functionality. */ public class SqshConsole { /** * Enumerates the reason that a call to readLine accepted its input and returned * to the caller */ public enum AcceptCause { /** * Means that the user's input "normally" was complete. That is, they * ran a command or the terminated the statement with a terminator */ NORMAL, /** * The user explicitly asked for the statement to be executed (typically * via a "CTRL-G"). */ EXECUTE } private SqshContext context; private LineReader reader; private LineReader simpleLineReader = null; private AcceptCause acceptCause = AcceptCause.NORMAL; /** * Enables or disables JLine's ability to do multi-line editing. */ private boolean multiLineEnabled = true; public SqshConsole(SqshContext context) { this.context = context; File readlineHistory = new File(context.getConfigDirectory(), "jline_history"); reader = LineReaderBuilder.builder() .appName("jsqsh") .terminal(newTerminal()) .completer(new JLineCompleter(context)) .parser(new DefaultParser()) .variable(LineReader.HISTORY_FILE, readlineHistory.toString()) .build(); /* * This installs a widget that intercepts \n and attempts to determine if the * input is "finished" and should be run or if the user should keep typing * the current statement. I hate that I have to do this for all of the keymaps * that JLine3 has... */ for (String keyMap : reader.getKeyMaps().keySet()) { // Bind to CTRL-M (enter) reader.getKeyMaps().get(keyMap).bind(new Reference("jsqsh-accept"), ctrl('M')); // Bind CTRL-G to automatically do "go" reader.getKeyMaps().get(keyMap).bind(new Reference("jsqsh-go"), ctrl('G')); } reader.getWidgets().put("jsqsh-accept", new JLineAcceptBufferWidget()); reader.getWidgets().put("jsqsh-go", new JLineExecuteBufferWidget()); reader.setOpt(LineReader.Option.DISABLE_EVENT_EXPANSION); } public String readSingleLine(String prompt, Character mask) { return getSimpleLineReader().readLine(prompt, mask); } public LineReader getSimpleLineReader() { if (simpleLineReader == null) { simpleLineReader = LineReaderBuilder.builder() .appName("jsqsh") .terminal(newTerminal()) .build(); } return simpleLineReader; } private Terminal newTerminal() { Terminal terminal; try { terminal = TerminalBuilder.builder() .nativeSignals(true) .build(); } catch (IOException e) { System.err.println("Unable to create terminal: " + e.getMessage() + ". Falling back to dumb terminal"); try { terminal = TerminalBuilder.builder() .dumb(true) .build(); } catch (IOException e2) { System.err.println("Unable to create dumb terminal: " + e2.getMessage() + ". Giving up"); throw new RuntimeException(e.getMessage(), e); } } return terminal; } public String readLine(int lineOffset, String prompt, String prompt2, Character mask, String initialInput) { reset(); reader.setVariable(LineReader.SECONDARY_PROMPT_PATTERN, prompt2); reader.setVariable(LineReader.LINE_OFFSET, lineOffset); try { return reader.readLine(prompt, mask, initialInput); } finally { reader.setVariable(LineReader.LINE_OFFSET, 0); reader.setVariable(LineReader.SECONDARY_PROMPT_PATTERN, null); } } /** * Toggles whether or not readLine will save its read input to the history * @param isHistoryEnabled true if history saving is enabled. */ public void setHistoryEnabled(boolean isHistoryEnabled) { if (isHistoryEnabled) { reader.setVariable(LineReader.DISABLE_HISTORY, Boolean.FALSE); } else { reader.setVariable(LineReader.DISABLE_HISTORY, Boolean.TRUE); } } /** * When called after <code>readLine()</code> returns normally, this indicates * the reason that it returned (@see AcceptCause}. * * @return The reason that the user's input was accepted. */ public AcceptCause getAcceptCause() { return acceptCause; } public void addToHistory(String input) { reader.getHistory().add(input); } public void saveHistory() throws IOException { reader.getHistory().save(); } /** * @return true if multi-line editing is enabled when JLine is in use * (during interactive mode) */ public boolean isMultiLineEnabled() { return multiLineEnabled; } /** * Changes whether or not multi-line editing is enabled when JLine is in use. * * @param multiLineEnabled true if multi-line editing is enabled, false * otherwise. */ public void setMultiLineEnabled(boolean multiLineEnabled) { this.multiLineEnabled = multiLineEnabled; } /** * @return The terminal being used by the underlying JLine reader. */ public Terminal getTerminal() { return reader.getTerminal(); } /** * Sets the editing mode for the console reader. Logically supported names are * 'vi' and 'emacs', however any valid keymap supported by JLine3 is supported. * * @param name The name of the editing mode. */ public void setEditingMode(String name) { Map<String, KeyMap<Binding>> keyMaps = reader.getKeyMaps(); if ("vi".equals(name)) { keyMaps.put(LineReader.MAIN, keyMaps.get(LineReader.VIINS)); } else if ("vi-move".equals(name)) { keyMaps.put(LineReader.MAIN, keyMaps.get(LineReader.VICMD)); } else if ("vi-insert".equals(name)) { keyMaps.put(LineReader.MAIN, keyMaps.get(LineReader.VIINS)); } else if ("emacs".equals(name)) { keyMaps.put(LineReader.MAIN, keyMaps.get(LineReader.EMACS)); } else { if (keyMaps.containsKey(name)) { keyMaps.put(LineReader.MAIN, keyMaps.get(name)); } } } /** * @return The current line editing mode. This should be one of "vi" or "emacs" */ public String getEditingMode() { Map<String, KeyMap<Binding>> keyMaps = reader.getKeyMaps(); KeyMap<Binding> currentKeyMap = keyMaps.get(LineReader.MAIN); if (currentKeyMap == keyMaps.get(LineReader.VICMD) || currentKeyMap == keyMaps.get(LineReader.VIOPP)) { return "vi"; } else if (currentKeyMap == keyMaps.get(LineReader.VICMD)) { return "vi-move"; } else if (currentKeyMap == keyMaps.get(LineReader.EMACS)) { return "emacs"; } else { for (Map.Entry<String, KeyMap<Binding>> e : keyMaps.entrySet()) { if (e.getValue() == currentKeyMap) { return e.getKey(); } } } return "unknown"; } private void reset() { acceptCause = AcceptCause.NORMAL; } private String ctrl(char ch) { return Character.toString((char) (ch & 0x1f)); } /** * JLine widget that is used to intercept ENTER/RETURN (CTRL-M) and * determine if it should "accept" the input, meaning that the user's * done typing input and jsqsh should use it, or if it should continue * prompting the user for input. */ private class JLineAcceptBufferWidget implements Widget { @Override public boolean apply() { final Buffer buffer = reader.getBuffer(); if (context.getCurrentSession().isInputComplete(buffer.toString(), buffer.cursor())) { acceptCause = AcceptCause.NORMAL; reader.callWidget(LineReader.ACCEPT_LINE); } else { buffer.write('\n'); } return true; } } private class JLineExecuteBufferWidget implements Widget { @Override public boolean apply() { acceptCause = AcceptCause.EXECUTE; reader.callWidget(LineReader.ACCEPT_LINE); return true; } } }