[gjs/ewlsh/nova-repl: 2/2] Implement non-blocking Repl with Mainloop




commit 6a9aa73b4a4257ec369427b6c8fdbc3174f067ae
Author: Evan Welsh <contact evanwelsh com>
Date:   Sat Jan 29 19:08:13 2022 -0800

    Implement non-blocking Repl with Mainloop
    
    Implement Repl with Node.js readline#Implement non-blocking Repl with Mainloop

 gjs/console.cpp                   |  20 +-
 installed-tests/js/.eslintrc.yml  |   1 +
 installed-tests/js/meson.build    |   1 +
 installed-tests/js/testRepl.js    | 134 +++++++
 js.gresource.xml                  |   8 +
 modules/console.cpp               | 258 +++++--------
 modules/console.h                 |   4 +-
 modules/esm/_bootstrap/default.js |   3 +
 modules/esm/_bootstrap/repl.js    |  10 +
 modules/esm/_repl/callbacks.js    |  99 +++++
 modules/esm/_repl/primordials.js  |  36 ++
 modules/esm/_repl/utils.js        | 419 +++++++++++++++++++++
 modules/esm/repl.js               | 746 ++++++++++++++++++++++++++++++++++++++
 modules/modules.cpp               |   3 +-
 modules/script/console.js         |  12 +
 15 files changed, 1573 insertions(+), 181 deletions(-)
---
diff --git a/gjs/console.cpp b/gjs/console.cpp
index 49c822992..d51a98aca 100644
--- a/gjs/console.cpp
+++ b/gjs/console.cpp
@@ -30,6 +30,7 @@ static char *command = NULL;
 static gboolean print_version = false;
 static gboolean print_js_version = false;
 static gboolean debugging = false;
+static gboolean use_interactive_repl = false;
 static gboolean exec_as_module = false;
 static bool enable_profiler = false;
 
@@ -43,6 +44,8 @@ static GOptionEntry entries[] = {
     { "command", 'c', 0, G_OPTION_ARG_STRING, &command, "Program passed in as a string", "COMMAND" },
     { "coverage-prefix", 'C', 0, G_OPTION_ARG_STRING_ARRAY, &coverage_prefixes, "Add the prefix PREFIX to 
the list of files to generate coverage info for", "PREFIX" },
     { "coverage-output", 0, 0, G_OPTION_ARG_STRING, &coverage_output_path, "Write coverage output to a 
directory DIR. This option is mandatory when using --coverage-prefix", "DIR", },
+    { "interactive", 'i', 0, G_OPTION_ARG_NONE, &use_interactive_repl,
+        "Start the interactive repl"},
     { "include-path", 'I', 0, G_OPTION_ARG_STRING_ARRAY, &include_path, "Add the directory DIR to the list 
of directories to search for js files.", "DIR" },
     { "module", 'm', 0, G_OPTION_ARG_NONE, &exec_as_module, "Execute the file as a module." },
     { "profile", 0, G_OPTION_FLAG_OPTIONAL_ARG | G_OPTION_FLAG_FILENAME,
@@ -257,6 +260,7 @@ main(int argc, char **argv)
     print_js_version = false;
     debugging = false;
     exec_as_module = false;
+    use_interactive_repl = false;
     g_option_context_set_ignore_unknown_options(context, false);
     g_option_context_set_help_enabled(context, true);
     if (!g_option_context_parse_strv(context, &gjs_argv, &error)) {
@@ -293,9 +297,19 @@ main(int argc, char **argv)
             exit(1);
         }
 
-        script = g_strdup("const Console = imports.console; Console.interact();");
-        len = strlen(script);
-        filename = "<stdin>";
+        if (use_interactive_repl) {
+            script = nullptr;
+            exec_as_module = true;
+            filename =
+                "resource:///org/gnome/gjs/modules/esm/_bootstrap/repl.js";
+            interactive_mode = true;
+        } else {
+            script = g_strdup(
+                "const Console = imports.console; Console.interact();");
+            filename = "<stdin>";
+            len = strlen(script);
+        }
+
         program_name = gjs_argv[0];
         interactive_mode = true;
     } else {
diff --git a/installed-tests/js/.eslintrc.yml b/installed-tests/js/.eslintrc.yml
index a8d60f9a1..d920cbce5 100644
--- a/installed-tests/js/.eslintrc.yml
+++ b/installed-tests/js/.eslintrc.yml
@@ -31,6 +31,7 @@ overrides:
       - testESModules.js
       - testEncoding.js
       - testGLibLogWriter.js
+      - testRepl.js
       - testTimers.js
       - modules/importmeta.js
       - modules/exports.js
diff --git a/installed-tests/js/meson.build b/installed-tests/js/meson.build
index 6a5c40b50..17796eeb5 100644
--- a/installed-tests/js/meson.build
+++ b/installed-tests/js/meson.build
@@ -233,6 +233,7 @@ endif
 modules_tests = [
     'Async',
     'Console',
+    'Repl',
     'ESModules',
     'Encoding',
     'GLibLogWriter',
diff --git a/installed-tests/js/testRepl.js b/installed-tests/js/testRepl.js
new file mode 100644
index 000000000..d73871276
--- /dev/null
+++ b/installed-tests/js/testRepl.js
@@ -0,0 +1,134 @@
+import Gio from 'gi://Gio';
+
+import {AsyncReadline} from 'repl';
+
+function createReadline() {
+    return new AsyncReadline({stdin: null, stdout: null, stderr: null, prompt: '> ', enableColor: false});
+}
+
+function createReadlineWithStreams() {
+    const stdin = new Gio.MemoryInputStream();
+    const stdout = Gio.MemoryOutputStream.new_resizable();
+    const stderr = Gio.MemoryOutputStream.new_resizable();
+
+    const readline = new AsyncReadline({stdin, stdout, stderr, prompt: '> ', enableColor: false});
+
+    return {
+        readline, teardown() {
+            readline.cancel();
+
+            try {
+                readline.stdout.close(null);
+            } catch { }
+
+            try {
+                readline.stdin.close(null);
+            } catch { }
+
+            try {
+                readline.stderr.close(null);
+            } catch { }
+        },
+    };
+}
+
+function expectReadlineOutput({readline, input, output, keystrokes = 1}) {
+    return new Promise(resolve => {
+        let renderCount = 0;
+
+        readline.connect('render', () => {
+            if (++renderCount === keystrokes) {
+                readline.disconnectAll();
+
+                expect(readline.line).toBe(output);
+                resolve();
+            }
+        });
+
+        readline.stdin.add_bytes(new TextEncoder().encode(input));
+    });
+}
+
+describe('Repl', () => {
+    it('handles key events on stdin', async function () {
+        const {readline, teardown} = createReadlineWithStreams();
+
+        readline.prompt();
+
+        await expectReadlineOutput({
+            readline,
+            input: 'a',
+            output: 'a',
+        });
+
+        await expectReadlineOutput({
+            readline,
+            input: 'b',
+            output: 'ab',
+        });
+
+        await expectReadlineOutput({
+            readline,
+            input: '\x1b[D\x1b[Dcr',
+            output: 'crab',
+            keystrokes: 4,
+        });
+
+        teardown();
+    });
+});
+
+describe('Readline', () => {
+    it('can move word left', function () {
+        const readline = createReadline();
+
+        readline.line = 'lorem ipsum';
+        readline.cursor = readline.line.length;
+
+        readline.wordLeft();
+
+        expect(readline.line).toBe('lorem ipsum');
+        expect(readline.cursor).toBe('lorem '.length);
+    });
+
+
+    it('can move word right', function () {
+        const readline = createReadline();
+
+        readline.line = 'lorem ipsum';
+        readline.cursor = 0;
+
+        readline.wordRight();
+
+        expect(readline.line).toBe('lorem ipsum');
+        expect(readline.cursor).toBe('lorem '.length);
+    });
+
+    it('can delete word left', function () {
+        const readline = createReadline();
+
+        readline.line = 'lorem ipsum';
+        readline.cursor = readline.line.length;
+
+        readline.deleteWordLeft();
+
+        const output = 'lorem ';
+
+        expect(readline.line).toBe(output);
+        expect(readline.cursor).toBe(output.length);
+    });
+
+    it('can delete word right', function () {
+        const readline = createReadline();
+
+        readline.line = 'lorem ipsum';
+        readline.cursor = 0;
+
+        readline.deleteWordRight();
+
+        const output = 'ipsum';
+
+        expect(readline.line).toBe(output);
+        expect(readline.cursor).toBe(0);
+    });
+});
diff --git a/js.gresource.xml b/js.gresource.xml
index 4d3fde355..ba3212ed4 100644
--- a/js.gresource.xml
+++ b/js.gresource.xml
@@ -9,6 +9,7 @@
 
     <!-- ESM-based modules -->
     <file>modules/esm/_bootstrap/default.js</file>
+    <file>modules/esm/_bootstrap/repl.js</file>
 
     <file>modules/esm/_encoding/encoding.js</file>
     <file>modules/esm/_encoding/encodingMap.js</file>
@@ -16,10 +17,16 @@
 
     <file>modules/esm/_timers.js</file>
 
+    <file>modules/esm/_repl/utils.js</file>
+    <file>modules/esm/_repl/primordials.js</file>
+    <file>modules/esm/_repl/callbacks.js</file>
+  
     <file>modules/esm/cairo.js</file>
     <file>modules/esm/gettext.js</file>
     <file>modules/esm/console.js</file>
     <file>modules/esm/gi.js</file>
+    <file>modules/esm/events.js</file>
+    <file>modules/esm/repl.js</file>
     <file>modules/esm/system.js</file>
 
     <!-- Script-based Modules -->
@@ -33,6 +40,7 @@
 
     <file>modules/script/byteArray.js</file>
     <file>modules/script/cairo.js</file>
+    <file>modules/script/console.js</file>
     <file>modules/script/gettext.js</file>
     <file>modules/script/lang.js</file>
     <file>modules/script/_legacy.js</file>
diff --git a/modules/console.cpp b/modules/console.cpp
index c21942490..e03cd1453 100644
--- a/modules/console.cpp
+++ b/modules/console.cpp
@@ -3,54 +3,35 @@
 // SPDX-License-Identifier: MPL-1.1 OR GPL-2.0-or-later OR LGPL-2.1-or-later
 // SPDX-FileCopyrightText: 1998 Netscape Communications Corporation
 
-#include <config.h>  // for HAVE_READLINE_READLINE_H
-
-#ifdef HAVE_SIGNAL_H
-#    include <setjmp.h>
-#    include <signal.h>
-#    ifdef _WIN32
-#        define sigjmp_buf jmp_buf
-#        define siglongjmp(e, v) longjmp (e, v)
-#        define sigsetjmp(v, m) setjmp (v)
-#    endif
-#endif
-
-#ifdef HAVE_READLINE_READLINE_H
-#    include <stdio.h>  // include before readline/readline.h
-
-#    include <readline/history.h>
-#    include <readline/readline.h>
-#endif
+#include <config.h>
 
 #include <string>
 
 #include <glib.h>
-#include <glib/gprintf.h>  // for g_fprintf
+#include <stdio.h>
 
 #include <js/CallArgs.h>
 #include <js/CompilationAndEvaluation.h>
 #include <js/CompileOptions.h>
 #include <js/ErrorReport.h>
 #include <js/Exception.h>
+#include <js/PropertySpec.h>
 #include <js/RootingAPI.h>
 #include <js/SourceText.h>
 #include <js/TypeDecls.h>
-#include <js/Warnings.h>
+#include <js/Utility.h>
 #include <jsapi.h>  // for JS_IsExceptionPending, Exce...
 
-#include "gjs/atoms.h"
 #include "gjs/context-private.h"
+#include "gjs/jsapi-util-args.h"
 #include "gjs/jsapi-util.h"
 #include "modules/console.h"
+#include "util/console.h"
 
 namespace mozilla {
 union Utf8Unit;
 }
 
-static void gjs_console_warning_reporter(JSContext*, JSErrorReport* report) {
-    JS::PrintError(stderr, report, /* reportWarnings = */ true);
-}
-
 /* Based on js::shell::AutoReportException from SpiderMonkey. */
 class AutoReportException {
     JSContext *m_cx;
@@ -90,69 +71,20 @@ public:
     }
 };
 
-
-// Adapted from https://stackoverflow.com/a/17035073/172999
-class AutoCatchCtrlC {
-#ifdef HAVE_SIGNAL_H
-    void (*m_prev_handler)(int);
-
-    static void handler(int signal) {
-        if (signal == SIGINT)
-            siglongjmp(jump_buffer, 1);
-    }
-
- public:
-    static sigjmp_buf jump_buffer;
-
-    AutoCatchCtrlC() {
-        m_prev_handler = signal(SIGINT, &AutoCatchCtrlC::handler);
-    }
-
-    ~AutoCatchCtrlC() {
-        if (m_prev_handler != SIG_ERR)
-            signal(SIGINT, m_prev_handler);
-    }
-
-    void raise_default() {
-        if (m_prev_handler != SIG_ERR)
-            signal(SIGINT, m_prev_handler);
-        raise(SIGINT);
-    }
-#endif  // HAVE_SIGNAL_H
-};
-
-#ifdef HAVE_SIGNAL_H
-sigjmp_buf AutoCatchCtrlC::jump_buffer;
-#endif  // HAVE_SIGNAL_H
-
 [[nodiscard]] static bool gjs_console_readline(char** bufp,
                                                const char* prompt) {
-#ifdef HAVE_READLINE_READLINE_H
-    char *line;
-    line = readline(prompt);
-    if (!line)
-        return false;
-    if (line[0] != '\0')
-        add_history(line);
-    *bufp = line;
-#else   // !HAVE_READLINE_READLINE_H
     char line[256];
     fprintf(stdout, "%s", prompt);
     fflush(stdout);
     if (!fgets(line, sizeof line, stdin))
         return false;
     *bufp = g_strdup(line);
-#endif  // !HAVE_READLINE_READLINE_H
     return true;
 }
 
-/* Return value of false indicates an uncatchable exception, rather than any
- * exception. (This is because the exception should be auto-printed around the
- * invocation of this function.)
- */
-[[nodiscard]] static bool gjs_console_eval_and_print(JSContext* cx,
-                                                     const std::string& bytes,
-                                                     int lineno) {
+[[nodiscard]] static bool gjs_console_eval(JSContext* cx,
+                                           const std::string& bytes, int lineno,
+                                           JS::MutableHandleValue result) {
     JS::SourceText<mozilla::Utf8Unit> source;
     if (!source.init(cx, bytes.c_str(), bytes.size(),
                      JS::SourceOwnership::Borrowed))
@@ -161,116 +93,86 @@ sigjmp_buf AutoCatchCtrlC::jump_buffer;
     JS::CompileOptions options(cx);
     options.setFileAndLine("typein", lineno);
 
-    JS::RootedValue result(cx);
-    if (!JS::Evaluate(cx, options, source, &result)) {
-        if (!JS_IsExceptionPending(cx))
-            return false;
-    }
+    JS::RootedValue eval_result(cx);
+    if (!JS::Evaluate(cx, options, source, &eval_result))
+        return false;
 
     GjsContextPrivate* gjs = GjsContextPrivate::from_cx(cx);
     gjs->schedule_gc_if_needed();
 
-    if (result.isUndefined())
+    result.set(eval_result);
+    return true;
+}
+
+GJS_JSAPI_RETURN_CONVENTION
+static bool gjs_console_interact(JSContext* context, unsigned argc,
+                                 JS::Value* vp) {
+    JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
+    JS::RootedObject global(context, gjs_get_import_global(context));
+
+    JS::UniqueChars prompt;
+    if (!gjs_parse_call_args(context, "interact", args, "s", "prompt", &prompt))
+        return false;
+
+    GjsAutoChar buffer;
+    if (!gjs_console_readline(buffer.out(), prompt.get())) {
         return true;
+    }
 
-    g_fprintf(stdout, "%s\n", gjs_value_debug_string(cx, result).c_str());
+    return gjs_string_from_utf8(context, buffer, args.rval());
+}
+
+GJS_JSAPI_RETURN_CONVENTION
+static bool gjs_console_enable_raw_mode(JSContext* cx, unsigned argc,
+                                        JS::Value* vp) {
+    JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
+    if (!gjs_parse_call_args(cx, "enableRawMode", args, ""))
+        return false;
+
+    args.rval().setBoolean(Gjs::Console::enable_raw_mode());
     return true;
 }
 
 GJS_JSAPI_RETURN_CONVENTION
-static bool
-gjs_console_interact(JSContext *context,
-                     unsigned   argc,
-                     JS::Value *vp)
-{
-    JS::CallArgs argv = JS::CallArgsFromVp(argc, vp);
-    bool eof, exit_warning;
-    JS::RootedObject global(context, gjs_get_import_global(context));
-    char* temp_buf;
+static bool gjs_console_disable_raw_mode(JSContext* cx, unsigned argc,
+                                         JS::Value* vp) {
+    JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
+    if (!gjs_parse_call_args(cx, "disableRawMode", args, ""))
+        return false;
+
+    args.rval().setBoolean(Gjs::Console::disable_raw_mode());
+    return true;
+}
+
+GJS_JSAPI_RETURN_CONVENTION
+static bool gjs_console_eval_js(JSContext* cx, unsigned argc, JS::Value* vp) {
+    JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
+    JS::UniqueChars expr;
     int lineno;
-    int startline;
-
-#ifndef HAVE_READLINE_READLINE_H
-    int rl_end = 0;  // nonzero if using readline and any text is typed in
-#endif
-
-    JS::SetWarningReporter(context, gjs_console_warning_reporter);
-
-    AutoCatchCtrlC ctrl_c;
-
-    // Separate initialization from declaration because of possible overwriting
-    // when siglongjmp() jumps into this function
-    eof = exit_warning = false;
-    temp_buf = nullptr;
-    lineno = 1;
-    do {
-        /*
-         * Accumulate lines until we get a 'compilable unit' - one that either
-         * generates an error (before running out of source) or that compiles
-         * cleanly.  This should be whenever we get a complete statement that
-         * coincides with the end of a line.
-         */
-        startline = lineno;
-        std::string buffer;
-        do {
-#ifdef HAVE_SIGNAL_H
-            // sigsetjmp() returns 0 if control flow encounters it normally, and
-            // nonzero if it's been jumped to. In the latter case, use a while
-            // loop so that we call sigsetjmp() a second time to reinit the jump
-            // buffer.
-            while (sigsetjmp(AutoCatchCtrlC::jump_buffer, 1) != 0) {
-                g_fprintf(stdout, "\n");
-                if (buffer.empty() && rl_end == 0) {
-                    if (!exit_warning) {
-                        g_fprintf(stdout,
-                                  "(To exit, press Ctrl+C again or Ctrl+D)\n");
-                        exit_warning = true;
-                    } else {
-                        ctrl_c.raise_default();
-                    }
-                } else {
-                    exit_warning = false;
-                }
-                buffer.clear();
-                startline = lineno = 1;
-            }
-#endif  // HAVE_SIGNAL_H
-
-            if (!gjs_console_readline(
-                    &temp_buf, startline == lineno ? "gjs> " : ".... ")) {
-                eof = true;
-                break;
-            }
-            buffer += temp_buf;
-            buffer += "\n";
-            g_free(temp_buf);
-            lineno++;
-        } while (!JS_Utf8BufferIsCompilableUnit(context, global, buffer.c_str(),
-                                                buffer.size()));
-
-        bool ok;
-        {
-            AutoReportException are(context);
-            ok = gjs_console_eval_and_print(context, buffer, startline);
-        }
-        exit_warning = false;
-
-        GjsContextPrivate* gjs = GjsContextPrivate::from_cx(context);
-        ok = gjs->run_jobs_fallible() && ok;
-
-        if (!ok) {
-            /* If this was an uncatchable exception, throw another uncatchable
-             * exception on up to the surrounding JS::Evaluate() in main(). This
-             * happens when you run gjs-console and type imports.system.exit(0);
-             * at the prompt. If we don't throw another uncatchable exception
-             * here, then it's swallowed and main() won't exit. */
-            return false;
-        }
-    } while (!eof);
+    if (!gjs_parse_call_args(cx, "eval", args, "si", "expression", &expr,
+                             "lineNumber", &lineno))
+        return false;
+
+    return gjs_console_eval(cx, std::string(expr.get()), lineno, args.rval());
+}
 
-    g_fprintf(stdout, "\n");
+GJS_JSAPI_RETURN_CONVENTION
+static bool gjs_console_is_valid_js(JSContext* cx, unsigned argc,
+                                    JS::Value* vp) {
+    JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
+    JS::RootedString str(cx);
+    if (!gjs_parse_call_args(cx, "isValid", args, "S", "code", &str))
+        return false;
 
-    argv.rval().setUndefined();
+    JS::UniqueChars code;
+    size_t code_len;
+    if (!gjs_string_to_utf8_n(cx, str, &code, &code_len))
+        return false;
+
+    JS::RootedObject global(cx, gjs_get_import_global(cx));
+
+    args.rval().setBoolean(
+        JS_Utf8BufferIsCompilableUnit(cx, global, code.get(), code_len));
     return true;
 }
 
@@ -291,9 +193,15 @@ static bool gjs_console_clear_terminal(JSContext* cx, unsigned argc,
 }
 
 static JSFunctionSpec console_module_funcs[] = {
+    JS_FN("interact", gjs_console_interact, 1, GJS_MODULE_PROP_FLAGS),
+    JS_FN("enableRawMode", gjs_console_enable_raw_mode, 0,
+          GJS_MODULE_PROP_FLAGS),
+    JS_FN("disableRawMode", gjs_console_disable_raw_mode, 0,
+          GJS_MODULE_PROP_FLAGS),
+    JS_FN("eval", gjs_console_eval_js, 2, GJS_MODULE_PROP_FLAGS),
+    JS_FN("isValid", gjs_console_is_valid_js, 1, GJS_MODULE_PROP_FLAGS),
     JS_FN("clearTerminal", gjs_console_clear_terminal, 1,
           GJS_MODULE_PROP_FLAGS),
-    JS_FN("interact", gjs_console_interact, 1, GJS_MODULE_PROP_FLAGS),
     JS_FS_END,
 };
 
diff --git a/modules/console.h b/modules/console.h
index 73ed4c0ee..7df6a02d2 100644
--- a/modules/console.h
+++ b/modules/console.h
@@ -12,7 +12,7 @@
 #include "gjs/macros.h"
 
 GJS_JSAPI_RETURN_CONVENTION
-bool gjs_define_console_stuff(JSContext              *context,
-                              JS::MutableHandleObject module);
+bool gjs_define_console_private_stuff(JSContext* context,
+                                      JS::MutableHandleObject module);
 
 #endif  // MODULES_CONSOLE_H_
diff --git a/modules/esm/_bootstrap/default.js b/modules/esm/_bootstrap/default.js
index ff1f28bfc..24abd76fd 100644
--- a/modules/esm/_bootstrap/default.js
+++ b/modules/esm/_bootstrap/default.js
@@ -9,3 +9,6 @@ import '_encoding/encoding';
 import 'console';
 // Bootstrap the Timers API
 import '_timers';
+
+// Install the Repl constructor for Console.interact()
+import 'repl';
diff --git a/modules/esm/_bootstrap/repl.js b/modules/esm/_bootstrap/repl.js
new file mode 100644
index 000000000..cb8e98eb0
--- /dev/null
+++ b/modules/esm/_bootstrap/repl.js
@@ -0,0 +1,10 @@
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2021 Evan Welsh <contact evanwelsh com>
+
+import {Repl} from 'repl';
+
+const repl = new Repl();
+
+globalThis.repl = repl;
+
+repl.start();
diff --git a/modules/esm/_repl/callbacks.js b/modules/esm/_repl/callbacks.js
new file mode 100644
index 000000000..37910e03f
--- /dev/null
+++ b/modules/esm/_repl/callbacks.js
@@ -0,0 +1,99 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: Node.js contributors. All rights reserved.
+
+/* eslint-disable */
+
+import {primordials} from './primordials.js';
+
+const {
+    NumberIsNaN,
+} = primordials;
+
+const ERR_INVALID_ARG_VALUE = Error;
+const ERR_INVALID_CURSOR_POS = Error;
+
+// Adapted from 
https://github.com/nodejs/node/blob/56679eb53044b03e4da0f7420774d54f0c550eec/lib/internal/readline/callbacks.js
+
+import {CSI} from './utils.js';
+
+const {
+    kClearLine,
+    kClearToLineBeginning,
+    kClearToLineEnd,
+} = CSI;
+
+
+/**
+ * moves the cursor to the x and y coordinate on the given stream
+ */
+
+/**
+ * @param x
+ * @param y
+ */
+function cursorTo(x, y) {
+    if (NumberIsNaN(x))
+        throw new ERR_INVALID_ARG_VALUE('x', x);
+    if (NumberIsNaN(y))
+        throw new ERR_INVALID_ARG_VALUE('y', y);
+
+    if (typeof x !== 'number')
+        throw new ERR_INVALID_CURSOR_POS();
+
+    const data = typeof y !== 'number' ? CSI`${x + 1}G` : CSI`${y + 1};${x + 1}H`;
+    return data;
+}
+
+/**
+ * moves the cursor relative to its current location
+ */
+
+/**
+ * @param dx
+ * @param dy
+ */
+function moveCursor(dx, dy) {
+    let data = '';
+
+    if (dx < 0)
+        data += CSI`${-dx}D`;
+    else if (dx > 0)
+        data += CSI`${dx}C`;
+
+
+    if (dy < 0)
+        data += CSI`${-dy}A`;
+    else if (dy > 0)
+        data += CSI`${dy}B`;
+
+
+    return data;
+}
+
+/**
+ * clears the current line the cursor is on:
+ *   -1 for left of the cursor
+ *   +1 for right of the cursor
+ *    0 for the entire line
+ */
+
+/**
+ * @param dir
+ */
+function clearLine(dir) {
+    const type =
+        dir < 0 ? kClearToLineBeginning : dir > 0 ? kClearToLineEnd : kClearLine;
+    return type;
+}
+
+
+
+/**
+ * clears the screen from the current position of the cursor down
+ */
+
+export {
+    clearLine,
+    cursorTo,
+    moveCursor
+};
diff --git a/modules/esm/_repl/primordials.js b/modules/esm/_repl/primordials.js
new file mode 100644
index 000000000..1afa892b5
--- /dev/null
+++ b/modules/esm/_repl/primordials.js
@@ -0,0 +1,36 @@
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// 2022 Evan Welsh <contact evanwelsh com>
+
+/**
+ * @typedef {F extends ((...args: infer Args) => infer Result) ?  ((instance: I, ...args: Args) => Result) : 
never} UncurriedFunction
+ * @template I
+ * @template F
+ */
+
+/**
+ * @template {Record<string, any>} T
+ * @template {keyof T} K
+ * @param {T} [type] the instance type for the function
+ * @param {K} key the function to curry
+ * @returns {UncurriedFunction<T, T[K]>}
+ */
+function uncurryThis(type, key) {
+    const func = type[key];
+    return (instance, ...args) => func.apply(instance, args);
+}
+
+const primordials = {
+    ArrayPrototypeSlice: uncurryThis(Array.prototype, 'slice'),
+    ArrayPrototypeSort: uncurryThis(Array.prototype, 'sort'),
+    RegExpPrototypeTest: uncurryThis(RegExp.prototype, 'test'),
+    StringFromCharCode: String.fromCharCode,
+    StringPrototypeCharCodeAt: uncurryThis(String.prototype, 'charCodeAt'),
+    StringPrototypeCodePointAt: uncurryThis(String.prototype, 'codePointAt'),
+    StringPrototypeMatch: uncurryThis(String.prototype, 'match'),
+    StringPrototypeSlice: uncurryThis(String.prototype, 'slice'),
+    StringPrototypeToLowerCase: uncurryThis(String.prototype, 'toLowerCase'),
+    Symbol,
+    NumberIsNaN: Number.isNaN,
+};
+
+export {primordials, uncurryThis};
diff --git a/modules/esm/_repl/utils.js b/modules/esm/_repl/utils.js
new file mode 100644
index 000000000..9934093b9
--- /dev/null
+++ b/modules/esm/_repl/utils.js
@@ -0,0 +1,419 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: Node.js contributors. All rights reserved.
+
+/* eslint-disable */
+
+import {primordials} from './primordials.js';
+
+/**
+ * @typedef {object} KeyDefinition
+ * @property {string} name
+ * @property {string} sequence
+ * @property {boolean} ctrl
+ * @property {boolean} meta
+ * @property {boolean} shift
+ */
+
+/**
+ * @typedef {object} KeyEvent
+ * @property {string | undefined} sequence
+ * @property {KeyDefinition} key
+ */
+
+// Copied from 
https://github.com/nodejs/node/blob/56679eb53044b03e4da0f7420774d54f0c550eec/lib/internal/readline/utils.js
+
+const {
+  ArrayPrototypeSlice,
+  ArrayPrototypeSort,
+  RegExpPrototypeTest,
+  StringFromCharCode,
+  StringPrototypeCharCodeAt,
+  StringPrototypeCodePointAt,
+  StringPrototypeMatch,
+  StringPrototypeSlice,
+  StringPrototypeToLowerCase,
+  Symbol,
+} = primordials;
+
+const kUTF16SurrogateThreshold = 0x10000; // 2 ** 16
+const kEscape = '\x1b';
+const kSubstringSearch = Symbol('kSubstringSearch');
+
+function CSI(strings, ...args) {
+  let ret = `${kEscape}[`;
+  for (let n = 0; n < strings.length; n++) {
+    ret += strings[n];
+    if (n < args.length)
+      ret += args[n];
+  }
+  return ret;
+}
+
+CSI.kEscape = kEscape;
+CSI.kClearToLineBeginning = CSI`1K`;
+CSI.kClearToLineEnd = CSI`0K`;
+CSI.kClearLine = CSI`2K`;
+CSI.kClearScreenDown = CSI`0J`;
+
+// TODO(BridgeAR): Treat combined characters as single character, i.e,
+// 'a\u0301' and '\u0301a' (both have the same visual output).
+// Check Canonical_Combining_Class in
+// http://userguide.icu-project.org/strings/properties
+function charLengthLeft(str, i) {
+  if (i <= 0)
+    return 0;
+  if ((i > 1 &&
+      StringPrototypeCodePointAt(str, i - 2) >= kUTF16SurrogateThreshold) ||
+      StringPrototypeCodePointAt(str, i - 1) >= kUTF16SurrogateThreshold) {
+    return 2;
+  }
+  return 1;
+}
+
+function charLengthAt(str, i) {
+  if (str.length <= i) {
+    // Pretend to move to the right. This is necessary to autocomplete while
+    // moving to the right.
+    return 1;
+  }
+  return StringPrototypeCodePointAt(str, i) >= kUTF16SurrogateThreshold ? 2 : 1;
+}
+
+/**
+  Some patterns seen in terminal key escape codes, derived from combos seen
+  at http://www.midnight-commander.org/browser/lib/tty/key.c
+
+  ESC letter
+  ESC [ letter
+  ESC [ modifier letter
+  ESC [ 1 ; modifier letter
+  ESC [ num char
+  ESC [ num ; modifier char
+  ESC O letter
+  ESC O modifier letter
+  ESC O 1 ; modifier letter
+  ESC N letter
+  ESC [ [ num ; modifier char
+  ESC [ [ 1 ; modifier letter
+  ESC ESC [ num char
+  ESC ESC O letter
+
+  - char is usually ~ but $ and ^ also happen with rxvt
+  - modifier is 1 +
+                (shift     * 1) +
+                (left_alt  * 2) +
+                (ctrl      * 4) +
+                (right_alt * 8)
+  - two leading ESCs apparently mean the same as one leading ESC
+ *
+ * @param callback
+ * @returns {Generator<KeyEvent | undefined, never, string>}
+ */
+function* emitKeys(callback) {
+  while (true) {
+    let ch = yield;
+    let s = ch;
+    let escaped = false;
+    const key = {
+      sequence: null,
+      name: undefined,
+      ctrl: false,
+      meta: false,
+      shift: false
+    };
+
+    if (ch === kEscape) {
+      escaped = true;
+      s += (ch = yield);
+
+      if (ch === kEscape) {
+        s += (ch = yield);
+      }
+    }
+
+    if (escaped && (ch === 'O' || ch === '[')) {
+      // ANSI escape sequence
+      let code = ch;
+      let modifier = 0;
+
+      if (ch === 'O') {
+        // ESC O letter
+        // ESC O modifier letter
+        s += (ch = yield);
+
+        if (ch >= '0' && ch <= '9') {
+          modifier = (ch >> 0) - 1;
+          s += (ch = yield);
+        }
+
+        code += ch;
+      } else if (ch === '[') {
+        // ESC [ letter
+        // ESC [ modifier letter
+        // ESC [ [ modifier letter
+        // ESC [ [ num char
+        s += (ch = yield);
+
+        if (ch === '[') {
+          // \x1b[[A
+          //      ^--- escape codes might have a second bracket
+          code += ch;
+          s += (ch = yield);
+        }
+
+        /*
+         * Here and later we try to buffer just enough data to get
+         * a complete ascii sequence.
+         *
+         * We have basically two classes of ascii characters to process:
+         *
+         *
+         * 1. `\x1b[24;5~` should be parsed as { code: '[24~', modifier: 5 }
+         *
+         * This particular example is featuring Ctrl+F12 in xterm.
+         *
+         *  - `;5` part is optional, e.g. it could be `\x1b[24~`
+         *  - first part can contain one or two digits
+         *
+         * So the generic regexp is like /^\d\d?(;\d)?[~^$]$/
+         *
+         *
+         * 2. `\x1b[1;5H` should be parsed as { code: '[H', modifier: 5 }
+         *
+         * This particular example is featuring Ctrl+Home in xterm.
+         *
+         *  - `1;5` part is optional, e.g. it could be `\x1b[H`
+         *  - `1;` part is optional, e.g. it could be `\x1b[5H`
+         *
+         * So the generic regexp is like /^((\d;)?\d)?[A-Za-z]$/
+         *
+         */
+        const cmdStart = s.length - 1;
+
+        // Skip one or two leading digits
+        if (ch >= '0' && ch <= '9') {
+          s += (ch = yield);
+
+          if (ch >= '0' && ch <= '9') {
+            s += (ch = yield);
+          }
+        }
+
+        // skip modifier
+        if (ch === ';') {
+          s += (ch = yield);
+
+          if (ch >= '0' && ch <= '9') {
+            s += yield;
+          }
+        }
+
+        /*
+         * We buffered enough data, now trying to extract code
+         * and modifier from it
+         */
+        const cmd = StringPrototypeSlice(s, cmdStart);
+        let match;
+
+        if ((match = StringPrototypeMatch(cmd, /^(\d\d?)(;(\d))?([~^$])$/))) {
+          code += match[1] + match[4];
+          modifier = (match[3] || 1) - 1;
+        } else if (
+          (match = StringPrototypeMatch(cmd, /^((\d;)?(\d))?([A-Za-z])$/))
+        ) {
+          code += match[4];
+          modifier = (match[3] || 1) - 1;
+        } else {
+          code += cmd;
+        }
+      }
+
+      // Parse the key modifier
+      key.ctrl = !!(modifier & 4);
+      key.meta = !!(modifier & 10);
+      key.shift = !!(modifier & 1);
+      key.code = code;
+
+      // Parse the key itself
+      switch (code) {
+        /* xterm/gnome ESC [ letter (with modifier) */
+        case '[P': key.name = 'f1'; break;
+        case '[Q': key.name = 'f2'; break;
+        case '[R': key.name = 'f3'; break;
+        case '[S': key.name = 'f4'; break;
+
+        /* xterm/gnome ESC O letter (without modifier) */
+        case 'OP': key.name = 'f1'; break;
+        case 'OQ': key.name = 'f2'; break;
+        case 'OR': key.name = 'f3'; break;
+        case 'OS': key.name = 'f4'; break;
+
+        /* xterm/rxvt ESC [ number ~ */
+        case '[11~': key.name = 'f1'; break;
+        case '[12~': key.name = 'f2'; break;
+        case '[13~': key.name = 'f3'; break;
+        case '[14~': key.name = 'f4'; break;
+
+        /* from Cygwin and used in libuv */
+        case '[[A': key.name = 'f1'; break;
+        case '[[B': key.name = 'f2'; break;
+        case '[[C': key.name = 'f3'; break;
+        case '[[D': key.name = 'f4'; break;
+        case '[[E': key.name = 'f5'; break;
+
+        /* common */
+        case '[15~': key.name = 'f5'; break;
+        case '[17~': key.name = 'f6'; break;
+        case '[18~': key.name = 'f7'; break;
+        case '[19~': key.name = 'f8'; break;
+        case '[20~': key.name = 'f9'; break;
+        case '[21~': key.name = 'f10'; break;
+        case '[23~': key.name = 'f11'; break;
+        case '[24~': key.name = 'f12'; break;
+
+        /* xterm ESC [ letter */
+        case '[A': key.name = 'up'; break;
+        case '[B': key.name = 'down'; break;
+        case '[C': key.name = 'right'; break;
+        case '[D': key.name = 'left'; break;
+        case '[E': key.name = 'clear'; break;
+        case '[F': key.name = 'end'; break;
+        case '[H': key.name = 'home'; break;
+
+        /* xterm/gnome ESC O letter */
+        case 'OA': key.name = 'up'; break;
+        case 'OB': key.name = 'down'; break;
+        case 'OC': key.name = 'right'; break;
+        case 'OD': key.name = 'left'; break;
+        case 'OE': key.name = 'clear'; break;
+        case 'OF': key.name = 'end'; break;
+        case 'OH': key.name = 'home'; break;
+
+        /* xterm/rxvt ESC [ number ~ */
+        case '[1~': key.name = 'home'; break;
+        case '[2~': key.name = 'insert'; break;
+        case '[3~': key.name = 'delete'; break;
+        case '[4~': key.name = 'end'; break;
+        case '[5~': key.name = 'pageup'; break;
+        case '[6~': key.name = 'pagedown'; break;
+
+        /* putty */
+        case '[[5~': key.name = 'pageup'; break;
+        case '[[6~': key.name = 'pagedown'; break;
+
+        /* rxvt */
+        case '[7~': key.name = 'home'; break;
+        case '[8~': key.name = 'end'; break;
+
+        /* rxvt keys with modifiers */
+        case '[a': key.name = 'up'; key.shift = true; break;
+        case '[b': key.name = 'down'; key.shift = true; break;
+        case '[c': key.name = 'right'; key.shift = true; break;
+        case '[d': key.name = 'left'; key.shift = true; break;
+        case '[e': key.name = 'clear'; key.shift = true; break;
+
+        case '[2$': key.name = 'insert'; key.shift = true; break;
+        case '[3$': key.name = 'delete'; key.shift = true; break;
+        case '[5$': key.name = 'pageup'; key.shift = true; break;
+        case '[6$': key.name = 'pagedown'; key.shift = true; break;
+        case '[7$': key.name = 'home'; key.shift = true; break;
+        case '[8$': key.name = 'end'; key.shift = true; break;
+
+        case 'Oa': key.name = 'up'; key.ctrl = true; break;
+        case 'Ob': key.name = 'down'; key.ctrl = true; break;
+        case 'Oc': key.name = 'right'; key.ctrl = true; break;
+        case 'Od': key.name = 'left'; key.ctrl = true; break;
+        case 'Oe': key.name = 'clear'; key.ctrl = true; break;
+
+        case '[2^': key.name = 'insert'; key.ctrl = true; break;
+        case '[3^': key.name = 'delete'; key.ctrl = true; break;
+        case '[5^': key.name = 'pageup'; key.ctrl = true; break;
+        case '[6^': key.name = 'pagedown'; key.ctrl = true; break;
+        case '[7^': key.name = 'home'; key.ctrl = true; break;
+        case '[8^': key.name = 'end'; key.ctrl = true; break;
+
+        /* misc. */
+        case '[Z': key.name = 'tab'; key.shift = true; break;
+        default: key.name = 'undefined'; break;
+      }
+    } else if (ch === '\r') {
+      // carriage return
+      key.name = 'return';
+      key.meta = escaped;
+    } else if (ch === '\n') {
+      // Enter, should have been called linefeed
+      key.name = 'enter';
+      key.meta = escaped;
+    } else if (ch === '\t') {
+      // tab
+      key.name = 'tab';
+      key.meta = escaped;
+    } else if (ch === '\b' || ch === '\x7f') {
+      // backspace or ctrl+h
+      key.name = 'backspace';
+      key.meta = escaped;
+    } else if (ch === kEscape) {
+      // escape key
+      key.name = 'escape';
+      key.meta = escaped;
+    } else if (ch === ' ') {
+      key.name = 'space';
+      key.meta = escaped;
+    } else if (!escaped && ch <= '\x1a') {
+      // ctrl+letter
+      key.name = StringFromCharCode(
+        StringPrototypeCharCodeAt(ch) + StringPrototypeCharCodeAt('a') - 1
+      );
+      key.ctrl = true;
+    } else if (RegExpPrototypeTest(/^[0-9A-Za-z]$/, ch)) {
+      // Letter, number, shift+letter
+      key.name = StringPrototypeToLowerCase(ch);
+      key.shift = RegExpPrototypeTest(/^[A-Z]$/, ch);
+      key.meta = escaped;
+    } else if (escaped) {
+      // Escape sequence timeout
+      key.name = ch.length ? undefined : 'escape';
+      key.meta = true;
+    }
+
+    key.sequence = s;
+
+    if (s.length !== 0 && (key.name !== undefined || escaped)) {
+      /* Named character or sequence */
+      // stream.emit('keypress', escaped ? undefined : s, key);
+      // GJS:
+      callback(escaped ? undefined : s, key);
+    } else if (charLengthAt(s, 0) === s.length) {
+      /* Single unnamed character, e.g. "." */
+      // stream.emit('keypress', s, key);
+      // GJS:
+      callback(s, key);
+    }
+    /* Unrecognized or broken escape sequence, don't emit anything */
+  }
+}
+
+// This runs in O(n log n).
+function commonPrefix(strings) {
+  if (strings.length === 1) {
+    return strings[0];
+  }
+  const sorted = ArrayPrototypeSort(ArrayPrototypeSlice(strings));
+  const min = sorted[0];
+  const max = sorted[sorted.length - 1];
+  for (let i = 0; i < min.length; i++) {
+    if (min[i] !== max[i]) {
+      return StringPrototypeSlice(min, 0, i);
+    }
+  }
+  return min;
+}
+
+export {
+  charLengthAt,
+  charLengthLeft,
+  commonPrefix,
+  emitKeys,
+  kSubstringSearch,
+  CSI
+};
diff --git a/modules/esm/repl.js b/modules/esm/repl.js
new file mode 100644
index 000000000..31cbcb6a7
--- /dev/null
+++ b/modules/esm/repl.js
@@ -0,0 +1,746 @@
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2021 Evan Welsh <contact evanwelsh com>
+
+import GLib from 'gi://GLib';
+let Gio;
+
+import {emitKeys, CSI} from './_repl/utils.js';
+import {cursorTo} from './_repl/callbacks.js';
+
+import {EventEmitter} from './events.js';
+
+const cursorHide = CSI`?25l`;
+const cursorShow = CSI`?25h`;
+
+const Console = import.meta.importSync('_consoleNative');
+
+// TODO: Integrate with new printer once it is merged...
+
+/**
+ * @param {any} value any valid JavaScript value to stringify
+ */
+function toString(value) {
+    if (typeof value === 'function')
+        return value.toString();
+
+    if (typeof value === 'object') {
+        // JSON will error if the object
+        // has circular references or cannot
+        // be converted.
+        try {
+            return JSON.stringify(value);
+        } catch {
+        }
+    }
+    return `${value}`;
+}
+
+/**
+ * @param {string} string the string to splice
+ * @param {number} index the index to start removing characters at
+ * @param {number} removeCount how many characters to remove
+ * @param {string} replacement a string to replace the removed characters with
+ * @returns {string}
+ */
+function StringSplice(string, index, removeCount = 0, replacement = '') {
+    return string.slice(0, index) + replacement + string.slice(index + removeCount);
+}
+
+export class Readline extends EventEmitter {
+    #prompt;
+    #input = '';
+    #cancelling = false;
+
+    /**
+     * Store pending lines
+     *
+     * @example
+     * gjs > 'a pending line...
+     * ..... '
+     *
+     * @type {string[]}
+     */
+    #pendingInputLines = [];
+
+    /**
+     * @param {object} options _
+     * @param {string} options.prompt the prompt to print prior to the line
+     */
+    constructor({prompt}) {
+        ({Gio} = imports.gi);
+
+        super();
+
+        this.#prompt = prompt;
+    }
+
+    [Symbol.toStringTag]() {
+        return 'Readline';
+    }
+
+    get cancelled() {
+        return this.#cancelling;
+    }
+
+    get line() {
+        return this.#input;
+    }
+
+    set line(value) {
+        this.#input = value;
+    }
+
+    validate(input) {
+        return Console.isValid(input);
+    }
+
+    processLine() {
+        const {line} = this;
+        // Rebuild the input...
+        const js = [...this.#pendingInputLines, line].join('\n');
+
+        // Reset state...
+        this.#input = '';
+
+        // Only trigger input if this is a compilable unit...
+        if (this.validate(js)) {
+            // Reset lines before input is triggered
+            this.#pendingInputLines = [];
+            this.emit('line', js);
+        } else {
+            // Buffer the input until a compilable unit is found...
+            this.#pendingInputLines.push(line);
+        }
+    }
+
+    get inputPrompt() {
+        if (this.#pendingInputLines.length > 0) {
+            // Create a prefix like '... '
+            return ' '.padStart(4, '.');
+        }
+
+        return this.#prompt;
+    }
+
+    print(_output) {
+    }
+
+    render() {
+    }
+
+    prompt() {
+        this.#cancelling = false;
+    }
+
+    exit() {
+    }
+
+    cancel() {
+        this.#cancelling = true;
+    }
+}
+
+export class AsyncReadline extends Readline {
+    #exitWarning;
+
+    #parser;
+
+    #cancellable = null;
+
+    /**
+     * Store previously inputted lines
+     *
+     * @type {string[]}
+     */
+    #history = [];
+
+    /**
+     * The cursor's current column position.
+     */
+    #cursorColumn = 0;
+
+    /**
+     * @param {object} options _
+     * @param {Gio.UnixOutputStream} options.stdin the input stream to treat as stdin
+     * @param {Gio.UnixOutputStream} options.stdout the output stream to treat as stdout
+     * @param {Gio.UnixOutputStream} options.stderr the output stream to treat as stderr
+     * @param {boolean} options.enableColor whether to print ANSI color codes
+     * @param {string} options.prompt the prompt to print prior to the line
+     */
+    constructor({stdin, stdout, stderr, enableColor, prompt}) {
+        super({prompt});
+
+        this.stdin = stdin;
+        this.stdout = stdout;
+        this.stderr = stderr;
+        this.enableColor = enableColor;
+
+
+        this.#parser = emitKeys(this.#onKeyPress.bind(this));
+        this.#parser.next();
+
+
+        this.#exitWarning = false;
+    }
+
+    get line() {
+        if (this.historyIndex > -1)
+            return this.#history[this.historyIndex];
+
+
+        return super.line;
+    }
+
+    set line(value) {
+        if (this.historyIndex > -1) {
+            this.#history[this.historyIndex] = value;
+            return;
+        }
+
+        super.line = value;
+    }
+
+    exit() {
+        if (this.#exitWarning) {
+            this.#exitWarning = false;
+            this.emit('exit');
+        } else {
+            this.#exitWarning = true;
+            this.print('\n(To exit, press Ctrl+C again or Ctrl+D)\n');
+        }
+    }
+
+    historyUp() {
+        if (this.historyIndex < this.#history.length - 1) {
+            this.historyIndex++;
+            this.cursor = -1;
+        }
+    }
+
+    historyDown() {
+        if (this.historyIndex >= 0) {
+            this.historyIndex--;
+            this.cursor = -1;
+        }
+    }
+
+    moveCursorToBeginning() {
+        this.cursor = 0;
+    }
+
+    moveCursorToEnd() {
+        this.cursor = this.line.length;
+    }
+
+    moveCursorLeft() {
+        this.cursor--;
+    }
+
+    moveCursorRight() {
+        this.cursor++;
+    }
+
+    addChar(char) {
+        this.line = StringSplice(this.line, this.cursor, 0, char);
+        this.moveCursorRight();
+    }
+
+    deleteChar() {
+        const {line} = this;
+
+        if (line.length > 0 && this.cursor > 0) {
+            const x = StringSplice(line, this.cursor - 1, 1);
+
+            this.line = x;
+            this.moveCursorLeft();
+        }
+    }
+
+    deleteCharRightOrClose() {
+        const {line} = this;
+
+        if (this.cursor < line.length - 1)
+            this.line = StringSplice(this.line, this.cursor, 1);
+        else
+            this.exit();
+    }
+
+    deleteToBeginning() {
+        this.line = StringSplice(this.line, 0, this.cursor);
+    }
+
+    deleteToEnd() {
+        this.line = StringSplice(this.line, this.cursor);
+    }
+
+    /**
+     * Adapted from lib/readline.js in Node.js
+     */
+    deleteWordLeft() {
+        const {line} = this;
+
+        if (this.cursor > 0) {
+            // Reverse the string and match a word near beginning
+            // to avoid quadratic time complexity
+            let leading = line.slice(0, this.cursor);
+            const reversed = [...leading].reverse().join('');
+            const match = reversed.match(/^\s*(?:[^\w\s]+|\w+)?/);
+            leading = leading.slice(0,
+                leading.length - match[0].length);
+            this.line = leading.concat(line.slice(this.cursor));
+            this.cursor = leading.length;
+        }
+    }
+
+    /**
+     * Adapted from lib/readline.js in Node.js
+     */
+    deleteWordRight() {
+        const {line} = this;
+
+        if (line.length > 0 && this.cursor < line.length) {
+            const trailing = line.slice(this.cursor);
+            const match = trailing.match(/^(?:\s+|\W+|\w+)\s*/);
+            this.line = line.slice(0, this.cursor).concat(
+                trailing.slice(match[0].length));
+        }
+    }
+
+    /**
+     * Adapted from lib/readline.js in Node.js
+     */
+    wordLeft() {
+        const {line} = this;
+        if (this.cursor > 0) {
+            // Reverse the string and match a word near beginning
+            // to avoid quadratic time complexity
+            const leading = line.slice(0, this.cursor);
+            const reversed = [...leading].reverse().join('');
+            const match = reversed.match(/^\s*(?:[^\w\s]+|\w+)?/);
+
+            this.cursor -= match[0].length;
+            this.cursor = Math.max(0, this.cursor);
+        }
+    }
+
+    /**
+     * Adapted from lib/readline.js in Node.js
+     */
+    wordRight() {
+        const {line} = this;
+
+        if (this.cursor < line.length) {
+            const trailing = line.slice(this.cursor);
+            const match = trailing.match(/^(?:\s+|[^\w\s]+|\w+)\s*/);
+
+            this.cursor += match[0].length;
+        }
+    }
+
+    processLine() {
+        const {line} = this;
+
+        this.#history.unshift(line);
+        this.historyIndex = -1;
+        this.#exitWarning = false;
+        this.cursor = 0;
+        this.#write('\n');
+
+        super.processLine();
+    }
+
+    #onKeyPress(sequence, key) {
+        this.#processKey(key);
+
+        if (!this.cancelled)
+            this.render();
+    }
+
+    #processKey(key) {
+        if (!key.sequence)
+            return;
+
+        if (key.ctrl && !key.meta && !key.shift) {
+            switch (key.name) {
+            case 'c':
+                this.exit();
+                return;
+            case 'h':
+                this.deleteChar();
+                return;
+            case 'd':
+                this.deleteCharRightOrClose();
+                return;
+            case 'u':
+                this.deleteToBeginning();
+                return;
+            case 'k':
+                this.deleteToEnd();
+                return;
+            case 'a':
+                this.moveCursorToBeginning();
+                return;
+            case 'e':
+                this.moveCursorToEnd();
+                return;
+            case 'b':
+                this.moveCursorLeft();
+                return;
+            case 'f':
+                this.moveCursorRight();
+                return;
+            case 'l':
+                Console.clearTerminal();
+                return;
+            case 'n':
+                this.historyDown();
+                return;
+            case 'p':
+                this.historyUp();
+                return;
+            case 'z':
+                // Pausing is unsupported.
+                return;
+            case 'w':
+            case 'backspace':
+                this.deleteWordLeft();
+                return;
+            case 'delete':
+                this.deleteWordRight();
+                return;
+            case 'left':
+                this.wordLeft();
+                return;
+            case 'right':
+                this.wordRight();
+                return;
+            }
+        } else if (key.meta && !key.shift) {
+            switch (key.name) {
+            case 'd':
+                this.deleteWordRight();
+                return;
+            case 'backspace':
+                this.deleteWordLeft();
+                return;
+            case 'b':
+                this.wordLeft();
+                return;
+            case 'f':
+                this.wordRight();
+                return;
+            }
+        }
+
+        switch (key.name) {
+        case 'up':
+            this.historyUp();
+            return;
+        case 'down':
+            this.historyDown();
+            return;
+        case 'left':
+            this.moveCursorLeft();
+            return;
+        case 'right':
+            this.moveCursorRight();
+            return;
+        case 'backspace':
+            this.deleteChar();
+            return;
+        case 'return':
+            this.processLine();
+            return;
+        }
+
+        this.addChar(key.sequence);
+    }
+
+    /**
+     * @param {number} column the column to move the cursor to
+     */
+    set cursor(column) {
+        if (column < 0) {
+            this.#cursorColumn = 0;
+            return;
+        }
+
+        // Ensure the input index isn't longer than the content...
+        this.#cursorColumn = Math.min(this.line.length, column);
+    }
+
+    get cursor() {
+        return this.#cursorColumn;
+    }
+
+    render() {
+        // Prevent the cursor from flashing while we render...
+        this.#write(cursorHide);
+
+        const {inputPrompt, line} = this;
+
+        this.#write(
+            cursorTo(0),
+            CSI.kClearScreenDown,
+            inputPrompt,
+            line,
+            cursorTo(inputPrompt.length + this.cursor),
+            cursorShow
+        );
+
+        this.emit('render');
+    }
+
+    #write(...strings) {
+        const bytes = new TextEncoder().encode(strings.join(''));
+
+        this.stdout.write_bytes(bytes, null);
+        this.stdout.flush(null);
+    }
+
+    /**
+     * @param {string[]} strings strings to write to stdout
+     */
+    print(...strings) {
+        this.#write(...strings, '\n');
+    }
+
+    /**
+     * @param {Uint8Array} bytes an array of inputted bytes to process
+     * @returns {void}
+     */
+    handleInput(bytes) {
+        if (bytes.length === 0)
+            return;
+
+        const input = String.fromCharCode(...bytes.values());
+
+        for (const byte of input) {
+            this.#parser.next(byte);
+
+            if (this.cancelled)
+                break;
+        }
+    }
+
+    #asyncReadHandler(stream, result) {
+        if (result) {
+            try {
+                const gbytes = stream.read_bytes_finish(result);
+
+                this.handleInput(gbytes.toArray());
+            } catch (error) {
+                if (!error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) {
+                    console.error(error);
+                    imports.system.exit(1);
+
+                    return;
+                }
+            }
+        }
+
+        if (this.cancelled)
+            return;
+
+        this.#cancellable = new Gio.Cancellable();
+        stream.read_bytes_async(8, 0, this.#cancellable, this.#asyncReadHandler.bind(this));
+    }
+
+    cancel() {
+        super.cancel();
+
+        this.#cancellable?.cancel();
+        this.#cancellable = null;
+
+        this.#write('\n');
+    }
+
+    prompt() {
+        super.prompt();
+        this.render();
+
+        // Start the async read loop...
+        this.#asyncReadHandler(this.stdin);
+    }
+}
+
+export class SyncReadline extends Readline {
+    constructor({prompt}) {
+        super({prompt});
+    }
+
+    prompt() {
+        while (!this.cancelled) {
+            const {inputPrompt} = this;
+
+            try {
+                this.line = Console.interact(inputPrompt).split('');
+            } catch {
+                this.line = '';
+            }
+
+
+            this.processLine();
+        }
+    }
+
+    print(output) {
+        print(output);
+    }
+}
+
+export class Repl {
+    #lineNumber = 0;
+    #isAsync = false;
+
+    /** @type {boolean} */
+    #supportsColor;
+    /** @type {string} */
+    #version;
+
+    #mainloop = false;
+
+    constructor() {
+        ({Gio} = imports.gi);
+
+        this.#version = imports.system.versionString;
+
+        try {
+            this.#supportsColor = GLib.log_writer_supports_color(1) && GLib.getenv('NO_COLOR') === null;
+        } catch {
+            this.#supportsColor ||= false;
+        }
+
+        try {
+            this.#isAsync &&= GLib.getenv('GJS_REPL_USE_FALLBACK') !== 'true';
+            this.#isAsync = 'UnixInputStream' in Gio && 'UnixOutputStream' in Gio;
+            this.#isAsync &&= Console.enableRawMode();
+        } catch {
+            this.#isAsync = false;
+        }
+    }
+
+    [Symbol.toStringTag]() {
+        return 'Repl';
+    }
+
+    get lineNumber() {
+        return this.#lineNumber;
+    }
+
+    get supportsColor() {
+        return this.#supportsColor;
+    }
+
+    #print(string) {
+        this.input.print(`${string}`);
+    }
+
+    #evaluateInternal(lines) {
+        try {
+            const result = Console.eval(lines, this.#lineNumber);
+
+            if (result !== undefined)
+                this.#print(`${toString(result)}`);
+
+            return null;
+        } catch (error) {
+            return error;
+        }
+    }
+
+    #printError(error) {
+        if (error.message)
+            this.#print(`Uncaught ${error.name}: ${error.message}`);
+        else
+            this.#print(`${toString(error)}`);
+    }
+
+    evaluate(lines) {
+        this.#lineNumber++;
+
+        // TODO(ewlsh): Object/code block detection similar to Node
+        let wrappedLines = lines.trim();
+        if (wrappedLines.startsWith('{') &&
+            !wrappedLines.endsWith(';'))
+            wrappedLines = `(${wrappedLines})\n`;
+
+        // Attempt to evaluate any object literals in () first
+        let error = this.#evaluateInternal(wrappedLines);
+        if (!error)
+            return;
+
+        error = this.#evaluateInternal(lines);
+        if (!error)
+            return;
+
+        this.#printError(error);
+    }
+
+    #start() {
+        this.input.print(`GJS v${this.#version}`);
+
+        this.input.connect('line', (_, line) => {
+            if (typeof line === 'string' && line.trim().startsWith('exit()'))
+                this.exit();
+            else
+                this.evaluate(line);
+        });
+
+        this.input.connect('exit', () => {
+            this.exit();
+        });
+
+        this.input.prompt();
+    }
+
+    start() {
+        if (!this.#isAsync) {
+            this.input = new SyncReadline({prompt: '> '});
+
+            this.#start();
+
+            return;
+        }
+
+        try {
+            const stdin = Gio.UnixInputStream.new(0, false);
+            const stdout = new Gio.BufferedOutputStream({
+                baseStream: Gio.UnixOutputStream.new(1, false),
+                closeBaseStream: false,
+                autoGrow: true,
+            });
+            const stderr = Gio.UnixOutputStream.new(2, false);
+
+            this.input = new AsyncReadline({
+                stdin,
+                stdout,
+                stderr,
+                enableColor: this.#supportsColor,
+                prompt: '> ',
+            });
+
+            this.#start();
+
+            this.#mainloop = true;
+            imports.mainloop.run('repl');
+        } finally {
+            Console.disableRawMode();
+        }
+    }
+
+    exit() {
+        try {
+            this.input.cancel();
+
+            if (this.#mainloop)
+                imports.mainloop.quit('repl');
+        } catch {
+            // Force an exit if a user doesn't define their
+            // replacement mainloop's quit function.
+            imports.system.exit(1);
+        }
+    }
+}
+
+imports.console.Repl = Repl;
diff --git a/modules/modules.cpp b/modules/modules.cpp
index c9b7061d4..f0a950d89 100644
--- a/modules/modules.cpp
+++ b/modules/modules.cpp
@@ -21,6 +21,7 @@ gjs_register_static_modules (void)
     gjs_register_native_module("cairoNative", gjs_js_define_cairo_stuff);
 #endif
     gjs_register_native_module("system", gjs_js_define_system_stuff);
-    gjs_register_native_module("console", gjs_define_console_stuff);
+    gjs_register_native_module("_consoleNative",
+                               gjs_define_console_private_stuff);
     gjs_register_native_module("_print", gjs_define_print_stuff);
 }
diff --git a/modules/script/console.js b/modules/script/console.js
new file mode 100644
index 000000000..d80b699c1
--- /dev/null
+++ b/modules/script/console.js
@@ -0,0 +1,12 @@
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2021 Evan Welsh <contact evanwelsh com>
+
+/* exported Repl, interact */
+
+var Repl = null;
+
+function interact() {
+    const repl = new Repl();
+
+    repl.start();
+}


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]