[gjs/ewlsh/whatwg-console: 42/47] modules: Implement WHATWG console specification




commit 014f9aa782bc2a1d748e61e98a26e2da10b37ec8
Author: Evan Welsh <contact evanwelsh com>
Date:   Thu Jul 1 10:21:01 2021 -0700

    modules: Implement WHATWG console specification

 .editorconfig                     |   3 +
 .eslintrc.yml                     |   1 +
 js.gresource.xml                  |   1 +
 modules/esm/_bootstrap/default.js |   2 +
 modules/esm/_encoding/encoding.js |  16 +-
 modules/esm/console.js            | 713 ++++++++++++++++++++++++++++++++++++++
 6 files changed, 732 insertions(+), 4 deletions(-)
---
diff --git a/.editorconfig b/.editorconfig
index 848b14fc..da26890c 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -12,3 +12,6 @@ charset = utf-8
 trim_trailing_whitespace = true
 end_of_line = lf
 insert_final_newline = true
+
+[*.js]
+quote_type = single
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/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/_encoding/encoding.js b/modules/esm/_encoding/encoding.js
index e84b752b..21a5cfd9 100644
--- a/modules/esm/_encoding/encoding.js
+++ b/modules/esm/_encoding/encoding.js
@@ -39,13 +39,13 @@ class TextDecoder {
         if (!encodingDefinition)
             throw new RangeError(`Invalid encoding label: '${encoding}'`);
 
-
         if (encodingDefinition.label === 'replacement') {
             throw new RangeError(
                 `Unsupported replacement encoding: '${encoding}'`
             );
         }
 
+        this._internalEncoding = encodingDefinition.internalLabel;
         Object.defineProperty(this, '_internalEncoding', {
             value: encodingDefinition.internalLabel,
             enumerable: false,
@@ -99,15 +99,21 @@ class TextDecoder {
             input = bytes;
         } else if (bytes instanceof Object.getPrototypeOf(Uint8Array)) {
             let {buffer, byteLength, byteOffset} =
-                /** @type {Uint32Array} */ bytes;
+            /** @type {Uint32Array} */
+            // eslint-disable-next-line no-extra-parens
+            (bytes);
             input = new Uint8Array(buffer, byteOffset, byteLength);
         } else if (
             typeof bytes === 'object' &&
             bytes !== null &&
             'buffer' in bytes &&
+            // @ts-expect-error
             bytes.buffer instanceof ArrayBuffer
         ) {
-            let {buffer, byteLength, byteOffset} = bytes;
+            let {buffer, byteLength, byteOffset} =
+                /** @type {Uint32Array} */
+                // eslint-disable-next-line no-extra-parens
+                (bytes);
             input = new Uint8Array(buffer, byteOffset, byteLength);
         } else if (bytes === undefined) {
             input = new Uint8Array(0);
@@ -127,7 +133,6 @@ class TextDecoder {
             if (this.encoding !== 'utf-8')
                 throw new Error('Cannot ignore BOM for non-UTF8 encoding.');
 
-
             let {buffer, byteLength, byteOffset} = input;
             input = new Uint8Array(buffer, byteOffset + 3, byteLength - 3);
         }
@@ -156,6 +161,9 @@ class TextEncoder {
     }
 }
 
+globalThis.TextEncoder = TextEncoder;
+globalThis.TextDecoder = TextDecoder;
+
 Object.defineProperties(globalThis, {
     TextEncoder: {
         configurable: false,
diff --git a/modules/esm/console.js b/modules/esm/console.js
new file mode 100644
index 00000000..a045cb85
--- /dev/null
+++ b/modules/esm/console.js
@@ -0,0 +1,713 @@
+// 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');
+
+export 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
+ */
+function formatOptimally(item) {
+    // TODO: Consider 'optimal' formatting.
+    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.
+export 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 '[object 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);
+    }
+
+    /**
+     * 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 effect.
+     *
+     * @returns {void}
+     */
+    clear() {
+        throw new Error('clear() is not implemented.');
+    }
+
+    /**
+     * 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}: ${String(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] = Date.now();
+    }
+
+    /**
+     * 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 duration = Date.now() - startTime;
+            const concat = `${label}: ${duration}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 duration = Date.now() - startTime;
+            const concat = `${label}: ${duration.toFixed(0)}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 is empty, return.
+        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.
+        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 there are no args passed, return target.
+        if (args.length === 1)
+            return target;
+
+        // Let current be the second element of args.
+        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) {
+        // If specifier is %s, convert to string using String(...)
+        case '%s':
+            converted = String(current);
+            break;
+            // If specifier is %d or %i:
+        case '%d':
+        case '%i':
+            // If current is a symbol, replace with NaN
+            if (typeof current === 'symbol')
+                converted = Number.NaN;
+            // Otherwise, replace with parseInt.
+            else
+                converted = parseInt(current, 10);
+            break;
+            // If specifier is %f:
+        case '%f':
+            // If current is a symbol, replace with NaN
+            if (typeof current === 'symbol')
+                converted = Number.NaN;
+            // Otherwise, replace with parseFloat.
+            else
+                converted = parseFloat(current);
+            break;
+            // If specifier is %o:
+        case '%o':
+            // Format current "optimally"
+            converted = formatOptimally(current);
+            break;
+            // If specifier is %O:
+        case '%O':
+            // Format current "generically"
+            converted = formatGenerically(current);
+            break;
+            // TODO: consider how to handle %c
+        case '%c':
+            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 target does not have any format specifiers left, return result.
+        if (hasFormatSpecifiers(target))
+            return result;
+
+        // If result's size is 1, return result.
+        if (result.length === 1)
+            return result;
+
+        // Return Formatter(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':
+            severity = GLib.LogLevelFlags.LEVEL_WARNING;
+            break;
+        case 'error':
+        case 'assert':
+            severity = GLib.LogLevelFlags.LEVEL_CRITICAL;
+            break;
+        default:
+            severity = GLib.LogLevelFlags.LEVEL_MESSAGE;
+        }
+
+        // 2.4. Reporting warnings to the console
+        // https://console.spec.whatwg.org/#reporting-warnings
+
+        if (logLevel === 'reportWarning')
+            severity = GLib.LogLevelFlags.LEVEL_WARNING;
+
+        let output = args
+            .map(a => {
+                if (a === null)
+                    return 'null';
+                // TODO: Use a better object printer
+                else if (typeof a === 'object')
+                    return JSON.stringify(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,
+    },
+});
+
+export const console = new Console();
+
+/**
+ * @param {string} domain set the GLib log domain for the global console object.
+ */
+export function setConsoleLogDomain(domain) {
+    console.setLogDomain(domain);
+}
+
+/**
+ * @returns {string}
+ */
+export function getConsoleLogDomain() {
+    return console.logDomain;
+}
+
+// 1 Namespace console
+//
+/**
+ * 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>]} */
+    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 '[object console]';
+        },
+    },
+});
+Object.freeze(globalConsole);
+
+globalThis.console = globalConsole;
+Object.defineProperties(globalThis, {
+    console: {
+        configurable: false,
+        enumerable: true,
+        writable: false,
+        value: globalConsole,
+    },
+});
+
+export default {
+    console,
+    Console,
+};


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