ConsoleIOContext.java revision 3827:44bdefe64114
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 26package jdk.internal.jshell.tool; 27 28import jdk.jshell.SourceCodeAnalysis.Documentation; 29import jdk.jshell.SourceCodeAnalysis.QualifiedNames; 30import jdk.jshell.SourceCodeAnalysis.Suggestion; 31 32import java.io.IOException; 33import java.io.InputStream; 34import java.io.InterruptedIOException; 35import java.io.PrintStream; 36import java.io.UncheckedIOException; 37import java.util.ArrayList; 38import java.util.Arrays; 39import java.util.Collection; 40import java.util.Collections; 41import java.util.HashMap; 42import java.util.Iterator; 43import java.util.List; 44import java.util.Locale; 45import java.util.Map; 46import java.util.Objects; 47import java.util.Optional; 48import java.util.function.Function; 49import java.util.prefs.BackingStoreException; 50import java.util.stream.Collectors; 51import java.util.stream.Stream; 52 53import jdk.internal.shellsupport.doc.JavadocFormatter; 54import jdk.internal.jline.NoInterruptUnixTerminal; 55import jdk.internal.jline.Terminal; 56import jdk.internal.jline.TerminalFactory; 57import jdk.internal.jline.TerminalSupport; 58import jdk.internal.jline.WindowsTerminal; 59import jdk.internal.jline.console.ConsoleReader; 60import jdk.internal.jline.console.KeyMap; 61import jdk.internal.jline.console.UserInterruptException; 62import jdk.internal.jline.console.completer.Completer; 63import jdk.internal.jline.console.history.History; 64import jdk.internal.jline.console.history.MemoryHistory; 65import jdk.internal.jline.extra.EditingHistory; 66import jdk.internal.jshell.tool.StopDetectingInputStream.State; 67import jdk.internal.misc.Signal; 68import jdk.internal.misc.Signal.Handler; 69 70class ConsoleIOContext extends IOContext { 71 72 private static final String HISTORY_LINE_PREFIX = "HISTORY_LINE_"; 73 74 final JShellTool repl; 75 final StopDetectingInputStream input; 76 final ConsoleReader in; 77 final EditingHistory history; 78 final MemoryHistory userInputHistory = new MemoryHistory(); 79 80 String prefix = ""; 81 82 ConsoleIOContext(JShellTool repl, InputStream cmdin, PrintStream cmdout) throws Exception { 83 this.repl = repl; 84 this.input = new StopDetectingInputStream(() -> repl.state.stop(), ex -> repl.hard("Error on input: %s", ex)); 85 Terminal term; 86 if (System.getProperty("test.jdk") != null) { 87 term = new TestTerminal(input); 88 } else if (System.getProperty("os.name").toLowerCase(Locale.US).contains(TerminalFactory.WINDOWS)) { 89 term = new JShellWindowsTerminal(input); 90 } else { 91 term = new JShellUnixTerminal(input); 92 } 93 term.init(); 94 in = new ConsoleReader(cmdin, cmdout, term); 95 in.setExpandEvents(false); 96 in.setHandleUserInterrupt(true); 97 List<String> persistenHistory = Stream.of(repl.prefs.keys()) 98 .filter(key -> key.startsWith(HISTORY_LINE_PREFIX)) 99 .sorted() 100 .map(key -> repl.prefs.get(key, null)) 101 .collect(Collectors.toList()); 102 in.setHistory(history = new EditingHistory(in, persistenHistory) { 103 @Override protected boolean isComplete(CharSequence input) { 104 return repl.analysis.analyzeCompletion(input.toString()).completeness().isComplete(); 105 } 106 }); 107 in.setBellEnabled(true); 108 in.setCopyPasteDetection(true); 109 in.addCompleter(new Completer() { 110 private String lastTest; 111 private int lastCursor; 112 private boolean allowSmart = false; 113 @Override public int complete(String test, int cursor, List<CharSequence> result) { 114 int[] anchor = new int[] {-1}; 115 List<Suggestion> suggestions; 116 if (prefix.isEmpty() && test.trim().startsWith("/")) { 117 suggestions = repl.commandCompletionSuggestions(test, cursor, anchor); 118 } else { 119 int prefixLength = prefix.length(); 120 suggestions = repl.analysis.completionSuggestions(prefix + test, cursor + prefixLength, anchor); 121 anchor[0] -= prefixLength; 122 } 123 if (!Objects.equals(lastTest, test) || lastCursor != cursor) 124 allowSmart = true; 125 126 boolean smart = allowSmart && 127 suggestions.stream() 128 .anyMatch(Suggestion::matchesType); 129 130 lastTest = test; 131 lastCursor = cursor; 132 allowSmart = !allowSmart; 133 134 suggestions.stream() 135 .filter(s -> !smart || s.matchesType()) 136 .map(Suggestion::continuation) 137 .forEach(result::add); 138 139 boolean onlySmart = suggestions.stream() 140 .allMatch(Suggestion::matchesType); 141 142 if (smart && !onlySmart) { 143 Optional<String> prefix = 144 suggestions.stream() 145 .map(Suggestion::continuation) 146 .reduce(ConsoleIOContext::commonPrefix); 147 148 String prefixStr = prefix.orElse("").substring(cursor - anchor[0]); 149 try { 150 in.putString(prefixStr); 151 cursor += prefixStr.length(); 152 } catch (IOException ex) { 153 throw new IllegalStateException(ex); 154 } 155 result.add(repl.messageFormat("jshell.console.see.more")); 156 return cursor; //anchor should not be used. 157 } 158 159 if (result.isEmpty()) { 160 try { 161 //provide "empty completion" feedback 162 //XXX: this only works correctly when there is only one Completer: 163 in.beep(); 164 } catch (IOException ex) { 165 throw new UncheckedIOException(ex); 166 } 167 } 168 169 return anchor[0]; 170 } 171 }); 172 bind(DOCUMENTATION_SHORTCUT, (Runnable) () -> documentation(repl)); 173 for (FixComputer computer : FIX_COMPUTERS) { 174 for (String shortcuts : SHORTCUT_FIXES) { 175 bind(shortcuts + computer.shortcut, (Runnable) () -> fixes(computer)); 176 } 177 } 178 try { 179 Signal.handle(new Signal("CONT"), new Handler() { 180 @Override public void handle(Signal sig) { 181 try { 182 in.getTerminal().reset(); 183 in.redrawLine(); 184 in.flush(); 185 } catch (Exception ex) { 186 ex.printStackTrace(); 187 } 188 } 189 }); 190 } catch (IllegalArgumentException ignored) { 191 //the CONT signal does not exist on this platform 192 } 193 } 194 195 @Override 196 public String readLine(String prompt, String prefix) throws IOException, InputInterruptedException { 197 this.prefix = prefix; 198 try { 199 return in.readLine(prompt); 200 } catch (UserInterruptException ex) { 201 throw (InputInterruptedException) new InputInterruptedException().initCause(ex); 202 } 203 } 204 205 @Override 206 public boolean interactiveOutput() { 207 return true; 208 } 209 210 @Override 211 public Iterable<String> currentSessionHistory() { 212 return history.currentSessionEntries(); 213 } 214 215 @Override 216 public void close() throws IOException { 217 //save history: 218 try { 219 for (String key : repl.prefs.keys()) { 220 if (key.startsWith(HISTORY_LINE_PREFIX)) 221 repl.prefs.remove(key); 222 } 223 Collection<? extends String> savedHistory = history.save(); 224 if (!savedHistory.isEmpty()) { 225 int len = (int) Math.ceil(Math.log10(savedHistory.size()+1)); 226 String format = HISTORY_LINE_PREFIX + "%0" + len + "d"; 227 int index = 0; 228 for (String historyLine : savedHistory) { 229 repl.prefs.put(String.format(format, index++), historyLine); 230 } 231 } 232 } catch (BackingStoreException ex) { 233 throw new IllegalStateException(ex); 234 } 235 in.shutdown(); 236 try { 237 in.getTerminal().restore(); 238 } catch (Exception ex) { 239 throw new IOException(ex); 240 } 241 input.shutdown(); 242 } 243 244 private void bind(String shortcut, Object action) { 245 KeyMap km = in.getKeys(); 246 for (int i = 0; i < shortcut.length(); i++) { 247 Object value = km.getBound(Character.toString(shortcut.charAt(i))); 248 if (value instanceof KeyMap) { 249 km = (KeyMap) value; 250 } else { 251 km.bind(shortcut.substring(i), action); 252 } 253 } 254 } 255 256 private static final String DOCUMENTATION_SHORTCUT = "\033\133\132"; //Shift-TAB 257 private static final String[] SHORTCUT_FIXES = { 258 "\033\015", //Alt-Enter (Linux) 259 "\033\012", //Alt-Enter (Linux) 260 "\033\133\061\067\176", //F6/Alt-F1 (Mac) 261 "\u001BO3P" //Alt-F1 (Linux) 262 }; 263 264 private String lastDocumentationBuffer; 265 private int lastDocumentationCursor = (-1); 266 267 private void documentation(JShellTool repl) { 268 String buffer = in.getCursorBuffer().buffer.toString(); 269 int cursor = in.getCursorBuffer().cursor; 270 boolean firstInvocation = !buffer.equals(lastDocumentationBuffer) || cursor != lastDocumentationCursor; 271 lastDocumentationBuffer = buffer; 272 lastDocumentationCursor = cursor; 273 List<String> doc; 274 String seeMore; 275 Terminal term = in.getTerminal(); 276 if (prefix.isEmpty() && buffer.trim().startsWith("/")) { 277 doc = Arrays.asList(repl.commandDocumentation(buffer, cursor, firstInvocation)); 278 seeMore = "jshell.console.see.help"; 279 } else { 280 JavadocFormatter formatter = new JavadocFormatter(term.getWidth(), 281 term.isAnsiSupported()); 282 Function<Documentation, String> convertor; 283 if (firstInvocation) { 284 convertor = Documentation::signature; 285 } else { 286 convertor = d -> formatter.formatJavadoc(d.signature(), d.javadoc()) + 287 (d.javadoc() == null ? repl.messageFormat("jshell.console.no.javadoc") 288 : ""); 289 } 290 doc = repl.analysis.documentation(prefix + buffer, cursor + prefix.length(), !firstInvocation) 291 .stream() 292 .map(convertor) 293 .collect(Collectors.toList()); 294 seeMore = "jshell.console.see.javadoc"; 295 } 296 297 try { 298 if (doc != null && !doc.isEmpty()) { 299 if (firstInvocation) { 300 in.println(); 301 in.println(doc.stream().collect(Collectors.joining("\n"))); 302 in.println(repl.messageFormat(seeMore)); 303 in.redrawLine(); 304 in.flush(); 305 } else { 306 in.println(); 307 308 int height = term.getHeight(); 309 String lastNote = ""; 310 311 PRINT_DOC: for (Iterator<String> docIt = doc.iterator(); docIt.hasNext(); ) { 312 String currentDoc = docIt.next(); 313 String[] lines = currentDoc.split("\n"); 314 int firstLine = 0; 315 316 PRINT_PAGE: while (true) { 317 int toPrint = height - 1; 318 319 while (toPrint > 0 && firstLine < lines.length) { 320 in.println(lines[firstLine++]); 321 toPrint--; 322 } 323 324 if (firstLine >= lines.length) { 325 break; 326 } 327 328 lastNote = repl.getResourceString("jshell.console.see.next.page"); 329 in.print(lastNote + ConsoleReader.RESET_LINE); 330 in.flush(); 331 332 while (true) { 333 int r = in.readCharacter(); 334 335 switch (r) { 336 case ' ': continue PRINT_PAGE; 337 case 'q': 338 case 3: 339 break PRINT_DOC; 340 default: 341 in.beep(); 342 break; 343 } 344 } 345 } 346 347 if (docIt.hasNext()) { 348 lastNote = repl.getResourceString("jshell.console.see.next.javadoc"); 349 in.print(lastNote + ConsoleReader.RESET_LINE); 350 in.flush(); 351 352 while (true) { 353 int r = in.readCharacter(); 354 355 switch (r) { 356 case ' ': continue PRINT_DOC; 357 case 'q': 358 case 3: 359 break PRINT_DOC; 360 default: 361 in.beep(); 362 break; 363 } 364 } 365 } 366 } 367 //clear the "press space" line: 368 in.getCursorBuffer().buffer.replace(0, buffer.length(), lastNote); 369 in.getCursorBuffer().cursor = 0; 370 in.killLine(); 371 in.getCursorBuffer().buffer.append(buffer); 372 in.getCursorBuffer().cursor = cursor; 373 in.redrawLine(); 374 in.flush(); 375 } 376 } else { 377 in.beep(); 378 } 379 } catch (IOException ex) { 380 throw new IllegalStateException(ex); 381 } 382 } 383 384 private static String commonPrefix(String str1, String str2) { 385 for (int i = 0; i < str2.length(); i++) { 386 if (!str1.startsWith(str2.substring(0, i + 1))) { 387 return str2.substring(0, i); 388 } 389 } 390 391 return str2; 392 } 393 394 @Override 395 public boolean terminalEditorRunning() { 396 Terminal terminal = in.getTerminal(); 397 if (terminal instanceof JShellUnixTerminal) 398 return ((JShellUnixTerminal) terminal).isRaw(); 399 return false; 400 } 401 402 @Override 403 public void suspend() { 404 try { 405 in.getTerminal().restore(); 406 } catch (Exception ex) { 407 throw new IllegalStateException(ex); 408 } 409 } 410 411 @Override 412 public void resume() { 413 try { 414 in.getTerminal().init(); 415 } catch (Exception ex) { 416 throw new IllegalStateException(ex); 417 } 418 } 419 420 public void beforeUserCode() { 421 synchronized (this) { 422 inputBytes = null; 423 } 424 input.setState(State.BUFFER); 425 } 426 427 public void afterUserCode() { 428 input.setState(State.WAIT); 429 } 430 431 @Override 432 public void replaceLastHistoryEntry(String source) { 433 history.fullHistoryReplace(source); 434 } 435 436 //compute possible options/Fixes based on the selected FixComputer, present them to the user, 437 //and perform the selected one: 438 private void fixes(FixComputer computer) { 439 String input = prefix + in.getCursorBuffer().toString(); 440 int cursor = prefix.length() + in.getCursorBuffer().cursor; 441 FixResult candidates = computer.compute(repl, input, cursor); 442 443 try { 444 final boolean printError = candidates.error != null && !candidates.error.isEmpty(); 445 if (printError) { 446 in.println(candidates.error); 447 } 448 if (candidates.fixes.isEmpty()) { 449 in.beep(); 450 if (printError) { 451 in.redrawLine(); 452 in.flush(); 453 } 454 } else if (candidates.fixes.size() == 1 && !computer.showMenu) { 455 if (printError) { 456 in.redrawLine(); 457 in.flush(); 458 } 459 candidates.fixes.get(0).perform(in); 460 } else { 461 List<Fix> fixes = new ArrayList<>(candidates.fixes); 462 fixes.add(0, new Fix() { 463 @Override 464 public String displayName() { 465 return repl.messageFormat("jshell.console.do.nothing"); 466 } 467 468 @Override 469 public void perform(ConsoleReader in) throws IOException { 470 in.redrawLine(); 471 } 472 }); 473 474 Map<Character, Fix> char2Fix = new HashMap<>(); 475 in.println(); 476 for (int i = 0; i < fixes.size(); i++) { 477 Fix fix = fixes.get(i); 478 char2Fix.put((char) ('0' + i), fix); 479 in.println("" + i + ": " + fixes.get(i).displayName()); 480 } 481 in.print(repl.messageFormat("jshell.console.choice")); 482 in.flush(); 483 int read; 484 485 read = in.readCharacter(); 486 487 Fix fix = char2Fix.get((char) read); 488 489 if (fix == null) { 490 in.beep(); 491 fix = fixes.get(0); 492 } 493 494 in.println(); 495 496 fix.perform(in); 497 498 in.flush(); 499 } 500 } catch (IOException ex) { 501 ex.printStackTrace(); 502 } 503 } 504 505 private byte[] inputBytes; 506 private int inputBytesPointer; 507 508 @Override 509 public synchronized int readUserInput() throws IOException { 510 while (inputBytes == null || inputBytes.length <= inputBytesPointer) { 511 boolean prevHandleUserInterrupt = in.getHandleUserInterrupt(); 512 History prevHistory = in.getHistory(); 513 514 try { 515 input.setState(State.WAIT); 516 in.setHandleUserInterrupt(true); 517 in.setHistory(userInputHistory); 518 inputBytes = (in.readLine("") + System.getProperty("line.separator")).getBytes(); 519 inputBytesPointer = 0; 520 } catch (UserInterruptException ex) { 521 throw new InterruptedIOException(); 522 } finally { 523 in.setHistory(prevHistory); 524 in.setHandleUserInterrupt(prevHandleUserInterrupt); 525 input.setState(State.BUFFER); 526 } 527 } 528 return inputBytes[inputBytesPointer++]; 529 } 530 531 /** 532 * A possible action which the user can choose to perform. 533 */ 534 public interface Fix { 535 /** 536 * A name that should be shown to the user. 537 */ 538 public String displayName(); 539 /** 540 * Perform the given action. 541 */ 542 public void perform(ConsoleReader in) throws IOException; 543 } 544 545 /** 546 * A factory for {@link Fix}es. 547 */ 548 public abstract static class FixComputer { 549 private final char shortcut; 550 private final boolean showMenu; 551 552 /** 553 * Construct a new FixComputer. {@code shortcut} defines the key which should trigger this FixComputer. 554 * If {@code showMenu} is {@code false}, and this computer returns exactly one {@code Fix}, 555 * no options will be show to the user, and the given {@code Fix} will be performed. 556 */ 557 public FixComputer(char shortcut, boolean showMenu) { 558 this.shortcut = shortcut; 559 this.showMenu = showMenu; 560 } 561 562 /** 563 * Compute possible actions for the given code. 564 */ 565 public abstract FixResult compute(JShellTool repl, String code, int cursor); 566 } 567 568 /** 569 * A list of {@code Fix}es with a possible error that should be shown to the user. 570 */ 571 public static class FixResult { 572 public final List<Fix> fixes; 573 public final String error; 574 575 public FixResult(List<Fix> fixes, String error) { 576 this.fixes = fixes; 577 this.error = error; 578 } 579 } 580 581 private static final FixComputer[] FIX_COMPUTERS = new FixComputer[] { 582 new FixComputer('v', false) { //compute "Introduce variable" Fix: 583 private void performToVar(ConsoleReader in, String type) throws IOException { 584 in.redrawLine(); 585 in.setCursorPosition(0); 586 in.putString(type + " = "); 587 in.setCursorPosition(in.getCursorBuffer().cursor - 3); 588 in.flush(); 589 } 590 591 @Override 592 public FixResult compute(JShellTool repl, String code, int cursor) { 593 String type = repl.analysis.analyzeType(code, cursor); 594 if (type == null) { 595 return new FixResult(Collections.emptyList(), null); 596 } 597 List<Fix> fixes = new ArrayList<>(); 598 fixes.add(new Fix() { 599 @Override 600 public String displayName() { 601 return repl.messageFormat("jshell.console.create.variable"); 602 } 603 604 @Override 605 public void perform(ConsoleReader in) throws IOException { 606 performToVar(in, type); 607 } 608 }); 609 int idx = type.lastIndexOf("."); 610 if (idx > 0) { 611 String stype = type.substring(idx + 1); 612 QualifiedNames res = repl.analysis.listQualifiedNames(stype, stype.length()); 613 if (res.isUpToDate() && res.getNames().contains(type) 614 && !res.isResolvable()) { 615 fixes.add(new Fix() { 616 @Override 617 public String displayName() { 618 return "import: " + type + ". " + 619 repl.messageFormat("jshell.console.create.variable"); 620 } 621 622 @Override 623 public void perform(ConsoleReader in) throws IOException { 624 repl.state.eval("import " + type + ";"); 625 in.println("Imported: " + type); 626 performToVar(in, stype); 627 } 628 }); 629 } 630 } 631 return new FixResult(fixes, null); 632 } 633 }, 634 new FixComputer('i', true) { //compute "Add import" Fixes: 635 @Override 636 public FixResult compute(JShellTool repl, String code, int cursor) { 637 QualifiedNames res = repl.analysis.listQualifiedNames(code, cursor); 638 List<Fix> fixes = new ArrayList<>(); 639 for (String fqn : res.getNames()) { 640 fixes.add(new Fix() { 641 @Override 642 public String displayName() { 643 return "import: " + fqn; 644 } 645 646 @Override 647 public void perform(ConsoleReader in) throws IOException { 648 repl.state.eval("import " + fqn + ";"); 649 in.println("Imported: " + fqn); 650 in.redrawLine(); 651 } 652 }); 653 } 654 if (res.isResolvable()) { 655 return new FixResult(Collections.emptyList(), 656 repl.messageFormat("jshell.console.resolvable")); 657 } else { 658 String error = ""; 659 if (fixes.isEmpty()) { 660 error = repl.messageFormat("jshell.console.no.candidate"); 661 } 662 if (!res.isUpToDate()) { 663 error += repl.messageFormat("jshell.console.incomplete"); 664 } 665 return new FixResult(fixes, error); 666 } 667 } 668 } 669 }; 670 671 private static final class JShellUnixTerminal extends NoInterruptUnixTerminal { 672 673 private final StopDetectingInputStream input; 674 675 public JShellUnixTerminal(StopDetectingInputStream input) throws Exception { 676 this.input = input; 677 } 678 679 public boolean isRaw() { 680 try { 681 return getSettings().get("-a").contains("-icanon"); 682 } catch (IOException | InterruptedException ex) { 683 return false; 684 } 685 } 686 687 @Override 688 public InputStream wrapInIfNeeded(InputStream in) throws IOException { 689 return input.setInputStream(super.wrapInIfNeeded(in)); 690 } 691 692 @Override 693 public void disableInterruptCharacter() { 694 } 695 696 @Override 697 public void enableInterruptCharacter() { 698 } 699 700 } 701 702 private static final class JShellWindowsTerminal extends WindowsTerminal { 703 704 private final StopDetectingInputStream input; 705 706 public JShellWindowsTerminal(StopDetectingInputStream input) throws Exception { 707 this.input = input; 708 } 709 710 @Override 711 public void init() throws Exception { 712 super.init(); 713 setAnsiSupported(false); 714 } 715 716 @Override 717 public InputStream wrapInIfNeeded(InputStream in) throws IOException { 718 return input.setInputStream(super.wrapInIfNeeded(in)); 719 } 720 721 } 722 723 private static final class TestTerminal extends TerminalSupport { 724 725 private final StopDetectingInputStream input; 726 727 public TestTerminal(StopDetectingInputStream input) throws Exception { 728 super(true); 729 setAnsiSupported(false); 730 setEchoEnabled(true); 731 this.input = input; 732 } 733 734 @Override 735 public InputStream wrapInIfNeeded(InputStream in) throws IOException { 736 return input.setInputStream(super.wrapInIfNeeded(in)); 737 } 738 739 } 740} 741