From 4bdd0eb3d504adb0494218dd72db38be1b5259df Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 19:47:50 +0100 Subject: [PATCH 1/9] :bug: fix handling of degenerate dot cases in key splitting logic --- lib/src/extensions/decode.dart | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index b8ffc34..c9ceb1e 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -342,15 +342,16 @@ extension _$Decode on QS { required int maxDepth, required bool strictDepth, }) { - // Optionally normalize `a.b` to `a[b]` before splitting. - final String key = - allowDots ? _dotToBracketTopLevel(originalKey) : originalKey; - // Depth==0 → do not split at all (reference `qs` behavior). + // Important: return the *original* key with no dot→bracket normalization. if (maxDepth <= 0) { - return [key]; + return [originalKey]; } + // Optionally normalize `a.b` to `a[b]` before splitting (only when depth > 0). + final String key = + allowDots ? _dotToBracketTopLevel(originalKey) : originalKey; + final List segments = []; // Parent token before the first '[' (may be empty when key starts with '[') @@ -425,9 +426,10 @@ extension _$Decode on QS { /// - Only dots at depth == 0 split. /// - Dots inside `[...]` are preserved. /// - Degenerate cases are preserved and do not create empty segments: - /// * leading '.' (e.g., ".a") keeps the dot literal, - /// * double dots ("a..b") keep the first dot literal, - /// * trailing dot ("a.") keeps the trailing dot (which is ignored by the splitter). + /// * ".[" (e.g., "a.[b]") skips the dot so "a.[b]" behaves like "a[b]". + /// * leading '.' (e.g., ".a") starts a new segment → "[a]" (leading dot is ignored). + /// * double dots ("a..b") keep the first dot literal. + /// * trailing dot ("a.") keeps the trailing dot (ignored by the splitter). /// - Only literal `.` are considered for splitting here. In this library, keys are normally /// percent‑decoded before this step; thus a top‑level `%2E` typically becomes a literal `.` /// and will split when `allowDots` is true. @@ -451,11 +453,7 @@ extension _$Decode on QS { final bool hasNext = i + 1 < s.length; final String next = hasNext ? s[i + 1] : '\u0000'; - // preserve a *leading* '.' as a literal, unless it's the ".[" degenerate. - if (i == 0 && (!hasNext || next != '[')) { - sb.write('.'); - i++; - } else if (hasNext && next == '[') { + if (hasNext && next == '[') { // Degenerate ".[" → skip the dot so "a.[b]" behaves like "a[b]". i++; // consume the '.' } else if (!hasNext || next == '.') { @@ -463,7 +461,7 @@ extension _$Decode on QS { sb.write('.'); i++; } else { - // Normal split: convert a.b → a[b] at top level. + // Normal split: convert top-level ".a" or "a.b" into a bracket segment. final int start = ++i; int j = start; while (j < s.length && s[j] != '.' && s[j] != '[') { From 3fc1be8c37b431cb104b84dc97f25d861e125a71 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 19:48:03 +0100 Subject: [PATCH 2/9] :white_check_mark: add comprehensive tests for encoded dot behavior in key decoding --- test/unit/decode_test.dart | 171 ++++++++++++++++++++++++++++++++++++- 1 file changed, 170 insertions(+), 1 deletion(-) diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index 9663719..b23730c 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -2488,7 +2488,7 @@ void main() { expect( QS.decode('.a=x', opt), equals({ - '.a': 'x', + 'a': 'x', }), ); }); @@ -2563,4 +2563,173 @@ void main() { ); }); }); + + group('encoded dot behavior in keys (%2E / %2e)', () { + test( + "allowDots=false, decodeDotInKeys=false: encoded dots decode to literal '.'; no dot-splitting", + () { + const opt = DecodeOptions(allowDots: false, decodeDotInKeys: false); + expect(QS.decode('a%2Eb=c', opt), equals({'a.b': 'c'})); + expect(QS.decode('a%2eb=c', opt), equals({'a.b': 'c'})); + }); + + test( + 'allowDots=true, decodeDotInKeys=false: double-encoded dots are preserved inside segments; encoded and plain dots split', + () { + // Plain dot splits + expect( + QS.decode('a.b=c', + const DecodeOptions(allowDots: true, decodeDotInKeys: false)), + equals({ + 'a': {'b': 'c'} + }), + ); + + // Encoded dot stays encoded inside the first segment (no extra split) + expect( + QS.decode('name%252Eobj.first=John', + const DecodeOptions(allowDots: true, decodeDotInKeys: false)), + equals({ + 'name%2Eobj': {'first': 'John'} + }), + ); + + // Lowercase variant inside first segment + expect( + QS.decode('a%2eb.c=d', + const DecodeOptions(allowDots: true, decodeDotInKeys: false)), + equals({ + 'a': { + 'b': {'c': 'd'} + } + }), + ); + }); + + test( + "allowDots=true, decodeDotInKeys=true: encoded dots become literal '.' inside a segment (no extra split)", + () { + expect( + QS.decode('name%252Eobj.first=John', + const DecodeOptions(allowDots: true, decodeDotInKeys: true)), + equals({ + 'name.obj': {'first': 'John'} + }), + ); + + // Double-encoded single segment becomes a literal dot after post-split mapping + expect( + QS.decode('a%252Eb=c', + const DecodeOptions(allowDots: true, decodeDotInKeys: true)), + equals({'a.b': 'c'}), + ); + + // Lowercase mapping as well (inside brackets) + expect( + QS.decode('a[%2e]=x', + const DecodeOptions(allowDots: true, decodeDotInKeys: true)), + equals({ + 'a': {'.': 'x'} + }), + ); + }); + + test( + 'bracket segment: %2E mapped based on decodeDotInKeys; case-insensitive', + () { + // When disabled, percent-decoding inside brackets yields '.' (no extra split) + expect( + QS.decode('a[%2E]=x', + const DecodeOptions(allowDots: false, decodeDotInKeys: false)), + equals({ + 'a': {'.': 'x'} + }), + ); + expect( + QS.decode('a[%2e]=x', + const DecodeOptions(allowDots: true, decodeDotInKeys: false)), + equals({ + 'a': {'.': 'x'} + }), + ); + + // When enabled, convert to '.' regardless of case + expect( + QS.decode('a[%2E]=x', + const DecodeOptions(allowDots: true, decodeDotInKeys: true)), + equals({ + 'a': {'.': 'x'} + }), + ); + + // Invalid combo: allowDots=false with decodeDotInKeys=true should throw + expect( + () => QS.decode( + 'a[%2e]=x', DecodeOptions(allowDots: false, decodeDotInKeys: true)), + throwsA(anyOf( + isA(), + isA(), + isA(), + )), + ); + }); + + test("bare-key (no '='): behavior matches key decoding path", () { + // allowDots=false → %2E decodes to '.'; no splitting because allowDots=false + expect( + QS.decode( + 'a%2Eb', + const DecodeOptions( + allowDots: false, + decodeDotInKeys: false, + strictNullHandling: true, + ), + ), + equals({'a.b': null}), + ); + + // allowDots=true & decodeDotInKeys=false → keep %2E inside key segment (split into a nested map) + expect( + QS.decode('a%2Eb', + const DecodeOptions(allowDots: true, decodeDotInKeys: false)), + equals({ + 'a': {'b': ''} + }), + ); + }); + + test('depth=0 with allowDots=true: do not split key', () { + expect( + QS.decode('a.b=c', const DecodeOptions(allowDots: true, depth: 0)), + equals({'a.b': 'c'}), + ); + }); + + test( + 'top-level dot→bracket conversion guardrails: leading/trailing/double dots', + () { + // Leading dot: ".a" should yield { "a": ... } when allowDots=true + expect( + QS.decode('.a=x', + const DecodeOptions(allowDots: true, decodeDotInKeys: false)), + equals({'a': 'x'}), + ); + + // Trailing dot: "a." should NOT create an empty bracket segment; remains literal + expect( + QS.decode('a.=x', + const DecodeOptions(allowDots: true, decodeDotInKeys: false)), + equals({'a.': 'x'}), + ); + + // Double dots: only the second dot causes a split; the empty middle segment is preserved as a literal dot + expect( + QS.decode('a..b=x', + const DecodeOptions(allowDots: true, decodeDotInKeys: false)), + equals({ + 'a.': {'b': 'x'} + }), + ); + }); + }); } From f2b3d02183bc2435999fd8b7423273193a078fae Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 21:41:59 +0100 Subject: [PATCH 3/9] :bug: fix bracketed key detection in dot decoding logic --- lib/src/extensions/decode.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index c9ceb1e..9bdd513 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -273,9 +273,9 @@ extension _$Decode on QS { // dots inside bracket segments can be treated as literal `.` without introducing extra // dot‑splits. Top‑level dot splitting (which only applies to literal `.`) already // happened in `_splitKeyIntoSegments`. - final String cleanRoot = root.startsWith('[') && root.endsWith(']') - ? root.slice(1, root.length - 1) - : root; + final bool wasBracketed = root.startsWith('[') && root.endsWith(']'); + final String cleanRoot = + wasBracketed ? root.slice(1, root.length - 1) : root; final String decodedRoot = options.decodeDotInKeys ? cleanRoot.replaceAll('%2E', '.').replaceAll('%2e', '.') : cleanRoot; @@ -284,7 +284,7 @@ extension _$Decode on QS { obj = {'0': leaf}; } else if (index != null && index >= 0 && - root != decodedRoot && + wasBracketed && index.toString() == decodedRoot && options.parseLists && index <= options.listLimit) { From 682c1bdd79491bfd9dd34c66dcb6522a229a57cc Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 21:43:34 +0100 Subject: [PATCH 4/9] :white_check_mark: add tests for leading and encoded dot handling in key decoding --- test/unit/decode_test.dart | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index b23730c..7618c25 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -2483,7 +2483,7 @@ void main() { })); }); - test('leading dot preserved when allowDots=true', () { + test('leading dot splits to a new segment when allowDots=true', () { const opt = DecodeOptions(allowDots: true); expect( QS.decode('.a=x', opt), @@ -2565,6 +2565,18 @@ void main() { }); group('encoded dot behavior in keys (%2E / %2e)', () { + test('leading dot before bracket: skip the dot (.[a]=x)', () { + const opt = DecodeOptions(allowDots: true, decodeDotInKeys: true); + expect(QS.decode('.[a]=x', opt), equals({'a': 'x'})); + }); + + test('depth=0 with encoded dot: do not split key', () { + expect( + QS.decode('a%2Eb=c', const DecodeOptions(allowDots: true, depth: 0)), + equals({'a.b': 'c'}), + ); + }); + test( "allowDots=false, decodeDotInKeys=false: encoded dots decode to literal '.'; no dot-splitting", () { From fab63422b32b9b78b393ccb94aa24cf1cd112843 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 22:51:56 +0100 Subject: [PATCH 5/9] :bug: fix handling of degenerate dot segments in key parsing logic --- lib/src/extensions/decode.dart | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index 9bdd513..71f7bbb 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -279,7 +279,9 @@ extension _$Decode on QS { final String decodedRoot = options.decodeDotInKeys ? cleanRoot.replaceAll('%2E', '.').replaceAll('%2e', '.') : cleanRoot; - final int? index = int.tryParse(decodedRoot); + final int? index = (wasBracketed && options.parseLists) + ? int.tryParse(decodedRoot) + : null; if (!options.parseLists && decodedRoot == '') { obj = {'0': leaf}; } else if (index != null && @@ -297,6 +299,7 @@ extension _$Decode on QS { ); obj[index] = leaf; } else { + // Normalise numeric-looking keys back to their canonical string form when not a list index obj[index?.toString() ?? decodedRoot] = leaf; } } @@ -464,6 +467,19 @@ extension _$Decode on QS { // Normal split: convert top-level ".a" or "a.b" into a bracket segment. final int start = ++i; int j = start; + // Accept [A-Za-z0-9_] at the start of a segment; otherwise, keep '.' literal. + bool isIdentStart(int cu) => switch (cu) { + >= 0x41 && <= 0x5A => true, // A-Z + >= 0x61 && <= 0x7A => true, // a-z + >= 0x30 && <= 0x39 => true, // 0-9 + 0x5F => true, // _ + _ => false, + }; + if (start >= s.length || !isIdentStart(s.codeUnitAt(start))) { + // keep as literal if next char isn't an ident start + sb.write('.'); + continue; + } while (j < s.length && s[j] != '.' && s[j] != '[') { j++; } From 23d404b6933a8676db333e71703e11fb0b78b552 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 23:02:49 +0100 Subject: [PATCH 6/9] :bug: fix normalization of synthetic bracket segments for unterminated groups in key decoding --- lib/src/extensions/decode.dart | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index 71f7bbb..a4af612 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -276,9 +276,26 @@ extension _$Decode on QS { final bool wasBracketed = root.startsWith('[') && root.endsWith(']'); final String cleanRoot = wasBracketed ? root.slice(1, root.length - 1) : root; - final String decodedRoot = options.decodeDotInKeys + String decodedRoot = options.decodeDotInKeys ? cleanRoot.replaceAll('%2E', '.').replaceAll('%2e', '.') : cleanRoot; + + // Synthetic remainder normalization: + // If this segment originated from an unterminated bracket group, it will look like + // "[[...]]" after wrapping. After stripping the outermost brackets above, `decodedRoot` + // can end with a trailing ']' that does not have a matching opening bracket in the + // same string (e.g., "[b[c]"). In that case, drop the trailing ']' so the literal key + // becomes "[b[c" (matches Kotlin/Python ports). + if (wasBracketed && + root.startsWith('[[') && + decodedRoot.endsWith(']')) { + final int opens = RegExp(r'\[').allMatches(decodedRoot).length; + final int closes = RegExp(r'\]').allMatches(decodedRoot).length; + if (opens > closes) { + decodedRoot = decodedRoot.substring(0, decodedRoot.length - 1); + } + } + final int? index = (wasBracketed && options.parseLists) ? int.tryParse(decodedRoot) : null; @@ -388,8 +405,11 @@ extension _$Decode on QS { } if (close < 0) { - // Unterminated group: treat the entire key as a single literal segment (qs semantics). - return [key]; + // Unterminated group: keep the already-captured parent (if any), + // and wrap the raw remainder starting at `open` as a single synthetic + // bracket segment. Do not throw even if `strictDepth=true`. + segments.add('[${key.substring(open)}]'); + return segments; } segments From bf3550030e0f451f0989cf42d2068eacddd9498f Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 23:02:55 +0100 Subject: [PATCH 7/9] :white_check_mark: add tests for key splitting behavior with depth remainder and strictDepth options --- test/unit/decode_test.dart | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index 7618c25..63b6aca 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -2744,4 +2744,52 @@ void main() { ); }); }); + + group('key splitting: depth remainder & strictDepth (dot + bracket parity)', + () { + test( + 'allowDots=true, depth=1: split once, stash remainder as literal bracket string', + () { + expect( + QS.decode('a.b.c=d', const DecodeOptions(allowDots: true, depth: 1)), + equals({ + 'a': { + 'b': {'[c]': 'd'} + } + }), + ); + }); + + test( + 'allowDots=true, depth=1: two-segment remainder becomes "[c][d]" literal', + () { + expect( + QS.decode('a.b.c.d=e', const DecodeOptions(allowDots: true, depth: 1)), + equals({ + 'a': { + 'b': {'[c][d]': 'e'} + } + }), + ); + }); + + test('strictDepth=true + allowDots=true: well-formed overflow throws', () { + expect( + () => QS.decode('a.b.c=d', + const DecodeOptions(allowDots: true, depth: 1, strictDepth: true)), + throwsA(isA()), + ); + }); + + test( + 'unterminated bracket group: do not throw even with strictDepth=true; wrap raw remainder', + () { + expect( + QS.decode('a[b[c]=x', const DecodeOptions(depth: 5, strictDepth: true)), + equals({ + 'a': {'[b[c': 'x'} + }), + ); + }); + }); } From bec29ec13b0e7cf970ddc0de81ecde30c30986f7 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 23:18:04 +0100 Subject: [PATCH 8/9] :bug: fix list limit error messaging and improve bracket counting logic in key decoding --- lib/src/extensions/decode.dart | 36 +++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index a4af612..d82ecf4 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -51,10 +51,11 @@ extension _$Decode on QS { final List splitVal = val.split(','); if (options.throwOnLimitExceeded && (currentListLength + splitVal.length) > options.listLimit) { - throw RangeError( - 'List limit exceeded. ' - 'Only ${options.listLimit} element${options.listLimit == 1 ? '' : 's'} allowed in a list.', - ); + final String msg = options.listLimit < 0 + ? 'List parsing is disabled (listLimit < 0).' + : 'List limit exceeded. Only ${options.listLimit} ' + 'element${options.listLimit == 1 ? '' : 's'} allowed in a list.'; + throw RangeError(msg); } final int remaining = options.listLimit - currentListLength; if (remaining <= 0) return const []; @@ -66,10 +67,11 @@ extension _$Decode on QS { // Guard incremental growth of an existing list as we parse additional items. if (options.throwOnLimitExceeded && currentListLength >= options.listLimit) { - throw RangeError( - 'List limit exceeded. ' - 'Only ${options.listLimit} element${options.listLimit == 1 ? '' : 's'} allowed in a list.', - ); + final String msg = options.listLimit < 0 + ? 'List parsing is disabled (listLimit < 0).' + : 'List limit exceeded. Only ${options.listLimit} ' + 'element${options.listLimit == 1 ? '' : 's'} allowed in a list.'; + throw RangeError(msg); } return val; @@ -289,8 +291,12 @@ extension _$Decode on QS { if (wasBracketed && root.startsWith('[[') && decodedRoot.endsWith(']')) { - final int opens = RegExp(r'\[').allMatches(decodedRoot).length; - final int closes = RegExp(r'\]').allMatches(decodedRoot).length; + int opens = 0, closes = 0; + for (int k = 0; k < decodedRoot.length; k++) { + final cu = decodedRoot.codeUnitAt(k); + if (cu == 0x5B) opens++; + if (cu == 0x5D) closes++; + } if (opens > closes) { decodedRoot = decodedRoot.substring(0, decodedRoot.length - 1); } @@ -489,10 +495,12 @@ extension _$Decode on QS { int j = start; // Accept [A-Za-z0-9_] at the start of a segment; otherwise, keep '.' literal. bool isIdentStart(int cu) => switch (cu) { - >= 0x41 && <= 0x5A => true, // A-Z - >= 0x61 && <= 0x7A => true, // a-z - >= 0x30 && <= 0x39 => true, // 0-9 - 0x5F => true, // _ + (>= 0x41 && <= 0x5A) || // A-Z + (>= 0x61 && <= 0x7A) || // a-z + (>= 0x30 && <= 0x39) || // 0-9 + 0x5F || // _ + 0x2D => // - + true, _ => false, }; if (start >= s.length || !isIdentStart(s.codeUnitAt(start))) { From ad1b168a6c91e762d30d5a98120fe41c4b603810 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 23:33:19 +0100 Subject: [PATCH 9/9] :bug: fix list parsing logic by removing unnecessary bracketed check for index assignment --- lib/src/extensions/decode.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index d82ecf4..c420c7b 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -309,7 +309,6 @@ extension _$Decode on QS { obj = {'0': leaf}; } else if (index != null && index >= 0 && - wasBracketed && index.toString() == decodedRoot && options.parseLists && index <= options.listLimit) {