Skip to content

Commit 101315c

Browse files
committed
⚡ simplify key parsing and enhance numeric entity interpretation
1 parent 26e7de5 commit 101315c

File tree

4 files changed

+187
-62
lines changed

4 files changed

+187
-62
lines changed

lib/src/extensions/decode.dart

Lines changed: 76 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
part of '../qs.dart';
22

3-
extension _$Decode on QS {
4-
static String _interpretNumericEntities(String str) => str.replaceAllMapped(
5-
RegExp(r'&#(\d+);'),
6-
(Match match) => String.fromCharCode(
7-
int.parse(match.group(1)!),
8-
),
9-
);
3+
final RegExp _dotToBracket = RegExp(r'\.([^.\[]+)');
104

5+
extension _$Decode on QS {
116
static dynamic _parseListValue(
127
dynamic val,
138
DecodeOptions options,
@@ -117,7 +112,7 @@ extension _$Decode on QS {
117112
!Utils.isEmpty(val) &&
118113
options.interpretNumericEntities &&
119114
charset == latin1) {
120-
val = _interpretNumericEntities(
115+
val = Utils.interpretNumericEntities(
121116
val is Iterable
122117
? val.map((e) => e.toString()).join(',')
123118
: val.toString(),
@@ -213,60 +208,91 @@ extension _$Decode on QS {
213208
static dynamic _parseKeys(
214209
String? givenKey,
215210
dynamic val,
216-
DecodeOptions options,
217-
bool valuesParsed,
218-
) {
219-
if (givenKey?.isEmpty ?? true) {
220-
return;
221-
}
211+
DecodeOptions options, [
212+
bool valuesParsed = false,
213+
]) {
214+
if (givenKey == null || givenKey.isEmpty) return null;
222215

223-
// Transform dot notation to bracket notation
224-
String key = options.allowDots
225-
? givenKey!.replaceAllMapped(
226-
RegExp(r'\.([^.[]+)'),
227-
(Match match) => '[${match[1]}]',
228-
)
229-
: givenKey!;
230-
231-
// The regex chunks
232-
final RegExp brackets = RecursiveRegex(
233-
startDelimiter: '[',
234-
endDelimiter: ']',
216+
final segments = _splitKeyIntoSegments(
217+
originalKey: givenKey,
218+
allowDots: options.allowDots,
219+
maxDepth: options.depth,
220+
strictDepth: options.strictDepth,
235221
);
236222

237-
// Get the parent
238-
Match? segment = options.depth > 0 ? brackets.firstMatch(key) : null;
239-
final String parent = segment != null ? key.slice(0, segment.start) : key;
223+
return _parseObject(segments, val, options, valuesParsed);
224+
}
240225

241-
// Stash the parent if it exists
242-
final List<String> keys = [];
243-
if (parent.isNotEmpty) {
244-
keys.add(parent);
226+
static List<String> _splitKeyIntoSegments({
227+
required String originalKey,
228+
required bool allowDots,
229+
required int maxDepth,
230+
required bool strictDepth,
231+
}) {
232+
final key = allowDots
233+
? originalKey.replaceAllMapped(_dotToBracket, (m) => '[${m[1]}]')
234+
: originalKey;
235+
236+
// Depth=0: do not split and do not throw (qs semantics)
237+
if (maxDepth <= 0) {
238+
return <String>[key];
245239
}
246240

247-
// Loop through children appending to the array until we hit depth
248-
int i = 0;
249-
while (options.depth > 0 &&
250-
(segment = brackets.firstMatch(key)) != null &&
251-
i < options.depth) {
252-
i += 1;
253-
if (segment != null) {
254-
keys.add(segment.group(0)!);
255-
// Update the key to start searching from the next position
256-
key = key.slice(segment.end);
241+
final segments = <String>[];
242+
243+
// Parent (everything before first '['), may be empty
244+
final first = key.indexOf('[');
245+
final parent = first >= 0 ? key.substring(0, first) : key;
246+
if (parent.isNotEmpty) segments.add(parent);
247+
248+
final n = key.length;
249+
var open = first;
250+
var depth = 0;
251+
252+
while (open >= 0 && depth < maxDepth) {
253+
// Balance nested brackets inside this group: "[ ... possibly [] ... ]"
254+
var level = 1;
255+
var i = open + 1;
256+
var close = -1;
257+
258+
while (i < n) {
259+
final ch = key.codeUnitAt(i);
260+
if (ch == 0x5B) {
261+
// '['
262+
level++;
263+
} else if (ch == 0x5D) {
264+
// ']'
265+
level--;
266+
if (level == 0) {
267+
close = i;
268+
break;
269+
}
270+
}
271+
i++;
272+
}
273+
274+
if (close < 0) {
275+
// Unterminated group, stop collecting groups
276+
break;
257277
}
278+
279+
segments.add(key.substring(open, close + 1)); // includes enclosing [ ]
280+
depth++;
281+
282+
// find next group, starting after this one
283+
open = key.indexOf('[', close + 1);
258284
}
259285

260-
// If there's a remainder, check strictDepth option for throw, else just add whatever is left
261-
if (segment != null) {
262-
if (options.strictDepth) {
286+
if (open >= 0) {
287+
// We still have remainder starting with '['
288+
if (strictDepth) {
263289
throw RangeError(
264-
'Input depth exceeded depth option of ${options.depth} and strictDepth is true',
265-
);
290+
'Input depth exceeded $maxDepth and strictDepth is true');
266291
}
267-
keys.add('[${key.slice(segment.start)}]');
292+
// Stash the remainder as a single segment (qs behavior)
293+
segments.add('[${key.substring(open)}]');
268294
}
269295

270-
return _parseObject(keys, val, options, valuesParsed);
296+
return segments;
271297
}
272298
}

lib/src/qs.dart

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,9 @@ import 'package:qs_dart/src/models/decode_options.dart';
1111
import 'package:qs_dart/src/models/encode_options.dart';
1212
import 'package:qs_dart/src/models/undefined.dart';
1313
import 'package:qs_dart/src/utils.dart';
14-
import 'package:recursive_regex/recursive_regex.dart';
1514
import 'package:weak_map/weak_map.dart';
1615

1716
part 'extensions/decode.dart';
18-
1917
part 'extensions/encode.dart';
2018

2119
/// A query string decoder (parser) and encoder (stringifier) class.
@@ -52,16 +50,14 @@ final class QS {
5250
// Iterate over the keys and setup the new object
5351
if (tempObj?.isNotEmpty ?? false) {
5452
for (final MapEntry<String, dynamic> entry in tempObj!.entries) {
55-
obj = Utils.merge(
56-
obj,
57-
_$Decode._parseKeys(
58-
entry.key,
59-
entry.value,
60-
options,
61-
input is String,
62-
),
63-
options,
64-
);
53+
final parsed = _$Decode._parseKeys(
54+
entry.key, entry.value, options, input is String);
55+
56+
if (obj.isEmpty && parsed is Map<String, dynamic>) {
57+
obj = parsed; // direct assignment – no merge needed
58+
} else {
59+
obj = Utils.merge(obj, parsed, options) as Map<String, dynamic>;
60+
}
6561
}
6662
}
6763

lib/src/utils.dart

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,4 +495,51 @@ final class Utils {
495495
(val is String && val.isEmpty) ||
496496
(val is Iterable && val.isEmpty) ||
497497
(val is Map && val.isEmpty);
498+
499+
static String interpretNumericEntities(String s) {
500+
if (s.length < 4) return s;
501+
final sb = StringBuffer();
502+
var i = 0;
503+
while (i < s.length) {
504+
final ch = s.codeUnitAt(i);
505+
if (ch == 0x26 /* & */ &&
506+
i + 2 < s.length &&
507+
s.codeUnitAt(i + 1) == 0x23 /* # */) {
508+
var j = i + 2;
509+
if (j < s.length) {
510+
int code = 0;
511+
final start = j;
512+
while (j < s.length) {
513+
final cu = s.codeUnitAt(j);
514+
if (cu < 0x30 || cu > 0x39) break; // 0..9
515+
code = code * 10 + (cu - 0x30);
516+
j++;
517+
}
518+
if (j < s.length && s.codeUnitAt(j) == 0x3B /* ; */ && j > start) {
519+
if (code <= 0xFFFF) {
520+
sb.writeCharCode(code);
521+
} else if (code <= 0x10FFFF) {
522+
final v = code - 0x10000;
523+
sb.writeCharCode(0xD800 | (v >> 10)); // high surrogate
524+
sb.writeCharCode(0xDC00 | (v & 0x3FF)); // low surrogate
525+
} else {
526+
// out of range: keep literal '&' and continue
527+
sb.writeCharCode(0x26);
528+
i++;
529+
continue;
530+
}
531+
i = j + 1;
532+
continue;
533+
}
534+
}
535+
// not a well-formed entity: keep literal '&'
536+
sb.writeCharCode(0x26);
537+
i++;
538+
} else {
539+
sb.writeCharCode(ch);
540+
i++;
541+
}
542+
}
543+
return sb.toString();
544+
}
498545
}

test/unit/utils_test.dart

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,5 +1027,61 @@ void main() {
10271027
equals('%26%2355357%3B%26%2356489%3B'),
10281028
);
10291029
});
1030+
1031+
group('interpretNumericEntities', () {
1032+
test('returns input unchanged when there are no entities', () {
1033+
expect(Utils.interpretNumericEntities('hello world'), 'hello world');
1034+
expect(Utils.interpretNumericEntities('100% sure'), '100% sure');
1035+
});
1036+
1037+
test('decodes a single decimal entity', () {
1038+
expect(Utils.interpretNumericEntities('A = &#65;'), 'A = A');
1039+
expect(Utils.interpretNumericEntities('&#48;&#49;&#50;'), '012');
1040+
});
1041+
1042+
test('decodes multiple entities in a sentence', () {
1043+
const input = 'Hello &#87;&#111;&#114;&#108;&#100;!';
1044+
const expected = 'Hello World!';
1045+
expect(Utils.interpretNumericEntities(input), expected);
1046+
});
1047+
1048+
test('surrogate pair as two decimal entities (emoji)', () {
1049+
// U+1F4A9 (💩) is represented as two decimal entities:
1050+
// 55357 (0xD83D) and 56489 (0xDCA9)
1051+
expect(Utils.interpretNumericEntities('&#55357;&#56489;'), '💩');
1052+
});
1053+
1054+
test('entities can appear at string boundaries', () {
1055+
expect(Utils.interpretNumericEntities('&#65;BC'), 'ABC');
1056+
expect(Utils.interpretNumericEntities('ABC&#33;'), 'ABC!');
1057+
expect(Utils.interpretNumericEntities('&#65;'), 'A');
1058+
});
1059+
1060+
test('mixes literals and entities', () {
1061+
// '=' is 61
1062+
expect(Utils.interpretNumericEntities('x&#61;y'), 'x=y');
1063+
expect(Utils.interpretNumericEntities('x=&#61;y'), 'x==y');
1064+
});
1065+
1066+
test('malformed patterns remain unchanged', () {
1067+
// No digits
1068+
expect(Utils.interpretNumericEntities('&#;'), '&#;');
1069+
// Missing semicolon
1070+
expect(Utils.interpretNumericEntities('&#12'), '&#12');
1071+
// Hex form not supported by this decoder
1072+
expect(Utils.interpretNumericEntities('&#x41;'), '&#x41;');
1073+
// Space inside
1074+
expect(Utils.interpretNumericEntities('&# 12;'), '&# 12;');
1075+
// Negative / non-digit after '#'
1076+
expect(Utils.interpretNumericEntities('&#-12;'), '&#-12;');
1077+
// Mixed garbage
1078+
expect(Utils.interpretNumericEntities('&#+;'), '&#+;');
1079+
});
1080+
1081+
test('out-of-range code points remain unchanged', () {
1082+
// Max valid is 0x10FFFF (1114111). One above should be left as literal.
1083+
expect(Utils.interpretNumericEntities('&#1114112;'), '&#1114112;');
1084+
});
1085+
});
10301086
});
10311087
}

0 commit comments

Comments
 (0)