diff --git a/package-lock.json b/package-lock.json index 07428d0a..6fbe5085 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "luxon": "^3.5.0", "open": "10.1.0", "shiki": "^1.15.2", + "table": "^6.9.0", "update-notifier": "^7.3.1", "yaml": "^2.6.1", "yargs": "^17.7.2" @@ -1036,6 +1037,15 @@ "node": ">=12" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/atomically": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.0.3.tgz", @@ -2107,6 +2117,22 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.5.tgz", + "integrity": "sha512-5JnBCWpFlMo0a3ciDy/JckMzzv1U9coZrIhedq+HXxxUfDTAiS0LA8OKVao4G9BxmCVck/jtA5r3KAtRWEyD8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -2943,6 +2969,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "license": "MIT" }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -3929,6 +3961,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4149,6 +4190,23 @@ "node": ">=0.3.1" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -4287,6 +4345,56 @@ "node": ">=8" } }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tar-fs": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz", diff --git a/package.json b/package.json index a3f39d90..2884b259 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "luxon": "^3.5.0", "open": "10.1.0", "shiki": "^1.15.2", + "table": "^6.9.0", "update-notifier": "^7.3.1", "yaml": "^2.6.1", "yargs": "^17.7.2" diff --git a/src/lib/formatting/formatter.mjs b/src/lib/formatting/formatter.mjs new file mode 100644 index 00000000..d2f1b27d --- /dev/null +++ b/src/lib/formatting/formatter.mjs @@ -0,0 +1,140 @@ +import { table } from "table"; + +import { colorize, Format } from "./colorize.mjs"; + +/** Returns an array of objects as a ascii table + * @param {object|Array} objectOrArray - The array of objects to format. + * @param {object} params The parameters for the table. + * @param {import("table").TableUserConfig} params.config - The configuration object. + * @param {Array} params.columns - The columns to display. + * @param {string} [params.header] - The header to display. + * @returns {string} - The formatted table. + */ +const toTable = (objectOrArray, { config, columns, header }) => { + const data = Array.isArray(objectOrArray) ? objectOrArray : [objectOrArray]; + let rows = [columns]; + + if (Array.isArray(data)) { + data.forEach((row) => { + rows.push(columns.map((column) => row[column])); + }); + } else { + rows.push(columns.map((column) => [column, data[column]])); + } + + const spanningCells = []; + if (header) { + rows.unshift([header, ...Array(columns.length - 1).fill("")]); + spanningCells.push({ + col: 0, + row: 0, + colSpan: columns.length, + alignment: "center", + }); + } + + return table(rows, { + ...config, + ...(spanningCells.length ? { spanningCells } : {}), + }); +}; + +/** + * Returns an array of objects as a TSV string + * @param {object|Array} objectOrArray - The array of objects to format. + * @param {object} params The parameters for the table. + * @param {string} [params.color] - The color to use. + * @param {Array} params.columns - The columns to display. + * @returns {string} - The formatted table. + */ +const toTSV = (objectOrArray, { color, columns }) => { + const data = Array.isArray(objectOrArray) ? objectOrArray : [objectOrArray]; + if (!data.length) { + return ""; + } + + const rows = data.map((row) => + columns.map((column) => row[column]).join("\t"), + ); + + return colorize(rows.join("\n"), { color, format: Format.TSV }); +}; + +/** + * Returns an array of objects as a short string + * @param {object|Array} objectOrArray - The array of objects to format. + * @param {object} params The parameters for the short formatter. + * @param {(Array) => string} params.formatter - The formatter function. + * @returns {string} - The formatted short string. + */ +const toShort = (objectOrArray, { formatter }) => { + const data = Array.isArray(objectOrArray) ? objectOrArray : [objectOrArray]; + return formatter(data); +}; + +/** + * Returns an array of objects as a string in the requested format + * @param {object} params The parameters for the formatter. + * @param {object|Array} params.data - The array of objects to format. + * @param {import("./colorize.mjs").Format} params.format - The format to use. + * @param {boolean} [params.color] - Whether to colorize the output. + * @param {object} [params.config] - The configuration object. + * @returns {string} - The formatted string. + */ +const toFormat = ({ data, format, color, config }) => { + switch (format) { + case Format.TABLE: + return toTable(data, { ...config.table }); + case Format.YAML: + return colorize(data, { color, format: Format.YAML }); + case Format.FQL: + return colorize(data, { color, format: Format.FQL }); + case Format.TSV: + return toTSV(data, { color, ...config.tsv }); + case "short": + return toShort(data, { color, ...config.short }); + case Format.JSON: + return colorize(data, { color, format: Format.JSON }); + default: + throw new Error(`Unknown output format requested: ${format}`); + } +}; + +/** + * Creates a formatter function that curries the config object for toFormat + * @param {object} config The configuration object. + * @param {string} [config.header] - The header to display. + * @param {Array} config.columns - The columns to display. + * @param {(Array) => string} config.short.formatter - The formatter function. + * @returns {(Array) => string} - The formatter function. + */ +export const createFormatter = (config) => { + const { header, columns, short } = config; + + if (typeof short.formatter !== "function") { + throw new Error("short formatter must be a function"); + } + + if (!Array.isArray(columns) || !columns.length) { + throw new Error("columns must be an array with at least one column"); + } + + if (typeof header !== "string") { + throw new Error("header must be a string"); + } + + if (!config.table) { + config.table = { header, columns }; + } else { + config.table.columns = config.table.columns ?? columns; + config.table.header = config.table.header ?? header; + } + + if (!config.tsv) { + config.tsv = { columns }; + } + + return ({ data, format, color }) => { + return toFormat({ data, format, color, config }); + }; +};