diff --git a/src/main/java/de/featjar/base/FeatJAR.java b/src/main/java/de/featjar/base/FeatJAR.java index deb218a..02a6800 100644 --- a/src/main/java/de/featjar/base/FeatJAR.java +++ b/src/main/java/de/featjar/base/FeatJAR.java @@ -32,6 +32,7 @@ import de.featjar.base.io.IO; import de.featjar.base.log.BufferedLog; import de.featjar.base.log.CallerFormatter; +import de.featjar.base.log.ColorFormatter; import de.featjar.base.log.ConfigurableLog; import de.featjar.base.log.EmptyProgressBar; import de.featjar.base.log.IProgressBar; @@ -204,6 +205,20 @@ public static Configuration testConfiguration() { return configuration; } + /** + * {@return a new FeatJAR configuration with values intended for shell settings} + */ + public static Configuration shellConfiguration() { + final Configuration configuration = new Configuration(); + configuration + .logConfig + .logToSystemOut(Log.Verbosity.MESSAGE, Log.Verbosity.INFO, Log.Verbosity.PROGRESS) + .logToSystemErr(Log.Verbosity.ERROR, Log.Verbosity.WARNING) + .addFormatter(new ColorFormatter()); + configuration.cacheConfig.setCachePolicy(Cache.CachePolicy.CACHE_NONE); + return configuration; + } + /** * {@return the current FeatJAR instance} * diff --git a/src/main/java/de/featjar/base/cli/ACommand.java b/src/main/java/de/featjar/base/cli/ACommand.java index 5485593..bd1fb00 100644 --- a/src/main/java/de/featjar/base/cli/ACommand.java +++ b/src/main/java/de/featjar/base/cli/ACommand.java @@ -20,6 +20,8 @@ */ package de.featjar.base.cli; +import de.featjar.base.data.Result; +import de.featjar.base.shell.ShellSession; import java.nio.file.Path; import java.util.List; @@ -49,4 +51,26 @@ public abstract class ACommand implements ICommand { public final List> getOptions() { return Option.getAllOptions(getClass()); } + + /** + * {@return an option list with all parsed arguments and properties including a path variable from the session} + */ + public OptionList getShellOptions(ShellSession session, List cmdParams) { + OptionList optionList = new OptionList(); + + if (cmdParams.isEmpty()) { + throw new IllegalArgumentException("No path object specified"); + } + + Result path = session.get(cmdParams.get(0), Path.class); + + if (path.isEmpty()) { + throw new IllegalArgumentException(String.format("'%s' is not a session object", cmdParams.get(0))); + } + + optionList.parseArguments(); + optionList.parseProperties(INPUT_OPTION, String.valueOf(path.get())); + + return optionList; + } } diff --git a/src/main/java/de/featjar/base/cli/Commands.java b/src/main/java/de/featjar/base/cli/Commands.java index f926aa6..c3844d2 100644 --- a/src/main/java/de/featjar/base/cli/Commands.java +++ b/src/main/java/de/featjar/base/cli/Commands.java @@ -72,6 +72,10 @@ public class Commands extends AExtensionPoint { */ public static final Pattern STANDARD_INPUT_PATTERN = Pattern.compile(STANDARD_INPUT + "(\\.(.+))?"); + public static Commands getInstance() { + return FeatJAR.extensionPoint(Commands.class); + } + /** * Runs a given function in a new thread, aborting it when it is not done after a timeout expires. * If the entire process should be stopped afterwards, {@link System#exit(int)} must be called explicitly. diff --git a/src/main/java/de/featjar/base/cli/ICommand.java b/src/main/java/de/featjar/base/cli/ICommand.java index b1c52df..bcbda5b 100644 --- a/src/main/java/de/featjar/base/cli/ICommand.java +++ b/src/main/java/de/featjar/base/cli/ICommand.java @@ -21,6 +21,7 @@ package de.featjar.base.cli; import de.featjar.base.extension.IExtension; +import de.featjar.base.shell.ShellSession; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -62,4 +63,19 @@ default Optional getShortName() { * @return exit code */ int run(OptionList optionParser); + + /** + * Parses arguments into an option list. + * + * @param session the shell session + * @param cmdParams the given arguments for the command + * @return an option list containing parsed arguments + */ + default OptionList getShellOptions(ShellSession session, List cmdParams) { + OptionList optionList = new OptionList(); + + optionList.parseArguments(); + + return optionList; + } } diff --git a/src/main/java/de/featjar/base/cli/OptionList.java b/src/main/java/de/featjar/base/cli/OptionList.java index f424d33..aa73bd1 100644 --- a/src/main/java/de/featjar/base/cli/OptionList.java +++ b/src/main/java/de/featjar/base/cli/OptionList.java @@ -357,6 +357,16 @@ private void parseConfigurationFiles(List problemList) { } } } + /** + * Adds a option with custom value to properties. + * + * @param option the option + * @param optionValue the command + */ + public void parseProperties(Option option, String optionValue) { + Result parse = option.parse(optionValue); + properties.put(option.getName(), parse.get()); + } private void parseRemainingArguments(List problemList) { ListIterator listIterator = arguments.listIterator(); diff --git a/src/main/java/de/featjar/base/env/Process.java b/src/main/java/de/featjar/base/env/Process.java index a0af640..472d458 100644 --- a/src/main/java/de/featjar/base/env/Process.java +++ b/src/main/java/de/featjar/base/env/Process.java @@ -22,6 +22,7 @@ import de.featjar.base.FeatJAR; import de.featjar.base.data.Problem; +import de.featjar.base.data.Problem.Severity; import de.featjar.base.data.Result; import de.featjar.base.data.Void; import java.io.BufferedReader; @@ -39,6 +40,7 @@ import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.Supplier; +import java.util.stream.Collectors; /** * Executes an external executable in a process. @@ -134,8 +136,19 @@ public java.lang.Process start() throws IOException { @Override public Result> get() { List output = new ArrayList<>(); - Result result = run(output::add, output::add); - return result.map(r -> output); + List error = new ArrayList<>(); + Result result = run(output::add, error::add); + + if (result.isPresent()) { + output.addAll(error); + return result.map(r -> output); + } else { + if (error.isEmpty()) { + return result.map(r -> null); + } else { + return Result.empty(new Problem(error.stream().collect(Collectors.joining("\n")), Severity.ERROR)); + } + } } /** diff --git a/src/main/java/de/featjar/base/log/ColorFormatter.java b/src/main/java/de/featjar/base/log/ColorFormatter.java new file mode 100644 index 0000000..531a4a2 --- /dev/null +++ b/src/main/java/de/featjar/base/log/ColorFormatter.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2025 FeatJAR-Development-Team + * + * This file is part of FeatJAR-base. + * + * base is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, + * or (at your option) any later version. + * + * base 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with base. If not, see . + * + * See for further information. + */ +package de.featjar.base.log; + +import de.featjar.base.log.Log.Verbosity; + +/** + * Prepends different colors to logs and appends a reset of the colors as suffix. + * + * @author Niclas Kleinert + */ +public class ColorFormatter implements IFormatter { + + private static final String TERMINAL_COLOR_LIGHT_BLUE = "\033[38;2;173;236;255m"; + private static final String TERMINAL_COLOR_YELLOW = "\033[38;2;255;255;0m"; + private static final String TERMINAL_COLOR_RED = "\033[38;2;255;0;0m"; + private static final String TERMINAL_COLOR_RESET = "\033[0m"; + + @Override + public String getPrefix(String message, Verbosity verbosity) { + switch (verbosity) { + case INFO: + return TERMINAL_COLOR_LIGHT_BLUE; + case WARNING: + return TERMINAL_COLOR_YELLOW; + case ERROR: + return TERMINAL_COLOR_RED; + case MESSAGE: + case PROGRESS: + case DEBUG: + break; + default: + throw new IllegalStateException(String.valueOf(verbosity)); + } + return ""; + } + + @Override + public String getSuffix(String message, Verbosity verbosity) { + return TERMINAL_COLOR_RESET; + } +} diff --git a/src/main/java/de/featjar/base/log/Log.java b/src/main/java/de/featjar/base/log/Log.java index ea0485a..cfa3297 100644 --- a/src/main/java/de/featjar/base/log/Log.java +++ b/src/main/java/de/featjar/base/log/Log.java @@ -314,6 +314,18 @@ default void plainMessage(Object messageObject) { plainMessage(() -> String.valueOf(messageObject)); } + default void noLineBreakMessage(Supplier message) { + print(message, Verbosity.MESSAGE, false); + } + + default void noLineBreakMessage(String formatMessage, Object... elements) { + noLineBreakMessage(() -> String.format(formatMessage, elements)); + } + + default void noLineBreakMessage(Object messageObject) { + noLineBreakMessage(() -> String.valueOf(messageObject)); + } + /** * Logs a debug message. * diff --git a/src/main/java/de/featjar/base/shell/ClearShellCommand.java b/src/main/java/de/featjar/base/shell/ClearShellCommand.java new file mode 100644 index 0000000..e1f3881 --- /dev/null +++ b/src/main/java/de/featjar/base/shell/ClearShellCommand.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2025 FeatJAR-Development-Team + * + * This file is part of FeatJAR-base. + * + * base is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, + * or (at your option) any later version. + * + * base 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with base. If not, see . + * + * See for further information. + */ +package de.featjar.base.shell; + +import de.featjar.base.FeatJAR; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Deletes all variables from the entire shell session. + * + * @author Niclas Kleinert + */ +public class ClearShellCommand implements IShellCommand { + + @Override + public void execute(ShellSession session, List cmdParams) { + String choice = Shell.readCommand("Clearing the entire session. Proceed ? (y)es (n)o") + .orElse("") + .toLowerCase() + .trim(); + + if (Objects.equals("y", choice)) { + session.clear(); + FeatJAR.log().message("Clearing successful"); + } else if (Objects.equals("n", choice)) { + FeatJAR.log().message("Clearing aborted"); + } + } + + @Override + public Optional getShortName() { + return Optional.of("clear"); + } + + @Override + public Optional getDescription() { + return Optional.of("- delete the entire session"); + } +} diff --git a/src/main/java/de/featjar/base/shell/DeleteShellCommand.java b/src/main/java/de/featjar/base/shell/DeleteShellCommand.java new file mode 100644 index 0000000..b80a6cb --- /dev/null +++ b/src/main/java/de/featjar/base/shell/DeleteShellCommand.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2025 FeatJAR-Development-Team + * + * This file is part of FeatJAR-base. + * + * base is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, + * or (at your option) any later version. + * + * base 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with base. If not, see . + * + * See for further information. + */ +package de.featjar.base.shell; + +import de.featjar.base.FeatJAR; +import de.featjar.base.log.Log.Verbosity; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Deletes a given list of variables from the shell session. + * + * @author Niclas Kleinert + */ +public class DeleteShellCommand implements IShellCommand { + + @Override + public void execute(ShellSession session, List cmdParams) { + + if (cmdParams.isEmpty()) { + session.printAll(); + cmdParams = Shell.readCommand("Enter the variable names you want to delete or leave blank to abort:") + .map(c -> Arrays.stream(c.split("\\s+")).collect(Collectors.toList())) + .orElse(Collections.emptyList()); + } + + cmdParams.forEach(e -> { + session.remove(e) + .ifPresent(a -> FeatJAR.log().message("Removing of " + e + " successful")) + .orElseLog(Verbosity.ERROR); + }); + } + + @Override + public Optional getShortName() { + return Optional.of("delete"); + } + + @Override + public Optional getDescription() { + return Optional.of("- ... - delete session variables"); + } +} diff --git a/src/main/java/de/featjar/base/shell/ExitShellCommand.java b/src/main/java/de/featjar/base/shell/ExitShellCommand.java new file mode 100644 index 0000000..f0e1a6c --- /dev/null +++ b/src/main/java/de/featjar/base/shell/ExitShellCommand.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2025 FeatJAR-Development-Team + * + * This file is part of FeatJAR-base. + * + * base is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, + * or (at your option) any later version. + * + * base 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with base. If not, see . + * + * See for further information. + */ +package de.featjar.base.shell; + +import java.util.List; +import java.util.Optional; + +/** + * Exits the shell. + * + * @author Niclas Kleinert + */ +public class ExitShellCommand implements IShellCommand { + + @Override + public void execute(ShellSession session, List cmdParams) { + System.exit(0); + } + + @Override + public Optional getShortName() { + return Optional.of("exit"); + } + + @Override + public Optional getDescription() { + return Optional.of("- leave shell"); + } +} diff --git a/src/main/java/de/featjar/base/shell/HelpShellCommand.java b/src/main/java/de/featjar/base/shell/HelpShellCommand.java new file mode 100644 index 0000000..45b4da3 --- /dev/null +++ b/src/main/java/de/featjar/base/shell/HelpShellCommand.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2025 FeatJAR-Development-Team + * + * This file is part of FeatJAR-base. + * + * base is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, + * or (at your option) any later version. + * + * base 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with base. If not, see . + * + * See for further information. + */ +package de.featjar.base.shell; + +import de.featjar.base.FeatJAR; +import java.util.List; +import java.util.Optional; + +/** + * Prints basic usage informations of the shell and all available shell commands. + * + * @author Niclas Kleinert + */ +public class HelpShellCommand implements IShellCommand { + + @Override + public void execute(ShellSession session, List cmdParams) { + printBasicUsage(); + printAllCommands(); + } + + /** + * Prints all {@link IShellCommand} that are registered at {@link ShellCommands} + */ + public void printAllCommands() { + FeatJAR.log().message("Supported commands are: \n"); + ShellCommands.getInstance().getExtensions().stream() + .map(c -> c.getShortName() + .orElse("") + .concat(" " + c.getDescription().orElse(""))) + .forEach(FeatJAR.log()::message); + FeatJAR.log().message("\n"); + } + + /** + * Prints basic usage informations of the shell. + */ + private void printBasicUsage() { + FeatJAR.log().message("Interactive shell"); + FeatJAR.log().message("Capitalization of COMMANDS is NOT taken into account"); + FeatJAR.log().message("You can cancel ANY command by pressing the (ESC) key"); + } + + @Override + public Optional getShortName() { + return Optional.of("help"); + } + + @Override + public Optional getDescription() { + return Optional.of("- print all commads"); + } +} diff --git a/src/main/java/de/featjar/base/shell/IShellCommand.java b/src/main/java/de/featjar/base/shell/IShellCommand.java new file mode 100644 index 0000000..9b15277 --- /dev/null +++ b/src/main/java/de/featjar/base/shell/IShellCommand.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2025 FeatJAR-Development-Team + * + * This file is part of FeatJAR-base. + * + * base is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, + * or (at your option) any later version. + * + * base 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with base. If not, see . + * + * See for further information. + */ +package de.featjar.base.shell; + +import de.featjar.base.extension.IExtension; +import java.util.List; +import java.util.Optional; + +/** + * A shell command run within a {@link ShellCommands} + * + * @author Niclas Kleinert + */ +public interface IShellCommand extends IExtension { + + /** + * Executes the shell command. + * + * @param session the storage location of all variables + * @param cmdParams all arguments except the shell command + */ + void execute(ShellSession session, List cmdParams); + + /** + * {@return this command's short name, if any} The short name can be used to call this command from the CLI. + */ + default Optional getShortName() { + return Optional.empty(); + } + + /** + * {@return this command's description name, if any} + */ + default Optional getDescription() { + return Optional.empty(); + } +} diff --git a/src/main/java/de/featjar/base/shell/PrintShellCommand.java b/src/main/java/de/featjar/base/shell/PrintShellCommand.java new file mode 100644 index 0000000..08756eb --- /dev/null +++ b/src/main/java/de/featjar/base/shell/PrintShellCommand.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2025 FeatJAR-Development-Team + * + * This file is part of FeatJAR-base. + * + * base is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, + * or (at your option) any later version. + * + * base 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with base. If not, see . + * + * See for further information. + */ +package de.featjar.base.shell; + +import de.featjar.base.FeatJAR; +import de.featjar.base.log.Log.Verbosity; +import de.featjar.base.tree.structure.ITree; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Prints the content of given shell session variables. + * + * @author Niclas Kleinert + */ +public class PrintShellCommand implements IShellCommand { + + @Override + public void execute(ShellSession session, List cmdParams) { + if (cmdParams.isEmpty()) { + session.printAll(); + cmdParams = Shell.readCommand("Enter the variable names you want to print or leave blank to abort:") + .map(c -> Arrays.stream(c.toLowerCase().split("\\s+")).collect(Collectors.toList())) + .orElse(Collections.emptyList()); + } + + cmdParams.forEach(e -> { + session.get(e) + .ifPresent(m -> { + FeatJAR.log().message(e + ":"); + printMap(m); + }) + .orElseLog(Verbosity.ERROR); + }); + } + + private void printMap(Object v) { + if (v instanceof ITree) { + FeatJAR.log().message(((ITree) v).print()); + } else { + FeatJAR.log().message(v); + } + FeatJAR.log().message(""); + } + + public Optional getShortName() { + return Optional.of("print"); + } + + public Optional getDescription() { + return Optional.of("... - print the content of variables"); + } +} diff --git a/src/main/java/de/featjar/base/shell/RunShellCommand.java b/src/main/java/de/featjar/base/shell/RunShellCommand.java new file mode 100644 index 0000000..4e8f47c --- /dev/null +++ b/src/main/java/de/featjar/base/shell/RunShellCommand.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2025 FeatJAR-Development-Team + * + * This file is part of FeatJAR-base. + * + * base is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, + * or (at your option) any later version. + * + * base 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with base. If not, see . + * + * See for further information. + */ +package de.featjar.base.shell; + +import de.featjar.base.FeatJAR; +import de.featjar.base.cli.ACommand; +import de.featjar.base.cli.Commands; +import de.featjar.base.cli.ICommand; +import de.featjar.base.cli.Option; +import de.featjar.base.cli.OptionList; +import de.featjar.base.data.Result; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Allows {@link ACommand} to be run via the shell with the possibility to + * alter certain options before the command is executed. + * + * @author Niclas Kleinert + */ +public class RunShellCommand implements IShellCommand { + + @Override + public void execute(ShellSession session, List cmdParams) { + if (cmdParams.isEmpty()) { + FeatJAR.log().info(String.format("Usage: %s", getDescription().orElse(""))); + return; + } + try { + Result cliCommand = Commands.getInstance().getExtension(cmdParams.get(0)); + + if (cliCommand.isEmpty()) { + FeatJAR.log().error(String.format("Command '%s' not found", cmdParams.get(0))); + return; + } + OptionList shellOptions = cliCommand.get().getShellOptions(session, cmdParams.subList(1, cmdParams.size())); + + shellOptions = alterOptions(cliCommand, shellOptions); + + int runResult = cliCommand.get().run(shellOptions); + + if (runResult == 0) { + FeatJAR.log().message("Successfull"); + } else { + FeatJAR.log() + .error( + "Errorcode '%d' occured in command '%s'", + runResult, cliCommand.get().getIdentifier()); + } + + } catch (IllegalArgumentException iae) { + iae.printStackTrace(); + FeatJAR.log().error(iae.getMessage()); + FeatJAR.log().info(String.format("Usage %s", getDescription().get())); + } + } + + private OptionList alterOptions(Result cliCommand, OptionList shellOptions) { + String choice; + + while (true) { + AtomicInteger i = new AtomicInteger(1); + List> options = cliCommand.get().getOptions(); + int numberChoice; + + options.forEach(o -> { + FeatJAR.log() + .message(i.getAndIncrement() + ". " + o + "=" + + shellOptions.getResult(o).map(String::valueOf).orElse("")); + }); + choice = String.valueOf(Shell.readCommand("Alter options ?\nSelect a number or leave blank to proceed:\n") + .orElse("")) + .toLowerCase(); + + if (choice.isBlank()) { + break; + } + try { + numberChoice = Integer.parseInt(choice) - 1; + } catch (NumberFormatException e) { + FeatJAR.log().error("Only decimal numbers are a valid choice"); + continue; + } + + if (options.size() - 1 < numberChoice || numberChoice < 1) { + FeatJAR.log().error("Number does not exist"); + continue; + } + + choice = String.valueOf(Shell.readCommand("Enter the new value:\n").orElse("")); + shellOptions.parseProperties(options.get(numberChoice), choice); + } + return shellOptions; + } + + @Override + public Optional getShortName() { + return Optional.of("run"); + } + + @Override + public Optional getDescription() { + return Optional.of(" - launch non shellcommands"); + } +} diff --git a/src/main/java/de/featjar/base/shell/Shell.java b/src/main/java/de/featjar/base/shell/Shell.java new file mode 100644 index 0000000..168ed9d --- /dev/null +++ b/src/main/java/de/featjar/base/shell/Shell.java @@ -0,0 +1,543 @@ +/* + * Copyright (C) 2025 FeatJAR-Development-Team + * + * This file is part of FeatJAR-base. + * + * base is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, + * or (at your option) any later version. + * + * base 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with base. If not, see . + * + * See for further information. + */ +package de.featjar.base.shell; + +import de.featjar.base.FeatJAR; +import de.featjar.base.data.Problem; +import de.featjar.base.data.Problem.Severity; +import de.featjar.base.data.Result; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Scanner; +import java.util.concurrent.CancellationException; +import java.util.stream.Collectors; + +/** + * The basic shell that provides direct access to all commands of FeatJAR. + * + * @author Niclas Kleinert + */ +public class Shell { + private static Shell instance; + private ShellSession session; + private final Scanner shellScanner; + private List history; + private int historyIndex; + private final BufferedReader reader; + private StringBuilder input; + String historyCommandLine; + private int cursorX; + private boolean lastArrowKeyUp; + private boolean lastArrowKeyDown; + private static final String START_OF_TERMINAL_LINE = "$ "; + private static final int CURSOR_START_POSITION = START_OF_TERMINAL_LINE.length() + 1; + + private Shell() { + this.session = new ShellSession(); + this.history = new LinkedList<>(); + + if (isWindows()) { + this.shellScanner = new Scanner(System.in); + this.reader = null; + } else { + this.shellScanner = null; + this.reader = new BufferedReader(new InputStreamReader(System.in)); + } + } + + public static Shell getInstance() { + return (instance == null) ? (instance = new Shell()) : instance; + } + + public static void main(String[] args) { + Shell.getInstance().run(); + } + + private void run() { + FeatJAR.initialize(FeatJAR.shellConfiguration()); + printArt(); + new HelpShellCommand().execute(null, null); + while (true) { + List cmdArg = null; + try { + cmdArg = readCommand(START_OF_TERMINAL_LINE) + .map(c -> Arrays.stream(c.split("\\s+")).collect(Collectors.toList())) + .orElse(Collections.emptyList()); + } catch (CancellationException e) { + FeatJAR.log().message(e.getMessage()); + exitInputMode(); + System.exit(0); + } + if (!cmdArg.isEmpty()) { + try { + Result command = parseCommand(cmdArg.get(0)); + cmdArg.remove(0); + if (command.isPresent()) { + history.add(command.get().getShortName().get() + " " + + cmdArg.stream().map(String::valueOf).collect(Collectors.joining(" "))); + command.get().execute(session, cmdArg); + } + } catch (CancellationException e) { + FeatJAR.log().message(e.getMessage()); + } + } + } + } + + private Result parseCommand(String commandString) { + ShellCommands shellCommandsExentionsPoint = ShellCommands.getInstance(); + List commands = shellCommandsExentionsPoint.getExtensions().stream() + .filter(command -> command.getShortName() + .map(name -> name.toLowerCase().startsWith(commandString)) + .orElse(Boolean.FALSE)) + .collect(Collectors.toList()); + + if (commands.size() > 1) { + Map ambiguousCommands = new HashMap(); + int i = 1; + + FeatJAR.log() + .info( + ("Command name '%s' is ambiguous! choose one of the following %d commands (leave blank to abort): \n"), + commandString, + commands.size()); + + for (IShellCommand c : commands) { + FeatJAR.log() + .message(i + "." + c.getShortName().get() + " - " + + c.getDescription().get()); + ambiguousCommands.put(i, c); + i++; + } + + String choice = readCommand("").orElse(""); + + if (choice.isBlank()) { + return Result.empty(); + } + int parsedChoice; + try { + parsedChoice = Integer.parseInt(choice); + } catch (NumberFormatException e) { + return Result.empty(addProblem(Severity.ERROR, String.format("'%s' is no vaild number", choice), e)); + } + + for (Map.Entry entry : ambiguousCommands.entrySet()) { + if (Objects.equals(entry.getKey(), parsedChoice)) { + return Result.of(entry.getValue()); + } + } + return Result.empty(addProblem( + Severity.ERROR, + "Command name '%s' is ambiguous! It matches the following commands: \n%s and wrong number !", + commandString, + commands.stream().map(IShellCommand::getIdentifier).collect(Collectors.joining("\n")))); + } + + IShellCommand command = null; + if (commands.isEmpty()) { + Result matchingExtension = shellCommandsExentionsPoint.getMatchingExtension(commandString); + if (matchingExtension.isEmpty()) { + FeatJAR.log().message("No such command '" + commandString + "'. \n shows all viable commands"); + return Result.empty(addProblem(Severity.ERROR, "No command matched the name '%s'!", commandString)); + } + command = matchingExtension.get(); + } else { + if (commands.get(0).getShortName().get().toLowerCase().matches(commandString)) { + command = commands.get(0); + return Result.of(command); + } + String choice = readCommand( + "Do you mean: " + commands.get(0).getShortName().get() + "? (ENTER) or (a)bort\n") + .orElse(""); + if (choice.isEmpty()) { + command = commands.get(0); + } else { + return Result.empty(); + } + } + return Result.of(command); + } + + private Problem addProblem(Severity severity, String message, Object... arguments) { + return new Problem(String.format(message, arguments), severity); + } + + private void handleTabulatorAutoComplete() { + if (input.length() == 0) { + return; + } + List commands = ShellCommands.getInstance().getExtensions().stream() + .filter(command -> command.getShortName() + .map(name -> name.toLowerCase().startsWith(String.valueOf(input))) + .orElse(Boolean.FALSE)) + .map(cmd -> cmd.getShortName().get().toLowerCase()) + .collect(Collectors.toList()); + + if (commands.isEmpty()) { + return; + } + + String prefix = commands.get(0); + + for (int i = 1; i < commands.size(); i++) { + prefix = calculateSimilarPrefix(prefix, commands.get(i)); + } + input.setLength(0); + input = input.append(prefix); + cursorX = input.length(); + + displayCharacters(String.valueOf(input)); + } + + private String calculateSimilarPrefix(String oldPrefix, String nextString) { + int minPrefixLength = Math.min(oldPrefix.length(), nextString.length()); + int i = 0; + while (i < minPrefixLength && oldPrefix.charAt(i) == nextString.charAt(i)) { + i++; + } + return oldPrefix.substring(0, i); + } + + private boolean isWindows() { + return System.getProperty("os.name").startsWith("Windows"); + } + + /** + * Displays the typed characters in the console. + * + * @param typedText the typed characters + */ + private void displayCharacters(String typedText) { + /* + * '\r' moves the cursor to the beginning of the line + * '\u001B[2K' or '\033[2K' erases the entire line + * '\u001B' (unicode) or '\033' (octal) for ESC work fine here + * '\u001B[#G' moves cursor to column # + * see for more documentation: https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797 + */ + FeatJAR.log().noLineBreakMessage("\r"); + FeatJAR.log().noLineBreakMessage("\033[2K"); + FeatJAR.log().noLineBreakMessage("$ " + typedText); + FeatJAR.log().noLineBreakMessage("\033[" + (cursorX + CURSOR_START_POSITION) + "G"); + } + + /** + * Static access for {@link #readShellCommand(String)} + * Reads characters one by one without line buffering into a string until ENTER is pressed. + * Handles special keys. ESC cancels every command. Ensures that arrow keys work as in a normal shell (including a terminal history). + * Page keys are ignored. Interrupts do not need special treatment and, therefore, work as usual. + * + * @param prompt the message that is shown in the terminal + * @return all normal keys combined into a string + */ + public static Optional readCommand(String prompt) { + return Shell.getInstance().readShellCommand(prompt); + } + + private Optional readShellCommand(String prompt) { + FeatJAR.log().noLineBreakMessage(prompt); + + if (isWindows()) { + String inputWindows = shellScanner.nextLine().trim(); + return inputWindows.isEmpty() ? Optional.empty() : Optional.of(inputWindows); + } + + input = new StringBuilder(); + int key; + historyIndex = history.size() - 1; + historyCommandLine = (history.size() > 0) ? history.get(history.size() - 1) : ""; + + try { + enterInputMode(); + while (true) { + + key = reader.read(); + + if (isEnter(key)) { + FeatJAR.log().noLineBreakMessage("\r\n"); + break; + } + if (isTabulator(key)) { + handleTabulatorAutoComplete(); + continue; + } + if (isBackspace(key)) { + handleBackspaceKey(); + continue; + } + if (isEscape(key)) { + handleEscapeKey(key); + continue; + } + handleNormalKey(key); + } + } catch (IOException e) { + e.printStackTrace(); + } finally { + exitInputMode(); + } + cursorX = 0; + + return input.length() == 0 ? Optional.empty() : Optional.of(String.valueOf(input)); + } + + private void handleEscapeKey(int key) throws IOException { + key = getNextKey(key); + if (key == '[') { + key = getNextKey(key); + if (isPageKey(key)) { + // ignore + lastArrowKeyUp = false; + } else if (isDelete(key)) { + handleDeleteKey(key); + lastArrowKeyUp = false; + } else { + handleArrowKeys(key); + } + + } else if (input.length() != 0) { + historyIndex = history.size() - 1; + resetInputLine(); + lastArrowKeyUp = false; + } else { + exitInputMode(); + throw new CancellationException("\nCommand canceled\n"); + } + } + + private int getNextKey(int key) throws IOException { + return reader.ready() ? reader.read() : key; + } + + private boolean isPageKey(int key) throws IOException { + return (key == 53 || key == 54) && getNextKey(key) == 126; + } + + private boolean isTabulator(int key) { + return key == 9; + } + + private boolean isDelete(int key) { + return key == 51; + } + + private boolean isEscape(int key) { + return key == 27; + } + + private boolean isBackspace(int key) { + return key == 127 || key == 8; + } + + private boolean isEnter(int key) { + return key == '\r' || key == '\n'; + } + + private void handleArrowKeys(int key) { + final char ARROW_UP = 'A', ARROW_DOWN = 'B', ARROW_RIGHT = 'C', ARROW_LEFT = 'D'; + switch (key) { + case ARROW_UP: + if (history.isEmpty()) { + return; + } + if (historyIndex == 0 || (!lastArrowKeyUp && (historyIndex == history.size() - 1))) { + if (lastArrowKeyDown) { + historyIndex--; + } + historyCommandLine = history.get(historyIndex); + moveToEndOfHistory(); + return; + } + if (historyIndex > 0) { + historyIndex--; + historyCommandLine = history.get(historyIndex); + moveToEndOfHistory(); + return; + } + case ARROW_DOWN: + if (history.isEmpty()) { + return; + } + if (lastArrowKeyUp && historyIndex != 0 && (historyIndex + 1) < history.size()) { + historyIndex++; + historyCommandLine = history.get(historyIndex); + moveToStartofHistory(); + return; + } + if (!((historyIndex + 1) < history.size())) { + moveOutOfHistory(); + return; + } else { + historyIndex++; + historyCommandLine = history.get(historyIndex); + moveToStartofHistory(); + return; + } + case ARROW_RIGHT: + moveCursorRight(); + break; + case ARROW_LEFT: + moveCursorLeft(); + break; + } + } + + private void handleBackspaceKey() { + if (input.length() != 0) { + if (cursorX >= 0) { + if (cursorX <= input.length() && cursorX != 0) { + input.deleteCharAt(cursorX - 1); + displayCharacters(input.toString()); + FeatJAR.log().noLineBreakMessage("\b"); + } + if (cursorX != 0) { + cursorX--; + } + } + } + } + + private void handleDeleteKey(int key) throws IOException { + key = getNextKey(key); + if (key == '~') { + if (input.length() != 0 && cursorX != input.length()) { + input.deleteCharAt(cursorX); + displayCharacters(input.toString()); + } + } + } + + private void handleNormalKey(int key) { + cursorX++; + + if (input.length() == 0) { + input.append((char) key); + } else { + input.insert(cursorX - 1, (char) key); + } + displayCharacters(input.toString()); + lastArrowKeyUp = false; + lastArrowKeyDown = false; + } + + private void resetInputLine() { + input.setLength(0); + cursorX = 0; + displayCharacters(""); + } + + private void moveToStartofHistory() { + input.setLength(0); + input.append(historyCommandLine); + cursorX = input.length() - 1; + displayCharacters(historyCommandLine); + lastArrowKeyUp = false; + lastArrowKeyDown = true; + } + + /* + * Moves out of the command history and resets the two lastArrowKey booleans. + */ + + private void moveOutOfHistory() { + resetInputLine(); + lastArrowKeyUp = false; + lastArrowKeyDown = false; + } + + private void moveToEndOfHistory() { + input.setLength(0); + input.append(historyCommandLine); + cursorX = input.length() - 1; + displayCharacters(historyCommandLine); + lastArrowKeyUp = true; + lastArrowKeyDown = false; + } + + private void moveCursorLeft() { + if (cursorX > 0) { + cursorX--; + FeatJAR.log().noLineBreakMessage("\033[D"); + } + } + + private void moveCursorRight() { + if (cursorX < input.length()) { + cursorX++; + FeatJAR.log().noLineBreakMessage("\033[C"); + } + } + + /** + *Sets the terminal into a 'raw' like mode that has no line buffer such that the shell can read a single key press, + *signals like CTRL+C do still work. + */ + private void enterInputMode() { + try { + /* + * sh executes the command in a new console. + * -c tells the shell to read commands from the following string. + * stty change and print terminal line settings + * -icanon disables the classical line buffered input mode, instead every key press gets directly send to the terminal + * -echo has to be disabled in combination with icanon to allow ANSI escape sequences to actually to what they are supposed to do + * (e.g. "\033[D" to move the cursor one space to the left). Otherwise the control code gets directly printed to the console + * without executing the the ANSI escape sequence. + */ + Runtime.getRuntime() + .exec(new String[] {"sh", "-c", "stty -icanon -echo . + * + * See for further information. + */ +package de.featjar.base.shell; + +import de.featjar.base.FeatJAR; +import de.featjar.base.extension.AExtensionPoint; + +/** + * Extension point for {@link IShellCommand}. + * + * @author Niclas Kleinert + */ +public class ShellCommands extends AExtensionPoint { + + public static ShellCommands getInstance() { + return FeatJAR.extensionPoint(ShellCommands.class); + } +} diff --git a/src/main/java/de/featjar/base/shell/ShellSession.java b/src/main/java/de/featjar/base/shell/ShellSession.java new file mode 100644 index 0000000..79b66ef --- /dev/null +++ b/src/main/java/de/featjar/base/shell/ShellSession.java @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2025 FeatJAR-Development-Team + * + * This file is part of FeatJAR-base. + * + * base is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, + * or (at your option) any later version. + * + * base 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with base. If not, see . + * + * See for further information. + */ +package de.featjar.base.shell; + +import de.featjar.base.FeatJAR; +import de.featjar.base.data.Problem; +import de.featjar.base.data.Problem.Severity; +import de.featjar.base.data.Result; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Map.Entry; + +/** + * The session in which all loaded formats are stored. + * + * @author Niclas Kleinert + */ +public class ShellSession { + + private static class StoredElement { + private Class type; + private T element; + + public StoredElement(Class type, T element) { + this.type = type; + this.element = element; + } + } + + private final Map> elements; + + public ShellSession() { + elements = new LinkedHashMap<>(); + } + + /** + * Returns the element if the key is present in the session + * and casts the element to the known type. + * + * @param generic type of element + * @param key the elements' key + * @param kownType the elements' type + * @return the element of the shell session or an empty result if the element is not present + */ + @SuppressWarnings("unchecked") + public Result get(String key, Class kownType) { + StoredElement storedElement = elements.get(key); + + if (storedElement == null) { + return Result.empty(addNotPresentProblem(key)); + } + if (storedElement.type == kownType) { + return Result.of((T) storedElement.type.cast(storedElement.element)); + } else { + throw new RuntimeException("Wrong Type"); + } + } + + /** + * Returns the element if the key is present in the session or + * ,otherwise, an empty result containing an error message. + * + * @param key the elements' key + * @return the element that is mapped to the key or an empty result if it is not present + */ + public Result get(String key) { + return elements.get(key) != null + ? Result.of(elements.get(key)).map(e -> e.element) + : Result.empty(addNotPresentProblem(key)); + } + + /** + * Returns the type of an element or + * ,otherwise, an empty result containing an error message. + * + * @param key the elements' key + * @return the type of the element or an empty result if the element is not present + */ + public Result getType(String key) { + return elements.get(key) != null + ? Result.of(elements.get(key)).map(e -> e.type) + : Result.empty(addNotPresentProblem(key)); + } + + /** + * Removes a single element of the shell session. + * + * @param key the elements' key + * @return non-null previous value if the removal was successful + */ + public Result remove(String key) { + return Result.of(elements.remove(key)).or(Result.empty(addNotPresentProblem(key))); + } + + private Problem addNotPresentProblem(String key) { + return new Problem(String.format("A variable named '%s' is not present in the session!", key), Severity.ERROR); + } + + /** + * Puts an element into the session. + * + * @param generic type of element + * @param key the elements' key + * @param element the element of the shell session + * @param type the elements' type + */ + public void put(String key, T element, Class type) { + elements.put(key, new StoredElement(type, element)); + } + + /** + * Removes all elements of the session. + */ + public void clear() { + elements.clear(); + } + + /** + * {@return the number of elements in the session} + */ + public int getSize() { + return elements.size(); + } + + /** + * Checks if the shell session contains a element with a specific key. + * + * @param key the elements' key + * @return true if a variable with given key is present + */ + public boolean containsKey(String key) { + return elements.containsKey(key); + } + + /** + * Checks if the shell session is empty. + * + * @return true if no element is present + */ + public boolean isEmpty() { + return elements.isEmpty(); + } + + /** + * Prints a single element if there is a matching key. + * + * @param key the elements' key + */ + public void printSingleELement(String key) { + for (Entry> entry : elements.entrySet()) { + if (entry.getKey().equals(key)) { + FeatJAR.log() + .message("Variable: " + key + " Type: " + + entry.getValue().type.getSimpleName() + "\n"); + break; + } + } + } + + /** + * Prints everything present in the session. + */ + public void printAll() { + elements.entrySet().forEach(m -> FeatJAR.log() + .message(m.getKey() + " (" + m.getValue().type.getSimpleName() + ")")); + } + + @SuppressWarnings({"unused"}) + public void printSortedByVarNames() { + elements.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(m -> FeatJAR.log() + .message(m.getKey() + " " + m.getValue().type.getSimpleName())); + } + + @SuppressWarnings({"unused"}) + public void printSortedByType() { + elements.entrySet().stream() + .sorted(Comparator.comparing(e -> String.valueOf(e.getValue().type))) + .forEach(m -> FeatJAR.log() + .message(m.getKey() + " " + m.getValue().type.getSimpleName())); + } +} diff --git a/src/main/java/de/featjar/base/shell/Variables.java b/src/main/java/de/featjar/base/shell/Variables.java new file mode 100644 index 0000000..8adc700 --- /dev/null +++ b/src/main/java/de/featjar/base/shell/Variables.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2025 FeatJAR-Development-Team + * + * This file is part of FeatJAR-base. + * + * base is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, + * or (at your option) any later version. + * + * base 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with base. If not, see . + * + * See for further information. + */ +package de.featjar.base.shell; + +import java.util.List; +import java.util.Optional; + +/** + * Shows all stored variables within {@link ShellSession} + * + * @author Niclas Kleinert + */ +public class Variables implements IShellCommand { + + @Override + public void execute(ShellSession session, List cmdParams) { + session.printAll(); + } + + @Override + public Optional getShortName() { + return Optional.of("variables"); + } + + @Override + public Optional getDescription() { + return Optional.of("- print the name and type of all session variables"); + } +} diff --git a/src/main/resources/extensions.xml b/src/main/resources/extensions.xml index 5a44fcc..aa26490 100644 --- a/src/main/resources/extensions.xml +++ b/src/main/resources/extensions.xml @@ -5,4 +5,13 @@ + + + + + + + + +