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 }