diff options
author | Evan Welsh <contact@evanwelsh.com> | 2023-03-12 13:08:08 -0700 |
---|---|---|
committer | Evan Welsh <contact@evanwelsh.com> | 2023-03-12 13:08:08 -0700 |
commit | 96c80122e474aac114d0d52acde1f2de3a711b85 (patch) | |
tree | 28e834f09788922be2adebf7b89a913fc0dc60d4 | |
parent | 064894bf82755f51aaa1f762c06cacc8bf55720f (diff) | |
download | gjs-ewlsh/nova-repl.tar.gz |
feat: Console async workewlsh/nova-repl
-rw-r--r-- | gjs/console.cpp | 19 | ||||
-rw-r--r-- | installed-tests/js/.eslintrc.yml | 1 | ||||
-rw-r--r-- | installed-tests/js/meson.build | 1 | ||||
-rw-r--r-- | installed-tests/js/testReadline.js | 161 | ||||
-rw-r--r-- | js.gresource.xml | 11 | ||||
-rw-r--r-- | meson.build | 1 | ||||
-rw-r--r-- | modules/console.cpp | 334 | ||||
-rw-r--r-- | modules/console.h | 4 | ||||
-rw-r--r-- | modules/esm/_bootstrap/default.js | 6 | ||||
-rw-r--r-- | modules/esm/_bootstrap/repl.js | 10 | ||||
-rw-r--r-- | modules/esm/_legacyGlobal.js | 53 | ||||
-rw-r--r-- | modules/esm/_prettyPrint.js | 118 | ||||
-rw-r--r-- | modules/esm/_readline/callbacks.js | 72 | ||||
-rw-r--r-- | modules/esm/_readline/primordials.js | 20 | ||||
-rw-r--r-- | modules/esm/_readline/terminal.js | 220 | ||||
-rw-r--r-- | modules/esm/_readline/utils.js | 397 | ||||
-rw-r--r-- | modules/esm/console.js | 25 | ||||
-rw-r--r-- | modules/esm/readline.js | 622 | ||||
-rw-r--r-- | modules/esm/repl.js | 150 | ||||
-rw-r--r-- | modules/internal/loader.js | 31 | ||||
-rw-r--r-- | modules/modules.cpp | 2 | ||||
-rw-r--r-- | modules/script/_bootstrap/default.js | 166 | ||||
-rw-r--r-- | modules/script/console.js | 12 |
23 files changed, 2041 insertions, 395 deletions
diff --git a/gjs/console.cpp b/gjs/console.cpp index c2e15789..02917b42 100644 --- a/gjs/console.cpp +++ b/gjs/console.cpp @@ -30,6 +30,7 @@ static GjsAutoChar command; 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; @@ -48,6 +49,7 @@ static GOptionEntry entries[] = { "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.out(), "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.out(), "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, @@ -274,6 +276,7 @@ int 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)) { @@ -308,9 +311,19 @@ int main(int argc, char** argv) { return EXIT_FAILURE; } - 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 74a8a4f3..21a6eada 100644 --- a/installed-tests/js/.eslintrc.yml +++ b/installed-tests/js/.eslintrc.yml @@ -34,6 +34,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 6db887d2..35f9e0e4 100644 --- a/installed-tests/js/meson.build +++ b/installed-tests/js/meson.build @@ -240,6 +240,7 @@ endforeach modules_tests = [ 'Async', 'Console', + 'Readline', 'ESModules', 'AsyncMainloop', 'Encoding', diff --git a/installed-tests/js/testReadline.js b/installed-tests/js/testReadline.js new file mode 100644 index 00000000..02e04b8e --- /dev/null +++ b/installed-tests/js/testReadline.js @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later +// SPDX-FileCopyrightText: 2022 Evan Welsh <contact@evanwelsh.com> + +import Gio from 'gi://Gio'; + +import { AsyncReadline } from 'readline'; + +function createReadline() { + return new AsyncReadline({ + inputStream: null, + outputStream: null, + errorOutputStream: null, + prompt: '> ', + enableColor: false, + }); +} + +function createReadlineWithStreams() { + const inputStream = new Gio.MemoryInputStream(); + const outputStream = Gio.MemoryOutputStream.new_resizable(); + const errorOutputStream = Gio.MemoryOutputStream.new_resizable(); + + const readline = new AsyncReadline({ + inputStream, + outputStream, + errorOutputStream, + prompt: '> ', + enableColor: false, + }); + + return { + readline, + inputStream, + teardown() { + readline.cancel(); + + try { + readline.stdout.close(null); + } catch {} + + try { + readline.stdin.close(null); + } catch {} + + try { + readline.stderr.close(null); + } catch {} + }, + }; +} + +function expectReadlineOutput({ + readline, + inputStream, + 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(); + } + }); + + inputStream.add_bytes(new TextEncoder().encode(input)); + }); +} + + + +describe('Readline', () => { + describe('AsyncReadline', () => { + it('handles key events on stdin', async function () { + const { readline, inputStream, teardown } = createReadlineWithStreams(); + + readline.prompt(); + + await expectReadlineOutput({ + readline, + inputStream, + input: 'a', + output: 'a', + }); + + await expectReadlineOutput({ + readline, + inputStream, + input: 'b', + output: 'ab', + }); + + await expectReadlineOutput({ + readline, + inputStream, + input: '\x1b[D\x1b[Dcr', + output: 'crab', + keystrokes: 4, + }); + + teardown(); + }); + }); + + 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 e12dea12..25a2e178 100644 --- a/js.gresource.xml +++ b/js.gresource.xml @@ -8,6 +8,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> @@ -15,10 +16,19 @@ <file>modules/esm/_timers.js</file> + <file>modules/esm/_readline/utils.js</file> + <file>modules/esm/_readline/primordials.js</file> + <file>modules/esm/_readline/callbacks.js</file> + <file>modules/esm/_readline/terminal.js</file> + <file>modules/esm/cairo.js</file> + <file>modules/esm/_prettyPrint.js</file> + <file>modules/esm/_legacyGlobal.js</file> <file>modules/esm/gettext.js</file> <file>modules/esm/console.js</file> <file>modules/esm/gi.js</file> + <file>modules/esm/repl.js</file> + <file>modules/esm/readline.js</file> <file>modules/esm/system.js</file> <!-- Script-based Modules --> @@ -32,6 +42,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/meson.build b/meson.build index 4410a110..0934784a 100644 --- a/meson.build +++ b/meson.build @@ -337,6 +337,7 @@ if build_readline endif header_conf.set('USE_UNITY_BUILD', get_option('unity')) header_conf.set('HAVE_SYS_SYSCALL_H', cxx.check_header('sys/syscall.h')) +header_conf.set('HAVE_SYS_IOCTL_H', cxx.check_header('sys/ioctl.h')) header_conf.set('HAVE_TERMIOS_H', cxx.check_header('termios.h')) header_conf.set('HAVE_UNISTD_H', cxx.check_header('unistd.h')) header_conf.set('HAVE_SIGNAL_H', cxx.check_header('signal.h', diff --git a/modules/console.cpp b/modules/console.cpp index 35df0d2a..f2f6c140 100644 --- a/modules/console.cpp +++ b/modules/console.cpp @@ -3,29 +3,24 @@ // 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.h> #include <glib/gprintf.h> // for g_fprintf +#include <stdio.h> + +#if defined(HAVE_SYS_IOCTL_H) && defined(HAVE_UNISTD_H) +# include <fcntl.h> +# include <sys/ioctl.h> +# include <unistd.h> +# if defined(TIOCGWINSZ) +# define GET_SIZE_USE_IOCTL +# endif +#endif #include <js/CallAndConstruct.h> #include <js/CallArgs.h> @@ -35,6 +30,8 @@ #include <js/ErrorReport.h> #include <js/Exception.h> #include <js/PropertyAndElement.h> +#include <js/PropertyDescriptor.h> +#include <js/PropertySpec.h> #include <js/RootingAPI.h> #include <js/SourceText.h> #include <js/TypeDecls.h> @@ -47,18 +44,16 @@ #include "gjs/atoms.h" #include "gjs/context-private.h" #include "gjs/global.h" +#include "gjs/jsapi-util-args.h" #include "gjs/jsapi-util.h" #include "gjs/macros.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; @@ -98,84 +93,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; } -std::string print_string_value(JSContext* cx, JS::HandleValue v_string) { - if (!v_string.isString()) - return "[unexpected result from printing value]"; - - JS::RootedString printed_string(cx, v_string.toString()); - JS::AutoSaveExceptionState exc_state(cx); - JS::UniqueChars chars(JS_EncodeStringToUTF8(cx, printed_string)); - exc_state.restore(); - if (!chars) - return "[error printing value]"; - - return chars.get(); -} - -/* 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, - JS::HandleObject global, - 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)) @@ -184,132 +115,86 @@ std::string print_string_value(JSContext* cx, JS::HandleValue v_string) { 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()) - return true; + 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::AutoSaveExceptionState exc_state(cx); - JS::RootedValue v_printed_string(cx); - JS::RootedValue v_pretty_print( - cx, gjs_get_global_slot(global, GjsGlobalSlot::PRETTY_PRINT_FUNC)); - bool ok = JS::Call(cx, global, v_pretty_print, JS::HandleValueArray(result), - &v_printed_string); - if (!ok) - gjs_log_exception(cx); - exc_state.restore(); - - if (ok) { - g_fprintf(stdout, "%s\n", - print_string_value(cx, v_printed_string).c_str()); - } else { - g_fprintf(stdout, "[error printing value]\n"); + 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; } + 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); - volatile bool eof, exit_warning; // accessed after setjmp() - JS::RootedObject global(context, gjs_get_import_global(context)); - char* temp_buf; - volatile int lineno; // accessed after setjmp() - volatile int startline; // accessed after setjmp() +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; -#ifndef HAVE_READLINE_READLINE_H - int rl_end = 0; // nonzero if using readline and any text is typed in -#endif + args.rval().setBoolean(Gjs::Console::disable_raw_mode()); + return true; +} - 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, global, 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); +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; + 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; + + JS::UniqueChars code; + size_t code_len; + if (!gjs_string_to_utf8_n(cx, str, &code, &code_len)) + return false; - argv.rval().setUndefined(); + JS::RootedObject global(cx, gjs_get_import_global(cx)); + + args.rval().setBoolean( + JS_Utf8BufferIsCompilableUnit(cx, global, code.get(), code_len)); return true; } @@ -329,10 +214,57 @@ static bool gjs_console_clear_terminal(JSContext* cx, unsigned argc, return true; } +bool gjs_console_get_terminal_size(JSContext* cx, unsigned argc, + JS::Value* vp) { + JS::RootedObject obj(cx, JS_NewPlainObject(cx)); + if (!obj) + return false; + + // Use 'int' because Windows uses int values, whereas most Unix systems + // use 'short' + unsigned int width, height; + + JS::CallArgs argv = JS::CallArgsFromVp(argc, vp); +#ifdef GET_SIZE_USE_IOCTL + struct winsize ws; + + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) < 0) { + gjs_throw(cx, "No terminal output is present.\n"); + return false; + } + + width = ws.ws_col; + height = ws.ws_row; +#else + // TODO(ewlsh): Implement Windows equivalent. + // See + // https://docs.microsoft.com/en-us/windows/console/window-and-screen-buffer-size. + gjs_throw(cx, "Unable to retrieve terminal size on this platform.\n"); + return false; +#endif + + const GjsAtoms& atoms = GjsContextPrivate::atoms(cx); + if (!JS_DefinePropertyById(cx, obj, atoms.height(), height, + JSPROP_READONLY) || + !JS_DefinePropertyById(cx, obj, atoms.width(), width, JSPROP_READONLY)) + return false; + + argv.rval().setObject(*obj); + return true; +} + 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("getDimensions", gjs_console_get_terminal_size, 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 73ed4c0e..7df6a02d 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 ff1f28bf..1d9b6465 100644 --- a/modules/esm/_bootstrap/default.js +++ b/modules/esm/_bootstrap/default.js @@ -9,3 +9,9 @@ import '_encoding/encoding'; import 'console'; // Bootstrap the Timers API import '_timers'; +// Install the Repl constructor for Console.interact() +import 'repl'; +// Install the pretty printing global function +import '_prettyPrint'; +// Setup legacy global values +import '_legacyGlobal'; diff --git a/modules/esm/_bootstrap/repl.js b/modules/esm/_bootstrap/repl.js new file mode 100644 index 00000000..cb8e98eb --- /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/_legacyGlobal.js b/modules/esm/_legacyGlobal.js new file mode 100644 index 00000000..c6d4bd2f --- /dev/null +++ b/modules/esm/_legacyGlobal.js @@ -0,0 +1,53 @@ +import { prettyPrint } from './_prettyPrint.js'; + +const { + print, + printerr, + log: nativeLog, + logError: nativeLogError, +} = import.meta.importSync('_print'); + +function log(...args) { + return nativeLog(args.map(arg => typeof arg === 'string' ? arg : prettyPrint(arg)).join(' ')); +} + +function logError(e, ...args) { + if (args.length === 0) + return nativeLogError(e); + return nativeLogError(e, args.map(arg => typeof arg === 'string' ? arg : prettyPrint(arg)).join(' ')); +} + +Object.defineProperties(globalThis, { + ARGV: { + configurable: false, + enumerable: true, + get() { + // Wait until after bootstrap or programArgs won't be set. + return imports.system.programArgs; + }, + }, + print: { + configurable: false, + enumerable: true, + writable: true, + value: print, + }, + printerr: { + configurable: false, + enumerable: true, + writable: true, + value: printerr, + }, + log: { + configurable: false, + enumerable: true, + writable: true, + value: log, + }, + logError: { + configurable: false, + enumerable: true, + writable: true, + value: logError, + }, +}); diff --git a/modules/esm/_prettyPrint.js b/modules/esm/_prettyPrint.js new file mode 100644 index 00000000..f379ba9c --- /dev/null +++ b/modules/esm/_prettyPrint.js @@ -0,0 +1,118 @@ +const { setPrettyPrintFunction } = import.meta.importSync('_print'); + +/** + * @param {unknown} value + * @returns + */ +export function prettyPrint(value) { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + + switch (typeof value) { + case 'object': + if (value.toString === Object.prototype.toString || + value.toString === Array.prototype.toString || + value.toString === Date.prototype.toString) { + const printedObjects = new WeakSet(); + return formatObject(value, printedObjects); + } + // If the object has a nonstandard toString, prefer that + return value.toString(); + case 'function': + if (value.toString === Function.prototype.toString) + return formatFunction(value); + return value.toString(); + case 'string': + return JSON.stringify(value); + case 'symbol': + return formatSymbol(value); + default: + return value.toString(); + } +} + +function formatPropertyKey(key) { + if (typeof key === 'symbol') + return `[${formatSymbol(key)}]`; + return `${key}`; +} + +function formatObject(obj, printedObjects) { + printedObjects.add(obj); + if (Array.isArray(obj)) + return formatArray(obj, printedObjects).toString(); + + if (obj instanceof Date) + return formatDate(obj); + + if (obj[Symbol.toStringTag] === 'GIRepositoryNamespace') + return obj.toString(); + + const formattedObject = []; + const keys = Object.getOwnPropertyNames(obj).concat(Object.getOwnPropertySymbols(obj)); + for (const propertyKey of keys) { + const value = obj[propertyKey]; + const key = formatPropertyKey(propertyKey); + switch (typeof value) { + case 'object': + if (printedObjects.has(value)) + formattedObject.push(`${key}: [Circular]`); + else + formattedObject.push(`${key}: ${formatObject(value, printedObjects)}`); + break; + case 'function': + formattedObject.push(`${key}: ${formatFunction(value)}`); + break; + case 'string': + formattedObject.push(`${key}: "${value}"`); + break; + case 'symbol': + formattedObject.push(`${key}: ${formatSymbol(value)}`); + break; + default: + formattedObject.push(`${key}: ${value}`); + break; + } + } + return Object.keys(formattedObject).length === 0 ? '{}' + : `{ ${formattedObject.join(', ')} }`; +} + +function formatArray(arr, printedObjects) { + const formattedArray = []; + for (const [key, value] of arr.entries()) { + if (printedObjects.has(value)) + formattedArray[key] = '[Circular]'; + else + formattedArray[key] = prettyPrint(value); + } + return `[${formattedArray.join(', ')}]`; +} + +function formatDate(date) { + return date.toISOString(); +} + +function formatFunction(func) { + let funcOutput = `[ Function: ${func.name} ]`; + return funcOutput; +} + +function formatSymbol(sym) { + // Try to format Symbols in the same way that they would be constructed. + + // First check if this is a global registered symbol + const globalKey = Symbol.keyFor(sym); + if (globalKey !== undefined) + return `Symbol.for("${globalKey}")`; + + const descr = sym.description; + // Special-case the 'well-known' (built-in) Symbols + if (descr.startsWith('Symbol.')) + return descr; + + // Otherwise, it's just a regular symbol + return `Symbol("${descr}")`; +} + +setPrettyPrintFunction(globalThis, prettyPrint); diff --git a/modules/esm/_readline/callbacks.js b/modules/esm/_readline/callbacks.js new file mode 100644 index 00000000..109543f8 --- /dev/null +++ b/modules/esm/_readline/callbacks.js @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Node.js contributors. All rights reserved. + +import {CSI} from './utils.js'; + +const {Number} = globalThis; + +const NumberIsNaN = Number.isNaN; + +// Adapted from https://github.com/nodejs/node/blob/1b550ba1af50a9e7eed9b27a92902115f98cf4d8/lib/internal/readline/callbacks.js + +/* eslint-disable */ + +const { + kClearLine, + kClearToLineBeginning, + kClearToLineEnd, +} = CSI; + +/** + * moves the cursor to the x and y coordinate on the given stream + */ + +function cursorTo(x, y) { + if (NumberIsNaN(x)) throw new Error('Invalid argument x is NaN'); + if (NumberIsNaN(y)) throw new Error('Invalid argument y is NaN'); + if (typeof x !== 'number') throw new Error('Invalid argument x is not a number'); + + 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 + */ + +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 + */ + +function clearLine(dir) { + const type = + dir < 0 ? kClearToLineBeginning : dir > 0 ? kClearToLineEnd : kClearLine; + return type; +} + +export { + clearLine, + cursorTo, + moveCursor, +}; diff --git a/modules/esm/_readline/primordials.js b/modules/esm/_readline/primordials.js new file mode 100644 index 00000000..57638904 --- /dev/null +++ b/modules/esm/_readline/primordials.js @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later +// SPDX-FileCopyrightText: 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]>} + */ +export function uncurryThis(type, key) { + const func = type[key]; + return (instance, ...args) => func.apply(instance, args); +} diff --git a/modules/esm/_readline/terminal.js b/modules/esm/_readline/terminal.js new file mode 100644 index 00000000..7610c9e6 --- /dev/null +++ b/modules/esm/_readline/terminal.js @@ -0,0 +1,220 @@ + +import GLib from 'gi://GLib'; +import gi from 'gi'; + +import { emitKeys, CSI } from './utils.js'; +import { cursorTo } from './callbacks.js'; + +const NativeConsole = import.meta.importSync('_consoleNative'); + +/** @type {import('gi://Gio')} */ +let Gio; + +function requireGio() { + if (!Gio) Gio = gi.require('Gio'); +} + +const cursorHide = CSI`?25l`; +const cursorShow = CSI`?25h`; + +/** + * @typedef {object} TerminalOptions +* @property {Gio.InputStream | null} [inputStream] the input stream +* @property {Gio.OutputStream | null} [outputStream] the output stream +* @property {Gio.OutputStream | null} [errorOutputStream] the error output stream +* @property {boolean} [enableColor] whether to print ANSI color codes + */ + +export class Terminal { + /** + * Pending writes to the stream + */ + #buffer = []; + /** + * @type {Gio.Cancellable | null} + */ + #cancellable = null; + + #parser; + + /** + * @param {TerminalOptions} options _ + */ + constructor({ + onKeyPress, + inputStream = Terminal.stdin, + outputStream = Terminal.stdout, + errorOutputStream = Terminal.stderr, + enableColor, + }) { + this.inputStream = inputStream; + this.outputStream = outputStream; + this.errorOutputStream = errorOutputStream; + this.enableColor = enableColor ?? this.supportsColor(); + this.cancelled = false; + + this.#parser = emitKeys(onKeyPress); + this.#parser.next(); + } + + get dimensions() { + const values = NativeConsole.getDimensions(); + return { height: values.height, width: values.width }; + } + + commit() { + const bytes = new TextEncoder().encode(this.#buffer.join('')); + this.#buffer = []; + + if (!this.outputStream) return; + + this.outputStream.write_bytes(bytes, null); + this.outputStream.flush(null); + } + + print(...strings) { + this.#buffer.push(...strings); + } + + clearScreenDown() { + this.#buffer.push(CSI.kClearScreenDown); + } + + newLine() { + this.#buffer.push('\n'); + } + hideCursor() { + this.#buffer.push(cursorHide); + } + + showCursor() { + this.#buffer.push(cursorShow); + } + + cursorTo(x, y) { + this.#buffer.push(cursorTo(x, y)); + } + /** + * @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) { + requireGio(); + + 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) + ); + } + + stopInput() { + this.cancelled = true; + + this.#cancellable?.cancel(); + this.#cancellable = null; + + this.#buffer = []; + } + + startInput() { + if (!this.inputStream) throw new Error('Terminal has no input stream') + + this.#asyncReadHandler(this.inputStream); + } + + supportsColor() { + return ( + this.outputStream && + GLib.log_writer_supports_color(this.outputStream.fd) && + GLib.getenv('NO_COLOR') === null + ); + } + + static hasUnixStreams() { + requireGio(); + + return 'UnixInputStream' in Gio && 'UnixOutputStream' in Gio; + } + + static #stdin = null; + static #stdout = null; + static #stderr = null; + + static get stdout() { + if (!Terminal.hasUnixStreams()) { + throw new Error('Missing Gio.UnixOutputStream'); + } + + requireGio(); + + return (this.#stdout ??= new Gio.BufferedOutputStream({ + baseStream: Gio.UnixOutputStream.new(1, false), + closeBaseStream: false, + autoGrow: true, + })); + } + + static get stdin() { + if (!Terminal.hasUnixStreams()) { + throw new Error('Missing Gio.UnixInputStream'); + } + + requireGio(); + + return (this.#stdin ??= Gio.UnixInputStream.new(0, false)); + } + + static get stderr() { + if (!Terminal.hasUnixStreams()) { + throw new Error('Missing Gio.UnixOutputStream'); + } + + requireGio(); + + return (this.#stderr ??= Gio.UnixOutputStream.new(2, false)); + } +} + +export function setRawMode(enabled) { + if (enabled) { + const success = NativeConsole.enableRawMode(); + + if (!success) throw new Error('Could not set raw mode on stdin'); + } else { + NativeConsole.disableRawMode(); + } +} diff --git a/modules/esm/_readline/utils.js b/modules/esm/_readline/utils.js new file mode 100644 index 00000000..b03c1c8e --- /dev/null +++ b/modules/esm/_readline/utils.js @@ -0,0 +1,397 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Node.js contributors. All rights reserved. + +import {uncurryThis} from './primordials.js'; + +const ArrayPrototypeSlice = uncurryThis(Array.prototype, 'slice'); +const ArrayPrototypeSort = uncurryThis(Array.prototype, 'sort'); +const RegExpPrototypeExec = uncurryThis(RegExp.prototype, 'exec'); +const StringFromCharCode = String.fromCharCode; +const StringPrototypeCharCodeAt = uncurryThis(String.prototype, 'charCodeAt'); +const StringPrototypeCodePointAt = uncurryThis(String.prototype, 'codePointAt'); +const StringPrototypeSlice = uncurryThis(String.prototype, 'slice'); +const StringPrototypeToLowerCase = uncurryThis(String.prototype, 'toLowerCase'); + + +// Adapted from https://github.com/nodejs/node/blob/56679eb53044b03e4da0f7420774d54f0c550eec/lib/internal/readline/utils.js + +/* eslint-disable */ + +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 +*/ +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 = RegExpPrototypeExec(/^(\d\d?)(;(\d))?([~^$])$/, cmd))) { + code += match[1] + match[4]; + modifier = (match[3] || 1) - 1; + } else if ( + (match = RegExpPrototypeExec(/^((\d;)?(\d))?([A-Za-z])$/, cmd)) + ) { + 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 (RegExpPrototypeExec(/^[0-9A-Za-z]$/, ch) !== null) { + // Letter, number, shift+letter + key.name = StringPrototypeToLowerCase(ch); + key.shift = RegExpPrototypeExec(/^[A-Z]$/, ch) !== null; + 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 */ + callback(escaped ? undefined : s, key); + } else if (charLengthAt(s, 0) === s.length) { + /* Single unnamed character, e.g. "." */ + 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 === 0) { + return ''; + } + 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/console.js b/modules/esm/console.js index 9b57f97b..0bc7deb2 100644 --- a/modules/esm/console.js +++ b/modules/esm/console.js @@ -3,6 +3,8 @@ import GLib from 'gi://GLib'; +import { prettyPrint } from './_prettyPrint.js'; + const NativeConsole = import.meta.importSync('_consoleNative'); const DEFAULT_LOG_DOMAIN = 'Gjs-Console'; @@ -36,7 +38,15 @@ function hasFormatSpecifiers(str) { * @param {any} item an item to format */ function formatGenerically(item) { - return JSON.stringify(item, null, 4); + if (typeof item === 'string') { + return JSON.stringify(item, null, 4); + } + + try { + return JSON.stringify(item, null, 4); + } catch { + return `${item}`; + } } /** @@ -44,6 +54,7 @@ function formatGenerically(item) { * @returns {string} */ function formatOptimally(item) { + try { // Handle optimal error formatting. if (item instanceof Error) { return `${item.toString()}${item.stack ? '\n' : ''}${item.stack @@ -53,10 +64,12 @@ function formatOptimally(item) { .join('\n')}`; } - // TODO: Enhance 'optimal' formatting. - // There is a current work on a better object formatter for GJS in - // https://gitlab.gnome.org/GNOME/gjs/-/merge_requests/587 - return JSON.stringify(item, null, 4); + + return prettyPrint(item); +} catch { +} + +return formatGenerically(item); } /** @@ -490,7 +503,7 @@ class Console { * https://console.spec.whatwg.org/#printer * * This implementation of Printer maps WHATWG log severity to - * {@see GLib.LogLevelFlags} and outputs using GLib structured logging. + * {@link GLib.LogLevelFlags} and outputs using GLib structured logging. * * @param {string} logLevel the log level (log tag) the args should be * emitted with diff --git a/modules/esm/readline.js b/modules/esm/readline.js new file mode 100644 index 00000000..30e07a7c --- /dev/null +++ b/modules/esm/readline.js @@ -0,0 +1,622 @@ +import GLib from 'gi://GLib'; +import gi from 'gi'; + +import { charLengthAt, charLengthLeft } from './_readline/utils.js'; +import {Terminal} from './_readline/terminal.js'; + +export {Terminal, setRawMode} from './_readline/terminal.js'; + + +const NativeConsole = import.meta.importSync('_consoleNative'); + +/** + * @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) + ); +} + +/** + * @typedef {object} ReadlineOptions + * @property {string} prompt the prompt to print prior to the line + * @property {string} [pendingPrompt] the prompt shown when multiline input is pending + * @property {boolean} [enableColor] whether to print ANSI color codes + */ + +/** @typedef {ReadlineOptions & import('./_readline/terminal.js').TerminalOptions} AsyncReadlineOptions */ + +/** + * A basic abstraction around line-by-line input + */ +export class Readline { + #prompt; + #pendingPrompt; + /** + * The current input + */ + #input = ''; + /** + * Whether to cancel the prompt + */ + #cancelling = false; + + /** + * Store pending lines of multiline input + * + * @example + * gjs > 'a pending line... + * ..... ' + * + * @type {string[]} + */ + #pendingInputLines = []; + + get isRaw() { + throw new Error('Unimplemented'); + } + + get isAsync() { + throw new Error('Unimplemented'); + } + + /** + * @param {ReadlineOptions} options _ + */ + constructor({ prompt, pendingPrompt = ' '.padStart(4, '.') }) { + this.#prompt = prompt; + this.#pendingPrompt = pendingPrompt; + } + + [Symbol.toStringTag]() { + return 'Readline'; + } + + get cancelled() { + return this.#cancelling; + } + + get line() { + return this.#input; + } + + set line(value) { + this.#input = value; + } + + hasMultilineInput() { + return this.#pendingInputLines.length > 0; + } + + addLineToMultilineInput(line) { + // Buffer the pending input... + this.#pendingInputLines.push(line); + } + + completeMultilineInput() { + // Reset lines before input is triggered + this.#pendingInputLines = []; + } + + processLine() { + const { line } = this; + + // Rebuild the input... + const multilineInput = [...this.#pendingInputLines, line].join('\n'); + + this.emit('validate', line, multilineInput); + + // Reset state... + this.#input = ''; + if (this.#pendingInputLines.length > 0) { + return; + } + + if (multilineInput.includes('\n')) { + this.emit('multiline', multilineInput); + } else { + this.emit('line', multilineInput); + } + } + + get inputPrompt() { + if (this.#pendingInputLines.length > 0) { + return this.#pendingPrompt; + } + + return this.#prompt; + } + + print(_output) {} + + render() {} + + prompt() { + this.#cancelling = false; + } + + exit() {} + + cancel() { + this.#cancelling = true; + } + + /** + * @param {ReadlineOptions} options _ + */ + static create(options) { + let isAsync = false; + + try { + // Only enable async input if GJS_READLINE_USE_FALLBACK is not 'true' + isAsync = GLib.getenv('GJS_READLINE_USE_FALLBACK') !== 'true'; + // Only enable async input if the terminal supports Unix streams + isAsync &&= Terminal.hasUnixStreams(); + } catch { + // Otherwise, disable async + isAsync = false; + } + + if (isAsync) { + return new AsyncReadline(options); + } + + return new SyncReadline(options); + } +} +imports.signals.addSignalMethods(Readline.prototype); + +/** + * Synchronously reads lines and emits events to handle them + */ +export class SyncReadline extends Readline { + /** + * @param {ReadlineOptions} options _ + */ + constructor({ prompt, pendingPrompt }) { + super({ prompt, pendingPrompt, enableColor: false }); + } + + prompt() { + while (!this.cancelled) { + const { inputPrompt } = this; + + try { + this.line = NativeConsole.interact(inputPrompt).split(''); + } catch { + this.line = ''; + } + + this.processLine(); + } + } + + print(output) { + print(output); + } + + get isAsync() { + return false; + } + + get isRaw() { + return false; + } +} + +/** + * Asynchronously reads lines and prints output, allowing a mainloop + * to run in parallel. + */ +export class AsyncReadline extends Readline { + #exitWarning = false; + + /** + * Store previously inputted lines + * + * @type {string[]} + */ + #history = []; + #historyIndex = -1; + + /** + * The cursor's current column position. + */ + #cursorColumn = 0; + + /** + * @type {Terminal} + */ + #terminal; + + /** + * @param {AsyncReadlineOptions} options _ + */ + constructor({ + inputStream, + outputStream, + errorOutputStream, + prompt, + pendingPrompt + }) { + super({ prompt, pendingPrompt }); + + this.#terminal = new Terminal({ + inputStream, + outputStream, + errorOutputStream, + onKeyPress: (_, key) => { + this.#processKey(key); + + if (!this.cancelled) this.render(); + }, + }); + } + + get isAsync() { + return true; + } + + get isRaw() { + return true; + } + + /** + * Gets the current line of input or a line from history if the user has scrolled up + */ + get line() { + if (this.#historyIndex > -1) return this.#history[this.#historyIndex]; + + return super.line; + } + + /** + * Modifies the current line of input or a line from history if the user has scrolled up + */ + 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.moveCursorToEnd(); + } + } + + historyDown() { + if (this.#historyIndex >= 0) { + this.#historyIndex--; + this.moveCursorToEnd(); + } + } + + moveCursorToBeginning() { + this.cursor = 0; + } + + moveCursorToEnd() { + this.cursor = this.line.length; + } + + moveCursorLeft() { + this.cursor -= charLengthLeft(this.line, this.cursor); + } + + moveCursorRight() { + this.cursor += charLengthAt(this.line, this.cursor); + } + + addChar(char) { + this.line = StringSplice(this.line, this.cursor, 0, char); + this.moveCursorRight(); + } + + deleteChar() { + const { line, cursor } = this; + + if (line.length > 0 && cursor > 0) { + const charLength = charLengthLeft(this.line, this.cursor); + const modified = StringSplice( + line, + cursor - charLength, + charLength + ); + + this.line = modified; + this.moveCursorLeft(); + } + } + + deleteCharRightOrClose() { + const { line, cursor } = this; + + if (cursor < line.length - 1) { + const charLength = charLengthAt(this.line, this.cursor); + this.line = StringSplice(line, cursor, charLength); + return; + } + + this.exit(); + } + + deleteToBeginning() { + this.line = StringSplice(this.line, 0, this.cursor); + } + + deleteToEnd() { + this.line = StringSplice(this.line, this.cursor); + } + + /** + * Adapted from lib/internal/readline/interface.js in Node.js + */ + deleteWordLeft() { + const { line, cursor } = this; + + if (cursor > 0) { + // Reverse the string and match a word near beginning + // to avoid quadratic time complexity + let leading = line.slice(0, 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(cursor)); + this.cursor = leading.length; + } + } + + /** + * Adapted from lib/internal/readline/interface.js in Node.js + */ + deleteWordRight() { + const { line, cursor } = this; + + if (line.length > 0 && cursor < line.length) { + const trailing = line.slice(cursor); + const match = trailing.match(/^(?:\s+|\W+|\w+)\s*/); + this.line = line + .slice(0, cursor) + .concat(trailing.slice(match[0].length)); + } + } + + /** + * Adapted from lib/internal/readline/interface.js in Node.js + */ + wordLeft() { + const { line, cursor } = this; + + if (cursor > 0) { + // Reverse the string and match a word near beginning + // to avoid quadratic time complexity + const leading = line.slice(0, cursor); + const reversed = [...leading].reverse().join(''); + const match = reversed.match(/^\s*(?:[^\w\s]+|\w+)?/); + + this.cursor -= match[0].length; + } + } + + /** + * Adapted from lib/internal/readline/interface.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; + } + } + + /** + * @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); + } + + /** + * The current column the cursor is at + */ + get cursor() { + return this.#cursorColumn; + } + + processLine() { + const { line } = this; + + // Add the line to history + this.#history.unshift(line); + + // Reset the CTRL-C exit warning + this.#exitWarning = false; + + // Move the cursor to the beginning of the new line + this.moveCursorToBeginning(); + + // Print a newline + this.#terminal.newLine(); + // Commit changes + this.#terminal.commit(); + + // Call Readline.processLine to handle input validation + // and act on the input + super.processLine(); + + // Reset the history scroll so the user sees the current + // input + this.#historyIndex = -1; + } + + #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': + NativeConsole.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); + } + + render() { + // Prevent the cursor from flashing while we render... + this.#terminal.hideCursor(); + this.#terminal.cursorTo(0); + this.#terminal.clearScreenDown(); + + const { inputPrompt, line } = this; + this.#terminal.print(inputPrompt, line); + this.#terminal.cursorTo(inputPrompt.length + this.cursor); + this.#terminal.showCursor(); + + this.#terminal.commit(); + + this.emit('render'); + } + + /** + * @param {string[]} strings strings to write to stdout + */ + print(...strings) { + this.#terminal.print(...strings); + this.#terminal.newLine(); + this.#terminal.commit(); + } + + cancel() { + super.cancel(); + + this.#terminal.stopInput(); + this.#terminal.newLine(); + this.#terminal.commit(); + } + + prompt() { + super.prompt(); + + this.render(); + + // Start the async read loop... + this.#terminal.startInput(); + } +} + diff --git a/modules/esm/repl.js b/modules/esm/repl.js new file mode 100644 index 00000000..798624b5 --- /dev/null +++ b/modules/esm/repl.js @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later +// SPDX-FileCopyrightText: 2021 Evan Welsh <contact@evanwelsh.com> + +import gi from 'gi'; + +import { setRawMode, Readline } from './readline.js'; +import { prettyPrint } from './_prettyPrint.js'; + +const NativeConsole = import.meta.importSync('_consoleNative'); +const system = import.meta.importSync('system'); + +function runMainloop() { + imports.mainloop.run('repl'); +} + +function quitMainloop() { + imports.mainloop.quit('repl'); +} + +export class Repl { + #lineNumber = 0; + + /** @type {string} */ + #version; + + constructor() { + this.#version = system.versionString; + } + + [Symbol.toStringTag]() { + return 'Repl'; + } + + get lineNumber() { + return this.#lineNumber; + } + + #print(string) { + this.readline.print(`${string}`); + } + + #evaluateInternal(lines) { + try { + const result = NativeConsole.eval(lines, this.#lineNumber); + + this.#print(`${prettyPrint(result)}`); + + return null; + } catch (error) { + return error; + } + } + + #printError(error) { + if (error.message) + this.#print(`Uncaught ${error.name}: ${error.message}`); + else this.#print(`${prettyPrint(error)}`); + } + + #isValidInput(input) { + return NativeConsole.isValid(input); + } + + evaluateInput(lines) { + this.#lineNumber++; + + // Rough object/code block detection similar to Node + let trimmedLines = lines.trim(); + if (trimmedLines.startsWith('{') && !trimmedLines.endsWith(';')) { + let wrappedLines = `(${trimmedLines})\n`; + + // Attempt to evaluate any object literals in () first + let error = this.#evaluateInternal(wrappedLines); + if (error) this.#printError(error); + + return; + } + + let error = this.#evaluateInternal(lines); + if (!error) return; + + this.#printError(error); + } + + #startInput() { + this.readline.connect('validate', (_, line, input) => { + const isValid = this.#isValidInput(input); + + if (isValid && this.readline.hasMultilineInput()) { + this.readline.completeMultilineInput(); + return; + } + + if (!isValid) { + this.readline.addLineToMultilineInput(line); + } + }); + + this.readline.connect('line', (_, line) => { + this.evaluateInput(line); + }); + + this.readline.connect('multiline', (_, multiline) => { + this.evaluateInput(multiline); + }); + + this.readline.connect('exit', () => { + this.exit(); + }); + + this.readline.print(`GJS v${this.#version}`); + this.readline.prompt(); + } + + start() { + const prompt = '> '; + const pendingPrompt = '... '; + + this.readline = Readline.create({ prompt, pendingPrompt }); + + const {isRaw, isAsync} = this.readline; + + + try { + if (isRaw) setRawMode(true); + + this.#startInput(); + + if (isAsync) runMainloop(); + } finally { + if (isRaw) setRawMode(false); + } + } + + exit() { + try { + this.readline.cancel(); + + if (this.readline.isAsync) { + quitMainloop(); + } + } catch { + // Force an exit if an error occurs + imports.system.exit(1); + } + } +} + +// Install the Repl class in imports.console for backwards compatibility +imports.console.Repl = Repl; diff --git a/modules/internal/loader.js b/modules/internal/loader.js index 2f3f71d8..34854da5 100644 --- a/modules/internal/loader.js +++ b/modules/internal/loader.js @@ -67,7 +67,7 @@ class InternalModuleLoader { /** * @param {typeof globalThis} global the global object to handle module * resolution - * @param {(string, string) => import("../types").Module} compileFunc the + * @param {(string, string) => Module} compileFunc the * function to compile a source into a module for a particular global * object. Should be compileInternalModule() for InternalModuleLoader, * but overridden in ModuleLoader @@ -154,12 +154,12 @@ class InternalModuleLoader { /** * @param {string} specifier the specifier (e.g. relative path, root package) to resolve - * @param {string | null} importingModuleURI the URI of the module + * @param {ModulePrivate} importingModule the private object of the module * triggering this resolve * * @returns {Module | null} */ - resolveModule(specifier, importingModuleURI) { + resolveModule(specifier, importingModule) { const registry = getRegistry(this.global); // Check if the module has already been loaded @@ -168,7 +168,7 @@ class InternalModuleLoader { return module; // 1) Resolve path and URI-based imports. - const uri = this.resolveSpecifier(specifier, importingModuleURI); + const uri = this.resolveSpecifier(specifier, importingModule.uri); if (uri) { module = registry.get(uri.uri); @@ -180,7 +180,7 @@ class InternalModuleLoader { if (!result) return null; - const [text, internal = false] = result; + const [text, internal = importingModule.internal] = result; const priv = new ModulePrivate(uri.uri, uri.uri, internal); const compiled = this.compileModule(priv, text); @@ -193,7 +193,7 @@ class InternalModuleLoader { } moduleResolveHook(importingModulePriv, specifier) { - const resolved = this.resolveModule(specifier, importingModulePriv.uri ?? null); + const resolved = this.resolveModule(specifier, importingModulePriv); if (!resolved) throw new ImportError(`Module not found: ${specifier}`); @@ -300,7 +300,7 @@ class ModuleLoader extends InternalModuleLoader { * erroring if no resource is found. * * @param {string} specifier the module specifier to resolve for an import - * @returns {import("./internalLoader").Module} + * @returns {Module} */ resolveBareSpecifier(specifier) { // 2) Resolve internal imports. @@ -332,10 +332,10 @@ class ModuleLoader extends InternalModuleLoader { * @param {ModulePrivate} importingModulePriv * the private object of the module initiating the import * @param {string} specifier the module specifier to resolve for an import - * @returns {import("./internalLoader").Module} + * @returns {Module} */ moduleResolveHook(importingModulePriv, specifier) { - const module = this.resolveModule(specifier, importingModulePriv.uri); + const module = this.resolveModule(specifier, importingModulePriv); if (module) return module; @@ -348,18 +348,17 @@ class ModuleLoader extends InternalModuleLoader { if (!importingModulePriv || !importingModulePriv.uri) throw new ImportError('Cannot resolve relative imports from an unknown file.'); - return this.resolveModuleAsync(specifier, importingModulePriv.uri); + return this.resolveModuleAsync(specifier, importingModulePriv); } /** * Resolves a module import with optional handling for relative imports asynchronously. * * @param {string} specifier the specifier (e.g. relative path, root package) to resolve - * @param {string | null} importingModuleURI the URI of the module - * triggering this resolve - * @returns {import("../types").Module} + * @param {ModulePrivate | null} importingModule the private object of the module triggering this resolve + * @returns {Module} */ - async resolveModuleAsync(specifier, importingModuleURI) { + async resolveModuleAsync(specifier, importingModule) { const registry = getRegistry(this.global); // Check if the module has already been loaded @@ -368,7 +367,7 @@ class ModuleLoader extends InternalModuleLoader { return module; // 1) Resolve path and URI-based imports. - const uri = this.resolveSpecifier(specifier, importingModuleURI); + const uri = this.resolveSpecifier(specifier, importingModule.uri); if (uri) { module = registry.get(uri.uri); @@ -385,7 +384,7 @@ class ModuleLoader extends InternalModuleLoader { if (module) return module; - const [text, internal = false] = result; + const [text, internal = importingModule.internal] = result; const priv = new ModulePrivate(uri.uri, uri.uri, internal); const compiled = this.compileModule(priv, text); diff --git a/modules/modules.cpp b/modules/modules.cpp index 987aa51f..5ae299be 100644 --- a/modules/modules.cpp +++ b/modules/modules.cpp @@ -20,6 +20,6 @@ void gjs_register_static_modules(void) { registry.add("cairoNative", gjs_js_define_cairo_stuff); #endif registry.add("system", gjs_js_define_system_stuff); - registry.add("console", gjs_define_console_stuff); + registry.add("_consoleNative", gjs_define_console_private_stuff); registry.add("_print", gjs_define_print_stuff); } diff --git a/modules/script/_bootstrap/default.js b/modules/script/_bootstrap/default.js index 59c24930..428218f5 100644 --- a/modules/script/_bootstrap/default.js +++ b/modules/script/_bootstrap/default.js @@ -1,169 +1,3 @@ // SPDX-License-Identifier: MIT OR LGPL-2.0-or-later // SPDX-FileCopyrightText: 2013 Red Hat, Inc. // SPDX-FileCopyrightText: 2020 Evan Welsh <contact@evanwelsh.com> - -(function (exports) { - 'use strict'; - - const { - print, - printerr, - log: nativeLog, - logError: nativeLogError, - setPrettyPrintFunction, - } = imports._print; - - function log(...args) { - return nativeLog(args.map(arg => typeof arg === 'string' ? arg : prettyPrint(arg)).join(' ')); - } - - function logError(e, ...args) { - if (args.length === 0) - return nativeLogError(e); - return nativeLogError(e, args.map(arg => typeof arg === 'string' ? arg : prettyPrint(arg)).join(' ')); - } - - function prettyPrint(value) { - switch (typeof value) { - case 'object': - if (value.toString === Object.prototype.toString || - value.toString === Array.prototype.toString || - value.toString === Date.prototype.toString) { - const printedObjects = new WeakSet(); - return formatObject(value, printedObjects); - } - // If the object has a nonstandard toString, prefer that - return value.toString(); - case 'function': - if (value.toString === Function.prototype.toString) - return formatFunction(value); - return value.toString(); - case 'string': - return JSON.stringify(value); - case 'symbol': - return formatSymbol(value); - default: - return value.toString(); - } - } - - function formatPropertyKey(key) { - if (typeof key === 'symbol') - return `[${formatSymbol(key)}]`; - return `${key}`; - } - - function formatObject(obj, printedObjects) { - printedObjects.add(obj); - if (Array.isArray(obj)) - return formatArray(obj, printedObjects).toString(); - - if (obj instanceof Date) - return formatDate(obj); - - if (obj[Symbol.toStringTag] === 'GIRepositoryNamespace') - return obj.toString(); - - const formattedObject = []; - const keys = Object.getOwnPropertyNames(obj).concat(Object.getOwnPropertySymbols(obj)); - for (const propertyKey of keys) { - const value = obj[propertyKey]; - const key = formatPropertyKey(propertyKey); - switch (typeof value) { - case 'object': - if (printedObjects.has(value)) - formattedObject.push(`${key}: [Circular]`); - else - formattedObject.push(`${key}: ${formatObject(value, printedObjects)}`); - break; - case 'function': - formattedObject.push(`${key}: ${formatFunction(value)}`); - break; - case 'string': - formattedObject.push(`${key}: "${value}"`); - break; - case 'symbol': - formattedObject.push(`${key}: ${formatSymbol(value)}`); - break; - default: - formattedObject.push(`${key}: ${value}`); - break; - } - } - return Object.keys(formattedObject).length === 0 ? '{}' - : `{ ${formattedObject.join(', ')} }`; - } - - function formatArray(arr, printedObjects) { - const formattedArray = []; - for (const [key, value] of arr.entries()) { - if (printedObjects.has(value)) - formattedArray[key] = '[Circular]'; - else - formattedArray[key] = prettyPrint(value); - } - return `[${formattedArray.join(', ')}]`; - } - - function formatDate(date) { - return date.toISOString(); - } - - function formatFunction(func) { - let funcOutput = `[ Function: ${func.name} ]`; - return funcOutput; - } - - function formatSymbol(sym) { - // Try to format Symbols in the same way that they would be constructed. - - // First check if this is a global registered symbol - const globalKey = Symbol.keyFor(sym); - if (globalKey !== undefined) - return `Symbol.for("${globalKey}")`; - - const descr = sym.description; - // Special-case the 'well-known' (built-in) Symbols - if (descr.startsWith('Symbol.')) - return descr; - - // Otherwise, it's just a regular symbol - return `Symbol("${descr}")`; - } - - Object.defineProperties(exports, { - ARGV: { - configurable: false, - enumerable: true, - get() { - // Wait until after bootstrap or programArgs won't be set. - return imports.system.programArgs; - }, - }, - print: { - configurable: false, - enumerable: true, - writable: true, - value: print, - }, - printerr: { - configurable: false, - enumerable: true, - writable: true, - value: printerr, - }, - log: { - configurable: false, - enumerable: true, - writable: true, - value: log, - }, - logError: { - configurable: false, - enumerable: true, - writable: true, - value: logError, - }, - }); - setPrettyPrintFunction(exports, prettyPrint); -})(globalThis); diff --git a/modules/script/console.js b/modules/script/console.js new file mode 100644 index 00000000..d80b699c --- /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(); +} |