@absolunet/ioc2.1.0

View on GitHub

support/services/Dumper.js

//--------------------------------------------------------
//-- Node IoC - Support - Services - Dumper
//--------------------------------------------------------

import __          from '@absolunet/private-registry';
import checksTypes from '../../support/mixins/checksTypes';


/**
 * @private
 * @typedef {object<string, string|number>} DumperThemeFont
 * @property {string} name - The font family name.
 * @property {number} weight - The font weight.
 * @property {string} size - The font size, with the unit of measure.
 * @property {string} link - The URL to load the font from.
 * @memberof support.services.Dumper
 */

/**
 * @private
 * @typedef {object<string, string>} DumperThemeColors
 * @property {string} background - The background color.
 * @property {string} text - The default text color.
 * @property {string} key - The object and array keys color.
 * @property {string} type - The value type names color.
 * @property {string} boolean - The boolean values color.
 * @property {string} function - The function values color.
 * @property {string} number - The numeric value color.
 * @property {string} string - The string values color.
 * @property {string} symbol - The Symbol values color.
 * @memberof support.services.Dumper
 */

/**
 * @private
 * @typedef {object} DumperTheme
 * @property {number} indent - The indentation size.
 * @property {boolean} open - Indicates that the dumper should expand all objects and arrays on render.
 * @property {support.services.Dumper.DumperThemeFont} font - The font configuration.
 * @property {support.services.Dumper.DumperThemeColors} colors - The colors.
 * @memberof support.services.Dumper
 */

/**
 * @private
 * @typedef {object} DumperData
 * @property {Array<string>} dumps - The rendered dumps.
 * @property {Array<*>} values - The dumped raw values.
 * @property {support.services.Dumper.DumperTheme} theme - The current theme configuration.
 * @property {string} [location] - The dump file location.
 * @property {string} [locationLink] - The dump file location link formatted for current IDE.
 * @memberof support.services.Dumper
 */

/**
 * HTTP variable dumper.
 *
 * @memberof support.services
 * @hideconstructor
 */
class Dumper extends checksTypes() {

	/**
	 * Class dependencies: <code>['app', 'config', 'ide.link']</code>.
	 *
	 * @type {Array<string>}
	 */
	static get dependencies() {
		return (super.dependencies || []).concat(['app', 'config', 'ide.link']);
	}

	/**
	 * @inheritdoc
	 * @private
	 */
	init() {
		__(this).set('instances', new Map());
		__(this).set('currentInstances', []);
		this.resetDelta();
		this.useTheme(this.config.get('dev.dumper.default', 'absolunet'));
	}

	/**
	 * Dump variables as an HTTP response, or in the console if response is not available.
	 *
	 * @param {...*} parameters - The dumped parameters.
	 */
	dump(...parameters) {
		if (this.shouldDump()) {
			this.respondWithDump(this.getDumpData(...parameters));
		}
	}

	/**
	 * Dump variable from the explicitly given file.
	 *
	 * @param {string} file - The file the dump was originally called.
	 * @param {...*} parameters - The dumped parameters.
	 */
	dumpForFile(file, ...parameters) {
		if (this.shouldDump()) {
			this.respondWithDump(this.getDumpDataForFile(file, ...parameters));
		}
	}

	/**
	 * Send the proper response with the dumped data.
	 *
	 * @param {support.services.Dumper.DumperData} data - The processed dump data model.
	 */
	respondWithDump(data) {
		const { response, terminal } = this;
		if (this.shouldDump()) {

			if (response) {
				response.status(500).end(this.makeView('index', data));

				this.setResponse(undefined);
			} else {
				data.values.forEach((value) => {
					terminal.echo(value);
				});
			}
		}
	}

	/**
	 * Get dump without the whole HTML page around it.
	 *
	 * @param {...*} parameters - The dumped parameters.
	 * @returns {string} The rendered dump.
	 */
	getDump(...parameters) {
		return this.makeDump(this.getDumpData(...parameters));
	}

	/**
	 * Get dump without the whole HTML page around it from the explicitly given file.
	 *
	 * @param {string} file - The file the dump was originally called.
	 * @param {...*} parameters - The dumped parameters.
	 * @returns {string} The rendered dump.
	 */
	getDumpForFile(file, ...parameters) {
		return this.makeDump(this.getDumpDataForFile(file, ...parameters));
	}

	/**
	 * Make dump view with the dump formatted data.
	 *
	 * @param {support.services.Dumper.DumperData} data - The processed dump data model.
	 * @returns {string} The rendered dump.
	 */
	makeDump(data) {
		return this.makeView('dump', data);
	}

	/**
	 * Get dump data without the main rendered view.
	 *
	 * @param {...*} parameters - The dumped parameters.
	 * @returns {support.services.Dumper.DumperData} The processed dump data model.
	 */
	getDumpData(...parameters) {
		const { theme } = this;
		const location = this.getLocation();
		const locationLink = this.getLocationLink();

		const dumps = parameters.map((parameter) => {
			return this.stringify(parameter);
		});

		this.resetDelta();

		return { dumps, location, locationLink, theme, values: parameters };
	}

	/**
	 * Get dump data withou the main rendered view for the explicitly given file.
	 *
	 * @param {string} file - The file the dump was originally called.
	 * @param {...*} parameters - The dumped parameters.
	 * @returns {support.services.Dumper.DumperData} The processed dump data model.
	 */
	getDumpDataForFile(file, ...parameters) {
		const data        = this.getDumpData(...parameters);
		data.location     = file;
		data.locationLink = this.getLocationLink(file);

		return data;
	}

	/**
	 * Stringify a value by using the dedicated views for the current view engine.
	 *
	 * @param {*} data - The data to stringify.
	 * @param {number} [level=0] - The depth level.
	 * @returns {string} The stringified value.
	 */
	stringify(data, level = 0) {
		const delta = this.getDelta(data);

		const isObject = this.isObject(data);
		let type;
		let value;

		if (isObject) {
			({ type, value } = this.getStringifiedObjectModel(data, level));
		} else {
			({ type, value } = this.getStringifiedPrimitiveModel(data));
		}

		return this.makeView('item', { delta, isObject, type, value });
	}

	/**
	 * Stringify a plain object, an instance or an array and return a model containing rendering information.
	 *
	 * @param {object|Array<*>} data - The data to stringify.
	 * @param {number} [level=0] - The depth level.
	 * @returns {{type: string, value: string, empty: boolean}} The rendering information with the stringified value.
	 */
	getStringifiedObjectModel(data, level = 0) {
		const keys    = Object.keys(data).sort();
		const symbols = Array.isArray(data) ? { open: '[', close: ']' } : { open: '{', close: '}' };
		const depth   = [...new Array(level).keys()];

		const type = this.getObjectTypeName(data);

		const currentInstances = __(this).get('currentInstances');
		currentInstances.push(data);

		const items = keys.map((key) => {
			return this.getStringifiedObjectItemModel(key, data[key], level + 1);
		});

		currentInstances.splice(currentInstances.indexOf(data), 1);

		return {
			empty: keys.length === 0,
			type,
			value: this.makeView('object', { depth, items, symbols })
		};
	}

	/**
	 * Stringify object item and return a model containing rendering information.
	 *
	 * @param {string|number} key - The object item key.
	 * @param {*} data - The object item.
	 * @param {number} [level=1] - The depth level.
	 * @returns {{depth: *, value: *, key: *}} The rendering information with the stringified value.
	 */
	getStringifiedObjectItemModel(key, data, level = 1) {
		const itemDepth  = [...new Array(level).keys()];
		const isCircular = typeof data === 'object' && __(this).get('currentInstances').includes(data);

		return {
			depth: itemDepth,
			key,
			value: this[`stringify${isCircular ? 'Circular' : ''}`](data, level)
		};
	}

	/**
	 * Stringify ciurcular reference.
	 *
	 * @param {object|Array<*>} data - The circular instance.
	 * @returns {string} The stringified circular reference.
	 */
	stringifyCircular(data) {
		return this.makeView('item', {
			delta: this.getDelta(data),
			isObject: true,
			type: this.getObjectTypeName(data),
			value: `[circular]`
		});
	}

	/**
	 * Stringify a non-object primitive and return a model containing rendering information.
	 *
	 * @param {*} data - The data to stringify.
	 * @returns {{type: string, value: string}} The rendering information with the stringified value.
	 */
	getStringifiedPrimitiveModel(data) {
		const type = data === null ? 'null' : typeof data;
		let value;

		if (['boolean', 'number', 'string', 'undefined'].includes(typeof data) || !data) {
			value = JSON.stringify(data);
		} else {
			value = data.toString();

			if (type === 'function') {
				value = value.replace(/^(?<start>.*)\{.*/su, '$<start>{}');
			}
		}

		return {
			type,
			value: this.makeView('primitive', { type, value })
		};
	}

	/**
	 * Get object type formatted name.
	 *
	 * @param {object|Array<*>} data - The object to get name from.
	 * @returns {string} The object name.
	 */
	getObjectTypeName(data) {
		const type = (data.constructor || Object).name;

		return [Object.name, Array.name].includes(type) ? type.toLowerCase() : type;
	}

	/**
	 * Reset the instance delta.
	 */
	resetDelta() {
		__(this).set('delta', 0);
	}

	/**
	 * Get delta for specific instance.
	 * If not an object or an array, null is returned.
	 *
	 * @param {*} instance - The instance to get delta from.
	 * @returns {null|number} The delta, or null if not applicable.
	 */
	getDelta(instance) {
		if (!this.isObject(instance)) {
			return null;
		}

		const instances = __(this).get('instances');

		const existingDelta = instances.get(instance);

		if (existingDelta) {
			return existingDelta;
		}

		const delta = __(this).get('delta') + 1;
		instances.set(instance, delta);
		__(this).set('delta', delta);

		return delta;
	}

	/**
	 * Use theme by name.
	 * The theme data will be fetched from the `http.dumper.themes` configuration.
	 *
	 * @param {string} theme - The theme name.
	 * @returns {http.services.Dumper} The current dumper instance.
	 */
	useTheme(theme) {
		__(this).set('theme', theme);

		return this;
	}

	/**
	 * Set the current response instance.
	 *
	 * @param {response} response - The current response instance.
	 * @returns {http.services.Dumper} The current dumper instance.
	 */
	setResponse(response) {
		__(this).set('response', response);

		return this;
	}

	/**
	 * Render a view by name with the appropriate namespace for the current engine.
	 *
	 * @param {string} viewName - The view name, without namespace or engine prefix.
	 * @param {*} data - The view-model data.
	 * @returns {string} The rendered view.
	 */
	makeView(viewName, data) {
		if (this.shouldDump()) {
			return this.view.make(`dumper::${this.config.get('view.engine', 'jsrender')}.${viewName}`, data);
		}

		return '';
	}

	/**
	 * Get the dump file and line location.
	 *
	 * @returns {string} The dump call location.
	 */
	getLocation() {
		return new Error().stack.split('\n').slice(1).filter((line) => {
			return !line.includes(this.app.formatPath(__dirname, '..'));
		}).shift().replace(/.*\((?<location>.*:\w+:\w+)\)/u, '$<location>');
	}

	/**
	 * Get the dump location IDE link.
	 *
	 * @param {string} [location=this.getLocation()] - The location to get link from.
	 * @returns {string} The IDE link to the dump call.
	 */
	getLocationLink(location = this.getLocation()) {
		const [file, line] = location.split(':');

		return (this.ideLink.get(this.config.get('dev.ide')) || '')
			.replace('%line', encodeURIComponent(line))
			.replace('%file', encodeURIComponent(file));
	}

	/**
	 * Check if the dumper should dump or return dumped content based on the current environment and configuration.
	 *
	 * @returns {boolean} Indicates that the dumper should dump.
	 */
	shouldDump() {
		return this.config.get('dev.dumper.enabled', false);
	}

	/**
	 * The theme configuration.
	 *
	 * @type {object<string, *>}
	 */
	get theme() {
		return this.config.get(`dev.dumper.themes.${__(this).get('theme')}`, {
			indent: 4,
			open:   false,
			font: {
				name:   'Fira mono',
				weight: 400,
				size:   '1em',
				link:   'https://fonts.googleapis.com/css?family=Fira+Mono:400',
				colors: {
					'background': '#2b2d3c',
					'text':       '#4ea4e7',
					'key':        '#f2f2f2',
					'type':       '#1aabb6',
					'boolean':    '#ff5252',
					'function':   '#ff5252',
					'number':     '#ff5252',
					'string':     '#b6d8ee',
					'symbol':     '#b6d8ee'
				}
			}
		});
	}

	/**
	 * The current response instance.
	 *
	 * @type {response|null}
	 */
	get response() {
		return __(this).get('response') || null;
	}

	/**
	 * Terminal service.
	 *
	 * @type {console.services.Terminal}
	 */
	get terminal() {
		return this.app.make('terminal');
	}

	/**
	 * View factory.
	 *
	 * @type {view.services.Factory}
	 */
	get view() {
		return this.app.make('view');
	}

}


export default Dumper;