diff --git a/bin/jora b/bin/jora old mode 100644 new mode 100755 diff --git a/index.js b/index.js index ac2ffd1..b8ed950 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); const cli = require('clap'); const jora = require('jora/dist/jora'); +const colorize = require('./utils/colorize'); function readFromStream(stream, processBuffer) { const buffer = []; @@ -15,6 +16,7 @@ function readFromStream(stream, processBuffer) { function processOptions(options, args) { const query = options.query || args[0]; const pretty = options.pretty || false; + const color = options.color; let inputFile = options.input; let outputFile = options.output; @@ -35,6 +37,7 @@ function processOptions(options, args) { return { query, pretty, + color, inputFile, outputFile }; @@ -88,7 +91,8 @@ function processStream(options) { if (options.outputFile) { fs.writeFileSync(options.outputFile, serializedResult, 'utf-8'); } else { - console.log(serializedResult); + const result = options.color ? colorize(serializedResult) : serializedResult; + console.log(result); } }); } @@ -101,6 +105,7 @@ var command = cli.create('jora', '[query]') .option('-p, --pretty [indent]', 'Pretty print with optionally specified indentation (4 spaces by default)', value => value === undefined ? 4 : Number(value) || false , false) + .option('--no-color', 'Suppress color output') .action(function(args) { var options = processOptions(this.values, args); diff --git a/package-lock.json b/package-lock.json index 29b8ec4..658ef1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -203,14 +203,17 @@ "dev": true }, "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + "version": "4.1.0", + "resolved": "http://npm.msk.avito.ru/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" }, "ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } }, "append-transform": { "version": "1.0.0", @@ -337,15 +340,13 @@ "dev": true }, "chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "requires": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" } }, "chardet": { @@ -368,6 +369,30 @@ "integrity": "sha512-4CoL/A3hf90V3VIEjeuhSvlGFEHKzOz+Wfc2IVZc+FaUgU0ZQafJTP49fvnULipOPcAfqhyI2duwQyns6xqjYA==", "requires": { "chalk": "^1.1.3" + }, + "dependencies": { + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" + } } }, "cli-cursor": { @@ -428,7 +453,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "requires": { "color-name": "1.1.3" } @@ -436,8 +460,7 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, "colors": { "version": "0.5.1", @@ -1133,13 +1156,19 @@ "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", "requires": { "ansi-regex": "^2.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "http://npm.msk.avito.ru/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + } } }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, "hasha": { "version": "3.0.0", @@ -2488,6 +2517,13 @@ "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "requires": { "ansi-regex": "^2.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "http://npm.msk.avito.ru/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + } } }, "strip-bom": { @@ -2509,9 +2545,12 @@ "dev": true }, "supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } }, "table": { "version": "5.4.1", diff --git a/package.json b/package.json index 3690521..f594e2c 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "coveralls": "nyc report --reporter=text-lcov | coveralls" }, "dependencies": { + "ansi-regex": "^4.1.0", + "chalk": "^2.4.2", "clap": "^1.0.9", "jora": "1.0.0-alpha.10" }, diff --git a/test/fixture.json b/test/fixture.json new file mode 100644 index 0000000..9d03f06 --- /dev/null +++ b/test/fixture.json @@ -0,0 +1,15 @@ +{ + "string": "st\"ri\u1234ng", + "number": 1234, + "emptyArray": [], + "emptyObject": {}, + "null": null, + "false": false, + "true": true, + "complexJSON": { + "f\"oo\u01234" + :-0.1234, "bar": 0e-3, + "ba/": -0.1234e12, + "quux": -1.123 + } +} diff --git a/test/helpers.js b/test/helpers.js new file mode 100644 index 0000000..4d322a0 --- /dev/null +++ b/test/helpers.js @@ -0,0 +1,23 @@ +const { + LEFT_BRACE, + RIGHT_BRACE, + LEFT_BRACKET, + RIGHT_BRACKET, + STRING, + NUMBER, + NULL, + FALSE, + TRUE +} = require('../utils/constants').TOKENS; +const { TOKEN_COLORS } = require('../utils/constants'); +const ansiRegex = require('ansi-regex'); + +module.exports = { + STRING: TOKEN_COLORS[STRING](' ').match(ansiRegex()), + NUMBER: TOKEN_COLORS[NUMBER](' ').match(ansiRegex()), + EMPTY_ARRAY: [...TOKEN_COLORS[LEFT_BRACKET](' ').match(ansiRegex()), ...TOKEN_COLORS[RIGHT_BRACKET](' ').match(ansiRegex())], + EMPTY_OBJECT: [...TOKEN_COLORS[LEFT_BRACE](' ').match(ansiRegex()), ...TOKEN_COLORS[RIGHT_BRACE](' ').match(ansiRegex())], + NULL: TOKEN_COLORS[NULL](' ').match(ansiRegex()), + FALSE: TOKEN_COLORS[FALSE](' ').match(ansiRegex()), + TRUE: TOKEN_COLORS[TRUE](' ').match(ansiRegex()) +}; diff --git a/test/test.js b/test/test.js index ef094ad..d42d042 100644 --- a/test/test.js +++ b/test/test.js @@ -4,14 +4,31 @@ const child = require('child_process'); const cmd = 'node'; const pkgJson = path.join(__dirname, '../package.json'); const pkgJsonData = require(pkgJson); +const fs = require('fs'); +const colorize = require('../utils/colorize'); +const fixture = require('../test/fixture.json'); +const ansiRegex = require('ansi-regex'); +const { + STRING, + NUMBER, + EMPTY_ARRAY, + EMPTY_OBJECT, + NULL, + FALSE, + TRUE +} = require('./helpers'); function match(rx) { return actual => rx.test(actual); } +function matchANSI(fixture) { + return data => assert.deepEqual(data.match(ansiRegex()), fixture); +} + function run() { var args = [path.join(__dirname, '../bin/jora')].concat(Array.prototype.slice.call(arguments)); - var proc = child.spawn(cmd, args, { stdio: 'pipe' }); + var proc = child.spawn(cmd, args, { stdio: 'pipe', env: { FORCE_COLOR: true } }); var error = ''; var wrapper = new Promise(function(resolve, reject) { proc.once('exit', code => @@ -63,7 +80,7 @@ it('should output help with `-h` or `--help`', () => ); it('should output data itself when no query', () => - run() + run('--no-color') .input('42') .output('42') ); @@ -74,25 +91,25 @@ it('should output version', () => ); it('should read content from stdin if no file specified', () => - run('version') + run('version', '--no-color') .input(JSON.stringify(pkgJsonData)) .output(JSON.stringify(pkgJsonData.version)) ); it('should read from file', () => - run('-i', pkgJson, '-q', 'version') + run('-i', pkgJson, '-q', 'version', '--no-color') .output(JSON.stringify(pkgJsonData.version)) ); describe('pretty print', function() { it('indentation should be 4 spaces by default', () => - run('dependencies.keys()', '-p') + run('dependencies.keys()', '-p', '--no-color') .input(JSON.stringify(pkgJsonData)) .output(JSON.stringify(Object.keys(pkgJsonData.dependencies), null, 4)) ); it('indentation should be as specified', () => - run('dependencies.keys()', '-p', '3') + run('dependencies.keys()', '-p', '3', '--no-color') .input(JSON.stringify(pkgJsonData)) .output(JSON.stringify(Object.keys(pkgJsonData.dependencies), null, 3)) ); @@ -120,3 +137,46 @@ describe('errors', function() { ) ); }); + +describe('colorizer', function() { + it('Should colorize string', () => + run('string') + .input(JSON.stringify(fixture)) + .output(matchANSI(STRING)) + ); + it('Should colorize number', () => + run('number') + .input(JSON.stringify(fixture)) + .output(matchANSI(NUMBER)) + ); + it('Should colorize empty array', () => + run('emptyArray') + .input(JSON.stringify(fixture)) + .output(matchANSI(EMPTY_ARRAY)) + ); + it('Should colorize empty object', () => + run('emptyArray') + .input(JSON.stringify(fixture)) + .output(matchANSI(EMPTY_OBJECT)) + ); + it('Should colorize empty object', () => + run('null') + .input(JSON.stringify(fixture)) + .output(matchANSI(NULL)) + ); + it('Should colorize empty object', () => + run('false') + .input(JSON.stringify(fixture)) + .output(matchANSI(FALSE)) + ); + it('Should colorize empty object', () => + run('true') + .input(JSON.stringify(fixture)) + .output(matchANSI(TRUE)) + ); + it('Should colorize raw complex JSON', () => + colorize( + fs.readFileSync(path.resolve(__dirname, './fixture.json')).toString() + ).match(ansiRegex()) + ); +}); diff --git a/utils/colorize.js b/utils/colorize.js new file mode 100644 index 0000000..47150cd --- /dev/null +++ b/utils/colorize.js @@ -0,0 +1,56 @@ +const { + STRING, + STRING_KEY, + WHITESPACE, + COLON, + RIGHT_BRACE, + RIGHT_BRACKET +} = require('./constants').TOKENS; +const { TOKEN_COLORS } = require('./constants'); +const tokenize = require('./tokenize'); + +const markKeys = (tokens) => { + for (let i = 0; i < tokens.length; i++) { + let token = tokens[i]; + + if (token.type === STRING) { + for (let j = i + 1; j < tokens.length; j++) { + if (tokens[j].type === WHITESPACE) { + continue; + } + if (tokens[j].type === COLON) { + token.type = STRING_KEY; + break; + } + if ( + tokens[j].type === STRING || + tokens[j].type === RIGHT_BRACE || + tokens[j].type === RIGHT_BRACKET + ) { + break; + } + } + } + } + + return tokens; +}; + +module.exports = (input) => { + let result = ''; + + const tokens = tokenize(input); + const markedTokens = markKeys(tokens); + + for (let i = 0; i < markedTokens.length; i++) { + let token = markedTokens[i]; + + if (TOKEN_COLORS[token.type]) { + result += TOKEN_COLORS[token.type](token.value); + } else { + result += token.value; + } + } + + return result; +}; diff --git a/utils/constants.js b/utils/constants.js new file mode 100644 index 0000000..d9705ff --- /dev/null +++ b/utils/constants.js @@ -0,0 +1,54 @@ +const chalk = require('chalk'); + +const TOKENS = { + LEFT_BRACE: 0, // { + RIGHT_BRACE: 1, // } + LEFT_BRACKET: 2, // [ + RIGHT_BRACKET: 3, // ] + COLON: 4, // : + COMMA: 5, // , + STRING: 6, // + STRING_KEY: 7, // + NUMBER: 8, // + TRUE: 9, // true + FALSE: 10, // false + NULL: 11, // null + WHITESPACE: 12 // +}; + +const TOKEN_COLORS = { + [TOKENS.LEFT_BRACE]: chalk.bold.white, + [TOKENS.RIGHT_BRACE]: chalk.bold.white, + [TOKENS.LEFT_BRACKET]: chalk.bold.white, + [TOKENS.RIGHT_BRACKET]: chalk.bold.white, + [TOKENS.COLON]: chalk.bold.white, + [TOKENS.COMMA]: chalk.bold.white, + [TOKENS.STRING]: chalk.green, + [TOKENS.STRING_KEY]: chalk.green.yellow, + [TOKENS.NUMBER]: chalk.blue, + [TOKENS.TRUE]: chalk.cyan, + [TOKENS.FALSE]: chalk.cyan, + [TOKENS.NULL]: chalk.red +}; + +const PUNCTUATOR_TOKENS_MAP = { + '{': TOKENS.LEFT_BRACE, + '}': TOKENS.RIGHT_BRACE, + '[': TOKENS.LEFT_BRACKET, + ']': TOKENS.RIGHT_BRACKET, + ':': TOKENS.COLON, + ',': TOKENS.COMMA +}; + +const KEYWORD_TOKENS_MAP = { + 'true': TOKENS.TRUE, + 'false': TOKENS.FALSE, + 'null': TOKENS.NULL +}; + +module.exports = { + TOKENS, + TOKEN_COLORS, + PUNCTUATOR_TOKENS_MAP, + KEYWORD_TOKENS_MAP +}; diff --git a/utils/tokenize.js b/utils/tokenize.js new file mode 100644 index 0000000..04bea35 --- /dev/null +++ b/utils/tokenize.js @@ -0,0 +1,205 @@ +const { + STRING, + NUMBER, + WHITESPACE +} = require('./constants').TOKENS; +const { + PUNCTUATOR_TOKENS_MAP, + KEYWORD_TOKENS_MAP +} = require('./constants'); + +// HELPERS + +// digit +// A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9). +function isDigit(code) { + return code >= 0x0030 && code <= 0x0039; +} + +// hex digit +// A digit, or a code point between U+0041 LATIN CAPITAL LETTER A (A) and U+0046 LATIN CAPITAL LETTER F (F), +// or a code point between U+0061 LATIN SMALL LETTER A (a) and U+0066 LATIN SMALL LETTER F (f). +function isHexDigit(code) { + return ( + isDigit(code) || // 0 .. 9 + (code >= 0x0041 && code <= 0x0046) || // A .. F + (code >= 0x0061 && code <= 0x0066) // a .. f + ); +} + +function isWhiteSpace(code) { + return ( + code === 0x0009 || // \t + code === 0x000A || // \n + code === 0x000D || // \r + code === 0x0020 // space + ); +} + +// PARSERS + +function parseWhitespace(input, index) { + const start = index; + + for (; index < input.length; index++) { + if (!isWhiteSpace(input.charCodeAt(index))) { + break; + } + } + + if (start === index) { + return null; + } + + return { + type: WHITESPACE, + value: input.substring(start, index) + }; +} + +function parseDelim(input, index) { + const char = input.charAt(index); + + if (char in PUNCTUATOR_TOKENS_MAP) { + return { + type: PUNCTUATOR_TOKENS_MAP[char], + value: char + }; + } + + return null; +} + +function parseKeyword(input, index) { + for (const name in KEYWORD_TOKENS_MAP) { + if (KEYWORD_TOKENS_MAP.hasOwnProperty(name) && input.substr(index, name.length) === name) { + return { + type: KEYWORD_TOKENS_MAP[name], + value: name + }; + } + } + + return null; +} + +function parseString(input, index) { + const start = index; + + if (input.charAt(index) !== '"') { + return null; + } + + for (index++; index < input.length; index++) { + switch (input.charAt(index)) { + case '"': + return { + type: STRING, + value: input.substring(start, index + 1) + }; + + case '\\': + index++; + if (input.charCodeAt(index) === 'u') { + // ensure there is at least 5 chars: a code and closing quote + if (input.length - index < 5) { + return null; + } + + for (let i = 0; i < 4; i++, index++) { + if (!isHexDigit(input.charCodeAt(index))) { + return null; + } + } + } + break; + } + } + + return null; +} + +function findDecimalNumberEnd(source, offset) { + for (; offset < source.length; offset++) { + if (!isDigit(source.charCodeAt(offset))) { + break; + } + } + + return offset; +} + +// Consume a number +function parseNumber(input, index) { + const start = index; + let code = input.charCodeAt(index); + + // If the next input code point is U+002B PLUS SIGN (+) or U+002D HYPHEN-MINUS (-), + // consume it and append it to repr. + if (code === 0x002B || code === 0x002D) { + code = input.charCodeAt(index += 1); + } + + // While the next input code point is a digit, consume it and append it to repr. + if (isDigit(code)) { + index = findDecimalNumberEnd(input, index + 1); + code = input.charCodeAt(index); + } else { + return null; + } + + // If the next 2 input code points are U+002E FULL STOP (.) followed by a digit, then: + if (code === 0x002E && isDigit(input.charCodeAt(index + 1))) { + code = input.charCodeAt(index += 2); + index = findDecimalNumberEnd(input, index); + } + + // If the next 2 or 3 input code points are U+0045 LATIN CAPITAL LETTER E (E) + // or U+0065 LATIN SMALL LETTER E (e), ... , followed by a digit, then: + if (code === 0x0045 /* E */ || code === 0x0065 /* e */) { + var sign = 0; + code = input.charCodeAt(index + 1); + + // ... optionally followed by U+002D HYPHEN-MINUS (-) or U+002B PLUS SIGN (+) ... + if (code === 0x002D || code === 0x002B) { + sign = 1; + code = input.charCodeAt(index + 2); + } + + // ... followed by a digit + if (isDigit(code)) { + index = findDecimalNumberEnd(input, index + 1 + sign + 1); + } + } + + return { + type: NUMBER, + value: input.slice(start, index) + }; +} + +module.exports = (input) => { + let index = 0; + + const tokens = []; + + while (index < input.length) { + const token = ( + parseWhitespace(input, index) || + parseDelim(input, index) || + parseKeyword(input, index) || + parseString(input, index) || + parseNumber(input, index) + ); + + if (!token) { + break; + } + + tokens.push(token); + + index += token.value.length; + } + + return tokens; +};