2 * Author: Alex Kocharin <alex@kocharin.ru>
3 * GIT: https://github.com/rlidwka/jju
4 * License: WTFPL, grab your copy here: http://www.wtfpl.net/txt/copying/
7 var assert = require('assert')
8 var tokenize = require('./parse').tokenize
9 var stringify = require('./stringify').stringify
10 var analyze = require('./analyze').analyze
12 function isObject(x) {
13 return typeof(x) === 'object' && x !== null
16 function value_to_tokenlist(value, stack, options, is_key, indent) {
17 options = Object.create(options)
18 options._stringify_key = !!is_key
21 options._prefix = indent.prefix.map(function(x) {
26 if (options._splitMin == null) options._splitMin = 0
27 if (options._splitMax == null) options._splitMax = 0
29 var stringified = stringify(value, options)
32 return [ { raw: stringified, type: 'key', stack: stack, value: value } ]
35 options._addstack = stack
36 var result = tokenize(stringified, {
43 // '1.2.3' -> ['1','2','3']
44 function arg_to_path(path) {
46 if (typeof(path) === 'number') path = String(path)
48 if (path === '') path = []
49 if (typeof(path) === 'string') path = path.split('.')
51 if (!Array.isArray(path)) throw Error('Invalid path type, string or array expected')
55 // returns new [begin, end] or false if not found
57 // {x:3, xxx: 111, y: [111, {q: 1, e: 2} ,333] }
58 // f('y',0) returns this B^^^^^^^^^^^^^^^^^^^^^^^^E
59 // then f('1',1) would reduce it to B^^^^^^^^^^E
60 function find_element_in_tokenlist(element, lvl, tokens, begin, end) {
61 while(tokens[begin].stack[lvl] != element) {
62 if (begin++ >= end) return false
64 while(tokens[end].stack[lvl] != element) {
65 if (end-- < begin) return false
70 function is_whitespace(token_type) {
71 return token_type === 'whitespace'
72 || token_type === 'newline'
73 || token_type === 'comment'
76 function find_first_non_ws_token(tokens, begin, end) {
77 while(is_whitespace(tokens[begin].type)) {
78 if (begin++ >= end) return false
83 function find_last_non_ws_token(tokens, begin, end) {
84 while(is_whitespace(tokens[end].type)) {
85 if (end-- < begin) return false
91 * when appending a new element of an object/array, we are trying to
92 * figure out the style used on the previous element
94 * return {prefix, sep1, sep2, suffix}
96 * ' "key" : "element" \r\n'
97 * prefix^^^^ sep1^ ^^sep2 ^^^^^^^^suffix
99 * begin - the beginning of the object/array
100 * end - last token of the last element (value or comma usually)
102 function detect_indent_style(tokens, is_array, begin, end, level) {
111 if (tokens[end].type === 'separator' && tokens[end].stack.length !== level+1 && tokens[end].raw !== ',') {
112 // either a beginning of the array (no last element) or other weird situation
114 // just return defaults
118 // ' "key" : "value" ,'
119 // skipping last separator, we're now here ^^
120 if (tokens[end].type === 'separator')
121 end = find_last_non_ws_token(tokens, begin, end - 1)
122 if (end === false) return result
124 // ' "key" : "value" ,'
125 // skipping value ^^^^^^^
126 while(tokens[end].stack.length > level) end--
129 while(is_whitespace(tokens[end].type)) {
130 if (end < begin) return result
131 if (tokens[end].type === 'whitespace') {
132 result.sep2.unshift(tokens[end])
134 // newline, comment or other unrecognized codestyle
140 // ' "key" : "value" ,'
141 // skipping separator ^
142 assert.equal(tokens[end].type, 'separator')
143 assert.equal(tokens[end].raw, ':')
144 while(is_whitespace(tokens[--end].type)) {
145 if (end < begin) return result
146 if (tokens[end].type === 'whitespace') {
147 result.sep1.unshift(tokens[end])
149 // newline, comment or other unrecognized codestyle
154 assert.equal(tokens[end].type, 'key')
158 // ' "key" : "value" ,'
159 // skipping key ^^^^^
160 while(is_whitespace(tokens[end].type)) {
161 if (end < begin) return result
162 if (tokens[end].type === 'whitespace') {
163 result.prefix.unshift(tokens[end])
164 } else if (tokens[end].type === 'newline') {
165 result.newline.unshift(tokens[end])
168 // comment or other unrecognized codestyle
177 function Document(text, options) {
178 var self = Object.create(Document.prototype)
180 if (options == null) options = {}
181 //options._structure = true
182 var tokens = self._tokens = tokenize(text, options)
183 self._data = tokens.data
185 self._options = options
187 var stats = analyze(text, options)
188 if (options.indent == null) {
189 options.indent = stats.indent
191 if (options.quote == null) {
192 options.quote = stats.quote
194 if (options.quote_keys == null) {
195 options.quote_keys = stats.quote_keys
197 if (options.no_trailing_comma == null) {
198 options.no_trailing_comma = !stats.has_trailing_comma
203 // return true if it's a proper object
205 function check_if_can_be_placed(key, object, is_unset) {
206 //if (object == null) return false
207 function error(add) {
208 return Error("You can't " + (is_unset ? 'unset' : 'set') + " key '" + key + "'" + add)
211 if (!isObject(object)) {
212 throw error(' of an non-object')
214 if (Array.isArray(object)) {
215 // array, check boundary
216 if (String(key).match(/^\d+$/)) {
217 key = Number(String(key))
218 if (object.length < key || (is_unset && object.length === key)) {
219 throw error(', out of bounds')
220 } else if (is_unset && object.length !== key+1) {
221 throw error(' in the middle of an array')
226 throw error(' of an array')
234 // usage: document.set('path.to.something', 'value')
235 // or: document.set(['path','to','something'], 'value')
236 Document.prototype.set = function(path, value) {
237 path = arg_to_path(path)
239 // updating this._data and check for errors
240 if (path.length === 0) {
241 if (value === undefined) throw Error("can't remove root document")
246 var data = this._data
248 for (var i=0; i<path.length-1; i++) {
249 check_if_can_be_placed(path[i], data, false)
252 if (i === path.length-1) {
253 check_if_can_be_placed(path[i], data, value === undefined)
256 var new_key = !(path[i] in data)
258 if (value === undefined) {
259 if (Array.isArray(data)) {
265 data[path[i]] = value
269 // for inserting document
270 if (!this._tokens.length)
271 this._tokens = [ { raw: '', type: 'literal', stack: [], value: undefined } ]
274 find_first_non_ws_token(this._tokens, 0, this._tokens.length - 1),
275 find_last_non_ws_token(this._tokens, 0, this._tokens.length - 1),
277 for (var i=0; i<path.length-1; i++) {
278 position = find_element_in_tokenlist(path[i], i, this._tokens, position[0], position[1])
279 if (position == false) throw Error('internal error, please report this')
281 // assume that i == path.length-1 here
283 if (path.length === 0) {
284 var newtokens = value_to_tokenlist(value, path, this._options)
287 } else if (!new_key) {
288 // replace old value with a new one (or deleting something)
289 var pos_old = position
290 position = find_element_in_tokenlist(path[i], i, this._tokens, position[0], position[1])
292 if (value === undefined && position !== false) {
293 // deleting element (position !== false ensures there's something)
296 if (!Array.isArray(data)) {
297 // removing element from an object, `{x:1, key:CURRENT} -> {x:1}`
298 // removing sep, literal and optional sep
300 var pos2 = find_last_non_ws_token(this._tokens, pos_old[0], position[0] - 1)
301 assert.equal(this._tokens[pos2].type, 'separator')
302 assert.equal(this._tokens[pos2].raw, ':')
306 var pos2 = find_last_non_ws_token(this._tokens, pos_old[0], position[0] - 1)
307 assert.equal(this._tokens[pos2].type, 'key')
308 assert.equal(this._tokens[pos2].value, path[path.length-1])
312 // removing comma in arrays and objects
313 var pos2 = find_last_non_ws_token(this._tokens, pos_old[0], position[0] - 1)
314 assert.equal(this._tokens[pos2].type, 'separator')
315 if (this._tokens[pos2].raw === ',') {
318 // beginning of the array/object, so we should remove trailing comma instead
319 pos2 = find_first_non_ws_token(this._tokens, position[1] + 1, pos_old[1])
320 assert.equal(this._tokens[pos2].type, 'separator')
321 if (this._tokens[pos2].raw === ',') {
327 var indent = pos2 !== false
328 ? detect_indent_style(this._tokens, Array.isArray(data), pos_old[0], position[1] - 1, i)
330 var newtokens = value_to_tokenlist(value, path, this._options, false, indent)
334 // insert new key, that's tricky
335 var path_1 = path.slice(0, i)
337 // find a last separator after which we're inserting it
338 var pos2 = find_last_non_ws_token(this._tokens, position[0] + 1, position[1] - 1)
339 assert(pos2 !== false)
341 var indent = pos2 !== false
342 ? detect_indent_style(this._tokens, Array.isArray(data), position[0] + 1, pos2, i)
345 var newtokens = value_to_tokenlist(value, path, this._options, false, indent)
347 // adding leading whitespaces according to detected codestyle
349 if (indent.newline && indent.newline.length)
350 prefix = prefix.concat(indent.newline)
351 if (indent.prefix && indent.prefix.length)
352 prefix = prefix.concat(indent.prefix)
354 // adding '"key":' (as in "key":"value") to object values
355 if (!Array.isArray(data)) {
356 prefix = prefix.concat(value_to_tokenlist(path[path.length-1], path_1, this._options, true))
357 if (indent.sep1 && indent.sep1.length)
358 prefix = prefix.concat(indent.sep1)
359 prefix.push({raw: ':', type: 'separator', stack: path_1})
360 if (indent.sep2 && indent.sep2.length)
361 prefix = prefix.concat(indent.sep2)
364 newtokens.unshift.apply(newtokens, prefix)
366 // check if prev token is a separator AND they're at the same level
367 if (this._tokens[pos2].type === 'separator' && this._tokens[pos2].stack.length === path.length-1) {
368 // previous token is either , or [ or {
369 if (this._tokens[pos2].raw === ',') {
370 // restore ending comma
371 newtokens.push({raw: ',', type: 'separator', stack: path_1})
374 // previous token isn't a separator, so need to insert one
375 newtokens.unshift({raw: ',', type: 'separator', stack: path_1})
378 if (indent.suffix && indent.suffix.length)
379 newtokens.push.apply(newtokens, indent.suffix)
381 assert.equal(this._tokens[position[1]].type, 'separator')
386 newtokens.unshift(position[1] - position[0] + 1)
387 newtokens.unshift(position[0])
388 this._tokens.splice.apply(this._tokens, newtokens)
393 // convenience method
394 Document.prototype.unset = function(path) {
395 return this.set(path, undefined)
398 Document.prototype.get = function(path) {
399 path = arg_to_path(path)
401 var data = this._data
402 for (var i=0; i<path.length; i++) {
403 if (!isObject(data)) return undefined
409 Document.prototype.has = function(path) {
410 path = arg_to_path(path)
412 var data = this._data
413 for (var i=0; i<path.length; i++) {
414 if (!isObject(data)) return false
417 return data !== undefined
420 // compare old object and new one, and change differences only
421 Document.prototype.update = function(value) {
423 change([], self._data, value)
426 function change(path, old_data, new_data) {
427 if (!isObject(new_data) || !isObject(old_data)) {
428 // if source or dest is primitive, just replace
429 if (new_data !== old_data)
430 self.set(path, new_data)
432 } else if (Array.isArray(new_data) != Array.isArray(old_data)) {
433 // old data is an array XOR new data is an array, replace as well
434 self.set(path, new_data)
436 } else if (Array.isArray(new_data)) {
437 // both values are arrays here
439 if (new_data.length > old_data.length) {
440 // adding new elements, so going forward
441 for (var i=0; i<new_data.length; i++) {
443 change(path, old_data[i], new_data[i])
448 // removing something, so going backward
449 for (var i=old_data.length-1; i>=0; i--) {
451 change(path, old_data[i], new_data[i])
457 // both values are objects here
458 for (var i in new_data) {
460 change(path, old_data[i], new_data[i])
464 for (var i in old_data) {
465 if (i in new_data) continue
467 change(path, old_data[i], new_data[i])
474 Document.prototype.toString = function() {
475 return this._tokens.map(function(x) {
480 module.exports.Document = Document
482 module.exports.update = function updateJSON(source, new_value, options) {
483 return Document(source, options).update(new_value).toString()