com.planet57.gshell.internal.ShellImpl.java Source code

Java tutorial

Introduction

Here is the source code for com.planet57.gshell.internal.ShellImpl.java

Source

/*
 * Copyright (c) 2009-present the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.planet57.gshell.internal;

import java.io.File;
import java.io.IOException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;

import javax.annotation.Nullable;
import javax.inject.Inject;
import javax.inject.Named;

import com.google.common.base.Strings;
import com.google.common.base.Throwables;
import com.planet57.gshell.branding.Branding;
import com.planet57.gshell.branding.BrandingSupport;
import com.planet57.gshell.command.CommandAction.ExitNotification;
import com.planet57.gshell.command.CommandRegistry;
import com.planet57.gshell.functions.FunctionRegistry;
import com.planet57.gshell.shell.Shell;
import com.planet57.gshell.shell.ShellErrorHandler;
import com.planet57.gshell.shell.ShellScriptLoader;
import com.planet57.gshell.util.io.IO;
import com.planet57.gshell.event.EventManager;
import com.planet57.gshell.help.HelpPageManager;
import com.planet57.gshell.util.jline.LoggingCompleter;
import com.planet57.gshell.variables.VariableNames;
import com.planet57.gshell.variables.Variables;
import org.apache.felix.gogo.jline.Expander;
import org.apache.felix.gogo.jline.Highlighter;
import org.apache.felix.gogo.jline.ParsedLineImpl;
import org.apache.felix.gogo.jline.Parser;
import org.apache.felix.gogo.runtime.Closure;
import org.apache.felix.gogo.runtime.CommandSessionImpl;
import org.apache.felix.service.command.CommandSession;
import org.apache.felix.service.command.Job;
import org.jline.reader.Completer;
import org.jline.reader.EndOfFileException;
import org.jline.reader.History;
import org.jline.reader.LineReader;
import org.jline.reader.LineReaderBuilder;
import org.jline.reader.UserInterruptException;
import org.jline.reader.impl.history.DefaultHistory;
import org.jline.style.Styler;
import org.jline.terminal.Terminal;
import org.jline.terminal.Terminal.Signal;
import org.jline.terminal.Terminal.SignalHandler;
import org.sonatype.goodies.common.ComponentSupport;
import org.sonatype.goodies.lifecycle.LifecycleManager;

import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static com.planet57.gshell.variables.VariableNames.SHELL_PROMPT;
import static com.planet57.gshell.variables.VariableNames.SHELL_RPROMPT;

/**
 * Default {@link Shell} component.
 *
 * @since 2.0
 */
@Named
public class ShellImpl extends ComponentSupport implements Shell {
    private final LifecycleManager lifecycles = new LifecycleManager();

    private final CommandProcessorImpl commandProcessor;

    private final Completer completer;

    private final ShellErrorHandler errorHandler;

    private final History history;

    private final ShellScriptLoader scriptLoader;

    private IO io;

    private Variables variables;

    private Branding branding;

    private CommandSessionImpl currentSession;

    private LineReader lineReader;

    @Inject
    public ShellImpl(final EventManager events, final CommandRegistry commandRegistry,
            final FunctionRegistry functionRegistry, final HelpPageManager helpPageManager,
            final CommandProcessorImpl commandProcessor, @Named("shell") final Completer completer) {
        checkNotNull(events);
        checkNotNull(commandRegistry);
        checkNotNull(functionRegistry);
        checkNotNull(helpPageManager);

        this.commandProcessor = checkNotNull(commandProcessor);
        this.completer = checkNotNull(completer);

        this.errorHandler = new ShellErrorHandler();
        this.history = new DefaultHistory();
        this.scriptLoader = new ShellScriptLoader();

        lifecycles.add(events, commandRegistry, functionRegistry, helpPageManager);
    }

    /**
     * Initialize runtime state; must be called before {@link #start()}.
     */
    public void init(final IO io, final Variables variables, final Branding branding) {
        this.io = checkNotNull(io);
        this.variables = checkNotNull(variables);
        this.branding = checkNotNull(branding);

        // HACK: more variables shenanigans
        VariablesProvider.set(variables);
    }

    // custom/simplified lifecycle so we can fire do-start and do-started
    private final AtomicBoolean started = new AtomicBoolean(false);

    @Override
    public void start() throws Exception {
        synchronized (started) {
            checkState(!started.get(), "Already started");
            log.debug("Starting");
            doStart();
            started.set(true);
            doStarted();
            log.debug("Started");
        }
    }

    @Override
    public void stop() throws Exception {
        synchronized (started) {
            ensureStarted();
            log.debug("Stopping");
            doStop();
            started.set(false);
            log.debug("Stopped");
        }
    }

    private void ensureStarted() {
        synchronized (started) {
            checkState(started.get(), "Not started");
        }
    }

    private void doStart() throws Exception {
        checkState(io != null);
        checkState(variables != null);
        checkState(branding != null);

        lifecycles.start();

        // apply any branding customization
        branding.customize(this);
    }

    private void doStarted() throws Exception {
        CommandSessionImpl session = commandProcessor.createSession(io.streams.in, io.streams.out, io.streams.err);
        session.put(CommandActionFunction.SHELL_VAR, this);
        session.put(CommandActionFunction.TERMINAL_VAR, io.terminal);

        // FIXME: copy variables to session; can't presently provide the underlying map; this breaks dynamic variable setting
        session.getVariables().putAll(variables.asMap());

        currentSession = session;

        scriptLoader.loadProfileScripts(this);
    }

    private void doStop() throws Exception {
        if (currentSession != null) {
            currentSession.close();
            currentSession = null;
        }

        lineReader = null;

        lifecycles.stop();
    }

    @Override
    public Branding getBranding() {
        return branding;
    }

    @Override
    public Terminal getTerminal() {
        return io.terminal;
    }

    @Override
    public Variables getVariables() {
        return variables;
    }

    @Override
    public History getHistory() {
        return history;
    }

    @Override
    public Object execute(final CharSequence line) throws Exception {
        ensureStarted();
        checkNotNull(line);

        CommandSessionImpl session = currentSession;

        Object result;
        try {
            result = session.execute(line);
            setLastResult(session, result);
        } catch (Throwable failure) {
            Throwable cause = failure;

            // gogo encodes Error as ExecutionException; decode here
            if (cause instanceof ExecutionException) {
                cause = cause.getCause();
            }

            log.trace("Failure", cause);
            setLastResult(session, cause);
            Throwables.propagateIfPossible(cause, Exception.class, Error.class);
            throw failure;
        } finally {
            // HACK: copy session variables back to shell's variables
            variables.asMap().clear();
            variables.asMap().putAll(session.getVariables());
            VariablesProvider.set(variables);
        }

        return result;
    }

    @Override
    public void run() throws Exception {
        ensureStarted();

        log.debug("Starting interactive console");

        final CommandSessionImpl session = currentSession;

        scriptLoader.loadInteractiveScripts(this);

        final Terminal terminal = io.terminal;

        File historyFile = new File(branding.getUserContextDir(), branding.getHistoryFileName());

        lineReader = LineReaderBuilder.builder().appName(branding.getProgramName()).terminal(terminal)
                .parser(new Parser()) // install gogo-jline program accessible parser impl
                .expander(new Expander(session)).completer(new LoggingCompleter(completer))
                .highlighter(new Highlighter(session)).history(history).variables(session.getVariables())
                .variable(LineReader.HISTORY_FILE, historyFile).build();

        // automatically freshen line; this handles redrawing the line on CTRL-C
        lineReader.setOpt(LineReader.Option.AUTO_FRESH_LINE);

        renderMessage(io, branding.getWelcomeMessage());

        // handle CTRL-C
        final SignalHandler previousInterruptHandler = terminal.handle(Signal.INT, s -> {
            Job current = session.foregroundJob();
            if (current != null) {
                log.debug("Interrupting task: {}", current);
                current.interrupt();
            }
        });

        // handle CTRL-Z
        final SignalHandler previousSuspendHandler = terminal.handle(Signal.TSTP, s -> {
            Job current = session.foregroundJob();
            if (current != null) {
                log.debug("Suspending task: {}", current);
                current.suspend();
            }
        });

        log.trace("Running");
        boolean running = true;
        try {
            while (running) {
                try {
                    String line = lineReader.readLine(prompt(session), rprompt(session), null, null);
                    if (log.isTraceEnabled()) {
                        traceLine(line);
                    }

                    ParsedLineImpl parsedLine = (ParsedLineImpl) lineReader.getParsedLine();
                    if (parsedLine == null) {
                        throw new EndOfFileException();
                    }

                    execute(parsedLine.program());
                } catch (UserInterruptException e) {
                    log.trace("User interrupted", e);
                    continue;
                } catch (EndOfFileException | ExitNotification e) {
                    log.trace("Exit requested", e);
                    running = false;
                } catch (Throwable failure) {
                    boolean verbose = variables.require(VariableNames.SHELL_ERRORS, Boolean.class, true);
                    running = errorHandler.handleError(io.err, failure, verbose);
                }

                waitForJobCompletion(session);
            }

            log.trace("Stopping");
            try {
                history.save();
            } catch (IOException e) {
                log.warn("Failed to save history", e);
            }
        } finally {
            terminal.handle(Signal.INT, previousInterruptHandler);
            terminal.handle(Signal.TSTP, previousSuspendHandler);
        }
        log.trace("Stopped");

        renderMessage(io, branding.getGoodbyeMessage());
    }

    /**
     * Wait for current job, if any, to complete.
     */
    @SuppressWarnings("SynchronizationOnLocalVariableOrMethodParameter")
    private void waitForJobCompletion(final CommandSessionImpl session) throws InterruptedException {
        while (true) {
            Job job = session.foregroundJob();
            if (job == null)
                break;

            log.debug("Waiting for job completion: {}", job);
            synchronized (job) {
                if (job.status() == Job.Status.Foreground) {
                    job.wait();
                }
            }
        }
    }

    private void traceLine(final String line) {
        if (line.length() == 0 || !log.isTraceEnabled()) {
            return;
        }

        StringBuilder hex = new StringBuilder();
        StringBuilder idx = new StringBuilder();

        line.chars().forEach(ch -> {
            hex.append('x').append(Integer.toHexString(ch)).append(' ');
            idx.append(' ').append((char) ch).append("  ");
        });

        log.trace("Read line: {}\n{}\n{}", line, hex, idx);
    }

    private static void setLastResult(final CommandSession session, final Object result) {
        session.put(VariableNames.LAST_RESULT, result);
    }

    private static void renderMessage(final IO io, @Nullable String message) {
        if (message != null) {
            // HACK: branding does not have easy access to Terminal; so allow a line to be rendered via replacement token
            if (message.contains(BrandingSupport.LINE_TOKEN)) {
                message = message.replace(BrandingSupport.LINE_TOKEN,
                        Strings.repeat("-", io.terminal.getWidth() - 1));
            }
            io.out.println(message);
            io.out.flush();
        }
    }

    //
    // Prompts
    //

    @Nullable
    private String expand(final CommandSessionImpl session, @Nullable final Object value) {
        if (value != null) {
            try {
                Object result = org.apache.felix.gogo.runtime.Expander.expand(value.toString(),
                        new Closure(session, null, null));
                if (result != null) {
                    return result.toString();
                }
            } catch (Exception e) {
                log.warn("Failed to expand: {}", value, e);
            }
        }
        return null;
    }

    private String prompt(final CommandSessionImpl session) {
        Object value = session.get(SHELL_PROMPT);
        if (value == null) {
            value = branding.getPrompt();
        }

        String prompt = expand(session, value);

        // post-expand render prompt for style
        if (prompt != null) {
            prompt = Styler.factory("shell").evaluate(prompt).toAnsi(io.terminal);
        }

        // fail-safe prompt
        if (prompt == null) {
            prompt = String.format("%s> ", branding.getProgramName());
        }

        return prompt;
    }

    @Nullable
    private String rprompt(final CommandSessionImpl session) {
        Object value = session.get(SHELL_RPROMPT);
        if (value == null) {
            value = branding.getRightPrompt();
        }

        if (value != null) {
            String prompt = expand(session, value);

            // post-expand render prompt for style
            if (prompt != null) {
                prompt = Styler.factory("shell").evaluate(prompt).toAnsi(io.terminal);
            }

            return prompt;
        }

        return null;
    }
}