// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later // SPDX-FileCopyrightText: 2021 Evan Welsh 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); } /** * Implementation of the WHATWG Console object. */ class Console { #groupIndentation = ''; #countLabels = {}; #timeLabels = {}; #logDomain = DEFAULT_LOG_DOMAIN; 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; const 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.#logger('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.#groupIndentation = ''; imports.gi.GjsPrivate.clear_terminal(); } /** * Logs a message with severity equal to {@see GLib.LogLevelFlags.DEBUG}. * * @param {...any} data formatting substitutions, if applicable */ debug(...data) { this.#logger('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.#logger('error', data); } /** * Logs a message with severity equal to {@see GLib.LogLevelFlags.INFO}. * * @param {...any} data formatting substitutions, if applicable */ info(...data) { this.#logger('info', data); } /** * Logs a message with severity equal to {@see GLib.LogLevelFlags.MESSAGE}. * * @param {...any} data formatting substitutions, if applicable */ log(...data) { this.#logger('log', data); } // 1.1.7 table(tabularData, properties) table(tabularData, _properties) { this.log(tabularData); } /** * @param {...any} data formatting substitutions, if applicable * @returns {void} */ trace(...data) { if (data.length === 0) data = ['Trace']; this.#logger('trace', data); } /** * Logs a message with severity equal to {@see GLib.LogLevelFlags.WARNING}. * * @param {...any} data formatting substitutions, if applicable * @returns {void} */ warn(...data) { 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.#printer('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.#countLabels[label] ??= 0; const count = ++this.#countLabels[label]; const concat = `${label}: ${count}`; this.#logger('count', [concat]); } /** * @param {string} label the unique label to reset the count for * @returns {void} */ countReset(label) { const count = this.#countLabels[label]; if (typeof count !== 'number') this.#printer('reportWarning', [`No count found for label: '${label}'.`]); else this.#countLabels[label] = 0; } // 1.3 Grouping functions // https://console.spec.whatwg.org/#grouping /** * @param {...any} data formatting substitutions, if applicable * @returns {void} */ group(...data) { this.#logger('group', data); this.#groupIndentation += ' '; } /** * 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.#groupIndentation = this.#groupIndentation.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.#timeLabels[label] = imports.gi.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 startTime = this.#timeLabels[label]; if (typeof startTime !== 'number') { this.#printer('reportWarning', [ `No time log found for label: '${label}'.`, ]); } else { const durationMs = (imports.gi.GLib.get_monotonic_time() - startTime) / 1000; const concat = `${label}: ${durationMs.toFixed(3)} ms`; data.unshift(concat); this.#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 startTime = this.#timeLabels[label]; if (typeof startTime !== 'number') { this.#printer('reportWarning', [ `No time log found for label: '${label}'.`, ]); } else { delete this.#timeLabels[label]; const durationMs = (imports.gi.GLib.get_monotonic_time() - startTime) / 1000; const concat = `${label}: ${durationMs.toFixed(3)} ms`; this.#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.#logDomain = String(logDomain); } /** * @returns {string} */ get logDomain() { return this.#logDomain; } // 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} */ #logger(logLevel, args) { if (args.length === 0) return; const [first, ...rest] = args; if (rest.length === 0) { this.#printer(logLevel, [first]); return undefined; } // If first does not contain any format specifiers, don't call Formatter if (typeof first !== 'string' || !hasFormatSpecifiers(first)) { this.#printer(logLevel, args); return undefined; } // Otherwise, perform print the result of Formatter. this.#printer(logLevel, this.#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 */ #formatter(args) { // The initial formatting string is the first arg let target = args[0]; if (args.length === 1) return target; const 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[]]} */ const result = [target, ...args.slice(2)]; if (!hasFormatSpecifiers(target)) return result; if (result.length === 1) return result; return this.#formatter(result); } /** * @typedef {object} PrinterOptions * @param {Array.} [stackTrace] an error stacktrace to append * @param {Record} [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} */ #printer(logLevel, args, options) { const GLib = imports.gi.GLib; 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; } const 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.#groupIndentation + output; const extraFields = {}; let stackTrace = options?.stackTrace; if (!stackTrace && (logLevel === 'trace' || severity <= GLib.LogLevelFlags.LEVEL_WARNING)) { stackTrace = new Error().stack; const currentFile = stackTrace.match(/^[^@]*@(.*):\d+:\d+$/m)?.at(1); const index = stackTrace.lastIndexOf(currentFile) + currentFile.length; stackTrace = stackTrace.substring(index).split('\n'); // Remove the remainder of the first line stackTrace.shift(); } if (logLevel === 'trace') { if (stackTrace?.length) { formattedOutput += `\n${stackTrace.map(s => `${this.#groupIndentation}${s}`).join('\n')}`; } else { formattedOutput += `\n${this.#groupIndentation}No trace available`; } } if (stackTrace?.length) { const [stackLine] = stackTrace; const match = stackLine.match(/^([^@]*)@(.*):(\d+):\d+$/); if (match) { const [_, func, file, line] = match; if (func) extraFields.CODE_FUNC = func; if (file) extraFields.CODE_FILE = file; if (line) extraFields.CODE_LINE = line; } } GLib.log_structured(this.#logDomain, severity, { MESSAGE: formattedOutput, ...extraFields, ...options?.fields ?? {}, }); } } 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} */ const globalConsole = Object.create({}); const propertyNames = /** @type {['constructor', ...Array]} */ // 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 }; export default { getConsoleLogDomain, setConsoleLogDomain, DEFAULT_LOG_DOMAIN, };