Skip to content
Merged
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ install:

sure:
@# Help: Analyze the project's Dart code, check the formatting one or more Dart files and run unit tests for the current project.
make check_style && make tests
make check_style && make test

show_test_coverage:
@# Help: Run Dart unit tests for the current project and show the coverage.
Expand Down
1 change: 1 addition & 0 deletions lib/qs_dart.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
/// See the repository README for complete examples and edge‑case notes.
library;

export 'src/enums/decode_kind.dart';
export 'src/enums/duplicates.dart';
export 'src/enums/format.dart';
export 'src/enums/list_format.dart';
Expand Down
36 changes: 36 additions & 0 deletions lib/src/enums/decode_kind.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/// Decoding context used by the query string parser and utilities.
///
/// This enum indicates whether a piece of text is being decoded as a **key**
/// (or key segment) or as a **value**. The distinction matters for
/// percent‑encoded dots (`%2E` / `%2e`) that appear **in keys**:
///
/// * When decoding **keys**, implementations often *preserve* encoded dots so
/// higher‑level options like `allowDots` and `decodeDotInKeys` can be applied
/// consistently during key‑splitting.
/// * When decoding **values**, implementations typically perform full percent
/// decoding.
///
/// ### Usage
///
/// ```dart
/// import 'package:qs_dart/qs.dart';
///
/// DecodeKind k = DecodeKind.key; // decode a key/segment
/// DecodeKind v = DecodeKind.value; // decode a value
/// ```
///
/// ### Notes
///
/// Prefer identity comparisons with enum members (e.g. `kind == DecodeKind.key`).
/// The underlying `name`/`index` are implementation details and should not be
/// relied upon for logic.
enum DecodeKind {
/// Decode a **key** (or key segment). Implementations may preserve
/// percent‑encoded dots (`%2E` / `%2e`) so that dot‑splitting semantics can be
/// applied later according to parser options.
key,

/// Decode a **value**. Implementations typically perform full percent
/// decoding.
value,
}
14 changes: 9 additions & 5 deletions lib/src/extensions/decode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,15 @@ extension _$Decode on QS {

late final String key;
dynamic val;
// Bare key without '=', interpret as null vs empty-string per strictNullHandling.
// Decode key/value using key-aware decoder, no %2E protection shim.
if (pos == -1) {
key = options.decoder(part, charset: charset);
// Decode bare key (no '=') using key-aware decoder
key = options.decoder(part, charset: charset, kind: DecodeKind.key);
val = options.strictNullHandling ? null : '';
} else {
key = options.decoder(part.slice(0, pos), charset: charset);
// Decode key slice using key-aware decoder; values decode as value kind
key = options.decoder(part.slice(0, pos),
charset: charset, kind: DecodeKind.key);
// Decode the substring *after* '=', applying list parsing and the configured decoder.
val = Utils.apply<dynamic>(
_parseListValue(
Expand All @@ -160,7 +163,8 @@ extension _$Decode on QS {
? (obj[key] as List).length
: 0,
),
(dynamic val) => options.decoder(val, charset: charset),
(dynamic v) =>
options.decoder(v, charset: charset, kind: DecodeKind.value),
);
}

Expand Down Expand Up @@ -257,7 +261,7 @@ extension _$Decode on QS {
? root.slice(1, root.length - 1)
: root;
final String decodedRoot = options.decodeDotInKeys
? cleanRoot.replaceAll('%2E', '.')
? cleanRoot.replaceAll('%2E', '.').replaceAll('%2e', '.')
: cleanRoot;
final int? index = int.tryParse(decodedRoot);
if (!options.parseLists && decodedRoot == '') {
Expand Down
103 changes: 79 additions & 24 deletions lib/src/models/decode_options.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:convert' show Encoding, latin1, utf8;

import 'package:equatable/equatable.dart';
import 'package:qs_dart/src/enums/decode_kind.dart';
import 'package:qs_dart/src/enums/duplicates.dart';
import 'package:qs_dart/src/utils.dart';

Expand All @@ -27,28 +28,30 @@ import 'package:qs_dart/src/utils.dart';
/// See also: the options types in other ports for parity, and the individual
/// doc comments below for precise semantics.

/// Signature for a custom scalar decoder used by [DecodeOptions].
/// Preferred signature for a custom scalar decoder used by [DecodeOptions].
///
/// The function receives a single raw token (already split from the query)
/// and an optional [charset] hint (either [utf8] or [latin1]). The [charset]
/// reflects the *effective* charset after any sentinel (`utf8=✓`) handling.
///
/// Return the decoded value for the token — typically a `String`, `num`,
/// `bool`, or `null`. If no decoder is provided, the library falls back to
/// [Utils.decode].
///
/// Notes
/// - This hook runs on **individual tokens only**; do not parse brackets,
/// delimiters, or build containers here.
/// - If you throw from this function, the error will surface out of
/// [QS.decode].
typedef Decoder = dynamic Function(String? value, {Encoding? charset});
/// Implementations may choose to ignore [charset] or [kind], but both are
/// provided to enable key-aware decoding when desired.
typedef Decoder = dynamic Function(
String? value, {
Encoding? charset,
DecodeKind? kind,
});

/// Back-compat: decoder with optional [charset] only.
typedef Decoder1 = dynamic Function(String? value, {Encoding? charset});

/// Decoder that accepts only [kind] (no [charset]).
typedef Decoder2 = dynamic Function(String? value, {DecodeKind? kind});

/// Back-compat: single-argument decoder (value only).
typedef Decoder3 = dynamic Function(String? value);

/// Options that configure the output of [QS.decode].
final class DecodeOptions with EquatableMixin {
const DecodeOptions({
bool? allowDots,
Decoder? decoder,
Object? decoder,
bool? decodeDotInKeys,
this.allowEmptyLists = false,
this.listLimit = 20,
Expand All @@ -65,7 +68,7 @@ final class DecodeOptions with EquatableMixin {
this.strictDepth = false,
this.strictNullHandling = false,
this.throwOnLimitExceeded = false,
}) : allowDots = allowDots ?? decodeDotInKeys == true || false,
}) : allowDots = allowDots ?? (decodeDotInKeys ?? false),
decodeDotInKeys = decodeDotInKeys ?? false,
_decoder = decoder,
assert(
Expand Down Expand Up @@ -156,13 +159,65 @@ final class DecodeOptions with EquatableMixin {

/// Optional custom scalar decoder for a single token.
/// If not provided, falls back to [Utils.decode].
final Decoder? _decoder;
final Object? _decoder;

/// Decode a single scalar using either the custom decoder or the default
/// implementation in [Utils.decode]. The [kind] indicates whether the token
/// is a key (or key segment) or a value.
dynamic decoder(
String? value, {
Encoding? charset,
DecodeKind kind = DecodeKind.value,
}) {
final Object? decoder = _decoder;

// If no custom decoder is provided, use the default decoding logic.
if (decoder == null) {
return Utils.decode(value, charset: charset ?? this.charset);
}

// Prefer strongly-typed variants first
if (decoder is Decoder) return decoder(value, charset: charset, kind: kind);
if (decoder is Decoder1) return decoder(value, charset: charset);
if (decoder is Decoder2) return decoder(value, kind: kind);
if (decoder is Decoder3) return decoder(value);

/// Decode a single scalar using either the custom [Decoder] or the default
/// implementation in [Utils.decode].
dynamic decoder(String? value, {Encoding? charset}) => _decoder is Function
? _decoder?.call(value, charset: charset)
: Utils.decode(value, charset: charset);
// Dynamic callable or class with `call` method
try {
// Try full shape (value, {charset, kind})
return (decoder as dynamic)(value, charset: charset, kind: kind);
} on NoSuchMethodError catch (_) {
// fall through
} on TypeError catch (_) {
// fall through
}
try {
// Try (value, {charset})
return (decoder as dynamic)(value, charset: charset);
} on NoSuchMethodError catch (_) {
// fall through
} on TypeError catch (_) {
// fall through
}
try {
// Try (value, {kind})
return (decoder as dynamic)(value, kind: kind);
} on NoSuchMethodError catch (_) {
// fall through
} on TypeError catch (_) {
// fall through
}
try {
// Try (value)
return (decoder as dynamic)(value);
} on NoSuchMethodError catch (_) {
// Fallback to default
return Utils.decode(value, charset: charset ?? this.charset);
} on TypeError catch (_) {
// Fallback to default
return Utils.decode(value, charset: charset ?? this.charset);
}
}

/// Return a new [DecodeOptions] with the provided overrides.
DecodeOptions copyWith({
Expand All @@ -182,7 +237,7 @@ final class DecodeOptions with EquatableMixin {
bool? parseLists,
bool? strictNullHandling,
bool? strictDepth,
Decoder? decoder,
Object? decoder,
}) =>
DecodeOptions(
allowDots: allowDots ?? this.allowDots,
Expand Down
10 changes: 8 additions & 2 deletions lib/src/qs.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:convert' show latin1, utf8, Encoding;
import 'dart:typed_data' show ByteBuffer;

import 'package:qs_dart/src/enums/decode_kind.dart';
import 'package:qs_dart/src/enums/duplicates.dart';
import 'package:qs_dart/src/enums/format.dart';
import 'package:qs_dart/src/enums/list_format.dart';
Expand All @@ -12,6 +13,9 @@ import 'package:qs_dart/src/models/undefined.dart';
import 'package:qs_dart/src/utils.dart';
import 'package:weak_map/weak_map.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';

Expand Down Expand Up @@ -65,8 +69,10 @@ final class QS {
: input;

// Guardrail: if the top-level parameter count is large, temporarily disable
// list parsing to keep memory bounded (matches Node `qs`).
if (options.parseLists &&
// list parsing to keep memory bounded (matches Node `qs`). Only apply for
// raw string inputs, not for pre-tokenized maps.
if (input is String &&
options.parseLists &&
options.listLimit > 0 &&
(tempObj?.length ?? 0) > options.listLimit) {
options = options.copyWith(parseLists: false);
Expand Down
Loading