require("string.prototype.codepointat"); var fs = require("fs"); function Iterator(text) { var pos = 0, length = text.length; this.peek = function(num) { num = num || 0; if(pos + num >= length) { return null; } return text.charAt(pos + num); }; this.next = function(inc) { inc = inc || 1; if(pos >= length) { return null; } return text.charAt((pos += inc) - inc); }; this.pos = function() { return pos; }; } var rWhitespace = /\s/; function isWhitespace(chr) { return rWhitespace.test(chr); } function consumeWhiteSpace(iter) { var start = iter.pos(); while(isWhitespace(iter.peek())) { iter.next(); } return { type: "whitespace", start: start, end: iter.pos() }; } function startsComment(chr) { return chr === "!" || chr === "#"; } function isEOL(chr) { return chr == null || chr === "\n" || chr === "\r"; } function consumeComment(iter) { var start = iter.pos(); while(!isEOL(iter.peek())) { iter.next(); } return { type: "comment", start: start, end: iter.pos() }; } function startsKeyVal(chr) { return !isWhitespace(chr) && !startsComment(chr); } function startsSeparator(chr) { return chr === "=" || chr === ":" || isWhitespace(chr); } function startsEscapedVal(chr) { return chr === "\\"; } function consumeEscapedVal(iter) { var start = iter.pos(); iter.next(); // move past "\" var curChar = iter.next(); if(curChar === "u") { // encoded unicode char iter.next(4); // Read in the 4 hex values } return { type: "escaped-value", start: start, end: iter.pos() }; } function consumeKey(iter) { var start = iter.pos(), children = []; var curChar; while((curChar = iter.peek()) !== null) { if(startsSeparator(curChar)) { break; } if(startsEscapedVal(curChar)) { children.push(consumeEscapedVal(iter)); continue; } iter.next(); } return { type: "key", start: start, end: iter.pos(), children: children }; } function consumeKeyValSeparator(iter) { var start = iter.pos(); var seenHardSep = false, curChar; while((curChar = iter.peek()) !== null) { if(isEOL(curChar)) { break; } if(isWhitespace(curChar)) { iter.next(); continue; } if(seenHardSep) { break; } seenHardSep = (curChar === ":" || curChar === "="); if(seenHardSep) { iter.next(); continue; } break; // curChar is a non-separtor char } return { type: "key-value-separator", start: start, end: iter.pos() }; } function startsLineBreak(iter) { return iter.peek() === "\\" && isEOL(iter.peek(1)); } function consumeLineBreak(iter) { var start = iter.pos(); iter.next(); // consume \ if(iter.peek() === "\r") { iter.next(); } iter.next(); // consume \n var curChar; while((curChar = iter.peek()) !== null) { if(isEOL(curChar)) { break; } if(!isWhitespace(curChar)) { break; } iter.next(); } return { type: "line-break", start: start, end: iter.pos() }; } function consumeVal(iter) { var start = iter.pos(), children = []; var curChar; while((curChar = iter.peek()) !== null) { if(startsLineBreak(iter)) { children.push(consumeLineBreak(iter)); continue; } if(startsEscapedVal(curChar)) { children.push(consumeEscapedVal(iter)); continue; } if(isEOL(curChar)) { break; } iter.next(); } return { type: "value", start: start, end: iter.pos(), children: children }; } function consumeKeyVal(iter) { return { type: "key-value", start: iter.pos(), children: [ consumeKey(iter), consumeKeyValSeparator(iter), consumeVal(iter) ], end: iter.pos() }; } var renderChild = { "escaped-value": function(child, text) { var type = text.charAt(child.start + 1); if(type === "t") { return "\t"; } if(type === "r") { return "\r"; } if(type === "n") { return "\n"; } if(type === "f") { return "\f"; } if(type !== "u") { return type; } return String.fromCharCode(parseInt(text.substr(child.start + 2, 4), 16)); }, "line-break": function (child, text) { return ""; } }; function rangeToBuffer(range, text) { var start = range.start, buffer = []; for(var i = 0; i < range.children.length; i++) { var child = range.children[i]; buffer.push(text.substring(start, child.start)); buffer.push(renderChild[child.type](child, text)); start = child.end; } buffer.push(text.substring(start, range.end)); return buffer; } function rangesToObject(ranges, text) { var obj = Object.create(null); // Creates to a true hash map for(var i = 0; i < ranges.length; i++) { var range = ranges[i]; if(range.type !== "key-value") { continue; } var key = rangeToBuffer(range.children[0], text).join(""); var val = rangeToBuffer(range.children[2], text).join(""); obj[key] = val; } return obj; } function stringToRanges(text) { var iter = new Iterator(text), ranges = []; var curChar; while((curChar = iter.peek()) !== null) { if(isWhitespace(curChar)) { ranges.push(consumeWhiteSpace(iter)); continue; } if(startsComment(curChar)) { ranges.push(consumeComment(iter)); continue; } if(startsKeyVal(curChar)) { ranges.push(consumeKeyVal(iter)); continue; } throw Error("Something crazy happened. text: '" + text + "'; curChar: '" + curChar + "'"); } return ranges; } function isNewLineRange(range) { if(!range) { return false; } if(range.type === "whitespace") { return true; } if(range.type === "literal") { return isWhitespace(range.text) && range.text.indexOf("\n") > -1; } return false; } function escapeMaker(escapes) { return function escapeKey(key) { var zeros = [ "", "0", "00", "000" ]; var buf = []; for(var i = 0; i < key.length; i++) { var chr = key.charAt(i); if(escapes[chr]) { buf.push(escapes[chr]); continue; } var code = chr.codePointAt(0); if(code <= 0x7F) { buf.push(chr); continue; } var hex = code.toString(16); buf.push("\\u"); buf.push(zeros[4 - hex.length]); buf.push(hex); } return buf.join(""); }; } var escapeKey = escapeMaker({ " ": "\\ ", "\n": "\\n", ":": "\\:", "=": "\\=" }); var escapeVal = escapeMaker({ "\n": "\\n" }); function Editor(text, options) { if (typeof text === 'object') { options = text; text = null; } text = text || ""; var path = options.path; var separator = options.separator || '='; var ranges = stringToRanges(text); var obj = rangesToObject(ranges, text); var keyRange = Object.create(null); // Creates to a true hash map for(var i = 0; i < ranges.length; i++) { var range = ranges[i]; if(range.type !== "key-value") { continue; } var key = rangeToBuffer(range.children[0], text).join(""); keyRange[key] = range; } this.addHeadComment = function(comment) { if(comment == null) { return; } ranges.unshift({ type: "literal", text: "# " + comment.replace(/\n/g, "\n# ") + "\n" }); }; this.get = function(key) { return obj[key]; }; this.set = function(key, val, comment) { if(val == null) { this.unset(key); return; } obj[key] = val; var escapedKey = escapeKey(key); var escapedVal = escapeVal(val); var range = keyRange[key]; if(!range) { keyRange[key] = range = { type: "literal", text: escapedKey + separator + escapedVal }; var prevRange = ranges[ranges.length - 1]; if(prevRange != null && !isNewLineRange(prevRange)) { ranges.push({ type: "literal", text: "\n" }); } ranges.push(range); } // comment === null deletes comment. if comment === undefined, it's left alone if(comment !== undefined) { range.comment = comment && "# " + comment.replace(/\n/g, "\n# ") + "\n"; } if(range.type === "literal") { range.text = escapedKey + separator + escapedVal; if(range.comment != null) { range.text = range.comment + range.text; } } else if(range.type === "key-value") { range.children[2] = { type: "literal", text: escapedVal }; } else { throw "Unknown node type: " + range.type; } }; this.unset = function(key) { if(!(key in obj)) { return; } var range = keyRange[key]; var idx = ranges.indexOf(range); ranges.splice(idx, (isNewLineRange(ranges[idx + 1]) ? 2 : 1)); delete keyRange[key]; delete obj[key]; }; this.valueOf = this.toString = function() { var buffer = [], stack = [].concat(ranges); var node; while((node = stack.shift()) != null) { switch(node.type) { case "literal": buffer.push(node.text); break; case "key": case "value": case "comment": case "whitespace": case "key-value-separator": case "escaped-value": case "line-break": buffer.push(text.substring(node.start, node.end)); break; case "key-value": Array.prototype.unshift.apply(stack, node.children); if(node.comment) { stack.unshift({ type: "literal", text: node.comment }); } break; } } return buffer.join(""); }; this.save = function(newPath, callback) { if(typeof newPath === 'function') { callback = newPath; newPath = path; } newPath = newPath || path; if(!newPath) { if (callback) { return callback("Unknown path"); } throw new Error("Unknown path"); } if (callback) { fs.writeFile(newPath, this.toString(), callback); } else { fs.writeFileSync(newPath, this.toString()); } }; } function createEditor(/*path, options, callback*/) { var path, options, callback; var args = Array.prototype.slice.call(arguments); for (var i = 0; i < args.length; i ++) { var arg = args[i]; if (!path && typeof arg === 'string') { path = arg; } else if (!options && typeof arg === 'object') { options = arg; } else if (!callback && typeof arg === 'function') { callback = arg; } } options = options || {}; path = path || options.path; callback = callback || options.callback; options.path = path; if(!path) { return new Editor(options); } if(!callback) { return new Editor(fs.readFileSync(path).toString(), options); } return fs.readFile(path, function(err, text) { if(err) { return callback(err, null); } text = text.toString(); return callback(null, new Editor(text, options)); }); } function parse(text) { text = text.toString(); var ranges = stringToRanges(text); return rangesToObject(ranges, text); } function read(path, callback) { if(!callback) { return parse(fs.readFileSync(path)); } return fs.readFile(path, function(err, data) { if(err) { return callback(err, null); } return callback(null, parse(data)); }); } module.exports = { parse: parse, read: read, createEditor: createEditor };