• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1"use strict"
2
3const wcwidth = require('./width')
4const {
5  padRight,
6  padCenter,
7  padLeft,
8  splitIntoLines,
9  splitLongWords,
10  truncateString
11} = require('./utils')
12
13const DEFAULT_HEADING_TRANSFORM = key => key.toUpperCase()
14
15const DEFAULT_DATA_TRANSFORM = (cell, column, index) => cell
16
17const DEFAULTS = Object.freeze({
18  maxWidth: Infinity,
19  minWidth: 0,
20  columnSplitter: ' ',
21  truncate: false,
22  truncateMarker: '…',
23  preserveNewLines: false,
24  paddingChr: ' ',
25  showHeaders: true,
26  headingTransform: DEFAULT_HEADING_TRANSFORM,
27  dataTransform: DEFAULT_DATA_TRANSFORM
28})
29
30module.exports = function(items, options = {}) {
31
32  let columnConfigs = options.config || {}
33  delete options.config // remove config so doesn't appear on every column.
34
35  let maxLineWidth = options.maxLineWidth || Infinity
36  if (maxLineWidth === 'auto') maxLineWidth = process.stdout.columns || Infinity
37  delete options.maxLineWidth // this is a line control option, don't pass it to column
38
39  // Option defaults inheritance:
40  // options.config[columnName] => options => DEFAULTS
41  options = mixin({}, DEFAULTS, options)
42
43  options.config = options.config || Object.create(null)
44
45  options.spacing = options.spacing || '\n' // probably useless
46  options.preserveNewLines = !!options.preserveNewLines
47  options.showHeaders = !!options.showHeaders;
48  options.columns = options.columns || options.include // alias include/columns, prefer columns if supplied
49  let columnNames = options.columns || [] // optional user-supplied columns to include
50
51  items = toArray(items, columnNames)
52
53  // if not suppled column names, automatically determine columns from data keys
54  if (!columnNames.length) {
55    items.forEach(function(item) {
56      for (let columnName in item) {
57        if (columnNames.indexOf(columnName) === -1) columnNames.push(columnName)
58      }
59    })
60  }
61
62  // initialize column defaults (each column inherits from options.config)
63  let columns = columnNames.reduce((columns, columnName) => {
64    let column = Object.create(options)
65    columns[columnName] = mixin(column, columnConfigs[columnName])
66    return columns
67  }, Object.create(null))
68
69  // sanitize column settings
70  columnNames.forEach(columnName => {
71    let column = columns[columnName]
72    column.name = columnName
73    column.maxWidth = Math.ceil(column.maxWidth)
74    column.minWidth = Math.ceil(column.minWidth)
75    column.truncate = !!column.truncate
76    column.align = column.align || 'left'
77  })
78
79  // sanitize data
80  items = items.map(item => {
81    let result = Object.create(null)
82    columnNames.forEach(columnName => {
83      // null/undefined -> ''
84      result[columnName] = item[columnName] != null ? item[columnName] : ''
85      // toString everything
86      result[columnName] = '' + result[columnName]
87      if (columns[columnName].preserveNewLines) {
88        // merge non-newline whitespace chars
89        result[columnName] = result[columnName].replace(/[^\S\n]/gmi, ' ')
90      } else {
91        // merge all whitespace chars
92        result[columnName] = result[columnName].replace(/\s/gmi, ' ')
93      }
94    })
95    return result
96  })
97
98  // transform data cells
99  columnNames.forEach(columnName => {
100    let column = columns[columnName]
101    items = items.map((item, index) => {
102      let col = Object.create(column)
103      item[columnName] = column.dataTransform(item[columnName], col, index)
104
105      let changedKeys = Object.keys(col)
106      // disable default heading transform if we wrote to column.name
107      if (changedKeys.indexOf('name') !== -1) {
108        if (column.headingTransform !== DEFAULT_HEADING_TRANSFORM) return
109        column.headingTransform = heading => heading
110      }
111      changedKeys.forEach(key => column[key] = col[key])
112      return item
113    })
114  })
115
116  // add headers
117  let headers = {}
118  if(options.showHeaders) {
119    columnNames.forEach(columnName => {
120      let column = columns[columnName]
121
122      if(!column.showHeaders){
123        headers[columnName] = '';
124        return;
125      }
126
127      headers[columnName] = column.headingTransform(column.name)
128    })
129    items.unshift(headers)
130  }
131  // get actual max-width between min & max
132  // based on length of data in columns
133  columnNames.forEach(columnName => {
134    let column = columns[columnName]
135    column.width = items
136    .map(item => item[columnName])
137    .reduce((min, cur) => {
138      // if already at maxWidth don't bother testing
139      if (min >= column.maxWidth) return min
140      return Math.max(min, Math.min(column.maxWidth, Math.max(column.minWidth, wcwidth(cur))))
141    }, 0)
142  })
143
144  // split long words so they can break onto multiple lines
145  columnNames.forEach(columnName => {
146    let column = columns[columnName]
147    items = items.map(item => {
148      item[columnName] = splitLongWords(item[columnName], column.width, column.truncateMarker)
149      return item
150    })
151  })
152
153  // wrap long lines. each item is now an array of lines.
154  columnNames.forEach(columnName => {
155    let column = columns[columnName]
156    items = items.map((item, index) => {
157      let cell = item[columnName]
158      item[columnName] = splitIntoLines(cell, column.width)
159
160      // if truncating required, only include first line + add truncation char
161      if (column.truncate && item[columnName].length > 1) {
162        item[columnName] = splitIntoLines(cell, column.width - wcwidth(column.truncateMarker))
163        let firstLine = item[columnName][0]
164        if (!endsWith(firstLine, column.truncateMarker)) item[columnName][0] += column.truncateMarker
165        item[columnName] = item[columnName].slice(0, 1)
166      }
167      return item
168    })
169  })
170
171  // recalculate column widths from truncated output/lines
172  columnNames.forEach(columnName => {
173    let column = columns[columnName]
174    column.width = items.map(item => {
175      return item[columnName].reduce((min, cur) => {
176        if (min >= column.maxWidth) return min
177        return Math.max(min, Math.min(column.maxWidth, Math.max(column.minWidth, wcwidth(cur))))
178      }, 0)
179    }).reduce((min, cur) => {
180      if (min >= column.maxWidth) return min
181      return Math.max(min, Math.min(column.maxWidth, Math.max(column.minWidth, cur)))
182    }, 0)
183  })
184
185
186  let rows = createRows(items, columns, columnNames, options.paddingChr) // merge lines into rows
187  // conceive output
188  return rows.reduce((output, row) => {
189    return output.concat(row.reduce((rowOut, line) => {
190      return rowOut.concat(line.join(options.columnSplitter))
191    }, []))
192  }, [])
193  .map(line => truncateString(line, maxLineWidth))
194  .join(options.spacing)
195}
196
197/**
198 * Convert wrapped lines into rows with padded values.
199 *
200 * @param Array items data to process
201 * @param Array columns column width settings for wrapping
202 * @param Array columnNames column ordering
203 * @return Array items wrapped in arrays, corresponding to lines
204 */
205
206function createRows(items, columns, columnNames, paddingChr) {
207  return items.map(item => {
208    let row = []
209    let numLines = 0
210    columnNames.forEach(columnName => {
211      numLines = Math.max(numLines, item[columnName].length)
212    })
213    // combine matching lines of each rows
214    for (let i = 0; i < numLines; i++) {
215      row[i] = row[i] || []
216      columnNames.forEach(columnName => {
217        let column = columns[columnName]
218        let val = item[columnName][i] || '' // || '' ensures empty columns get padded
219        if (column.align === 'right') row[i].push(padLeft(val, column.width, paddingChr))
220        else if (column.align === 'center' || column.align === 'centre') row[i].push(padCenter(val, column.width, paddingChr))
221        else row[i].push(padRight(val, column.width, paddingChr))
222      })
223    }
224    return row
225  })
226}
227
228/**
229 * Object.assign
230 *
231 * @return Object Object with properties mixed in.
232 */
233
234function mixin(...args) {
235  if (Object.assign) return Object.assign(...args)
236  return ObjectAssign(...args)
237}
238
239function ObjectAssign(target, firstSource) {
240  "use strict";
241  if (target === undefined || target === null)
242    throw new TypeError("Cannot convert first argument to object");
243
244  var to = Object(target);
245
246  var hasPendingException = false;
247  var pendingException;
248
249  for (var i = 1; i < arguments.length; i++) {
250    var nextSource = arguments[i];
251    if (nextSource === undefined || nextSource === null)
252      continue;
253
254    var keysArray = Object.keys(Object(nextSource));
255    for (var nextIndex = 0, len = keysArray.length; nextIndex < len; nextIndex++) {
256      var nextKey = keysArray[nextIndex];
257      try {
258        var desc = Object.getOwnPropertyDescriptor(nextSource, nextKey);
259        if (desc !== undefined && desc.enumerable)
260          to[nextKey] = nextSource[nextKey];
261      } catch (e) {
262        if (!hasPendingException) {
263          hasPendingException = true;
264          pendingException = e;
265        }
266      }
267    }
268
269    if (hasPendingException)
270      throw pendingException;
271  }
272  return to;
273}
274
275/**
276 * Adapted from String.prototype.endsWith polyfill.
277 */
278
279function endsWith(target, searchString, position) {
280  position = position || target.length;
281  position = position - searchString.length;
282  let lastIndex = target.lastIndexOf(searchString);
283  return lastIndex !== -1 && lastIndex === position;
284}
285
286
287function toArray(items, columnNames) {
288  if (Array.isArray(items)) return items
289  let rows = []
290  for (let key in items) {
291    let item = {}
292    item[columnNames[0] || 'key'] = key
293    item[columnNames[1] || 'value'] = items[key]
294    rows.push(item)
295  }
296  return rows
297}
298