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.IndexResult;
  30 import jdk.jshell.SourceCodeAnalysis.Suggestion;
  31 
  32 import java.awt.event.ActionListener;
  33 import java.io.IOException;
  34 import java.io.InputStream;
  35 import java.io.PrintStream;
  36 import java.io.UncheckedIOException;
  37 import java.lang.reflect.Method;
  38 import java.util.ArrayList;
  39 import java.util.Collections;
  40 import java.util.HashMap;
  41 import java.util.List;
  42 import java.util.Locale;
  43 import java.util.Map;
  44 import java.util.Objects;
  45 import java.util.Optional;
  46 import java.util.function.Supplier;
  47 
  48 import jdk.internal.jline.NoInterruptUnixTerminal;
  49 import jdk.internal.jline.Terminal;
  50 import jdk.internal.jline.TerminalFactory;
  51 import jdk.internal.jline.WindowsTerminal;
  52 import jdk.internal.jline.console.ConsoleReader;
  53 import jdk.internal.jline.console.KeyMap;
  54 import jdk.internal.jline.console.UserInterruptException;
  55 import jdk.internal.jline.console.completer.Completer;
  56 import jdk.internal.jshell.tool.StopDetectingInputStream.State;
  57 
  58 class ConsoleIOContext extends IOContext {
  59 
  60     final JShellTool repl;
  61     final StopDetectingInputStream input;
  62     final ConsoleReader in;
  63     final EditingHistory history;
  64 
  65     String prefix = "";
  66 
  67     ConsoleIOContext(JShellTool repl, InputStream cmdin, PrintStream cmdout) throws Exception {
  68         this.repl = repl;
  69         this.input = new StopDetectingInputStream(() -> repl.state.stop(), ex -> repl.hard("Error on input: %s", ex));
  70         Terminal term;
  71         if (System.getProperty("os.name").toLowerCase(Locale.US).contains(TerminalFactory.WINDOWS)) {
  72             term = new JShellWindowsTerminal(input);
  73         } else {
  74             term = new JShellUnixTerminal(input);
  75         }
  76         term.init();
  77         in = new ConsoleReader(cmdin, cmdout, term);
  78         in.setExpandEvents(false);
  79         in.setHandleUserInterrupt(true);
  80         in.setHistory(history = new EditingHistory(JShellTool.PREFS) {
  81             @Override protected CompletionInfo analyzeCompletion(String input) {
  82                 return repl.analysis.analyzeCompletion(input);
  83             }
  84         });
  85         in.setBellEnabled(true);
  86         in.addCompleter(new Completer() {
  87             private String lastTest;
  88             private int lastCursor;
  89             private boolean allowSmart = false;
  90             @Override public int complete(String test, int cursor, List<CharSequence> result) {
  91                 int[] anchor = new int[] {-1};
  92                 List<Suggestion> suggestions;
  93                 if (prefix.isEmpty() && test.trim().startsWith("/")) {
  94                     suggestions = repl.commandCompletionSuggestions(test, cursor, anchor);
  95                 } else {
  96                     int prefixLength = prefix.length();
  97                     suggestions = repl.analysis.completionSuggestions(prefix + test, cursor + prefixLength, anchor);
  98                     anchor[0] -= prefixLength;
  99                 }
 100                 if (!Objects.equals(lastTest, test) || lastCursor != cursor)
 101                     allowSmart = true;
 102 
 103                 boolean smart = allowSmart &&
 104                                 suggestions.stream()
 105                                            .anyMatch(s -> s.isSmart);
 106 
 107                 lastTest = test;
 108                 lastCursor = cursor;
 109                 allowSmart = !allowSmart;
 110 
 111                 suggestions.stream()
 112                            .filter(s -> !smart || s.isSmart)
 113                            .map(s -> s.continuation)
 114                            .forEach(result::add);
 115 
 116                 boolean onlySmart = suggestions.stream()
 117                                                .allMatch(s -> s.isSmart);
 118 
 119                 if (smart && !onlySmart) {
 120                     Optional<String> prefix =
 121                             suggestions.stream()
 122                                        .map(s -> s.continuation)
 123                                        .reduce(ConsoleIOContext::commonPrefix);
 124 
 125                     String prefixStr = prefix.orElse("").substring(cursor - anchor[0]);
 126                     try {
 127                         in.putString(prefixStr);
 128                         cursor += prefixStr.length();
 129                     } catch (IOException ex) {
 130                         throw new IllegalStateException(ex);
 131                     }
 132                     result.add("<press tab to see more>");
 133                     return cursor; //anchor should not be used.
 134                 }
 135 
 136                 if (result.isEmpty()) {
 137                     try {
 138                         //provide "empty completion" feedback
 139                         //XXX: this only works correctly when there is only one Completer:
 140                         in.beep();
 141                     } catch (IOException ex) {
 142                         throw new UncheckedIOException(ex);
 143                     }
 144                 }
 145 
 146                 return anchor[0];
 147             }
 148         });
 149         bind(DOCUMENTATION_SHORTCUT, (ActionListener) evt -> documentation(repl));
 150         bind(CTRL_UP, (ActionListener) evt -> moveHistoryToSnippet(((EditingHistory) in.getHistory())::previousSnippet));
 151         bind(CTRL_DOWN, (ActionListener) evt -> moveHistoryToSnippet(((EditingHistory) in.getHistory())::nextSnippet));
 152         for (FixComputer computer : fixComputers) {
 153             for (String shortcuts : SHORTCUT_FIXES) {
 154                 bind(shortcuts + computer.shortcut, (ActionListener) evt -> fixes(computer));
 155             }
 156         }
 157     }
 158 
 159     @Override
 160     public String readLine(String prompt, String prefix) throws IOException, InputInterruptedException {
 161         this.prefix = prefix;
 162         try {
 163             return in.readLine(prompt);
 164         } catch (UserInterruptException ex) {
 165             throw (InputInterruptedException) new InputInterruptedException().initCause(ex);
 166         }
 167     }
 168 
 169     @Override
 170     public boolean interactiveOutput() {
 171         return true;
 172     }
 173 
 174     @Override
 175     public Iterable<String> currentSessionHistory() {
 176         return history.currentSessionEntries();
 177     }
 178 
 179     @Override
 180     public void close() throws IOException {
 181         history.save();
 182         in.shutdown();
 183         try {
 184             in.getTerminal().restore();
 185         } catch (Exception ex) {
 186             throw new IOException(ex);
 187         }
 188     }
 189 
 190     private void moveHistoryToSnippet(Supplier<Boolean> action) {
 191         if (!action.get()) {
 192             try {
 193                 in.beep();
 194             } catch (IOException ex) {
 195                 throw new IllegalStateException(ex);
 196             }
 197         } else {
 198             try {
 199                 //could use:
 200                 //in.resetPromptLine(in.getPrompt(), in.getHistory().current().toString(), -1);
 201                 //but that would mean more re-writing on the screen, (and prints an additional
 202                 //empty line), so using setBuffer directly:
 203                 Method setBuffer = in.getClass().getDeclaredMethod("setBuffer", String.class);
 204 
 205                 setBuffer.setAccessible(true);
 206                 setBuffer.invoke(in, in.getHistory().current().toString());
 207                 in.flush();
 208             } catch (ReflectiveOperationException | IOException ex) {
 209                 throw new IllegalStateException(ex);
 210             }
 211         }
 212     }
 213 
 214     private void bind(String shortcut, Object action) {
 215         KeyMap km = in.getKeys();
 216         for (int i = 0; i < shortcut.length(); i++) {
 217             Object value = km.getBound(Character.toString(shortcut.charAt(i)));
 218             if (value instanceof KeyMap) {
 219                 km = (KeyMap) value;
 220             } else {
 221                 km.bind(shortcut.substring(i), action);
 222             }
 223         }
 224     }
 225 
 226     private static final String DOCUMENTATION_SHORTCUT = "\033\133\132"; //Shift-TAB
 227     private static final String CTRL_UP = "\033\133\061\073\065\101"; //Ctrl-UP
 228     private static final String CTRL_DOWN = "\033\133\061\073\065\102"; //Ctrl-DOWN
 229     private static final String[] SHORTCUT_FIXES = {
 230         "\033\015", //Alt-Enter (Linux)
 231         "\033\133\061\067\176", //F6/Alt-F1 (Mac)
 232         "\u001BO3P" //Alt-F1 (Linux)
 233     };
 234 
 235     private void documentation(JShellTool repl) {
 236         String buffer = in.getCursorBuffer().buffer.toString();
 237         int cursor = in.getCursorBuffer().cursor;
 238         String doc;
 239         if (prefix.isEmpty() && buffer.trim().startsWith("/")) {
 240             doc = repl.commandDocumentation(buffer, cursor);
 241         } else {
 242             doc = repl.analysis.documentation(prefix + buffer, cursor + prefix.length());
 243         }
 244 
 245         try {
 246             if (doc != null) {
 247                 in.println();
 248                 in.println(doc);
 249                 in.redrawLine();
 250                 in.flush();
 251             } else {
 252                 in.beep();
 253             }
 254         } catch (IOException ex) {
 255             throw new IllegalStateException(ex);
 256         }
 257     }
 258 
 259     private static String commonPrefix(String str1, String str2) {
 260         for (int i = 0; i < str2.length(); i++) {
 261             if (!str1.startsWith(str2.substring(0, i + 1))) {
 262                 return str2.substring(0, i);
 263             }
 264         }
 265 
 266         return str2;
 267     }
 268 
 269     @Override
 270     public boolean terminalEditorRunning() {
 271         Terminal terminal = in.getTerminal();
 272         if (terminal instanceof JShellUnixTerminal)
 273             return ((JShellUnixTerminal) terminal).isRaw();
 274         return false;
 275     }
 276 
 277     @Override
 278     public void suspend() {
 279         try {
 280             in.getTerminal().restore();
 281         } catch (Exception ex) {
 282             throw new IllegalStateException(ex);
 283         }
 284     }
 285 
 286     @Override
 287     public void resume() {
 288         try {
 289             in.getTerminal().init();
 290         } catch (Exception ex) {
 291             throw new IllegalStateException(ex);
 292         }
 293     }
 294 
 295     public void beforeUserCode() {
 296         input.setState(State.BUFFER);
 297     }
 298 
 299     public void afterUserCode() {
 300         input.setState(State.WAIT);
 301     }
 302 
 303     @Override
 304     public void replaceLastHistoryEntry(String source) {
 305         history.fullHistoryReplace(source);
 306     }
 307 
 308     private void fixes(FixComputer computer) {
 309         String input = prefix + in.getCursorBuffer().toString();
 310         int cursor = prefix.length() + in.getCursorBuffer().cursor;
 311         FixResult candidates = computer.compute(repl, input, cursor);
 312 
 313         try {
 314             final boolean printError = candidates.error != null && !candidates.error.isEmpty();
 315             if (printError) {
 316                 in.println(candidates.error);
 317             }
 318             if (candidates.fixes.isEmpty()) {
 319                 in.beep();
 320                 if (printError) {
 321                     in.redrawLine();
 322                     in.flush();
 323                 }
 324             } else if (candidates.fixes.size() == 1 && !computer.showMenu) {
 325                 if (printError) {
 326                     in.redrawLine();
 327                     in.flush();
 328                 }
 329                 candidates.fixes.get(0).perform(in);
 330             } else {
 331                 List<Fix> fixes = new ArrayList<>(candidates.fixes);
 332                 fixes.add(0, new Fix() {
 333                     @Override
 334                     public String displayName() {
 335                         return "Do nothing";
 336                     }
 337 
 338                     @Override
 339                     public void perform(ConsoleReader in) throws IOException {
 340                         in.redrawLine();
 341                     }
 342                 });
 343 
 344                 Map<Character, Fix> char2Fix = new HashMap<>();
 345                 in.println();
 346                 for (int i = 0; i < fixes.size(); i++) {
 347                     Fix fix = fixes.get(i);
 348                     char2Fix.put((char) ('0' + i), fix);
 349                     in.println("" + i + ": " + fixes.get(i).displayName());
 350                 }
 351                 char2Fix.put((char) 3, fixes.get(0)); //Ctrl-C
 352                 in.print("Choice: ");
 353                 in.flush();
 354                 int read;
 355 
 356                 while (true) {
 357                     read = in.readCharacter();
 358 
 359                     Fix fix = char2Fix.get((char) read);
 360 
 361                     if (fix != null) {
 362                         in.println();
 363                         
 364                         fix.perform(in);
 365 
 366                         in.flush();
 367                         break;
 368                     }
 369                 }
 370             }
 371         } catch (IOException ex) {
 372             ex.printStackTrace();
 373         }
 374     }
 375 
 376     public interface Fix {
 377         public String displayName();
 378         public void perform(ConsoleReader in) throws IOException;
 379     }
 380 
 381     public abstract static class FixComputer {
 382         private final char shortcut;
 383         private final boolean showMenu;
 384 
 385         public FixComputer(char shortcut, boolean showMenu) {
 386             this.shortcut = shortcut;
 387             this.showMenu = showMenu;
 388         }
 389         
 390         public abstract FixResult compute(JShellTool repl, String code, int cursor);
 391     }
 392     
 393     public static class FixResult {
 394         public final List<Fix> fixes;
 395         public final String error;
 396 
 397         public FixResult(List<Fix> fixes, String error) {
 398             this.fixes = fixes;
 399             this.error = error;
 400         }
 401     }
 402 
 403     private static final FixComputer[] fixComputers = new FixComputer[] {
 404         new FixComputer('v', false) {
 405             @Override
 406             public FixResult compute(JShellTool repl, String code, int cursor) {
 407                 String type = repl.analysis.analyzeType(code, cursor);
 408                 if (type == null) {
 409                     return new FixResult(Collections.emptyList(), null);
 410                 }
 411                 return new FixResult(Collections.singletonList(new Fix() {
 412                     @Override
 413                     public String displayName() {
 414                         return "Create variable";
 415                     }
 416                     @Override
 417                     public void perform(ConsoleReader in) throws IOException {
 418                         in.redrawLine();
 419                         in.setCursorPosition(0);
 420                         in.putString(type + "  = ");
 421                         in.setCursorPosition(in.getCursorBuffer().cursor - 3);
 422                         in.flush();
 423                     }
 424                 }), null);
 425             }
 426         },
 427         new FixComputer('i', true) {
 428             @Override
 429             public FixResult compute(JShellTool repl, String code, int cursor) {
 430                 IndexResult res = repl.analysis.getDeclaredSymbols(code, cursor);
 431                 List<Fix> fixes = new ArrayList<>();
 432                 for (String fqn : res.getFqns()) {
 433                     fixes.add(new Fix() {
 434                         @Override
 435                         public String displayName() {
 436                             return "import: " + fqn;
 437                         }
 438                         @Override
 439                         public void perform(ConsoleReader in) throws IOException {
 440                             repl.state.eval("import " + fqn + ";");
 441                             in.println("Imported: " + fqn);
 442                             in.redrawLine();
 443                         }
 444                     });
 445                 }
 446                 if (res.isResolvable()) {
 447                     return new FixResult(Collections.emptyList(),
 448                                          "\nThe identifier is resolvable in this context.");
 449                 } else {
 450                     String error = "";
 451                     if (fixes.isEmpty()) {
 452                         error = "\nNo candidate FQNs found to import.";
 453                     }
 454                     if (!res.isUpToDate()) {
 455                         error += "\nResults may be incomplete; try again later for complete results.";
 456                     }
 457                     return new FixResult(fixes, error);
 458                 }
 459             }
 460         }
 461     };
 462 
 463     private static final class JShellUnixTerminal extends NoInterruptUnixTerminal {
 464 
 465         private final StopDetectingInputStream input;
 466 
 467         public JShellUnixTerminal(StopDetectingInputStream input) throws Exception {
 468             this.input = input;
 469         }
 470 
 471         public boolean isRaw() {
 472             try {
 473                 return getSettings().get("-a").contains("-icanon");
 474             } catch (IOException | InterruptedException ex) {
 475                 return false;
 476             }
 477         }
 478 
 479         @Override
 480         public InputStream wrapInIfNeeded(InputStream in) throws IOException {
 481             return input.setInputStream(super.wrapInIfNeeded(in));
 482         }
 483 
 484         @Override
 485         public void disableInterruptCharacter() {
 486         }
 487 
 488         @Override
 489         public void enableInterruptCharacter() {
 490         }
 491 
 492     }
 493 
 494     private static final class JShellWindowsTerminal extends WindowsTerminal {
 495 
 496         private final StopDetectingInputStream input;
 497 
 498         public JShellWindowsTerminal(StopDetectingInputStream input) throws Exception {
 499             this.input = input;
 500         }
 501 
 502         @Override
 503         public void init() throws Exception {
 504             super.init();
 505             setAnsiSupported(false);
 506         }
 507 
 508         @Override
 509         public InputStream wrapInIfNeeded(InputStream in) throws IOException {
 510             return input.setInputStream(super.wrapInIfNeeded(in));
 511         }
 512 
 513     }
 514 }