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