diff --git a/README.md b/README.md index c6eee00..6c14b7a 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,10 @@ Attempts to turn the given INI string into a nested data object. ```js // You can also use `decode` -const object = parse(``) +const object = parse(``) + +// Preserve the comments when parsing +const objectWithComments = parse(``, { preserveComments: true }) ``` ### Stringify @@ -152,8 +155,13 @@ stringify(object,{ * Some parsers treat duplicate names by themselves as arrays */ - bracketedArray : true + bracketedArray : true, + + /** + * Whether to save comments during stringify. Default value is false. + */ + preserveComments, }) ``` diff --git a/lib/ini.js b/lib/ini.js index 0e8623e..9b3dbce 100644 --- a/lib/ini.js +++ b/lib/ini.js @@ -1,4 +1,5 @@ const { hasOwnProperty } = Object.prototype +const COMMENTS_ID = Symbol.for('comments_id') const encode = (obj, opt = {}) => { if (typeof opt === 'string') { @@ -12,6 +13,8 @@ const encode = (obj, opt = {}) => { /* istanbul ignore next */ opt.platform = opt.platform || (typeof process !== 'undefined' && process.platform) opt.bracketedArray = opt.bracketedArray !== false + const preserveComments = opt.preserveComments || false + const commentsDictionary = hasOwnProperty.call(obj, COMMENTS_ID) ? obj[COMMENTS_ID] : {} /* istanbul ignore next */ const eol = opt.platform === 'win32' ? '\r\n' : '\n' @@ -46,24 +49,40 @@ const encode = (obj, opt = {}) => { for (const k of keys) { const val = obj[k] if (val && Array.isArray(val)) { + if (preserveComments && hasOwnProperty.call(commentsDictionary, k)) { + out += commentsDictionary[k] + } for (const item of val) { out += safe(`${k}${arraySuffix}`).padEnd(padToChars, ' ') + separator + safe(item) + eol } } else if (val && typeof val === 'object') { children.push(k) } else { + if (preserveComments && hasOwnProperty.call(commentsDictionary, k)) { + out += commentsDictionary[k] + } out += safe(k).padEnd(padToChars, ' ') + separator + safe(val) + eol } } if (opt.section && out.length) { - out = '[' + safe(opt.section) + ']' + (opt.newline ? eol + eol : eol) + out + let sectionComments = '' + if (preserveComments && hasOwnProperty.call(commentsDictionary, opt.section)) { + sectionComments = commentsDictionary[opt.section] + } + out = sectionComments + '[' + safe(opt.section) + ']' + (opt.newline ? eol + eol : eol) + out } for (const k of children) { + if (/comments/.test(k)) { + continue + } const nk = splitSections(k, '.').join('\\.') const section = (opt.section ? opt.section + '.' : '') + nk - const child = encode(obj[k], { + const childObject = hasOwnProperty.call(obj, COMMENTS_ID) ? + Object.assign(obj[k], { [COMMENTS_ID]: obj[COMMENTS_ID] }) : + obj[k] + const child = encode(childObject, { ...opt, section, }) @@ -104,16 +123,26 @@ function splitSections (str, separator) { } const decode = (str, opt = {}) => { + // The `typeof` check is required because accessing the `process` directly fails on browsers. + /* istanbul ignore next */ + opt.platform = opt.platform || (typeof process !== 'undefined' && process.platform) + /* istanbul ignore next */ + const eol = opt.platform === 'win32' ? '\r\n' : '\n' opt.bracketedArray = opt.bracketedArray !== false const out = Object.create(null) let p = out let section = null + const commentsDictionary = {} + let lineCommentArray = [] // section |key = value const re = /^\[([^\]]*)\]\s*$|^([^=]+)(=(.*))?$/i const lines = str.split(/[\r\n]+/g) const duplicates = {} for (const line of lines) { + if (line && line.match(/^[#;]/)) { + lineCommentArray.push(line) + } if (!line || line.match(/^\s*[;#]/) || line.match(/^\s*$/)) { continue } @@ -130,6 +159,10 @@ const decode = (str, opt = {}) => { continue } p = out[section] = out[section] || Object.create(null) + if (lineCommentArray.length > 0) { + commentsDictionary[section] = lineCommentArray.join(eol) + eol + lineCommentArray = [] + } continue } const keyRaw = unsafe(match[2]) @@ -163,8 +196,16 @@ const decode = (str, opt = {}) => { // array by accidentally forgetting the brackets if (Array.isArray(p[key])) { p[key].push(value) + if (lineCommentArray.length > 0) { + commentsDictionary[key] = lineCommentArray.join(eol) + eol + lineCommentArray = [] + } } else { p[key] = value + if (lineCommentArray.length > 0) { + commentsDictionary[key] = lineCommentArray.join(eol) + eol + lineCommentArray = [] + } } } @@ -204,6 +245,8 @@ const decode = (str, opt = {}) => { delete out[del] } + out[COMMENTS_ID] = commentsDictionary + return out } diff --git a/tap-snapshots/test/foo.js.test.cjs b/tap-snapshots/test/foo.js.test.cjs index 2646323..3bd648d 100644 --- a/tap-snapshots/test/foo.js.test.cjs +++ b/tap-snapshots/test/foo.js.test.cjs @@ -302,3 +302,77 @@ label=debug value=10 ` + +exports[`test/foo.js TAP stringify with comments > must match snapshot 1`] = ` +o=p +a with spaces=b c +; wrap in quotes to JSON-decode and preserve spaces +" xa n p "="\\"\\r\\nyoyoyo\\r\\r\\n" +; wrap in quotes to get a key with a bracket, not a section. +"[disturbing]"=hey you never know +; Test single quotes +s=something +; Test mixing quotes +s1="something' +; Test double quotes +s2=something else +; Test blank value +s3= +; Test value with only spaces +s4= +; Test quoted value with only spaces +s5=" " +; Test quoted value with leading and trailing spaces +s6=" a " +; Test no equal sign +s7=true +; Test bool(true) +true=true +; Test bool(false) +false=false +; Test null +null=null +; Test undefined +undefined=undefined +; Test arrays +zr[]=deedee +; This should be included in the array +ar[]=one +ar[]=three +ar[]=this is included +; Test resetting of a value (and not turn it into an array) +br=warm +eq="eq=eq" + +; a section +[a] +av=a val +e={ o: p, a: { av: a val, b: { c: { e: "this [value]" } } } } +j="\\"{ o: \\"p\\", a: { av: \\"a val\\", b: { c: { e: \\"this [value]\\" } } } }\\"" +"[]"=a square? +; Nested array +cr[]=four +cr[]=eight + +; nested child without middle parent +; should create otherwise-empty a.b +[a.b.c] +e=1 +j=2 + +; dots in the section name should be literally interpreted +[x\\.y\\.z] +x.y.z=xyz + +[x\\.y\\.z.a\\.b\\.c] +; nested child without middle parent +; should create otherwise-empty a.b +a.b.c=abc +; this next one is not a comment! it's escaped! +nocomment=this\\; this is not a comment +# Support the use of the number sign (#) as an alternative to the semicolon for indicating comments. +# http://en.wikipedia.org/wiki/INI_file#Comments +# this next one is not a comment! it's escaped! +noHashComment=this\\# this is not a comment + +` diff --git a/test/foo.js b/test/foo.js index fe92435..ef0d235 100644 --- a/test/foo.js +++ b/test/foo.js @@ -94,3 +94,9 @@ test('encode within browser context', function (t) { t.matchSnapshot(e) t.end() }) +test('stringify with comments', function (t) { + const d = i.parse(data) + const s = i.stringify(d, { preserveComments: true }) + t.matchSnapshot(s) + t.end() +})