1 /*
   2  * Copyright (c) 2015, Oracle and/or its affiliates. All rights reserved.
   3  * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
   4  *
   5  * This code is free software; you can redistribute it and/or modify it
   6  * under the terms of the GNU General Public License version 2 only, as
   7  * published by the Free Software Foundation.  Oracle designates this
   8  * particular file as subject to the "Classpath" exception as provided
   9  * by Oracle in the LICENSE file that accompanied this code.
  10  *
  11  * This code is distributed in the hope that it will be useful, but WITHOUT
  12  * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
  13  * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
  14  * version 2 for more details (a copy is included in the LICENSE file that
  15  * accompanied this code).
  16  *
  17  * You should have received a copy of the GNU General Public License version
  18  * 2 along with this work; if not, write to the Free Software Foundation,
  19  * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
  20  *
  21  * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
  22  * or visit www.oracle.com if you need additional information or have any
  23  * questions.
  24  */
  25 
  26 package jdk.internal.jshell.tool;
  27 
  28 import jdk.jshell.SourceCodeAnalysis.CompletionInfo;
  29 import jdk.jshell.SourceCodeAnalysis.Suggestion;
  30 
  31 import java.awt.event.ActionListener;
  32 import java.io.IOException;
  33 import java.io.InputStream;
  34 import java.io.PrintStream;
  35 import java.io.UncheckedIOException;
  36 import java.lang.reflect.Method;
  37 import java.util.List;
  38 import java.util.Locale;
  39 import java.util.Objects;
  40 import java.util.Optional;
  41 import java.util.function.Supplier;
  42 
  43 import jdk.internal.jline.NoInterruptUnixTerminal;
  44 import jdk.internal.jline.Terminal;
  45 import jdk.internal.jline.TerminalFactory;
  46 import jdk.internal.jline.WindowsTerminal;
  47 import jdk.internal.jline.console.ConsoleReader;
  48 import jdk.internal.jline.console.KeyMap;
  49 import jdk.internal.jline.console.UserInterruptException;
  50 import jdk.internal.jline.console.completer.Completer;
  51 import jdk.internal.jshell.tool.StopDetectingInputStream.State;
  52 
  53 class ConsoleIOContext extends IOContext {
  54 
  55     final JShellTool repl;
  56     final StopDetectingInputStream input;
  57     final ConsoleReader in;
  58     final EditingHistory history;
  59 
  60     String prefix = "";
  61 
  62     ConsoleIOContext(JShellTool repl, InputStream cmdin, PrintStream cmdout) throws Exception {
  63         this.repl = repl;
  64         this.input = new StopDetectingInputStream(() -> repl.state.stop(), ex -> repl.hard("Error on input: %s", ex));
  65         Terminal term;
  66         if (System.getProperty("os.name").toLowerCase(Locale.US).contains(TerminalFactory.WINDOWS)) {
  67             term = new JShellWindowsTerminal(input);
  68         } else {
  69             term = new JShellUnixTerminal(input);
  70         }
  71         term.init();
  72         in = new ConsoleReader(cmdin, cmdout, term);
  73         in.setExpandEvents(false);
  74         in.setHandleUserInterrupt(true);
  75         in.setHistory(history = new EditingHistory(JShellTool.PREFS) {
  76             @Override protected CompletionInfo analyzeCompletion(String input) {
  77                 return repl.analysis.analyzeCompletion(input);
  78             }
  79         });
  80         in.setBellEnabled(true);
  81         in.addCompleter(new Completer() {
  82             private String lastTest;
  83             private int lastCursor;
  84             private boolean allowSmart = false;
  85             @Override public int complete(String test, int cursor, List<CharSequence> result) {
  86                 int[] anchor = new int[] {-1};
  87                 List<Suggestion> suggestions;
  88                 if (prefix.isEmpty() && test.trim().startsWith("/")) {
  89                     suggestions = repl.commandCompletionSuggestions(test, cursor, anchor);
  90                 } else {
  91                     int prefixLength = prefix.length();
  92                     suggestions = repl.analysis.completionSuggestions(prefix + test, cursor + prefixLength, anchor);
  93                     anchor[0] -= prefixLength;
  94                 }
  95                 if (!Objects.equals(lastTest, test) || lastCursor != cursor)
  96                     allowSmart = true;
  97 
  98                 boolean smart = allowSmart &&
  99                                 suggestions.stream()
 100                                            .anyMatch(s -> s.isSmart);
 101 
 102                 lastTest = test;
 103                 lastCursor = cursor;
 104                 allowSmart = !allowSmart;
 105 
 106                 suggestions.stream()
 107                            .filter(s -> !smart || s.isSmart)
 108                            .map(s -> s.continuation)
 109                            .forEach(result::add);
 110 
 111                 boolean onlySmart = suggestions.stream()
 112                                                .allMatch(s -> s.isSmart);
 113 
 114                 if (smart && !onlySmart) {
 115                     Optional<String> prefix =
 116                             suggestions.stream()
 117                                        .map(s -> s.continuation)
 118                                        .reduce(ConsoleIOContext::commonPrefix);
 119 
 120                     String prefixStr = prefix.orElse("").substring(cursor - anchor[0]);
 121                     try {
 122                         in.putString(prefixStr);
 123                         cursor += prefixStr.length();
 124                     } catch (IOException ex) {
 125                         throw new IllegalStateException(ex);
 126                     }
 127                     result.add("<press tab to see more>");
 128                     return cursor; //anchor should not be used.
 129                 }
 130 
 131                 if (result.isEmpty()) {
 132                     try {
 133                         //provide "empty completion" feedback
 134                         //XXX: this only works correctly when there is only one Completer:
 135                         in.beep();
 136                     } catch (IOException ex) {
 137                         throw new UncheckedIOException(ex);
 138                     }
 139                 }
 140 
 141                 return anchor[0];
 142             }
 143         });
 144         bind(DOCUMENTATION_SHORTCUT, (ActionListener) evt -> documentation(repl));
 145         bind(CTRL_UP, (ActionListener) evt -> moveHistoryToSnippet(((EditingHistory) in.getHistory())::previousSnippet));
 146         bind(CTRL_DOWN, (ActionListener) evt -> moveHistoryToSnippet(((EditingHistory) in.getHistory())::nextSnippet));
 147     }
 148 
 149     @Override
 150     public String readLine(String prompt, String prefix) throws IOException, InputInterruptedException {
 151         this.prefix = prefix;
 152         try {
 153             return in.readLine(prompt);
 154         } catch (UserInterruptException ex) {
 155             throw (InputInterruptedException) new InputInterruptedException().initCause(ex);
 156         }
 157     }
 158 
 159     @Override
 160     public boolean interactiveOutput() {
 161         return true;
 162     }
 163 
 164     @Override
 165     public Iterable<String> currentSessionHistory() {
 166         return history.currentSessionEntries();
 167     }
 168 
 169     @Override
 170     public void close() throws IOException {
 171         history.save();
 172         in.shutdown();
 173         try {
 174             in.getTerminal().restore();
 175         } catch (Exception ex) {
 176             throw new IOException(ex);
 177         }
 178     }
 179 
 180     private void moveHistoryToSnippet(Supplier<Boolean> action) {
 181         if (!action.get()) {
 182             try {
 183                 in.beep();
 184             } catch (IOException ex) {
 185                 throw new IllegalStateException(ex);
 186             }
 187         } else {
 188             try {
 189                 //could use:
 190                 //in.resetPromptLine(in.getPrompt(), in.getHistory().current().toString(), -1);
 191                 //but that would mean more re-writing on the screen, (and prints an additional
 192                 //empty line), so using setBuffer directly:
 193                 Method setBuffer = in.getClass().getDeclaredMethod("setBuffer", String.class);
 194 
 195                 setBuffer.setAccessible(true);
 196                 setBuffer.invoke(in, in.getHistory().current().toString());
 197                 in.flush();
 198             } catch (ReflectiveOperationException | IOException ex) {
 199                 throw new IllegalStateException(ex);
 200             }
 201         }
 202     }
 203 
 204     private void bind(String shortcut, Object action) {
 205         KeyMap km = in.getKeys();
 206         for (int i = 0; i < shortcut.length(); i++) {
 207             Object value = km.getBound(Character.toString(shortcut.charAt(i)));
 208             if (value instanceof KeyMap) {
 209                 km = (KeyMap) value;
 210             } else {
 211                 km.bind(shortcut.substring(i), action);
 212             }
 213         }
 214     }
 215 
 216     private static final String DOCUMENTATION_SHORTCUT = "\033\133\132"; //Shift-TAB
 217     private static final String CTRL_UP = "\033\133\061\073\065\101"; //Ctrl-UP
 218     private static final String CTRL_DOWN = "\033\133\061\073\065\102"; //Ctrl-DOWN
 219 
 220     private void documentation(JShellTool repl) {
 221         String buffer = in.getCursorBuffer().buffer.toString();
 222         int cursor = in.getCursorBuffer().cursor;
 223         String doc;
 224         if (prefix.isEmpty() && buffer.trim().startsWith("/")) {
 225             doc = repl.commandDocumentation(buffer, cursor);
 226         } else {
 227             doc = repl.analysis.documentation(prefix + buffer, cursor + prefix.length());
 228         }
 229 
 230         try {
 231             if (doc != null) {
 232                 in.println();
 233                 in.println(doc);
 234                 in.redrawLine();
 235                 in.flush();
 236             } else {
 237                 in.beep();
 238             }
 239         } catch (IOException ex) {
 240             throw new IllegalStateException(ex);
 241         }
 242     }
 243 
 244     private static String commonPrefix(String str1, String str2) {
 245         for (int i = 0; i < str2.length(); i++) {
 246             if (!str1.startsWith(str2.substring(0, i + 1))) {
 247                 return str2.substring(0, i);
 248             }
 249         }
 250 
 251         return str2;
 252     }
 253 
 254     @Override
 255     public boolean terminalEditorRunning() {
 256         Terminal terminal = in.getTerminal();
 257         if (terminal instanceof JShellUnixTerminal)
 258             return ((JShellUnixTerminal) terminal).isRaw();
 259         return false;
 260     }
 261 
 262     @Override
 263     public void suspend() {
 264         try {
 265             in.getTerminal().restore();
 266         } catch (Exception ex) {
 267             throw new IllegalStateException(ex);
 268         }
 269     }
 270 
 271     @Override
 272     public void resume() {
 273         try {
 274             in.getTerminal().init();
 275         } catch (Exception ex) {
 276             throw new IllegalStateException(ex);
 277         }
 278     }
 279 
 280     public void beforeUserCode() {
 281         input.setState(State.BUFFER);
 282     }
 283 
 284     public void afterUserCode() {
 285         input.setState(State.WAIT);
 286     }
 287 
 288     @Override
 289     public void replaceLastHistoryEntry(String source) {
 290         history.fullHistoryReplace(source);
 291     }
 292 
 293     private static final class JShellUnixTerminal extends NoInterruptUnixTerminal {
 294 
 295         private final StopDetectingInputStream input;
 296 
 297         public JShellUnixTerminal(StopDetectingInputStream input) throws Exception {
 298             this.input = input;
 299         }
 300 
 301         public boolean isRaw() {
 302             try {
 303                 return getSettings().get("-a").contains("-icanon");
 304             } catch (IOException | InterruptedException ex) {
 305                 return false;
 306             }
 307         }
 308 
 309         @Override
 310         public InputStream wrapInIfNeeded(InputStream in) throws IOException {
 311             return input.setInputStream(super.wrapInIfNeeded(in));
 312         }
 313 
 314         @Override
 315         public void disableInterruptCharacter() {
 316         }
 317 
 318         @Override
 319         public void enableInterruptCharacter() {
 320         }
 321 
 322     }
 323 
 324     private static final class JShellWindowsTerminal extends WindowsTerminal {
 325 
 326         private final StopDetectingInputStream input;
 327 
 328         public JShellWindowsTerminal(StopDetectingInputStream input) throws Exception {
 329             this.input = input;
 330         }
 331 
 332         @Override
 333         public void init() throws Exception {
 334             super.init();
 335             setAnsiSupported(false);
 336         }
 337 
 338         @Override
 339         public InputStream wrapInIfNeeded(InputStream in) throws IOException {
 340             return input.setInputStream(super.wrapInIfNeeded(in));
 341         }
 342 
 343     }
 344 }