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