[gjs/ewlsh/whatwg-console: 6/9] modules: Implement WHATWG console specification




commit b3f6fd6be3e227ffb336dcd4101a577a7931fa55
Author: Evan Welsh <contact evanwelsh com>
Date:   Sat Aug 14 15:43:58 2021 -0700

    modules: Implement WHATWG console specification

 .editorconfig                     |   3 +
 .eslintrc.yml                     |   1 +
 js.gresource.xml                  |   1 +
 modules/esm/_bootstrap/default.js |   2 +
 modules/esm/console.js            | 730 ++++++++++++++++++++++++++++++++++++++
 5 files changed, 737 insertions(+)
---
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/console.js b/modules/esm/console.js
new file mode 100644
index 00000000..0b8deaba
--- /dev/null
+++ b/modules/esm/console.js
@@ -0,0 +1,730 @@
+// 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 duration = GLib.get_monotonic_time() - 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 = GLib.get_monotonic_time() - 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;
+    }
+
+    /**
+     * 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;
+        case '%c':
+            // Ignore %c as GJS doesn't support CSS
+            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':
+            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';
+                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]