Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.github.techouse.qskotlin.internal

import io.github.techouse.qskotlin.enums.Duplicates
import io.github.techouse.qskotlin.enums.Sentinel
import io.github.techouse.qskotlin.internal.Decoder.dotToBracketTopLevel
import io.github.techouse.qskotlin.models.DecodeOptions
import io.github.techouse.qskotlin.models.Undefined
import java.nio.charset.Charset
Expand Down Expand Up @@ -41,6 +42,7 @@ internal object Decoder {

/**
* Parses a query string into a map of key-value pairs, handling various options for decoding.
* Percent-encoded brackets `%5B`/`%5D` are normalized to literal `[`/`]` before splitting.
*
* @param str The query string to parse.
* @param options The decoding options that affect how the string is parsed.
Expand Down Expand Up @@ -117,7 +119,7 @@ internal object Decoder {
// Decode the key slice as a key; values decode as values
key = options.decodeKey(part.take(pos), charset).orEmpty()
value =
Utils.apply<Any?>(
Utils.apply(
parseListValue(
part.substring(pos + 1),
options,
Expand Down Expand Up @@ -196,7 +198,7 @@ internal object Decoder {
when {
options.allowEmptyLists &&
(leaf == "" || (options.strictNullHandling && leaf == null)) ->
mutableListOf<Any?>()
mutableListOf()
else -> Utils.combine<Any?>(emptyList<Any?>(), leaf)
}
} else {
Expand Down Expand Up @@ -278,18 +280,29 @@ internal object Decoder {
}

/**
* Converts a dot notation key to bracket notation at the top level.
* Convert top-level dot segments into bracket segments, preserving dots inside brackets and
* ignoring degenerate top-level dots.
*
* @param s The string to convert, which may contain dot notation.
* @return The converted string with brackets replacing dots at the top level.
* Rules:
* - Only dots at depth == 0 split. Dots inside `\[\]` are preserved.
* - Percent-encoded dots (`%2E`/`%2e`) never split here (they may map to '.' later).
* - Degenerates:
* * leading '.' → preserved (e.g., `".a"` stays `".a"`),
* * double dots `"a..b"` → the first dot is preserved (`"a.\[b]"`),
* * trailing dot `"a."` → trailing '.' is preserved and ignored by the splitter.
*
* Examples:
* - `user.email.name` → `user\[email]\[name]`
* - `a\[b].c` → `a\[b]\[c]`
* - `a\[.].c` → `a\[.]\[c]`
* - `a%2E\[b]` → remains `a%2E\[b]` (no split here)
*/
private fun dotToBracketTopLevel(s: String): String {
val sb = StringBuilder(s.length)
var depth = 0
var i = 0
while (i < s.length) {
val ch = s[i]
when (ch) {
when (val ch = s[i]) {
'[' -> {
depth++
sb.append(ch)
Expand All @@ -302,21 +315,41 @@ internal object Decoder {
}
'.' -> {
if (depth == 0) {
// collect the next segment name (stop at '.' or '[')
val start = ++i
var j = start
while (j < s.length && s[j] != '.' && s[j] != '[') j++
if (j > start) {
sb.append('[').append(s, start, j).append(']')
i = j
} else {
sb.append('.') // nothing to convert
// Look ahead to decide what to do with a top‑level dot
val hasNext = i + 1 < s.length
val next = if (hasNext) s[i + 1] else '\u0000'
when {
// Degenerate ".[" → skip the dot so "a.[b]" behaves like "a[b]"
next == '[' -> {
i++ // consume the '.'
}
// Preserve literal dot for "a." (trailing) and for "a..b" (the first
// dot)
!hasNext || next == '.' -> {
sb.append('.')
i++
}
else -> {
// Normal split: convert a.b → a[b] at top level
val start = ++i
var j = start
while (j < s.length && s[j] != '.' && s[j] != '[') j++
sb.append('[').append(s, start, j).append(']')
i = j
}
}
} else {
sb.append('.')
i++
}
}
'%' -> {
// Preserve percent sequences verbatim at top level. Encoded dots (%2E/%2e)
// are *not* used as separators here; they may be mapped to '.' later
// when parsing segments (see DecodeOptions.defaultDecode/parseObject).
sb.append('%')
i++
}
else -> {
sb.append(ch)
i++
Expand All @@ -327,14 +360,20 @@ internal object Decoder {
}

/**
* Splits a key into segments based on brackets and dots, handling depth and strictness.
* Split a key into segments based on balanced brackets.
*
* Notes:
* - Top-level dot splitting (`a.b` → `a\[b]`) happens earlier via [dotToBracketTopLevel] when
* [allowDots] is true.
* - Unterminated '[': the entire key is treated as a single literal segment (qs semantics).
* - If [strictDepth] is false and depth is exceeded, the remainder is kept as one final bracket
* segment.
*
* @param originalKey The original key to split.
* @param allowDots Whether to allow dots in the key.
* @param maxDepth The maximum depth for splitting.
* @param strictDepth Whether to enforce strict depth limits.
* @return A list of segments derived from the original key.
* @throws IndexOutOfBoundsException if the depth exceeds maxDepth and strictDepth is true.
* @param allowDots Whether to allow top-level dot splitting (already applied upstream).
* @param maxDepth The maximum number of bracket segments to collect.
* @param strictDepth When true, exceeding [maxDepth] throws; when false, the remainder is a
* single trailing segment.
*/
internal fun splitKeyIntoSegments(
originalKey: String,
Expand All @@ -360,9 +399,31 @@ internal object Decoder {
var open = first
var depth = 0
while (open >= 0 && depth < maxDepth) {
val close = key.indexOf(']', open + 1)
if (close < 0) break
segments.add(key.substring(open, close + 1)) // e.g. "[p]" or "[]"
var i2 = open + 1
var level = 1
var close = -1

// Balance nested '[' and ']' within the same group,
// so "[with[inner]]" is treated as one segment.
while (i2 < key.length) {
val ch2 = key[i2]
if (ch2 == '[') {
level++
} else if (ch2 == ']') {
level--
if (level == 0) {
close = i2
break
}
}
i2++
}

if (close < 0) {
break // unterminated group; stop collecting
}

segments.add(key.substring(open, close + 1)) // includes the surrounding [ ]
depth++
open = key.indexOf('[', close + 1)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,10 @@ data class DecodeOptions(
/**
* Effective `decodeDotInKeys` value.
*
* Defaults to `false` when unspecified. When `true`, encoded dots (`%2E`/`%2e`) inside key
* segments are mapped to `.` **after** splitting, without introducing extra dot‑splits.
* Defaults to `false` when unspecified. Inside bracket segments, percent-decoding will
* naturally yield '.' from `%2E/%2e`. `decodeDotInKeys` controls whether encoded dots at the
* top level are treated as additional split points; it does not affect the literal '.' produced
* by percent-decoding inside bracket segments.
*/
val getDecodeDotInKeys: Boolean
get() = decodeDotInKeys ?: false
Expand All @@ -168,7 +170,8 @@ data class DecodeOptions(
* Uses the provided [decoder] when set; otherwise falls back to [Utils.decode]. For backward
* compatibility, a [legacyDecoder] `(value, charset)` can be supplied and is adapted
* internally. The [kind] will be [DecodeKind.KEY] for keys (and key segments) and
* [DecodeKind.VALUE] for values.
* [DecodeKind.VALUE] for values, and is forwarded to custom decoders. The library default does
* not vary decoding based on [kind].
*/
internal fun decode(
value: String?,
Expand All @@ -180,102 +183,29 @@ data class DecodeOptions(
return if (d != null) {
d.decode(value, charset, kind) // honor nulls from user decoder
} else {
defaultDecode(value, charset, kind)
defaultDecode(value, charset)
}
}

/**
* Default library decode.
*
* For [DecodeKind.KEY], protects encoded dots (`%2E`/`%2e`) **before** percent‑decoding so key
* splitting and post‑split mapping run on the intended tokens.
* Keys are decoded identically to values via [Utils.decode], which percent‑decodes `%2E/%2e` to
* '.'. Whether a '.' participates in key splitting is decided by the parser (based on options).
*/
private fun defaultDecode(value: String?, charset: Charset?, kind: DecodeKind): Any? {
private fun defaultDecode(value: String?, charset: Charset?): Any? {
if (value == null) return null
if (kind == DecodeKind.KEY) {
val protected =
protectEncodedDotsForKeys(value, includeOutsideBrackets = (allowDots == true))
return Utils.decode(protected, charset)
}
// Keys decode exactly like values; do NOT “protect” encoded dots.
return Utils.decode(value, charset)
}

/**
* Double‑encode %2E/%2e in KEY strings so the percent‑decoder does not turn them into '.' too
* early.
*
* When [includeOutsideBrackets] is true, occurrences both inside and outside bracket segments
* are protected. Otherwise, only those **inside** `[...]` are protected. Note: only literal
* `[`/`]` affect depth; percent‑encoded brackets (`%5B`/`%5D`) are treated as content, not
* structure.
*/
private fun protectEncodedDotsForKeys(input: String, includeOutsideBrackets: Boolean): String {
val pct = input.indexOf('%')
if (pct < 0) return input
if (input.indexOf("2E", pct) < 0 && input.indexOf("2e", pct) < 0) return input
val n = input.length
val sb = StringBuilder(n + 8)
var depth = 0
var i = 0
while (i < n) {
when (val ch = input[i]) {
'[' -> {
depth++
sb.append(ch)
i++
}
']' -> {
if (depth > 0) depth--
sb.append(ch)
i++
}
'%' -> {
if (
i + 2 < n &&
input[i + 1] == '2' &&
(input[i + 2] == 'E' || input[i + 2] == 'e')
) {
val inside = depth > 0
if (inside || includeOutsideBrackets) {
sb.append("%25").append(if (input[i + 2] == 'E') "2E" else "2e")
} else {
sb.append('%').append('2').append(input[i + 2])
}
i += 3
} else {
sb.append(ch)
i++
}
}
else -> {
sb.append(ch)
i++
}
}
}
return sb.toString()
}

/**
* Back‑compat helper: decode a value without key/value kind context.
*
* Prefer calling [decode] directly (or [decodeKey]/[decodeValue] for explicit context).
*/
@Deprecated(
message =
"Deprecated: use decodeKey/decodeValue (or decode(value, charset, kind)) to honor key/value context. This will be removed in the next major.",
replaceWith = ReplaceWith("decode(value, charset)"),
level = DeprecationLevel.WARNING,
)
@Suppress("unused")
@JvmOverloads
fun getDecoder(value: String?, charset: Charset? = null): Any? = decode(value, charset)

/** Convenience: decode a key to String? */
internal fun decodeKey(value: String?, charset: Charset?): String? =
@JvmOverloads
fun decodeKey(value: String?, charset: Charset? = this.charset): String? =
decode(value, charset, DecodeKind.KEY)?.toString() // keys are always coerced to String

/** Convenience: decode a value */
internal fun decodeValue(value: String?, charset: Charset?): Any? =
@JvmOverloads
fun decodeValue(value: String?, charset: Charset? = this.charset): Any? =
decode(value, charset, DecodeKind.VALUE)
}
Loading
Loading