diff --git a/config.js b/config.js new file mode 100644 index 0000000..08b0899 --- /dev/null +++ b/config.js @@ -0,0 +1,15 @@ +'use strict' +const chalk = require('chalk') +const noop = x => x + +module.exports = { + space: global.FX_STYLE_SPACE || 2, + null: global.FX_STYLE_NULL || chalk.grey.bold, + number: global.FX_STYLE_NUMBER || chalk.cyan.bold, + boolean: global.FX_STYLE_BOOLEAN || chalk.yellow.bold, + string: global.FX_STYLE_STRING || chalk.green.bold, + key: global.FX_STYLE_KEY || chalk.blue.bold, + bracket: global.FX_STYLE_BRACKET || noop, + comma: global.FX_STYLE_COMMA || noop, + colon: global.FX_STYLE_COLON || noop, +} diff --git a/fx.js b/fx.js index 7ca7e34..1787a5a 100644 --- a/fx.js +++ b/fx.js @@ -1,11 +1,17 @@ +'use strict' const fs = require('fs') const tty = require('tty') -const blessed = require('neo-blessed') +const blessed = require('@medv/blessed') const stringWidth = require('string-width') -const indent = require('indent-string') -const chalk = require('chalk') +const reduce = require('./reduce') +const print = require('./print') + +module.exports = function start(filename, source) { + let json = source + let index = new Map() + const expanded = new Set() + expanded.add('') // Root of JSON -module.exports = function start(input) { const ttyFd = fs.openSync('/dev/tty', 'r+') const program = blessed.program({ @@ -18,11 +24,6 @@ module.exports = function start(input) { 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, @@ -31,8 +32,10 @@ module.exports = function start(input) { top: 0, width: '100%', height: '100%', + mouse: true, keys: true, vi: true, + ignoreArrows: true, alwaysScroll: true, scrollable: true, }) @@ -44,170 +47,143 @@ module.exports = function start(input) { 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 + const input = blessed.textbox({ + parent: screen, + bottom: 0, + left: 0, + height: 1, + width: '100%', + }) + + screen.title = filename + input.hide() + + screen.key(['escape', 'q', 'C-c'], function (ch, key) { + // If exit program immediately, stdin may still receive mouse events which will be printed in stdout. + program.disableMouse() + setTimeout(() => process.exit(0), 10) + }) + + screen.on('resize', render) + + input.on('action', function (code) { + if (code && code.length !== 0) { + try { + json = reduce(source, code) + } catch (e) { + // pass + } + } else { + box.height = '100%' + input.hide() + json = source } - })() - - 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.size < 1000 && expanded.add(path)) + box.focus() + program.cursorPos(0, 0) render() }) + + input.on('update', function (code) { + if (code && code.length !== 0) { + try { + const pretender = reduce(source, code) + if (typeof pretender !== 'undefined') { + json = pretender + } + } catch (e) { + // pass + } + } + render() + }) + + + box.key(':', function () { + box.height = '100%-1' + input.show() + input.readInput() + render() + }) + + box.key('e', function () { + walk(json, path => expanded.size < 1000 && expanded.add(path)) + render() + }) + box.key('S-e', function () { expanded.clear() expanded.add('') render() }) - function walk(v, cb, path = '') { - if (!v) { - return - } + box.key('up', function () { + program.showCursor() - if (Array.isArray(v)) { - cb(path) - let i = 0 - for (let item of v) { - walk(item, cb, path + '[' + (i++) + ']') + const pos = box.childBase + program.y + const rest = [...index.keys()].filter(i => i < pos) + if (rest.length > 0) { + const next = Math.max(...rest) + const y = next - box.childBase + if (y <= 0) { + box.scroll(-1) + screen.render() } + const line = box.getScreenLines()[next] + const x = line.search(/\S/) + program.cursorPos(y, x) } + }) - 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) + box.key('down', function () { + program.showCursor() + + const pos = box.childBase + program.y + const rest = [...index.keys()].filter(i => i > pos) + if (rest.length > 0) { + const next = Math.min(...rest) + const y = next - box.childBase + if (y >= box.height) { + box.scroll(1) + screen.render() } + const line = box.getScreenLines()[next] + const x = line.search(/\S/) + program.cursorPos(y, x) } - } + }) - 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' + box.key('right', function () { + program.showCursor() + const pos = box.childBase + program.y + const path = index.get(pos) + if (!expanded.has(path)) { + expanded.add(path) + render() } + }) - if (typeof v === 'undefined') { - return void 0 + box.key('left', function () { + program.showCursor() + const pos = box.childBase + program.y + const path = index.get(pos) + if (expanded.has(path)) { + expanded.delete(path) + render() } - - 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) { + program.hideCursor() + const pos = box.childBase + mouse.y const line = box.getScreenLines()[pos] if (mouse.x >= stringWidth(line)) { return } + const x = line.search(/\S/) + program.cursorPos(mouse.y, x) + const path = index.get(pos) if (expanded.has(path)) { expanded.delete(path) @@ -218,7 +194,12 @@ module.exports = function start(input) { }) function render() { - const content = print(input) + let content + [content, index] = print(json, expanded) + + if (typeof content === 'undefined') { + content = 'undefined' + } // TODO: Move to own fork of blessed. let row = 0 @@ -252,3 +233,25 @@ module.exports = function start(input) { box.focus() render() } + +function walk(v, cb, path = '') { + if (!v) { + return + } + + 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) + } + } +} diff --git a/index.js b/index.js index b62a2e7..8b3301a 100755 --- a/index.js +++ b/index.js @@ -1,10 +1,9 @@ #!/usr/bin/env node 'use strict' const os = require('os') +const fs = require('fs') const path = require('path') -const pretty = require('@medv/prettyjson') const {stdin, stdout, stderr} = process - try { require(path.join(os.homedir(), '.fxrc')) } catch (err) { @@ -12,14 +11,21 @@ try { throw err } } +const print = require('./print') +const reduce = require('./reduce') const usage = ` Usage $ fx [code ...] Examples + $ fx package.json + $ echo '{"key": "value"}' | fx 'x => x.key' value + + $ echo '{"key": "value"}' | fx .key + value $ echo '[1,2,3]' | fx 'this.map(x => x * 2)' [2, 4, 6] @@ -32,75 +38,49 @@ const usage = ` $ echo '{"foo": 1, "bar": 2}' | fx ? ["foo", "bar"] - - $ echo '{"key": "value"}' | fx .key - value - + ` function main(input) { + let args = process.argv.slice(2) + let filename = 'fx' + if (input === '') { - stderr.write(usage) - process.exit(2) + if (args.length === 0) { + stderr.write(usage) + process.exit(2) + } + + input = fs.readFileSync(args[0]) + filename = path.basename(args[0]) + args = args.slice(1) } const json = JSON.parse(input) - const args = process.argv.slice(2) if (args.length === 0 && stdout.isTTY) { - require('./fx')(json) + require('./fx')(filename, json) return } - const result = args.reduce(reduce, json) + const output = args.reduce(reduce, json) - if (typeof result === 'undefined') { + if (typeof output === 'undefined') { stderr.write('undefined\n') - } else if (typeof result === 'string') { - console.log(result) - } else if (stdout.isTTY) { - console.log(pretty(result)) + } else if (typeof output === 'string') { + console.log(output) } else { - console.log(JSON.stringify(result, null, 2)) + const [text] = print(output) + console.log(text) } } -function reduce(json, code) { - if (/^\w+\s*=>/.test(code)) { - const fx = eval(code) - return fx(json) - } - - if (/yield/.test(code)) { - const fx = eval(` - function fn() { - const gen = (function*(){ - ${code.replace(/\\\n/g, '')} - }).call(this) - return [...gen] - }; fn - `) - return fx.call(json) - } - - if (/^\?$/.test(code)) { - return Object.keys(json) - } - - if (/^\./.test(code)) { - const fx = eval(`function fn() { return ${code === '.' ? 'this' : 'this' + code} }; fn`) - return fx.call(json) - } - - const fx = eval(`function fn() { return ${code} }; fn`) - return fx.call(json) -} - function run() { stdin.setEncoding('utf8') if (stdin.isTTY) { main('') + return } let buff = '' diff --git a/package.json b/package.json index dba4ec4..44fa61f 100644 --- a/package.json +++ b/package.json @@ -34,15 +34,15 @@ "node": ">=8" }, "dependencies": { - "@medv/prettyjson": "^1.0.1", + "@medv/blessed": "^1.0.0", "chalk": "^2.4.1", + "editor-widget": "^1.1.1", "indent-string": "^3.2.0", - "neo-blessed": "^0.2.0", "string-width": "^2.1.1" }, "devDependencies": { "ava": "^0.25.0", "pkg": "^4.3.4", - "release-it": "^7.6.2" + "release-it": "^8.2.0" } } diff --git a/print.js b/print.js new file mode 100644 index 0000000..d0b8db7 --- /dev/null +++ b/print.js @@ -0,0 +1,86 @@ +'use strict' +const indent = require('indent-string') +const config = require('./config') + +function print(input, expanded = null) { + const index = new Map() + let row = 0 + + function doPrint(v, path = '') { + index.set(row, path) + + const eol = () => { + row++ + return '\n' + } + + if (typeof v === 'undefined') { + return void 0 + } + + if (v === null) { + return config.null(v) + } + + if (typeof v === 'number' && Number.isFinite(v)) { + return config.number(v) + } + + if (typeof v === 'boolean') { + return config.boolean(v) + + } + + if (typeof v === 'string') { + return config.string(JSON.stringify(v)) + } + + if (Array.isArray(v)) { + if (expanded && !expanded.has(path)) { + return config.bracket('[') + '\u2026' + config.bracket(']') + } + + let output = config.bracket('[') + 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 + ']')) + output += i++ < len - 1 ? config.comma(',') : '' + output += eol() + } + + return output + config.bracket(']') + } + + if (typeof v === 'object' && v.constructor === Object) { + if (expanded && !expanded.has(path)) { + return config.bracket('{') + '\u2026' + config.bracket('}') + } + + let output = config.bracket('{') + eol() + + const entries = Object.entries(v) + .filter(([key, value]) => typeof value !== 'undefined') // JSON.stringify compatibility + const len = entries.length + + let i = 0 + for (let [key, value] of entries) { + const part = config.key(JSON.stringify(key)) + config.colon(':') + ' ' + doPrint(value, path + '.' + key) + output += indent(part, config.space) + output += i++ < len - 1 ? config.comma(',') : '' + output += eol() + } + + return output + config.bracket('}') + } + + return v.toString() + } + + return [doPrint(input), index] +} + +module.exports = print diff --git a/reduce.js b/reduce.js new file mode 100644 index 0000000..9ffea1b --- /dev/null +++ b/reduce.js @@ -0,0 +1,34 @@ +'use strict' + +function reduce(json, code) { + if (/^\w+\s*=>/.test(code)) { + const fx = eval(code) + return fx(json) + } + + if (/yield/.test(code)) { + const fx = eval(` + function fn() { + const gen = (function*(){ + ${code.replace(/\\\n/g, '')} + }).call(this) + return [...gen] + }; fn + `) + return fx.call(json) + } + + if ('?' === code) { + return Object.keys(json) + } + + if (/^\./.test(code)) { + const fx = eval(`function fn() { return ${code === '.' ? 'this' : 'this' + code} }; fn`) + return fx.call(json) + } + + const fx = eval(`function fn() { return ${code} }; fn`) + return fx.call(json) +} + +module.exports = reduce