[gjs/ewlsh/whatwg-console: 1/3] modules: Implement WHATWG console specification




commit abf3505e4ceffbb5fb7a634189c8da51447c1984
Author: Evan Welsh <contact evanwelsh com>
Date:   Mon Aug 16 01:14:13 2021 -0700

    modules: Implement WHATWG console specification

 .eslintrc.yml                     |   1 +
 installed-tests/js/.eslintrc.yml  |   1 +
 installed-tests/js/matchers.js    |  36 +-
 installed-tests/js/meson.build    |   1 +
 installed-tests/js/testConsole.js | 257 ++++++++++++++
 js.gresource.xml                  |   1 +
 modules/esm/_bootstrap/default.js |   2 +
 modules/esm/console.js            | 720 ++++++++++++++++++++++++++++++++++++++
 8 files changed, 1018 insertions(+), 1 deletion(-)
---
diff --git a/.eslintrc.yml b/.eslintrc.yml
index dadf40bd..26bd8c74 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -259,5 +259,6 @@ globals:
   window: readonly
   TextEncoder: readonly
   TextDecoder: readonly
+  console: readonly
 parserOptions:
   ecmaVersion: 2020
diff --git a/installed-tests/js/.eslintrc.yml b/installed-tests/js/.eslintrc.yml
index cdf5cf9f..abc9c527 100644
--- a/installed-tests/js/.eslintrc.yml
+++ b/installed-tests/js/.eslintrc.yml
@@ -33,6 +33,7 @@ overrides:
   - files:
       - matchers.js
       - testCairoModule.js
+      - testConsole.js
       - testESModules.js
       - testEncoding.js
       - testGLibLogWriter.js
diff --git a/installed-tests/js/matchers.js b/installed-tests/js/matchers.js
index 6a2848f6..1e05828f 100644
--- a/installed-tests/js/matchers.js
+++ b/installed-tests/js/matchers.js
@@ -26,7 +26,41 @@ export function arrayLikeWithExactContents(elements) {
          * @returns {string}
          */
         jasmineToString() {
-            return `${JSON.stringify(elements)}`;
+            return `<arrayLikeWithExactContents(${
+                elements.constructor.name
+            }[${JSON.stringify(Array.from(elements))}]>)`;
+        },
+    };
+}
+
+/**
+ * A jasmine asymmetric matcher which compares a given string to an
+ * array-like object of bytes. The compared bytes are decoded using
+ * TextDecoder and then compared using jasmine.stringMatching.
+ *
+ * @param {string | RegExp} text the text or regular expression to compare decoded bytes to
+ * @param {string} [encoding] the encoding of elements
+ * @returns
+ */
+export function decodedStringMatching(text, encoding = 'utf-8') {
+    const matcher = jasmine.stringMatching(text);
+
+    return {
+        /**
+         * @param {ArrayLike<number>} compareTo an array of bytes to decode and compare to
+         * @returns {boolean}
+         */
+        asymmetricMatch(compareTo) {
+            const decoder = new TextDecoder(encoding);
+            const decoded = decoder.decode(new Uint8Array(Array.from(compareTo)));
+
+            return matcher.asymmetricMatch(decoded, []);
+        },
+        /**
+         * @returns {string}
+         */
+        jasmineToString() {
+            return `<decodedStringMatching(${text})>`;
         },
     };
 }
diff --git a/installed-tests/js/meson.build b/installed-tests/js/meson.build
index f85b9586..b42f3b20 100644
--- a/installed-tests/js/meson.build
+++ b/installed-tests/js/meson.build
@@ -241,6 +241,7 @@ endif
 # minijasmine flag
 
 modules_tests = [
+    'Console',
     'ESModules',
     'Encoding',
     'GLibLogWriter',
diff --git a/installed-tests/js/testConsole.js b/installed-tests/js/testConsole.js
new file mode 100644
index 00000000..43027439
--- /dev/null
+++ b/installed-tests/js/testConsole.js
@@ -0,0 +1,257 @@
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2021 Evan Welsh <contact evanwelsh com>
+
+// eslint-disable-next-line
+/// <reference types="jasmine" />
+
+import GLib from 'gi://GLib';
+import {DEFAULT_LOG_DOMAIN, Console} from 'console';
+
+import {decodedStringMatching} from './matchers.js';
+
+function objectContainingLogMessage(
+    message,
+    domain = DEFAULT_LOG_DOMAIN,
+    fields = {}
+) {
+    return jasmine.objectContaining({
+        MESSAGE: decodedStringMatching(message),
+        GLIB_DOMAIN: decodedStringMatching(domain),
+        ...fields,
+    });
+}
+
+describe('console', function () {
+    /** @type {jasmine.Spy<(_level: any, _fields: any) => any>} */
+    let writer_func;
+
+    /**
+     * @param {RegExp | string} message _
+     * @param {*} [logLevel] _
+     * @param {*} [domain] _
+     * @param {*} [fields] _
+     */
+    function expectLog(
+        message,
+        logLevel = GLib.LogLevelFlags.LEVEL_MESSAGE,
+        domain = DEFAULT_LOG_DOMAIN,
+        fields = {}
+    ) {
+        expect(writer_func).toHaveBeenCalledOnceWith(
+            logLevel,
+            objectContainingLogMessage(message, domain, fields)
+        );
+
+        // Always reset the calls, so that we can assert at the end that no
+        // unexpected messages were logged
+        writer_func.calls.reset();
+    }
+
+    beforeAll(function () {
+        writer_func = jasmine.createSpy(
+            'Console test writer func',
+            function (level, _fields) {
+                if (level === GLib.LogLevelFlags.ERROR)
+                    return GLib.LogWriterOutput.UNHANDLED;
+
+                return GLib.LogWriterOutput.HANDLED;
+            }
+        );
+
+        writer_func.and.callThrough();
+
+        // @ts-expect-error The existing binding doesn't accept any parameters because
+        // it is a raw pointer.
+        GLib.log_set_writer_func(writer_func);
+    });
+
+    beforeEach(function () {
+        writer_func.calls.reset();
+    });
+
+    it('has correct object tag', function () {
+        expect(console.toString()).toBe('[object console]');
+    });
+
+    it('logs a message', function () {
+        console.log('a log');
+
+        expect(writer_func).toHaveBeenCalledOnceWith(
+            GLib.LogLevelFlags.LEVEL_MESSAGE,
+            objectContainingLogMessage('a log')
+        );
+        writer_func.calls.reset();
+    });
+
+    it('logs a warning', function () {
+        console.warn('a warning');
+
+        expect(writer_func).toHaveBeenCalledOnceWith(
+            GLib.LogLevelFlags.LEVEL_WARNING,
+            objectContainingLogMessage('a warning')
+        );
+        writer_func.calls.reset();
+    });
+
+    it('logs an informative message', function () {
+        console.info('an informative message');
+
+        expect(writer_func).toHaveBeenCalledOnceWith(
+            GLib.LogLevelFlags.LEVEL_INFO,
+            objectContainingLogMessage('an informative message')
+        );
+        writer_func.calls.reset();
+    });
+
+    describe('console.clear()', function () {
+        it('clear can be called.', function () {
+            console.clear();
+        });
+
+        it('clear resets indentation.', function () {
+            console.group('a group');
+            expectLog('a group');
+            console.log('a log');
+            expectLog('  a log');
+            console.clear();
+            console.log('a log');
+            expectLog('a log');
+        });
+    });
+
+    // %s - string
+    // %d or %i - integer
+    // %f - float
+    // %o  - "optimal" object formatting
+    // %O - "generic" object formatting
+    // %c - "CSS" formatting (unimplemented by GJS)
+    describe('string replacement', function () {
+        const functions = {
+            log: GLib.LogLevelFlags.LEVEL_MESSAGE,
+            warn: GLib.LogLevelFlags.LEVEL_WARNING,
+            info: GLib.LogLevelFlags.LEVEL_INFO,
+            error: GLib.LogLevelFlags.LEVEL_CRITICAL,
+        };
+
+        Object.entries(functions).forEach(([fn, level]) => {
+            it(`console.${fn}() supports %s`, function () {
+                console[fn]('Does this %s substitute correctly?', 'modifier');
+                expectLog('Does this modifier substitute correctly?', level);
+            });
+
+            it(`console.${fn}() supports %d`, function () {
+                console[fn]('Does this %d substitute correctly?', 10);
+                expectLog('Does this 10 substitute correctly?', level);
+            });
+
+            it(`console.${fn}() supports %i`, function () {
+                console[fn]('Does this %i substitute correctly?', 26);
+                expectLog('Does this 26 substitute correctly?', level);
+            });
+
+            it(`console.${fn}() supports %f`, function () {
+                console[fn]('Does this %f substitute correctly?', 27.56331);
+                expectLog('Does this 27.56331 substitute correctly?', level);
+            });
+
+            it(`console.${fn}() supports %o`, function () {
+                console[fn]('Does this %o substitute correctly?', new Error());
+                expectLog(/Does this Error\n.*substitute correctly\?/s, level);
+            });
+
+            it(`console.${fn}() supports %O`, function () {
+                console[fn]('Does this %O substitute correctly?', new Error());
+                expectLog('Does this {} substitute correctly?', level);
+            });
+
+            it(`console.${fn}() ignores %c`, function () {
+                console[fn]('Does this %c substitute correctly?', 'modifier');
+                expectLog('Does this  substitute correctly?', level);
+            });
+
+            it(`console.${fn}() supports mixing substitutions`, function () {
+                console[fn](
+                    'Does this %s and the %f substitute correctly alongside %d?',
+                    'string',
+                    3.14,
+                    14
+                );
+                expectLog(
+                    'Does this string and the 3.14 substitute correctly alongside 14?',
+                    level
+                );
+            });
+
+            it(`console.${fn}() supports invalid numbers`, function () {
+                console[fn](
+                    'Does this support parsing %i incorrectly?',
+                    'a string'
+                );
+                expectLog('Does this support parsing NaN incorrectly?', level);
+            });
+
+            it(`console.${fn}() supports missing substitutions`, function () {
+                console[fn]('Does this support a missing %s substitution?');
+                expectLog(
+                    'Does this support a missing %s substitution?',
+                    level
+                );
+            });
+        });
+    });
+
+    describe('console.time()', function () {
+        it('ends correctly', function (done) {
+            console.time('testing time');
+
+            // console.time logs nothing.
+            expect(writer_func).not.toHaveBeenCalled();
+
+            setTimeout(() => {
+                console.timeLog('testing time');
+
+                expectLog(/testing time: (.*)ms/);
+
+                console.timeEnd('testing time');
+
+                expectLog(/testing time: (.*)ms/);
+
+                console.timeLog('testing time');
+
+                expectLog(
+                    "No time log found for label: 'testing time'.",
+                    GLib.LogLevelFlags.LEVEL_WARNING
+                );
+
+                done();
+            }, 10);
+        });
+
+        it("doesn't log initially", function (done) {
+            console.time('testing time');
+
+            // console.time logs nothing.
+            expect(writer_func).not.toHaveBeenCalled();
+
+            setTimeout(() => {
+                console.timeEnd('testing time');
+                expectLog(/testing time: (.*)ms/);
+
+                done();
+            }, 10);
+        });
+
+        afterEach(function () {
+            // Ensure we only got the log lines that we expected
+            expect(writer_func).not.toHaveBeenCalled();
+        });
+    });
+});
+
+describe('Console constructor', function () {
+    const console = new Console();
+
+    it('has correct object tag', function () {
+        expect(console.toString()).toBe('[object Console]');
+    });
+});
diff --git a/js.gresource.xml b/js.gresource.xml
index 947049c2..a730f2b8 100644
--- a/js.gresource.xml
+++ b/js.gresource.xml
@@ -16,6 +16,7 @@
   
     <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/system.js</file>
 
diff --git a/modules/esm/_bootstrap/default.js b/modules/esm/_bootstrap/default.js
index eb315af7..afb155b0 100644
--- a/modules/esm/_bootstrap/default.js
+++ b/modules/esm/_bootstrap/default.js
@@ -5,3 +5,5 @@
 
 // Bootstrap the Encoding API
 import '_encoding/encoding';
+// Bootstrap the Console API
+import 'console';
diff --git a/modules/esm/console.js b/modules/esm/console.js
new file mode 100644
index 00000000..08fead8c
--- /dev/null
+++ b/modules/esm/console.js
@@ -0,0 +1,720 @@
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2021 Evan Welsh <contact evanwelsh com>
+
+import GLib from 'gi://GLib';
+
+const sLogger = Symbol('Logger');
+const sPrinter = Symbol('Printer');
+const sFormatter = Symbol('Formatter');
+const sGroupIndentation = Symbol('GroupIndentation');
+const sTimeLabels = Symbol('Time Labels');
+const sCountLabels = Symbol('Count Labels');
+const sLogDomain = Symbol('Log Domain');
+
+const DEFAULT_LOG_DOMAIN = 'Gjs-Console';
+
+// A line-by-line implementation of https://console.spec.whatwg.org/.
+
+// 2.2.1. Formatting specifiers
+// https://console.spec.whatwg.org/#formatting-specifiers
+//
+// %s - string
+// %d or %i - integer
+// %f - float
+// %o - "optimal" object formatting
+// %O - "generic" object formatting
+// %c - "CSS" formatting (unimplemented by GJS)
+
+/**
+ * A simple regex to capture formatting specifiers
+ */
+const specifierTest = /%(d|i|s|f|o|O|c)/;
+
+/**
+ * @param {string} str a string to check for format specifiers like %s or %i
+ * @returns {boolean}
+ */
+function hasFormatSpecifiers(str) {
+    return specifierTest.test(str);
+}
+
+/**
+ * @param {any} item an item to format
+ */
+function formatGenerically(item) {
+    return JSON.stringify(item, null, 4);
+}
+
+/**
+ * @param {any} item an item to format
+ * @returns {string}
+ */
+function formatOptimally(item) {
+    // Handle optimal error formatting.
+    if (item instanceof Error) {
+        return `${item.toString()}${item.stack ? '\n' : ''}${item.stack
+            ?.split('\n')
+            // Pad each stacktrace line.
+            .map(line => line.padStart(2, ' '))
+            .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);
+}
+
+const propertyAttributes = {
+    writable: true,
+    enumerable: false,
+    configurable: true,
+};
+
+/**
+ * @typedef ConsoleInternalProps
+ * @property {string} [sGroupIndentation]
+ * @property {Record<string, number>} [sCountLabels]
+ * @property {Record<string, number>} [sTimeLabels]
+ * @property {string} [sLogDomain]
+ */
+
+/**
+ * Implementation of the WHATWG Console object.
+ *
+ * @implements {ConsoleInternalProps}
+ */
+// @ts-expect-error Console does not actually implement ConsoleInternalProps,
+// once private class fields are merged we will remove the interface.
+class Console {
+    constructor() {
+        // Redefine the internal functions as non-enumerable.
+        Object.defineProperties(this, {
+            [sLogger]: {
+                ...propertyAttributes,
+                value: this[sLogger].bind(this),
+            },
+            [sFormatter]: {
+                ...propertyAttributes,
+                value: this[sFormatter].bind(this),
+            },
+            [sPrinter]: {
+                ...propertyAttributes,
+                value: this[sPrinter].bind(this),
+            },
+        });
+    }
+
+    get [Symbol.toStringTag]() {
+        return 'Console';
+    }
+
+    // 1.1 Logging functions
+    // https://console.spec.whatwg.org/#logging
+
+    /**
+     * Logs a critical message if the condition is not truthy.
+     * {@see console.error()} for additional information.
+     *
+     * @param {boolean} condition a boolean condition which, if false, causes
+     *   the log to print
+     * @param  {...any} data formatting substitutions, if applicable
+     * @returns {void}
+     */
+    assert(condition, ...data) {
+        if (condition)
+            return;
+
+        let message = 'Assertion failed';
+
+        if (data.length === 0)
+            data.push(message);
+
+        if (typeof data[0] !== 'string') {
+            data.unshift(message);
+        } else {
+            const first = data.shift();
+            data.unshift(`${message}: ${first}`);
+        }
+        this[sLogger]('assert', data);
+    }
+
+    /**
+     * Resets grouping and clears the terminal on systems supporting ANSI
+     * terminal control sequences.
+     *
+     * In file-based stdout or systems which do not support clearing,
+     * console.clear() has no visual effect.
+     *
+     * @returns {void}
+     */
+    clear() {
+        this[sGroupIndentation] = '';
+    }
+
+    /**
+     * Logs a message with severity equal to {@see GLib.LogLevelFlags.DEBUG}.
+     *
+     * @param  {...any} data formatting substitutions, if applicable
+     */
+    debug(...data) {
+        this[sLogger]('debug', data);
+    }
+
+    /**
+     * Logs a message with severity equal to {@see GLib.LogLevelFlags.CRITICAL}.
+     * Does not use {@see GLib.LogLevelFlags.ERROR} to avoid asserting and
+     * forcibly shutting down the application.
+     *
+     * @param  {...any} data formatting substitutions, if applicable
+     */
+    error(...data) {
+        this[sLogger]('error', data);
+    }
+
+    /**
+     * Logs a message with severity equal to {@see GLib.LogLevelFlags.INFO}.
+     *
+     * @param  {...any} data formatting substitutions, if applicable
+     */
+    info(...data) {
+        this[sLogger]('info', data);
+    }
+
+    /**
+     * Logs a message with severity equal to {@see GLib.LogLevelFlags.MESSAGE}.
+     *
+     * @param  {...any} data formatting substitutions, if applicable
+     */
+    log(...data) {
+        this[sLogger]('log', data);
+    }
+
+    // 1.1.7 table(tabularData, properties)
+    table(_tabularData, _properties) {
+        throw new Error('table() is not implemented.');
+    }
+
+    /**
+     * @param  {...any} data formatting substitutions, if applicable
+     * @returns {void}
+     */
+    trace(...data) {
+        if (data.length === 0)
+            data = ['Trace'];
+
+        this[sPrinter]('trace', data, {
+            stackTrace:
+                // We remove the first line to avoid logging this line as part
+                // of the trace.
+                new Error().stack?.split('\n', 2)?.[1],
+        });
+    }
+
+    /**
+     * @param  {...any} data formatting substitutions, if applicable
+     * @returns {void}
+     */
+    warn(...data) {
+        const {[sLogger]: Logger} = this;
+        Logger('warn', data);
+    }
+
+    /**
+     * @param {object} item an item to format generically
+     * @param {never} [options] any additional options for the formatter. Unused
+     *   in our implementation.
+     */
+    dir(item, options) {
+        const object = formatGenerically(item);
+
+        this[sPrinter]('dir', [object], options);
+    }
+
+    /**
+     * @param  {...any} data formatting substitutions, if applicable
+     * @returns {void}
+     */
+    dirxml(...data) {
+        this.log(...data);
+    }
+
+    // 1.2 Counting functions
+    // https://console.spec.whatwg.org/#counting
+
+    /**
+     * Logs how many times console.count(label) has been called with a given
+     * label.
+     * {@see console.countReset()} for resetting a count.
+     *
+     * @param  {string} label unique identifier for this action
+     * @returns {void}
+     */
+    count(label) {
+        this[sCountLabels][label] = this[sCountLabels][label] ?? 0;
+        const count = ++this[sCountLabels][label];
+        const concat = `${label}: ${count}`;
+
+        this[sLogger]('count', [concat]);
+    }
+
+    /**
+     * @param  {string} label the unique label to reset the count for
+     * @returns {void}
+     */
+    countReset(label) {
+        const {[sPrinter]: Printer} = this;
+
+        const count = this[sCountLabels][label];
+
+        if (typeof count !== 'number')
+            Printer('reportWarning', [`No count found for label: '${label}'.`]);
+        else
+            this[sCountLabels][label] = 0;
+    }
+
+    // 1.3 Grouping functions
+    // https://console.spec.whatwg.org/#grouping
+
+    /**
+     * @param  {...any} data formatting substitutions, if applicable
+     * @returns {void}
+     */
+    group(...data) {
+        const {[sLogger]: Logger} = this;
+
+        Logger('group', data);
+
+        this[sGroupIndentation] += '  ';
+    }
+
+    /**
+     * Alias for console.group()
+     *
+     * @param  {...any} data formatting substitutions, if applicable
+     * @returns {void}
+     */
+    groupCollapsed(...data) {
+        // We can't 'collapse' output in a terminal, so we alias to
+        // group()
+        this.group(...data);
+    }
+
+    /**
+     * @returns {void}
+     */
+    groupEnd() {
+        this[sGroupIndentation] = this[sGroupIndentation].slice(0, -2);
+    }
+
+    // 1.4 Timing functions
+    // https://console.spec.whatwg.org/#timing
+
+    /**
+     * @param {string} label unique identifier for this action, pass to
+     *   console.timeEnd() to complete
+     * @returns {void}
+     */
+    time(label) {
+        this[sTimeLabels][label] = GLib.get_monotonic_time();
+    }
+
+    /**
+     * Logs the time since the last call to console.time(label) where label is
+     * the same.
+     *
+     * @param {string} label unique identifier for this action, pass to
+     *   console.timeEnd() to complete
+     * @param {...any} data string substitutions, if applicable
+     * @returns {void}
+     */
+    timeLog(label, ...data) {
+        const {[sPrinter]: Printer} = this;
+
+        const startTime = this[sTimeLabels][label];
+
+        if (typeof startTime !== 'number') {
+            Printer('reportWarning', [
+                `No time log found for label: '${label}'.`,
+            ]);
+        } else {
+            const durationMs = (GLib.get_monotonic_time() - startTime) / 1000;
+            const concat = `${label}: ${durationMs.toFixed(3)} ms`;
+            data.unshift(concat);
+
+            Printer('timeLog', data);
+        }
+    }
+
+    /**
+     * Logs the time since the last call to console.time(label) and completes
+     * the action.
+     * Call console.time(label) again to re-measure.
+     *
+     * @param {string} label unique identifier for this action
+     * @returns {void}
+     */
+    timeEnd(label) {
+        const {[sPrinter]: Printer} = this;
+        const startTime = this[sTimeLabels][label];
+
+        if (typeof startTime !== 'number') {
+            Printer('reportWarning', [
+                `No time log found for label: '${label}'.`,
+            ]);
+        } else {
+            delete this[sTimeLabels][label];
+
+            const durationMs = (GLib.get_monotonic_time() - startTime) / 1000;
+            const concat = `${label}: ${durationMs.toFixed(3)} ms`;
+
+            Printer('timeEnd', [concat]);
+        }
+    }
+
+    // Non-standard functions which are de-facto standards.
+    // Similar to Node, we define these as no-ops for now.
+
+    /**
+     * @deprecated Not implemented in GJS
+     *
+     * @param {string} _label unique identifier for this action, pass to
+     *   console.profileEnd to complete
+     * @returns {void}
+     */
+    profile(_label) {}
+
+    /**
+     * @deprecated Not implemented in GJS
+     *
+     * @param {string} _label unique identifier for this action
+     * @returns {void}
+     */
+    profileEnd(_label) {}
+
+    /**
+     * @deprecated Not implemented in GJS
+     *
+     * @param {string} _label unique identifier for this action
+     * @returns {void}
+     */
+    timeStamp(_label) {}
+
+    // GJS-specific extensions for integrating with GLib structured logging
+
+    /**
+     * @param {string} logDomain the GLib log domain this Console should print
+     *   with. Defaults to 'Gjs-Console'.
+     * @returns {void}
+     */
+    setLogDomain(logDomain) {
+        this[sLogDomain] = String(logDomain);
+    }
+
+    /**
+     * @returns {string}
+     */
+    get logDomain() {
+        return this[sLogDomain];
+    }
+
+    // 2. Supporting abstract operations
+    // https://console.spec.whatwg.org/#supporting-ops
+
+    /**
+     * 2.1. Logger
+     * https://console.spec.whatwg.org/#logger
+     *
+     * Conditionally applies formatting based on the inputted arguments,
+     * and prints at the provided severity (logLevel)
+     *
+     * @param {string} logLevel the severity (log level) the args should be
+     *   emitted with
+     * @param {unknown[]} args the arguments to pass to the printer
+     * @returns {void}
+     */
+    [sLogger](logLevel, args) {
+        const {[sFormatter]: Formatter, [sPrinter]: Printer} = this;
+
+        if (args.length === 0)
+            return;
+
+        let [first, ...rest] = args;
+
+        if (rest.length === 0) {
+            Printer(logLevel, [first]);
+            return undefined;
+        }
+
+        // If first does not contain any format specifiers, don't call Formatter
+        if (typeof first !== 'string' || !hasFormatSpecifiers(first)) {
+            Printer(logLevel, args);
+            return undefined;
+        }
+
+        // Otherwise, perform print the result of Formatter.
+        Printer(logLevel, Formatter([first, ...rest]));
+
+        return undefined;
+    }
+
+    /**
+     * 2.2. Formatter
+     * https://console.spec.whatwg.org/#formatter
+     *
+     * @param {[string, ...any[]]} args an array of format strings followed by
+     *   their arguments
+     */
+    [sFormatter](args) {
+        const {[sFormatter]: Formatter} = this;
+
+        // The initial formatting string is the first arg
+        let target = args[0];
+
+        if (args.length === 1)
+            return target;
+
+        let current = args[1];
+
+        // Find the index of the first format specifier.
+        const specifierIndex = specifierTest.exec(target).index;
+        const specifier = target.slice(specifierIndex, specifierIndex + 2);
+        let converted = null;
+        switch (specifier) {
+        case '%s':
+            converted = String(current);
+            break;
+        case '%d':
+        case '%i':
+            if (typeof current === 'symbol')
+                converted = Number.NaN;
+            else
+                converted = parseInt(current, 10);
+            break;
+        case '%f':
+            if (typeof current === 'symbol')
+                converted = Number.NaN;
+            else
+                converted = parseFloat(current);
+            break;
+        case '%o':
+            converted = formatOptimally(current);
+            break;
+        case '%O':
+            converted = formatGenerically(current);
+            break;
+        case '%c':
+            converted = '';
+            break;
+        }
+        // If any of the previous steps set converted, replace the specifier in
+        // target with the converted value.
+        if (converted !== null) {
+            target =
+                target.slice(0, specifierIndex) +
+                converted +
+                target.slice(specifierIndex + 2);
+        }
+
+        /**
+         * Create the next format input...
+         *
+         * @type {[string, ...any[]]}
+         */
+        let result = [target, ...args.slice(2)];
+
+        if (!hasFormatSpecifiers(target))
+            return result;
+
+        if (result.length === 1)
+            return result;
+
+        return Formatter(result);
+    }
+
+    /**
+     * @typedef {object} PrinterOptions
+     * @param {string} [stackTrace] an error stacktrace to append
+     * @param {Record<string, any>} [fields] fields to include in the structured
+     *   logging call
+     */
+
+    /**
+     * 2.3. Printer
+     * https://console.spec.whatwg.org/#printer
+     *
+     * This implementation of Printer maps WHATWG log severity to
+     * {@see GLib.LogLevelFlags} and outputs using GLib structured logging.
+     *
+     * @param {string} logLevel the log level (log tag) the args should be
+     *   emitted with
+     * @param {unknown[]} args the arguments to print, either a format string
+     *   with replacement args or multiple strings
+     * @param {PrinterOptions} [options] additional options for the
+     *   printer
+     * @returns {void}
+     */
+    [sPrinter](logLevel, args, options) {
+        let severity;
+
+        switch (logLevel) {
+        case 'log':
+        case 'dir':
+        case 'dirxml':
+        case 'trace':
+        case 'group':
+        case 'groupCollapsed':
+        case 'timeLog':
+        case 'timeEnd':
+            severity = GLib.LogLevelFlags.LEVEL_MESSAGE;
+            break;
+        case 'debug':
+            severity = GLib.LogLevelFlags.LEVEL_DEBUG;
+            break;
+        case 'count':
+        case 'info':
+            severity = GLib.LogLevelFlags.LEVEL_INFO;
+            break;
+        case 'warn':
+        case 'countReset':
+        case 'reportWarning':
+            severity = GLib.LogLevelFlags.LEVEL_WARNING;
+            break;
+        case 'error':
+        case 'assert':
+            severity = GLib.LogLevelFlags.LEVEL_CRITICAL;
+            break;
+        default:
+            severity = GLib.LogLevelFlags.LEVEL_MESSAGE;
+        }
+
+        let output = args
+            .map(a => {
+                if (a === null)
+                    return 'null';
+                else if (typeof a === 'object')
+                    return formatOptimally(a);
+                else if (typeof a === 'function')
+                    return a.toString();
+                else if (typeof a === 'undefined')
+                    return 'undefined';
+                else if (typeof a === 'bigint')
+                    return `${a}n`;
+                else
+                    return String(a);
+            })
+            .join(' ');
+
+        let formattedOutput = this[sGroupIndentation] + output;
+
+        if (logLevel === 'trace') {
+            formattedOutput =
+                `${output}\n${options?.stackTrace}` ?? 'No trace available';
+        }
+
+        GLib.log_structured(this[sLogDomain], severity, {
+            MESSAGE: formattedOutput,
+        });
+    }
+}
+
+Object.defineProperties(Console.prototype, {
+    [sGroupIndentation]: {
+        ...propertyAttributes,
+        value: '',
+    },
+    [sCountLabels]: {
+        ...propertyAttributes,
+        /** @type {Record<string, number>} */
+        value: {},
+    },
+    [sTimeLabels]: {
+        ...propertyAttributes,
+        /** @type {Record<string, number>} */
+        value: {},
+    },
+    [sLogDomain]: {
+        ...propertyAttributes,
+        value: DEFAULT_LOG_DOMAIN,
+    },
+});
+
+const console = new Console();
+
+/**
+ * @param {string} domain set the GLib log domain for the global console object.
+ */
+function setConsoleLogDomain(domain) {
+    console.setLogDomain(domain);
+}
+
+/**
+ * @returns {string}
+ */
+function getConsoleLogDomain() {
+    return console.logDomain;
+}
+
+/**
+ * For historical web-compatibility reasons, the namespace object for
+ * console must have {} as its [[Prototype]].
+ *
+ * @type {Omit<Console, 'setLogDomain' | 'logDomain'>}
+ */
+const globalConsole = Object.create({});
+
+const propertyNames =
+    /** @type {['constructor', ...Array<string & keyof Console>]} */
+    // eslint-disable-next-line no-extra-parens
+    (Object.getOwnPropertyNames(Console.prototype));
+const propertyDescriptors = Object.getOwnPropertyDescriptors(Console.prototype);
+for (const key of propertyNames) {
+    if (key === 'constructor')
+        continue;
+
+    // This non-standard function shouldn't be included.
+    if (key === 'setLogDomain')
+        continue;
+
+    const descriptor = propertyDescriptors[key];
+    if (typeof descriptor.value !== 'function')
+        continue;
+
+    Object.defineProperty(globalConsole, key, {
+        ...descriptor,
+        value: descriptor.value.bind(console),
+    });
+}
+Object.defineProperties(globalConsole, {
+    [Symbol.toStringTag]: {
+        configurable: false,
+        enumerable: true,
+        get() {
+            return 'console';
+        },
+    },
+});
+Object.freeze(globalConsole);
+
+Object.defineProperty(globalThis, 'console', {
+    configurable: false,
+    enumerable: true,
+    writable: false,
+    value: globalConsole,
+});
+
+export {
+    getConsoleLogDomain,
+    setConsoleLogDomain,
+    DEFAULT_LOG_DOMAIN,
+    console,
+    Console
+};
+
+export default {
+    getConsoleLogDomain,
+    setConsoleLogDomain,
+    DEFAULT_LOG_DOMAIN,
+    console,
+    Console,
+};


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