From 78fa48b26929152263c185a67b9e9bca8bdc601e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 16:49:59 +0000 Subject: [PATCH] fix: preserve single-quoted values on decode/encode round-trip Fixes #242. Parse still unquotes for JS consumers, but stringify now restores single quotes that were present in the original text when it is safe to do so. Unquoted and double-quoted safe values stay unquoted. Co-authored-by: David --- lib/ini.js | 33 +++++++++++- tap-snapshots/test/foo.js.test.cjs | 24 ++++----- test/single-quotes.js | 82 ++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 test/single-quotes.js diff --git a/lib/ini.js b/lib/ini.js index beb390d..1070481 100644 --- a/lib/ini.js +++ b/lib/ini.js @@ -1,5 +1,31 @@ const { hasOwnProperty } = Object.prototype +// Remember which keys were written with single quotes so stringify can +// restore them. Parse still unquotes the JS value (see PR #16). +const singleQuotedKeys = new WeakMap() + +const markSingleQuoted = (obj, key) => { + let keys = singleQuotedKeys.get(obj) + if (!keys) { + keys = new Set() + singleQuotedKeys.set(obj, keys) + } + keys.add(key) +} + +const emitValue = (obj, key, val) => { + const keys = singleQuotedKeys.get(obj) + if (!keys || !keys.has(key)) { + return safe(val) + } + const inner = typeof val === 'string' ? val : String(val) + // A raw `'` inside the value would break single-quote wrapping. + if (inner.includes("'")) { + return safe(val) + } + return "'" + inner + "'" +} + const encode = (obj, opt = {}) => { if (typeof opt === 'string') { opt = { section: opt } @@ -52,7 +78,7 @@ const encode = (obj, opt = {}) => { } else if (val && typeof val === 'object') { children.push(k) } else { - out += safe(k).padEnd(padToChars, ' ') + separator + safe(val) + eol + out += safe(k).padEnd(padToChars, ' ') + separator + emitValue(obj, k, val) + eol } } @@ -146,6 +172,8 @@ const decode = (str, opt = {}) => { if (key === '__proto__') { continue } + const trimmedValue = match[3] ? (match[4] || '').trim() : '' + const wasSingleQuoted = isQuoted(trimmedValue) && trimmedValue.startsWith("'") const valueRaw = match[3] ? unsafe(match[4]) : true const value = valueRaw === 'true' || valueRaw === 'false' || @@ -167,6 +195,9 @@ const decode = (str, opt = {}) => { p[key].push(value) } else { p[key] = value + if (wasSingleQuoted) { + markSingleQuoted(p, key) + } } } diff --git a/tap-snapshots/test/foo.js.test.cjs b/tap-snapshots/test/foo.js.test.cjs index 2646323..e1a16f1 100644 --- a/tap-snapshots/test/foo.js.test.cjs +++ b/tap-snapshots/test/foo.js.test.cjs @@ -70,13 +70,13 @@ o=p a with spaces=b c " xa n p "="\\"\\r\\nyoyoyo\\r\\r\\n" "[disturbing]"=hey you never know -s=something +s='something' s1="something' s2=something else s3= s4= -s5=" " -s6=" a " +s5=' ' +s6=' a ' s7=true true=true false=false @@ -116,13 +116,13 @@ o = p a with spaces = b c " xa n p " = "\\"\\r\\nyoyoyo\\r\\r\\n" "[disturbing]" = hey you never know -s = something +s = 'something' s1 = "something' s2 = something else s3 = s4 = -s5 = " " -s6 = " a " +s5 = ' ' +s6 = ' a ' s7 = true true = true false = false @@ -169,13 +169,13 @@ eq = "eq=eq" false = false null = null o = p -s = something +s = 'something' s1 = "something' s2 = something else s3 = s4 = -s5 = " " -s6 = " a " +s5 = ' ' +s6 = ' a ' s7 = true true = true undefined = undefined @@ -249,13 +249,13 @@ eq="eq=eq" false=false null=null o=p -s=something +s='something' s1="something' s2=something else s3= s4= -s5=" " -s6=" a " +s5=' ' +s6=' a ' s7=true true=true undefined=undefined diff --git a/test/single-quotes.js b/test/single-quotes.js new file mode 100644 index 0000000..15cca2e --- /dev/null +++ b/test/single-quotes.js @@ -0,0 +1,82 @@ +const ini = require('../') +const t = require('tap') + +const issue242 = `ActiveIndex = '0' +MaximizedFlag = '1911' +ProductVersion = '14.0.25793.400' +Version = '1' +WindowCount = '1'` + +t.test('decode then encode preserves single-quoted values (#242)', t => { + const parsed = ini.parse(issue242) + + // Parse still unquotes for JS consumers (PR #16) + t.equal(parsed.ActiveIndex, 0) + t.equal(parsed.MaximizedFlag, 1911) + t.equal(parsed.ProductVersion, '14.0.25793.400') + t.equal(parsed.Version, 1) + t.equal(parsed.WindowCount, 1) + + const encoded = ini.stringify(parsed) + t.equal(encoded, [ + "ActiveIndex='0'", + "MaximizedFlag='1911'", + "ProductVersion='14.0.25793.400'", + "Version='1'", + "WindowCount='1'", + '', + ].join('\n')) + t.end() +}) + +t.test('unquoted safe values stay unquoted', t => { + t.equal(ini.stringify(ini.parse('ActiveIndex = 0')), 'ActiveIndex=0\n') + t.equal(ini.stringify(ini.parse('name = something')), 'name=something\n') + t.equal(ini.stringify({ ActiveIndex: 0 }), 'ActiveIndex=0\n') + t.equal(ini.stringify({ ActiveIndex: '0' }), 'ActiveIndex=0\n') + t.end() +}) + +t.test('double-quoted safe values are still unquoted on encode', t => { + t.equal(ini.stringify(ini.parse('name = "something"')), 'name=something\n') + t.equal(ini.stringify(ini.parse('s2 = "something else"')), 's2=something else\n') + t.end() +}) + +t.test('double quotes still used for special characters', t => { + t.equal(ini.stringify({ eq: 'eq=eq' }), 'eq="eq=eq"\n') + t.equal(ini.stringify(ini.parse('eq = "eq=eq"')), 'eq="eq=eq"\n') + t.end() +}) + +t.test('whitespace option keeps restored single quotes', t => { + const encoded = ini.stringify(ini.parse("ActiveIndex = '0'"), { whitespace: true }) + t.equal(encoded, "ActiveIndex = '0'\n") + t.end() +}) + +t.test('mixed quoted and unquoted keys in one object', t => { + const parsed = ini.parse("quoted = '1'\nplain = 2\n") + t.equal(ini.stringify(parsed), "quoted='1'\nplain=2\n") + t.end() +}) + +t.test('single-quoted value containing a quote falls back to safe()', t => { + const parsed = ini.parse("odd = 'a'b'\n") + t.equal(parsed.odd, "a'b") + t.equal(ini.stringify(parsed), "odd=a'b\n") + t.end() +}) + +t.test('single quotes are preserved inside sections', t => { + const parsed = ini.parse("[window]\nActiveIndex = '0'\nTitle = unquoted\n") + t.equal(parsed.window.ActiveIndex, 0) + t.equal(parsed.window.Title, 'unquoted') + t.equal(ini.stringify(parsed), "[window]\nActiveIndex='0'\nTitle=unquoted\n") + t.end() +}) + +t.test('empty single-quoted value round-trips', t => { + t.equal(ini.stringify(ini.parse("x = ''")), "x=''\n") + t.end() +})