All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.neo4j.shell.terminal.JlineTerminal Maven / Gradle / Ivy

The newest version!
/*
 * Copyright (c) "Neo4j"
 * Neo4j Sweden AB [https://neo4j.com]
 *
 * This file is part of Neo4j.
 *
 * Neo4j is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see .
 */
package org.neo4j.shell.terminal;

import static java.util.stream.StreamSupport.stream;

import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.function.Supplier;
import org.jline.reader.Expander;
import org.jline.reader.History;
import org.jline.reader.LineReader;
import org.jline.reader.MaskingCallback;
import org.jline.reader.ParsedLine;
import org.jline.terminal.Terminal;
import org.neo4j.shell.Historian;
import org.neo4j.shell.completions.DbInfo;
import org.neo4j.shell.exception.NoMoreInputException;
import org.neo4j.shell.exception.UserInterruptException;
import org.neo4j.shell.log.Logger;
import org.neo4j.shell.parser.StatementParser.ParsedStatements;
import org.neo4j.shell.printer.AnsiFormattedText;
import org.neo4j.shell.printer.Printer;
import org.neo4j.shell.timeout.IdleTimeoutService;

/**
 * CypherShellTerminal backed by jline.
 */
public class JlineTerminal implements CypherShellTerminal {
    private static final Logger log = Logger.create();
    static final String NO_CONTINUATION_PROMPT_PATTERN = "  ";

    private final LineReader jLineReader;
    private final Printer printer;
    private final Reader reader;
    private final Writer writer;
    private final boolean isInteractive;
    private final DbInfo dbInfo;
    private final Supplier simplePromptSupplier;
    private final IdleTimeoutService exitOnIdleTimeoutService;
    private final IdleTimeoutService stopPollingWhenIdleService;
    private final IdleTimeoutHook idleTimeoutHook;

    public JlineTerminal(
            LineReader jLineReader,
            boolean isInteractive,
            Printer printer,
            Supplier simplePromptSupplier,
            Duration idleTimeout,
            Duration idleDelay,
            DbInfo dbInfo) {
        assert jLineReader.getParser() instanceof StatementJlineParser;
        this.jLineReader = jLineReader;
        this.printer = printer;
        this.isInteractive = isInteractive;
        this.simplePromptSupplier = simplePromptSupplier;
        this.reader = new JLineReader();
        this.writer = new JLineWriter();
        this.dbInfo = dbInfo;
        this.exitOnIdleTimeoutService = IdleTimeoutService.create(idleTimeout, idleDelay, () -> {
            System.err.println(
                    "Timeout after idling, avoid this by increasing --idle-timeout or omitting it completely.");
            System.exit(124);
        });
        this.stopPollingWhenIdleService = IdleTimeoutService.create(
                Duration.of(1, ChronoUnit.MINUTES),
                Duration.of(30, ChronoUnit.SECONDS),
                dbInfo::stopPolling,
                dbInfo::resumePolling);
        this.idleTimeoutHook = new IdleTimeoutHook();
    }

    private StatementJlineParser getParser() {
        return (StatementJlineParser) jLineReader.getParser();
    }

    @Override
    public Reader read() {
        jLineReader.getTerminal().resume();
        return reader;
    }

    @Override
    public SimplePrompt simplePrompt() {
        return simplePromptSupplier.get();
    }

    @Override
    public Writer write() {
        return writer;
    }

    @Override
    public boolean isInteractive() {
        return isInteractive;
    }

    @Override
    public Historian getHistory() {
        return new JlineHistorian();
    }

    @Override
    public void setHistoryBehaviour(HistoryBehaviour behaviour) throws IOException {
        if (behaviour instanceof FileHistory fileHistory) {
            setFileHistory(fileHistory.historyFile());
        } else if (behaviour instanceof DefaultHistory) {
            setFileHistory(Historian.defaultHistoryFile());
        } else if (behaviour instanceof InMemoryHistory) {
            jLineReader.setVariable(LineReader.HISTORY_FILE, null);
            loadHistory();
        }
    }

    private static void safeCreateHistoryFile(Path path) throws IOException {
        if (Files.isDirectory(path)) {
            throw new IOException("History file cannot be a directory, please delete " + path);
        }
        if (!Files.exists(path.getParent())) {
            Files.createDirectories(path.getParent());
        }
        if (!Files.exists(path)) {
            try {
                Files.createFile(path);
            } catch (FileAlreadyExistsException e) {
                // Ignore
            }
        }
        if (isPosix()) {
            Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rw-------"));
        }
    }

    private static boolean isPosix() {
        return FileSystems.getDefault().supportedFileAttributeViews().contains("posix");
    }

    private void setFileHistory(Path file) throws IOException {
        safeCreateHistoryFile(file);
        if (!file.equals(jLineReader.getVariable(LineReader.HISTORY_FILE))) {
            jLineReader.setVariable(LineReader.HISTORY_FILE, file);
            // the load here makes sure that history will work right from the start
            loadHistory();
            Runtime.getRuntime().addShutdownHook(new Thread(this::flushHistory));
        }
    }

    @Override
    public void bindUserInterruptHandler(UserInterruptHandler handler) {
        jLineReader.getTerminal().handle(Terminal.Signal.INT, signal -> handler.handleUserInterrupt());
    }

    private void flushHistory() {
        try {
            getHistory().flushHistory();
        } catch (IOException e) {
            log.error("Failed to save history", e);
            printer.printError("Failed to save history: " + e.getMessage());
        }
    }

    private void loadHistory() {
        try {
            jLineReader.getHistory().load();
        } catch (IOException e) {
            log.error("Failed to load history", e);
            printer.printError("Failed to load history: " + e.getMessage());
        }
    }

    @Override
    public void close() throws Exception {
        exitOnIdleTimeoutService.close();
        dbInfo.close();
    }

    private class JlineHistorian implements Historian {
        @Override
        public List getHistory() {
            loadHistory();
            return stream(jLineReader.getHistory().spliterator(), false)
                    .map(History.Entry::line)
                    .toList();
        }

        @Override
        public void flushHistory() throws IOException {
            jLineReader.getHistory().save();
        }

        @Override
        public void clear() throws IOException {
            jLineReader.getHistory().purge();
        }
    }

    private class JLineReader implements Reader {
        private String readLine(String prompt, boolean mask) throws NoMoreInputException, UserInterruptException {
            try {
                stopPollingWhenIdleService.resume();
                exitOnIdleTimeoutService.resume();
                final var hook = mask ? new MaskingIdleTimeoutHook() : idleTimeoutHook;
                return jLineReader.readLine(prompt, null, hook, null);
            } catch (org.jline.reader.EndOfFileException e) {
                throw new NoMoreInputException();
            } catch (org.jline.reader.UserInterruptException e) {
                throw new UserInterruptException(e.getPartialLine());
            } finally {
                stopPollingWhenIdleService.pause();
                exitOnIdleTimeoutService.pause();
            }
        }

        @Override
        public ParsedStatements readStatement(AnsiFormattedText prompt)
                throws NoMoreInputException, UserInterruptException {
            getParser().setEnableStatementParsing(true);
            jLineReader.setVariable(LineReader.SECONDARY_PROMPT_PATTERN, continuationPromptPattern(prompt));

            var line = readLine(prompt.resetAndRender(), false);
            var parsed = jLineReader.getParsedLine();

            if (parsed instanceof ParsedLineStatements statements) {
                if (!statements.line().equals(line)) {
                    throw new IllegalStateException("Unparsed lines do not match");
                }
                return statements.statements();
            } else {
                throw new IllegalStateException(
                        "Unexpected type of parsed line " + parsed.getClass().getSimpleName());
            }
        }

        private String continuationPromptPattern(AnsiFormattedText prompt) {
            if (prompt.textLength() > PROMPT_MAX_LENGTH) {
                return NO_CONTINUATION_PROMPT_PATTERN;
            } else {
                // Note, jline has built in support for this using '%P', but that causes a bug in certain environments
                // https://github.com/jline/jline3/issues/751
                return " ".repeat(prompt.textLength());
            }
        }

        @Override
        public String simplePrompt(String prompt, boolean mask) throws NoMoreInputException, UserInterruptException {
            try {
                // Temporarily disable history, completion and statement parsing for simple prompts
                jLineReader.getVariables().put(LineReader.DISABLE_HISTORY, Boolean.TRUE);
                jLineReader.getVariables().put(LineReader.DISABLE_COMPLETION, Boolean.TRUE);
                getParser().setEnableStatementParsing(false);

                return readLine(prompt, mask);
            } finally {
                jLineReader.getVariables().remove(LineReader.DISABLE_HISTORY);
                jLineReader.getVariables().remove(LineReader.DISABLE_COMPLETION);
                getParser().setEnableStatementParsing(true);
            }
        }
    }

    private class JLineWriter implements Writer {
        @Override
        public void println(String line) {
            jLineReader.printAbove(line + System.lineSeparator());
        }
    }

    public static class EmptyExpander implements Expander {
        @Override
        public String expandHistory(History history, String line) {
            return line;
        }

        @Override
        public String expandVar(String word) {
            return word;
        }
    }

    // This is a hack to let us timeout on idle inside of jline
    private class IdleTimeoutHook implements MaskingCallback {
        @Override
        public String display(String line) {
            exitOnIdleTimeoutService.imAwake();
            stopPollingWhenIdleService.imAwake();
            return line;
        }

        @Override
        public String history(String line) {
            return line;
        }
    }

    private class MaskingIdleTimeoutHook implements MaskingCallback {
        @Override
        public String display(String line) {
            exitOnIdleTimeoutService.imAwake();
            stopPollingWhenIdleService.imAwake();
            return "*".repeat(line.length());
        }

        @Override
        public String history(String line) {
            return null;
        }
    }

    interface ParsedLineStatements extends ParsedLine {
        ParsedStatements statements();
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy