UNPKG

18.3 kBJavaScriptView Raw
1/*
2 * Copyright 2014-2016 Guy Bedford (http://guybedford.com)
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17var path = require('path');
18var fs = require('fs');
19var mkdirp = require('mkdirp');
20
21/*
22 * Configuration base class
23 * For creating and managing configurations which sync to files
24 */
25module.exports = ConfigFile;
26
27function ConfigFile(fileName, ordering) {
28 this.fileName = path.resolve(fileName);
29
30 this.ordering = ordering;
31
32 // configuration file style is detected when loading
33 this.style = null;
34
35 // we note the configuration file timestamp
36 this.timestamp = null;
37
38 // properties are stored as an ordered array of { key, value } pairs
39 // nested objects are in turn array values
40 // value properties are { value } objects
41 this.properties = [];
42
43 // allow adding change events
44 this.changeEvents = [];
45
46 // we only need to write if the file has actually changed
47 this.changed = false;
48
49 this.read();
50}
51
52function configError(memberArray, msg) {
53 if (arguments.length == 1) {
54 msg = memberArray;
55 memberArray = [];
56 }
57 throw new TypeError('Error reading %' + path.relative(process.cwd(), this.fileName) + '%\n\t' +
58 (memberArray.length ? '%' + memberArray.join('.') + '% ' : 'File ') + msg + '.');
59}
60
61function propertyEquals(propA, propB) {
62 if (propA instanceof Array || propB instanceof Array) {
63 if (!(propA instanceof Array && propB instanceof Array))
64 return false;
65
66 if (propA.length != propB.length)
67 return false;
68
69 return !propA.some(function(itemA, index) {
70 var itemB = propB[index];
71 return itemA.key != itemB.key || !propertyEquals(itemA.value, itemB.value);
72 });
73 }
74 else {
75 return propA.value === propB.value;
76 }
77}
78
79// adds the new property to the given properties object
80// if the property exists, it is updated to the new value
81// if not the property is placed at the first appropriate position alphabetically
82// returns true when an actual change is made
83// ordering is an array representing the property order suggestion, to use to apply ordering algorithm
84function setProperty(properties, key, value, ordering) {
85 var changed = false;
86 if (properties.some(function(prop) {
87 if (prop.key == key) {
88 // determine equality
89 if (!propertyEquals(prop.value, value))
90 changed = true;
91 prop.value = value;
92 return true;
93 }
94 }))
95 return changed;
96
97 if (!ordering || !ordering.length) {
98 properties.push({
99 key: key,
100 value: value
101 });
102 return true;
103 }
104
105 // apply ordering algorithm
106 var orderIndex = orderingIndex(ordering, key);
107
108 // find the max and minimum index in this property list given the ordering spec
109 var maxOrderIndex = properties.length, minOrderIndex = 0;
110 if (orderIndex != -1)
111 properties.forEach(function(prop, index) {
112 // get the ordering index of the current property
113 var propOrderIndex = orderingIndex(ordering, prop.key);
114 if (propOrderIndex != -1) {
115 if (propOrderIndex < orderIndex && index + 1 > minOrderIndex && index < maxOrderIndex)
116 minOrderIndex = index + 1;
117 if (propOrderIndex > orderIndex && index < maxOrderIndex && index >= minOrderIndex)
118 maxOrderIndex = index;
119 }
120 });
121
122 // within the ordering range, use alphabetical ordering
123 orderIndex = -1;
124 for (var i = minOrderIndex; i < maxOrderIndex; i++)
125 if (properties[i].key > key) {
126 orderIndex = i;
127 break;
128 }
129
130 if (orderIndex == -1)
131 orderIndex = maxOrderIndex;
132
133 properties.splice(orderIndex, 0, {
134 key: key,
135 value: value
136 });
137
138 return true;
139}
140
141// returns a property object for a given key from the property list
142// returns undefined if not found
143// properties is already assumed to be an array
144function getProperty(properties, key) {
145 var propMatch = {
146 index: -1,
147 property: undefined
148 };
149 properties.some(function(prop, index) {
150 if (prop.key == key) {
151 propMatch = {
152 property: prop,
153 index: index
154 };
155 return true;
156 }
157 });
158 return propMatch;
159}
160
161ConfigFile.prototype.rename = function(newName) {
162 newName = path.resolve(newName);
163 if (this.fileName == newName)
164 return;
165 this.originalName = this.originalName || this.timestamp != -1 && this.fileName;
166 this.fileName = newName;
167 try {
168 this.timestamp = fs.statSync(this.fileName).mtime.getTime();
169 }
170 catch(e) {
171 if (e.code != 'ENOENT')
172 throw e;
173 this.timestamp = -1;
174 }
175 this.changed = true;
176};
177
178// only applies to values
179// Returns undefined for no value
180// throws if an object, with a fileName reference
181// member lookups not in objects throw
182// type is optional, and can be 'array', 'number', 'boolean', 'string' to add simple type checking
183ConfigFile.prototype.getValue = function(memberArray, type) {
184 var parentProps = this.getProperties(memberArray.slice(0, memberArray.length - 1));
185
186 if (!parentProps)
187 return;
188
189 var prop = getProperty(parentProps, memberArray[memberArray.length - 1]).property;
190
191 if (prop === undefined)
192 return;
193
194 if (prop.value instanceof Array)
195 configError.call(this, memberArray, 'must be a value');
196
197 var value = prop.value.value;
198
199 if (type == 'array' && !(value instanceof Array) || (type && type != 'array' && typeof value != type))
200 configError.call(this, memberArray, 'must be a' + (type == 'array' ? 'n ' : ' ') + type + ' value');
201
202 return value;
203};
204
205function orderingIndex(ordering, key) {
206 for (var i = 0; i < ordering.length; i++)
207 if (ordering[i] === key || ordering[i] instanceof Array && ordering[i][0] == key)
208 return i;
209 return -1;
210}
211
212function getOrdering(memberArray, ordering) {
213 memberArray.some(function(member) {
214 var orderIndex = orderingIndex(ordering, member);
215 if (orderIndex != -1 && ordering[orderIndex] instanceof Array) {
216 ordering = ordering[orderIndex][1];
217 }
218 else {
219 ordering = [];
220 return true;
221 }
222 });
223 return ordering;
224}
225
226// returns properties array
227// If not a properties array, returns undefined
228// If any member is a value instead of an object, returns undefined
229// When createIfUndefined is set, object is created with the correct ordering
230// setting changed: true in the process if necessary
231// If any member is a value with createIfUndefined, throws an error
232ConfigFile.prototype.getProperties = function(memberArray, createIfUndefined) {
233 var properties = this.properties;
234 var ordering = this.ordering;
235 var self = this;
236 memberArray.some(function(member, index) {
237 var prop = getProperty(properties, member).property;
238 if (prop) {
239 properties = prop.value;
240 if (!(properties instanceof Array)) {
241 if (createIfUndefined) {
242 configError.call(self, memberArray.slice(0, index + 1), 'should be an object');
243 }
244 else {
245 properties = undefined;
246 return true;
247 }
248 }
249 }
250 else {
251 if (createIfUndefined) {
252 setProperty(properties, member, properties = [], ordering);
253 self.onChange(memberArray);
254 }
255 else {
256 properties = undefined;
257 return true;
258 }
259 }
260 ordering = getOrdering([member], ordering);
261 });
262 return properties;
263};
264
265// returns properties array as a readable JS object of values.
266// Nested objects throw nice error unless nested is set to true
267// if the object does not exist, returns undefined
268// if the property corresponds to a value, throws
269ConfigFile.prototype.getObject = function(memberArray, nested, createIfUndefined) {
270 var properties = this.getProperties(memberArray, createIfUndefined);
271
272 if (!properties)
273 return;
274
275 var obj = propertiesToObject(properties);
276
277 var self = this;
278 if (!nested)
279 Object.keys(obj).forEach(function(key) {
280 if (typeof obj[key] == 'object' && obj[key] !== null && !(obj[key] instanceof Array))
281 configError.call(self, memberArray, 'should not contain a nested object at %' + key + '%');
282 });
283
284 return obj;
285};
286function propertiesToObject(properties) {
287 var obj = {};
288 properties.forEach(function(p) {
289 var prop = p.key;
290 var val = p.value;
291
292 if (val instanceof Array)
293 obj[prop] = propertiesToObject(val);
294 else
295 obj[prop] = val.value;
296 });
297 return obj;
298}
299
300function objectToProperties(obj) {
301 var properties = [];
302 Object.keys(obj).forEach(function(key) {
303 var value = obj[key];
304 if (typeof value == 'object' && !(value instanceof Array) && value !== null)
305 value = objectToProperties(value);
306 else
307 value = { value: value };
308 properties.push({
309 key: key,
310 value: value
311 });
312 });
313 return properties;
314}
315
316ConfigFile.prototype.has = function(memberArray) {
317 var parentProps = this.getProperties(memberArray.slice(0, memberArray.length - 1));
318
319 if (!parentProps)
320 return false;
321
322 return getProperty(parentProps, memberArray[memberArray.length - 1]).property !== undefined;
323};
324
325// removes the given property member name if it exists
326ConfigFile.prototype.remove = function(memberArray, clearParentsIfMadeEmpty) {
327 var parentProps = this.getProperties(memberArray.slice(0, memberArray.length - 1));
328
329 if (!parentProps)
330 return false;
331
332 var self = this;
333 var removed = parentProps.some(function(prop, index) {
334 if (prop.key == memberArray[memberArray.length - 1]) {
335 parentProps.splice(index, 1);
336 self.onChange(memberArray.slice(0, memberArray.length - 1));
337 return true;
338 }
339 });
340
341 if (clearParentsIfMadeEmpty && removed && parentProps.length == 0 && memberArray.length > 1)
342 this.remove(memberArray.slice(0, memberArray.length - 1), true);
343
344 return removed;
345};
346
347ConfigFile.prototype.clearIfEmpty = function(memberArray) {
348 var props = this.getProperties(memberArray);
349 if (props && !props.length)
350 this.remove(memberArray);
351};
352
353// sets this.changed if a change
354// retains property ordering
355// overwrites anything already existing
356// creates objects if not existing, at correct ordered location
357ConfigFile.prototype.setValue = function(memberArray, value) {
358 var properties = this.getProperties(memberArray.slice(0, memberArray.length - 1), true);
359
360 var ordering = getOrdering(memberArray.slice(0, memberArray.length - 1), this.ordering);
361
362 if (setProperty(properties, memberArray[memberArray.length - 1], { value: value }, ordering))
363 this.onChange(memberArray);
364};
365
366// handles nested objects, memberArray can be 0 length for base-level population
367// where target object already exists, it overwrites retaining the same ordering
368// default behaviour is to not write empty objects, but to also not clear objects made empty
369// also avoids unnecessary changes
370ConfigFile.prototype.setProperties = function(memberArray, properties, clearIfEmpty, keepOrder, extend) {
371 var targetProperties;
372
373 if (!properties.length) {
374 targetProperties = this.getProperties(memberArray);
375 if (targetProperties && targetProperties.length) {
376 if (clearIfEmpty)
377 this.remove(memberArray);
378 else
379 targetProperties.splice(0, targetProperties.length);
380 this.onChange(memberArray);
381 }
382 else if (!clearIfEmpty)
383 this.getProperties(memberArray, true);
384 return;
385 }
386
387 targetProperties = this.getProperties(memberArray, true);
388
389 var ordering;
390 if (!keepOrder)
391 ordering = getOrdering(memberArray.slice(0, memberArray.length - 1), this.ordering);
392
393 var self = this;
394
395 var setKeys = [];
396 properties.forEach(function(prop) {
397 setKeys.push(prop.key);
398 if (setProperty(targetProperties, prop.key, prop.value, ordering))
399 self.onChange(memberArray);
400 });
401
402 if (extend !== true)
403 for (var i = 0; i < targetProperties.length; i++) {
404 var prop = targetProperties[i];
405 if (setKeys.indexOf(prop.key) == -1) {
406 targetProperties.splice(i--, 1);
407 self.onChange(memberArray);
408 }
409 }
410};
411
412// ensures the given property is first in its containing object property
413// skips if the property does not exist
414ConfigFile.prototype.orderFirst = function(memberArray) {
415 var properties = this.getProperties(memberArray.slice(0, memberArray.length - 1));
416
417 if (!properties)
418 return;
419
420 var propIndex = getProperty(properties, memberArray[memberArray.length - 1]).index;
421
422 if (propIndex != -1 && propIndex != 0) {
423 properties.unshift(properties.splice(propIndex, 1)[0]);
424 this.onChange(memberArray.slice(0, memberArray.length - 1));
425 }
426};
427
428// ensures the given property is last in its containing object property
429// skips if the property does not exist
430ConfigFile.prototype.orderLast = function(memberArray) {
431 var properties = this.getProperties(memberArray.slice(0, memberArray.length - 1));
432
433 if (!properties)
434 return;
435
436 var propIndex = getProperty(properties, memberArray[memberArray.length - 1]).index;
437
438 if (propIndex != -1 && propIndex != properties.length - 1) {
439 properties.push(properties.splice(propIndex, 1)[0]);
440 this.onChange(memberArray.slice(0, memberArray.length - 1));
441 }
442};
443
444// only clears on empty if not already existing as empty
445// sets this.changed, retains ordering and overwrites as with setValue
446// keepOrder indicates if new properties should be added in current iteration order
447// instead of applying the ordering algorithm
448ConfigFile.prototype.setObject = function(memberArray, obj, clearIfEmpty, keepOrder) {
449 // convert object into a properties array
450 return this.setProperties(memberArray, objectToProperties(obj), clearIfEmpty, keepOrder, false);
451};
452ConfigFile.prototype.extendObject = function(memberArray, obj, keepOrder) {
453 return this.setProperties(memberArray, objectToProperties(obj), false, keepOrder, true);
454};
455
456// default serialization is as a JSON file, but these can be overridden
457ConfigFile.prototype.serialize = function(obj) {
458 var jsonString = JSON.stringify(obj, null, this.style.tab);
459
460 if (this.style.trailingNewline)
461 jsonString += this.style.newline;
462
463 return jsonString
464 .replace(/([^\\])""/g, '$1' + this.style.quote + this.style.quote) // empty strings
465 .replace(/([^\\])"/g, '$1' + this.style.quote)
466 .replace(/\n/g, this.style.newline);
467};
468ConfigFile.prototype.deserialize = function(source) {
469 return JSON.parse(source);
470};
471
472// note that the given proprety is changed
473// also triggers change events
474ConfigFile.prototype.onChange = function(memberArray) {
475 // run any attached change events
476 if (!this.changeEvents.reduce(function(stopPropagation, evt) {
477 return stopPropagation || evt(memberArray);
478 }, false))
479 this.changed = true;
480};
481
482// read and write are sync functions
483ConfigFile.prototype.read = function() {
484 var contents;
485 try {
486 this.timestamp = fs.statSync(this.fileName).mtime.getTime();
487 contents = fs.readFileSync(this.fileName).toString();
488 }
489 catch(e) {
490 if (e.code != 'ENOENT')
491 throw e;
492
493 this.timestamp = -1;
494 contents = '';
495 }
496
497 this.style = detectStyle(contents);
498
499 var deserializedObj;
500
501 try {
502 deserializedObj = this.deserialize(contents || '{}') || {};
503 }
504 catch(e) {
505 configError.call(this, e.toString());
506 }
507
508 this.setObject([], deserializedObj, false, true);
509 this.changed = false;
510};
511ConfigFile.prototype.write = function() {
512 var timestamp;
513 try {
514 timestamp = fs.statSync(this.fileName).mtime.getTime();
515 }
516 catch(e) {
517 if (e.code != 'ENOENT')
518 throw e;
519
520 timestamp = -1;
521 }
522
523 if (timestamp !== this.timestamp)
524 throw new Error('Configuration file ' + path.relative(process.cwd(), this.fileName) + ' has been modified by another process.');
525
526 if (this.changed || timestamp == -1) {
527 // if the file doesn't exist make sure the folder exists
528 mkdirp.sync(path.dirname(this.fileName));
529 var obj = this.getObject([], true);
530 fs.writeFileSync(this.fileName, this.serialize(obj));
531 this.timestamp = fs.statSync(this.fileName).mtime.getTime();
532
533 this.changed = false;
534
535 // if the file was renamed, remove the old file now after writing
536 if (this.originalName) {
537 fs.unlinkSync(this.originalName);
538 this.originalName = null;
539 }
540 }
541};
542
543function detectStyle(string) {
544 var style = {
545 tab: ' ',
546 newline: require('os').EOL,
547 trailingNewline: true,
548 quote: '"'
549 };
550
551 var newLineMatch = string.match( /\r?\n|\r(?!\n)/);
552 if (newLineMatch)
553 style.newline = newLineMatch[0];
554
555 // best-effort tab detection
556 // yes this is overkill, but it avoids possibly annoying edge cases
557 var tabSpaces = string.split(style.newline).map(function(line) { return line.match(/^[ \t]*/)[0]; }) || [];
558 var tabDifferenceFreqs = {};
559 var lastLength = 0;
560 tabSpaces.forEach(function(tabSpace) {
561 var diff = Math.abs(tabSpace.length - lastLength);
562 if (diff != 0)
563 tabDifferenceFreqs[diff] = (tabDifferenceFreqs[diff] || 0) + 1;
564 lastLength = tabSpace.length;
565 });
566 var bestTabLength;
567 Object.keys(tabDifferenceFreqs).forEach(function(tabLength) {
568 if (!bestTabLength || tabDifferenceFreqs[tabLength] >= tabDifferenceFreqs[bestTabLength])
569 bestTabLength = tabLength;
570 });
571 // having determined the most common spacing difference length,
572 // generate samples of this tab length from the end of each line space
573 // the most common sample is then the tab string
574 var tabSamples = {};
575 tabSpaces.forEach(function(tabSpace) {
576 var sample = tabSpace.substr(tabSpace.length - bestTabLength);
577 tabSamples[sample] = (tabSamples[sample] || 0) + 1;
578 });
579 var bestTabSample;
580 Object.keys(tabSamples).forEach(function(sample) {
581 if (!bestTabSample || tabSamples[sample] > tabSamples[bestTabSample])
582 bestTabSample = sample;
583 });
584
585 if (bestTabSample)
586 style.tab = bestTabSample;
587
588 var quoteMatch = string.match(/"|'/);
589 if (quoteMatch)
590 style.quote = quoteMatch[0];
591
592 if (string && !string.match(new RegExp(style.newline + '$')))
593 style.trailingNewline = false;
594
595 return style;
596}
\No newline at end of file