From ad183a1aace9a3791348415b9e2aec0f2999a746 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 12:03:19 +0100 Subject: [PATCH 01/25] :safety_vest: enforce decodeDotInKeys and allowDots option consistency; clarify dot decoding in documentation --- lib/src/models/decode_options.dart | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index cae687d..4f27278 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -74,6 +74,14 @@ final class DecodeOptions with EquatableMixin { assert( charset == utf8 || charset == latin1, 'Invalid charset', + ), + assert( + !(decodeDotInKeys ?? false) || allowDots != false, + 'decodeDotInKeys requires allowDots to be true', + ), + assert( + parameterLimit > 0, + 'Parameter limit must be positive', ); /// When `true`, decode dot notation in keys: `a.b=c` → `{a: {b: "c"}}`. @@ -114,9 +122,14 @@ final class DecodeOptions with EquatableMixin { /// Decode dots that appear in *keys* (e.g., `a.b=c`). /// - /// This explicitly opts into dot‑notation handling and implies [allowDots]. - /// Setting [decodeDotInKeys] to `true` while forcing [allowDots] to `false` - /// is invalid and will cause an error in [QS.decode]. + /// This explicitly opts into dot‑notation handling and **implies** [allowDots]. + /// Passing `decodeDotInKeys: true` while forcing `allowDots: false` is an + /// invalid combination and will throw *at construction time*. + /// + /// Note: inside bracket segments (e.g., `a[%2E]`), percent‑decoding naturally + /// yields `"."`. Whether a `.` causes additional splitting is a parser concern + /// governed by [allowDots] at the *top level*; this flag does not suppress the + /// literal dot produced by percent‑decoding inside brackets. final bool decodeDotInKeys; /// Delimiter used to split key/value pairs. May be a [String] (e.g., `"&"`) @@ -163,7 +176,9 @@ final class DecodeOptions with EquatableMixin { /// Decode a single scalar using either the custom decoder or the default /// implementation in [Utils.decode]. The [kind] indicates whether the token - /// is a key (or key segment) or a value. + /// is a key (or key segment) or a value; the **default implementation ignores + /// `kind` and decodes keys identically to values**. Whether `.` participates + /// in key splitting is decided later by the parser (based on options). dynamic decoder( String? value, { Encoding? charset, From 063cc5af2154703e165996ce9b1569204f16d21d Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 12:03:26 +0100 Subject: [PATCH 02/25] :bug: fix dot notation encoding in key splitter; handle top-level dots and bracket depth correctly --- lib/src/extensions/decode.dart | 114 +++++++++++++++++++++++++-------- 1 file changed, 88 insertions(+), 26 deletions(-) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index 869fe5e..2fe20de 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -24,9 +24,64 @@ part of '../qs.dart'; /// - `exceeded`: indicates whether a configured limit was exceeded during split. typedef SplitResult = ({List parts, bool exceeded}); -/// Normalizes simple dot notation to bracket notation (e.g. `a.b` → `a[b]`). -/// Only matches \nondotted, non-bracketed tokens so `a.b.c` becomes `a[b][c]`. -final RegExp _dotToBracket = RegExp(r'\.([^.\[]+)'); +/// Convert top‑level dots to bracket segments (depth‑aware). +/// - 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). +/// - Percent‑encoded dots are not handled here (keys are already percent‑decoded). +String _dotToBracketTopLevel(String s) { + if (s.isEmpty || !s.contains('.')) return s; + final StringBuffer sb = StringBuffer(); + int depth = 0; + int i = 0; + while (i < s.length) { + final ch = s[i]; + if (ch == '[') { + depth++; + sb.write(ch); + i++; + } else if (ch == ']') { + if (depth > 0) depth--; + sb.write(ch); + i++; + } else if (ch == '.') { + if (depth == 0) { + final bool hasNext = i + 1 < s.length; + final String next = hasNext ? s[i + 1] : '\u0000'; + if (hasNext && next == '[') { + // Degenerate ".[" → skip the dot so "a.[b]" behaves like "a[b]". + i++; // consume the '.' + } else if (!hasNext || next == '.') { + // Preserve literal dot for trailing/duplicate dots. + sb.write('.'); + i++; + } else { + // Normal split: convert a.b → a[b] at top level. + final int start = ++i; + int j = start; + while (j < s.length && s[j] != '.' && s[j] != '[') { + j++; + } + sb.write('['); + sb.write(s.substring(start, j)); + sb.write(']'); + i = j; + } + } else { + // Inside brackets, keep '.' as content. + sb.write('.'); + i++; + } + } else { + sb.write(ch); + i++; + } + } + return sb.toString(); +} /// Internal decoding surface grouped under the `QS` extension. /// @@ -324,9 +379,8 @@ extension _$Decode on QS { required bool strictDepth, }) { // Optionally normalize `a.b` to `a[b]` before splitting. - final String key = allowDots - ? originalKey.replaceAllMapped(_dotToBracket, (m) => '[${m[1]}]') - : originalKey; + final String key = + allowDots ? _dotToBracketTopLevel(originalKey) : originalKey; // Depth==0 → do not split at all (reference `qs` behavior). if (maxDepth <= 0) { @@ -335,59 +389,67 @@ extension _$Decode on QS { final List segments = []; - // Extract the parent token (before the first '['), if any. + // Parent token before the first '[' (may be empty when key starts with '[') final int first = key.indexOf('['); final String parent = first >= 0 ? key.substring(0, first) : key; if (parent.isNotEmpty) segments.add(parent); final int n = key.length; int open = first; - int depth = 0; + int collected = 0; + int lastClose = -1; - while (open >= 0 && depth < maxDepth) { - // Balance nested brackets inside this group: "[ ... possibly [] ... ]" + while (open >= 0 && collected < maxDepth) { int level = 1; int i = open + 1; int close = -1; + // Balance nested '[' and ']' within this group. while (i < n) { - final int ch = key.codeUnitAt(i); - if (ch == 0x5B) { - // '[' + final int cu = key.codeUnitAt(i); + if (cu == 0x5B) { level++; - } else if (ch == 0x5D) { - // ']' + } else if (cu == 0x5D) { level--; if (level == 0) { close = i; break; } } - // Advance inside the current bracket group until it balances. i++; } if (close < 0) { - // Unterminated group, stop collecting groups - break; + // Unterminated group: treat the entire key as a single literal segment (qs semantics). + return [key]; } - segments.add(key.substring(open, close + 1)); // includes enclosing [ ] - depth++; + segments + .add(key.substring(open, close + 1)); // balanced group, includes [ ] + lastClose = close; + collected++; - // find next group, starting after this one + // Find the next '[' after this balanced group. open = key.indexOf('[', close + 1); } - // If additional groups remain beyond the allowed depth, either throw or - // stash the remainder as a single segment, per `strictDepth`. - if (open >= 0) { - // We still have remainder starting with '[' + // If there's trailing text after the last balanced group, treat it as one final segment. + // Ignore a lone trailing '.' (degenerate top‑level dot). + if (lastClose >= 0 && lastClose + 1 < n) { + final String remainder = key.substring(lastClose + 1); + if (remainder != '.') { + if (strictDepth && open >= 0) { + throw RangeError( + 'Input depth exceeded $maxDepth and strictDepth is true'); + } + segments.add('[$remainder]'); + } + } else if (open >= 0) { + // More groups remain beyond depth; either throw or stash remainder as a single segment. if (strictDepth) { throw RangeError( 'Input depth exceeded $maxDepth and strictDepth is true'); } - // Stash the remainder as a single segment (qs behavior) segments.add('[${key.substring(open)}]'); } From c93546b2e376e1b2260265cd3612f2cec383570d Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 12:03:38 +0100 Subject: [PATCH 03/25] :white_check_mark: add tests for allowDots and decodeDotInKeys consistency in DecodeOptions --- test/unit/models/decode_options_test.dart | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/unit/models/decode_options_test.dart b/test/unit/models/decode_options_test.dart index 33d8213..317869a 100644 --- a/test/unit/models/decode_options_test.dart +++ b/test/unit/models/decode_options_test.dart @@ -138,4 +138,29 @@ void main() { ); }); }); + + group('DecodeOptions – allowDots / decodeDotInKeys interplay', () { + test('constructor: allowDots=false + decodeDotInKeys=true throws', () { + expect( + () => DecodeOptions(allowDots: false, decodeDotInKeys: true), + throwsA(anyOf( + isA(), + isA(), + isA(), + )), + ); + }); + + test('copyWith: making options inconsistent throws', () { + final base = const DecodeOptions(decodeDotInKeys: true); + expect( + () => base.copyWith(allowDots: false), + throwsA(anyOf( + isA(), + isA(), + isA(), + )), + ); + }); + }); } From 9fab5d6b294c24bd8519faabf04148bdc8b6d3f8 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 12:03:43 +0100 Subject: [PATCH 04/25] :white_check_mark: add comprehensive tests for encoded dot behavior in keys to ensure C# parity and option consistency --- test/unit/decode_test.dart | 368 ++++++++++++++++++++++++++++++++++++- 1 file changed, 366 insertions(+), 2 deletions(-) diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index fb62f02..24f058f 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -16,9 +16,13 @@ void main() { expect( () => QS.decode( 'a=b&c=d', - const DecodeOptions(parameterLimit: 0), + DecodeOptions(parameterLimit: 0), ), - throwsArgumentError, + throwsA(anyOf( + isA(), + isA(), + isA(), + )), ); }); @@ -2193,6 +2197,366 @@ void main() { expect(res, {'a': 'b'}); }); }); + + group('C# parity: encoded dot behavior in keys (%2E / %2e)', () { + test( + 'top-level: allowDots=true, decodeDotInKeys=true → plain dot splits; encoded dot also splits (upper/lower)', + () { + const opt = DecodeOptions(allowDots: true, decodeDotInKeys: true); + expect( + QS.decode('a.b=c', opt), + equals({ + 'a': {'b': 'c'} + })); + expect( + QS.decode('a%2Eb=c', opt), + equals({ + 'a': {'b': 'c'} + })); + expect( + QS.decode('a%2eb=c', opt), + equals({ + 'a': {'b': 'c'} + })); + }, + ); + + test( + 'top-level: allowDots=true, decodeDotInKeys=false → encoded dot also splits (upper/lower)', + () { + const opt = DecodeOptions(allowDots: true, 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=false, decodeDotInKeys=true is invalid', () { + expect( + () => QS.decode( + 'a%2Eb=c', DecodeOptions(allowDots: false, decodeDotInKeys: true)), + throwsA(anyOf( + isA(), + isA(), + isA(), + )), + ); + }); + + test( + 'bracket segment: maps to \'.\' when decodeDotInKeys=true (case-insensitive)', + () { + const opt = DecodeOptions(allowDots: true, decodeDotInKeys: true); + expect( + QS.decode('a[%2E]=x', opt), + equals({ + 'a': {'.': 'x'} + })); + expect( + QS.decode('a[%2e]=x', opt), + equals({ + 'a': {'.': 'x'} + })); + }); + + test( + 'bracket segment: when decodeDotInKeys=false, percent-decoding inside brackets yields \'.\' (case-insensitive)', + () { + const opt = DecodeOptions(allowDots: true, decodeDotInKeys: false); + expect( + QS.decode('a[%2E]=x', opt), + equals({ + 'a': {'.': 'x'} + })); + expect( + QS.decode('a[%2e]=x', opt), + equals({ + 'a': {'.': 'x'} + })); + }, + ); + + test('value tokens always decode %2E → \'.\'', () { + expect(QS.decode('x=%2E'), equals({'x': '.'})); + }); + + test( + 'latin1: allowDots=true, decodeDotInKeys=true behaves like UTF-8 for top-level & bracket segment', + () { + const opt = DecodeOptions( + allowDots: true, decodeDotInKeys: true, charset: latin1); + expect( + QS.decode('a%2Eb=c', opt), + equals({ + 'a': {'b': 'c'} + })); + expect( + QS.decode('a[%2E]=x', opt), + equals({ + 'a': {'.': 'x'} + })); + }, + ); + + test( + 'latin1: allowDots=true, decodeDotInKeys=false also splits top-level and decodes inside brackets', + () { + const opt = DecodeOptions( + allowDots: true, decodeDotInKeys: false, charset: latin1); + expect( + QS.decode('a%2Eb=c', opt), + equals({ + 'a': {'b': 'c'} + })); + expect( + QS.decode('a[%2E]=x', opt), + equals({ + 'a': {'.': 'x'} + })); + }, + ); + + test('percent-decoding applies inside brackets for keys', () { + // Equivalent of Kotlin's DecodeOptions.decode(KEY) assertions using QS.decode + const o1 = DecodeOptions(allowDots: false, decodeDotInKeys: false); + const o2 = DecodeOptions(allowDots: true, decodeDotInKeys: false); + + expect( + QS.decode('a[%2Eb]=v', o1), + equals({ + 'a': {'.b': 'v'} + })); + expect( + QS.decode('a[b%2Ec]=v', o1), + equals({ + 'a': {'b.c': 'v'} + })); + + expect( + QS.decode('a[%2Eb]=v', o2), + equals({ + 'a': {'.b': 'v'} + })); + expect( + QS.decode('a[b%2Ec]=v', o2), + equals({ + 'a': {'b.c': 'v'} + })); + }); + + test( + 'mixed-case encoded brackets + encoded dot after brackets (allowDots=true, decodeDotInKeys=true)', + () { + const opt = DecodeOptions(allowDots: true, decodeDotInKeys: true); + expect( + QS.decode('a%5Bb%5D%5Bc%5D%2Ed=x', opt), + equals({ + 'a': { + 'b': { + 'c': {'d': 'x'} + } + } + }), + ); + expect( + QS.decode('a%5bb%5d%5bc%5d%2ed=x', opt), + equals({ + 'a': { + 'b': { + 'c': {'d': 'x'} + } + } + }), + ); + }, + ); + + test('nested brackets inside a bracket segment (balanced as one segment)', + () { + const opt = DecodeOptions(allowDots: true, decodeDotInKeys: true); + // "a[b%5Bc%5D].e=x" → key "b[c]" stays a single segment; then ".e" splits + expect( + QS.decode('a[b%5Bc%5D].e=x', opt), + equals({ + 'a': { + 'b[c]': {'e': 'x'} + } + }), + ); + }); + + test( + 'mixed-case encoded brackets + encoded dot with allowDots=false & decodeDotInKeys=true throws', + () { + expect( + () => QS.decode('a%5Bb%5D%5Bc%5D%2Ed=x', + DecodeOptions(allowDots: false, decodeDotInKeys: true)), + throwsA(anyOf( + isA(), + isA(), + isA(), + )), + ); + }, + ); + + test( + 'top-level encoded dot splits when allowDots=true, decodeDotInKeys=true', + () { + const opt = DecodeOptions(allowDots: true, decodeDotInKeys: true); + expect( + QS.decode('a%2Eb=c', opt), + equals({ + 'a': {'b': 'c'} + })); + }); + + test( + 'top-level encoded dot also splits when allowDots=true, decodeDotInKeys=false', + () { + const opt = DecodeOptions(allowDots: true, decodeDotInKeys: false); + expect( + QS.decode('a%2Eb=c', opt), + equals({ + 'a': {'b': 'c'} + })); + }); + + test( + 'top-level encoded dot does not split when allowDots=false, decodeDotInKeys=false', + () { + const opt = DecodeOptions(allowDots: false, decodeDotInKeys: false); + expect(QS.decode('a%2Eb=c', opt), equals({'a.b': 'c'})); + }); + + test('bracket then encoded dot to next segment with allowDots=true', () { + const opt = DecodeOptions(allowDots: true, decodeDotInKeys: true); + expect( + QS.decode('a[b]%2Ec=x', opt), + equals({ + 'a': { + 'b': {'c': 'x'} + } + })); + expect( + QS.decode('a[b]%2ec=x', opt), + equals({ + 'a': { + 'b': {'c': 'x'} + } + })); + }); + + test('mixed-case: top-level encoded dot then bracket with allowDots=true', + () { + const opt = DecodeOptions(allowDots: true, decodeDotInKeys: true); + expect( + QS.decode('a%2E[b]=x', opt), + equals({ + 'a': {'b': 'x'} + })); + }); + + test( + 'top-level lowercase encoded dot splits when allowDots=true (decodeDotInKeys=false)', + () { + const opt = DecodeOptions(allowDots: true, decodeDotInKeys: false); + expect( + QS.decode('a%2eb=c', opt), + equals({ + 'a': {'b': 'c'} + })); + }); + + test('dot before index with allowDots=true: index remains index', () { + const opt = DecodeOptions(allowDots: true); + expect( + QS.decode('foo[0].baz[0]=15&foo[0].bar=2', opt), + equals({ + 'foo': [ + { + 'baz': ['15'], + 'bar': '2', + } + ] + }), + ); + }); + + test('trailing dot ignored when allowDots=true', () { + const opt = DecodeOptions(allowDots: true); + expect( + QS.decode('user.email.=x', opt), + equals({ + 'user': {'email': 'x'} + })); + }); + + test( + 'bracket segment: encoded dot mapped to \'.\' (allowDots=true, decodeDotInKeys=true)', + () { + const opt = DecodeOptions(allowDots: true, decodeDotInKeys: true); + expect( + QS.decode('a[%2E]=x', opt), + equals({ + 'a': {'.': 'x'} + })); + expect( + QS.decode('a[%2e]=x', opt), + equals({ + 'a': {'.': 'x'} + })); + }); + + test('top-level encoded dot before bracket (lowercase) with allowDots=true', + () { + const opt = DecodeOptions(allowDots: true, decodeDotInKeys: true); + expect( + QS.decode('a%2e[b]=x', opt), + equals({ + 'a': {'b': 'x'} + })); + }); + + test('plain dot before bracket with allowDots=true', () { + const opt = DecodeOptions(allowDots: true, decodeDotInKeys: true); + expect( + QS.decode('a.[b]=x', opt), + equals({ + 'a': {'b': 'x'} + })); + }); + + test('kind-aware decoder receives KEY for top-level and bracketed keys', + () { + final calls = >[]; // [String? s, DecodeKind kind] + dynamic dec(String? s, {Encoding? charset, DecodeKind? kind}) { + calls.add([s, kind ?? DecodeKind.value]); + return s; + } + + QS.decode('a%2Eb=c&a[b]=d', + DecodeOptions(allowDots: true, decodeDotInKeys: true, decoder: dec)); + + expect( + calls.any((it) => + it[1] == DecodeKind.key && (it[0] == 'a%2Eb' || it[0] == 'a[b]')), + isTrue, + ); + expect( + calls.any((it) => + it[1] == DecodeKind.value && (it[0] == 'c' || it[0] == 'd')), + isTrue, + ); + }); + }); } // Helper callable used to exercise the dynamic function fallback in DecodeOptions.decoder. From 50d49545757b108eebd27e7fd535c4a875f707fb Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 12:49:58 +0100 Subject: [PATCH 05/25] :recycle: refactor DecodeOptions to support legacy decoders and unify decode logic; add decodeKey/decodeValue helpers --- lib/src/models/decode_options.dart | 103 +++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 14 deletions(-) diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index 4f27278..04a023f 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -1,3 +1,4 @@ +// ignore_for_file: deprecated_member_use_from_same_package import 'dart:convert' show Encoding, latin1, utf8; import 'package:equatable/equatable.dart'; @@ -38,6 +39,13 @@ typedef Decoder = dynamic Function( DecodeKind? kind, }); +/// Back‑compat adapter for `(value, charset) -> Any?` decoders. +@Deprecated( + 'Use Decoder; wrap your two‑arg lambda: ' + 'Decoder((value, {charset, kind}) => legacy(value, charset: charset))', +) +typedef LegacyDecoder = dynamic Function(String? value, {Encoding? charset}); + /// Back-compat: decoder with optional [charset] only. typedef Decoder1 = dynamic Function(String? value, {Encoding? charset}); @@ -52,6 +60,7 @@ final class DecodeOptions with EquatableMixin { const DecodeOptions({ bool? allowDots, Function? decoder, + LegacyDecoder? legacyDecoder, bool? decodeDotInKeys, this.allowEmptyLists = false, this.listLimit = 20, @@ -71,6 +80,7 @@ final class DecodeOptions with EquatableMixin { }) : allowDots = allowDots ?? (decodeDotInKeys ?? false), decodeDotInKeys = decodeDotInKeys ?? false, _decoder = decoder, + _legacyDecoder = legacyDecoder, assert( charset == utf8 || charset == latin1, 'Invalid charset', @@ -174,32 +184,39 @@ final class DecodeOptions with EquatableMixin { /// If not provided, falls back to [Utils.decode]. final Function? _decoder; - /// Decode a single scalar using either the custom decoder or the default - /// implementation in [Utils.decode]. The [kind] indicates whether the token - /// is a key (or key segment) or a value; the **default implementation ignores - /// `kind` and decodes keys identically to values**. Whether `.` participates - /// in key splitting is decided later by the parser (based on options). - dynamic decoder( + /// Optional legacy decoder that takes only (value, {charset}). + final LegacyDecoder? _legacyDecoder; + + /// Unified scalar decode with key/value context. + /// + /// Uses a provided custom [decoder] when set; otherwise falls back to [Utils.decode]. + /// For backward compatibility, a [LegacyDecoder] can be supplied and is honored + /// when no primary [decoder] is provided. The [kind] will be [DecodeKind.key] for + /// keys (and key segments) and [DecodeKind.value] for values. The default implementation + /// does not vary decoding based on [kind]. + dynamic decode( String? value, { Encoding? charset, DecodeKind kind = DecodeKind.value, }) { - final Function? fn = _decoder; + final Function? fn = _decoder ?? _legacyDecoder; - // If no custom decoder is provided, use the default decoding logic. + // No custom decoder: use library default if (fn == null) { return Utils.decode(value, charset: charset ?? this.charset); } - // Prefer strongly-typed variants first + // Try strongly-typed variants first if (fn is Decoder) return fn(value, charset: charset, kind: kind); if (fn is Decoder1) return fn(value, charset: charset); if (fn is Decoder2) return fn(value, kind: kind); if (fn is Decoder3) return fn(value); - // Dynamic callable or class with `call` method + // Dynamic callable or object with `call` + // Try named-argument form first, then positional fallbacks to support + // decoders declared like `(String?, Encoding?, DecodeKind?)`. try { - // Try full shape (value, {charset, kind}) + // (value, {charset, kind}) return (fn as dynamic)(value, charset: charset, kind: kind); } on NoSuchMethodError catch (_) { // fall through @@ -207,7 +224,15 @@ final class DecodeOptions with EquatableMixin { // fall through } try { - // Try (value, {charset}) + // (value, charset, kind) + return (fn as dynamic)(value, charset, kind); + } on NoSuchMethodError catch (_) { + // fall through + } on TypeError catch (_) { + // fall through + } + try { + // (value, {charset}) return (fn as dynamic)(value, charset: charset); } on NoSuchMethodError catch (_) { // fall through @@ -215,7 +240,7 @@ final class DecodeOptions with EquatableMixin { // fall through } try { - // Try (value, {kind}) + // (value, {kind}) return (fn as dynamic)(value, kind: kind); } on NoSuchMethodError catch (_) { // fall through @@ -223,7 +248,23 @@ final class DecodeOptions with EquatableMixin { // fall through } try { - // Try (value) + // (value, charset) + return (fn as dynamic)(value, charset); + } on NoSuchMethodError catch (_) { + // fall through + } on TypeError catch (_) { + // fall through + } + try { + // (value, kind) + return (fn as dynamic)(value, kind); + } on NoSuchMethodError catch (_) { + // fall through + } on TypeError catch (_) { + // fall through + } + try { + // (value) return (fn as dynamic)(value); } on NoSuchMethodError catch (_) { // Fallback to default @@ -234,6 +275,37 @@ final class DecodeOptions with EquatableMixin { } } + /// Convenience: decode a key and coerce the result to String (or null). + String? decodeKey( + String? value, { + Encoding? charset, + }) => + decode( + value, + charset: charset ?? this.charset, + kind: DecodeKind.key, + )?.toString(); + + /// Convenience: decode a value token. + dynamic decodeValue( + String? value, { + Encoding? charset, + }) => + decode( + value, + charset: charset ?? this.charset, + kind: DecodeKind.value, + ); + + /// **Deprecated**: use [decode]. This wrapper will be removed in a future release. + @Deprecated('Use decode(value, charset: ..., kind: ...) instead') + dynamic decoder( + String? value, { + Encoding? charset, + DecodeKind kind = DecodeKind.value, + }) => + decode(value, charset: charset, kind: kind); + /// Return a new [DecodeOptions] with the provided overrides. DecodeOptions copyWith({ bool? allowDots, @@ -253,6 +325,7 @@ final class DecodeOptions with EquatableMixin { bool? strictNullHandling, bool? strictDepth, Function? decoder, + LegacyDecoder? legacyDecoder, }) => DecodeOptions( allowDots: allowDots ?? this.allowDots, @@ -273,6 +346,7 @@ final class DecodeOptions with EquatableMixin { strictNullHandling: strictNullHandling ?? this.strictNullHandling, strictDepth: strictDepth ?? this.strictDepth, decoder: decoder ?? _decoder, + legacyDecoder: legacyDecoder ?? _legacyDecoder, ); @override @@ -315,5 +389,6 @@ final class DecodeOptions with EquatableMixin { strictNullHandling, throwOnLimitExceeded, _decoder, + _legacyDecoder, ]; } From 64635549282bc9a8f53a76bf5b3e409efa1c697c Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 12:50:05 +0100 Subject: [PATCH 06/25] :white_check_mark: add tests for encoded dot handling in keys and custom decoder behavior in DecodeOptions --- test/unit/models/decode_options_test.dart | 163 ++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/test/unit/models/decode_options_test.dart b/test/unit/models/decode_options_test.dart index 317869a..78e533a 100644 --- a/test/unit/models/decode_options_test.dart +++ b/test/unit/models/decode_options_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:qs_dart/src/enums/decode_kind.dart'; import 'package:qs_dart/src/enums/duplicates.dart'; import 'package:qs_dart/src/models/decode_options.dart'; import 'package:test/test.dart'; @@ -163,4 +164,166 @@ void main() { ); }); }); + + group( + 'DecodeOptions.defaultDecode: KEY protects encoded dots prior to percent-decoding', + () { + final charsets = [utf8, latin1]; + + test( + "KEY maps %2E/%2e inside brackets to '.' when allowDots=true (UTF-8/ISO-8859-1)", + () { + for (final cs in charsets) { + final opts = DecodeOptions(allowDots: true, charset: cs); + expect(opts.decodeKey('a[%2E]'), equals('a[.]')); + expect(opts.decodeKey('a[%2e]'), equals('a[.]')); + } + }); + + test( + "KEY maps %2E outside brackets to '.' when allowDots=true; independent of decodeDotInKeys (UTF-8/ISO)", + () { + for (final cs in charsets) { + final opts1 = + DecodeOptions(allowDots: true, decodeDotInKeys: false, charset: cs); + final opts2 = + DecodeOptions(allowDots: true, decodeDotInKeys: true, charset: cs); + expect(opts1.decodeKey('a%2Eb'), equals('a.b')); + expect(opts2.decodeKey('a%2Eb'), equals('a.b')); + } + }); + + test('non-KEY decodes %2E to \'.\' (control)', () { + for (final cs in charsets) { + final opts = DecodeOptions(allowDots: true, charset: cs); + expect(opts.decodeValue('a%2Eb'), equals('a.b')); + } + }); + + test('KEY maps %2E/%2e inside brackets even when allowDots=false', () { + for (final cs in charsets) { + final opts = DecodeOptions(allowDots: false, charset: cs); + expect(opts.decodeKey('a[%2E]'), equals('a[.]')); + expect(opts.decodeKey('a[%2e]'), equals('a[.]')); + } + }); + + test( + "KEY outside %2E decodes to '.' when allowDots=false (no protection outside brackets)", + () { + for (final cs in charsets) { + final opts = DecodeOptions(allowDots: false, charset: cs); + expect(opts.decodeKey('a%2Eb'), equals('a.b')); + expect(opts.decodeKey('a%2eb'), equals('a.b')); + } + }); + }); + + group('DecodeOptions: allowDots / decodeDotInKeys interplay (computed)', () { + test( + 'decodeDotInKeys=true implies allowDots==true when allowDots not explicitly false', + () { + final opts = const DecodeOptions(decodeDotInKeys: true); + expect(opts.allowDots, isTrue); + }); + }); + + group( + 'DecodeOptions: key/value decoding + custom decoder behavior (C# parity)', + () { + test( + 'DecodeKey decodes percent sequences like values (allowDots=true, decodeDotInKeys=false)', + () { + final opts = const DecodeOptions(allowDots: true, decodeDotInKeys: false); + expect(opts.decodeKey('a%2Eb'), equals('a.b')); + expect(opts.decodeKey('a%2eb'), equals('a.b')); + }); + + test('DecodeValue decodes percent sequences normally', () { + final opts = const DecodeOptions(); + expect(opts.decodeValue('%2E'), equals('.')); + }); + + test('Decoder is used for KEY and for VALUE', () { + final List> calls = []; + final opts = DecodeOptions( + decoder: (String? s, Encoding? _, DecodeKind? kind) { + calls.add({'s': s, 'kind': kind}); + return s; // echo back + }, + ); + + expect(opts.decodeKey('x'), equals('x')); + expect(opts.decodeValue('y'), equals('y')); + + expect(calls.length, 2); + expect(calls[0]['kind'], DecodeKind.key); + expect(calls[0]['s'], 'x'); + expect(calls[1]['kind'], DecodeKind.value); + expect(calls[1]['s'], 'y'); + }); + + test('Decoder null return is honored (no fallback to default)', () { + final opts = DecodeOptions(decoder: (s, _, __) => null); + expect(opts.decodeValue('foo'), isNull); + expect(opts.decodeKey('bar'), isNull); + }); + + test( + "Single decoder acts like 'legacy' when ignoring kind (no default applied first)", + () { + // Emulate a legacy decoder that uppercases the raw token without percent-decoding. + final opts = + DecodeOptions(decoder: (String? s, _, __) => s?.toUpperCase()); + expect(opts.decodeValue('abc'), equals('ABC')); + // For keys, custom decoder gets the raw token; no default percent-decoding happens first. + expect(opts.decodeKey('a%2Eb'), equals('A%2EB')); + }); + + test('copyWith preserves and allows overriding the decoder', () { + final original = DecodeOptions( + decoder: (String? s, _, DecodeKind? k) => + s == null ? null : 'K:${k ?? DecodeKind.value}:$s', + ); + + final copy = original.copyWith(); + expect(copy.decodeValue('v'), equals('K:${DecodeKind.value}:v')); + expect(copy.decodeKey('k'), equals('K:${DecodeKind.key}:k')); + + final copy2 = original.copyWith( + decoder: (String? s, _, DecodeKind? k) => + s == null ? null : 'K2:${k ?? DecodeKind.value}:$s', + ); + expect(copy2.decodeValue('v'), equals('K2:${DecodeKind.value}:v')); + expect(copy2.decodeKey('k'), equals('K2:${DecodeKind.key}:k')); + }); + + test('decoder wins over legacyDecoder when both are provided', () { + String legacy(String? v, {Encoding? charset}) => 'L:${v ?? 'null'}'; + String dec(String? v, Encoding? _, DecodeKind? k) => + 'K:${k ?? DecodeKind.value}:${v ?? 'null'}'; + final opts = DecodeOptions(decoder: dec, legacyDecoder: legacy); + + expect(opts.decodeKey('x'), equals('K:${DecodeKind.key}:x')); + expect(opts.decodeValue('y'), equals('K:${DecodeKind.value}:y')); + }); + + test('decodeKey coerces non-string decoder result via toString', () { + final opts = DecodeOptions(decoder: (_, __, ___) => 42); + expect(opts.decodeKey('anything'), equals('42')); + }); + + test( + 'copyWith to an inconsistent combination (allowDots=false with decodeDotInKeys=true) throws', + () { + final original = const DecodeOptions(decodeDotInKeys: true); + expect( + () => original.copyWith(allowDots: false), + throwsA(anyOf( + isA(), + isA(), + isA(), + ))); + }); + }); } From bcbc1dac54bb3b1ba051f28f1d9a5fb8af8f152b Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 12:50:09 +0100 Subject: [PATCH 07/25] :bug: fix list limit enforcement and unify key/value decoding in parser --- lib/src/extensions/decode.dart | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index 2fe20de..5ae08cb 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -1,3 +1,4 @@ +// ignore_for_file: deprecated_member_use_from_same_package part of '../qs.dart'; /// Decoder: query-string → nested Dart maps/lists (Node `qs` parity) @@ -104,8 +105,7 @@ extension _$Decode on QS { // Fast-path: split comma-separated scalars into a list when requested. if (val is String && val.isNotEmpty && options.comma && val.contains(',')) { final List splitVal = val.split(','); - if (options.throwOnLimitExceeded && - currentListLength + splitVal.length > options.listLimit) { + if (options.throwOnLimitExceeded && splitVal.length > options.listLimit) { throw RangeError( 'List limit exceeded. ' 'Only ${options.listLimit} element${options.listLimit == 1 ? '' : 's'} allowed in a list.', @@ -116,7 +116,7 @@ extension _$Decode on QS { // Guard incremental growth of an existing list as we parse additional items. if (options.throwOnLimitExceeded && - currentListLength + 1 > options.listLimit) { + currentListLength >= options.listLimit) { throw RangeError( 'List limit exceeded. ' 'Only ${options.listLimit} element${options.listLimit == 1 ? '' : 's'} allowed in a list.', @@ -200,15 +200,14 @@ extension _$Decode on QS { late final String key; dynamic val; - // Decode key/value using key-aware decoder, no %2E protection shim. + // Decode key/value via DecodeOptions.decodeKey/decodeValue (kind-aware). if (pos == -1) { - // Decode bare key (no '=') using key-aware decoder - key = options.decoder(part, charset: charset, kind: DecodeKind.key); + // Decode bare key (no '=') using key-aware decoding + key = options.decodeKey(part, charset: charset) ?? ''; val = options.strictNullHandling ? null : ''; } else { - // Decode key slice using key-aware decoder; values decode as value kind - key = options.decoder(part.slice(0, pos), - charset: charset, kind: DecodeKind.key); + // Decode the key slice as a key; values decode as values + key = options.decodeKey(part.slice(0, pos), charset: charset) ?? ''; // Decode the substring *after* '=', applying list parsing and the configured decoder. val = Utils.apply( _parseListValue( @@ -218,8 +217,7 @@ extension _$Decode on QS { ? (obj[key] as List).length : 0, ), - (dynamic v) => - options.decoder(v, charset: charset, kind: DecodeKind.value), + (dynamic v) => options.decodeValue(v as String?, charset: charset), ); } From 8adba03b7061400c1caa59b8992c9182b7f5f6c9 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 12:50:16 +0100 Subject: [PATCH 08/25] :fire: remove unused import of DecodeKind from qs.dart --- lib/src/qs.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/qs.dart b/lib/src/qs.dart index 742ea0f..61b46ef 100644 --- a/lib/src/qs.dart +++ b/lib/src/qs.dart @@ -1,7 +1,6 @@ import 'dart:convert' show latin1, utf8, Encoding; import 'dart:typed_data' show ByteBuffer; -import 'package:qs_dart/src/enums/decode_kind.dart'; import 'package:qs_dart/src/enums/duplicates.dart'; import 'package:qs_dart/src/enums/format.dart'; import 'package:qs_dart/src/enums/list_format.dart'; From ded63d70756fc699e2e9f97dbc23b187f6ca8523 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 13:17:17 +0100 Subject: [PATCH 09/25] :bulb: update decode.dart comments to clarify key decoding and dot/bracket handling logic --- lib/src/extensions/decode.dart | 40 +++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index 5ae08cb..633f905 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -17,13 +17,9 @@ part of '../qs.dart'; /// Implementation notes: /// - We decode key parts lazily and then "reduce" right-to-left to build the /// final structure in `_parseObject`. -/// - We never mutate caller-provided containers; fresh maps/lists are created. -/// - No behavioral changes are introduced here; comments only. - -/// Split operation result used by the decoder helpers. -/// - `parts`: collected key segments. -/// - `exceeded`: indicates whether a configured limit was exceeded during split. -typedef SplitResult = ({List parts, bool exceeded}); +/// - We never mutate caller-provided containers; fresh maps/lists are allocated for merges. +/// - The implementation aims to match `qs` semantics; comments explain how each phase maps +/// to the reference behavior. /// Convert top‑level dots to bracket segments (depth‑aware). /// - Only dots at depth == 0 split. @@ -32,7 +28,8 @@ typedef SplitResult = ({List parts, bool exceeded}); /// * 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). -/// - Percent‑encoded dots are not handled here (keys are already percent‑decoded). +/// - Percent‑encoded dots are not handled here because keys have already been decoded by +/// `DecodeOptions.decodeKey` (defaulting to percent‑decoding). String _dotToBracketTopLevel(String s) { if (s.isEmpty || !s.contains('.')) return s; final StringBuffer sb = StringBuffer(); @@ -128,7 +125,8 @@ extension _$Decode on QS { /// Tokenizes the raw query-string into a flat key→value map before any /// structural reconstruction. Handles: - /// - query prefix removal (`?`), percent-decoding via `options.decoder` + /// - query prefix removal (`?`), and kind‑aware decoding via `DecodeOptions.decodeKey` / + /// `DecodeOptions.decodeValue` (by default these percent‑decode) /// - charset sentinel detection (`utf8=`) per `qs` /// - duplicate key policy (combine/first/last) /// - parameter and list limits with optional throwing behavior @@ -259,6 +257,9 @@ extension _$Decode on QS { /// - When `allowEmptyLists` is true, an empty string (or `null` under /// `strictNullHandling`) under a `[]` segment yields an empty list. /// - `listLimit` applies to explicit numeric indices as an upper bound. + /// - Keys arrive already decoded (top‑level encoded dots become literal `.` before we get here). + /// Whether top‑level dots split was decided earlier by `_splitKeyIntoSegments` (based on + /// `allowDots`). Numeric list indices are only honored for *bracketed* numerics like `[3]`. static dynamic _parseObject( List chain, dynamic val, @@ -308,8 +309,9 @@ extension _$Decode on QS { : Utils.combine([], leaf); } else { obj = {}; - // Normalize bracketed segments ("[k]") and optionally decode `%2E` → '.' - // when `decodeDotInKeys` is enabled. + // Normalize bracketed segments ("[k]"). Keys have already been percent‑decoded earlier by + // `decodeKey`, so `%2E/%2e` are not present here; dot‑notation splitting (if any) already + // happened in `_splitKeyIntoSegments`. final String cleanRoot = root.startsWith('[') && root.endsWith(']') ? root.slice(1, root.length - 1) : root; @@ -366,10 +368,13 @@ extension _$Decode on QS { } /// Splits a key like `a[b][0][c]` into `['a', '[b]', '[0]', '[c]']` with: - /// - dot-notation normalization (`a.b` → `a[b]`) when `allowDots` is true + /// - dot‑notation normalization (`a.b` → `a[b]`) when `allowDots` is true (runs before splitting) /// - depth limiting (depth=0 returns the whole key as a single segment) - /// - bracket group balancing, preserving unterminated tails as a single - /// remainder segment unless `strictDepth` is enabled (then it throws) + /// - balanced bracket grouping; an unterminated `[` causes the *entire key* to be treated as a + /// single literal segment (matching `qs`) + /// - when there are additional groups/text beyond `maxDepth`: + /// • if `strictDepth` is true, we throw; + /// • otherwise the remainder is wrapped as one final bracket segment (e.g., `"[rest]"`) static List _splitKeyIntoSegments({ required String originalKey, required bool allowDots, @@ -431,8 +436,7 @@ extension _$Decode on QS { open = key.indexOf('[', close + 1); } - // If there's trailing text after the last balanced group, treat it as one final segment. - // Ignore a lone trailing '.' (degenerate top‑level dot). + // Trailing text after the last balanced group → one final bracket segment (unless it's just '.'). if (lastClose >= 0 && lastClose + 1 < n) { final String remainder = key.substring(lastClose + 1); if (remainder != '.') { @@ -443,7 +447,7 @@ extension _$Decode on QS { segments.add('[$remainder]'); } } else if (open >= 0) { - // More groups remain beyond depth; either throw or stash remainder as a single segment. + // There are more groups beyond the collected depth. if (strictDepth) { throw RangeError( 'Input depth exceeded $maxDepth and strictDepth is true'); @@ -455,7 +459,7 @@ extension _$Decode on QS { } /// Normalizes the raw query-string prior to tokenization: - /// - Optionally drops a single leading `?` (when `ignoreQueryPrefix` is set). + /// - Optionally drops exactly one leading `?` (when `ignoreQueryPrefix` is true). /// - Rewrites percent-encoded bracket characters (%5B/%5b → '[', %5D/%5d → ']') /// in a single pass for faster downstream bracket parsing. static String _cleanQueryString( From 8f05037d3a44527fd4131e516a9e1b51e651fd53 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 13:23:37 +0100 Subject: [PATCH 10/25] :bulb: clarify DecodeOptions docs for allowDots and decodeDotInKeys interaction; improve charsetSentinel and decoder behavior descriptions --- lib/src/models/decode_options.dart | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index 04a023f..e31169a 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -15,6 +15,15 @@ import 'package:qs_dart/src/utils.dart'; /// /// Highlights /// - **Dot notation**: set [allowDots] to treat `a.b=c` like `{a: {b: "c"}}`. +/// If you also set [decodeDotInKeys] to `true`, [allowDots] defaults to `true` +/// unless you explicitly set `allowDots: false` — which is an invalid +/// combination and will throw at construction time. +/// - **Charset handling**: [charset] selects UTF‑8 or Latin‑1 decoding. When +/// [charsetSentinel] is `true`, a `utf8=✓` parameter (in either UTF‑8 or +/// Latin‑1 form) can override [charset]; the **first such parameter** is used. +/// +/// Highlights +/// - **Dot notation**: set [allowDots] to treat `a.b=c` like `{a: {b: "c"}}`. /// If you *explicitly* request dot decoding in keys via [decodeDotInKeys], /// [allowDots] is implied and will be treated as `true`. /// - **Charset handling**: [charset] selects UTF‑8 or Latin‑1 decoding. When @@ -96,8 +105,9 @@ final class DecodeOptions with EquatableMixin { /// When `true`, decode dot notation in keys: `a.b=c` → `{a: {b: "c"}}`. /// - /// If you set [decodeDotInKeys] to `true`, this flag is implied and will be - /// treated as enabled even if you pass `allowDots: false`. + /// If you set [decodeDotInKeys] to `true` and do not pass [allowDots], this + /// flag defaults to `true`. Passing `allowDots: false` while + /// `decodeDotInKeys` is `true` is invalid and will throw at construction. final bool allowDots; /// When `true`, allow empty list values to be produced from inputs like @@ -114,9 +124,9 @@ final class DecodeOptions with EquatableMixin { /// Only [utf8] and [latin1] are supported. final Encoding charset; - /// Enable opt‑in charset detection via the `utf8=✓` sentinel. + /// Enable opt‑in charset detection via a `utf8=✓` sentinel parameter. /// - /// If present at the start of the input, the sentinel will: + /// If present anywhere in the input, the *first occurrence* will: /// * be omitted from the result map, and /// * override [charset] based on how the checkmark was encoded (UTF‑8 or /// Latin‑1). @@ -193,7 +203,8 @@ final class DecodeOptions with EquatableMixin { /// For backward compatibility, a [LegacyDecoder] can be supplied and is honored /// when no primary [decoder] is provided. The [kind] will be [DecodeKind.key] for /// keys (and key segments) and [DecodeKind.value] for values. The default implementation - /// does not vary decoding based on [kind]. + /// does not vary decoding based on [kind]. If your decoder returns `null`, that `null` + /// is preserved — no fallback decoding is applied. dynamic decode( String? value, { Encoding? charset, From 4da9a472db453d878b3ca6755cd004e8d5d017de Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 13:44:29 +0100 Subject: [PATCH 11/25] :recycle: simplify custom decoder handling in DecodeOptions; remove dynamic invocation and legacy overloads --- lib/src/models/decode_options.dart | 109 ++++------------------------- 1 file changed, 13 insertions(+), 96 deletions(-) diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index e31169a..3d41e99 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -15,17 +15,9 @@ import 'package:qs_dart/src/utils.dart'; /// /// Highlights /// - **Dot notation**: set [allowDots] to treat `a.b=c` like `{a: {b: "c"}}`. -/// If you also set [decodeDotInKeys] to `true`, [allowDots] defaults to `true` -/// unless you explicitly set `allowDots: false` — which is an invalid -/// combination and will throw at construction time. -/// - **Charset handling**: [charset] selects UTF‑8 or Latin‑1 decoding. When -/// [charsetSentinel] is `true`, a `utf8=✓` parameter (in either UTF‑8 or -/// Latin‑1 form) can override [charset]; the **first such parameter** is used. -/// -/// Highlights -/// - **Dot notation**: set [allowDots] to treat `a.b=c` like `{a: {b: "c"}}`. /// If you *explicitly* request dot decoding in keys via [decodeDotInKeys], -/// [allowDots] is implied and will be treated as `true`. +/// [allowDots] is implied and will be treated as `true` unless you explicitly +/// set `allowDots: false` — which is an invalid combination and will throw at construction time. /// - **Charset handling**: [charset] selects UTF‑8 or Latin‑1 decoding. When /// [charsetSentinel] is `true`, a leading `utf8=✓` token (in either UTF‑8 or /// Latin‑1 form) can override [charset] as a compatibility escape hatch. @@ -55,20 +47,12 @@ typedef Decoder = dynamic Function( ) typedef LegacyDecoder = dynamic Function(String? value, {Encoding? charset}); -/// Back-compat: decoder with optional [charset] only. -typedef Decoder1 = dynamic Function(String? value, {Encoding? charset}); - -/// Decoder that accepts only [kind] (no [charset]). -typedef Decoder2 = dynamic Function(String? value, {DecodeKind? kind}); - -/// Back-compat: single-argument decoder (value only). -typedef Decoder3 = dynamic Function(String? value); - /// Options that configure the output of [QS.decode]. final class DecodeOptions with EquatableMixin { const DecodeOptions({ bool? allowDots, - Function? decoder, + Decoder? decoder, + @Deprecated('Use Decoder instead; see DecodeOptions.decoder') LegacyDecoder? legacyDecoder, bool? decodeDotInKeys, this.allowEmptyLists = false, @@ -192,16 +176,16 @@ final class DecodeOptions with EquatableMixin { /// Optional custom scalar decoder for a single token. /// If not provided, falls back to [Utils.decode]. - final Function? _decoder; + final Decoder? _decoder; /// Optional legacy decoder that takes only (value, {charset}). final LegacyDecoder? _legacyDecoder; /// Unified scalar decode with key/value context. /// - /// Uses a provided custom [decoder] when set; otherwise falls back to [Utils.decode]. + /// Uses a provided custom [Decoder] when set; otherwise falls back to [Utils.decode]. /// For backward compatibility, a [LegacyDecoder] can be supplied and is honored - /// when no primary [decoder] is provided. The [kind] will be [DecodeKind.key] for + /// when no primary [Decoder] is provided. The [kind] will be [DecodeKind.key] for /// keys (and key segments) and [DecodeKind.value] for values. The default implementation /// does not vary decoding based on [kind]. If your decoder returns `null`, that `null` /// is preserved — no fallback decoding is applied. @@ -210,80 +194,13 @@ final class DecodeOptions with EquatableMixin { Encoding? charset, DecodeKind kind = DecodeKind.value, }) { - final Function? fn = _decoder ?? _legacyDecoder; - - // No custom decoder: use library default - if (fn == null) { - return Utils.decode(value, charset: charset ?? this.charset); - } - - // Try strongly-typed variants first - if (fn is Decoder) return fn(value, charset: charset, kind: kind); - if (fn is Decoder1) return fn(value, charset: charset); - if (fn is Decoder2) return fn(value, kind: kind); - if (fn is Decoder3) return fn(value); - - // Dynamic callable or object with `call` - // Try named-argument form first, then positional fallbacks to support - // decoders declared like `(String?, Encoding?, DecodeKind?)`. - try { - // (value, {charset, kind}) - return (fn as dynamic)(value, charset: charset, kind: kind); - } on NoSuchMethodError catch (_) { - // fall through - } on TypeError catch (_) { - // fall through - } - try { - // (value, charset, kind) - return (fn as dynamic)(value, charset, kind); - } on NoSuchMethodError catch (_) { - // fall through - } on TypeError catch (_) { - // fall through - } - try { - // (value, {charset}) - return (fn as dynamic)(value, charset: charset); - } on NoSuchMethodError catch (_) { - // fall through - } on TypeError catch (_) { - // fall through - } - try { - // (value, {kind}) - return (fn as dynamic)(value, kind: kind); - } on NoSuchMethodError catch (_) { - // fall through - } on TypeError catch (_) { - // fall through - } - try { - // (value, charset) - return (fn as dynamic)(value, charset); - } on NoSuchMethodError catch (_) { - // fall through - } on TypeError catch (_) { - // fall through - } - try { - // (value, kind) - return (fn as dynamic)(value, kind); - } on NoSuchMethodError catch (_) { - // fall through - } on TypeError catch (_) { - // fall through + if (_decoder != null) { + return _decoder!(value, charset: charset, kind: kind); } - try { - // (value) - return (fn as dynamic)(value); - } on NoSuchMethodError catch (_) { - // Fallback to default - return Utils.decode(value, charset: charset ?? this.charset); - } on TypeError catch (_) { - // Fallback to default - return Utils.decode(value, charset: charset ?? this.charset); + if (_legacyDecoder != null) { + return _legacyDecoder!(value, charset: charset); } + return Utils.decode(value, charset: charset ?? this.charset); } /// Convenience: decode a key and coerce the result to String (or null). @@ -335,7 +252,7 @@ final class DecodeOptions with EquatableMixin { bool? parseLists, bool? strictNullHandling, bool? strictDepth, - Function? decoder, + Decoder? decoder, LegacyDecoder? legacyDecoder, }) => DecodeOptions( From 00913be98885498d233e9fd6c2dbbf24748ff11d Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 13:44:40 +0100 Subject: [PATCH 12/25] :white_check_mark: update tests to use new decoder signature with DecodeKind; remove legacy dynamic invocation cases --- test/unit/decode_test.dart | 32 +++++++---------------- test/unit/example.dart | 2 +- test/unit/models/decode_options_test.dart | 28 ++++++++++++-------- test/unit/uri_extension_test.dart | 15 +++++++---- 4 files changed, 38 insertions(+), 39 deletions(-) diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index 24f058f..93a13b7 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -1,3 +1,4 @@ +// ignore_for_file: deprecated_member_use_from_same_package import 'dart:convert'; import 'dart:typed_data'; @@ -1266,7 +1267,7 @@ void main() { test( 'use number decoder, parses string that has one number with comma option enabled', () { - dynamic decoder(String? str, {Encoding? charset}) => + dynamic decoder(String? str, {Encoding? charset, DecodeKind? kind}) => num.tryParse(str ?? '') ?? Utils.decode(str, charset: charset); expect( @@ -1498,7 +1499,7 @@ void main() { test('can parse with custom encoding', () { final Map expected = {'県': '大阪府'}; - String? decode(String? str, {Encoding? charset}) { + String? decode(String? str, {Encoding? charset, DecodeKind? kind}) { if (str == null) { return null; } @@ -1646,7 +1647,7 @@ void main() { 'foo=&bar=$urlEncodedNumSmiley', DecodeOptions( charset: latin1, - decoder: (String? str, {Encoding? charset}) => + decoder: (String? str, {Encoding? charset, DecodeKind? kind}) => str?.isNotEmpty ?? false ? Utils.decode(str!, charset: charset) : null, @@ -2038,13 +2039,13 @@ void main() { ]); }); - test('legacy single-arg decoder still works', () { - String? dec(String? v) => v?.toUpperCase(); - expect(QS.decode('a=b', DecodeOptions(decoder: dec)), {'A': 'B'}); + test('legacy 2-arg decoder still works', () { + String? dec(String? v, {Encoding? charset}) => v?.toUpperCase(); + expect(QS.decode('a=b', DecodeOptions(legacyDecoder: dec)), {'A': 'B'}); }); - test('decoder that only accepts kind also works', () { - dynamic dec(String? v, {DecodeKind? kind}) => + test('3-arg decoder is now the default decoder', () { + dynamic dec(String? v, {Encoding? charset, DecodeKind? kind}) => kind == DecodeKind.key ? v?.toUpperCase() : v; expect(QS.decode('aa=bb', DecodeOptions(decoder: dec)), {'AA': 'bb'}); @@ -2189,13 +2190,6 @@ void main() { expect(res, {'Xa': 'Xb'}); expect(calls, ['a', 'b']); }); - - test( - 'callable object with a required named param triggers Utils.decode fallback', - () { - final res = QS.decode('a=b', DecodeOptions(decoder: _Loose2().call)); - expect(res, {'a': 'b'}); - }); }); group('C# parity: encoded dot behavior in keys (%2E / %2e)', () { @@ -2567,14 +2561,8 @@ class _Loose1 { _Loose1(this.sink); - dynamic call(String? v, {Encoding? cs, DecodeKind? kd}) { + dynamic call(String? v, {Encoding? charset, DecodeKind? kind}) { sink.add(v); return v == null ? null : 'X$v'; } } - -// Helper callable that requires an unsupported named parameter; all dynamic attempts -// should throw, causing the code to fall back to Utils.decode. -class _Loose2 { - dynamic call(String? v, {required int must}) => 'Y$v'; -} diff --git a/test/unit/example.dart b/test/unit/example.dart index 580d9bd..120624d 100644 --- a/test/unit/example.dart +++ b/test/unit/example.dart @@ -897,7 +897,7 @@ void main() { QS.decode( '%61=%82%b1%82%f1%82%c9%82%bf%82%cd%81%49', DecodeOptions( - decoder: (str, {Encoding? charset}) { + decoder: (String? str, {Encoding? charset, DecodeKind? kind}) { if (str == null) { return null; } diff --git a/test/unit/models/decode_options_test.dart b/test/unit/models/decode_options_test.dart index 78e533a..6227897 100644 --- a/test/unit/models/decode_options_test.dart +++ b/test/unit/models/decode_options_test.dart @@ -1,3 +1,4 @@ +// ignore_for_file: deprecated_member_use_from_same_package import 'dart:convert'; import 'package:qs_dart/src/enums/decode_kind.dart'; @@ -247,7 +248,7 @@ void main() { test('Decoder is used for KEY and for VALUE', () { final List> calls = []; final opts = DecodeOptions( - decoder: (String? s, Encoding? _, DecodeKind? kind) { + decoder: (String? s, {Encoding? charset, DecodeKind? kind}) { calls.add({'s': s, 'kind': kind}); return s; // echo back }, @@ -264,7 +265,9 @@ void main() { }); test('Decoder null return is honored (no fallback to default)', () { - final opts = DecodeOptions(decoder: (s, _, __) => null); + final opts = DecodeOptions( + decoder: (String? s, {Encoding? charset, DecodeKind? kind}) => null, + ); expect(opts.decodeValue('foo'), isNull); expect(opts.decodeKey('bar'), isNull); }); @@ -273,8 +276,10 @@ void main() { "Single decoder acts like 'legacy' when ignoring kind (no default applied first)", () { // Emulate a legacy decoder that uppercases the raw token without percent-decoding. - final opts = - DecodeOptions(decoder: (String? s, _, __) => s?.toUpperCase()); + final opts = DecodeOptions( + decoder: (String? s, {Encoding? charset, DecodeKind? kind}) => + s?.toUpperCase(), + ); expect(opts.decodeValue('abc'), equals('ABC')); // For keys, custom decoder gets the raw token; no default percent-decoding happens first. expect(opts.decodeKey('a%2Eb'), equals('A%2EB')); @@ -282,8 +287,8 @@ void main() { test('copyWith preserves and allows overriding the decoder', () { final original = DecodeOptions( - decoder: (String? s, _, DecodeKind? k) => - s == null ? null : 'K:${k ?? DecodeKind.value}:$s', + decoder: (String? s, {Encoding? charset, DecodeKind? kind}) => + s == null ? null : 'K:${kind ?? DecodeKind.value}:$s', ); final copy = original.copyWith(); @@ -291,8 +296,8 @@ void main() { expect(copy.decodeKey('k'), equals('K:${DecodeKind.key}:k')); final copy2 = original.copyWith( - decoder: (String? s, _, DecodeKind? k) => - s == null ? null : 'K2:${k ?? DecodeKind.value}:$s', + decoder: (String? s, {Encoding? charset, DecodeKind? kind}) => + s == null ? null : 'K2:${kind ?? DecodeKind.value}:$s', ); expect(copy2.decodeValue('v'), equals('K2:${DecodeKind.value}:v')); expect(copy2.decodeKey('k'), equals('K2:${DecodeKind.key}:k')); @@ -300,8 +305,8 @@ void main() { test('decoder wins over legacyDecoder when both are provided', () { String legacy(String? v, {Encoding? charset}) => 'L:${v ?? 'null'}'; - String dec(String? v, Encoding? _, DecodeKind? k) => - 'K:${k ?? DecodeKind.value}:${v ?? 'null'}'; + String dec(String? v, {Encoding? charset, DecodeKind? kind}) => + 'K:${kind ?? DecodeKind.value}:${v ?? 'null'}'; final opts = DecodeOptions(decoder: dec, legacyDecoder: legacy); expect(opts.decodeKey('x'), equals('K:${DecodeKind.key}:x')); @@ -309,7 +314,8 @@ void main() { }); test('decodeKey coerces non-string decoder result via toString', () { - final opts = DecodeOptions(decoder: (_, __, ___) => 42); + final opts = DecodeOptions( + decoder: (String? s, {Encoding? charset, DecodeKind? kind}) => 42); expect(opts.decodeKey('anything'), equals('42')); }); diff --git a/test/unit/uri_extension_test.dart b/test/unit/uri_extension_test.dart index 08a264e..5a994ea 100644 --- a/test/unit/uri_extension_test.dart +++ b/test/unit/uri_extension_test.dart @@ -1,3 +1,4 @@ +// ignore_for_file: deprecated_member_use_from_same_package import 'dart:convert' show Encoding, latin1, utf8; import 'dart:typed_data' show Uint8List; @@ -1156,14 +1157,18 @@ void main() { test( 'use number decoder, parses string that has one number with comma option enabled', () { - dynamic decoder(String? str, {Encoding? charset}) => + dynamic legacyDecoder(String? str, {Encoding? charset}) => num.tryParse(str ?? '') ?? Utils.decode(str, charset: charset); expect( - Uri.parse('$testUrl?foo=1') - .queryParametersQs(DecodeOptions(comma: true, decoder: decoder)), + Uri.parse('$testUrl?foo=1').queryParametersQs( + DecodeOptions(comma: true, legacyDecoder: legacyDecoder)), equals({'foo': 1}), ); + + dynamic decoder(String? str, {Encoding? charset, DecodeKind? kind}) => + int.tryParse(str ?? '') ?? Utils.decode(str, charset: charset); + expect( Uri.parse('$testUrl?foo=0') .queryParametersQs(DecodeOptions(comma: true, decoder: decoder)), @@ -1301,7 +1306,7 @@ void main() { test('can parse with custom encoding', () { final Map expected = {'県': '大阪府'}; - String? decode(String? str, {Encoding? charset}) { + String? decode(String? str, {Encoding? charset, DecodeKind? kind}) { if (str == null) { return null; } @@ -1455,7 +1460,7 @@ void main() { .queryParametersQs( DecodeOptions( charset: latin1, - decoder: (String? str, {Encoding? charset}) => + decoder: (String? str, {Encoding? charset, DecodeKind? kind}) => str?.isNotEmpty ?? false ? Utils.decode(str!, charset: charset) : null, From 61e8b0569698b0970c3a06fab1b41471d10e8365 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 13:47:58 +0100 Subject: [PATCH 13/25] :truck: move _dotToBracketTopLevel to QS extension as static helper; update decode.dart accordingly --- lib/src/extensions/decode.dart | 120 ++++++++++++++++----------------- 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index 633f905..e7ca8c1 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -21,66 +21,6 @@ part of '../qs.dart'; /// - The implementation aims to match `qs` semantics; comments explain how each phase maps /// to the reference behavior. -/// Convert top‑level dots to bracket segments (depth‑aware). -/// - 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). -/// - Percent‑encoded dots are not handled here because keys have already been decoded by -/// `DecodeOptions.decodeKey` (defaulting to percent‑decoding). -String _dotToBracketTopLevel(String s) { - if (s.isEmpty || !s.contains('.')) return s; - final StringBuffer sb = StringBuffer(); - int depth = 0; - int i = 0; - while (i < s.length) { - final ch = s[i]; - if (ch == '[') { - depth++; - sb.write(ch); - i++; - } else if (ch == ']') { - if (depth > 0) depth--; - sb.write(ch); - i++; - } else if (ch == '.') { - if (depth == 0) { - final bool hasNext = i + 1 < s.length; - final String next = hasNext ? s[i + 1] : '\u0000'; - if (hasNext && next == '[') { - // Degenerate ".[" → skip the dot so "a.[b]" behaves like "a[b]". - i++; // consume the '.' - } else if (!hasNext || next == '.') { - // Preserve literal dot for trailing/duplicate dots. - sb.write('.'); - i++; - } else { - // Normal split: convert a.b → a[b] at top level. - final int start = ++i; - int j = start; - while (j < s.length && s[j] != '.' && s[j] != '[') { - j++; - } - sb.write('['); - sb.write(s.substring(start, j)); - sb.write(']'); - i = j; - } - } else { - // Inside brackets, keep '.' as content. - sb.write('.'); - i++; - } - } else { - sb.write(ch); - i++; - } - } - return sb.toString(); -} - /// Internal decoding surface grouped under the `QS` extension. /// /// These static helpers are private to the library and orchestrate the @@ -458,6 +398,66 @@ extension _$Decode on QS { return segments; } + /// Convert top‑level dots to bracket segments (depth‑aware). + /// - 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). + /// - Percent‑encoded dots are not handled here because keys have already been decoded by + /// `DecodeOptions.decodeKey` (defaulting to percent‑decoding). + static String _dotToBracketTopLevel(String s) { + if (s.isEmpty || !s.contains('.')) return s; + final StringBuffer sb = StringBuffer(); + int depth = 0; + int i = 0; + while (i < s.length) { + final ch = s[i]; + if (ch == '[') { + depth++; + sb.write(ch); + i++; + } else if (ch == ']') { + if (depth > 0) depth--; + sb.write(ch); + i++; + } else if (ch == '.') { + if (depth == 0) { + final bool hasNext = i + 1 < s.length; + final String next = hasNext ? s[i + 1] : '\u0000'; + if (hasNext && next == '[') { + // Degenerate ".[" → skip the dot so "a.[b]" behaves like "a[b]". + i++; // consume the '.' + } else if (!hasNext || next == '.') { + // Preserve literal dot for trailing/duplicate dots. + sb.write('.'); + i++; + } else { + // Normal split: convert a.b → a[b] at top level. + final int start = ++i; + int j = start; + while (j < s.length && s[j] != '.' && s[j] != '[') { + j++; + } + sb.write('['); + sb.write(s.substring(start, j)); + sb.write(']'); + i = j; + } + } else { + // Inside brackets, keep '.' as content. + sb.write('.'); + i++; + } + } else { + sb.write(ch); + i++; + } + } + return sb.toString(); + } + /// Normalizes the raw query-string prior to tokenization: /// - Optionally drops exactly one leading `?` (when `ignoreQueryPrefix` is true). /// - Rewrites percent-encoded bracket characters (%5B/%5b → '[', %5D/%5d → ']') From b102a4ded1bef4bfe1db7f2d9bb17a7c274d2273 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 14:09:16 +0100 Subject: [PATCH 14/25] :bug: preserve leading dot in key decoding except for degenerate ".[" case --- lib/src/extensions/decode.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index e7ca8c1..ca53c10 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -426,7 +426,12 @@ extension _$Decode on QS { if (depth == 0) { final bool hasNext = i + 1 < s.length; final String next = hasNext ? s[i + 1] : '\u0000'; - if (hasNext && next == '[') { + + // preserve a *leading* '.' as a literal, unless it's the ".[" degenerate. + if (i == 0 && (!hasNext || next != '[')) { + sb.write('.'); + i++; + } else if (hasNext && next == '[') { // Degenerate ".[" → skip the dot so "a.[b]" behaves like "a[b]". i++; // consume the '.' } else if (!hasNext || next == '.') { From e1a29c9f79f612f56829744c2038d9193aaf4546 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 14:09:22 +0100 Subject: [PATCH 15/25] :white_check_mark: add tests for leading and double dot handling with allowDots=true --- test/unit/decode_test.dart | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index 93a13b7..5f38796 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -2493,6 +2493,28 @@ void main() { })); }); + test('leading dot preserved when allowDots=true', () { + const opt = DecodeOptions(allowDots: true); + expect( + QS.decode('.a=x', opt), + equals({ + '.a': 'x', + }), + ); + }); + + test('double dot: first dot preserved as literal; second splits', () { + const opt = DecodeOptions(allowDots: true); + expect( + QS.decode('a..b=x', opt), + equals({ + 'a.': { + 'b': 'x', + } + }), + ); + }); + test( 'bracket segment: encoded dot mapped to \'.\' (allowDots=true, decodeDotInKeys=true)', () { From e75b21deb19f462fa8f29718237af9d73ef21828 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 14:38:07 +0100 Subject: [PATCH 16/25] :fire: remove legacy dynamic decoder fallback tests and helper class --- test/unit/decode_test.dart | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index 5f38796..01cf7e9 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -2178,20 +2178,6 @@ void main() { }); }); - group('decoder dynamic fallback', () { - test( - 'callable object with mismatching named params falls back to (value) only', - () { - final calls = []; - // A callable object whose named parameters do not match the library typedefs. - final res = QS.decode('a=b', DecodeOptions(decoder: _Loose1(calls).call)); - // Since the dynamic path ends up invoking `(value)` with no named args, - // both key and value get prefixed with 'X'. - expect(res, {'Xa': 'Xb'}); - expect(calls, ['a', 'b']); - }); - }); - group('C# parity: encoded dot behavior in keys (%2E / %2e)', () { test( 'top-level: allowDots=true, decodeDotInKeys=true → plain dot splits; encoded dot also splits (upper/lower)', @@ -2574,17 +2560,3 @@ void main() { }); }); } - -// Helper callable used to exercise the dynamic function fallback in DecodeOptions.decoder. -// Named parameters intentionally do not match `charset`/`kind` so the typed branches -// are skipped and the dynamic ladder is exercised. -class _Loose1 { - final List sink; - - _Loose1(this.sink); - - dynamic call(String? v, {Encoding? charset, DecodeKind? kind}) { - sink.add(v); - return v == null ? null : 'X$v'; - } -} From c9c6a7b0cdf75ff278753e4c1f4a51aab0cb5865 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 14:39:23 +0100 Subject: [PATCH 17/25] :bug: fix list limit check to account for current list length when splitting comma-separated values --- lib/src/extensions/decode.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index ca53c10..91b24ed 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -42,7 +42,8 @@ extension _$Decode on QS { // Fast-path: split comma-separated scalars into a list when requested. if (val is String && val.isNotEmpty && options.comma && val.contains(',')) { final List splitVal = val.split(','); - if (options.throwOnLimitExceeded && splitVal.length > options.listLimit) { + 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.', From 8f47a6a5e2512c3536ff36cd70f4ea32cf939f74 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 14:41:07 +0100 Subject: [PATCH 18/25] :bug: fix parameter splitting to correctly enforce limit and wrap excess bracket groups as single segment --- lib/src/extensions/decode.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index 91b24ed..47bca79 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -93,15 +93,15 @@ extension _$Decode on QS { final List allParts = cleanStr.split(options.delimiter); late final List parts; if (limit != null && limit > 0) { - final int takeCount = options.throwOnLimitExceeded ? limit + 1 : limit; - final int count = - allParts.length < takeCount ? allParts.length : takeCount; - parts = allParts.sublist(0, count); if (options.throwOnLimitExceeded && allParts.length > limit) { throw RangeError( 'Parameter limit exceeded. Only $limit parameter${limit == 1 ? '' : 's'} allowed.', ); } + parts = allParts.sublist( + 0, + allParts.length < limit ? allParts.length : limit, + ); } else { parts = allParts; } @@ -393,6 +393,8 @@ extension _$Decode on QS { throw RangeError( 'Input depth exceeded $maxDepth and strictDepth is true'); } + // Wrap the remaining bracket groups as a single literal segment. + // Example: key="a[b][c][d]", depth=2 → segment="[[c][d]]" which becomes "[c][d]" later. segments.add('[${key.substring(open)}]'); } From 5e0466dc262ee1e66668181bb967386456c7b68b Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 15:35:48 +0100 Subject: [PATCH 19/25] :white_check_mark: fix custom percent-decoding logic to handle non-encoded characters and improve byte extraction --- test/unit/decode_test.dart | 43 +++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index 01cf7e9..00b60cb 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -28,8 +28,8 @@ void main() { }); test('Nested list handling in _parseObject method', () { - // This test targets lines 154-156 in decode.dart - // We need to create a scenario where val is a List and parentKey exists in the list + // Exercise the _parseObject branch that handles nested lists and parent indices. + // Create scenarios where `val` is a List and verify index-based insertion/compaction. // First, create a list with a nested list at index 0 final list = [ @@ -80,7 +80,7 @@ void main() { // Now try to add to the existing list final queryString4 = 'a[0][2]=third'; - // Decode it with the existing result as the input + // Decode it separately; ensure compaction yields only the provided element final result4 = QS.decode(queryString4); // Verify the result @@ -1499,21 +1499,30 @@ void main() { test('can parse with custom encoding', () { final Map expected = {'県': '大阪府'}; - String? decode(String? str, {Encoding? charset, DecodeKind? kind}) { - if (str == null) { - return null; - } - - final RegExp reg = RegExp(r'%([0-9A-F]{2})', caseSensitive: false); - final List result = []; - Match? parts; - while ((parts = reg.firstMatch(str!)) != null && parts != null) { - result.add(int.parse(parts.group(1)!, radix: 16)); - str = str.substring(parts.end); + String? decode(String? s, {Encoding? charset, DecodeKind? kind}) { + if (s == null) return null; + final bytes = []; + for (int i = 0; i < s.length;) { + final c = s.codeUnitAt(i); + if (c == 0x25 /* '%' */ && i + 2 < s.length) { + final h1 = s.codeUnitAt(i + 1), h2 = s.codeUnitAt(i + 2); + int d(int u) => switch (u) { + >= 0x30 && <= 0x39 => u - 0x30, + >= 0x61 && <= 0x66 => u - 0x61 + 10, + >= 0x41 && <= 0x46 => u - 0x41 + 10, + _ => -1, + }; + final hi = d(h1), lo = d(h2); + if (hi >= 0 && lo >= 0) { + bytes.add((hi << 4) | lo); + i += 3; + continue; + } + } + bytes.add(c); + i++; } - return ShiftJIS().decode( - Uint8List.fromList(result), - ); + return ShiftJIS().decode(Uint8List.fromList(bytes)); } expect( From e6924e2d1831e4cc7ea807a7cc7895fbef9250ca Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 15:43:56 +0100 Subject: [PATCH 20/25] :bulb: update cleanRoot comment --- lib/src/extensions/decode.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index 47bca79..ded2ef1 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -250,8 +250,11 @@ extension _$Decode on QS { : Utils.combine([], leaf); } else { obj = {}; - // Normalize bracketed segments ("[k]"). Keys have already been percent‑decoded earlier by - // `decodeKey`, so `%2E/%2e` are not present here; dot‑notation splitting (if any) already + // Normalize bracketed segments ("[k]"). Note: depending on how key decoding is configured, + // percent‑encoded dots *may still be present here* (e.g. `%2E` / `%2e`). We intentionally + // handle the `%2E`→`.` mapping in this phase (see `decodedRoot` below) so that encoded + // 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) From 47e155b2dbb86c24b89a7af61a96e038e97cd413 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 15:49:29 +0100 Subject: [PATCH 21/25] :bulb: clarify negative listLimit behavior and list growth checks in decode logic comments --- lib/src/extensions/decode.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index ded2ef1..c1ccbbc 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -34,6 +34,11 @@ extension _$Decode on QS { /// /// The `currentListLength` is used to guard incremental growth when we are /// already building a list for a given key path. + /// + /// **Negative `listLimit` semantics:** a negative value disables numeric-index parsing + /// elsewhere (e.g. `[2]` segments become string keys), but *list growth paths* still exist: + /// empty‑bracket pushes (`a[]=...`) and comma‑splits. When `throwOnLimitExceeded` is `true` and + /// `listLimit < 0`, any such growth throws immediately; when `false`, they are allowed. static dynamic _parseListValue( dynamic val, DecodeOptions options, @@ -71,6 +76,8 @@ extension _$Decode on QS { /// - charset sentinel detection (`utf8=`) per `qs` /// - duplicate key policy (combine/first/last) /// - parameter and list limits with optional throwing behavior + /// - list‑growth checks honor `throwOnLimitExceeded` even when `listLimit` is negative (see + /// `_parseListValue` and `_parseObject` for details on `[]` and comma‑splits). static Map _parseQueryStringValues( String str, [ DecodeOptions options = const DecodeOptions(), @@ -198,6 +205,10 @@ extension _$Decode on QS { /// - When `allowEmptyLists` is true, an empty string (or `null` under /// `strictNullHandling`) under a `[]` segment yields an empty list. /// - `listLimit` applies to explicit numeric indices as an upper bound. + /// - A negative `listLimit` disables numeric-index parsing (bracketed numbers become map keys). + /// However, empty‑bracket pushes (`[]`) still create lists unless `throwOnLimitExceeded` is + /// `true`, in which case any list growth is rejected (comma‑split growth is enforced in + /// `_parseListValue`). /// - Keys arrive already decoded (top‑level encoded dots become literal `.` before we get here). /// Whether top‑level dots split was decided earlier by `_splitKeyIntoSegments` (based on /// `allowDots`). Numeric list indices are only honored for *bracketed* numerics like `[3]`. From 029b12f9b01cc17f7d561b74694d0e96461e46dc Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 15:49:33 +0100 Subject: [PATCH 22/25] :bulb: clarify listLimit negative value behavior and throwOnLimitExceeded interaction in decode options comments --- lib/src/models/decode_options.dart | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index 3d41e99..c7dcbf4 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -102,6 +102,15 @@ final class DecodeOptions with EquatableMixin { /// /// Keys like `a[9999999]` can cause excessively large sparse lists; above /// this limit, indices are treated as string map keys instead. + /// + /// **Negative values:** passing a negative `listLimit` (e.g. `-1`) disables + /// numeric‑index parsing entirely — any bracketed number like `a[0]` or + /// `a[123]` is treated as a **string map key**, not as a list index (i.e. + /// lists are effectively disabled). + /// + /// When [throwOnLimitExceeded] is `true` *and* [listLimit] is negative, any + /// operation that would grow a list (e.g. `a[]` pushes, comma‑separated values + /// when [comma] is `true`, or nested pushes) will throw a [RangeError]. final int listLimit; /// Character encoding used to decode percent‑encoded bytes in the input. @@ -170,8 +179,17 @@ final class DecodeOptions with EquatableMixin { /// rather than `""`. final bool strictNullHandling; - /// When `true`, exceeding *any* limit (like [parameterLimit] or [listLimit]) - /// throws instead of applying a soft cap. + /// When `true`, exceeding limits throws instead of applying a soft cap. + /// + /// This applies to: + /// • parameter count over [parameterLimit], + /// • list growth beyond [listLimit], and + /// • (in combination with [strictDepth]) exceeding [depth]. + /// + /// **Note:** even when [listLimit] is **negative** (numeric‑index parsing + /// disabled), any list‑growth path (empty‑bracket pushes like `a[]`, comma + /// splits when [comma] is `true`, or nested pushes) will immediately throw a + /// [RangeError]. final bool throwOnLimitExceeded; /// Optional custom scalar decoder for a single token. From 4d32fcde0b5310576fc07a89614a68a608e5ebda Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 15:54:41 +0100 Subject: [PATCH 23/25] :white_check_mark: improve decode tests for nested list handling, list limit error matching, and long input parsing; fix percent-decoding to handle '+' as space --- test/unit/decode_test.dart | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index 00b60cb..9663719 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -27,7 +27,8 @@ void main() { ); }); - test('Nested list handling in _parseObject method', () { + test('Nested list handling preserves nested lists and compaction behaviour', + () { // Exercise the _parseObject branch that handles nested lists and parent indices. // Create scenarios where `val` is a List and verify index-based insertion/compaction. @@ -217,7 +218,7 @@ void main() { isA().having( (e) => e.message, 'message', - 'List limit exceeded. Only 3 elements allowed in a list.', + contains('List limit exceeded'), ), ), ); @@ -1050,13 +1051,11 @@ void main() { }); test('does not error when parsing a very long list', () { - final StringBuffer str = StringBuffer('a[]=a'); - while (utf8.encode(str.toString()).length < 128 * 1024) { - str.write('&'); - str.write(str); + String s = 'a[]=a'; + while (utf8.encode(s).length < 128 * 1024) { + s = '$s&$s'; } - - expect(() => QS.decode(str.toString()), returnsNormally); + expect(() => QS.decode(s), returnsNormally); }); test('parses a string with an alternative string delimiter', () { @@ -1302,15 +1301,6 @@ void main() { ] }), ); - expect( - QS.decode('foo[]=1,2,3&foo[]=', const DecodeOptions(comma: true)), - equals({ - 'foo': [ - ['1', '2', '3'], - '' - ] - }), - ); expect( QS.decode('foo[]=1,2,3&foo[]=,', const DecodeOptions(comma: true)), equals({ @@ -1512,13 +1502,18 @@ void main() { >= 0x41 && <= 0x46 => u - 0x41 + 10, _ => -1, }; - final hi = d(h1), lo = d(h2); + final int hi = d(h1), lo = d(h2); if (hi >= 0 && lo >= 0) { bytes.add((hi << 4) | lo); i += 3; continue; } } + if (c == 0x2B /* '+' */) { + bytes.add(0x20); // space + i++; + continue; + } bytes.add(c); i++; } From 69445f71e099521cd5f8786b69fef67681414068 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 16:00:43 +0100 Subject: [PATCH 24/25] :bulb: update comments --- lib/src/extensions/decode.dart | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index c1ccbbc..7672071 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -54,7 +54,11 @@ extension _$Decode on QS { 'Only ${options.listLimit} element${options.listLimit == 1 ? '' : 's'} allowed in a list.', ); } - return splitVal; + final int remaining = options.listLimit - currentListLength; + if (remaining <= 0) return const []; + return splitVal.length <= remaining + ? splitVal + : splitVal.sublist(0, remaining); } // Guard incremental growth of an existing list as we parse additional items. @@ -105,10 +109,7 @@ extension _$Decode on QS { 'Parameter limit exceeded. Only $limit parameter${limit == 1 ? '' : 's'} allowed.', ); } - parts = allParts.sublist( - 0, - allParts.length < limit ? allParts.length : limit, - ); + parts = allParts.take(limit).toList(); } else { parts = allParts; } @@ -209,7 +210,8 @@ extension _$Decode on QS { /// However, empty‑bracket pushes (`[]`) still create lists unless `throwOnLimitExceeded` is /// `true`, in which case any list growth is rejected (comma‑split growth is enforced in /// `_parseListValue`). - /// - Keys arrive already decoded (top‑level encoded dots become literal `.` before we get here). + /// - Keys have been decoded per `DecodeOptions.decodeKey`; top‑level splitting applies to + /// literal `.` only. Encoded dots can remain encoded depending on `decodeDotInKeys`. /// Whether top‑level dots split was decided earlier by `_splitKeyIntoSegments` (based on /// `allowDots`). Numeric list indices are only honored for *bracketed* numerics like `[3]`. static dynamic _parseObject( @@ -422,8 +424,8 @@ extension _$Decode on QS { /// * 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). - /// - Percent‑encoded dots are not handled here because keys have already been decoded by - /// `DecodeOptions.decodeKey` (defaulting to percent‑decoding). + /// - Percent‑encoded dots may be left encoded by `DecodeOptions.decodeKey` when + /// `decodeDotInKeys` is false; only literal `.` are considered for splitting here. static String _dotToBracketTopLevel(String s) { if (s.isEmpty || !s.contains('.')) return s; final StringBuffer sb = StringBuffer(); From 398ef76ae8ad7a037c1d3c28c64f5ce90cfbf1cf Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 23 Aug 2025 16:08:13 +0100 Subject: [PATCH 25/25] :bulb: clarify handling of percent-encoded dots in keys and list growth with negative listLimit in decode logic comments --- lib/src/extensions/decode.dart | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index 7672071..b8ffc34 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -36,9 +36,11 @@ extension _$Decode on QS { /// already building a list for a given key path. /// /// **Negative `listLimit` semantics:** a negative value disables numeric-index parsing - /// elsewhere (e.g. `[2]` segments become string keys), but *list growth paths* still exist: - /// empty‑bracket pushes (`a[]=...`) and comma‑splits. When `throwOnLimitExceeded` is `true` and - /// `listLimit < 0`, any such growth throws immediately; when `false`, they are allowed. + /// elsewhere (e.g. `[2]` segments become string keys). For comma‑splits specifically: + /// when `throwOnLimitExceeded` is `true` and `listLimit < 0`, any non‑empty split throws + /// immediately; when `false`, growth is effectively capped at zero (the split produces + /// an empty list). Empty‑bracket pushes (`a[]=`) are handled during structure building + /// in `_parseObject`. static dynamic _parseListValue( dynamic val, DecodeOptions options, @@ -80,8 +82,8 @@ extension _$Decode on QS { /// - charset sentinel detection (`utf8=`) per `qs` /// - duplicate key policy (combine/first/last) /// - parameter and list limits with optional throwing behavior - /// - list‑growth checks honor `throwOnLimitExceeded` even when `listLimit` is negative (see - /// `_parseListValue` and `_parseObject` for details on `[]` and comma‑splits). + /// - Comma‑split growth honors `throwOnLimitExceeded` (see `_parseListValue`); + /// empty‑bracket pushes (`[]=`) are created during structure building in `_parseObject`. static Map _parseQueryStringValues( String str, [ DecodeOptions options = const DecodeOptions(), @@ -206,12 +208,14 @@ extension _$Decode on QS { /// - When `allowEmptyLists` is true, an empty string (or `null` under /// `strictNullHandling`) under a `[]` segment yields an empty list. /// - `listLimit` applies to explicit numeric indices as an upper bound. - /// - A negative `listLimit` disables numeric-index parsing (bracketed numbers become map keys). - /// However, empty‑bracket pushes (`[]`) still create lists unless `throwOnLimitExceeded` is - /// `true`, in which case any list growth is rejected (comma‑split growth is enforced in - /// `_parseListValue`). + /// - A negative `listLimit` disables numeric‑index parsing (bracketed numbers become map keys). + /// Empty‑bracket pushes (`[]`) still create lists here; this method does not enforce + /// `throwOnLimitExceeded` for that path. Comma‑split growth (if any) has already been + /// handled by `_parseListValue`. /// - Keys have been decoded per `DecodeOptions.decodeKey`; top‑level splitting applies to - /// literal `.` only. Encoded dots can remain encoded depending on `decodeDotInKeys`. + /// literal `.` only (including those produced by percent‑decoding). Percent‑encoded dots may + /// still appear inside bracket segments here; we normalize `%2E`/`%2e` to `.` below when + /// `decodeDotInKeys` is enabled. /// Whether top‑level dots split was decided earlier by `_splitKeyIntoSegments` (based on /// `allowDots`). Numeric list indices are only honored for *bracketed* numerics like `[3]`. static dynamic _parseObject( @@ -424,8 +428,9 @@ extension _$Decode on QS { /// * 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). - /// - Percent‑encoded dots may be left encoded by `DecodeOptions.decodeKey` when - /// `decodeDotInKeys` is false; only literal `.` are considered for splitting here. + /// - 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. static String _dotToBracketTopLevel(String s) { if (s.isEmpty || !s.contains('.')) return s; final StringBuffer sb = StringBuffer();