blob: b4af4729d78d5656202d4d6ca131aa6b2f1b9d6b [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 org.apache.karaf.shell.impl.console;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintStream;
import java.lang.management.ManagementFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.felix.gogo.jline.ParsedLineImpl;
import org.apache.felix.gogo.jline.Shell;
import org.apache.felix.gogo.runtime.CommandSessionImpl;
import org.apache.felix.service.command.CommandProcessor;
import org.apache.felix.service.command.CommandSession;
import org.apache.felix.service.command.Converter;
import org.apache.felix.service.command.Function;
import org.apache.felix.service.command.Job;
import org.apache.felix.service.command.Job.Status;
import org.apache.felix.service.threadio.ThreadIO;
import org.apache.karaf.shell.api.console.Command;
import org.apache.karaf.shell.api.console.History;
import org.apache.karaf.shell.api.console.Registry;
import org.apache.karaf.shell.api.console.Session;
import org.apache.karaf.shell.api.console.SessionFactory;
import org.apache.karaf.shell.api.console.Terminal;
import org.apache.karaf.shell.impl.action.command.ActionCommand;
import org.apache.karaf.shell.impl.action.command.ActionMaskingCallback;
import org.apache.karaf.shell.impl.console.parsing.CommandLineParser;
import org.apache.karaf.shell.impl.console.parsing.KarafParser;
import org.apache.karaf.shell.support.ShellUtil;
import org.apache.karaf.shell.support.completers.FileCompleter;
import org.apache.karaf.shell.support.completers.FileOrUriCompleter;
import org.apache.karaf.shell.support.completers.UriCompleter;
import org.apache.karaf.util.filesstream.FilesStream;
import org.jline.builtins.Completers;
import org.jline.reader.*;
import org.jline.terminal.Size;
import org.jline.terminal.Terminal.Signal;
import org.jline.terminal.impl.DumbTerminal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static org.apache.felix.gogo.jline.Shell.VAR_SCOPE;
public class ConsoleSessionImpl implements Session {
private static final String SUPPRESS_WELCOME = "karaf.shell.suppress.welcome";
public static final String SHELL_INIT_SCRIPT = "karaf.shell.init.script";
public static final String SHELL_HISTORY_MAXSIZE = "karaf.shell.history.maxSize";
public static final String SHELL_HISTORY_FILE_MAXSIZE = "karaf.shell.history.file.maxSize";
public static final String PROMPT = "PROMPT";
public static final String DEFAULT_PROMPT = "\u001B[1m${USER}\u001B[0m@${APPLICATION}(${SUBSHELL})> ";
public static final String RPROMPT = "RPROMPT";
public static final String DEFAULT_RPROMPT = null;
private static final Logger LOGGER = LoggerFactory.getLogger(ConsoleSessionImpl.class);
// Input stream
volatile boolean running;
private AtomicBoolean closed = new AtomicBoolean(false);
final SessionFactory factory;
final ThreadIO threadIO;
final InputStream in;
final PrintStream out;
final PrintStream err;
private Runnable closeCallback;
final CommandSession session;
final Registry registry;
final Terminal terminal;
final org.jline.terminal.Terminal jlineTerminal;
final History history;
final LineReader reader;
final AggregateMaskingCallback maskingCallback;
private Thread thread;
private Properties brandingProps;
public ConsoleSessionImpl(SessionFactory factory,
CommandProcessor processor,
ThreadIO threadIO,
InputStream in,
PrintStream out,
PrintStream err,
Terminal term,
String encoding,
Runnable closeCallback) {
// Arguments
this.factory = factory;
this.threadIO = threadIO;
this.in = in;
this.out = out;
this.err = err;
this.closeCallback = closeCallback;
// Terminal
if (term instanceof org.jline.terminal.Terminal) {
terminal = term;
jlineTerminal = (org.jline.terminal.Terminal) term;
} else if (term != null) {
terminal = term;
// jlineTerminal = new KarafTerminal(term);
// TODO:JLINE
throw new UnsupportedOperationException();
} else {
try {
jlineTerminal = new DumbTerminal(in, out);
terminal = new JLineTerminal(jlineTerminal);
} catch (IOException e) {
throw new RuntimeException("Unable to create terminal", e);
}
}
if (jlineTerminal.getSize().getColumns() == 0) {
jlineTerminal.setSize(new Size(80, 24));
}
brandingProps = Branding.loadBrandingProperties(terminal.getClass().getName().endsWith("SshTerminal"));
// Create session
if (in == null || out == null || err == null) {
session = processor.createSession(((org.jline.terminal.Terminal) terminal).input(),
((org.jline.terminal.Terminal) terminal).output(),
((org.jline.terminal.Terminal) terminal).output());
} else {
session = processor.createSession(in, out, err);
}
// Completers
Completer builtinCompleter = createBuiltinCompleter();
CommandsCompleter commandsCompleter = new CommandsCompleter(factory, this);
Completer completer = (rdr, line, candidates) -> {
builtinCompleter.complete(rdr, line, candidates);
commandsCompleter.complete(rdr, line, candidates);
merge(candidates);
};
// Masking
maskingCallback = new AggregateMaskingCallback();
// Console reader
reader = LineReaderBuilder.builder()
.terminal(jlineTerminal)
.appName("karaf")
.variables(((CommandSessionImpl) session).getVariables())
.highlighter(new org.apache.felix.gogo.jline.Highlighter(session))
.parser(new KarafParser(this))
.completer(completer)
.build();
// History
final Path file = getHistoryFile();
reader.setVariable(LineReader.HISTORY_FILE, file);
String maxSizeStr = System.getProperty(SHELL_HISTORY_MAXSIZE);
if (maxSizeStr != null) {
reader.setVariable(LineReader.HISTORY_SIZE, Integer.parseInt(maxSizeStr));
}
String maxFileSizeStr = System.getProperty(SHELL_HISTORY_FILE_MAXSIZE);
if (maxFileSizeStr != null) {
reader.setVariable(LineReader.HISTORY_FILE_SIZE, Integer.parseInt(maxFileSizeStr));
}
history = new HistoryWrapper(reader.getHistory());
// Registry
registry = new RegistryImpl(factory.getRegistry(), this);
registry.register(factory);
registry.register(this);
registry.register(registry);
registry.register(terminal);
registry.register(history);
registry.register(commandsCompleter);
registry.register(new CommandNamesCompleter());
registry.register(new FileCompleter());
registry.register(new UriCompleter());
registry.register(new FileOrUriCompleter());
// Session
Properties sysProps = System.getProperties();
for (Object key : sysProps.keySet()) {
session.put(key.toString(), sysProps.get(key));
}
session.put(".session", this);
session.put(".processor", processor);
session.put(".commandSession", session);
session.put(".jline.reader", reader);
session.put(".jline.terminal", reader.getTerminal());
session.put(".jline.history", reader.getHistory());
session.put(Session.SCOPE, "shell:bundle:*");
session.put(Session.SUBSHELL, "");
session.put(Session.COMPLETION_MODE, loadCompletionMode());
session.put("USER", ShellUtil.getCurrentUserName());
session.put("TERM", terminal.getType());
session.put("APPLICATION", System.getProperty("karaf.name", "root"));
session.put("#LINES", (Function) (session, arguments) -> Integer.toString(terminal.getHeight()));
session.put("#COLUMNS", (Function) (session, arguments) -> Integer.toString(terminal.getWidth()));
session.put("pid", getPid());
session.put(Shell.VAR_COMPLETIONS, new HashMap<>());
session.put(Shell.VAR_READER, reader);
session.put(Shell.VAR_TERMINAL, reader.getTerminal());
session.put(CommandSession.OPTION_NO_GLOB, Boolean.TRUE);
session.currentDir(Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize());
}
private void merge(List<Candidate> candidates) {
Map<String, Candidate> map = new HashMap<>();
for (Candidate c : candidates) {
map.merge(c.value(), c, (c1, c2) -> c1.descr() != null ? c1 : c2);
}
candidates.clear();
candidates.addAll(map.values());
}
private Completer createBuiltinCompleter() {
Completers.CompletionEnvironment env = new Completers.CompletionEnvironment() {
@Override
public Map<String, List<Completers.CompletionData>> getCompletions() {
return Shell.getCompletions(session);
}
@Override
public Set<String> getCommands() {
return factory.getRegistry().getCommands().stream()
.map(c -> c.getScope() + ":" + c.getName())
.collect(Collectors.toSet());
}
@Override
public String resolveCommand(String command) {
String resolved = command;
if (command.indexOf(':') < 0) {
Set<String> commands = getCommands();
Object path = session.get(VAR_SCOPE);
String scopePath = (null == path ? "*" : path.toString());
for (String scope : scopePath.split(":")) {
for (String entry : commands) {
if ("*".equals(scope) && entry.endsWith(":" + command)
|| entry.equals(scope + ":" + command)) {
resolved = entry;
break;
}
}
}
}
return resolved;
}
@Override
public String commandName(String command) {
int idx = command.indexOf(':');
return idx >= 0 ? command.substring(idx + 1) : command;
}
@Override
public Object evaluate(LineReader reader, ParsedLine line, String func) throws Exception {
session.put(Shell.VAR_COMMAND_LINE, line);
return session.execute(func);
}
};
return new org.jline.builtins.Completers.Completer(env);
}
/**
* Subclasses can override to use a different history file.
*
* @return the history file
*/
protected Path getHistoryFile() {
String defaultHistoryPath = new File(System.getProperty("user.home"), ".karaf/karaf.history").toString();
return Paths.get(System.getProperty("karaf.history", defaultHistoryPath));
}
@Override
public Terminal getTerminal() {
return terminal;
}
public History getHistory() {
return history;
}
@Override
public Registry getRegistry() {
return registry;
}
@Override
public SessionFactory getFactory() {
return factory;
}
@Override
public Path currentDir() {
return session.currentDir();
}
@Override
public void currentDir(Path path) {
session.currentDir(path);
}
public void close() {
if (closed.compareAndSet(false, true)) {
if (running) {
try {
reader.getHistory().save();
} catch (IOException e) {
// ignore
}
}
running = false;
if (thread != Thread.currentThread() && thread != null) {
thread.interrupt();
}
if (closeCallback != null) {
closeCallback.run();
}
if (terminal instanceof AutoCloseable) {
try {
((AutoCloseable) terminal).close();
} catch (Exception e) {
// Ignore
}
}
if (session != null)
session.close();
}
}
public void run() {
try {
threadIO.setStreams(session.getKeyboard(), out, err);
thread = Thread.currentThread();
running = true;
welcomeBanner();
AtomicBoolean reading = new AtomicBoolean();
session.setJobListener((job, previous, current) -> {
if (previous == Status.Background || current == Status.Background
|| previous == Status.Suspended || current == Status.Suspended) {
int width = terminal.getWidth();
String status = current.name().toLowerCase();
jlineTerminal.writer().write(getStatusLine(job, width, status));
jlineTerminal.flush();
if (reading.get()) {
reader.callWidget(LineReader.REDRAW_LINE);
reader.callWidget(LineReader.REDISPLAY);
}
}
});
jlineTerminal.handle(Signal.TSTP, s -> {
Job current = session.foregroundJob();
if (current != null) {
current.suspend();
}
});
jlineTerminal.handle(Signal.INT, s -> {
Job current = session.foregroundJob();
if (current != null) {
current.interrupt();
}
});
String scriptFileName = System.getProperty(SHELL_INIT_SCRIPT);
executeScript(scriptFileName);
while (running) {
CharSequence command = readCommand(reading);
if (command == null) {
break;
}
if (command.length() > 0) {
doExecute(command);
}
}
close();
} finally {
try {
threadIO.close();
} catch (Throwable t) {
// Ignore
}
}
}
/**
* On the local console we only show the welcome banner once. This allows to suppress the banner
* on refreshs of the shell core bundle.
* On ssh we show it every time.
*/
private void welcomeBanner() {
if (!isLocal() || System.getProperty(SUPPRESS_WELCOME) == null) {
welcome(brandingProps);
setSessionProperties(brandingProps);
if (isLocal()) {
System.setProperty(SUPPRESS_WELCOME, "true");
}
}
}
private boolean isLocal() {
Boolean isLocal = (Boolean)session.get(Session.IS_LOCAL);
return isLocal != null && isLocal;
}
private CharSequence readCommand(AtomicBoolean reading) throws UserInterruptException {
CharSequence command = null;
reading.set(true);
try {
reader.readLine(getPrompt(), getRPrompt(), maskingCallback, null);
ParsedLine pl = reader.getParsedLine();
if (pl instanceof ParsedLineImpl) {
command = ((ParsedLineImpl) pl).program();
} else if (pl != null) {
command = pl.line();
} else {
command = reader.getBuffer().toString();
}
} catch (EndOfFileException e) {
command = null;
} catch (UserInterruptException e) {
command = ""; // Do nothing
} catch (Throwable t) {
ShellUtil.logException(this, t);
} finally {
reading.set(false);
}
return command;
}
private void doExecute(CharSequence command) {
try {
Object result = session.execute(command);
if (result != null) {
session.getConsole().println(session.format(result, Converter.INSPECT));
}
} catch (InterruptedException e) {
LOGGER.debug("Console session is closed");
} catch (Throwable t) {
ShellUtil.logException(this, t);
}
}
private String getStatusLine(Job job, int width, String status) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < width - 1; i++) {
sb.append(' ');
}
sb.append('\r');
sb.append("[").append(job.id()).append("] ");
sb.append(status);
for (int i = status.length(); i < "background".length(); i++) {
sb.append(' ');
}
sb.append(" ").append(job.command()).append("\n");
return sb.toString();
}
@Override
public Object execute(CharSequence commandline) throws Exception {
String command = CommandLineParser.parse(this, commandline.toString());
return session.execute(command);
}
@Override
public Object get(String name) {
return session.get(name);
}
@Override
public void put(String name, Object value) {
session.put(name, value);
}
@Override
public InputStream getKeyboard() {
return session.getKeyboard();
}
@Override
public PrintStream getConsole() {
return session.getConsole();
}
@Override
public String resolveCommand(String name) {
// TODO: optimize
if (!name.contains(":")) {
String[] scopes = ((String) get(Session.SCOPE)).split(":");
List<Command> commands = registry.getCommands();
for (String scope : scopes) {
boolean globalScope = Session.SCOPE_GLOBAL.equals(scope);
for (Command command : commands) {
if ((globalScope || command.getScope().equals(scope)) && command.getName().equals(name)) {
return command.getScope() + ":" + name;
}
}
}
}
return name;
}
@Override
public String readLine(String prompt, Character mask) throws IOException {
LineReader reader = LineReaderBuilder.builder()
.terminal(jlineTerminal)
.appName("karaf")
.parser((line, cursor, context) -> new SimpleParsedLine(line, cursor))
.build();
reader.setOpt(LineReader.Option.DISABLE_EVENT_EXPANSION);
reader.setVariable(LineReader.DISABLE_HISTORY, Boolean.TRUE);
reader.setVariable(LineReader.DISABLE_COMPLETION, Boolean.TRUE);
return reader.readLine(prompt, mask);
}
private String loadCompletionMode() {
String mode;
try {
File shellCfg = new File(System.getProperty("karaf.etc"), "/org.apache.karaf.shell.cfg");
Properties properties = new Properties();
properties.load(new FileInputStream(shellCfg));
mode = (String) properties.get("completionMode");
if (mode == null) {
LOGGER.debug("completionMode property is not defined in etc/org.apache.karaf.shell.cfg file. Using default completion mode.");
mode = Session.COMPLETION_MODE_GLOBAL;
}
} catch (Exception e) {
LOGGER.warn("Can't read {}/org.apache.karaf.shell.cfg file. The completion is set to default.", System.getProperty("karaf.etc"));
mode = Session.COMPLETION_MODE_GLOBAL;
}
return mode;
}
private void executeScript(String names) {
FilesStream.stream(names).forEach(this::doExecuteScript);
}
private void doExecuteScript(Path scriptFileName) {
Object oldScript = session.put("script", Paths.get(System.getProperty("karaf.home")).relativize(scriptFileName));
try {
String script = String.join("\n",
Files.readAllLines(scriptFileName));
session.execute(script);
} catch (Exception e) {
LOGGER.debug("Error in initialization script {}", scriptFileName, e);
if (!(e instanceof InterruptedException)) {
System.err.println("Error in initialization script: " + scriptFileName + ": " + e.getMessage());
}
} finally {
session.put("script", oldScript);
}
}
protected void welcome(Properties brandingProps) {
String welcome = brandingProps.getProperty("welcome");
if (welcome != null && welcome.length() > 0) {
session.getConsole().println(welcome);
}
}
protected void setSessionProperties(Properties brandingProps) {
for (Map.Entry<Object, Object> entry : brandingProps.entrySet()) {
String key = (String) entry.getKey();
if (key.startsWith("session.")) {
session.put(key.substring("session.".length()), entry.getValue());
}
}
}
protected String getPrompt() {
return doGetPrompt(PROMPT, DEFAULT_PROMPT);
}
protected String getRPrompt() {
return doGetPrompt(RPROMPT, DEFAULT_RPROMPT);
}
protected String doGetPrompt(String var, String def) {
try {
String prompt;
try {
Object p = session.get(var);
if (p != null) {
prompt = p.toString();
} else {
var = var.toLowerCase();
p = session.get(var);
if (p != null) {
prompt = p.toString();
} else {
if (brandingProps.getProperty(var) != null) {
prompt = brandingProps.getProperty(var);
// we put the PROMPT in ConsoleSession to avoid to read
// the properties file each time.
session.put(var, prompt);
} else {
prompt = def;
}
}
}
} catch (Throwable t) {
prompt = def;
}
if (prompt != null) {
Matcher matcher = Pattern.compile("\\$\\{([^}]+)\\}").matcher(prompt);
while (matcher.find()) {
Object rep = session.get(matcher.group(1));
if (rep != null) {
prompt = prompt.replace(matcher.group(0), rep.toString());
matcher.reset(prompt);
}
}
}
return prompt;
} catch (Throwable t) {
return "$ ";
}
}
private String getPid() {
String name = ManagementFactory.getRuntimeMXBean().getName();
String[] parts = name.split("@");
return parts[0];
}
private static class SimpleParsedLine implements ParsedLine {
private final String line;
private final int cursor;
public SimpleParsedLine(String line, int cursor) {
this.line = line;
this.cursor = cursor;
}
@Override
public String word() {
return line;
}
@Override
public int wordCursor() {
return cursor;
}
@Override
public int wordIndex() {
return 0;
}
@Override
public List<String> words() {
return Collections.singletonList(line);
}
@Override
public String line() {
return line;
}
@Override
public int cursor() {
return cursor;
}
}
private class AggregateMaskingCallback implements MaskingCallback {
private final List<Command> commands = new ArrayList<>();
private final Map<String, ActionMaskingCallback> regexs = new HashMap<>();
@Override
public String display(String line) {
return compute(line);
}
@Override
public String history(String line) {
return compute(line);
}
private String compute(String line) {
Collection<Command> commands;
boolean update;
synchronized (this) {
commands = factory.getRegistry().getCommands();
update = !commands.equals(this.commands);
}
if (update) {
Map<String, ActionMaskingCallback> regexs = new HashMap<>();
for (Command cmd : commands) {
if (cmd instanceof ActionCommand) {
ActionMaskingCallback amc = ActionMaskingCallback.build((ActionCommand) cmd);
if (amc != null) {
regexs.put(cmd.getScope() + ":" + cmd.getName(), amc);
}
}
}
synchronized (this) {
this.commands.clear();
this.regexs.clear();
this.commands.addAll(commands);
this.regexs.putAll(regexs);
}
}
try {
ParsedLine pl = reader.getParser().parse(line, line.length());
String cmd = resolveCommand(pl.words().get(0));
ActionMaskingCallback repl = regexs.get(cmd);
if (repl != null) {
line = repl.filter(line, pl);
}
} catch (SyntaxError e) {
// Ignore
} catch (Exception e) {
LOGGER.debug("Exception caught while masking command line", e);
}
return line;
}
}
}