1 /*
   2  * Copyright (c) 2015, 2016, 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.QualifiedNames;
  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.ArrayList;
  38 import java.util.Collection;
  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 import java.util.prefs.BackingStoreException;
  48 import java.util.stream.Collectors;
  49 import java.util.stream.Stream;
  50 
  51 import jdk.internal.jline.NoInterruptUnixTerminal;
  52 import jdk.internal.jline.Terminal;
  53 import jdk.internal.jline.TerminalFactory;
  54 import jdk.internal.jline.TerminalSupport;
  55 import jdk.internal.jline.WindowsTerminal;
  56 import jdk.internal.jline.console.ConsoleReader;
  57 import jdk.internal.jline.console.KeyMap;
  58 import jdk.internal.jline.console.UserInterruptException;
  59 import jdk.internal.jline.console.completer.Completer;
  60 import jdk.internal.jline.console.history.History;
  61 import jdk.internal.jline.console.history.MemoryHistory;
  62 import jdk.internal.jline.extra.EditingHistory;
  63 import jdk.internal.jshell.tool.StopDetectingInputStream.State;
  64 
  65 class ConsoleIOContext extends IOContext {
  66 
  67     private static final String HISTORY_LINE_PREFIX = "HISTORY_LINE_";
  68 
  69     final JShellTool repl;
  70     final StopDetectingInputStream input;
  71     final ConsoleReader in;
  72     final EditingHistory history;
  73     final MemoryHistory userInputHistory = new MemoryHistory();
  74 
  75     String prefix = "";
  76 
  77     ConsoleIOContext(JShellTool repl, InputStream cmdin, PrintStream cmdout) throws Exception {
  78         this.repl = repl;
  79         this.input = new StopDetectingInputStream(() -> repl.state.stop(), ex -> repl.hard("Error on input: %s", ex));
  80         Terminal term;
  81         if (System.getProperty("test.jdk") != null) {
  82             term = new TestTerminal(input);
  83         } else if (System.getProperty("os.name").toLowerCase(Locale.US).contains(TerminalFactory.WINDOWS)) {
  84             term = new JShellWindowsTerminal(input);
  85         } else {
  86             term = new JShellUnixTerminal(input);
  87         }
  88         term.init();
  89         in = new ConsoleReader(cmdin, cmdout, term);
  90         in.setExpandEvents(false);
  91         in.setHandleUserInterrupt(true);
  92         List<String> persistenHistory = Stream.of(repl.prefs.keys())
  93                                               .filter(key -> key.startsWith(HISTORY_LINE_PREFIX))
  94                                               .sorted()
  95                                               .map(key -> repl.prefs.get(key, null))
  96                                               .collect(Collectors.toList());
  97         in.setHistory(history = new EditingHistory(in, persistenHistory) {
  98             @Override protected boolean isComplete(CharSequence input) {
  99                 return repl.analysis.analyzeCompletion(input.toString()).completeness().isComplete();
 100             }
 101         });
 102         in.setBellEnabled(true);
 103         in.setCopyPasteDetection(true);
 104         in.addCompleter(new Completer() {
 105             private String lastTest;
 106             private int lastCursor;
 107             private boolean allowSmart = false;
 108             @Override public int complete(String test, int cursor, List<CharSequence> result) {
 109                 int[] anchor = new int[] {-1};
 110                 List<Suggestion> suggestions;
 111                 if (prefix.isEmpty() && test.trim().startsWith("/")) {
 112                     suggestions = repl.commandCompletionSuggestions(test, cursor, anchor);
 113                 } else {
 114                     int prefixLength = prefix.length();
 115                     suggestions = repl.analysis.completionSuggestions(prefix + test, cursor + prefixLength, anchor);
 116                     anchor[0] -= prefixLength;
 117                 }
 118                 if (!Objects.equals(lastTest, test) || lastCursor != cursor)
 119                     allowSmart = true;
 120 
 121                 boolean smart = allowSmart &&
 122                                 suggestions.stream()
 123                                            .anyMatch(s -> s.matchesType());
 124 
 125                 lastTest = test;
 126                 lastCursor = cursor;
 127                 allowSmart = !allowSmart;
 128 
 129                 suggestions.stream()
 130                            .filter(s -> !smart || s.matchesType())
 131                            .map(s -> s.continuation())
 132                            .forEach(result::add);
 133 
 134                 boolean onlySmart = suggestions.stream()
 135                                                .allMatch(s -> s.matchesType());
 136 
 137                 if (smart && !onlySmart) {
 138                     Optional<String> prefix =
 139                             suggestions.stream()
 140                                        .map(s -> s.continuation())
 141                                        .reduce(ConsoleIOContext::commonPrefix);
 142 
 143                     String prefixStr = prefix.orElse("").substring(cursor - anchor[0]);
 144                     try {
 145                         in.putString(prefixStr);
 146                         cursor += prefixStr.length();
 147                     } catch (IOException ex) {
 148                         throw new IllegalStateException(ex);
 149                     }
 150                     result.add(repl.messageFormat("jshell.console.see.more"));
 151                     return cursor; //anchor should not be used.
 152                 }
 153 
 154                 if (result.isEmpty()) {
 155                     try {
 156                         //provide "empty completion" feedback
 157                         //XXX: this only works correctly when there is only one Completer:
 158                         in.beep();
 159                     } catch (IOException ex) {
 160                         throw new UncheckedIOException(ex);
 161                     }
 162                 }
 163 
 164                 return anchor[0];
 165             }
 166         });
 167         bind(DOCUMENTATION_SHORTCUT, (ActionListener) evt -> documentation(repl));
 168         for (FixComputer computer : FIX_COMPUTERS) {
 169             for (String shortcuts : SHORTCUT_FIXES) {
 170                 bind(shortcuts + computer.shortcut, (ActionListener) evt -> fixes(computer));
 171             }
 172         }
 173     }
 174 
 175     @Override
 176     public String readLine(String prompt, String prefix) throws IOException, InputInterruptedException {
 177         this.prefix = prefix;
 178         try {
 179             return in.readLine(prompt);
 180         } catch (UserInterruptException ex) {
 181             throw (InputInterruptedException) new InputInterruptedException().initCause(ex);
 182         }
 183     }
 184 
 185     @Override
 186     public boolean interactiveOutput() {
 187         return true;
 188     }
 189 
 190     @Override
 191     public Iterable<String> currentSessionHistory() {
 192         return history.currentSessionEntries();
 193     }
 194 
 195     @Override
 196     public void close() throws IOException {
 197         //save history:
 198         try {
 199             for (String key : repl.prefs.keys()) {
 200                 if (key.startsWith(HISTORY_LINE_PREFIX))
 201                     repl.prefs.remove(key);
 202             }
 203             Collection<? extends String> savedHistory = history.save();
 204             if (!savedHistory.isEmpty()) {
 205                 int len = (int) Math.ceil(Math.log10(savedHistory.size()+1));
 206                 String format = HISTORY_LINE_PREFIX + "%0" + len + "d";
 207                 int index = 0;
 208                 for (String historyLine : savedHistory) {
 209                     repl.prefs.put(String.format(format, index++), historyLine);
 210                 }
 211             }
 212         } catch (BackingStoreException ex) {
 213             throw new IllegalStateException(ex);
 214         }
 215         in.shutdown();
 216         try {
 217             in.getTerminal().restore();
 218         } catch (Exception ex) {
 219             throw new IOException(ex);
 220         }
 221         input.shutdown();
 222     }
 223 
 224     private void bind(String shortcut, Object action) {
 225         KeyMap km = in.getKeys();
 226         for (int i = 0; i < shortcut.length(); i++) {
 227             Object value = km.getBound(Character.toString(shortcut.charAt(i)));
 228             if (value instanceof KeyMap) {
 229                 km = (KeyMap) value;
 230             } else {
 231                 km.bind(shortcut.substring(i), action);
 232             }
 233         }
 234     }
 235 
 236     private static final String DOCUMENTATION_SHORTCUT = "\033\133\132"; //Shift-TAB
 237     private static final String[] SHORTCUT_FIXES = {
 238         "\033\015", //Alt-Enter (Linux)
 239         "\033\012", //Alt-Enter (Linux)
 240         "\033\133\061\067\176", //F6/Alt-F1 (Mac)
 241         "\u001BO3P" //Alt-F1 (Linux)
 242     };
 243 
 244     private void documentation(JShellTool repl) {
 245         String buffer = in.getCursorBuffer().buffer.toString();
 246         int cursor = in.getCursorBuffer().cursor;
 247         String doc;
 248         if (prefix.isEmpty() && buffer.trim().startsWith("/")) {
 249             doc = repl.commandDocumentation(buffer, cursor);
 250         } else {
 251             doc = repl.analysis.documentation(prefix + buffer, cursor + prefix.length());
 252         }
 253 
 254         try {
 255             if (doc != null) {
 256                 in.println();
 257                 in.println(doc);
 258                 in.redrawLine();
 259                 in.flush();
 260             } else {
 261                 in.beep();
 262             }
 263         } catch (IOException ex) {
 264             throw new IllegalStateException(ex);
 265         }
 266     }
 267 
 268     private static String commonPrefix(String str1, String str2) {
 269         for (int i = 0; i < str2.length(); i++) {
 270             if (!str1.startsWith(str2.substring(0, i + 1))) {
 271                 return str2.substring(0, i);
 272             }
 273         }
 274 
 275         return str2;
 276     }
 277 
 278     @Override
 279     public boolean terminalEditorRunning() {
 280         Terminal terminal = in.getTerminal();
 281         if (terminal instanceof JShellUnixTerminal)
 282             return ((JShellUnixTerminal) terminal).isRaw();
 283         return false;
 284     }
 285 
 286     @Override
 287     public void suspend() {
 288         try {
 289             in.getTerminal().restore();
 290         } catch (Exception ex) {
 291             throw new IllegalStateException(ex);
 292         }
 293     }
 294 
 295     @Override
 296     public void resume() {
 297         try {
 298             in.getTerminal().init();
 299         } catch (Exception ex) {
 300             throw new IllegalStateException(ex);
 301         }
 302     }
 303 
 304     public void beforeUserCode() {
 305         synchronized (this) {
 306             inputBytes = null;
 307         }
 308         input.setState(State.BUFFER);
 309     }
 310 
 311     public void afterUserCode() {
 312         input.setState(State.WAIT);
 313     }
 314 
 315     @Override
 316     public void replaceLastHistoryEntry(String source) {
 317         history.fullHistoryReplace(source);
 318     }
 319 
 320     //compute possible options/Fixes based on the selected FixComputer, present them to the user,
 321     //and perform the selected one:
 322     private void fixes(FixComputer computer) {
 323         String input = prefix + in.getCursorBuffer().toString();
 324         int cursor = prefix.length() + in.getCursorBuffer().cursor;
 325         FixResult candidates = computer.compute(repl, input, cursor);
 326 
 327         try {
 328             final boolean printError = candidates.error != null && !candidates.error.isEmpty();
 329             if (printError) {
 330                 in.println(candidates.error);
 331             }
 332             if (candidates.fixes.isEmpty()) {
 333                 in.beep();
 334                 if (printError) {
 335                     in.redrawLine();
 336                     in.flush();
 337                 }
 338             } else if (candidates.fixes.size() == 1 && !computer.showMenu) {
 339                 if (printError) {
 340                     in.redrawLine();
 341                     in.flush();
 342                 }
 343                 candidates.fixes.get(0).perform(in);
 344             } else {
 345                 List<Fix> fixes = new ArrayList<>(candidates.fixes);
 346                 fixes.add(0, new Fix() {
 347                     @Override
 348                     public String displayName() {
 349                         return repl.messageFormat("jshell.console.do.nothing");
 350                     }
 351 
 352                     @Override
 353                     public void perform(ConsoleReader in) throws IOException {
 354                         in.redrawLine();
 355                     }
 356                 });
 357 
 358                 Map<Character, Fix> char2Fix = new HashMap<>();
 359                 in.println();
 360                 for (int i = 0; i < fixes.size(); i++) {
 361                     Fix fix = fixes.get(i);
 362                     char2Fix.put((char) ('0' + i), fix);
 363                     in.println("" + i + ": " + fixes.get(i).displayName());
 364                 }
 365                 in.print(repl.messageFormat("jshell.console.choice"));
 366                 in.flush();
 367                 int read;
 368 
 369                 read = in.readCharacter();
 370 
 371                 Fix fix = char2Fix.get((char) read);
 372 
 373                 if (fix == null) {
 374                     in.beep();
 375                     fix = fixes.get(0);
 376                 }
 377 
 378                 in.println();
 379 
 380                 fix.perform(in);
 381 
 382                 in.flush();
 383             }
 384         } catch (IOException ex) {
 385             ex.printStackTrace();
 386         }
 387     }
 388 
 389     private byte[] inputBytes;
 390     private int inputBytesPointer;
 391 
 392     @Override
 393     public synchronized int readUserInput() {
 394         while (inputBytes == null || inputBytes.length <= inputBytesPointer) {
 395             boolean prevHandleUserInterrupt = in.getHandleUserInterrupt();
 396             History prevHistory = in.getHistory();
 397 
 398             try {
 399                 input.setState(State.WAIT);
 400                 in.setHandleUserInterrupt(true);
 401                 in.setHistory(userInputHistory);
 402                 inputBytes = (in.readLine("") + System.getProperty("line.separator")).getBytes();
 403                 inputBytesPointer = 0;
 404             } catch (IOException ex) {
 405                 ex.printStackTrace();
 406                 return -1;
 407             } catch (UserInterruptException ex) {
 408                 repl.state.stop();
 409                 return -1;
 410             } finally {
 411                 in.setHistory(prevHistory);
 412                 in.setHandleUserInterrupt(prevHandleUserInterrupt);
 413                 input.setState(State.BUFFER);
 414             }
 415         }
 416         return inputBytes[inputBytesPointer++];
 417     }
 418 
 419     /**
 420      * A possible action which the user can choose to perform.
 421      */
 422     public interface Fix {
 423         /**
 424          * A name that should be shown to the user.
 425          */
 426         public String displayName();
 427         /**
 428          * Perform the given action.
 429          */
 430         public void perform(ConsoleReader in) throws IOException;
 431     }
 432 
 433     /**
 434      * A factory for {@link Fix}es.
 435      */
 436     public abstract static class FixComputer {
 437         private final char shortcut;
 438         private final boolean showMenu;
 439 
 440         /**
 441          * Construct a new FixComputer. {@code shortcut} defines the key which should trigger this FixComputer.
 442          * If {@code showMenu} is {@code false}, and this computer returns exactly one {@code Fix},
 443          * no options will be show to the user, and the given {@code Fix} will be performed.
 444          */
 445         public FixComputer(char shortcut, boolean showMenu) {
 446             this.shortcut = shortcut;
 447             this.showMenu = showMenu;
 448         }
 449 
 450         /**
 451          * Compute possible actions for the given code.
 452          */
 453         public abstract FixResult compute(JShellTool repl, String code, int cursor);
 454     }
 455 
 456     /**
 457      * A list of {@code Fix}es with a possible error that should be shown to the user.
 458      */
 459     public static class FixResult {
 460         public final List<Fix> fixes;
 461         public final String error;
 462 
 463         public FixResult(List<Fix> fixes, String error) {
 464             this.fixes = fixes;
 465             this.error = error;
 466         }
 467     }
 468 
 469     private static final FixComputer[] FIX_COMPUTERS = new FixComputer[] {
 470         new FixComputer('v', false) { //compute "Introduce variable" Fix:
 471             @Override
 472             public FixResult compute(JShellTool repl, String code, int cursor) {
 473                 String type = repl.analysis.analyzeType(code, cursor);
 474                 if (type == null) {
 475                     return new FixResult(Collections.emptyList(), null);
 476                 }
 477                 return new FixResult(Collections.singletonList(new Fix() {
 478                     @Override
 479                     public String displayName() {
 480                         return repl.messageFormat("jshell.console.create.variable");
 481                     }
 482                     @Override
 483                     public void perform(ConsoleReader in) throws IOException {
 484                         in.redrawLine();
 485                         in.setCursorPosition(0);
 486                         in.putString(type + "  = ");
 487                         in.setCursorPosition(in.getCursorBuffer().cursor - 3);
 488                         in.flush();
 489                     }
 490                 }), null);
 491             }
 492         },
 493         new FixComputer('i', true) { //compute "Add import" Fixes:
 494             @Override
 495             public FixResult compute(JShellTool repl, String code, int cursor) {
 496                 QualifiedNames res = repl.analysis.listQualifiedNames(code, cursor);
 497                 List<Fix> fixes = new ArrayList<>();
 498                 for (String fqn : res.getNames()) {
 499                     fixes.add(new Fix() {
 500                         @Override
 501                         public String displayName() {
 502                             return "import: " + fqn;
 503                         }
 504                         @Override
 505                         public void perform(ConsoleReader in) throws IOException {
 506                             repl.state.eval("import " + fqn + ";");
 507                             in.println("Imported: " + fqn);
 508                             in.redrawLine();
 509                         }
 510                     });
 511                 }
 512                 if (res.isResolvable()) {
 513                     return new FixResult(Collections.emptyList(),
 514                             repl.messageFormat("jshell.console.resolvable"));
 515                 } else {
 516                     String error = "";
 517                     if (fixes.isEmpty()) {
 518                         error = repl.messageFormat("jshell.console.no.candidate");
 519                     }
 520                     if (!res.isUpToDate()) {
 521                         error += repl.messageFormat("jshell.console.incomplete");
 522                     }
 523                     return new FixResult(fixes, error);
 524                 }
 525             }
 526         }
 527     };
 528 
 529     private static final class JShellUnixTerminal extends NoInterruptUnixTerminal {
 530 
 531         private final StopDetectingInputStream input;
 532 
 533         public JShellUnixTerminal(StopDetectingInputStream input) throws Exception {
 534             this.input = input;
 535         }
 536 
 537         public boolean isRaw() {
 538             try {
 539                 return getSettings().get("-a").contains("-icanon");
 540             } catch (IOException | InterruptedException ex) {
 541                 return false;
 542             }
 543         }
 544 
 545         @Override
 546         public InputStream wrapInIfNeeded(InputStream in) throws IOException {
 547             return input.setInputStream(super.wrapInIfNeeded(in));
 548         }
 549 
 550         @Override
 551         public void disableInterruptCharacter() {
 552         }
 553 
 554         @Override
 555         public void enableInterruptCharacter() {
 556         }
 557 
 558     }
 559 
 560     private static final class JShellWindowsTerminal extends WindowsTerminal {
 561 
 562         private final StopDetectingInputStream input;
 563 
 564         public JShellWindowsTerminal(StopDetectingInputStream input) throws Exception {
 565             this.input = input;
 566         }
 567 
 568         @Override
 569         public void init() throws Exception {
 570             super.init();
 571             setAnsiSupported(false);
 572         }
 573 
 574         @Override
 575         public InputStream wrapInIfNeeded(InputStream in) throws IOException {
 576             return input.setInputStream(super.wrapInIfNeeded(in));
 577         }
 578 
 579     }
 580 
 581     private static final class TestTerminal extends TerminalSupport {
 582 
 583         private final StopDetectingInputStream input;
 584 
 585         public TestTerminal(StopDetectingInputStream input) throws Exception {
 586             super(true);
 587             setAnsiSupported(false);
 588             setEchoEnabled(true);
 589             this.input = input;
 590         }
 591 
 592         @Override
 593         public InputStream wrapInIfNeeded(InputStream in) throws IOException {
 594             return input.setInputStream(super.wrapInIfNeeded(in));
 595         }
 596 
 597     }
 598 }