diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6de7ee0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.js +.DS_Store +npm-debug.log diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..9af9ace --- /dev/null +++ b/.npmignore @@ -0,0 +1,5 @@ +.npmignore +*.coffee +script/ +.DS_Store +npm-debug.log diff --git a/Gruntfile.coffee b/Gruntfile.coffee new file mode 100644 index 0000000..326a0b3 --- /dev/null +++ b/Gruntfile.coffee @@ -0,0 +1,36 @@ +module.exports = (grunt) -> + grunt.initConfig + pkg: grunt.file.readJSON('package.json') + + coffee: + glob_to_multiple: + expand: true + cwd: 'src' + src: ['*.coffee'] + dest: 'lib' + ext: '.js' + + coffeelint: + options: + max_line_length: + level: 'ignore' + + src: ['src/*.coffee'] + test: ['spec/*.coffee'] + + shell: + test: + command: 'npm test' + options: + stdout: true + stderr: true + failOnError: true + + grunt.loadNpmTasks('grunt-contrib-coffee') + grunt.loadNpmTasks('grunt-shell') + grunt.loadNpmTasks('grunt-coffeelint') + + grunt.registerTask 'clean', -> require('rimraf').sync('lib') + grunt.registerTask('lint', ['coffeelint:src', 'coffeelint:test']) + grunt.registerTask('default', ['coffeelint:src', 'coffee']) + grunt.registerTask('test', ['default', 'coffeelint:test', 'shell:test']) diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc4df53 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# season - CSON Node module + +Read and write CSON/JSON files seamlessly. + +## Installing + +```sh +npm install season +``` + +## Building + * Clone the repository + * Run `npm install` + * Run `grunt` to compile the CoffeeScript code + * Run `grunt test` to run the specs + +## Docs + +```coffeescript +CSON = require 'season' +``` + +### CSON.stringify(object) + +Convert the object to a CSON string. + +`object` - The object to convert to CSON. + +Returns the CSON string representation of the given object. + +### CSON.readObject(objectPath, callback) + +Read the CSON or JSON object at the given path and return it to the callback +once it is read and parsed. + +`objectPath` - The string path to a JSON or CSON object file. + +`callback` - The callback to call with the error or object once the path + is read and parsed. + +### CSON.writeObjectSync(objectPath, object) + +Write the object to the given path as either JSON or CSON depending on the +path's extension. + +`objectPath` - The string path to a JSON or CSON object file. + +`object` - The object to convert to a string and write to the path. + +### CSON.isObjectPath(objectPath) + +Is the given path a valid object path? + +Returns `true` if the path has a `.json` or `.cson` file extension, `false` +otherwise. diff --git a/bin/csonc b/bin/csonc new file mode 100755 index 0000000..8c05415 --- /dev/null +++ b/bin/csonc @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +require('../lib/csonc')(process.argv.slice(2)) diff --git a/package.json b/package.json new file mode 100644 index 0000000..c8fe6f6 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "season", + "version": "0.0.0", + "description": "CSON utilities", + "licenses": [ + { + "type": "MIT", + "url": "http://github.com/atom/season/raw/master/LICENSE.md" + } + ], + "main": "./lib/cson.js", + "bin": { + "csonc": "./bin/csonc" + }, + "scripts": { + "prepublish": "grunt coffee", + "test": "jasmine-focused --captureExceptions --coffee spec/" + }, + "repository": { + "type": "git", + "url": "https://github.com/atom/season.git" + }, + "bugs": { + "url": "https://github.com/atom/season/issues" + }, + "keywords": [ + "cson", + "json" + ], + "dependencies": { + "underscore": "~1.4.4", + "coffee-script": "~1.6.2" + }, + "devDependencies": { + "jasmine-focused": "~0.1.0", + "grunt-contrib-coffee": "~0.7.0", + "grunt-cli": "~0.1.8", + "grunt": "~0.4.1", + "grunt-shell": "~0.2.2", + "grunt-coffeelint": "0.0.6", + "temp": "~0.5.0" + } +} diff --git a/spec/cson-spec.coffee b/spec/cson-spec.coffee new file mode 100644 index 0000000..31a5044 --- /dev/null +++ b/spec/cson-spec.coffee @@ -0,0 +1,101 @@ +CSON = require '../lib/cson' + +describe "CSON", -> + describe ".stringify(object)", -> + describe "when the object is undefined", -> + it "throws an exception", -> + expect(-> CSON.stringify()).toThrow() + + describe "when the object is a function", -> + it "throws an exception", -> + expect(-> CSON.stringify(-> 'function')).toThrow() + + describe "when the object contains a function", -> + it "throws an exception", -> + expect(-> CSON.stringify(a: -> 'function')).toThrow() + + describe "when formatting an undefined key", -> + it "does not include the key in the formatted CSON", -> + expect(CSON.stringify(b: 1, c: undefined)).toBe "'b': 1" + + describe "when formatting a string", -> + it "returns formatted CSON", -> + expect(CSON.stringify(a: 'b')).toBe "'a': 'b'" + + it "escapes single quotes", -> + expect(CSON.stringify(a: "'b'")).toBe "'a': '\\\'b\\\''" + + it "doesn't escape double quotes", -> + expect(CSON.stringify(a: '"b"')).toBe "'a': '\"b\"'" + + it "escapes newlines", -> + expect(CSON.stringify("a\nb")).toBe "'a\\nb'" + + describe "when formatting a boolean", -> + it "returns formatted CSON", -> + expect(CSON.stringify(true)).toBe 'true' + expect(CSON.stringify(false)).toBe 'false' + expect(CSON.stringify(a: true)).toBe "'a': true" + expect(CSON.stringify(a: false)).toBe "'a': false" + + describe "when formatting a number", -> + it "returns formatted CSON", -> + expect(CSON.stringify(54321.012345)).toBe '54321.012345' + expect(CSON.stringify(a: 14)).toBe "'a': 14" + expect(CSON.stringify(a: 1.23)).toBe "'a': 1.23" + + describe "when formatting null", -> + it "returns formatted CSON", -> + expect(CSON.stringify(null)).toBe 'null' + expect(CSON.stringify(a: null)).toBe "'a': null" + + describe "when formatting an array", -> + describe "when the array is empty", -> + it "puts the array on a single line", -> + expect(CSON.stringify([])).toBe "[]" + + it "returns formatted CSON", -> + expect(CSON.stringify(a: ['b'])).toBe "'a': [\n 'b'\n]" + expect(CSON.stringify(a: ['b', 4])).toBe "'a': [\n 'b'\n 4\n]" + + describe "when the array has an undefined value", -> + it "formats the undefined value as null", -> + expect(CSON.stringify(['a', undefined, 'b'])).toBe "[\n 'a'\n null\n 'b'\n]" + + describe "when the array contains an object", -> + it "wraps the object in {}", -> + expect(CSON.stringify([{a:'b', a1: 'b1'}, {c: 'd'}])).toBe "[\n {\n 'a': 'b'\n 'a1': 'b1'\n }\n {\n 'c': 'd'\n }\n]" + + describe "when formatting an object", -> + describe "when the object is empty", -> + it "returns {}", -> + expect(CSON.stringify({})).toBe "{}" + + it "returns formatted CSON", -> + expect(CSON.stringify(a: {b: 'c'})).toBe "'a':\n 'b': 'c'" + expect(CSON.stringify(a:{})).toBe "'a': {}" + expect(CSON.stringify(a:[])).toBe "'a': []" + + describe "when converting back to an object", -> + it "produces the original object", -> + object = + showInvisibles: true + fontSize: 20 + core: + themes: ['a', 'b'] + whitespace: + ensureSingleTrailingNewline: true + + cson = CSON.stringify(object) + CoffeeScript = require 'coffee-script' + evaledObject = CoffeeScript.eval(cson, bare: true) + expect(evaledObject).toEqual object + + describe ".isObjectPath(objectPath)", -> + it "returns true if the path has an object extension", -> + expect(CSON.isObjectPath('/test2.json')).toBe true + expect(CSON.isObjectPath('/a/b.cson')).toBe true + expect(CSON.isObjectPath()).toBe false + expect(CSON.isObjectPath(null)).toBe false + expect(CSON.isObjectPath('')).toBe false + expect(CSON.isObjectPath('a/b/c.txt')).toBe false diff --git a/spec/csonc-spec.coffee b/spec/csonc-spec.coffee new file mode 100644 index 0000000..bf25e35 --- /dev/null +++ b/spec/csonc-spec.coffee @@ -0,0 +1,37 @@ +path = require 'path' +fs = require 'fs' +temp = require 'temp' +csonc = require '../lib/csonc' + +describe "CSON compilation to JSON", -> + [compileDir, inputFile, outputFile] = [] + + beforeEach -> + compileDir = temp.mkdirSync('season-compile-dir-') + inputFile = path.join(compileDir, 'input.cson') + outputFile = path.join(compileDir, 'input.json') + spyOn(process, 'exit') + spyOn(console, 'error') + + it "logs an error and exits when no input file is specified", -> + csonc([]) + expect(process.exit.mostRecentCall.args[0]).toBe 1 + expect(console.error.mostRecentCall.args[0].length).toBeGreaterThan 0 + + it "logs an error and exits when no input file is specified", -> + csonc(['./input.cson']) + expect(process.exit.mostRecentCall.args[0]).toBe 1 + expect(console.error.mostRecentCall.args[0].length).toBeGreaterThan 0 + + describe "when a valid CSON file is specified", -> + it "converts the file to JSON and writes it out", -> + fs.writeFileSync(inputFile, 'a: 3') + csonc([inputFile, outputFile]) + expect(fs.readFileSync(outputFile, {encoding: 'utf8'})).toBe '{\n "a": 3\n}\n' + + describe "when an invalid CSON file is specified", -> + it "logs an error and exits", -> + fs.writeFileSync(inputFile, '1234') + csonc([inputFile, outputFile]) + expect(process.exit.mostRecentCall.args[0]).toBe 1 + expect(console.error.mostRecentCall.args[0].length).toBeGreaterThan 0 diff --git a/src/cson.coffee b/src/cson.coffee new file mode 100644 index 0000000..9d5ca8a --- /dev/null +++ b/src/cson.coffee @@ -0,0 +1,122 @@ +fs = require 'fs' +path = require 'path' +_ = require 'underscore' + +multiplyString = (string, n) -> new Array(1 + n).join(string) + +stringifyIndent = (level=0) -> multiplyString(' ', Math.max(level, 0)) + +stringifyString = (string) -> + string = JSON.stringify(string) + string = string[1...-1] # Remove surrounding double quotes + string = string.replace(/\\"/g, '"') # Unescape escaped double quotes + string = string.replace(/'/g, '\\\'') # Escape single quotes + "'#{string}'" # Wrap in single quotes + +stringifyBoolean = (boolean) -> "#{boolean}" + +stringifyNumber = (number) -> "#{number}" + +stringifyNull = -> 'null' + +stringifyArray = (array, indentLevel=0) -> + return '[]' if array.length is 0 + + cson = '[\n' + for value in array + indent = stringifyIndent(indentLevel + 2) + cson += indent + if _.isString(value) + cson += stringifyString(value) + else if _.isBoolean(value) + cson += stringifyBoolean(value) + else if _.isNumber(value) + cson += stringifyNumber(value) + else if _.isNull(value) or value is undefined + cson += stringifyNull(value) + else if _.isArray(value) + cson += stringifyArray(value, indentLevel + 2) + else if _.isObject(value) + cson += "{\n#{stringifyObject(value, indentLevel + 4)}\n#{indent}}" + else + throw new Error("Unrecognized type for array value: #{value}") + cson += '\n' + "#{cson}#{stringifyIndent(indentLevel)}]" + +stringifyObject = (object, indentLevel=0) -> + return '{}' if _.isEmpty(object) + + cson = '' + prefix = '' + for key, value of object + continue if value is undefined + if _.isFunction(value) + throw new Error("Function specified as value to key: #{key}") + + cson += "#{prefix}#{stringifyIndent(indentLevel)}'#{key}':" + if _.isString(value) + cson += " #{stringifyString(value)}" + else if _.isBoolean(value) + cson += " #{stringifyBoolean(value)}" + else if _.isNumber(value) + cson += " #{stringifyNumber(value)}" + else if _.isNull(value) + cson += " #{stringifyNull(value)}" + else if _.isArray(value) + cson += " #{stringifyArray(value, indentLevel)}" + else if _.isObject(value) + if _.isEmpty(value) + cson += ' {}' + else + cson += "\n#{stringifyObject(value, indentLevel + 2)}" + else + throw new Error("Unrecognized value type for key: #{key} with value: #{value}") + prefix = '\n' + cson + +parseObject = (objectPath, contents) -> + if path.extname(objectPath) is '.cson' + CoffeeScript = require 'coffee-script' + CoffeeScript.eval(contents, {bare: true}) + else + JSON.parse(contents) + +module.exports = + isObjectPath: (objectPath) -> + return false unless objectPath + + extension = path.extname(objectPath) + extension is '.cson' or extension is '.json' + + readObjectSync: (objectPath) -> + parseObject(objectPath, fs.readFileSync(objectPath, {encoding: 'utf8'})) + + readObject: (objectPath, callback) -> + fs.readFile objectPath, {encoding: 'utf8'}, (err, contents) => + if error? + callback?(error) + else + try + callback?(null, @parseObject(objectPath, contents)) + catch err + callback?(err) + + writeObjectSync: (objectPath, object) -> + if path.extname(objectPath) is '.cson' + content = @stringify(object) + else + content = JSON.stringify(object, undefined, 2) + fs.writeFileSync(objectPath, "#{content}\n") + + stringify: (object) -> + throw new Error("Cannot stringify undefined object") if object is undefined + throw new Error("Cannot stringify function: #{object}") if _.isFunction(object) + + return stringifyString(object) if _.isString(object) + return stringifyBoolean(object) if _.isBoolean(object) + return stringifyNumber(object) if _.isNumber(object) + return stringifyNull(object) if _.isNull(object) + return stringifyArray(object) if _.isArray(object) + return stringifyObject(object) if _.isObject(object) + + throw new Error("Unrecognized type to stringify: #{object}") diff --git a/src/csonc.coffee b/src/csonc.coffee new file mode 100644 index 0000000..0827d50 --- /dev/null +++ b/src/csonc.coffee @@ -0,0 +1,27 @@ +path = require 'path' +_ = require 'underscore' +CSON = require './cson' + +module.exports = (argv=[]) -> + [inputFile, outputFile] = argv + + if inputFile?.length > 0 + inputFile = path.resolve(process.cwd(), inputFile) + else + console.error("Input file must be first argument") + process.exit(1) + return + + if outputFile?.length > 0 + outputFile = path.resolve(process.cwd(), outputFile) + else + console.error("Output file must be second argument") + process.exit(1) + return + + object = CSON.readObjectSync(inputFile) + if _.isObject(object) + CSON.writeObjectSync(outputFile, object) + else + console.error("Input file does not contain an object: #{inputFile}") + process.exit(1)