diff --git a/lib/src/enums/format.dart b/lib/src/enums/format.dart index d6687fce..6583597e 100644 --- a/lib/src/enums/format.dart +++ b/lib/src/enums/format.dart @@ -45,8 +45,9 @@ enum Format { /// Rewrites `%20` to `+` (space) and returns the result unchanged otherwise. /// No decoding is performed. - static String _rfc1738Formatter(String value) => value.replaceAll('%20', '+'); + static String _rfc1738Formatter(final String value) => + value.replaceAll('%20', '+'); /// Identity formatter: returns the input unchanged. - static String _rfc3986Formatter(String value) => value; + static String _rfc3986Formatter(final String value) => value; } diff --git a/lib/src/enums/list_format.dart b/lib/src/enums/list_format.dart index 2c89cf7f..3cb51030 100644 --- a/lib/src/enums/list_format.dart +++ b/lib/src/enums/list_format.dart @@ -61,14 +61,16 @@ enum ListFormat { String toString() => name; /// `foo[]` - static String _brackets(String prefix, [String? key]) => '$prefix[]'; + static String _brackets(final String prefix, [final String? key]) => + '$prefix[]'; /// `foo` (the encoder will join values with commas) - static String _comma(String prefix, [String? key]) => prefix; + static String _comma(final String prefix, [final String? key]) => prefix; /// `foo[]` - static String _indices(String prefix, [String? key]) => '$prefix[$key]'; + static String _indices(final String prefix, [final String? key]) => + '$prefix[$key]'; /// `foo` (the encoder will repeat the key per element) - static String _repeat(String prefix, [String? key]) => prefix; + static String _repeat(final String prefix, [final String? key]) => prefix; } diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index af9f1c6b..91b55b92 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -42,41 +42,154 @@ extension _$Decode on QS { /// an empty list). Empty‑bracket pushes (`a[]=`) are handled during structure building /// in `_parseObject`. static dynamic _parseListValue( - dynamic val, - DecodeOptions options, - int currentListLength, + final dynamic val, + final DecodeOptions options, + final int currentListLength, + final bool isListGrowthPath, ) { // 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) { - final String msg = options.listLimit < 0 - ? 'List parsing is disabled (listLimit < 0).' - : 'List limit exceeded. Only ${options.listLimit} ' - 'element${options.listLimit == 1 ? '' : 's'} allowed in a list.'; - throw RangeError(msg); - } final int remaining = options.listLimit - currentListLength; + + if (options.throwOnLimitExceeded) { + if (remaining < 0) { + throw RangeError(_listLimitExceededMessage(options.listLimit)); + } + final List splitVal = _splitCommaValue( + val, + maxParts: remaining == 0 ? 1 : remaining + 1, + ); + if (splitVal.length > remaining) { + throw RangeError(_listLimitExceededMessage(options.listLimit)); + } + return splitVal; + } + if (remaining <= 0) return const []; - return splitVal.length <= remaining - ? splitVal - : splitVal.sublist(0, remaining); + return _splitCommaValue(val, maxParts: remaining); } // Guard incremental growth of an existing list as we parse additional items. if (options.throwOnLimitExceeded && + isListGrowthPath && currentListLength >= options.listLimit) { - final String msg = options.listLimit < 0 - ? 'List parsing is disabled (listLimit < 0).' - : 'List limit exceeded. Only ${options.listLimit} ' - 'element${options.listLimit == 1 ? '' : 's'} allowed in a list.'; - throw RangeError(msg); + throw RangeError(_listLimitExceededMessage(options.listLimit)); } return val; } + /// Helper to generate consistent error messages for list limit violations, + /// based on the configured `listLimit`. + static String _listLimitExceededMessage(final int listLimit) => listLimit < 0 + ? 'List parsing is disabled (listLimit < 0).' + : 'List limit exceeded. Only $listLimit ' + 'element${listLimit == 1 ? '' : 's'} allowed in a list.'; + + /// Splits a comma-separated value into parts, respecting an optional `maxParts` limit. + static List _splitCommaValue( + final String value, { + final int? maxParts, + }) { + if (maxParts != null && maxParts <= 0) return const []; + + final List parts = []; + int start = 0; + while (true) { + if (maxParts != null && parts.length >= maxParts) break; + + final int comma = value.indexOf(',', start); + final int end = comma == -1 ? value.length : comma; + parts.add(value.substring(start, end)); + + if (comma == -1) break; + start = comma + 1; + } + + return parts; + } + + /// Splits the input string by the specified delimiter (string or pattern), + /// collecting only non-empty parts and respecting an optional `maxParts` limit. + static List _collectNonEmptyParts( + final String input, + final Pattern delimiter, { + final int? maxParts, + }) { + return switch (delimiter) { + String d => _collectNonEmptyStringParts(input, d, maxParts: maxParts), + _ => _collectNonEmptyPatternParts(input, delimiter, maxParts: maxParts), + }; + } + + /// Optimized splitter for string delimiters that collects only non-empty + /// parts and respects `maxParts`. + static List _collectNonEmptyStringParts( + final String input, + final String delimiter, { + final int? maxParts, + }) { + if (delimiter.isEmpty) { + throw ArgumentError('Delimiter must not be empty.'); + } + if (maxParts != null && maxParts <= 0) return const []; + + final List parts = []; + int start = 0; + while (true) { + if (maxParts != null && parts.length >= maxParts) break; + + final int next = input.indexOf(delimiter, start); + final int end = next == -1 ? input.length : next; + if (end > start) { + parts.add(input.substring(start, end)); + } + + if (next == -1) break; + start = next + delimiter.length; + } + + return parts; + } + + /// General splitter for pattern delimiters that collects only non-empty parts + static List _collectNonEmptyPatternParts( + final String input, + final Pattern delimiter, { + final int? maxParts, + }) { + if (maxParts != null && maxParts <= 0) return const []; + + final List out = []; + int start = 0; + + for (final Match match in delimiter.allMatches(input)) { + final int matchStart = match.start; + final int matchEnd = match.end; + + if (matchStart < start) continue; + + if (matchStart > start) { + out.add(input.substring(start, matchStart)); + if (maxParts != null && out.length >= maxParts) return out; + } + + // Defensive handling for zero-width matches to guarantee forward progress. + if (matchEnd <= start) { + if (start >= input.length) break; + start++; + } else { + start = matchEnd; + } + } + + if (start < input.length && (maxParts == null || out.length < maxParts)) { + out.add(input.substring(start)); + } + + return out; + } + /// Tokenizes the raw query-string into a flat key→value map before any /// structural reconstruction. Handles: /// - query prefix removal (`?`), and kind‑aware decoding via `DecodeOptions.decodeKey` / @@ -87,8 +200,8 @@ extension _$Decode on QS { /// - 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(), + final String str, [ + final DecodeOptions options = const DecodeOptions(), ]) { // 1) Normalize the incoming string (drop `?`, normalize %5B/%5D to brackets). final String cleanStr = @@ -105,17 +218,18 @@ extension _$Decode on QS { } // 3) Split by delimiter once; optionally truncate, optionally throw on overflow. - final List allParts = cleanStr.split(options.delimiter); - late final List parts; - if (limit != null && limit > 0) { - 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; + final int? takeCount = limit == null + ? null + : (options.throwOnLimitExceeded ? limit + 1 : limit); + final List parts = _collectNonEmptyParts( + cleanStr, + options.delimiter, + maxParts: takeCount, + ); + if (options.throwOnLimitExceeded && limit != null && parts.length > limit) { + throw RangeError( + 'Parameter limit exceeded. Only $limit parameter${limit == 1 ? '' : 's'} allowed.', + ); } // Charset probing (utf8=✓ / utf8=X). Skip the sentinel pair later. @@ -158,19 +272,30 @@ extension _$Decode on QS { val = options.strictNullHandling ? null : ''; } else { // Decode the key slice as a key; values decode as values - key = options.decodeKey(part.slice(0, pos), charset: charset) ?? ''; + final String rawKey = part.substring(0, pos); + key = options.decodeKey(rawKey, charset: charset) ?? ''; // Decode the substring *after* '=', applying list parsing and the configured decoder. + final bool existingKey = obj.containsKey(key); + final bool combiningDuplicates = + existingKey && options.duplicates == Duplicates.combine; + final int currentListLength = combiningDuplicates + ? (obj[key] is List ? (obj[key] as List).length : 1) + : 0; + final bool listGrowthFromKey = combiningDuplicates || + (options.parseLists && rawKey.endsWith('[]')); + val = Utils.apply( _parseListValue( - part.slice(pos + 1), + part.substring(pos + 1), options, - obj.containsKey(key) && obj[key] is List - ? (obj[key] as List).length - : 0, + currentListLength, + listGrowthFromKey, ), - (dynamic v) => options.decodeValue(v as String?, charset: charset), + (final dynamic v) => + options.decodeValue(v as String?, charset: charset), ); } + if (key.isEmpty) continue; // Optional HTML numeric entity interpretation (legacy Latin-1 queries). if (val != null && @@ -184,8 +309,10 @@ extension _$Decode on QS { } } - // Quirk: a literal `[]=` suffix forces an array container (qs behavior). - if (options.parseLists && part.contains('[]=')) { + // Quirk: a key ending in `[]` forces an array container (qs behavior). + if (options.parseLists && + pos != -1 && + part.substring(0, pos).endsWith('[]')) { val = [val]; } @@ -221,9 +348,11 @@ extension _$Decode on QS { /// - `listLimit` applies to explicit numeric indices and list growth via `[]`; /// when exceeded, lists are converted into maps with string indices. /// - 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`. + /// - List-growth context is forwarded to `_parseListValue` before reduction: + /// `chain.last == '[]'` (with `options.parseLists`) and `options.throwOnLimitExceeded` + /// can cause `_parseListValue` to throw on strict list growth checks. + /// Reviewers should trace `chain`/`options` into `_parseObject` and then into + /// `_parseListValue` for the exact throw paths. /// - 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 @@ -231,15 +360,18 @@ extension _$Decode on QS { /// 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, - DecodeOptions options, - bool valuesParsed, + final List chain, + final dynamic val, + final DecodeOptions options, + final bool valuesParsed, ) { + final bool isListGrowthPath = + chain.isNotEmpty && chain.last == '[]' && options.parseLists; + // Determine the current list length if we are appending into `[]`. late final int currentListLength; - if (chain.length >= 2 && chain.last == '[]') { + if (isListGrowthPath && chain.length >= 2) { final String prev = chain[chain.length - 2]; final bool bracketed = prev.startsWith('[') && prev.endsWith(']'); final int? parentIndex = @@ -264,6 +396,7 @@ extension _$Decode on QS { val, options, currentListLength, + isListGrowthPath, ); for (int i = chain.length - 1; i >= 0; --i) { @@ -293,7 +426,7 @@ extension _$Decode on QS { // happened in `_splitKeyIntoSegments`. final bool wasBracketed = root.startsWith('[') && root.endsWith(']'); final String cleanRoot = - wasBracketed ? root.slice(1, root.length - 1) : root; + wasBracketed ? root.substring(1, root.length - 1) : root; String decodedRoot = options.decodeDotInKeys && cleanRoot.contains('%2') ? cleanRoot.replaceAll('%2E', '.').replaceAll('%2e', '.') : cleanRoot; @@ -352,10 +485,10 @@ extension _$Decode on QS { /// depth constraints) and delegates to `_parseObject` to build the value. /// Returns `null` for empty keys. static dynamic _parseKeys( - String? givenKey, - dynamic val, - DecodeOptions options, [ - bool valuesParsed = false, + final String? givenKey, + final dynamic val, + final DecodeOptions options, [ + final bool valuesParsed = false, ]) { if (givenKey == null || givenKey.isEmpty) return null; @@ -378,10 +511,10 @@ extension _$Decode on QS { /// • 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, - required int maxDepth, - required bool strictDepth, + required final String originalKey, + required final bool allowDots, + required final int maxDepth, + required final bool strictDepth, }) { // Depth==0 → do not split at all (reference `qs` behavior). // Important: return the *original* key with no dot→bracket normalization. @@ -446,21 +579,15 @@ extension _$Decode on QS { if (lastClose >= 0 && lastClose + 1 < n) { final String remainder = key.substring(lastClose + 1); if (remainder != '.') { + // Note: if there are still uncollected bracket groups (open >= 0), + // they are part of this same remainder path; no separate overflow + // branch is needed. 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'); - } - // 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; @@ -477,7 +604,7 @@ extension _$Decode on QS { /// - 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) { + static String _dotToBracketTopLevel(final String s) { if (s.isEmpty || !s.contains('.')) return s; final StringBuffer sb = StringBuffer(); int depth = 0; @@ -509,7 +636,7 @@ extension _$Decode on QS { final int start = ++i; int j = start; // Accept [A-Za-z0-9_] at the start of a segment; otherwise, keep '.' literal. - bool isIdentStart(int cu) => switch (cu) { + bool isIdentStart(final int cu) => switch (cu) { (>= 0x41 && <= 0x5A) || // A-Z (>= 0x61 && <= 0x7A) || // a-z (>= 0x30 && <= 0x39) || // 0-9 @@ -550,7 +677,7 @@ extension _$Decode on QS { /// in a single pass for faster downstream bracket parsing. static String _cleanQueryString( String str, { - required bool ignoreQueryPrefix, + required final bool ignoreQueryPrefix, }) { // Drop exactly one leading '?' (qs semantics) — not all leading question marks. if (ignoreQueryPrefix && @@ -605,4 +732,104 @@ extension _$Decode on QS { } return sb.toString(); } + + /// Returns the earliest index in [key] that signals structured syntax. + /// + /// Structured syntax is: + /// - `[` always + /// - `.` when [allowDots] is true + /// - `%2E`/`%2e` when [allowDots] is true and keys are still encoded + /// + /// Returns `-1` when no structured marker is present. + static int _firstStructuredSplitIndex( + final String key, + final bool allowDots, + ) { + int splitAt = key.indexOf('['); + if (!allowDots) return splitAt; + + final int dotIndex = key.indexOf('.'); + if (dotIndex >= 0 && (splitAt < 0 || dotIndex < splitAt)) { + splitAt = dotIndex; + } + + int encodedDotIndex = -1; + if (key.contains('%')) { + final int upper = key.indexOf('%2E'); + final int lower = key.indexOf('%2e'); + if (upper >= 0 && lower >= 0) { + encodedDotIndex = upper < lower ? upper : lower; + } else { + encodedDotIndex = upper >= 0 ? upper : lower; + } + } + + if (encodedDotIndex >= 0 && (splitAt < 0 || encodedDotIndex < splitAt)) { + splitAt = encodedDotIndex; + } + + return splitAt; + } + + /// Computes the collision root for a structured [key]. + /// + /// This uses `_splitKeyIntoSegments` so root extraction follows the same + /// dot/bracket/depth rules as full decode. For leading bracket keys like + /// `[]` the root normalizes to `'0'` to match existing merge semantics. + static String _leadingStructuredRoot( + final String key, + final DecodeOptions options, + ) { + final List segments = _$Decode._splitKeyIntoSegments( + originalKey: key, + allowDots: options.allowDots, + maxDepth: options.depth, + strictDepth: options.strictDepth, + ); + if (segments.isEmpty) return key; + + final String first = segments.first; + if (!first.startsWith('[')) return first; + + final int last = first.lastIndexOf(']'); + final String cleanRoot = + last > 0 ? first.substring(1, last) : first.substring(1); + return cleanRoot.isEmpty ? '0' : cleanRoot; + } + + /// Pre-scans tokenized keys for decode fast-path decisions. + /// + /// Produces: + /// - [StructuredKeyScan.hasAnyStructuredSyntax] for flat-query early return + /// - [StructuredKeyScan.structuredKeys] for per-key bypass checks + /// - [StructuredKeyScan.structuredRoots] to preserve flat/structured root + /// collision behavior (for example `a=1` with `a[b]=2`) + static StructuredKeyScan _scanStructuredKeys( + final Map tempObj, + final DecodeOptions options, + ) { + if (tempObj.isEmpty) return const StructuredKeyScan.empty(); + + final bool allowDots = options.allowDots; + final Set roots = {}; + final Set structuredKeys = {}; + for (final String key in tempObj.keys) { + final int splitAt = _firstStructuredSplitIndex(key, allowDots); + + if (splitAt < 0) continue; + structuredKeys.add(key); + if (splitAt == 0) { + roots.add(_leadingStructuredRoot(key, options)); + } else { + roots.add(key.substring(0, splitAt)); + } + } + + if (structuredKeys.isEmpty) return const StructuredKeyScan.empty(); + return StructuredKeyScan( + hasAnyStructuredSyntax: true, + structuredRoots: roots, + structuredKeys: structuredKeys, + ); + } } diff --git a/lib/src/extensions/encode.dart b/lib/src/extensions/encode.dart index c8b67097..5b2c233b 100644 --- a/lib/src/extensions/encode.dart +++ b/lib/src/extensions/encode.dart @@ -17,6 +17,8 @@ part of '../qs.dart'; /// advanced via `KeyPathNode.append(...)`. extension _$Encode on QS { + /// Pre-instantiated generators for built-in list formats, so we can compare by + /// identity rather than stringly-typed name checks in hot code paths. static final ListFormatGenerator _indicesGenerator = ListFormat.indices.generator; static final ListFormatGenerator _bracketsGenerator = @@ -37,11 +39,11 @@ extension _$Encode on QS { /// - [prefix]: Root path seed used to initialize the first [KeyPathNode]. /// - [rootConfig]: Immutable encode options shared across traversal frames. static dynamic _encode( - dynamic object, { - required bool undefined, - required Set sideChannel, - required String prefix, - required EncodeConfig rootConfig, + final dynamic object, { + required final bool undefined, + required final Set sideChannel, + required final String prefix, + required final EncodeConfig rootConfig, }) { // Guarded fast path for deep single-key map chains under compatible options. final String? linear = _tryEncodeLinearChain( @@ -78,7 +80,7 @@ extension _$Encode on QS { // Single-entry MRU cache for bracket key segments: // if the current key matches the previous key, reuse the last "[key]" // string; otherwise build a new one and update the cached pair. - String bracketSegment(String encodedKey) { + String bracketSegment(final String encodedKey) { if (lastBracketKey == encodedKey && lastBracketSegment != null) { return lastBracketSegment!; } @@ -89,7 +91,7 @@ extension _$Encode on QS { } // Single-entry MRU cache for dot key segments, mirroring bracketSegment. - String dotSegment(String encodedKey) { + String dotSegment(final String encodedKey) { if (lastDotKey == encodedKey && lastDotSegment != null) { return lastDotSegment!; } @@ -101,7 +103,7 @@ extension _$Encode on QS { // Finalize a frame: clear active-path cycle tracking for this node, // pop it from the stack, and publish its encoded result to the parent. - void finishFrame(EncodeFrame frame, dynamic result) { + void finishFrame(final EncodeFrame frame, final dynamic result) { final Object? tracked = frame.trackedObject; if (tracked != null) { frame.sideChannel.remove(tracked); @@ -144,7 +146,7 @@ extension _$Encode on QS { obj is Iterable) { obj = Utils.apply( obj, - (value) => value is DateTime + (final value) => value is DateTime ? (config.serializeDate?.call(value) ?? value.toIso8601String()) : value, @@ -236,17 +238,18 @@ extension _$Encode on QS { : joinIterable.toList(growable: false); if (joinList.isNotEmpty) { - final String objKeysValue = - joinList.map((e) => e != null ? e.toString() : '').join(','); + final String objKeysValue = joinList + .map((final e) => e != null ? e.toString() : '') + .join(','); objKeys = [ - _ValueSentinel( + ValueSentinel( objKeysValue.isNotEmpty ? objKeysValue : null, ), ]; } else { objKeys = [ - const _ValueSentinel(Undefined()), + const ValueSentinel(Undefined()), ]; } } else if (config.filter is Iterable) { @@ -262,14 +265,14 @@ extension _$Encode on QS { } } else if (seqList != null) { if (config.sort != null) { - objKeys = - List.generate(seqList.length, (i) => i, growable: false); + objKeys = List.generate(seqList.length, (final i) => i, + growable: false); objKeys.sort(config.sort); } else if (seqList.length == 1) { objKeys = [0]; } else { - objKeys = - List.generate(seqList.length, (i) => i, growable: false); + objKeys = List.generate(seqList.length, (final i) => i, + growable: false); } } else { objKeys = const []; @@ -317,7 +320,7 @@ extension _$Encode on QS { late final dynamic value; late final bool valueUndefined; - if (key is _ValueSentinel) { + if (key is ValueSentinel) { if (key.value is Undefined) { value = null; valueUndefined = true; @@ -366,7 +369,7 @@ extension _$Encode on QS { ? keyString.replaceAll('.', '%2E') : keyString; - final bool isCommaSentinel = key is _ValueSentinel; + final bool isCommaSentinel = key is ValueSentinel; final KeyPathNode adjustedPath = frame.adjustedPath!; // Comma lists collapse to a sentinel key and reuse `frame.adjustedPath`, // so `_buildSequenceChildPath` is not called with `_commaGenerator`. @@ -432,11 +435,11 @@ extension _$Encode on QS { // Fast path for deep single-key map chains under strict option constraints. // Returns `null` when unsupported so the caller can use the generic encoder. static String? _tryEncodeLinearChain( - dynamic object, { - required bool undefined, - required Set sideChannel, - required String prefix, - required EncodeConfig config, + final dynamic object, { + required final bool undefined, + required final Set sideChannel, + required final String prefix, + required final EncodeConfig config, }) { if (undefined || config.encoder != null || @@ -530,10 +533,10 @@ extension _$Encode on QS { /// uses [KeyPathNode.fromMaterialized], which creates a fresh depth-1 root /// without sharing ancestor nodes, so incremental path caching is not reused. static KeyPathNode _buildSequenceChildPath( - KeyPathNode adjustedPath, - String encodedKey, - ListFormatGenerator generator, { - required String Function(String encodedKey) bracketSegment, + final KeyPathNode adjustedPath, + final String encodedKey, + final ListFormatGenerator generator, { + required final String Function(String encodedKey) bracketSegment, }) => switch (generator) { ListFormatGenerator gen when identical(gen, _indicesGenerator) => @@ -547,11 +550,3 @@ extension _$Encode on QS { ), }; } - -// Internal marker used for synthetic "value" entries (for example comma-list -// joins) so traversal can distinguish sentinel payloads from normal keys. -final class _ValueSentinel { - const _ValueSentinel(this.value); - - final dynamic value; -} diff --git a/lib/src/extensions/extensions.dart b/lib/src/extensions/extensions.dart index 2e7dee6a..7a4ca7e9 100644 --- a/lib/src/extensions/extensions.dart +++ b/lib/src/extensions/extensions.dart @@ -1,19 +1,3 @@ -// Utilities mirroring small JavaScript conveniences used across the qs Dart port. -// -// - `IterableExtension.whereNotType()`: filters out elements of a given type -// while preserving order (useful when handling heterogeneous collections during -// parsing). -// - `ListExtension.slice(start, [end])`: JS-style `Array.prototype.slice` for -// lists. Supports negative indices, clamps to bounds, and never throws for -// out-of-range values. Returns a new list that references the same element -// objects (non-deep copy). -// - `StringExtension.slice(start, [end])`: JS-style `String.prototype.slice` -// for strings with the same semantics (negative indices and clamping). -// -// These helpers are intentionally tiny and non-mutating so the compiler can -// inline them; they keep call sites close to the semantics of the original -// Node `qs` implementation. - extension IterableExtension on Iterable { /// Returns a **lazy** [Iterable] view that filters out all elements of type [Q]. /// @@ -26,78 +10,5 @@ extension IterableExtension on Iterable { /// final it = xs.whereNotType(); /// // iterates as: 1, 2, null /// ``` - Iterable whereNotType() => where((T el) => el is! Q); -} - -extension ListExtension on List { - /// JS-style `Array.prototype.slice` for lists that never throws on bounds. - /// - /// * Supports negative indices for [start] and [end]. - /// * Both indices are clamped into `[0, length]`. - /// * Returns a **new** list containing references to the original elements - /// (no deep copy). - /// - /// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice - /// - /// Examples: - /// ```dart - /// ['a','b','c'].slice(1); // ['b','c'] - /// ['a','b','c'].slice(-2, -1); // ['b'] - /// ['a','b','c'].slice(0, 99); // ['a','b','c'] - /// ``` - List slice([int start = 0, int? end]) { - final int l = length; - int s = start < 0 ? l + start : start; - int e = end == null ? l : (end < 0 ? l + end : end); - - if (s < 0) { - s = 0; - } else if (s > l) { - s = l; - } - if (e < 0) { - e = 0; - } else if (e > l) { - e = l; - } - - if (e <= s) return []; - return sublist(s, e); - } -} - -extension StringExtension on String { - /// JS-style `String.prototype.slice` with negative indices and clamping. - /// - /// Behaves like JavaScript: negative [start]/[end] are offset from the end; - /// indices are clamped to `[0, length]`; the operation never throws for - /// out-of-range values. - /// - /// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/slice - /// - /// Examples: - /// ```dart - /// 'hello'.slice(1); // 'ello' - /// 'hello'.slice(-2); // 'lo' - /// 'hello'.slice(0, 99); // 'hello' - /// ``` - String slice(int start, [int? end]) { - final int l = length; - int s = start < 0 ? l + start : start; - int e = end == null ? l : (end < 0 ? l + end : end); - - if (s < 0) { - s = 0; - } else if (s > l) { - s = l; - } - if (e < 0) { - e = 0; - } else if (e > l) { - e = l; - } - - if (e <= s) return ''; - return substring(s, e); - } + Iterable whereNotType() => where((final T el) => el is! Q); } diff --git a/lib/src/methods.dart b/lib/src/methods.dart index 594218a5..c2f22b63 100644 --- a/lib/src/methods.dart +++ b/lib/src/methods.dart @@ -29,7 +29,7 @@ import 'package:qs_dart/src/qs.dart'; /// final m2 = decode('a[0]=x&a[1]=y'); // => {'a': ['x', 'y']} /// final m3 = decode(Uri.parse('https://x?x=1')); // => {'x': '1'} /// ``` -Map decode(dynamic input, [DecodeOptions? options]) => +Map decode(final dynamic input, [DecodeOptions? options]) => QS.decode(input, options); /// Encode a Dart object into a query string (convenience for [QS.encode]). @@ -48,5 +48,5 @@ Map decode(dynamic input, [DecodeOptions? options]) => /// final s2 = encode({'a': ['x', 'y']}); // 'a[0]=x&a[1]=y' /// final s3 = encode({'user': {'id': 1, 'name': 'A'}}); // 'user[id]=1&user[name]=A' /// ``` -String encode(Object? object, [EncodeOptions? options]) => +String encode(final Object? object, [EncodeOptions? options]) => QS.encode(object, options); diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index 3183b152..fc108d85 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -216,9 +216,9 @@ final class DecodeOptions with EquatableMixin { /// 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 String? value, { + final Encoding? charset, + final DecodeKind kind = DecodeKind.value, }) { // Validate here to cover direct decodeKey/decodeValue usage; cached via Expando. validate(); @@ -233,8 +233,8 @@ final class DecodeOptions with EquatableMixin { /// Convenience: decode a key and coerce the result to String (or null). String? decodeKey( - String? value, { - Encoding? charset, + final String? value, { + final Encoding? charset, }) => decode( value, @@ -244,8 +244,8 @@ final class DecodeOptions with EquatableMixin { /// Convenience: decode a value token. dynamic decodeValue( - String? value, { - Encoding? charset, + final String? value, { + final Encoding? charset, }) => decode( value, @@ -256,33 +256,33 @@ final class DecodeOptions with EquatableMixin { /// **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, + final String? value, { + final Encoding? charset, + final DecodeKind kind = DecodeKind.value, }) => decode(value, charset: charset, kind: kind); /// Return a new [DecodeOptions] with the provided overrides. DecodeOptions copyWith({ - bool? allowDots, - bool? allowEmptyLists, - int? listLimit, - Encoding? charset, - bool? charsetSentinel, - bool? comma, - bool? decodeDotInKeys, - Pattern? delimiter, - int? depth, - Duplicates? duplicates, - bool? ignoreQueryPrefix, - bool? interpretNumericEntities, - num? parameterLimit, - bool? parseLists, - bool? strictNullHandling, - bool? strictDepth, - bool? throwOnLimitExceeded, - Decoder? decoder, - LegacyDecoder? legacyDecoder, + final bool? allowDots, + final bool? allowEmptyLists, + final int? listLimit, + final Encoding? charset, + final bool? charsetSentinel, + final bool? comma, + final bool? decodeDotInKeys, + final Pattern? delimiter, + final int? depth, + final Duplicates? duplicates, + final bool? ignoreQueryPrefix, + final bool? interpretNumericEntities, + final num? parameterLimit, + final bool? parseLists, + final bool? strictNullHandling, + final bool? strictDepth, + final bool? throwOnLimitExceeded, + final Decoder? decoder, + final LegacyDecoder? legacyDecoder, }) => DecodeOptions( allowDots: allowDots ?? this.allowDots, diff --git a/lib/src/models/encode_config.dart b/lib/src/models/encode_config.dart index f26af9e1..60fdf941 100644 --- a/lib/src/models/encode_config.dart +++ b/lib/src/models/encode_config.dart @@ -48,22 +48,22 @@ final class EncodeConfig with EquatableMixin { final Encoding charset; EncodeConfig copyWith({ - ListFormatGenerator? generateArrayPrefix, - bool? commaRoundTrip, - bool? commaCompactNulls, - bool? allowEmptyLists, - bool? strictNullHandling, - bool? skipNulls, - bool? encodeDotInKeys, - Object? encoder = _notSet, - Object? serializeDate = _notSet, - Object? sort = _notSet, - Object? filter = _notSet, - bool? allowDots, - Format? format, - Formatter? formatter, - bool? encodeValuesOnly, - Encoding? charset, + final ListFormatGenerator? generateArrayPrefix, + final bool? commaRoundTrip, + final bool? commaCompactNulls, + final bool? allowEmptyLists, + final bool? strictNullHandling, + final bool? skipNulls, + final bool? encodeDotInKeys, + final Object? encoder = _notSet, + final Object? serializeDate = _notSet, + final Object? sort = _notSet, + final Object? filter = _notSet, + final bool? allowDots, + final Format? format, + final Formatter? formatter, + final bool? encodeValuesOnly, + final Encoding? charset, }) { final nextGenerateArrayPrefix = generateArrayPrefix ?? this.generateArrayPrefix; @@ -127,7 +127,7 @@ final class EncodeConfig with EquatableMixin { ); } - EncodeConfig withEncoder(Encoder? value) => copyWith(encoder: value); + EncodeConfig withEncoder(final Encoder? value) => copyWith(encoder: value); @override List get props => [ diff --git a/lib/src/models/encode_options.dart b/lib/src/models/encode_options.dart index ae8195f8..09594021 100644 --- a/lib/src/models/encode_options.dart +++ b/lib/src/models/encode_options.dart @@ -135,7 +135,11 @@ final class EncodeOptions with EquatableMixin { /// Encodes a [value] to a [String]. /// /// Uses the provided [encoder] if available, otherwise uses [Utils.encode]. - String encoder(dynamic value, {Encoding? charset, Format? format}) => + String encoder( + final dynamic value, { + final Encoding? charset, + final Format? format, + }) => _encoder?.call( value, charset: charset ?? this.charset, @@ -151,7 +155,7 @@ final class EncodeOptions with EquatableMixin { /// /// Uses the provided [serializeDate] function if available, otherwise uses /// [DateTime.toIso8601String]. - String serializeDate(DateTime date) => _serializeDate != null + String serializeDate(final DateTime date) => _serializeDate != null ? _serializeDate!.call(date) : date.toIso8601String(); @@ -178,25 +182,25 @@ final class EncodeOptions with EquatableMixin { /// Returns a new [EncodeOptions] instance with updated values. EncodeOptions copyWith({ - bool? addQueryPrefix, - bool? allowDots, - bool? allowEmptyLists, - ListFormat? listFormat, - Encoding? charset, - bool? charsetSentinel, - String? delimiter, - bool? encode, - bool? encodeDotInKeys, - bool? encodeValuesOnly, - Format? format, - bool? skipNulls, - bool? strictNullHandling, - bool? commaRoundTrip, - bool? commaCompactNulls, - Sorter? sort, - dynamic filter, - DateSerializer? serializeDate, - Encoder? encoder, + final bool? addQueryPrefix, + final bool? allowDots, + final bool? allowEmptyLists, + final ListFormat? listFormat, + final Encoding? charset, + final bool? charsetSentinel, + final String? delimiter, + final bool? encode, + final bool? encodeDotInKeys, + final bool? encodeValuesOnly, + final Format? format, + final bool? skipNulls, + final bool? strictNullHandling, + final bool? commaRoundTrip, + final bool? commaCompactNulls, + final Sorter? sort, + final dynamic filter, + final DateSerializer? serializeDate, + final Encoder? encoder, }) => EncodeOptions( addQueryPrefix: addQueryPrefix ?? this.addQueryPrefix, diff --git a/lib/src/models/key_path_node.dart b/lib/src/models/key_path_node.dart index 3bfd5d28..48b56764 100644 --- a/lib/src/models/key_path_node.dart +++ b/lib/src/models/key_path_node.dart @@ -22,10 +22,10 @@ final class KeyPathNode { KeyPathNode? _dotEncoded; String? _materialized; - static KeyPathNode fromMaterialized(String value) => + static KeyPathNode fromMaterialized(final String value) => KeyPathNode._(null, value); - KeyPathNode append(String segment) => + KeyPathNode append(final String segment) => segment.isEmpty ? this : KeyPathNode._(this, segment); /// Returns a cached view with every literal dot replaced by `%2E`. @@ -146,6 +146,6 @@ final class KeyPathNode { return _materialized!; } - static String _replaceDots(String value) => + static String _replaceDots(final String value) => value.contains('.') ? value.replaceAll('.', '%2E') : value; } diff --git a/lib/src/models/structured_key_scan.dart b/lib/src/models/structured_key_scan.dart new file mode 100644 index 00000000..e5660d0b --- /dev/null +++ b/lib/src/models/structured_key_scan.dart @@ -0,0 +1,27 @@ +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart' show internal; + +@internal +final class StructuredKeyScan with EquatableMixin { + const StructuredKeyScan({ + required this.hasAnyStructuredSyntax, + required this.structuredRoots, + required this.structuredKeys, + }); + + const StructuredKeyScan.empty() + : hasAnyStructuredSyntax = false, + structuredRoots = const {}, + structuredKeys = const {}; + + final bool hasAnyStructuredSyntax; + final Set structuredRoots; + final Set structuredKeys; + + @override + List get props => [ + hasAnyStructuredSyntax, + structuredRoots, + structuredKeys, + ]; +} diff --git a/lib/src/models/value_sentinel.dart b/lib/src/models/value_sentinel.dart new file mode 100644 index 00000000..d68063f1 --- /dev/null +++ b/lib/src/models/value_sentinel.dart @@ -0,0 +1,14 @@ +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +// Internal marker used for synthetic "value" entries (for example comma-list +// joins) so traversal can distinguish sentinel payloads from normal keys. +@internal +final class ValueSentinel with EquatableMixin { + const ValueSentinel(this.value); + + final dynamic value; + + @override + List get props => [value]; +} diff --git a/lib/src/qs.dart b/lib/src/qs.dart index 22ac6839..1fd2ebfe 100644 --- a/lib/src/qs.dart +++ b/lib/src/qs.dart @@ -7,18 +7,16 @@ import 'package:qs_dart/src/enums/encode_phase.dart'; import 'package:qs_dart/src/enums/format.dart'; import 'package:qs_dart/src/enums/list_format.dart'; import 'package:qs_dart/src/enums/sentinel.dart'; -import 'package:qs_dart/src/extensions/extensions.dart'; import 'package:qs_dart/src/models/decode_options.dart'; import 'package:qs_dart/src/models/encode_config.dart'; import 'package:qs_dart/src/models/encode_frame.dart'; import 'package:qs_dart/src/models/encode_options.dart'; import 'package:qs_dart/src/models/key_path_node.dart'; +import 'package:qs_dart/src/models/structured_key_scan.dart'; import 'package:qs_dart/src/models/undefined.dart'; +import 'package:qs_dart/src/models/value_sentinel.dart'; import 'package:qs_dart/src/utils.dart'; -// Re-export for public API: consumers can `import 'package:qs_dart/qs.dart'` and access DecodeKind -export 'package:qs_dart/src/enums/decode_kind.dart'; - part 'extensions/decode.dart'; part 'extensions/encode.dart'; @@ -46,7 +44,10 @@ final class QS { /// /// See [DecodeOptions] for delimiter, nesting depth, numeric-entity handling, /// duplicates policy, and other knobs. - static Map decode(dynamic input, [DecodeOptions? options]) { + static Map decode( + final dynamic input, [ + DecodeOptions? options, + ]) { options ??= const DecodeOptions(); // Default to the library's safe, Node-`qs` compatible settings. options.validate(); @@ -69,14 +70,33 @@ final class QS { ? _$Decode._parseQueryStringValues(input, options) : input; + final bool decodeFromString = input is String; + final StructuredKeyScan structuredKeyScan = + decodeFromString && (tempObj?.isNotEmpty ?? false) + ? _$Decode._scanStructuredKeys(tempObj!, options) + : const StructuredKeyScan.empty(); + + if (decodeFromString && !structuredKeyScan.hasAnyStructuredSyntax) { + return Utils.compact(tempObj!); + } + Map obj = {}; // Merge each parsed key into the accumulator using the same rules as Node `qs`. // Iterate over the keys and setup the new object if (tempObj?.isNotEmpty ?? false) { for (final MapEntry entry in tempObj!.entries) { + if (decodeFromString) { + final String key = entry.key; + if (!structuredKeyScan.structuredKeys.contains(key) && + !structuredKeyScan.structuredRoots.contains(key)) { + obj[entry.key] = entry.value; + continue; + } + } + final parsed = _$Decode._parseKeys( - entry.key, entry.value, options, input is String); + entry.key, entry.value, options, decodeFromString); if (obj.isEmpty && parsed is Map) { obj = parsed; // direct assignment – no merge needed @@ -104,7 +124,7 @@ final class QS { /// control the leading `?` and sentinel token emission. /// /// See [EncodeOptions] for details about list formats, output format, and hooks. - static String encode(Object? object, [EncodeOptions? options]) { + static String encode(final Object? object, [EncodeOptions? options]) { options ??= const EncodeOptions(); // Use default encoding settings unless overridden by the caller. options.validate(); diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 1defe056..9e13a444 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -37,8 +37,8 @@ final class Utils { @internal @visibleForTesting static Map markOverflow( - Map obj, - int maxIndex, + final Map obj, + final int maxIndex, ) { _overflowIndex[obj] = maxIndex; return obj; @@ -46,19 +46,19 @@ final class Utils { /// Returns `true` if the given object is marked as an overflow container. @internal - static bool isOverflow(dynamic obj) => + static bool isOverflow(final dynamic obj) => obj is Map && _overflowIndex[obj] != null; /// Returns the tracked max numeric index for an overflow map, or -1 if absent. - static int _getOverflowIndex(Map obj) => _overflowIndex[obj] ?? -1; + static int _getOverflowIndex(final Map obj) => _overflowIndex[obj] ?? -1; /// Updates the tracked max numeric index for an overflow map. - static void _setOverflowIndex(Map obj, int maxIndex) { + static void _setOverflowIndex(final Map obj, final int maxIndex) { _overflowIndex[obj] = maxIndex; } /// Returns the larger of the current max and the parsed numeric key (if any). - static int _updateOverflowMax(int current, String key) { + static int _updateOverflowMax(final int current, final String key) { final int? parsed = int.tryParse(key); if (parsed == null || parsed < 0) { return current; @@ -90,9 +90,9 @@ final class Utils { /// This mirrors the behavior of the original Node.js `qs` merge routine, /// including treatment of `Undefined` sentinels. static dynamic merge( - dynamic target, - dynamic source, [ - DecodeOptions? options = const DecodeOptions(), + final dynamic target, + final dynamic source, [ + final DecodeOptions? options = const DecodeOptions(), ]) { late dynamic result; final List stack = [ @@ -100,7 +100,7 @@ final class Utils { target: target, source: source, options: options, - onResult: (dynamic value) => result = value, + onResult: (final dynamic value) => result = value, ), ]; @@ -139,7 +139,7 @@ final class Utils { bool sourceHasMap = false; if (sourceIsIterable) { sourceMaps = true; - for (final el in currentSource) { + for (final dynamic el in currentSource) { if (el is Undefined) { continue; } @@ -170,7 +170,7 @@ final class Utils { } if (frame.options?.parseLists == false && - target_.values.any((el) => el is Undefined)) { + target_.values.any((final el) => el is Undefined)) { final Map normalized = { for (final MapEntry entry in target_.entries) if (entry.value is! Undefined) @@ -380,7 +380,7 @@ final class Utils { if (frame.listIndex >= frame.sourceList!.length) { if (frame.options?.parseLists == false && - frame.indexedTarget!.values.any((el) => el is Undefined)) { + frame.indexedTarget!.values.any((final el) => el is Undefined)) { final Map normalized = { for (final MapEntry entry in frame.indexedTarget!.entries) @@ -432,7 +432,7 @@ final class Utils { } /// Converts an iterable to a zero-indexed [SplayTreeMap]. - static SplayTreeMap _toIndexedTreeMap(Iterable iterable) { + static SplayTreeMap _toIndexedTreeMap(final Iterable iterable) { final SplayTreeMap map = SplayTreeMap(); int i = 0; for (final v in iterable) { @@ -451,7 +451,10 @@ final class Utils { @internal @visibleForTesting @Deprecated('Use Uri.encodeComponent instead') - static String escape(String str, {Format? format = Format.rfc3986}) { + static String escape( + final String str, { + final Format? format = Format.rfc3986, + }) { final StringBuffer buffer = StringBuffer(); for (int i = 0; i < str.length; ++i) { @@ -499,7 +502,7 @@ final class Utils { @internal @visibleForTesting @Deprecated('Use Uri.decodeComponent instead') - static String unescape(String str) { + static String unescape(final String str) { if (!str.contains('%')) return str; final StringBuffer buffer = StringBuffer(); int i = 0; @@ -581,9 +584,9 @@ final class Utils { /// /// Note: Higher-level encoders are responsible for key assembly and joining. static String encode( - dynamic value, { - Encoding charset = utf8, - Format? format = Format.rfc3986, + final dynamic value, { + final Encoding charset = utf8, + final Format? format = Format.rfc3986, }) { if (charset != utf8 && charset != latin1) { throw ArgumentError.value( @@ -618,7 +621,7 @@ final class Utils { if (charset == latin1) { return Utils.escape(str!, format: format).replaceAllMapped( RegExp(r'%u[0-9a-f]{4}', caseSensitive: false), - (Match match) => + (final Match match) => '%26%23${int.parse(match.group(0)!.substring(2), radix: 16)}%3B', ); } @@ -650,7 +653,10 @@ final class Utils { } static void _writeEncodedSegment( - String segment, StringBuffer buffer, Format? format) { + final String segment, + final StringBuffer buffer, + final Format? format, + ) { for (int i = 0; i < segment.length; ++i) { int c = segment.codeUnitAt(i); @@ -728,7 +734,7 @@ final class Utils { } /// Fast latin1 percent-decoder - static String _decodeLatin1Percent(String s) { + static String _decodeLatin1Percent(final String s) { final StringBuffer sb = StringBuffer(); for (int i = 0; i < s.length; i++) { final int ch = s.codeUnitAt(i); @@ -746,7 +752,8 @@ final class Utils { return sb.toString(); } - static int _hexVal(int cu) { + /// Returns the numeric value of a hex digit character code, or -1 if invalid. + static int _hexVal(final int cu) { if (cu >= 0x30 && cu <= 0x39) return cu - 0x30; // '0'..'9' if (cu >= 0x41 && cu <= 0x46) return cu - 0x41 + 10; // 'A'..'F' if (cu >= 0x61 && cu <= 0x66) return cu - 0x61 + 10; // 'a'..'f' @@ -762,22 +769,27 @@ final class Utils { /// to returning the input unchanged (after `'+'` handling). /// /// Returns `null` if `str` is `null`. - static String? decode(String? str, {Encoding? charset = utf8}) { - final String? strWithoutPlus = str?.replaceAll('+', ' '); + static String? decode(final String? str, {final Encoding? charset = utf8}) { + if (str == null) return null; + + final bool hasPlus = str.contains('+'); + final bool hasPercent = str.contains('%'); + if (!hasPlus && !hasPercent) return str; + + final String strWithoutPlus = hasPlus ? str.replaceAll('+', ' ') : str; if (charset == latin1) { - final String? s = strWithoutPlus; - if (s == null) return null; - if (!s.contains('%')) return s; // fast path: nothing to decode + if (!hasPercent) return strWithoutPlus; try { - return _decodeLatin1Percent(s); + return _decodeLatin1Percent(strWithoutPlus); } catch (_) { - return s; + return strWithoutPlus; } } + + if (!hasPercent) return strWithoutPlus; + try { - return strWithoutPlus != null - ? Uri.decodeComponent(strWithoutPlus) - : null; + return Uri.decodeComponent(strWithoutPlus); } catch (_) { return strWithoutPlus; } @@ -792,7 +804,7 @@ final class Utils { /// when calling with shared objects because this mutates them. /// /// Returns the same `root` instance for chaining. - static Map compact(Map root) { + static Map compact(final Map root) { final List stack = [root]; // Identity-based visited set: ensures each concrete object is processed once @@ -844,11 +856,15 @@ final class Utils { /// combine([1,2], 3); // [1,2,3] /// combine('a', ['b','c']); // ['a','b','c'] /// ``` - static dynamic combine(dynamic a, dynamic b, {int? listLimit}) { + static dynamic combine( + final dynamic a, + final dynamic b, { + final int? listLimit, + }) { if (isOverflow(a)) { int newIndex = _getOverflowIndex(a); if (b is Iterable) { - for (final item in b) { + for (final dynamic item in b) { newIndex++; a[newIndex.toString()] = item; } @@ -876,7 +892,7 @@ final class Utils { /// Applies `fn` to a scalar or maps it over an iterable, returning the result. /// /// Handy when a caller may pass a single value or a collection. - static dynamic apply(dynamic val, T Function(T) fn) => + static dynamic apply(dynamic val, final T Function(T) fn) => val is Iterable ? val.map((item) => fn(item)) : fn(val); /// Returns `true` if `val` is a scalar we should encode as-is. @@ -887,7 +903,10 @@ final class Utils { /// `Future`, [Undefined]) return `false`. /// /// When `skipNulls == true`, empty strings and empty `Uri.toString()` return `false`. - static bool isNonNullishPrimitive(dynamic val, [bool skipNulls = false]) { + static bool isNonNullishPrimitive( + final dynamic val, [ + final bool skipNulls = false, + ]) { if (val is String) { return skipNulls ? val.isNotEmpty : true; } @@ -924,7 +943,7 @@ final class Utils { /// /// Treats `null`, [Undefined], empty strings, empty iterables and empty maps /// as “empty”. - static bool isEmpty(dynamic val) => + static bool isEmpty(final dynamic val) => val == null || val is Undefined || (val is String && val.isEmpty) || @@ -936,7 +955,7 @@ final class Utils { /// - Only decimal entities are recognized. /// - Gracefully leaves malformed/partial sequences untouched. /// - Produces surrogate pairs for code points > `0xFFFF`. - static String interpretNumericEntities(String s) { + static String interpretNumericEntities(final String s) { if (s.length < 4) return s; if (!s.contains('&#')) return s; final StringBuffer sb = StringBuffer(); @@ -985,7 +1004,7 @@ final class Utils { } /// Create an index-keyed map from an iterable. - static Map createIndexMap(Iterable iterable) { + static Map createIndexMap(final Iterable iterable) { if (iterable is List) { final list = iterable; final map = {}; diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index be2b73ab..088445d8 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:euc/jis.dart'; +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:qs_dart/src/qs.dart'; @@ -11,6 +12,20 @@ import 'package:test/test.dart'; import '../fixtures/data/empty_test_cases.dart'; +final class _CustomDelimiter implements Pattern { + const _CustomDelimiter(this.value); + + final String value; + + @override + Iterable allMatches(String string, [int start = 0]) => + RegExp(RegExp.escape(value)).allMatches(string, start); + + @override + Match? matchAsPrefix(String string, [int start = 0]) => + RegExp(RegExp.escape(value)).matchAsPrefix(string, start); +} + void main() { group('decode', () { test('throws ArgumentError when parameter limit is not positive', () { @@ -154,6 +169,136 @@ void main() { ); }); + test('preserves flat query behavior for non-structured keys', () { + expect( + QS.decode('k0=v0&k1=v1&k2=v2'), + equals({'k0': 'v0', 'k1': 'v1', 'k2': 'v2'}), + ); + }); + + test('preserves flat query behavior with comma and duplicate keys', () { + expect( + QS.decode('a=1,2&a=3', const DecodeOptions(comma: true)), + equals({ + 'a': ['1', '2', '3'] + }), + ); + }); + + test('preserves flat query behavior with charset sentinel enabled', () { + expect( + QS.decode( + 'utf8=%E2%9C%93&k0=v0&k1=v1', + const DecodeOptions(charsetSentinel: true, charset: latin1), + ), + equals({'k0': 'v0', 'k1': 'v1'}), + ); + }); + + test('preserves mixed flat and structured key merge behavior', () { + expect( + QS.decode('a=1&a[b]=2'), + equals({ + 'a': [ + '1', + {'b': '2'} + ] + }), + ); + expect( + QS.decode('a[b]=2&a=1'), + equals({ + 'a': {'b': '2'} + }), + ); + }); + + test('ignores empty segments and preserves empty key behavior', () { + expect(QS.decode('a=b&&c=d'), equals({'a': 'b', 'c': 'd'})); + expect(QS.decode('a=b&'), equals({'a': 'b'})); + expect(QS.decode('=a'), equals({})); + expect(QS.decode('a=b&=c&d=e'), equals({'a': 'b', 'd': 'e'})); + }); + + test('throws when delimiter is an empty string', () { + expect( + () => QS.decode('a=b&c=d', const DecodeOptions(delimiter: '')), + throwsArgumentError, + ); + }); + + test('parameter limit counts only non-empty segments', () { + expect( + QS.decode( + 'a=1&&b=2', + const DecodeOptions(parameterLimit: 2, throwOnLimitExceeded: true), + ), + equals({'a': '1', 'b': '2'}), + ); + expect( + () => QS.decode( + 'a=1&&b=2&&c=3', + const DecodeOptions(parameterLimit: 2, throwOnLimitExceeded: true), + ), + throwsA(isA()), + ); + }); + + test('comma strict mode with negative listLimit throws disabled message', + () { + expect( + () => QS.decode( + 'a=1,2', + const DecodeOptions( + comma: true, + listLimit: -1, + throwOnLimitExceeded: true, + ), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('List parsing is disabled'), + ), + ), + ); + }); + + test('structured root detection handles mixed-case encoded dots in one key', + () { + expect( + QS.decode( + 'a%2Eb%2ec=1', + const DecodeOptions(allowDots: true, decodeDotInKeys: false), + ), + equals({ + 'a': { + 'b': {'c': '1'} + } + }), + ); + }); + + test( + 'structured root detection scans both %2E and %2e when keys stay encoded', + () { + final options = DecodeOptions( + allowDots: true, + decoder: (String? value, {Encoding? charset, DecodeKind? kind}) => + value, + ); + + expect( + QS.decode('a%2Eb%2ec=1&a=2', options), + equals({ + 'a%2Eb%2ec': '1', + 'a': '2', + }), + ); + }, + ); + test('comma: false', () { expect( QS.decode('a[]=b&a[]=c'), @@ -701,6 +846,26 @@ void main() { ); }); + test('does not treat []= inside values as list-growth syntax', () { + const expected = { + 'a': {'b': 'x[]=y'} + }; + expect( + QS.decode('a[b]=x[]=y'), + equals(expected), + ); + expect( + QS.decode( + 'a[b]=x[]=y', + const DecodeOptions( + listLimit: 0, + throwOnLimitExceeded: true, + ), + ), + equals(expected), + ); + }); + test('allows empty values', () { expect(QS.decode(''), equals({})); expect(QS.decode(null), equals({})); @@ -1071,6 +1236,66 @@ void main() { ); }); + test('parses a string with a custom Pattern delimiter', () { + expect( + QS.decode( + 'a=b;c=d', + const DecodeOptions(delimiter: _CustomDelimiter(';')), + ), + equals({'a': 'b', 'c': 'd'}), + ); + }); + + test('enforces parameter limit with RegExp delimiter', () { + expect( + () => QS.decode( + 'a=1;;b=2;;c=3', + DecodeOptions( + delimiter: RegExp(r';+'), + parameterLimit: 2, + throwOnLimitExceeded: true, + ), + ), + throwsA(isA()), + ); + + expect( + QS.decode( + 'a=1;;b=2;;c=3', + DecodeOptions( + delimiter: RegExp(r';+'), + parameterLimit: 2, + ), + ), + equals({'a': '1', 'b': '2'}), + ); + }); + + test('enforces parameter limit with custom Pattern delimiter', () { + expect( + () => QS.decode( + 'a=1;b=2;c=3', + const DecodeOptions( + delimiter: _CustomDelimiter(';'), + parameterLimit: 2, + throwOnLimitExceeded: true, + ), + ), + throwsA(isA()), + ); + + expect( + QS.decode( + 'a=1;b=2;c=3', + const DecodeOptions( + delimiter: _CustomDelimiter(';'), + parameterLimit: 2, + ), + ), + equals({'a': '1', 'b': '2'}), + ); + }); + test('allows overriding parameter limit', () { expect( QS.decode('a=b&c=d', const DecodeOptions(parameterLimit: 1)), @@ -1959,6 +2184,86 @@ void main() { }); group('list limit tests', () { + test('map input root [] enforces strict list limit checks', () { + expect( + () => QS.decode( + {'[]': 'a'}, + const DecodeOptions(listLimit: 0, throwOnLimitExceeded: true), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('List limit exceeded'), + ), + ), + ); + }); + + test('map input root [] enforces negative listLimit strict mode', () { + expect( + () => QS.decode( + {'[]': 'a'}, + const DecodeOptions(listLimit: -1, throwOnLimitExceeded: true), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('List parsing is disabled'), + ), + ), + ); + }); + + test('strict list limit applies when duplicate scalar grows into a list', + () { + expect( + () => QS.decode( + 'a=1&a=2', + const DecodeOptions(listLimit: 1, throwOnLimitExceeded: true), + ), + throwsA(isA()), + ); + }); + + test( + 'strict list limit applies when duplicate scalar grows into a comma list', + () { + expect( + () => QS.decode( + 'a=1&a=2,3', + const DecodeOptions( + comma: true, + listLimit: 1, + throwOnLimitExceeded: true, + ), + ), + throwsA(isA()), + ); + }, + ); + + test('negative list limit does not throw for scalar values', () { + expect( + QS.decode( + 'a=1', + const DecodeOptions(listLimit: -1, throwOnLimitExceeded: true), + ), + equals({'a': '1'}), + ); + }); + + test('negative list limit throws on first [] push in strict mode', () { + expect( + () => QS.decode( + 'a[]=1', + const DecodeOptions(listLimit: -1, throwOnLimitExceeded: true), + ), + throwsA(isA()), + ); + }); + test('does not throw error when list is within limit', () { expect( QS.decode( diff --git a/test/unit/extensions/extensions_test.dart b/test/unit/extensions/extensions_test.dart index d776dcb3..3868a001 100644 --- a/test/unit/extensions/extensions_test.dart +++ b/test/unit/extensions/extensions_test.dart @@ -11,40 +11,4 @@ void main() { expect(result, [1, 2, 4, 5]); }); }); - - group('ListExtension', () { - test('whereNotUndefined', () { - const List list = [1, 2, Undefined(), 4, 5]; - final List result = list.whereNotType().toList(); - expect(result, isA>()); - expect(result, [1, 2, 4, 5]); - }); - - test('slice', () { - const List animals = [ - 'ant', - 'bison', - 'camel', - 'duck', - 'elephant', - ]; - expect(animals.slice(2), ['camel', 'duck', 'elephant']); - expect(animals.slice(2, 4), ['camel', 'duck']); - expect(animals.slice(1, 5), ['bison', 'camel', 'duck', 'elephant']); - expect(animals.slice(-2), ['duck', 'elephant']); - expect(animals.slice(2, -1), ['camel', 'duck']); - expect(animals.slice(), ['ant', 'bison', 'camel', 'duck', 'elephant']); - }); - }); - - group('StringExtensions', () { - test('slice', () { - const String str = 'The quick brown fox jumps over the lazy dog.'; - expect(str.slice(31), 'the lazy dog.'); - expect(str.slice(31, 1999), 'the lazy dog.'); - expect(str.slice(4, 19), 'quick brown fox'); - expect(str.slice(-4), 'dog.'); - expect(str.slice(-9, -5), 'lazy'); - }); - }); } diff --git a/test/unit/models/decode_options_test.dart b/test/unit/models/decode_options_test.dart index 3d293f7c..8f9713e2 100644 --- a/test/unit/models/decode_options_test.dart +++ b/test/unit/models/decode_options_test.dart @@ -1,6 +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:qs_dart/src/qs.dart'; diff --git a/test/unit/uri_extension_test.dart b/test/unit/uri_extension_test.dart index c34ba413..0012dd38 100644 --- a/test/unit/uri_extension_test.dart +++ b/test/unit/uri_extension_test.dart @@ -3,6 +3,7 @@ import 'dart:convert' show Encoding, latin1, utf8; import 'dart:typed_data' show Uint8List; import 'package:euc/jis.dart'; +import 'package:qs_dart/src/enums/decode_kind.dart'; import 'package:qs_dart/src/models/decode_options.dart'; import 'package:qs_dart/src/qs.dart'; import 'package:qs_dart/src/uri.dart'; diff --git a/tool/decode_perf_snapshot.dart b/tool/decode_perf_snapshot.dart new file mode 100644 index 00000000..a26e62be --- /dev/null +++ b/tool/decode_perf_snapshot.dart @@ -0,0 +1,156 @@ +import 'package:qs_dart/qs_dart.dart'; + +const int _warmupSamples = 5; +const int _measurementSamples = 7; + +const List<_DecodeCase> _cases = <_DecodeCase>[ + _DecodeCase( + name: 'C1', + count: 100, + comma: false, + utf8Sentinel: false, + valueLen: 8, + iterations: 120, + ), + _DecodeCase( + name: 'C2', + count: 1000, + comma: false, + utf8Sentinel: false, + valueLen: 40, + iterations: 16, + ), + _DecodeCase( + name: 'C3', + count: 1000, + comma: true, + utf8Sentinel: true, + valueLen: 40, + iterations: 16, + ), +]; + +final class _DecodeCase { + const _DecodeCase({ + required this.name, + required this.count, + required this.comma, + required this.utf8Sentinel, + required this.valueLen, + required this.iterations, + }); + + final String name; + final int count; + final bool comma; + final bool utf8Sentinel; + final int valueLen; + final int iterations; +} + +String _makeValue(int length, int seed) { + final StringBuffer out = StringBuffer(); + int state = (seed * 2654435761 + 1013904223) & 0xFFFFFFFF; + + for (int i = 0; i < length; i++) { + state ^= (state << 13) & 0xFFFFFFFF; + state ^= (state >> 17) & 0xFFFFFFFF; + state ^= (state << 5) & 0xFFFFFFFF; + + final int x = state % 62; + final int ch = switch (x) { + < 10 => 0x30 + x, + < 36 => 0x41 + (x - 10), + _ => 0x61 + (x - 36), + }; + out.writeCharCode(ch); + } + + return out.toString(); +} + +String _buildQuery({ + required int count, + required bool commaLists, + required bool utf8Sentinel, + required int valueLen, +}) { + final StringBuffer sb = StringBuffer(); + bool first = true; + + if (utf8Sentinel) { + sb.write('utf8=%E2%9C%93'); + first = false; + } + + for (int i = 0; i < count; i++) { + if (!first) sb.write('&'); + first = false; + + final String key = 'k$i'; + final String value = + (commaLists && i % 10 == 0) ? 'a,b,c' : _makeValue(valueLen, i); + sb + ..write(key) + ..write('=') + ..write(value); + } + + return sb.toString(); +} + +double _median(List values) { + values.sort(); + return values[values.length ~/ 2]; +} + +void main() { + print('qs.dart decode perf snapshot (median of 7 samples)'); + print('Decode (public API):'); + + for (final _DecodeCase c in _cases) { + final String query = _buildQuery( + count: c.count, + commaLists: c.comma, + utf8Sentinel: c.utf8Sentinel, + valueLen: c.valueLen, + ); + + final DecodeOptions options = DecodeOptions( + comma: c.comma, + parseLists: true, + parameterLimit: double.infinity, + throwOnLimitExceeded: false, + interpretNumericEntities: false, + charsetSentinel: c.utf8Sentinel, + ignoreQueryPrefix: false, + ); + + for (int i = 0; i < _warmupSamples; i++) { + QS.decode(query, options); + } + + final List samples = []; + int keyCount = 0; + + for (int s = 0; s < _measurementSamples; s++) { + final Stopwatch sw = Stopwatch()..start(); + Map decoded = const {}; + for (int i = 0; i < c.iterations; i++) { + decoded = QS.decode(query, options); + } + sw.stop(); + + keyCount = decoded.length; + samples.add(sw.elapsedMicroseconds / 1000.0 / c.iterations); + } + + print( + ' ${c.name}: count=${c.count.toString().padLeft(4)}, ' + 'comma=${c.comma.toString().padRight(5)}, ' + 'utf8=${c.utf8Sentinel.toString().padRight(5)}, ' + 'len=${c.valueLen.toString().padLeft(2)}: ' + '${_median(samples).toStringAsFixed(3).padLeft(7)} ms/op | keys=$keyCount', + ); + } +}