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