diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index 410ba26..764686c 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -49,7 +49,8 @@ extension _$Decode on QS { // Fast-path: split comma-separated scalars into a list when requested. if (val is String && val.isNotEmpty && options.comma && val.contains(',')) { final List splitVal = val.split(','); - if (options.throwOnLimitExceeded && splitVal.length > options.listLimit) { + if (options.throwOnLimitExceeded && + currentListLength + splitVal.length > options.listLimit) { throw RangeError( 'List limit exceeded. ' 'Only ${options.listLimit} element${options.listLimit == 1 ? '' : 's'} allowed in a list.', @@ -60,7 +61,7 @@ extension _$Decode on QS { // Guard incremental growth of an existing list as we parse additional items. if (options.throwOnLimitExceeded && - currentListLength >= options.listLimit) { + currentListLength + 1 > options.listLimit) { throw RangeError( 'List limit exceeded. ' 'Only ${options.listLimit} element${options.listLimit == 1 ? '' : 's'} allowed in a list.', @@ -94,19 +95,21 @@ extension _$Decode on QS { throw ArgumentError('Parameter limit must be a positive integer.'); } - // 3) Split by delimiter, respecting `parameterLimit` and whether we throw - // when the limit is exceeded. - final Iterable parts = limit != null && limit > 0 - ? cleanStr - .split(options.delimiter) - .take(options.throwOnLimitExceeded ? limit + 1 : limit) - : cleanStr.split(options.delimiter); - - // If we were asked to throw on overflow, detect it after the split/take. - if (options.throwOnLimitExceeded && limit != null && parts.length > limit) { - throw RangeError( - 'Parameter limit exceeded. Only $limit parameter${limit == 1 ? '' : 's'} allowed.', - ); + // 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) { + 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.', + ); + } + } else { + parts = allParts; } // Charset probing (utf8=✓ / utf8=X). Skip the sentinel pair later. @@ -118,10 +121,11 @@ extension _$Decode on QS { // 4) Scan once for a charset sentinel and adjust decoder charset accordingly. if (options.charsetSentinel) { for (i = 0; i < parts.length; ++i) { - if (parts.elementAt(i).startsWith('utf8=')) { - if (parts.elementAt(i) == Sentinel.charset.toString()) { + final String p = parts[i]; + if (p.startsWith('utf8=')) { + if (p == Sentinel.charset.toString()) { charset = utf8; - } else if (parts.elementAt(i) == Sentinel.iso.toString()) { + } else if (p == Sentinel.iso.toString()) { charset = latin1; } skipIndex = i; @@ -133,10 +137,8 @@ extension _$Decode on QS { // 5) Parse each `key=value` pair, honoring bracket-`]=` short-circuit for speed. final Map obj = {}; for (i = 0; i < parts.length; ++i) { - if (i == skipIndex) { - continue; - } - final String part = parts.elementAt(i); + if (i == skipIndex) continue; + final String part = parts[i]; final int bracketEqualsPos = part.indexOf(']='); final int pos = bracketEqualsPos == -1 ? part.indexOf('=') : bracketEqualsPos + 1; @@ -167,16 +169,16 @@ extension _$Decode on QS { !Utils.isEmpty(val) && options.interpretNumericEntities && charset == latin1) { - val = Utils.interpretNumericEntities( - val is Iterable - ? val.map((e) => e.toString()).join(',') - : val.toString(), - ); + if (val is Iterable) { + val = Utils.interpretNumericEntities(_joinIterableToCommaString(val)); + } else { + val = Utils.interpretNumericEntities(val.toString()); + } } // Quirk: a literal `[]=` suffix forces an array container (qs behavior). - if (part.contains('[]=')) { - val = val is Iterable ? [val] : val; + if (options.parseLists && part.contains('[]=')) { + val = [val]; } // Duplicate key policy: combine/first/last (default: combine). @@ -209,14 +211,20 @@ extension _$Decode on QS { // Determine the current list length if we are appending into `[]`. late final int currentListLength; - if (chain.isNotEmpty && chain.last == '[]') { - final int? parentKey = int.tryParse(chain.slice(0, -1).join('')); - - currentListLength = parentKey != null && - val is List && - val.firstWhereIndexedOrNull((int i, _) => i == parentKey) != null - ? val.elementAt(parentKey).length - : 0; + if (chain.length >= 2 && chain.last == '[]') { + final String prev = chain[chain.length - 2]; + final bool bracketed = prev.startsWith('[') && prev.endsWith(']'); + final int? parentIndex = + bracketed ? int.tryParse(prev.substring(1, prev.length - 1)) : null; + if (parentIndex != null && + parentIndex >= 0 && + val is List && + parentIndex < val.length) { + final dynamic parent = val[parentIndex]; + currentListLength = parent is List ? parent.length : 0; + } else { + currentListLength = 0; + } } else { currentListLength = 0; } @@ -431,4 +439,16 @@ extension _$Decode on QS { return sb.toString(); } + + /// Joins an iterable of objects into a comma-separated string. + static String _joinIterableToCommaString(Iterable it) { + final StringBuffer sb = StringBuffer(); + bool first = true; + for (final e in it) { + if (!first) sb.write(','); + sb.write(e == null ? '' : e.toString()); + first = false; + } + return sb.toString(); + } } diff --git a/lib/src/extensions/encode.dart b/lib/src/extensions/encode.dart index 34c4819..4f108dd 100644 --- a/lib/src/extensions/encode.dart +++ b/lib/src/extensions/encode.dart @@ -70,7 +70,8 @@ extension _$Encode on QS { }) { prefix ??= addQueryPrefix ? '?' : ''; generateArrayPrefix ??= ListFormat.indices.generator; - commaRoundTrip ??= generateArrayPrefix == ListFormat.comma.generator; + commaRoundTrip ??= + identical(generateArrayPrefix, ListFormat.comma.generator); formatter ??= format.formatter; dynamic obj = object; @@ -105,7 +106,7 @@ extension _$Encode on QS { null => obj.toIso8601String(), _ => serializeDate(obj), }; - } else if (generateArrayPrefix == ListFormat.comma.generator && + } else if (identical(generateArrayPrefix, ListFormat.comma.generator) && obj is Iterable) { obj = Utils.apply( obj, @@ -142,12 +143,24 @@ extension _$Encode on QS { return values; } + // Cache list form once for non-Map, non-String iterables to avoid repeated enumeration + List? seqList_; + final bool isSeq_ = obj is Iterable && obj is! String && obj is! Map; + if (isSeq_) { + if (obj is List) { + seqList_ = obj; + } else { + seqList_ = obj.toList(growable: false); + } + } + late final List objKeys; // Determine the set of keys/indices to traverse at this depth: // - For `.comma` lists we join values in-place. // - If `filter` is Iterable, it constrains the key set. // - Otherwise derive keys from Map/Iterable, and optionally sort them. - if (generateArrayPrefix == ListFormat.comma.generator && obj is Iterable) { + if (identical(generateArrayPrefix, ListFormat.comma.generator) && + obj is Iterable) { // we need to join elements in if (encodeValuesOnly && encoder != null) { obj = Utils.apply(obj, encoder); @@ -173,10 +186,10 @@ extension _$Encode on QS { late final Iterable keys; if (obj is Map) { keys = obj.keys; - } else if (obj is Iterable) { - keys = [for (int index = 0; index < obj.length; index++) index]; + } else if (seqList_ != null) { + keys = List.generate(seqList_.length, (i) => i, growable: false); } else { - keys = []; + keys = const []; } objKeys = sort != null ? (keys.toList()..sort(sort)) : keys.toList(); } @@ -188,12 +201,12 @@ extension _$Encode on QS { encodeDotInKeys ? prefix.replaceAll('.', '%2E') : prefix; final String adjustedPrefix = - commaRoundTrip && obj is Iterable && obj.length == 1 + (commaRoundTrip == true) && seqList_ != null && seqList_.length == 1 ? '$encodedPrefix[]' : encodedPrefix; // Emit `key[]` when an empty list is allowed, to preserve shape on round-trip. - if (allowEmptyLists && obj is Iterable && obj.isEmpty) { + if (allowEmptyLists && seqList_ != null && seqList_.isEmpty) { return '$adjustedPrefix[]'; } @@ -213,9 +226,15 @@ extension _$Encode on QS { if (obj is Map) { value = obj[key]; valueUndefined = !obj.containsKey(key); - } else if (obj is Iterable) { - value = obj.elementAt(key); - valueUndefined = false; + } else if (seqList_ != null) { + final int? idx = key is int ? key : int.tryParse(key.toString()); + if (idx != null && idx >= 0 && idx < seqList_.length) { + value = seqList_[idx]; + valueUndefined = false; + } else { + value = null; + valueUndefined = true; + } } else { // Best-effort dynamic indexer for user-defined classes that expose `operator []`. // If it throws (no indexer / wrong type), we fall through to the catch and mark undefined. @@ -237,9 +256,14 @@ extension _$Encode on QS { ? key.toString().replaceAll('.', '%2E') : key.toString(); - final String keyPrefix = obj is Iterable - ? generateArrayPrefix(adjustedPrefix, encodedKey) - : '$adjustedPrefix${allowDots ? '.$encodedKey' : '[$encodedKey]'}'; + final bool isCommaSentinel = + key is Map && key.containsKey('value'); + final String keyPrefix = (isCommaSentinel && + identical(generateArrayPrefix, ListFormat.comma.generator)) + ? adjustedPrefix + : (seqList_ != null + ? generateArrayPrefix(adjustedPrefix, encodedKey) + : '$adjustedPrefix${allowDots ? '.$encodedKey' : '[$encodedKey]'}'); // Thread cycle-detection state into recursive calls without keeping strong references. sideChannel[object] = step; @@ -256,9 +280,9 @@ extension _$Encode on QS { strictNullHandling: strictNullHandling, skipNulls: skipNulls, encodeDotInKeys: encodeDotInKeys, - encoder: generateArrayPrefix == ListFormat.comma.generator && + encoder: identical(generateArrayPrefix, ListFormat.comma.generator) && encodeValuesOnly && - obj is Iterable + seqList_ != null ? null : encoder, serializeDate: serializeDate, diff --git a/lib/src/extensions/extensions.dart b/lib/src/extensions/extensions.dart index 53c29f8..2e7dee6 100644 --- a/lib/src/extensions/extensions.dart +++ b/lib/src/extensions/extensions.dart @@ -1,20 +1,18 @@ -import 'dart:math' show min; - -/// 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. +// 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]. @@ -47,11 +45,25 @@ extension ListExtension on List { /// ['a','b','c'].slice(-2, -1); // ['b'] /// ['a','b','c'].slice(0, 99); // ['a','b','c'] /// ``` - List slice([int start = 0, int? end]) => sublist( - (start < 0 ? length + start : start).clamp(0, length), - (end == null ? length : (end < 0 ? length + end : end)) - .clamp(0, length), - ); + 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 { @@ -70,13 +82,22 @@ extension StringExtension on String { /// 'hello'.slice(0, 99); // 'hello' /// ``` String slice(int start, [int? end]) { - end ??= length; - if (end < 0) { - end = length + 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 (start < 0) { - start = length + start; + if (e < 0) { + e = 0; + } else if (e > l) { + e = l; } - return substring(start, min(end, length)); + + if (e <= s) return ''; + return substring(s, e); } } diff --git a/lib/src/models/undefined.dart b/lib/src/models/undefined.dart index 7bac449..479e9b6 100644 --- a/lib/src/models/undefined.dart +++ b/lib/src/models/undefined.dart @@ -16,8 +16,7 @@ final class Undefined with EquatableMixin { /// No-op copy that returns another equal sentinel. Kept for API symmetry. Undefined copyWith() => const Undefined(); - @override - /// No distinguishing fields — all [Undefined] instances are equal. - List get props => []; + @override + List get props => const []; } diff --git a/lib/src/qs.dart b/lib/src/qs.dart index 1bc36fa..3e83390 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:collection/collection.dart' show IterableExtension; 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'; @@ -115,14 +114,11 @@ final class QS { // Normalize supported inputs into a mutable map we can traverse. Map obj = switch (object) { Map map => {...map}, - Iterable iterable => iterable - .toList() - .asMap() - .map((int k, dynamic v) => MapEntry(k.toString(), v)), + Iterable iterable => Utils.createIndexMap(iterable), _ => {}, }; - final List keys = []; + final List keys = []; // Nothing to encode. if (obj.isEmpty) { @@ -150,18 +146,20 @@ final class QS { for (int i = 0; i < objKeys.length; i++) { final key = objKeys[i]; - if (key is! String? || (obj[key] == null && options.skipNulls)) { + if (key is! String || (obj[key] == null && options.skipNulls)) { continue; } + final ListFormatGenerator gen = options.listFormat.generator; + final bool crt = identical(gen, ListFormat.comma.generator) && + options.commaRoundTrip == true; + final encoded = _$Encode._encode( obj[key], undefined: !obj.containsKey(key), prefix: key, - generateArrayPrefix: options.listFormat.generator, - commaRoundTrip: - options.listFormat.generator == ListFormat.comma.generator && - options.commaRoundTrip == true, + generateArrayPrefix: gen, + commaRoundTrip: crt, allowEmptyLists: options.allowEmptyLists, strictNullHandling: options.strictNullHandling, skipNulls: options.skipNulls, @@ -180,9 +178,11 @@ final class QS { ); if (encoded is Iterable) { - keys.addAll(encoded); - } else { - keys.add(encoded); + for (final e in encoded) { + if (e != null) keys.add(e as String); + } + } else if (encoded != null) { + keys.add(encoded as String); } } diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 2319c2d..ae98b7c 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -64,8 +64,7 @@ final class Utils { if (target is Iterable) { if (target.any((el) => el is Undefined)) { // use a SplayTreeMap to keep the keys in order - final SplayTreeMap target_ = - SplayTreeMap.of(target.toList().asMap()); + final SplayTreeMap target_ = _toIndexedTreeMap(target); if (source is Iterable) { for (final (int i, dynamic item) in source.indexed) { @@ -94,7 +93,7 @@ final class Utils { // loop through the target list and merge the maps // then loop through the source list and add any new maps final SplayTreeMap target_ = - SplayTreeMap.of(target.toList().asMap()); + _toIndexedTreeMap(target); for (final (int i, dynamic item) in source.indexed) { target_.update( i, @@ -176,8 +175,8 @@ final class Utils { entry.key.toString(): entry.value }; - return source.entries.fold(mergeTarget, (Map acc, MapEntry entry) { - acc.update( + for (final MapEntry entry in source.entries) { + mergeTarget.update( entry.key.toString(), (value) => merge( value, @@ -186,8 +185,18 @@ final class Utils { ), ifAbsent: () => entry.value, ); - return acc; - }); + } + return mergeTarget; + } + + /// Converts an iterable to a zero-indexed [SplayTreeMap]. + static SplayTreeMap _toIndexedTreeMap(Iterable iterable) { + final SplayTreeMap map = SplayTreeMap(); + int i = 0; + for (final v in iterable) { + map[i++] = v; + } + return map; } /// Dart representation of JavaScript’s deprecated `escape` function. @@ -219,15 +228,12 @@ final class Utils { c == 0x2E || // . c == 0x2F || // / (format == Format.rfc1738 && (c == 0x28 || c == 0x29))) { - buffer.write(str[i]); + buffer.writeCharCode(c); continue; } if (c < 256) { - buffer.writeAll([ - '%', - c.toRadixString(16).padLeft(2, '0').toUpperCase(), - ]); + buffer.write(hexTable[c]); continue; } @@ -252,6 +258,7 @@ final class Utils { @visibleForTesting @Deprecated('Use Uri.decodeComponent instead') static String unescape(String str) { + if (!str.contains('%')) return str; final StringBuffer buffer = StringBuffer(); int i = 0; @@ -273,13 +280,13 @@ final class Utils { continue; } on FormatException { // Not a valid %u escape: treat '%' as literal. - buffer.write(str[i]); + buffer.writeCharCode(0x25); i++; continue; } } else { // Not enough characters for a valid %u escape: treat '%' as literal. - buffer.write(str[i]); + buffer.writeCharCode(0x25); i++; continue; } @@ -294,26 +301,26 @@ final class Utils { continue; } on FormatException { // Parsing failed: treat '%' as literal. - buffer.write(str[i]); + buffer.writeCharCode(0x25); i++; continue; } } else { // Not enough characters for a valid %XX escape: treat '%' as literal. - buffer.write(str[i]); + buffer.writeCharCode(0x25); i++; continue; } } } else { // '%' is the last character; treat it as literal. - buffer.write(str[i]); + buffer.writeCharCode(0x25); i++; continue; } } - buffer.write(str[i]); + buffer.writeCharCode(c); i++; } @@ -363,59 +370,135 @@ final class Utils { } final StringBuffer buffer = StringBuffer(); - - for (int j = 0; j < str!.length; j += _segmentLimit) { - final String segment = - str.length >= _segmentLimit ? str.slice(j, j + _segmentLimit) : str; - - for (int i = 0; i < segment.length; ++i) { - int c = segment.codeUnitAt(i); - - switch (c) { - case 0x2D: // - - case 0x2E: // . - case 0x5F: // _ - case 0x7E: // ~ - case int c when c >= 0x30 && c <= 0x39: // 0-9 - case int c when c >= 0x41 && c <= 0x5A: // a-z - case int c when c >= 0x61 && c <= 0x7A: // A-Z - case int c - when format == Format.rfc1738 && (c == 0x28 || c == 0x29): // ( ) - buffer.write(segment[i]); - continue; - case int c when c < 0x80: // ASCII - buffer.write(hexTable[c]); - continue; - case int c when c < 0x800: // 2 bytes - buffer.writeAll([ - hexTable[0xC0 | (c >> 6)], - hexTable[0x80 | (c & 0x3F)], - ]); - continue; - case int c when c < 0xD800 || c >= 0xE000: // 3 bytes - buffer.writeAll([ - hexTable[0xE0 | (c >> 12)], - hexTable[0x80 | ((c >> 6) & 0x3F)], - hexTable[0x80 | (c & 0x3F)], - ]); - continue; - default: - i++; - c = 0x10000 + - (((c & 0x3FF) << 10) | (segment.codeUnitAt(i) & 0x3FF)); - buffer.writeAll([ - hexTable[0xF0 | (c >> 18)], - hexTable[0x80 | ((c >> 12) & 0x3F)], - hexTable[0x80 | ((c >> 6) & 0x3F)], - hexTable[0x80 | (c & 0x3F)], - ]); + final String s = str!; + final int len = s.length; + if (len <= _segmentLimit) { + _writeEncodedSegment(s, buffer, format); + } else { + int j = 0; + while (j < len) { + int end = j + _segmentLimit; + if (end > len) end = len; + // Avoid splitting a UTF-16 surrogate pair across segment boundary. + if (end < len) { + final int last = s.codeUnitAt(end - 1); + if (last >= 0xD800 && last <= 0xDBFF) { + // keep the high surrogate with its low surrogate in next segment + end--; + } } + _writeEncodedSegment(s.substring(j, end), buffer, format); + j = end; // advance to the adjusted end } } return buffer.toString(); } + static void _writeEncodedSegment( + String segment, StringBuffer buffer, Format? format) { + for (int i = 0; i < segment.length; ++i) { + int c = segment.codeUnitAt(i); + + switch (c) { + case 0x2D: // - + case 0x2E: // . + case 0x5F: // _ + case 0x7E: // ~ + case int v when v >= 0x30 && v <= 0x39: // 0-9 + case int v when v >= 0x41 && v <= 0x5A: // A-Z + case int v when v >= 0x61 && v <= 0x7A: // a-z + case int v + when format == Format.rfc1738 && (v == 0x28 || v == 0x29): // ( ) + buffer.writeCharCode(c); + continue; + case int v when v < 0x80: // ASCII + buffer.write(hexTable[v]); + continue; + case int v when v < 0x800: // 2 bytes + buffer.writeAll([ + hexTable[0xC0 | (v >> 6)], + hexTable[0x80 | (v & 0x3F)], + ]); + continue; + case int v + when v < 0xD800 || v >= 0xE000: // 3 bytes (BMP, non-surrogates) + buffer.writeAll([ + hexTable[0xE0 | (v >> 12)], + hexTable[0x80 | ((v >> 6) & 0x3F)], + hexTable[0x80 | (v & 0x3F)], + ]); + continue; + + case int v when v >= 0xD800 && v <= 0xDBFF: // high surrogate + if (i + 1 < segment.length) { + final int w = segment.codeUnitAt(i + 1); + if (w >= 0xDC00 && w <= 0xDFFF) { + final int code = 0x10000 + (((v & 0x3FF) << 10) | (w & 0x3FF)); + buffer.writeAll([ + hexTable[0xF0 | (code >> 18)], + hexTable[0x80 | ((code >> 12) & 0x3F)], + hexTable[0x80 | ((code >> 6) & 0x3F)], + hexTable[0x80 | (code & 0x3F)], + ]); + i++; // consume low surrogate + continue; + } + } + // Lone high surrogate: encode as a 3-byte sequence of the code unit + buffer.writeAll([ + hexTable[0xE0 | (v >> 12)], + hexTable[0x80 | ((v >> 6) & 0x3F)], + hexTable[0x80 | (v & 0x3F)], + ]); + continue; + + case int v when v >= 0xDC00 && v <= 0xDFFF: // lone low surrogate + buffer.writeAll([ + hexTable[0xE0 | (v >> 12)], + hexTable[0x80 | ((v >> 6) & 0x3F)], + hexTable[0x80 | (v & 0x3F)], + ]); + continue; + + default: + // Fallback: encode as 3 bytes of the code unit (should not be hit) + buffer.writeAll([ + hexTable[0xE0 | (c >> 12)], + hexTable[0x80 | ((c >> 6) & 0x3F)], + hexTable[0x80 | (c & 0x3F)], + ]); + continue; + } + } + } + + /// Fast latin1 percent-decoder + static String _decodeLatin1Percent(String s) { + final StringBuffer sb = StringBuffer(); + for (int i = 0; i < s.length; i++) { + final int ch = s.codeUnitAt(i); + if (ch == 0x25 /* % */ && i + 2 < s.length) { + final int h1 = _hexVal(s.codeUnitAt(i + 1)); + final int h2 = _hexVal(s.codeUnitAt(i + 2)); + if (h1 >= 0 && h2 >= 0) { + sb.writeCharCode((h1 << 4) | h2); + i += 2; + continue; + } + } + sb.writeCharCode(ch); + } + return sb.toString(); + } + + static int _hexVal(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' + return -1; + } + /// Decodes a percent-encoded token back to a scalar string. /// /// - Treats `'+'` as space before decoding (URL form semantics). @@ -428,13 +511,13 @@ final class Utils { static String? decode(String? str, {Encoding? charset = utf8}) { final String? strWithoutPlus = str?.replaceAll('+', ' '); if (charset == latin1) { + final String? s = strWithoutPlus; + if (s == null) return null; + if (!s.contains('%')) return s; // fast path: nothing to decode try { - return strWithoutPlus?.replaceAllMapped( - RegExp(r'%[0-9a-f]{2}', caseSensitive: false), - (Match match) => Utils.unescape(match.group(0)!), - ); + return _decodeLatin1Percent(s); } catch (_) { - return strWithoutPlus; + return s; } } try { @@ -571,6 +654,7 @@ final class Utils { /// - Produces surrogate pairs for code points > `0xFFFF`. static String interpretNumericEntities(String s) { if (s.length < 4) return s; + if (!s.contains('&#')) return s; final StringBuffer sb = StringBuffer(); int i = 0; while (i < s.length) { @@ -615,4 +699,24 @@ final class Utils { } return sb.toString(); } + + /// Create an index-keyed map from an iterable. + static Map createIndexMap(Iterable iterable) { + if (iterable is List) { + final list = iterable; + final map = {}; + for (var i = 0; i < list.length; i++) { + map[i.toString()] = list[i]; + } + return map; + } else { + final map = {}; + var i = 0; + for (final v in iterable) { + map[i.toString()] = v; + i++; + } + return map; + } + } }