diff --git a/.github/assets/magik-session-wrapper/screenshot-force-transmit-of-prompt.png b/.github/assets/magik-session-wrapper/screenshot-force-transmit-of-prompt.png new file mode 100644 index 000000000..b030fa7cb Binary files /dev/null and b/.github/assets/magik-session-wrapper/screenshot-force-transmit-of-prompt.png differ diff --git a/.github/assets/magik-session-wrapper/screenshot-prompt-with-highlighting.png b/.github/assets/magik-session-wrapper/screenshot-prompt-with-highlighting.png new file mode 100644 index 000000000..815179fe2 Binary files /dev/null and b/.github/assets/magik-session-wrapper/screenshot-prompt-with-highlighting.png differ diff --git a/.github/assets/magik-session-wrapper/screenshot-prompt-with-syntax-error.png b/.github/assets/magik-session-wrapper/screenshot-prompt-with-syntax-error.png new file mode 100644 index 000000000..d085a8cdc Binary files /dev/null and b/.github/assets/magik-session-wrapper/screenshot-prompt-with-syntax-error.png differ diff --git a/.vscode/launch.json b/.vscode/launch.json index f6b73407d..7dfc46e5b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -38,6 +38,21 @@ "sw_type_dumper" ] }, + { + "type": "java", + "name": "Debug (Launch)-MagikSessionWrapper", + "request": "launch", + "mainClass": "nl.ramsolutions.sw.magik.sessionwrapper.Main", + "projectName": "magik-session-wrapper", + "args": [ + "--debug", + "--do-not-wait-for-prompt", + "--", + "${env:SMALLWORLD_GIS}/bin/share/runalias", + "-j", "-Djava.awt.headless=true", + "base" + ] + }, { "type": "java", "name": "Debug (Launch)-MagikToolkit", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 852e761c3..9a88a9cc6 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -21,7 +21,8 @@ "cd magik-language-server/client-vscode/server && ", "rm -f ./* ; ", "ln -s ../../../magik-language-server/target/magik-language-server-*.jar && ", - "ln -s ../../../magik-debug-adapter/target/magik-debug-adapter-*.jar", + "ln -s ../../../magik-debug-adapter/target/magik-debug-adapter-*.jar && ", + "ln -s ../../../magik-session-wrapper/target/magik-session-wrapper-*.jar", ], "group": "build", "presentation": { diff --git a/CHANGES.md b/CHANGES.md index b0f0735c3..d19ad74c8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -36,6 +36,7 @@ - Fix auto formatting bug where `_pragma` caused lines to be removed. - FormattingCheck now uses formatting code to detect issues. - Formatter now supports ranged formatting. +- Add `magik-session-wrapper` to wrap a Magik running image/session, providing a better CLI experience. - Several fixes. ### Breaking changes (reiterated from above) diff --git a/magik-language-server/client-vscode/client/src/alias-task-provider.ts b/magik-language-server/client-vscode/client/src/alias-task-provider.ts index 295076abd..9ca5a0131 100644 --- a/magik-language-server/client-vscode/client/src/alias-task-provider.ts +++ b/magik-language-server/client-vscode/client/src/alias-task-provider.ts @@ -2,6 +2,9 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; +import { getJavaExec } from './common'; +import { MAGIK_TOOLS_VERSION } from './const'; + export class MagikAliasTaskProvider implements vscode.TaskProvider, vscode.Disposable { public static readonly AliasType: string = 'run_alias'; @@ -49,7 +52,7 @@ export class MagikAliasTaskProvider implements vscode.TaskProvider, vscode.Dispo const runAliasPath = getRunAliasPath(); const aliasesPath = getAliasesPath(definition.aliasesPath); const environmentFile = getEnvironmentPath(definition.environmentPath); - const commandLine = getStartAliasCommand(runAliasPath, aliasesPath, definition.entry, environmentFile, definition.args); + const commandLine = getCommandLine(runAliasPath, aliasesPath, definition.entry, environmentFile, definition.args); const shellExecutionOptions: vscode.ShellExecutionOptions = { env: definition.env, }; @@ -126,8 +129,11 @@ function getAliasesPath(aliasesPath?: fs.PathLike): fs.PathLike { return vscode.workspace.getConfiguration().get('magik.aliases'); } -function getStartAliasCommand(runAliasPath: fs.PathLike, aliasesPath: fs.PathLike, entryName: string, environmentFile?: fs.PathLike, additionalArgs?: string[]): string { - let commandLine = `${runAliasPath} -a ${aliasesPath}`; +function getCommandLine(runAliasPath: fs.PathLike, aliasesPath: fs.PathLike, entryName: string, environmentFile?: fs.PathLike, additionalArgs?: string[]): string { + let commandLine = `${runAliasPath}`; + if (aliasesPath != null) { + commandLine = `${commandLine} -a ${aliasesPath}`; + } if (environmentFile != null) { commandLine = `${commandLine} -e ${environmentFile}`; } @@ -135,6 +141,20 @@ function getStartAliasCommand(runAliasPath: fs.PathLike, aliasesPath: fs.PathLik commandLine = `${commandLine} ${additionalArgs.join(' ')}`; } commandLine = `${commandLine} ${entryName}`; + + const useWrapper = vscode.workspace.getConfiguration().get('magik.useSessionWrapper', true); + if (useWrapper) { + const javaExec = getJavaExec(); + if (javaExec == null) { + const errorMessage = 'Could locate java executable, either set Java Home setting ("magik.javaHome") or JAVA_HOME environment variable.' + vscode.window.showWarningMessage(errorMessage); + throw new Error(errorMessage); + } + + const jar = path.join(__dirname, '..', '..', 'server', 'magik-session-wrapper-' + MAGIK_TOOLS_VERSION + '.jar'); + commandLine = `${javaExec} -jar ${jar} --debug -- ${commandLine}`; + } + return commandLine; } @@ -171,7 +191,7 @@ async function getAliasesTasks(aliasesPath: fs.PathLike): Promise additionalArguments: [], env: {}, }; - const commandLine = getStartAliasCommand(runAliasPath, aliasesPath, definition.entry, environmentFile, definition.additionalArguments); + const commandLine = getCommandLine(runAliasPath, aliasesPath, definition.entry, environmentFile, definition.additionalArguments); const shellExecutionOptions: vscode.ShellExecutionOptions = { env: definition.env, }; diff --git a/magik-language-server/client-vscode/package.json b/magik-language-server/client-vscode/package.json index 8fe53defc..1e63e4ff0 100644 --- a/magik-language-server/client-vscode/package.json +++ b/magik-language-server/client-vscode/package.json @@ -268,6 +268,11 @@ "description": "Path to environment file -- only used by the VS Code extension.", "type": "string" }, + "magik.useSessionWrapper": { + "description": "Use Smallworld session wrapper.", + "type": "boolean", + "default": true + }, "magik.formatting.indentChar": { "description": "Indent character, 'tab' or 'space' -- only used by the VS Code extension.", "type": "string" diff --git a/magik-language-server/client-vscode/server/.gitignore b/magik-language-server/client-vscode/server/.gitignore index 20e19b813..b16c1286d 100644 --- a/magik-language-server/client-vscode/server/.gitignore +++ b/magik-language-server/client-vscode/server/.gitignore @@ -1,2 +1,3 @@ magik-debug-adapter-*.jar magik-language-server-*.jar +magik-session-wrapper-*.jar diff --git a/magik-language-server/src/main/java/nl/ramsolutions/sw/magik/languageserver/SmallworldSessionWrapper.java b/magik-language-server/src/main/java/nl/ramsolutions/sw/magik/languageserver/SmallworldSessionWrapper.java deleted file mode 100644 index bca1e6551..000000000 --- a/magik-language-server/src/main/java/nl/ramsolutions/sw/magik/languageserver/SmallworldSessionWrapper.java +++ /dev/null @@ -1,280 +0,0 @@ -package nl.ramsolutions.sw.magik.languageserver; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.PrintStream; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.Map; -import java.util.logging.LogManager; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Terminal wrapper for Smallworld session. Allows you to start and stop a session. Interfaces with - * the Smallworld session via its terminal. Provides useful shortcuts like alt-p and alt-n, for - * previous and next command, respectively. - */ -@SuppressWarnings("checkstyle:MagicNumber") -public class SmallworldSessionWrapper { - - /** Session handler. */ - public interface ISmallworldSessionWrapperHandler { - - /** - * Process is started with pid. - * - * @param pid PID of the process. - */ - void processStarted(int pid); - - /** - * Handle data from the active session. - * - * @param data Data from active session to handle. - */ - void handleStdOut(byte[] data); - - /** - * Handle data from the active session. - * - * @param data Data from active session to handle. - */ - void handleStdErr(byte[] data); - - /** - * Smallworld process was ended, with exitValue. - * - * @param exitValue Exit value from process. - */ - void onExit(int exitValue); - } - - private static final Logger LOGGER = LoggerFactory.getLogger(SmallworldSessionWrapper.class); - private static final Charset CHARSET = StandardCharsets.ISO_8859_1; - - private Process process; - private final Path productDir; - private final Path aliasesPath; - private final String aliasesEntry; - private Thread ioThread; - private final ISmallworldSessionWrapperHandler handler; - - /** - * Constructor. - * - * @param productDir Path to Smallworld product dir. - * @param aliasesPath Path to aliases file - * @param aliasesEntry Entry to start. - * @param handler Handler to handle output from session. - */ - public SmallworldSessionWrapper( - final Path productDir, - final Path aliasesPath, - final String aliasesEntry, - final ISmallworldSessionWrapperHandler handler) { - this.productDir = productDir; - this.aliasesPath = aliasesPath; - this.aliasesEntry = aliasesEntry; - this.handler = handler; - } - - /** - * Start the session. - * - * @throws IOException - - */ - public void startSession() throws IOException { - LOGGER.debug("Starting session"); - - final String command = this.productDir.resolve("bin/share/runalias").toString(); - final ProcessBuilder processBuilder = - new ProcessBuilder(command, "-a", this.aliasesPath.toString(), this.aliasesEntry); - - // Set environment vars. - final Map environment = processBuilder.environment(); - environment.put("SMALLWORLD_GIS", this.productDir.toAbsolutePath().toString()); - - this.process = processBuilder.start(); - final int pid = -1; // Java 8 does not provide this PID. - LOGGER.debug("Started process, PID: {}", pid); - this.handler.processStarted(pid); - - // Start IO handler thread. - this.ioThread = - new Thread() { - @Override - public void run() { - runIoPump(); - } - }; - this.ioThread.start(); - } - - private void runIoPump() { - boolean running = true; - while (running) { - running = this.process.isAlive(); - - boolean pumpedIo = false; - try { - pumpedIo = pumpIo(); - - if (!pumpedIo) { - Thread.sleep(50); - } - } catch (IOException exception) { - LOGGER.error("Caught IOEXception", exception); - running = false; - } catch (InterruptedException exception) { - running = false; - Thread.currentThread().interrupt(); - } - } - - if (!this.process.isAlive()) { - final int exitValue = this.process.exitValue(); - LOGGER.debug("Started stopped, exitValue: {}", exitValue); - this.handler.onExit(exitValue); - - this.process = null; - } - - // The thread will end after this. - this.ioThread = null; - } - - private boolean pumpIo() throws IOException { - boolean pumpedIo = false; - final InputStream stdoutStream = this.process.getInputStream(); - final InputStream stderrStream = this.process.getErrorStream(); - - final byte[] buffer = new byte[1024]; - if (stdoutStream.available() != 0) { - pumpedIo = true; - final int read = stdoutStream.read(buffer); - if (read > 0) { - final byte[] bufferCopy = Arrays.copyOfRange(buffer, 0, read); - this.handler.handleStdOut(bufferCopy); - } - } - - if (stderrStream.available() != 0) { - pumpedIo = true; - final int read = stdoutStream.read(buffer); - if (read > 0) { - final byte[] bufferCopy = Arrays.copyOfRange(buffer, 0, read); - this.handler.handleStdErr(bufferCopy); - } - } - return pumpedIo; - } - - /** - * Get if sesesion is still active. - * - * @return true is session is still active, false otherwise. - */ - public boolean sessionActive() { - return this.process != null && this.process.isAlive(); - } - - /** Stop the session, by killing the process. */ - public void stopSession() { - if (this.process == null || !this.process.isAlive()) { - throw new IllegalStateException("Process is not active"); - } - - LOGGER.debug("Destroying process"); - this.process.destroy(); - this.process = null; - } - - /** - * Write data to the session. - * - * @param data Data to write. - * @throws IOException - - */ - public void writeData(byte[] data) throws IOException { - LOGGER.trace("Appending $ to sent data"); - - final byte[] buffer = new byte[data.length + 2]; - System.arraycopy(data, 0, buffer, 0, data.length); - buffer[data.length + 0] = '$'; - buffer[data.length + 1] = '\n'; - - if (LOGGER.isTraceEnabled()) { - String str = - new String(buffer, CHARSET) - .replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\b", "\\b") - .replace("\n", "\\n") - .replace("\t", "\\t") - .replace("\f", "\\f") - .replace("\r", "\\r"); - LOGGER.trace("Sending data: \"{}\"", str); - } - - final OutputStream stdinStream = this.process.getOutputStream(); - stdinStream.write(buffer); - stdinStream.flush(); - } - - /** - * Main entry point. - * - * @param args Given arguments. - * @throws IOException - - */ - public static void main(String[] args) throws IOException { - final InputStream stream = - Main.class.getClassLoader().getResourceAsStream("logging.properties"); - LogManager.getLogManager().readConfiguration(stream); // NOSONAR: Own logging configuration. - - final Path productDir = Path.of("/home/steven/opt/SW522/core"); - final Path aliasesPath = productDir.resolve(Path.of("config/gis_aliases")); - final String aliasesEntry = "base"; - final PrintStream out = System.out; // NOSONAR - - ISmallworldSessionWrapperHandler handler = - new ISmallworldSessionWrapperHandler() { - @Override - public void handleStdOut(byte[] buffer) { - out.print(new String(buffer, CHARSET)); - } - - @Override - public void handleStdErr(byte[] buffer) { - out.print(new String(buffer, CHARSET)); - } - - @Override - public void onExit(int exitValue) { - out.println("Exited: " + exitValue); - } - - @Override - public void processStarted(int pid) { - out.println("Process started: " + pid); - } - }; - - final SmallworldSessionWrapper wrapper = - new SmallworldSessionWrapper(productDir, aliasesPath, aliasesEntry, handler); - wrapper.startSession(); - - while (wrapper.sessionActive()) { - final byte[] buffer = new byte[1024]; - final int read = System.in.read(buffer); - if (read > 0) { - final byte[] bufferCopy = Arrays.copyOfRange(buffer, 0, read); - wrapper.writeData(bufferCopy); - } - } - } -} diff --git a/magik-lint/pom.xml b/magik-lint/pom.xml index 067919768..3e8cf5b67 100644 --- a/magik-lint/pom.xml +++ b/magik-lint/pom.xml @@ -27,6 +27,7 @@ commons-cli commons-cli + org.slf4j slf4j-api diff --git a/magik-session-wrapper/README.md b/magik-session-wrapper/README.md new file mode 100644 index 000000000..231adf912 --- /dev/null +++ b/magik-session-wrapper/README.md @@ -0,0 +1,70 @@ +# Magik session wrapper + +A wrapper around `runalias` which provides a better prompt than the standard Magik prompt. + +The standard Magik/CLI prompt is a bit bare bones. On Windows there is a bit of additional functionality such as previous/next command, but on Linux the prompt is minimal. As a result, the prompt is barely usable. This wrapper provides a better experience by providing functionality which is standard in regular REPL prompts. + +## Usage + +Run the wrapper with the command as you would normally run the `runalias`/`runalias.exe` command as arguments after a `--`. For example: + +```shell +$ java -jar magik-session-wrapper-.jar --debug --do-not-wait-for-prompt -- /opt/Smallworld/core/bin/share/runalias -j -Djava.awt.headless=true base +Sourcing .../core/config/environment + + + +---- Magik version 5.3.0.0-490 ---- +---- Running on Java version 17.0.13 ---- + +Starting +... +``` + +When the session is started AND a Magik prompt is provided (i.e., you would see `Magik>`), the wrapper will show its own prompt: + +```text +Found smallworld_registry: .../smallworld_registry (using environment variable SMALLWORLD_GIS) +WrapperMagik> +``` + +Then, you can use it like your normal Magik prompt, with added functionality. For example, syntax highlighting: + +![Prompt with syntax highlighting](../.github/assets/magik-session-wrapper/screenshot-prompt-with-highlighting.png "Prompt with syntax highlighting") + +Or in case of a syntax error, due to a missing `)` at the `show` invocation: + +![Prompt with syntax error](../.github/assets/magik-session-wrapper/screenshot-prompt-with-syntax-error.png "Prompt with syntax error") + +In case you need to force transmit the prompt to the session, write a single `$`-symbol on the last line: + +![Force transmit of prompt](../.github/assets/magik-session-wrapper/screenshot-force-transmit-of-prompt.png "Force transmit of prompt") + +## Supported functionality + +* Parsing of Magik +* Syntax error detection/detection of incomplete statements +* Completion of: + * Electric Magik templates + * Magik keywords +* Highlighting +* History +* Navigation using the keyboard: + * Left/right/up/down arrow keys to move within prompt + * Up/down to recall history + * Regular Unix keys: + * Ctrl-a to move cursor to start of line + * Ctrl-b to move cursor back one character + * Ctrl-f to move cursor forward one character + * Ctrl-r to search history + * Ctrl-u to cut the current line + * Ctrl-y to paste/"yank" + * Ctrl-_ to undo action + * Ctrl-backspace to delete word + * Alt-enter to insert line + * ... + * ANSI colors, for example through [sw5_color_terminal](https://github.com/StevenLooman/sw5_color_terminal) + +## Shortcomings + +Currently, the wrapper only shows lines from the session after a newline (`\n`) has been seen. Thus, the `.` showing at the start of the session will only be shown when the session prints a newline character. Furthermore, when the prompt is asking for user input, this is usually in the form of `Global ... does not exist: create it? (Y)`, without the newline. The wrapper forces a newline here to show the question, but other questions might not be shown. diff --git a/magik-session-wrapper/pom.xml b/magik-session-wrapper/pom.xml new file mode 100644 index 000000000..c15c7b765 --- /dev/null +++ b/magik-session-wrapper/pom.xml @@ -0,0 +1,109 @@ + + + 4.0.0 + + + nl.ramsolutions + magik-tools + 0.11.0-SNAPSHOT + + + magik-session-wrapper + StevenLooman :: SW :: Magik :: Session wrapper + + + + ${project.groupId} + magik-squid + ${project.version} + + + + org.jline + jline + + + + commons-cli + commons-cli + + + + org.slf4j + slf4j-api + + + org.slf4j + slf4j-jdk14 + + + + com.github.spotbugs + spotbugs-annotations + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.assertj + assertj-core + test + + + + + + + maven-shade-plugin + ${maven-shade-plugin.version} + + + package + + shade + + + true + + + org.sonarsource.sonarqube:sonar-plugin-api + + + + + org.jline:jline + + ** + + + + * + + META-INF/LICENSE + META-INF/LICENSE.txt + META-INF/NOTICE + META-INF/NOTICE.txt + + + + + + nl.ramsolutions.sw.magik.sessionwrapper.Main + + + + + + + + + + diff --git a/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineCompleter.java b/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineCompleter.java new file mode 100644 index 000000000..39c7bf5d0 --- /dev/null +++ b/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineCompleter.java @@ -0,0 +1,77 @@ +package nl.ramsolutions.sw.magik.sessionwrapper; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import nl.ramsolutions.sw.magik.api.MagikKeyword; +import org.jline.reader.Candidate; +import org.jline.reader.Completer; +import org.jline.reader.LineReader; +import org.jline.reader.ParsedLine; + +/** JLine completer for Magik. */ +class MagikJlineCompleter implements Completer { + + // Electric magik patterns for completion. + private static final Map ELECTRIC_MAGIK_COMPLETIONS = new HashMap<>(); + + static { + ELECTRIC_MAGIK_COMPLETIONS.put("iter", "_iter _method\n_endmethod"); + ELECTRIC_MAGIK_COMPLETIONS.put("private", "_private _method\n_endmethod"); + ELECTRIC_MAGIK_COMPLETIONS.put("abstract", "_abstract _method\n_endmethod"); + ELECTRIC_MAGIK_COMPLETIONS.put("method", "_method\n_endmethod"); + ELECTRIC_MAGIK_COMPLETIONS.put( + "pragma", "_pragma(classify_level=restricted, topic={}, usage={})"); + ELECTRIC_MAGIK_COMPLETIONS.put("def_slotted_exemplar", "def_slotted_exemplar()"); + ELECTRIC_MAGIK_COMPLETIONS.put("def_mixin", "def_mixin()"); + ELECTRIC_MAGIK_COMPLETIONS.put("remex", "remex()"); + ELECTRIC_MAGIK_COMPLETIONS.put("message_handler", "message_handler.new()"); + ELECTRIC_MAGIK_COMPLETIONS.put("define_condition", "condition.define_condition()"); + ELECTRIC_MAGIK_COMPLETIONS.put("define_binary_operator_case", "define_binary_operator_case()"); + + ELECTRIC_MAGIK_COMPLETIONS.put("if", "_if \n_then\n\n_endif"); + ELECTRIC_MAGIK_COMPLETIONS.put("over", "_over \n_loop\n_endloop"); + ELECTRIC_MAGIK_COMPLETIONS.put("catch", "_catch\n_endcatch"); + ELECTRIC_MAGIK_COMPLETIONS.put("block", "_block\n_endblock"); + ELECTRIC_MAGIK_COMPLETIONS.put("protect", "_protect\n_protection\n_endprotect"); + ELECTRIC_MAGIK_COMPLETIONS.put("lock", "_lock\n_unlock"); + ELECTRIC_MAGIK_COMPLETIONS.put("try", "_try\n_when\n_endtry"); + ELECTRIC_MAGIK_COMPLETIONS.put("proc", "_proc()\n_endproc"); + ELECTRIC_MAGIK_COMPLETIONS.put("loop", "_loop\n_endloop"); + ELECTRIC_MAGIK_COMPLETIONS.put("while", "_while \n_loop\n_endloop"); + ELECTRIC_MAGIK_COMPLETIONS.put("for", "_for _over \n_loop\n_endloop"); + } + + @Override + public void complete( + final LineReader reader, final ParsedLine line, final List candidates) { + final String word = line.word(); + this.completeElectricMagik(candidates, word); + this.completeKeywords(candidates, word); + } + + private void completeKeywords(final List candidates, final String word) { + Stream.of(MagikKeyword.keywordValues()) + .filter( + keyword -> { + final int index = keyword.indexOf(word); + return index == 0 || index == 1; + }) + .map(Candidate::new) + .forEach(candidates::add); + } + + private void completeElectricMagik(List candidates, String word) { + MagikJlineCompleter.ELECTRIC_MAGIK_COMPLETIONS.entrySet().stream() + .filter( + entry -> { + final String keyword = entry.getKey(); + final int index = keyword.indexOf(word); + return index == 0 || index == 1; + }) + .map(Map.Entry::getValue) + .map(Candidate::new) + .forEach(candidates::add); + } +} diff --git a/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineHighlighter.java b/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineHighlighter.java new file mode 100644 index 000000000..e6e54e02a --- /dev/null +++ b/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineHighlighter.java @@ -0,0 +1,33 @@ +package nl.ramsolutions.sw.magik.sessionwrapper; + +import com.sonar.sslr.api.AstNode; +import java.util.regex.Pattern; +import nl.ramsolutions.sw.magik.parser.MagikParser; +import org.jline.reader.Highlighter; +import org.jline.reader.LineReader; +import org.jline.utils.AttributedString; + +class MagikJlineHighlighter implements Highlighter { + + private final MagikParser parser = new MagikParser(); + private Pattern errorPattern; + private int errorIndex; + + @Override + public AttributedString highlight(final LineReader reader, final String buffer) { + final AstNode astNode = this.parser.parseSafe(buffer); + final MagikJlineHighlighterWalker walker = new MagikJlineHighlighterWalker(); + walker.walkAst(astNode); + return walker.getAttributedString(); + } + + @Override + public void setErrorPattern(final Pattern errorPattern) { + this.errorPattern = errorPattern; + } + + @Override + public void setErrorIndex(final int errorIndex) { + this.errorIndex = errorIndex; + } +} diff --git a/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineHighlighterWalker.java b/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineHighlighterWalker.java new file mode 100644 index 000000000..7ae81ea37 --- /dev/null +++ b/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineHighlighterWalker.java @@ -0,0 +1,124 @@ +package nl.ramsolutions.sw.magik.sessionwrapper; + +import com.sonar.sslr.api.AstNode; +import com.sonar.sslr.api.Token; +import com.sonar.sslr.api.Trivia; +import java.util.Set; +import nl.ramsolutions.sw.magik.analysis.MagikAstWalker; +import nl.ramsolutions.sw.magik.api.MagikKeyword; +import nl.ramsolutions.sw.magik.api.MagikOperator; +import nl.ramsolutions.sw.magik.api.MagikPunctuator; +import org.jline.utils.AttributedString; +import org.jline.utils.AttributedStringBuilder; +import org.jline.utils.AttributedStyle; + +class MagikJlineHighlighterWalker extends MagikAstWalker { + + private static final AttributedStyle STYLE_CONSTANT = + AttributedStyle.DEFAULT.foreground(AttributedStyle.MAGENTA); + private static final AttributedStyle STYLE_OPERATOR = + AttributedStyle.DEFAULT.foreground(AttributedStyle.CYAN); + private static final AttributedStyle STYLE_PUNCTUATOR = + AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW); + private static final AttributedStyle STYLE_KEYWORD = + AttributedStyle.DEFAULT.foreground(AttributedStyle.BLUE); + + private static final Set MAGIK_KEYWORDS = Set.of(MagikKeyword.keywordValues()); + private static final Set MAGIK_PUNCTUATORS = Set.of(MagikPunctuator.punctuatorValues()); + private static final Set MAGIK_OPERATORS = Set.of(MagikOperator.operatorValues()); + + private final AttributedStringBuilder attributedStringBuilder = new AttributedStringBuilder(); + + AttributedString getAttributedString() { + return this.attributedStringBuilder.toAttributedString(); + } + + private void appendConstant(final AstNode node) { + final String value = node.getTokenOriginalValue(); + this.attributedStringBuilder.append(value, STYLE_CONSTANT); + } + + @Override + protected void walkPostString(final AstNode node) { + this.appendConstant(node); + } + + @Override + protected void walkPostSymbol(final AstNode node) { + this.appendConstant(node); + } + + @Override + protected void walkPostCharacter(final AstNode node) { + this.appendConstant(node); + } + + @Override + protected void walkPostRegexp(final AstNode node) { + this.appendConstant(node); + } + + @Override + protected void walkPostNumber(final AstNode node) { + this.appendConstant(node); + } + + @Override + protected void walkPostIdentifier(final AstNode node) { + final String value = node.getTokenOriginalValue(); + this.attributedStringBuilder.append(value); + } + + @Override + protected void walkPostGlobalRef(final AstNode node) { + // @ is already added by the punctuator. + final AstNode labelNode = node.getChildren().get(1); + final String value = labelNode.getTokenOriginalValue(); + this.attributedStringBuilder.append(value); + } + + @Override + protected void walkPostLabel(final AstNode node) { + // @ is already added by the punctuator. + final AstNode labelNode = node.getChildren().get(1); + final String value = labelNode.getTokenOriginalValue(); + this.attributedStringBuilder.append(value); + } + + @Override + protected void walkPostSyntaxError(final AstNode node) { + final String value = node.getTokenOriginalValue(); + this.attributedStringBuilder.append( + value, AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)); + } + + @Override + protected void walkToken(final Token token) { + final String value = token.getOriginalValue(); + final String valueLowerCase = value.toLowerCase(); + if (MAGIK_KEYWORDS.contains(valueLowerCase)) { + this.attributedStringBuilder.append(value, STYLE_KEYWORD); + } else if (MAGIK_PUNCTUATORS.contains(value)) { + this.attributedStringBuilder.append(value, STYLE_PUNCTUATOR); + } else if (MAGIK_OPERATORS.contains(value)) { + this.attributedStringBuilder.append(value, STYLE_OPERATOR); + } + } + + private void walkCommentToken(final Token token) { + final String value = token.getOriginalValue(); + this.attributedStringBuilder.append( + value, AttributedStyle.DEFAULT.foreground(AttributedStyle.GREEN)); + } + + @Override + protected void walkTrivia(final Trivia trivia) { + if (trivia.isComment()) { + trivia.getTokens().forEach(this::walkCommentToken); + } else { + trivia.getTokens().stream() + .map(Token::getOriginalValue) + .forEach(this.attributedStringBuilder::append); + } + } +} diff --git a/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineParsedLine.java b/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineParsedLine.java new file mode 100644 index 000000000..e1786ebdb --- /dev/null +++ b/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineParsedLine.java @@ -0,0 +1,105 @@ +package nl.ramsolutions.sw.magik.sessionwrapper; + +import com.sonar.sslr.api.AstNode; +import com.sonar.sslr.api.Token; +import java.util.List; +import java.util.Objects; +import nl.ramsolutions.sw.magik.Position; +import nl.ramsolutions.sw.magik.analysis.AstQuery; +import org.jline.reader.ParsedLine; + +/** ParsedLine implementation for Magik. */ +class MagikJlineParsedLine implements ParsedLine { + + private final String line; + private final int cursor; + private final AstNode topNode; + + /** + * Constructor. + * + * @param line Line is the text that was entered. + * @param cursor Cursor is the cursor position in the line, starting at 0. + * @param topNode AstNode is the root node of the AST. + */ + MagikJlineParsedLine(final String line, final int cursor, final AstNode topNode) { + this.line = line; + this.cursor = cursor; + this.topNode = topNode; + } + + @Override + public String word() { + // Try getting the word via the AST. This requires that there is no syntax error. + final Position cursorPosition = this.getPositionFromCursor(); + if (cursorPosition.getColumn() == 0) { + return ""; + } + + final int previousLine = cursorPosition.getLine(); + final int previousColumn = cursorPosition.getColumn() - 1; + final Position previousPosition = new Position(previousLine, previousColumn); + final AstNode node = AstQuery.nodeAt(this.topNode, previousPosition); + if (node == null) { + return ""; + } + + final Token token = node.getToken(); + Objects.requireNonNull(token); + + return token.getOriginalValue(); + } + + @Override + public int wordCursor() { + final Position cursorPosition = this.getPositionFromCursor(); + final AstNode node = AstQuery.nodeAt(topNode, cursorPosition); + if (node == null) { + return 0; + } + + final Token token = node.getToken(); + Objects.requireNonNull(token); + + final Position tokenStartPosition = Position.fromTokenStart(token); + return cursorPosition.getColumn() - tokenStartPosition.getColumn(); + } + + @Override + public int wordIndex() { + // TODO? + return 0; + } + + @Override + public List words() { + // TODO? + return List.of(this.word()); + } + + @Override + public String line() { + return this.line; + } + + @Override + public int cursor() { + return this.cursor; + } + + private Position getPositionFromCursor() { + int positionLine = 1; + int positionColumn = 0; + final int maxIndex = Math.min(this.cursor, this.line.length()); + for (int i = 0; i < maxIndex; i++) { + if (this.line.charAt(i) == '\n') { + positionLine++; + positionColumn = 0; + } else { + positionColumn++; + } + } + + return new Position(positionLine, positionColumn); + } +} diff --git a/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineParser.java b/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineParser.java new file mode 100644 index 000000000..d5c08c053 --- /dev/null +++ b/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineParser.java @@ -0,0 +1,74 @@ +package nl.ramsolutions.sw.magik.sessionwrapper; + +import com.sonar.sslr.api.AstNode; +import nl.ramsolutions.sw.magik.api.MagikGrammar; +import nl.ramsolutions.sw.magik.parser.MagikParser; +import org.jline.reader.EOFError; +import org.jline.reader.ParsedLine; +import org.jline.reader.Parser; +import org.jline.reader.SyntaxError; + +/** JLine parser for Magik. */ +class MagikJlineParser implements Parser { + + final MagikParser magikParser = new MagikParser(); + + @Override + public ParsedLine parse(final String line, final int cursor, final ParseContext context) + throws SyntaxError { + final AstNode topNode = magikParser.parse(line); + + switch (context) { + case UNSPECIFIED: + return this.parseUnspecified(line, cursor, topNode); + + case ACCEPT_LINE: + return this.parseAcceptLine(line, cursor, topNode); + + case SPLIT_LINE: + return this.parseSplitLine(line, cursor, topNode); + + case COMPLETE: + return this.parseComplete(line, cursor, topNode); + + case SECONDARY_PROMPT: + return this.parseSecondaryPrompt(line, cursor, topNode); + + default: + throw new IllegalArgumentException("Unknown context: " + context); + } + } + + private ParsedLine parseUnspecified(final String line, final int cursor, final AstNode topNode) { + return new MagikJlineParsedLine(line, cursor, topNode); + } + + private ParsedLine parseAcceptLine(final String line, final int cursor, final AstNode topNode) { + if (line.endsWith("\n$")) { + // If ends with single $ on line, then force sending it to our session. + return new MagikJlineParsedLine(line, cursor, topNode); + } + + final AstNode syntaxErrorNode = topNode.getFirstDescendant(MagikGrammar.SYNTAX_ERROR); + if (syntaxErrorNode != null) { + final int tokenLine = syntaxErrorNode.getToken().getLine(); + final int tokenColumn = syntaxErrorNode.getToken().getColumn(); + throw new EOFError(tokenLine, tokenColumn, "Syntax error"); + } + + return new MagikJlineParsedLine(line, cursor, topNode); + } + + private ParsedLine parseSplitLine(final String line, final int cursor, final AstNode topNode) { + return new MagikJlineParsedLine(line, cursor, topNode); + } + + private ParsedLine parseComplete(final String line, final int cursor, final AstNode topNode) { + return new MagikJlineParsedLine(line, cursor, topNode); + } + + private ParsedLine parseSecondaryPrompt( + final String line, final int cursor, final AstNode topNode) { + return new MagikJlineParsedLine(line, cursor, topNode); + } +} diff --git a/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/Main.java b/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/Main.java new file mode 100644 index 000000000..b4131871e --- /dev/null +++ b/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/Main.java @@ -0,0 +1,187 @@ +package nl.ramsolutions.sw.magik.sessionwrapper; + +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.nio.file.Path; +import java.util.List; +import java.util.logging.LogManager; +import java.util.regex.Pattern; +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.CommandLineParser; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.Options; +import org.apache.commons.cli.ParseException; +import org.apache.commons.cli.PatternOptionBuilder; +import org.jline.reader.Completer; +import org.jline.reader.EndOfFileException; +import org.jline.reader.Highlighter; +import org.jline.reader.LineReader; +import org.jline.reader.LineReaderBuilder; +import org.jline.reader.Parser; +import org.jline.reader.PrintAboveWriter; +import org.jline.reader.UserInterruptException; +import org.jline.reader.impl.history.DefaultHistory; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Main entry point for magik session wrapper. */ +public class Main { + + private static final Logger LOGGER = LoggerFactory.getLogger(Main.class); + + private static final Options OPTIONS; + private static final Option OPTION_DEBUG = + Option.builder().longOpt("debug").desc("Show debug messages").build(); + private static final Option OPTION_HISTORY_FILE = + Option.builder() + .longOpt("history-file") + .desc("Path to history file") + .hasArg() + .type(PatternOptionBuilder.FILE_VALUE) + .build(); + private static final Option OPTION_DO_NOT_WAIT_FOR_PROMPT = + Option.builder() + .longOpt("do-not-wait-for-prompt") + .desc("Do not wait for Magik prompt") + .build(); + private static final Option OPTION_PROMPT_PATTERN = + Option.builder() + .longOpt("prompt-pattern") + .desc("Prompt pattern (regex)") + .hasArg() + .type(PatternOptionBuilder.STRING_VALUE) + .build(); + + static { + OPTIONS = new Options(); + OPTIONS.addOption(OPTION_DEBUG); + OPTIONS.addOption(OPTION_HISTORY_FILE); + OPTIONS.addOption(OPTION_DO_NOT_WAIT_FOR_PROMPT); + OPTIONS.addOption(OPTION_PROMPT_PATTERN); + } + + private Main() {} + + /** + * Initialize logger from logging.properties. + * + * @throws IOException - + */ + private static void initLogger() throws IOException { + final InputStream stream = + Main.class.getClassLoader().getResourceAsStream("logging.properties"); + LogManager.getLogManager().readConfiguration(stream); // NOSONAR: Own logging configuration. + } + + /** + * Initialize logger from debug-logging.properties. + * + * @throws IOException - + */ + private static void initDebugLogger() throws IOException { + final InputStream stream = + Main.class.getClassLoader().getResourceAsStream("debug-logging.properties"); + LogManager.getLogManager().readConfiguration(stream); // NOSONAR: Own logging configuration. + } + + /** + * Parse the command line. + * + * @param args Command line arguments. + * @return Parsed command line. + * @throws ParseException - + */ + private static CommandLine parseCommandline(final String[] args) throws ParseException { + final CommandLineParser parser = new DefaultParser(); + return parser.parse(Main.OPTIONS, args); + } + + /** + * Main entry point. + * + * @param args Arguments. + * @throws IOException - + * @throws ParseException - + */ + public static void main(final String[] args) throws IOException, ParseException { + final CommandLine commandLine = Main.parseCommandline(args); + if (commandLine.hasOption(OPTION_DEBUG)) { + Main.initDebugLogger(); + } else { + Main.initLogger(); + } + + // History file. + final String historyFilePath; + if (commandLine.hasOption(OPTION_HISTORY_FILE)) { + historyFilePath = commandLine.getOptionValue(OPTION_HISTORY_FILE); + } else { + historyFilePath = Path.of(System.getProperty("user.home"), ".magik_history").toString(); + } + System.setProperty("jline.history", historyFilePath); + + // History file. + final boolean waitForPrompt = !commandLine.hasOption(OPTION_DO_NOT_WAIT_FOR_PROMPT); + + // Prompt pattern. + final Pattern promptPattern; + if (commandLine.hasOption(OPTION_PROMPT_PATTERN)) { + final String promptPatternStr = commandLine.getOptionValue(OPTION_PROMPT_PATTERN); + promptPattern = Pattern.compile(promptPatternStr); + } else { + promptPattern = SmallworldSession.DEFAULT_PROMPT_PATTERN; + } + + final Terminal terminal = TerminalBuilder.builder().system(true).build(); + final DefaultHistory history = new DefaultHistory(); + final Parser parser = new MagikJlineParser(); + final Completer completer = new MagikJlineCompleter(); + final Highlighter highlighter = new MagikJlineHighlighter(); + final LineReader lineReader = + LineReaderBuilder.builder() + .terminal(terminal) + .history(history) + .parser(parser) + .completer(completer) + .highlighter(highlighter) + .variable(LineReader.SECONDARY_PROMPT_PATTERN, "%M%P > ") + .variable(LineReader.INDENTATION, 2) + .variable(LineReader.HISTORY_FILE, historyFilePath) + .build(); + + final PrintAboveWriter printAboveWriter = new PrintAboveWriter(lineReader); + final PrintWriter wrapperWriter = new PrintWriter(printAboveWriter, true); + + final List runAliasCommand = commandLine.getArgList(); + final SmallworldSession session = + new SmallworldSessionLauncher(runAliasCommand, wrapperWriter, promptPattern).launch(); + + try { + final PrintWriter sessionWriter = session.getSessionWriter(); + while (session.isAlive()) { + // Wait for prompt from Smallworld session. + if (waitForPrompt) { + session.waitForPrompt(); + } + + // Get input from user. + final String userInput = lineReader.readLine("WrapperMagik> "); + + // Send input to Smallworld session. + sessionWriter.println(userInput); + } + } catch (final EndOfFileException | UserInterruptException exception) { + LOGGER.debug("Exiting..."); + } finally { + history.save(); + + session.destroy(); + wrapperWriter.close(); + terminal.close(); + } + } +} diff --git a/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/SmallworldSession.java b/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/SmallworldSession.java new file mode 100644 index 000000000..a0ca9a123 --- /dev/null +++ b/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/SmallworldSession.java @@ -0,0 +1,170 @@ +package nl.ramsolutions.sw.magik.sessionwrapper; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Running Smallworld session. */ +class SmallworldSession { + + private static final String SESSION_OUTPUT_THREAD_NAME = "SessionOutputThread"; + private static final Pattern GLOBAL_DOES_NOT_EXIST_PATTERN = + Pattern.compile("Global .* does not exist: create it\\? \\(Y\\) "); + static final Pattern DEFAULT_PROMPT_PATTERN = Pattern.compile("^(Magik|MagikSF)> $"); + + private static final Logger LOGGER = LoggerFactory.getLogger(SmallworldSession.class); + private static final Charset SESSION_CHARSET = StandardCharsets.ISO_8859_1; + + private final Process process; + private final Pattern promptPattern; + private final Thread ioThread; + private Object promptSeen; + + /** + * Constructor. + * + * @param process The process of the Smallworld session. + * @param promptPattern The prompt pattern to match prompts on. + * @param outputWriter Output writer to write session output to. + */ + SmallworldSession( + final Process process, final Pattern promptPattern, final PrintWriter outputWriter) { + this.process = process; + this.promptPattern = promptPattern; + + this.ioThread = + new Thread(() -> this.sessionOutputThread(outputWriter), SESSION_OUTPUT_THREAD_NAME); + this.ioThread.start(); + + this.process + .onExit() + .thenAccept(p -> LOGGER.debug("Smallworld session exited with code: {}", p)); + } + + private void sessionOutputThread(final PrintWriter outputWriter) { + this.promptSeen = new Object(); + + final BufferedReader sessionReader = this.getSessionReader(); + final CharBuffer charBuffer = CharBuffer.allocate(1024); + try { + while (true) { + sessionReader.read(charBuffer); + charBuffer.flip(); + + final String sessionOutput = charBuffer.toString(); + charBuffer.clear(); + + this.processSessionOutput(outputWriter, sessionOutput); + } + } catch (final IOException exception) { + if (this.process.isAlive() || !exception.getMessage().equals("Stream closed")) { + LOGGER.error("Session reader thread stopped, exception occurred", exception); + } + } + + LOGGER.debug("Session output reader thread ended"); + } + + private void processSessionOutput(final PrintWriter outputWriter, final String str) { + // If prompt is seen, don't print it, but do signal it was seen. + if (this.promptPattern.matcher(str).matches()) { + this.signalPromptSeen(); + + return; + } + + // Work around `Global ... does not exist: create it?` questions on prompt, + // not ending with newline. + // NOTE: This is a hack, and only works in the case of undefined globals, but not for: + // - terminal.prompt_for_input() + // - terminal.prompt_y_or_n() + // - terminal.prompt_brief_choice() + // - terminal.prompt_long_choice() + final String strFixed; + if (this.matchesSessionQuestion(str)) { + strFixed = str + "\n"; + + this.signalPromptSeen(); + } else { + strFixed = str; + } + + // Write session output to output writer. + outputWriter.print(strFixed); + outputWriter.flush(); + } + + private boolean matchesSessionQuestion(final String str) { + return GLOBAL_DOES_NOT_EXIST_PATTERN.matcher(str).matches(); + } + + BufferedReader getSessionReader() { + final InputStream inputStream = this.process.getInputStream(); + final InputStreamReader inputStreamReader = new InputStreamReader(inputStream, SESSION_CHARSET); + return new BufferedReader(inputStreamReader); + } + + PrintWriter getSessionWriter() { + final OutputStream outputStream = this.process.getOutputStream(); + return new PrintWriter(outputStream, true, SESSION_CHARSET); + } + + long getPid() { + return this.process.pid(); + } + + boolean isAlive() { + return this.process.isAlive(); + } + + void destroy() { + this.process.destroy(); + + while (this.ioThread.isAlive()) { + try { + this.ioThread.join(); + } catch (final InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } + } + + private void signalPromptSeen() { + synchronized (this.promptSeen) { + this.promptSeen.notifyAll(); + } + } + + void waitForPrompt() { + // Wait for the io thread to start up. + while (this.promptSeen == null) { + try { + Thread.sleep(50); + } catch (final InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } + + // Wait for the prompt seen signal. + while (true) { + try { + synchronized (this.promptSeen) { + this.promptSeen.wait(); + } + + break; + } catch (final InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } + } +} diff --git a/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/SmallworldSessionLauncher.java b/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/SmallworldSessionLauncher.java new file mode 100644 index 000000000..0b5d97d83 --- /dev/null +++ b/magik-session-wrapper/src/main/java/nl/ramsolutions/sw/magik/sessionwrapper/SmallworldSessionLauncher.java @@ -0,0 +1,45 @@ +package nl.ramsolutions.sw.magik.sessionwrapper; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.List; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Builder for {@link SmallworldSession}. */ +class SmallworldSessionLauncher { + + private static final Logger LOGGER = LoggerFactory.getLogger(SmallworldSessionLauncher.class); + + private final List runAliasCommand; + private final PrintWriter outputWriter; + private final Pattern promptPattern; + + /** + * Constructor. + * + * @param runAliasCommand Run alias command. + * @param outputWriter Output writer. + * @param promptPattern Prompt pattern. + */ + SmallworldSessionLauncher( + final List runAliasCommand, + final PrintWriter outputWriter, + final Pattern promptPattern) { + this.runAliasCommand = runAliasCommand; + this.outputWriter = outputWriter; + this.promptPattern = promptPattern; + } + + SmallworldSession launch() throws IOException { + LOGGER.debug("Starting Smallworld session with command: {}", this.runAliasCommand); + + final ProcessBuilder processBuilder = new ProcessBuilder(this.runAliasCommand); + processBuilder.redirectErrorStream(true); + final Process process = processBuilder.start(); + LOGGER.debug("Started Smallworld session, pid: {}", process.pid()); + + return new SmallworldSession(process, this.promptPattern, this.outputWriter); + } +} diff --git a/magik-session-wrapper/src/main/resources/debug-logging.properties b/magik-session-wrapper/src/main/resources/debug-logging.properties new file mode 100644 index 000000000..1fae99ac2 --- /dev/null +++ b/magik-session-wrapper/src/main/resources/debug-logging.properties @@ -0,0 +1,9 @@ +handlers = java.util.logging.ConsoleHandler +.level = ALL + +java.util.logging.ConsoleHandler.level = ALL +java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter + +java.util.logging.SimpleFormatter.format = %1$tF %1$tT %4$-7s %3$s : %5$s %6$s%n + +org.jline.level = WARNING diff --git a/magik-session-wrapper/src/test/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineCompleterTest.java b/magik-session-wrapper/src/test/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineCompleterTest.java new file mode 100644 index 000000000..8fa4d79cb --- /dev/null +++ b/magik-session-wrapper/src/test/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineCompleterTest.java @@ -0,0 +1,53 @@ +package nl.ramsolutions.sw.magik.sessionwrapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.jline.reader.Candidate; +import org.jline.reader.ParsedLine; +import org.junit.jupiter.api.Test; + +/** Tests for {@link MagikJlineCompleter}. */ +class MagikJlineCompleterTest { + + @Test + void testCompleteKeywordWithUnderscore() { + final String code = "_hand"; + final MagikJlineParser parser = new MagikJlineParser(); + final ParsedLine parsedLine = parser.parse(code, 5); + + final MagikJlineCompleter completer = new MagikJlineCompleter(); + final List candidates = new ArrayList<>(); + completer.complete(null, parsedLine, candidates); + + assertThat(candidates).containsOnly(new Candidate("_handling")); + } + + @Test + void testCompleteKeywordWithoutUnderscore() { + final String code = "hand"; + final MagikJlineParser parser = new MagikJlineParser(); + final ParsedLine parsedLine = parser.parse(code, 4); + + final MagikJlineCompleter completer = new MagikJlineCompleter(); + final List candidates = new ArrayList<>(); + completer.complete(null, parsedLine, candidates); + + assertThat(candidates).containsOnly(new Candidate("_handling")); + } + + @Test + void testCompleteElectricMagik() { + final String code = "if"; + final MagikJlineParser parser = new MagikJlineParser(); + final ParsedLine parsedLine = parser.parse(code, 2); + + final MagikJlineCompleter completer = new MagikJlineCompleter(); + final List candidates = new ArrayList<>(); + completer.complete(null, parsedLine, candidates); + + assertThat(candidates) + .containsOnly(new Candidate("_if \n_then\n\n_endif"), new Candidate("_if")); + } +} diff --git a/magik-session-wrapper/src/test/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineHighlighterTest.java b/magik-session-wrapper/src/test/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineHighlighterTest.java new file mode 100644 index 000000000..0a988d787 --- /dev/null +++ b/magik-session-wrapper/src/test/java/nl/ramsolutions/sw/magik/sessionwrapper/MagikJlineHighlighterTest.java @@ -0,0 +1,117 @@ +package nl.ramsolutions.sw.magik.sessionwrapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.jline.utils.AttributedString; +import org.jline.utils.AttributedStringBuilder; +import org.jline.utils.AttributedStyle; +import org.junit.jupiter.api.Test; + +/** Tests for {@link MagikJlineHighlighter}. */ +class MagikJlineHighlighterTest { + + AttributedString highlight(final String code) { + final MagikJlineHighlighter highlighter = new MagikJlineHighlighter(); + return highlighter.highlight(null, code); + } + + @Test + void testHighlightPrompt1() { + final String code = + """ + write(10) + """; + final AttributedString attributedString = this.highlight(code); + final AttributedString expected = + new AttributedStringBuilder() + .append("write") + .append("(", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append("10", AttributedStyle.DEFAULT.foreground(AttributedStyle.MAGENTA)) + .append(")", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append("\n") + .toAttributedString(); + assertThat(attributedString).isEqualTo(expected); + } + + @Test + void testHighlightPrompt2() { + final String code = + """ + _for y _over 1.upto(10) # test + _loop@abc + show(y, (y), y + y, |y|, "abc", 'abc', 16r10, 10, @user:object, {10}, _true) + _endloop@abc + """; + final AttributedString attributedString = this.highlight(code); + final AttributedString expected = + new AttributedStringBuilder() + .append("_for", AttributedStyle.DEFAULT.foreground(AttributedStyle.BLUE)) + .append(" y ") + .append("_over", AttributedStyle.DEFAULT.foreground(AttributedStyle.BLUE)) + .append(" ") + .append("1", AttributedStyle.DEFAULT.foreground(AttributedStyle.MAGENTA)) + .append(".", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append("upto") + .append("(", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append("10", AttributedStyle.DEFAULT.foreground(AttributedStyle.MAGENTA)) + .append(")", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append(" ") + .append("# test", AttributedStyle.DEFAULT.foreground(AttributedStyle.GREEN)) + .append("\n") + .append("_loop", AttributedStyle.DEFAULT.foreground(AttributedStyle.BLUE)) + .append("@", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append("abc") + .append("\n") + .append(" show") + .append("(", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append("y") + .append(",", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append(" ") + .append("(", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append("y") + .append(")", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append(",", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append(" ") + .append("y") + .append(" ") + .append("+", AttributedStyle.DEFAULT.foreground(AttributedStyle.CYAN)) + .append(" ") + .append("y") + .append(",", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append(" ") + .append("|y|") + .append(",", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append(" ") + .append("\"abc\"", AttributedStyle.DEFAULT.foreground(AttributedStyle.MAGENTA)) + .append(",", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append(" ") + .append("'abc'", AttributedStyle.DEFAULT.foreground(AttributedStyle.MAGENTA)) + .append(",", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append(" ") + .append("16r10", AttributedStyle.DEFAULT.foreground(AttributedStyle.MAGENTA)) + .append(",", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append(" ") + .append("10", AttributedStyle.DEFAULT.foreground(AttributedStyle.MAGENTA)) + .append(",", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append(" ") + .append("@", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append("user:object") + .append(",", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append(" ") + .append("{", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append("10", AttributedStyle.DEFAULT.foreground(AttributedStyle.MAGENTA)) + .append("}", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append(",", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append(" ") + .append("_true", AttributedStyle.DEFAULT.foreground(AttributedStyle.BLUE)) + .append(")", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append("\n") + .append("_endloop", AttributedStyle.DEFAULT.foreground(AttributedStyle.BLUE)) + .append("@", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)) + .append("abc") + .append("\n") + .toAttributedString(); + assertThat(attributedString.toAnsi()).hasToString(expected.toAnsi()); + assertThat(attributedString).isEqualTo(expected); + } +} diff --git a/magik-squid/src/main/java/nl/ramsolutions/sw/magik/api/MagikKeyword.java b/magik-squid/src/main/java/nl/ramsolutions/sw/magik/api/MagikKeyword.java index 0a26e5a47..263dd81ac 100644 --- a/magik-squid/src/main/java/nl/ramsolutions/sw/magik/api/MagikKeyword.java +++ b/magik-squid/src/main/java/nl/ramsolutions/sw/magik/api/MagikKeyword.java @@ -84,13 +84,13 @@ public enum MagikKeyword implements GrammarRuleKey { * @return Keyword values */ public static String[] keywordValues() { - final String[] keywordsValue = new String[MagikKeyword.values().length]; + final String[] keywordsValues = new String[MagikKeyword.values().length]; int idx = 0; for (final MagikKeyword keyword : MagikKeyword.values()) { - keywordsValue[idx] = keyword.getValue(); + keywordsValues[idx] = keyword.getValue(); idx++; } - return keywordsValue; + return keywordsValues; } /** diff --git a/magik-squid/src/main/java/nl/ramsolutions/sw/magik/api/MagikOperator.java b/magik-squid/src/main/java/nl/ramsolutions/sw/magik/api/MagikOperator.java index 4e72dcb00..358ca652a 100644 --- a/magik-squid/src/main/java/nl/ramsolutions/sw/magik/api/MagikOperator.java +++ b/magik-squid/src/main/java/nl/ramsolutions/sw/magik/api/MagikOperator.java @@ -23,6 +23,21 @@ public enum MagikOperator implements GrammarRuleKey { private final String value; + /** + * Get all operator values. + * + * @return Operator values + */ + public static String[] operatorValues() { + final String[] operatorValues = new String[MagikOperator.values().length]; + int idx = 0; + for (final MagikOperator punctuator : MagikOperator.values()) { + operatorValues[idx] = punctuator.getValue(); + idx++; + } + return operatorValues; + } + MagikOperator(final String value) { this.value = value; } diff --git a/magik-squid/src/main/java/nl/ramsolutions/sw/magik/api/MagikPunctuator.java b/magik-squid/src/main/java/nl/ramsolutions/sw/magik/api/MagikPunctuator.java index f537200eb..42802026b 100644 --- a/magik-squid/src/main/java/nl/ramsolutions/sw/magik/api/MagikPunctuator.java +++ b/magik-squid/src/main/java/nl/ramsolutions/sw/magik/api/MagikPunctuator.java @@ -21,6 +21,21 @@ public enum MagikPunctuator implements GrammarRuleKey { private final String value; + /** + * Get all punctuator values. + * + * @return Punctuator values + */ + public static String[] punctuatorValues() { + final String[] punctuatorValuess = new String[MagikPunctuator.values().length]; + int idx = 0; + for (final MagikPunctuator punctuator : MagikPunctuator.values()) { + punctuatorValuess[idx] = punctuator.getValue(); + idx++; + } + return punctuatorValuess; + } + MagikPunctuator(final String value) { this.value = value; } diff --git a/pom.xml b/pom.xml index bcfac942e..8eb6e5ae6 100644 --- a/pom.xml +++ b/pom.xml @@ -16,6 +16,7 @@ magik-language-server magik-lint magik-squid + magik-session-wrapper magik-typed-checks magik-typed-lint sonar-magik-plugin @@ -89,6 +90,8 @@ 0.23.1 1.9.0 2.11.0 + 3.28.0 + 3.26.3 stevenlooman @@ -161,6 +164,12 @@ ${commons-cli.version} + + org.jline + jline + ${jline.version} + + org.slf4j slf4j-api