diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index 869fe5e..b8ffc34 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) @@ -16,17 +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}); - -/// 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'\.([^.\[]+)'); +/// - 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. /// Internal decoding surface grouped under the `QS` extension. /// @@ -41,6 +34,13 @@ 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). 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, @@ -50,18 +50,22 @@ extension _$Decode on QS { if (val is String && val.isNotEmpty && options.comma && val.contains(',')) { final List splitVal = val.split(','); if (options.throwOnLimitExceeded && - currentListLength + splitVal.length > options.listLimit) { + (currentListLength + splitVal.length) > options.listLimit) { throw RangeError( 'List limit exceeded. ' '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. 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.', @@ -73,10 +77,13 @@ 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 + /// - 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(), @@ -99,15 +106,12 @@ 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.take(limit).toList(); } else { parts = allParts; } @@ -145,15 +149,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( @@ -163,8 +166,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), ); } @@ -206,6 +208,16 @@ 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). + /// 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 (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( List chain, dynamic val, @@ -255,8 +267,12 @@ 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]"). 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) : root; @@ -313,10 +329,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, @@ -324,9 +343,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,67 +353,142 @@ 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 '[' + // 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 != '.') { + if (strictDepth && open >= 0) { + throw RangeError( + 'Input depth exceeded $maxDepth and strictDepth is true'); + } + segments.add('[$remainder]'); + } + } else if (open >= 0) { + // There are more groups beyond the collected depth. if (strictDepth) { throw RangeError( 'Input depth exceeded $maxDepth and strictDepth is true'); } - // Stash the remainder as a single segment (qs behavior) + // 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)}]'); } 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). + /// - 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(); + 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'; + + // 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 == '.') { + // 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 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( diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index cae687d..c7dcbf4 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'; @@ -15,7 +16,8 @@ import 'package:qs_dart/src/utils.dart'; /// 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. @@ -38,20 +40,20 @@ typedef Decoder = dynamic Function( DecodeKind? kind, }); -/// 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); +/// 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}); /// 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, this.listLimit = 20, @@ -71,15 +73,25 @@ final class DecodeOptions with EquatableMixin { }) : allowDots = allowDots ?? (decodeDotInKeys ?? false), decodeDotInKeys = decodeDotInKeys ?? false, _decoder = decoder, + _legacyDecoder = legacyDecoder, 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"}}`. /// - /// 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 @@ -90,15 +102,24 @@ 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. /// 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). @@ -114,9 +135,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., `"&"`) @@ -153,72 +179,79 @@ 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. /// If not provided, falls back to [Utils.decode]. - final Function? _decoder; + final Decoder? _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. - 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]. If your decoder returns `null`, that `null` + /// is preserved — no fallback decoding is applied. + dynamic decode( String? value, { Encoding? charset, DecodeKind kind = DecodeKind.value, }) { - final Function? fn = _decoder; - - // If no custom decoder is provided, use the default decoding logic. - if (fn == null) { - return Utils.decode(value, charset: charset ?? this.charset); - } - - // Prefer 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 - try { - // Try full shape (value, {charset, kind}) - return (fn as dynamic)(value, charset: charset, kind: kind); - } on NoSuchMethodError catch (_) { - // fall through - } on TypeError catch (_) { - // fall through - } - try { - // Try (value, {charset}) - return (fn as dynamic)(value, charset: charset); - } on NoSuchMethodError catch (_) { - // fall through - } on TypeError catch (_) { - // fall through - } - try { - // Try (value, {kind}) - return (fn as dynamic)(value, kind: kind); - } on NoSuchMethodError catch (_) { - // fall through - } on TypeError catch (_) { - // fall through + if (_decoder != null) { + return _decoder!(value, charset: charset, kind: kind); } - try { - // 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). + 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, @@ -237,7 +270,8 @@ final class DecodeOptions with EquatableMixin { bool? parseLists, bool? strictNullHandling, bool? strictDepth, - Function? decoder, + Decoder? decoder, + LegacyDecoder? legacyDecoder, }) => DecodeOptions( allowDots: allowDots ?? this.allowDots, @@ -258,6 +292,7 @@ final class DecodeOptions with EquatableMixin { strictNullHandling: strictNullHandling ?? this.strictNullHandling, strictDepth: strictDepth ?? this.strictDepth, decoder: decoder ?? _decoder, + legacyDecoder: legacyDecoder ?? _legacyDecoder, ); @override @@ -300,5 +335,6 @@ final class DecodeOptions with EquatableMixin { strictNullHandling, throwOnLimitExceeded, _decoder, + _legacyDecoder, ]; } 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'; diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index fb62f02..9663719 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'; @@ -16,15 +17,20 @@ void main() { expect( () => QS.decode( 'a=b&c=d', - const DecodeOptions(parameterLimit: 0), + DecodeOptions(parameterLimit: 0), ), - throwsArgumentError, + throwsA(anyOf( + isA(), + isA(), + isA(), + )), ); }); - 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 + 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. // First, create a list with a nested list at index 0 final list = [ @@ -75,7 +81,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 @@ -212,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'), ), ), ); @@ -1045,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', () { @@ -1262,7 +1266,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( @@ -1297,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({ @@ -1494,21 +1489,35 @@ void main() { test('can parse with custom encoding', () { final Map expected = {'県': '大阪府'}; - String? decode(String? str, {Encoding? charset}) { - 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 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++; } - return ShiftJIS().decode( - Uint8List.fromList(result), - ); + return ShiftJIS().decode(Uint8List.fromList(bytes)); } expect( @@ -1642,7 +1651,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, @@ -2034,13 +2043,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'}); @@ -2173,44 +2182,385 @@ void main() { }); }); - group('decoder dynamic fallback', () { + 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( - 'callable object with mismatching named params falls back to (value) only', + '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)', () { - 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']); + 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( - 'callable object with a required named param triggers Utils.decode fallback', + '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)', () { - final res = QS.decode('a=b', DecodeOptions(decoder: _Loose2().call)); - expect(res, {'a': 'b'}); + 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'} + } + }), + ); }); - }); -} -// 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; + 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(), + )), + ); + }, + ); - _Loose1(this.sink); + 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'} + })); + }); - dynamic call(String? v, {Encoding? cs, DecodeKind? kd}) { - sink.add(v); - return v == null ? null : 'X$v'; - } -} + 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', + } + ] + }), + ); + }); -// 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'; + test('trailing dot ignored when allowDots=true', () { + const opt = DecodeOptions(allowDots: true); + expect( + QS.decode('user.email.=x', opt), + equals({ + 'user': {'email': 'x'} + })); + }); + + 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)', + () { + 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, + ); + }); + }); } 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 33d8213..6227897 100644 --- a/test/unit/models/decode_options_test.dart +++ b/test/unit/models/decode_options_test.dart @@ -1,5 +1,7 @@ +// ignore_for_file: deprecated_member_use_from_same_package 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'; @@ -138,4 +140,196 @@ 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(), + )), + ); + }); + }); + + 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? charset, 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: (String? s, {Encoding? charset, DecodeKind? kind}) => 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, {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')); + }); + + test('copyWith preserves and allows overriding the decoder', () { + final original = DecodeOptions( + decoder: (String? s, {Encoding? charset, DecodeKind? kind}) => + s == null ? null : 'K:${kind ?? 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, {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')); + }); + + test('decoder wins over legacyDecoder when both are provided', () { + String legacy(String? v, {Encoding? charset}) => 'L:${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')); + expect(opts.decodeValue('y'), equals('K:${DecodeKind.value}:y')); + }); + + test('decodeKey coerces non-string decoder result via toString', () { + final opts = DecodeOptions( + decoder: (String? s, {Encoding? charset, DecodeKind? kind}) => 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(), + ))); + }); + }); } 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,