├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── fixtures ├── lsof.out ├── ps-2.out ├── ps-3.out └── ps.out ├── index.d.ts ├── index.js ├── index.test-d.ts ├── license ├── package.json ├── readme.md └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 16 14 | - 14 15 | - 12 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions/setup-node@v2 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm install 22 | - run: npm test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /fixtures/ps-2.out: -------------------------------------------------------------------------------- 1 | PID | COMM | ARGS 2 | 238 | /usr/libexec/Use | /usr/libexec/UserEventAgent (Aqua) 3 | 240 | /usr/sbin/distno | /usr/sbin/distnoted agent 4 | 241 | /usr/sbin/univer | /usr/sbin/universalaccessd launchd -s 5 | 242 | /usr/sbin/cfpref | /usr/sbin/cfprefsd agent 6 | 244 | /System/Library/ | /System/Library/PrivateFrameworks/CalendarAgent.framework/Executables/CalendarAgent 7 | 246 | /System/Library/ | /System/Library/CoreServices/SocialPushAgent.app/Contents/MacOS/SocialPushAgent 8 | 249 | /System/Library/ | /System/Library/CoreServices/Dock.app/Contents/MacOS/Dock 9 | 251 | /System/Library/ | /System/Library/CoreServices/SystemUIServer.app/Contents/MacOS/SystemUIServer 10 | 252 | /System/Library/ | /System/Library/CoreServices/Finder.app/Contents/MacOS/Finder 11 | 259 | /usr/sbin/pboard | /usr/sbin/pboard 12 | 267 | /usr/sbin/userno | /usr/sbin/usernoted 13 | 430 | login | login -pf sindresorhus 14 | 431 | -zsh | -zsh 15 | 459 | -zsh | -zsh 16 | 461 | cat | cat 17 | 891 | login | login -pf sindresorhus 18 | 892 | -zsh | -zsh 19 | 918 | -zsh | -zsh 20 | 920 | cat | cat 21 | -------------------------------------------------------------------------------- /fixtures/ps-3.out: -------------------------------------------------------------------------------- 1 | PID CMD STARTED 2 | 5971 emacs -nw Oct 29 3 | 22678 emacs -nw foo.js 13:10:36 4 | 28752 emacs -nw . Oct 28 5 | 31236 emacs -nw fixtures/ps-3.out 17:10:10 6 | 32513 emacs -nw README.md Oct 28 7 | -------------------------------------------------------------------------------- /fixtures/ps.out: -------------------------------------------------------------------------------- 1 | 2 | PID COMM ARGS 3 | 238 /usr/libexec/Use /usr/libexec/UserEventAgent (Aqua) 4 | 240 /usr/sbin/distno /usr/sbin/distnoted agent 5 | 241 /usr/sbin/univer /usr/sbin/universalaccessd launchd -s 6 | 242 /usr/sbin/cfpref /usr/sbin/cfprefsd agent 7 | 244 /System/Library/ /System/Library/PrivateFrameworks/CalendarAgent.framework/Executables/CalendarAgent 8 | 246 /System/Library/ /System/Library/CoreServices/SocialPushAgent.app/Contents/MacOS/SocialPushAgent 9 | 249 /System/Library/ /System/Library/CoreServices/Dock.app/Contents/MacOS/Dock 10 | 251 /System/Library/ /System/Library/CoreServices/SystemUIServer.app/Contents/MacOS/SystemUIServer 11 | 252 /System/Library/ /System/Library/CoreServices/Finder.app/Contents/MacOS/Finder 12 | 259 /usr/sbin/pboard /usr/sbin/pboard 13 | 267 /usr/sbin/userno /usr/sbin/usernoted 14 | 430 login login -pf sindresorhus 15 | 431 -zsh -zsh 16 | 459 -zsh -zsh 17 | 461 cat cat 18 | 891 login login -pf sindresorhus 19 | 892 -zsh -zsh 20 | 918 -zsh -zsh 21 | 920 cat cat 22 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export interface Options { 2 | /** 3 | Separator to split columns on. 4 | 5 | @default ' ' 6 | */ 7 | readonly separator?: string; 8 | 9 | /** 10 | Headers to use instead of the existing ones. 11 | */ 12 | readonly headers?: readonly string[]; 13 | 14 | /** 15 | Transform elements. 16 | 17 | Useful for being able to cleanup or change the type of elements. 18 | */ 19 | readonly transform?: ( 20 | element: string, 21 | header: string, 22 | columnIndex: number, 23 | rowIndex: number 24 | ) => Value; 25 | } 26 | 27 | /** 28 | Parse text columns, like the output of Unix commands. 29 | 30 | @param textColumns - Text columns to parse. 31 | 32 | @example 33 | ``` 34 | import {promisify} from 'node:util'; 35 | import childProcess from 'node:child_process'; 36 | import parseColumns from 'parse-columns'; 37 | 38 | const execFileP = promisify(childProcess.execFile); 39 | 40 | const {stdout} = await execFileP('df', ['-kP']); 41 | 42 | console.log(parseColumns(stdout, { 43 | transform: (item, header, columnIndex) => { 44 | // Coerce elements in column index 1 to 3 to a number 45 | if (columnIndex >= 1 && columnIndex <= 3) { 46 | return Number(item); 47 | } 48 | 49 | return item; 50 | } 51 | })); 52 | // [ 53 | // { 54 | // Filesystem: '/dev/disk1', 55 | // '1024-blocks': 487350400, 56 | // Used: 467528020, 57 | // Available: 19566380, 58 | // Capacity: '96%', 59 | // 'Mounted on': '/' 60 | // }, 61 | // … 62 | // ] 63 | ``` 64 | */ 65 | export default function parseColumns( 66 | textColumns: string, 67 | options?: Options 68 | ): Array>; 69 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import execall from 'execall'; 2 | import splitAt from 'split-at'; 3 | import escapeStringRegexp from 'escape-string-regexp'; 4 | 5 | /* 6 | Algorithm: 7 | Find separators that are on the same index on each line, remove consecutive ones, then split on those indexes. It's important to check each line as you don't want to split in the middle of a column row just because it contains the separator. 8 | */ 9 | 10 | const countSeparators = (lines, separator = ' ') => { 11 | const counts = []; 12 | const separatorRegex = new RegExp(escapeStringRegexp(separator), 'g'); 13 | const headerLength = (lines[0] || '').length; 14 | 15 | for (let line of lines) { 16 | // Ensure lines are as long as the header 17 | const padAmount = Math.ceil(Math.max(headerLength - line.length, 0) / separator.length); 18 | line += separator.repeat(padAmount); 19 | 20 | for (const {index: column} of execall(separatorRegex, line)) { 21 | counts[column] = typeof counts[column] === 'number' ? counts[column] + 1 : 1; 22 | } 23 | } 24 | 25 | return counts; 26 | }; 27 | 28 | const getSplits = (lines, separator) => { 29 | const counts = countSeparators(lines, separator); 30 | const splits = []; 31 | let consecutive = false; 32 | 33 | for (const [index, count] of counts.entries()) { 34 | if (count !== lines.length) { // eslint-disable-line no-negated-condition 35 | consecutive = false; 36 | } else { 37 | if (index !== 0 && !consecutive) { 38 | splits.push(index); 39 | } 40 | 41 | consecutive = true; 42 | } 43 | } 44 | 45 | return splits; 46 | }; 47 | 48 | export default function parseColumns(input, options = {}) { 49 | const lines = input.replace(/^\s*\n|\s+$/g, '').split('\n'); 50 | let splits = getSplits(lines, options.separator); 51 | const {transform} = options; 52 | const rows = []; 53 | let items; 54 | 55 | let {headers} = options; 56 | if (!headers) { 57 | headers = []; 58 | items = splitAt(lines[0], splits, {remove: true}); 59 | 60 | for (let [index, item] of items.entries()) { 61 | item = item.trim(); 62 | if (item) { 63 | headers.push(item); 64 | } else { 65 | splits[index - 1] = null; 66 | } 67 | } 68 | 69 | splits = splits.filter(Boolean); 70 | } 71 | 72 | for (const [index, line] of lines.slice(1).entries()) { 73 | items = splitAt(line, splits, {remove: true}); 74 | 75 | const row = {}; 76 | for (const [index2, header] of headers.entries()) { 77 | const item = (items[index2] || '').trim(); 78 | row[header] = transform ? transform(item, header, index2, index) : item; 79 | } 80 | 81 | rows.push(row); 82 | } 83 | 84 | return rows; 85 | } 86 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | import {expectType} from 'tsd'; 2 | import parseColumns from './index.js'; 3 | 4 | expectType>>(parseColumns('foo')); 5 | expectType>>( 6 | parseColumns('foo', {separator: ' '}), 7 | ); 8 | expectType>>( 9 | parseColumns('foo', {headers: ['foo', 'bar']}), 10 | ); 11 | expectType>>( 12 | parseColumns('foo', { 13 | transform(element, header, columnIndex, rowIndex) { 14 | expectType(element); 15 | expectType(header); 16 | expectType(columnIndex); 17 | expectType(rowIndex); 18 | 19 | if (columnIndex >= 1 && columnIndex <= 3) { 20 | return Number(element); 21 | } 22 | 23 | return element; 24 | }, 25 | }), 26 | ); 27 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parse-columns", 3 | "version": "3.0.0", 4 | "description": "Parse text columns, like the output of Unix commands", 5 | "license": "MIT", 6 | "repository": "sindresorhus/parse-columns", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": "./index.js", 15 | "engines": { 16 | "node": "^12.20.0 || ^14.13.1 || >=16.0.0" 17 | }, 18 | "scripts": { 19 | "test": "xo && ava && tsd" 20 | }, 21 | "files": [ 22 | "index.js", 23 | "index.d.ts" 24 | ], 25 | "keywords": [ 26 | "parse", 27 | "parser", 28 | "columns", 29 | "column", 30 | "row", 31 | "text", 32 | "string", 33 | "unix", 34 | "command", 35 | "output", 36 | "csv", 37 | "shell", 38 | "sh", 39 | "term", 40 | "table" 41 | ], 42 | "dependencies": { 43 | "escape-string-regexp": "^5.0.0", 44 | "execall": "^3.0.0", 45 | "split-at": "^3.0.0" 46 | }, 47 | "devDependencies": { 48 | "ava": "^3.15.0", 49 | "tsd": "^0.18.0", 50 | "xo": "^0.46.4" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # parse-columns 2 | 3 | > Parse text columns, like the output of Unix commands 4 | 5 | ## Install 6 | 7 | ```sh 8 | npm install parse-columns 9 | ``` 10 | 11 | ## Usage 12 | 13 | ``` 14 | $ df -kP 15 | Filesystem 1024-blocks Used Available Capacity Mounted on 16 | /dev/disk1 487350400 467871060 19223340 97% / 17 | devfs 185 185 0 100% /dev 18 | map -hosts 0 0 0 100% /net 19 | ``` 20 | 21 | ```js 22 | import {promisify} from 'node:util'; 23 | import childProcess from 'node:child_process'; 24 | import parseColumns from 'parse-columns'; 25 | 26 | const execFileP = promisify(childProcess.execFile); 27 | 28 | const {stdout} = await execFileP('df', ['-kP']); 29 | 30 | console.log(parseColumns(stdout, { 31 | transform: (item, header, columnIndex) => { 32 | // Coerce elements in column index 1 to 3 to a number 33 | if (columnIndex >= 1 && columnIndex <= 3) { 34 | return Number(item); 35 | } 36 | 37 | return item; 38 | } 39 | })); 40 | /* 41 | [ 42 | { 43 | Filesystem: '/dev/disk1', 44 | '1024-blocks': 487350400, 45 | Used: 467528020, 46 | Available: 19566380, 47 | Capacity: '96%', 48 | 'Mounted on': '/' 49 | }, 50 | … 51 | ] 52 | */ 53 | ``` 54 | 55 | ## API 56 | 57 | ### parseColumns(textColumns, options?) 58 | 59 | #### textColumns 60 | 61 | Type: `string` 62 | 63 | The text columns to parse. 64 | 65 | #### options 66 | 67 | Type: `object` 68 | 69 | ##### separator 70 | 71 | Type: `string` 72 | Default: `' '` 73 | 74 | Separator to split columns on. 75 | 76 | ##### headers 77 | 78 | Type: `string[]` 79 | 80 | Headers to use instead of the existing ones. 81 | 82 | ##### transform 83 | 84 | Type: `Function` 85 | 86 | Transform elements. 87 | 88 | Useful for being able to cleanup or change the type of elements. 89 | 90 | The supplied function gets the following arguments and is expected to return the element: 91 | 92 | - `element` *(string)* 93 | - `header` *(string)* 94 | - `columnIndex` *(number)* 95 | - `rowIndex` *(number)* 96 | 97 | ## Related 98 | 99 | - [parse-columns-cli](https://github.com/sindresorhus/parse-columns-cli) - CLI for this module 100 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import test from 'ava'; 3 | import parseColumns from './index.js'; 4 | 5 | const fixture1 = fs.readFileSync('fixtures/ps.out', 'utf8'); 6 | const fixture2 = fs.readFileSync('fixtures/ps-2.out', 'utf8'); 7 | const fixture3 = fs.readFileSync('fixtures/lsof.out', 'utf8'); 8 | const fixture4 = fs.readFileSync('fixtures/ps-3.out', 'utf8'); 9 | 10 | test.after('benchmark', () => { 11 | const count = 30; 12 | let total = 0; 13 | 14 | for (let index = 0; index < count; index++) { 15 | const start = Date.now(); 16 | parseColumns(fixture3); 17 | total += Date.now() - start; 18 | } 19 | 20 | console.log(`${count} iterations: ${total / (1000 * count)}s`); 21 | }); 22 | 23 | test('parse', t => { 24 | const fixture = parseColumns(fixture1); 25 | t.is(fixture[0].PID, '238'); 26 | }); 27 | 28 | test('headers option', t => { 29 | const fixture = parseColumns(fixture1, { 30 | headers: [ 31 | 'pid', 32 | 'name', 33 | 'cmd', 34 | ], 35 | }); 36 | t.is(fixture[0].pid, '238'); 37 | t.truthy(fixture[0].name); 38 | t.truthy(fixture[0].cmd); 39 | }); 40 | 41 | test('transform option', t => { 42 | const fixture = parseColumns(fixture1, { 43 | transform: (item, header, rowIndex, columnIndex) => { 44 | t.is(typeof rowIndex, 'number'); 45 | t.is(typeof columnIndex, 'number'); 46 | return header === 'PID' ? Number(item) : item; 47 | }, 48 | }); 49 | t.is(fixture[0].PID, 238); 50 | }); 51 | 52 | test('separator option', t => { 53 | const fixture = parseColumns(fixture2, { 54 | separator: '|', 55 | }); 56 | t.is(fixture[0].PID, '238'); 57 | }); 58 | 59 | test('differing line lengths', t => { 60 | const fixture = parseColumns(fixture3); 61 | const columns = 'COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME'.split(' '); 62 | 63 | t.true(fixture.every(row => Object.keys(row).length === columns.length && columns.every(column => Reflect.has(row, column)))); 64 | }); 65 | 66 | test('separators in values', t => { 67 | const fixture = parseColumns(fixture4); 68 | 69 | t.deepEqual(fixture, [ 70 | { 71 | PID: '5971', 72 | CMD: 'emacs -nw', 73 | STARTED: 'Oct 29', 74 | }, 75 | { 76 | PID: '22678', 77 | CMD: 'emacs -nw foo.js', 78 | STARTED: '13:10:36', 79 | }, 80 | { 81 | PID: '28752', 82 | CMD: 'emacs -nw .', 83 | STARTED: 'Oct 28', 84 | }, 85 | { 86 | PID: '31236', 87 | CMD: 'emacs -nw fixtures/ps-3.out', 88 | STARTED: '17:10:10', 89 | }, 90 | { 91 | PID: '32513', 92 | CMD: 'emacs -nw README.md', 93 | STARTED: 'Oct 28', 94 | }, 95 | ]); 96 | }); 97 | 98 | test('handles `df` output', t => { 99 | const data = parseColumns(` 100 | Filesystem Type 1024-blocks Used Available Capacity Mounted on 101 | xx.xxx.xxx.xx:/xxxxxxxxxxx nfs 198640150528 43008 198640107520 1% /run/xo-server/mounts/cbb36e4c-3353-4126-8588-18ba25697403 102 | `); 103 | 104 | t.deepEqual(data, [ 105 | { 106 | Filesystem: 'xx.xxx.xxx.xx:/xxxxxxxxxxx', 107 | Type: 'nfs', 108 | '1024-blocks': '198640150528', 109 | Used: '43008', 110 | Available: '198640107520', 111 | Capacity: '1%', 112 | 'Mounted on': '/run/xo-server/mounts/cbb36e4c-3353-4126-8588-18ba25697403', 113 | }, 114 | ]); 115 | }); 116 | 117 | test('handles `df` output with spaces', t => { 118 | const data = parseColumns(` 119 | Filesystem Type 1024-blocks Used Available Capacity Mounted on 120 | /dev/sda1 2 3 4 5 999 ext4 243617788 137765660 105852128 57% /media/foo1 2 3 4 5 999 121 | `); 122 | 123 | t.deepEqual(data, [ 124 | { 125 | Filesystem: '/dev/sda1 2 3 4 5 999', 126 | Type: 'ext4', 127 | '1024-blocks': '243617788', 128 | Used: '137765660', 129 | Available: '105852128', 130 | Capacity: '57%', 131 | 'Mounted on': '/media/foo1 2 3 4 5 999', 132 | }, 133 | ]); 134 | }); 135 | 136 | test.failing('handles `df` output with spaces and `headers` option', t => { 137 | const data = parseColumns(` 138 | Filesystem Type 1024-blocks Used Available Capacity Mounted on 139 | /dev/sda1 2 3 4 5 999 ext4 243617788 137765660 105852128 57% /media/foo1 2 3 4 5 999 140 | `, { 141 | headers: [ 142 | 'filesystem', 143 | 'type', 144 | 'size', 145 | 'used', 146 | 'available', 147 | 'capacity', 148 | 'mountpoint', 149 | ], 150 | }); 151 | 152 | t.deepEqual(data, [ 153 | { 154 | filesystem: '/dev/sda1 2 3 4 5 999', 155 | type: 'ext4', 156 | size: '243617788', 157 | used: '137765660', 158 | available: '105852128', 159 | capacity: '57%', 160 | mountpoint: '/media/foo1 2 3 4 5 999', 161 | }, 162 | ]); 163 | }); 164 | --------------------------------------------------------------------------------