diff --git a/README.md b/README.md index a1d0a00..fa1a40c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ -fx +

fx

+

fx example

+ +_* Function eXecution_ # [![Build Status](https://travis-ci.org/antonmedv/fx.svg?branch=master)](https://travis-ci.org/antonmedv/fx) @@ -10,6 +13,7 @@ Command-line JSON processing tool * Plain JavaScript * Formatting and highlighting * Standalone binary +* Interactive mode 🎉 ## Install @@ -22,17 +26,13 @@ Or download standalone binary from [releases](https://github.com/antonmedv/fx/re ## Usage Pipe into `fx` any JSON and anonymous function for reducing it. - ``` -$ fx [code ...] +$ echo '{...}' | fx [code ...] ``` -Pretty print JSON without passing any arguments: +Start interactive mode without passing any arguments: ``` -$ echo '{"key":"value"}' | fx -{ - "key": "value" -} +$ curl ... | fx ``` ### Anonymous function @@ -43,7 +43,7 @@ $ echo '{"foo": [{"bar": "value"}]}' | fx 'x => x.foo[0].bar' value ``` -### This Binding +### Binding If you don't pass anonymous function `param => ...`, code will be automatically transformed into anonymous function. And you can get access to JSON by `this` keyword: @@ -55,12 +55,19 @@ value ### Dot It is possible to omit `this` keyword: - ``` $ echo '{"foo": [{"bar": "value"}]}' | fx .foo[0].bar value ``` +If single dot is passed, JSON will be processed without modification: +``` +$ echo '{"foo": "bar"}' | fx . +{ + "foo": "bar" +} +``` + ### Chain You can pass any number of anonymous functions for reducing JSON: @@ -106,7 +113,7 @@ $ echo '{"count": 0}' | fx '{...this, count: 1}' } ``` -### Use npm package +### Using packages Use any npm package by installing it globally: ``` @@ -144,13 +151,32 @@ By the way, fx has shortcut for `Object.keys(this)`. Previous example can be rew $ cat package.json | fx this.dependencies ? ``` +### Interactive mode + +Start interactive mode without passing any arguments: +``` +$ curl ... | fx +``` +Click on fields to expand or collapse JSON tree, use mouse wheel to scroll view. To select text, press `alt` or `option` key. + +Next commands available in interactive mode: + +| Key | Command | +|-----|---------| +| `q` or `Esc` or `Ctrl`+`c` | Exit | +| `e`/`E` | Expand/Collapse all | +| `up`/`down` or `k`/`j` | Scroll up/down one line | +| `g`/`G` | Goto top/bottom | + ## Related * [jq](https://github.com/stedolan/jq) – cli JSON processor on C * [jl](https://github.com/chrisdone/jl) – functional sed for JSON on Haskell -* [xx](https://github.com/antonmedv/xx) – `fx`-like JSON tool on Go +* [xx](https://github.com/antonmedv/xx) – `fx`-like JSON tool (*go*) * [ymlx](https://github.com/matthewadams/ymlx) - `fx`-like YAML cli processor +* [jv](https://github.com/maxzender/jv) – interactive JSON viewer (*go*) +* [jid](https://github.com/simeji/jid) – interactive cli tool based on jq (*go*) ## License diff --git a/fx.js b/fx.js new file mode 100644 index 0000000..974a6c6 --- /dev/null +++ b/fx.js @@ -0,0 +1,250 @@ +const fs = require('fs') +const tty = require('tty') +const blessed = require('neo-blessed') +const stringWidth = require('string-width') +const indent = require('indent-string') +const chalk = require('chalk') + +module.exports = function start(input) { + const ttyFd = fs.openSync('/dev/tty', 'r+') + + const program = blessed.program({ + input: tty.ReadStream(ttyFd), + output: tty.WriteStream(ttyFd), + }) + + const screen = blessed.screen({ + program: program, + smartCSR: true, + fullUnicode: true, + }) + screen.title = 'fx' + screen.key(['escape', 'q', 'C-c'], function (ch, key) { + return process.exit(0) + }) + screen.on('resize', render) + + const box = blessed.box({ + parent: screen, + tags: true, + left: 0, + top: 0, + width: '100%', + height: '100%', + keys: true, + vi: true, + alwaysScroll: true, + scrollable: true, + }) + + const test = blessed.box({ + parent: screen, + hidden: true, + tags: true, + width: '100%', + }) + + const scrollSpeed = (() => { + let prev = new Date() + return () => { + const now = new Date() + const lines = now - prev < 20 ? 3 : 1 // TODO: Speed based on terminal. + prev = now + return lines + } + })() + + box.on('wheeldown', function () { + box.scroll(scrollSpeed()) + screen.render() + }) + box.on('wheelup', function () { + box.scroll(-scrollSpeed()) + screen.render() + }) + + // TODO: fx input + // const inputBar = blessed.textbox({ + // parent: screen, + // bottom: 0, + // left: 0, + // height: 1, + // width: '100%', + // keys: true, + // mouse: true, + // inputOnFocus: true, + // }) + + const expanded = new Set() + expanded.add('') // Root of JSON + + box.key('e', function () { + walk(input, path => expanded.add(path)) + render() + }) + box.key('S-e', function () { + expanded.clear() + expanded.add('') + render() + }) + + function walk(v, cb, path = '') { + if (Array.isArray(v)) { + cb(path) + let i = 0 + for (let item of v) { + walk(item, cb, path + '[' + (i++) + ']') + } + } + + if (typeof v === 'object' && v.constructor === Object) { + cb(path) + let i = 0 + for (let [key, value] of Object.entries(v)) { + walk(value, cb, path + '.' + key) + } + } + } + + const space = 2 + + let index = new Map() + let row = 0 + + function print(input) { + row = 0 + index = new Map() + return doPrint(input) + } + + function doPrint(v, path = '') { + const eol = () => { + row++ + return '\n' + } + + if (typeof v === 'undefined') { + return void 0 + } + + if (v === null) { + return chalk.grey.bold(v) + } + + if (typeof v === 'number' && Number.isFinite(v)) { + return chalk.cyan.bold(v) + + } + + if (typeof v === 'boolean') { + return chalk.yellow.bold(v) + + } + + if (typeof v === 'string') { + return chalk.green.bold(JSON.stringify(v)) + } + + if (Array.isArray(v)) { + index.set(row, path) + + if (!expanded.has(path)) { + return '[\u2026]' + } + + let output = '[' + eol() + + const len = v.length + let i = 0 + + for (let item of v) { + const value = typeof item === 'undefined' ? null : item // JSON.stringify compatibility + output += indent(doPrint(value, path + '[' + i + ']'), space) + output += i++ < len - 1 ? ',' : '' + output += eol() + } + + return output + ']' + } + + if (typeof v === 'object' && v.constructor === Object) { + index.set(row, path) + + if (!expanded.has(path)) { + return '{\u2026}' + } + + let output = '{' + eol() + + const entries = Object.entries(v).filter(noUndefined) // JSON.stringify compatibility + const len = entries.length + + let i = 0 + for (let [key, value] of entries) { + const part = chalk.blue.bold(JSON.stringify(key)) + ': ' + doPrint(value, path + '.' + key) + output += indent(part, space) + output += i++ < len - 1 ? ',' : '' + output += eol() + } + + return output + '}' + } + + return JSON.stringify(v) + } + + function noUndefined([key, value]) { + return typeof value !== 'undefined' + } + + box.on('click', function (mouse) { + const pos = box.childBase + mouse.y + const line = box.getScreenLines()[pos] + if (mouse.x >= stringWidth(line)) { + return + } + + const path = index.get(pos) + if (expanded.has(path)) { + expanded.delete(path) + } else { + expanded.add(path) + } + render() + }) + + function render() { + const content = print(input) + + // TODO: Move to own fork of blessed. + let row = 0 + for (let line of content.split('\n')) { + if (stringWidth(line) > box.width) { + test.setContent(line) + const pad = test.getScreenLines().length - 1 + + const update = new Map() + for (let [i, path] of index.entries()) { + if (i > row) { + index.delete(i) + update.set(i + pad, path) + } + } + + row += pad + + for (let [i, path] of update.entries()) { + index.set(i, path) + } + + } + row++ + } + + box.setContent(content) + screen.render() + } + + box.focus() + render() +} diff --git a/index.js b/index.js index d5cf65f..eb780ae 100755 --- a/index.js +++ b/index.js @@ -24,22 +24,32 @@ const usage = ` $ echo '{"key": "value"}' | fx .key value + ` function main(input) { + const {stdout, stderr} = process + if (input === '') { - console.log(usage) + stderr.write(usage) process.exit(2) } const json = JSON.parse(input) - const result = process.argv.slice(2).reduce(reduce, json) + const args = process.argv.slice(2) + + if (args.length === 0 && stdout.isTTY) { + require('./fx')(json) + return + } + + const result = args.reduce(reduce, json) if (typeof result === 'undefined') { - process.stderr.write('undefined\n') + stderr.write('undefined\n') } else if (typeof result === 'string') { console.log(result) - } else if (process.stdout.isTTY) { + } else if (stdout.isTTY) { console.log(pretty(result)) } else { console.log(JSON.stringify(result, null, 2)) @@ -77,23 +87,27 @@ function reduce(json, code) { return fx.call(json) } -const stdin = process.stdin -let buff = '' +function run() { + const stdin = process.stdin + stdin.setEncoding('utf8') -if (stdin.isTTY) { - main(buff) -} + let buff = '' -stdin.setEncoding('utf8') + if (stdin.isTTY) { + main('') + } -stdin.on('readable', () => { - let chunk + stdin.on('readable', () => { + let chunk - while ((chunk = stdin.read())) { - buff += chunk - } -}) + while ((chunk = stdin.read())) { + buff += chunk + } + }) + + stdin.on('end', () => { + main(buff) + }) +} -stdin.on('end', () => { - main(buff) -}) +run() diff --git a/package.json b/package.json index f3f9a26..90713d6 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "test": "ava", "release": "pkg . --out-path dist && release-it --github.release --github.assets=dist/*" }, + "pkg": { + "scripts": "node_modules/neo-blessed/lib/**/*.js" + }, "keywords": [ "json", "cli" @@ -23,7 +26,11 @@ "node": ">=8" }, "dependencies": { - "@medv/prettyjson": "^1.0.1" + "@medv/prettyjson": "^1.0.1", + "chalk": "^2.4.1", + "indent-string": "^3.2.0", + "neo-blessed": "^0.2.0", + "string-width": "^2.1.1" }, "devDependencies": { "ava": "^0.25.0",