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 }