diff --git a/README.md b/README.md index 5560883..ed5cecd 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,13 @@ Also available with: `require('@iarna/toml/parse-string')` Synchronously parse a TOML string and return an object. -## TOML.stringify(obj) → String [(example)](https://github.com/iarna/iarna-toml/blob/latest/examples/stringify.js) +## TOML.stringify(obj[, opts]) → String [(example)](https://github.com/iarna/iarna-toml/blob/latest/examples/stringify.js) Also available with: `require('@iarna/toml/stringify')` +`opts.skipThousandsSeparator` controls if inserting thousands separators should +be skipped when stringifying numbers. Defaults to false. + Serialize an object as TOML. ## [your-object].toJSON @@ -86,10 +89,13 @@ because `JSON` represents dates as strings and TOML can represent them natively. [`moment`](https://www.npmjs.com/package/moment) objects are treated the same as native `Date` objects, in this respect. -## TOML.stringify.value(obj) -> String +## TOML.stringify.value(obj[, opts]) -> String Also available with: `require('@iarna/toml/stringify').value` +`opts.skipThousandsSeparator` controls if inserting thousands separators should +be skipped when stringifying numbers. Defaults to false. + Serialize a value as TOML would. This is a fragment and not a complete valid TOML document. diff --git a/index.d.ts b/index.d.ts index e782460..7e619df 100644 --- a/index.d.ts +++ b/index.d.ts @@ -33,6 +33,13 @@ interface FuncParse { stream (): Transform } +interface StringifyOptions { + /** + * Skip inserting thousands separators when stringifying numbers. Defaults to false. + */ + skipThousandsSeparator?: boolean +} + interface FuncStringify { /** * Serialize an object as TOML. @@ -46,12 +53,12 @@ interface FuncStringify { * * `moment` objects are treated the same as native `Date` objects, in this respect. */ - (obj: JsonMap): string + (obj: JsonMap, options?: StringifyOptions): string /** * Serialize a value as TOML would. This is a fragment and not a complete valid TOML document. */ - value (any: AnyJson): string + value (any: AnyJson, options?: StringifyOptions): string } export const parse: FuncParse diff --git a/stringify.js b/stringify.js index 7642e10..618388f 100644 --- a/stringify.js +++ b/stringify.js @@ -2,269 +2,290 @@ module.exports = stringify module.exports.value = stringifyInline -function stringify (obj) { - if (obj === null) throw typeError('null') - if (obj === void (0)) throw typeError('undefined') - if (typeof obj !== 'object') throw typeError(typeof obj) - - if (typeof obj.toJSON === 'function') obj = obj.toJSON() - if (obj == null) return null - const type = tomlType(obj) - if (type !== 'table') throw typeError(type) - return stringifyObject('', '', obj) +function stringify (value, opts) { + const stringifier = new Stringifier(opts) + return stringifier.stringify(value) } -function typeError (type) { - return new Error('Can only stringify objects, not ' + type) +function stringifyInline (value, opts) { + const stringifier = new Stringifier(opts) + return stringifier.stringifyInline(value) } -function getInlineKeys (obj) { - return Object.keys(obj).filter(key => isInline(obj[key])) -} -function getComplexKeys (obj) { - return Object.keys(obj).filter(key => !isInline(obj[key])) -} +class Stringifier { + constructor (opts) { + if (!opts) opts = {} + this.opts = opts + } -function toJSON (obj) { - let nobj = Array.isArray(obj) ? [] : Object.prototype.hasOwnProperty.call(obj, '__proto__') ? {['__proto__']: undefined} : {} - for (let prop of Object.keys(obj)) { - if (obj[prop] && typeof obj[prop].toJSON === 'function' && !('toISOString' in obj[prop])) { - nobj[prop] = obj[prop].toJSON() - } else { - nobj[prop] = obj[prop] - } + stringify (obj) { + if (obj === null) throw this.typeError('null') + if (obj === void (0)) throw this.typeError('undefined') + if (typeof obj !== 'object') throw this.typeError(typeof obj) + + if (typeof obj.toJSON === 'function') obj = obj.toJSON() + if (obj == null) return null + const type = this.tomlType(obj) + if (type !== 'table') throw this.typeError(type) + return this.stringifyObject('', '', obj) } - return nobj -} -function stringifyObject (prefix, indent, obj) { - obj = toJSON(obj) - let inlineKeys - let complexKeys - inlineKeys = getInlineKeys(obj) - complexKeys = getComplexKeys(obj) - const result = [] - const inlineIndent = indent || '' - inlineKeys.forEach(key => { - var type = tomlType(obj[key]) - if (type !== 'undefined' && type !== 'null') { - result.push(inlineIndent + stringifyKey(key) + ' = ' + stringifyAnyInline(obj[key], true)) + typeError (type) { + return new Error('Can only stringify objects, not ' + type) + } + + getInlineKeys (obj) { + return Object.keys(obj).filter(key => this.isInline(obj[key])) + } + getComplexKeys (obj) { + return Object.keys(obj).filter(key => !this.isInline(obj[key])) + } + + _toJSON (obj) { + let nobj = Array.isArray(obj) ? [] : Object.prototype.hasOwnProperty.call(obj, '__proto__') ? {['__proto__']: undefined} : {} + for (let prop of Object.keys(obj)) { + if (obj[prop] && typeof obj[prop].toJSON === 'function' && !('toISOString' in obj[prop])) { + nobj[prop] = obj[prop].toJSON() + } else { + nobj[prop] = obj[prop] + } } - }) - if (result.length > 0) result.push('') - const complexIndent = prefix && inlineKeys.length > 0 ? indent + ' ' : '' - complexKeys.forEach(key => { - result.push(stringifyComplex(prefix, complexIndent, key, obj[key])) - }) - return result.join('\n') -} + return nobj + } -function isInline (value) { - switch (tomlType(value)) { - case 'undefined': - case 'null': - case 'integer': - case 'nan': - case 'float': - case 'boolean': - case 'string': - case 'datetime': - return true - case 'array': - return value.length === 0 || tomlType(value[0]) !== 'table' - case 'table': - return Object.keys(value).length === 0 - /* istanbul ignore next */ - default: - return false + stringifyObject (prefix, indent, obj) { + obj = this._toJSON(obj) + let inlineKeys + let complexKeys + inlineKeys = this.getInlineKeys(obj) + complexKeys = this.getComplexKeys(obj) + const result = [] + const inlineIndent = indent || '' + inlineKeys.forEach(key => { + var type = this.tomlType(obj[key]) + if (type !== 'undefined' && type !== 'null') { + result.push(inlineIndent + this.stringifyKey(key) + ' = ' + this.stringifyAnyInline(obj[key], true)) + } + }) + if (result.length > 0) result.push('') + const complexIndent = prefix && inlineKeys.length > 0 ? indent + ' ' : '' + complexKeys.forEach(key => { + result.push(this.stringifyComplex(prefix, complexIndent, key, obj[key])) + }) + return result.join('\n') } -} -function tomlType (value) { - if (value === undefined) { - return 'undefined' - } else if (value === null) { - return 'null' - /* eslint-disable valid-typeof */ - } else if (typeof value === 'bigint' || (Number.isInteger(value) && !Object.is(value, -0))) { - return 'integer' - } else if (typeof value === 'number') { - return 'float' - } else if (typeof value === 'boolean') { - return 'boolean' - } else if (typeof value === 'string') { - return 'string' - } else if ('toISOString' in value) { - return isNaN(value) ? 'undefined' : 'datetime' - } else if (Array.isArray(value)) { - return 'array' - } else { - return 'table' + isInline (value) { + switch (this.tomlType(value)) { + case 'undefined': + case 'null': + case 'integer': + case 'nan': + case 'float': + case 'boolean': + case 'string': + case 'datetime': + return true + case 'array': + return value.length === 0 || this.tomlType(value[0]) !== 'table' + case 'table': + return Object.keys(value).length === 0 + /* istanbul ignore next */ + default: + return false + } } -} -function stringifyKey (key) { - const keyStr = String(key) - if (/^[-A-Za-z0-9_]+$/.test(keyStr)) { - return keyStr - } else { - return stringifyBasicString(keyStr) + tomlType (value) { + if (value === undefined) { + return 'undefined' + } else if (value === null) { + return 'null' + /* eslint-disable valid-typeof */ + } else if (typeof value === 'bigint' || (Number.isInteger(value) && !Object.is(value, -0))) { + return 'integer' + } else if (typeof value === 'number') { + return 'float' + } else if (typeof value === 'boolean') { + return 'boolean' + } else if (typeof value === 'string') { + return 'string' + } else if ('toISOString' in value) { + return isNaN(value) ? 'undefined' : 'datetime' + } else if (Array.isArray(value)) { + return 'array' + } else { + return 'table' + } } -} -function stringifyBasicString (str) { - return '"' + escapeString(str).replace(/"/g, '\\"') + '"' -} + stringifyKey (key) { + const keyStr = String(key) + if (/^[-A-Za-z0-9_]+$/.test(keyStr)) { + return keyStr + } else { + return this.stringifyBasicString(keyStr) + } + } -function stringifyLiteralString (str) { - return "'" + str + "'" -} + stringifyBasicString (str) { + return '"' + this.escapeString(str).replace(/"/g, '\\"') + '"' + } -function numpad (num, str) { - while (str.length < num) str = '0' + str - return str -} + stringifyLiteralString (str) { + return "'" + str + "'" + } -function escapeString (str) { - return str.replace(/\\/g, '\\\\') - .replace(/[\b]/g, '\\b') - .replace(/\t/g, '\\t') - .replace(/\n/g, '\\n') - .replace(/\f/g, '\\f') - .replace(/\r/g, '\\r') - /* eslint-disable no-control-regex */ - .replace(/([\u0000-\u001f\u007f])/, c => '\\u' + numpad(4, c.codePointAt(0).toString(16))) - /* eslint-enable no-control-regex */ -} + numpad (num, str) { + while (str.length < num) str = '0' + str + return str + } -function stringifyMultilineString (str) { - let escaped = str.split(/\n/).map(str => { - return escapeString(str).replace(/"(?="")/g, '\\"') - }).join('\n') - if (escaped.slice(-1) === '"') escaped += '\\\n' - return '"""\n' + escaped + '"""' -} + escapeString (str) { + return str.replace(/\\/g, '\\\\') + .replace(/[\b]/g, '\\b') + .replace(/\t/g, '\\t') + .replace(/\n/g, '\\n') + .replace(/\f/g, '\\f') + .replace(/\r/g, '\\r') + /* eslint-disable no-control-regex */ + .replace(/([\u0000-\u001f\u007f])/, c => '\\u' + this.numpad(4, c.codePointAt(0).toString(16))) + /* eslint-enable no-control-regex */ + } + + stringifyMultilineString (str) { + let escaped = str.split(/\n/).map(str => { + return this.escapeString(str).replace(/"(?="")/g, '\\"') + }).join('\n') + if (escaped.slice(-1) === '"') escaped += '\\\n' + return '"""\n' + escaped + '"""' + } -function stringifyAnyInline (value, multilineOk) { - let type = tomlType(value) - if (type === 'string') { - if (multilineOk && /\n/.test(value)) { - type = 'string-multiline' - } else if (!/[\b\t\n\f\r']/.test(value) && /"/.test(value)) { - type = 'string-literal' + stringifyAnyInline (value, multilineOk) { + let type = this.tomlType(value) + if (type === 'string') { + if (multilineOk && /\n/.test(value)) { + type = 'string-multiline' + } else if (!/[\b\t\n\f\r']/.test(value) && /"/.test(value)) { + type = 'string-literal' + } } + return this.stringifyInline(value, type) } - return stringifyInline(value, type) -} -function stringifyInline (value, type) { - /* istanbul ignore if */ - if (!type) type = tomlType(value) - switch (type) { - case 'string-multiline': - return stringifyMultilineString(value) - case 'string': - return stringifyBasicString(value) - case 'string-literal': - return stringifyLiteralString(value) - case 'integer': - return stringifyInteger(value) - case 'float': - return stringifyFloat(value) - case 'boolean': - return stringifyBoolean(value) - case 'datetime': - return stringifyDatetime(value) - case 'array': - return stringifyInlineArray(value.filter(_ => tomlType(_) !== 'null' && tomlType(_) !== 'undefined' && tomlType(_) !== 'nan')) - case 'table': - return stringifyInlineTable(value) - /* istanbul ignore next */ - default: - throw typeError(type) + stringifyInline (value, type) { + /* istanbul ignore if */ + if (!type) type = this.tomlType(value) + switch (type) { + case 'string-multiline': + return this.stringifyMultilineString(value) + case 'string': + return this.stringifyBasicString(value) + case 'string-literal': + return this.stringifyLiteralString(value) + case 'integer': + return this.stringifyInteger(value) + case 'float': + return this.stringifyFloat(value) + case 'boolean': + return this.stringifyBoolean(value) + case 'datetime': + return this.stringifyDatetime(value) + case 'array': + return this.stringifyInlineArray(value.filter(_ => this.tomlType(_) !== 'null' && this.tomlType(_) !== 'undefined' && this.tomlType(_) !== 'nan')) + case 'table': + return this.stringifyInlineTable(value) + /* istanbul ignore next */ + default: + throw this.typeError(type) + } } -} -function stringifyInteger (value) { - /* eslint-disable security/detect-unsafe-regex */ - return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, '_') -} + stringifyInteger (value) { + if (this.opts.skipThousandsSeparator) { + return String(value) + } -function stringifyFloat (value) { - if (value === Infinity) { - return 'inf' - } else if (value === -Infinity) { - return '-inf' - } else if (Object.is(value, NaN)) { - return 'nan' - } else if (Object.is(value, -0)) { - return '-0.0' + /* eslint-disable security/detect-unsafe-regex */ + return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, '_') } - const [int, dec] = String(value).split('.') - return stringifyInteger(int) + '.' + dec -} -function stringifyBoolean (value) { - return String(value) -} + stringifyFloat (value) { + if (value === Infinity) { + return 'inf' + } else if (value === -Infinity) { + return '-inf' + } else if (Object.is(value, NaN)) { + return 'nan' + } else if (Object.is(value, -0)) { + return '-0.0' + } + const [int, dec] = String(value).split('.') + return this.stringifyInteger(int) + '.' + dec + } -function stringifyDatetime (value) { - return value.toISOString() -} + stringifyBoolean (value) { + return String(value) + } -function stringifyInlineArray (values) { - values = toJSON(values) - let result = '[' - const stringified = values.map(_ => stringifyInline(_)) - if (stringified.join(', ').length > 60 || /\n/.test(stringified)) { - result += '\n ' + stringified.join(',\n ') + '\n' - } else { - result += ' ' + stringified.join(', ') + (stringified.length > 0 ? ' ' : '') + stringifyDatetime (value) { + return value.toISOString() } - return result + ']' -} -function stringifyInlineTable (value) { - value = toJSON(value) - const result = [] - Object.keys(value).forEach(key => { - result.push(stringifyKey(key) + ' = ' + stringifyAnyInline(value[key], false)) - }) - return '{ ' + result.join(', ') + (result.length > 0 ? ' ' : '') + '}' -} + stringifyInlineArray (values) { + values = this._toJSON(values) + let result = '[' + const stringified = values.map(_ => this.stringifyInline(_)) + if (stringified.join(', ').length > 60 || /\n/.test(stringified)) { + result += '\n ' + stringified.join(',\n ') + '\n' + } else { + result += ' ' + stringified.join(', ') + (stringified.length > 0 ? ' ' : '') + } + return result + ']' + } -function stringifyComplex (prefix, indent, key, value) { - const valueType = tomlType(value) - /* istanbul ignore else */ - if (valueType === 'array') { - return stringifyArrayOfTables(prefix, indent, key, value) - } else if (valueType === 'table') { - return stringifyComplexTable(prefix, indent, key, value) - } else { - throw typeError(valueType) + stringifyInlineTable (value) { + value = this._toJSON(value) + const result = [] + Object.keys(value).forEach(key => { + result.push(this.stringifyKey(key) + ' = ' + this.stringifyAnyInline(value[key], false)) + }) + return '{ ' + result.join(', ') + (result.length > 0 ? ' ' : '') + '}' } -} -function stringifyArrayOfTables (prefix, indent, key, values) { - values = toJSON(values) - const firstValueType = tomlType(values[0]) - /* istanbul ignore if */ - if (firstValueType !== 'table') throw typeError(firstValueType) - const fullKey = prefix + stringifyKey(key) - let result = '' - values.forEach(table => { - if (result.length > 0) result += '\n' - result += indent + '[[' + fullKey + ']]\n' - result += stringifyObject(fullKey + '.', indent, table) - }) - return result -} + stringifyComplex (prefix, indent, key, value) { + const valueType = this.tomlType(value) + /* istanbul ignore else */ + if (valueType === 'array') { + return this.stringifyArrayOfTables(prefix, indent, key, value) + } else if (valueType === 'table') { + return this.stringifyComplexTable(prefix, indent, key, value) + } else { + throw this.typeError(valueType) + } + } -function stringifyComplexTable (prefix, indent, key, value) { - const fullKey = prefix + stringifyKey(key) - let result = '' - if (getInlineKeys(value).length > 0) { - result += indent + '[' + fullKey + ']\n' + stringifyArrayOfTables (prefix, indent, key, values) { + values = this._toJSON(values) + const firstValueType = this.tomlType(values[0]) + /* istanbul ignore if */ + if (firstValueType !== 'table') throw this.typeError(firstValueType) + const fullKey = prefix + this.stringifyKey(key) + let result = '' + values.forEach(table => { + if (result.length > 0) result += '\n' + result += indent + '[[' + fullKey + ']]\n' + result += this.stringifyObject(fullKey + '.', indent, table) + }) + return result + } + + stringifyComplexTable (prefix, indent, key, value) { + const fullKey = prefix + this.stringifyKey(key) + let result = '' + if (this.getInlineKeys(value).length > 0) { + result += indent + '[' + fullKey + ']\n' + } + return result + this.stringifyObject(fullKey + '.', indent, value) } - return result + stringifyObject(fullKey + '.', indent, value) } diff --git a/test/stringify.js b/test/stringify.js index 72ff72c..94443dd 100644 --- a/test/stringify.js +++ b/test/stringify.js @@ -26,7 +26,9 @@ const good = { 'infinity': {obj: {a: Infinity}, toml: `a = inf\n`}, '-infinity': {obj: {a: -Infinity}, toml: `a = -inf\n`}, '-0': {obj: {a: -0}, toml: 'a = -0.0\n'}, - 'multiline': {obj: {a: [ 'abc', 'ghi', 'abc', 'ghi', 'abc', 'ghi', 'abc', 'ghi', 'abc' ]}, toml: 'a = [\n "abc",\n "ghi",\n "abc",\n "ghi",\n "abc",\n "ghi",\n "abc",\n "ghi",\n "abc"\n]\n'} + 'multiline': {obj: {a: [ 'abc', 'ghi', 'abc', 'ghi', 'abc', 'ghi', 'abc', 'ghi', 'abc' ]}, toml: 'a = [\n "abc",\n "ghi",\n "abc",\n "ghi",\n "abc",\n "ghi",\n "abc",\n "ghi",\n "abc"\n]\n'}, + 'number separator': {obj: {a: 10000}, toml: 'a = 10_000\n'}, + 'no number separator': {obj: {a: 10000}, options: {skipThousandsSeparator: true}, toml: 'a = 10000\n'} } const bad = { 'stringify null': null, @@ -50,7 +52,7 @@ test('stringify', t => { }) Object.keys(good).forEach(msg => { try { - const result = TOML.stringify(good[msg].obj) + const result = TOML.stringify(good[msg].obj, good[msg].options) t.is(result, good[msg].toml, msg) } catch (err) { t.comment(err.message) @@ -69,3 +71,24 @@ test('stringify', t => { }) t.done() }) + +// This test is basically just to test that the stringifier is being properly invoked. +test('stringify.value', t => { + try { + const result = TOML.stringify.value(42) + t.is(result, '42', '42') + } catch (err) { + t.comment(err.message) + t.fail('42') + } + + try { + const result = TOML.stringify.value({ a: 'hello', b: 42 }) + t.is(result, '{ a = "hello", b = 42 }', 'obj') + } catch (err) { + t.comment(err.message) + t.fail('obj') + } + + t.done() +})