From 5ca29cb250798b9bcd2a2ae6a543efa84a5ea94b Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 19 May 2026 14:18:39 +0100 Subject: [PATCH 1/8] chore: update qs dependency version to ^6.15.2 in package.json and pnpm-lock.yaml --- tests/comparison/js/package.json | 2 +- tests/comparison/js/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/comparison/js/package.json b/tests/comparison/js/package.json index 9f0e25b..c576e71 100644 --- a/tests/comparison/js/package.json +++ b/tests/comparison/js/package.json @@ -10,6 +10,6 @@ "node": ">=24" }, "dependencies": { - "qs": "^6.15.1" + "qs": "^6.15.2" } } diff --git a/tests/comparison/js/pnpm-lock.yaml b/tests/comparison/js/pnpm-lock.yaml index 3613a71..c33c7c0 100644 --- a/tests/comparison/js/pnpm-lock.yaml +++ b/tests/comparison/js/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: qs: - specifier: ^6.15.1 - version: 6.15.1 + specifier: ^6.15.2 + version: 6.15.2 packages: @@ -69,8 +69,8 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==, tarball: https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz} engines: {node: '>= 0.4'} - qs@6.15.1: - resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==, tarball: https://registry.npmjs.org/qs/-/qs-6.15.1.tgz} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==, tarball: https://registry.npmjs.org/qs/-/qs-6.15.2.tgz} engines: {node: '>=0.6'} side-channel-list@1.0.1: @@ -147,7 +147,7 @@ snapshots: object-inspect@1.13.4: {} - qs@6.15.1: + qs@6.15.2: dependencies: side-channel: 1.1.0 From 47d4c4749a070c2a02f17b98a8b0a94b32bac16d Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 19 May 2026 14:44:04 +0100 Subject: [PATCH 2/8] fix(decode): match qs 6.15.2 bracket parsing --- src/decode.rs | 2 +- src/decode/keys.rs | 84 ++------------------- src/decode/tests/keys.rs | 14 ++-- src/decode/tests/mod.rs | 6 +- src/decode/tests/scanner.rs | 17 ++--- tests/divergences.rs | 4 +- tests/support/cases/node_parse.rs | 121 ++++++++++++++++++++++++++++++ 7 files changed, 149 insertions(+), 99 deletions(-) diff --git a/src/decode.rs b/src/decode.rs index 3a3371c..b5784bf 100644 --- a/src/decode.rs +++ b/src/decode.rs @@ -22,7 +22,7 @@ use self::accumulate::combine_with_limit; #[cfg(test)] use self::flat::{DefaultAccumulator, FlatValues, ParsedFlatValue, value_list_length_for_combine}; #[cfg(test)] -use self::keys::{dot_to_bracket_top_level, find_recoverable_balanced_open, parse_keys}; +use self::keys::{dot_to_bracket_top_level, parse_keys}; #[cfg(test)] use self::scalar::{ decode_component, decode_scalar, interpret_numeric_entities, interpret_numeric_entities_in_node, diff --git a/src/decode/keys.rs b/src/decode/keys.rs index c282298..22466f5 100644 --- a/src/decode/keys.rs +++ b/src/decode/keys.rs @@ -16,16 +16,16 @@ pub(crate) fn split_key_into_segments( max_depth: usize, strict_depth: bool, ) -> Result, DecodeError> { - if max_depth == 0 { - return Ok(vec![original_key.to_owned()]); - } - let key = if allow_dots { dot_to_bracket_top_level(original_key) } else { original_key.to_owned() }; + if max_depth == 0 { + return Ok(vec![key]); + } + let mut segments = Vec::new(); let first = key.find('['); let parent = first.map_or_else(|| key.as_str(), |index| &key[..index]); @@ -35,8 +35,6 @@ pub(crate) fn split_key_into_segments( let mut open = first; let mut depth = 0usize; - let mut last_close = None; - let mut broke_unterminated = false; while let Some(open_index) = open { if depth >= max_depth { @@ -63,21 +61,12 @@ pub(crate) fn split_key_into_segments( } let Some(close_index) = close else { - if let Some(recovered_open) = find_recoverable_balanced_open(&key, open_index + 1) { - if let Some(last) = segments.last_mut() { - last.push_str(&key[open_index..recovered_open]); - } else { - segments.push(key[..recovered_open].to_owned()); - } - open = Some(recovered_open); - continue; - } - broke_unterminated = true; - break; + let remainder = &key[open_index..]; + segments.push(format!("[{remainder}]")); + return Ok(segments); }; segments.push(key[open_index..=close_index].to_owned()); - last_close = Some(close_index); depth += 1; open = key[close_index + 1..] .find('[') @@ -85,29 +74,12 @@ pub(crate) fn split_key_into_segments( } if let Some(open_index) = open { - if strict_depth && !broke_unterminated { + if strict_depth { return Err(DecodeError::DepthExceeded { depth: max_depth }); } - if broke_unterminated && first == Some(0) { - return Ok(vec![original_key.to_owned()]); - } - let remainder = &key[open_index..]; segments.push(format!("[{remainder}]")); - return Ok(segments); - } - - if let Some(close_index) = last_close - && close_index + 1 < key.len() - { - let trailing = &key[close_index + 1..]; - if trailing != "." { - if strict_depth { - return Err(DecodeError::DepthExceeded { depth: max_depth }); - } - segments.push(format!("[{trailing}]")); - } } Ok(segments) @@ -165,14 +137,6 @@ fn parse_object( root.clone() }; - if root.starts_with('[') - && root.ends_with(']') - && clean_root.matches('[').count() > clean_root.matches(']').count() - && clean_root.ends_with(']') - { - clean_root.pop(); - } - if options.decode_dot_in_keys && clean_root.contains('%') { clean_root = replace_ascii_case_insensitive(&clean_root, "%2E", "."); } @@ -311,35 +275,3 @@ fn replace_ascii_case_insensitive(input: &str, needle: &str, replacement: &str) } output } - -pub(super) fn find_recoverable_balanced_open(key: &str, start: usize) -> Option { - let bytes = key.as_bytes(); - let mut candidate = start; - - while candidate < bytes.len() { - if bytes[candidate] != b'[' { - candidate += 1; - continue; - } - - let mut level = 1usize; - let mut scan = candidate + 1; - while scan < bytes.len() { - match bytes[scan] { - b'[' => level += 1, - b']' => { - level -= 1; - if level == 0 { - return Some(candidate); - } - } - _ => {} - } - scan += 1; - } - - candidate += 1; - } - - None -} diff --git a/src/decode/tests/keys.rs b/src/decode/tests/keys.rs index 014c9fd..fa2e50c 100644 --- a/src/decode/tests/keys.rs +++ b/src/decode/tests/keys.rs @@ -1,6 +1,4 @@ -use super::{ - DecodeOptions, Node, Value, find_recoverable_balanced_open, parse_keys, split_key_into_segments, -}; +use super::{DecodeOptions, Node, Value, parse_keys, split_key_into_segments}; fn scalar(value: &str) -> Node { Node::scalar(Value::String(value.to_owned())) @@ -10,15 +8,17 @@ fn scalar(value: &str) -> Node { fn structured_key_helpers_cover_recovered_roots_and_trailing_segments() { assert_eq!( split_key_into_segments("[a[b]", false, 5, false).unwrap(), - vec!["[a".to_owned(), "[b]".to_owned()] + vec!["[[a[b]]".to_owned()] ); - assert_eq!(find_recoverable_balanced_open("[a[b]", 1), Some(2)); assert_eq!( split_key_into_segments("a[b]tail", false, 5, false).unwrap(), - vec!["a".to_owned(), "[b]".to_owned(), "[tail]".to_owned()] + vec!["a".to_owned(), "[b]".to_owned()] + ); + assert_eq!( + split_key_into_segments("a[b]tail", false, 5, true).unwrap(), + vec!["a".to_owned(), "[b]".to_owned()] ); - assert!(split_key_into_segments("a[b]tail", false, 5, true).is_err()); } #[test] diff --git a/src/decode/tests/mod.rs b/src/decode/tests/mod.rs index 2734e2d..cd79310 100644 --- a/src/decode/tests/mod.rs +++ b/src/decode/tests/mod.rs @@ -4,9 +4,9 @@ pub(super) use super::scan::{ pub(super) use super::{ DefaultAccumulator, FlatValues, ParsedFlatValue, collect_pair_values, combine_with_limit, decode, decode_component, decode_from_pairs_map, decode_pairs, decode_scalar, - dot_to_bracket_top_level, finalize_flat, find_recoverable_balanced_open, - interpret_numeric_entities, interpret_numeric_entities_in_node, parse_keys, - parse_query_string_values, split_key_into_segments, value_list_length_for_combine, + dot_to_bracket_top_level, finalize_flat, interpret_numeric_entities, + interpret_numeric_entities_in_node, parse_keys, parse_query_string_values, + split_key_into_segments, value_list_length_for_combine, }; pub(super) use crate::internal::node::Node; pub(super) use crate::options::{ diff --git a/src/decode/tests/scanner.rs b/src/decode/tests/scanner.rs index 3ca14ad..17d50af 100644 --- a/src/decode/tests/scanner.rs +++ b/src/decode/tests/scanner.rs @@ -1,7 +1,6 @@ use super::{ Charset, DecodeDecoder, DecodeOptions, Delimiter, Regex, ScannedPart, Value, decode, - dot_to_bracket_top_level, find_recoverable_balanced_open, parse_query_string_values, - split_key_into_segments, + dot_to_bracket_top_level, parse_query_string_values, split_key_into_segments, }; use crate::options::DecodeKind; @@ -22,11 +21,15 @@ fn split_key_into_segments_handles_dots_and_unterminated_groups() { ); assert_eq!( split_key_into_segments("[", false, 5, false).unwrap(), - vec!["[".to_owned()] + vec!["[[]".to_owned()] ); assert_eq!( split_key_into_segments("a[b[c]", false, 5, true).unwrap(), - vec!["a[b".to_owned(), "[c]".to_owned()] + vec!["a".to_owned(), "[[b[c]]".to_owned()] + ); + assert_eq!( + split_key_into_segments("a.b", true, 0, false).unwrap(), + vec!["a[b]".to_owned()] ); } @@ -49,12 +52,6 @@ fn dot_before_bracket_preserves_literal_dot_in_parent_key() { ); } -#[test] -fn recoverable_balanced_open_finds_nested_group_after_unmatched_prefix() { - assert_eq!(find_recoverable_balanced_open("a[b[c]", 2), Some(3)); - assert_eq!(find_recoverable_balanced_open("a[b[c", 2), None); -} - #[test] fn parse_query_string_values_rejects_empty_string_delimiter() { let error = parse_query_string_values( diff --git a/tests/divergences.rs b/tests/divergences.rs index 371b0ab..851b95d 100644 --- a/tests/divergences.rs +++ b/tests/divergences.rs @@ -123,7 +123,7 @@ fn shared_port_function_filter_can_omit_branches_without_undefined_public_values } #[test] -fn node_compatible_top_level_dots_remain_raw_when_depth_is_zero() { +fn node_compatible_depth_zero_normalizes_dots_before_preserving_key() { let decoded = decode( "a.b=c", &DecodeOptions::new().with_allow_dots(true).with_depth(0), @@ -132,7 +132,7 @@ fn node_compatible_top_level_dots_remain_raw_when_depth_is_zero() { assert_eq!( decoded, - IndexMap::from([("a.b".to_owned(), Value::String("c".to_owned()))]) + IndexMap::from([("a[b]".to_owned(), Value::String("c".to_owned()))]) ); } diff --git a/tests/support/cases/node_parse.rs b/tests/support/cases/node_parse.rs index 80350b0..3b718b4 100644 --- a/tests/support/cases/node_parse.rs +++ b/tests/support/cases/node_parse.rs @@ -66,6 +66,17 @@ pub(crate) fn cases() -> Vec { "a[b][c]=d", DecodeOptions::new().with_depth(0), ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "parse.js", + "normalizes dots before applying depth zero", + "depth", + true, + ), + "a.b=c", + DecodeOptions::new().with_allow_dots(true).with_depth(0), + ), DecodeParityCase::new( CaseMeta::new( "node-qs", @@ -82,6 +93,116 @@ pub(crate) fn cases() -> Vec { "a[b][c][d]=e", DecodeOptions::new().with_depth(1).with_strict_depth(true), ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "parse.js", + "literal empty brackets inside bracket group", + "brackets", + true, + ), + "search[withbracket[]]=foobar", + DecodeOptions::new(), + ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "parse.js", + "single-level literal empty brackets inside bracket group", + "brackets", + true, + ), + "a[b[]]=c", + DecodeOptions::new(), + ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "parse.js", + "outer array push with literal empty brackets inside child group", + "brackets", + true, + ), + "list[][x[]]=y", + DecodeOptions::new(), + ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "parse.js", + "nested bracket pairs stay literal inside bracket group", + "brackets", + true, + ), + "a[b[c[]]]=d", + DecodeOptions::new(), + ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "parse.js", + "depth limit preserves literal nested bracket group", + "depth", + true, + ), + "a[b[c[]]][d]=e", + DecodeOptions::new().with_depth(1), + ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "parse.js", + "unterminated bracket group stays one literal segment", + "brackets", + true, + ), + "a[[]b=c", + DecodeOptions::new(), + ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "parse.js", + "trailing text after bracket group is ignored", + "brackets", + true, + ), + "a[b]tail=x", + DecodeOptions::new(), + ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "top-level percent-encoded bracket text is not mangled", + "encoded brackets", + true, + ), + "a%25255Bb=c", + DecodeOptions::new(), + ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "top-level percent-encoded closing bracket text is not mangled", + "encoded brackets", + true, + ), + "a%25255Db=c", + DecodeOptions::new(), + ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "nested percent-encoded bracket text is not mangled", + "encoded brackets", + true, + ), + "a%5Bb%25255Bc%5D=d", + DecodeOptions::new(), + ), DecodeParityCase::new( CaseMeta::new( "node-qs", From e16fd4947b0102aa0444c46109cef4b2c063365c Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 19 May 2026 14:44:29 +0100 Subject: [PATCH 3/8] fix(encode): match qs 6.15.2 stringify edge cases --- src/encode.rs | 2 +- src/encode/comma.rs | 59 +++++++++------ src/encode/scalar.rs | 7 +- src/encode/tests/helpers.rs | 65 ++++++++++++++-- tests/support/cases/csharp_encode.rs | 2 +- tests/support/cases/dart_encode.rs | 2 +- tests/support/cases/node_stringify.rs | 105 ++++++++++++++++++++++++++ 7 files changed, 201 insertions(+), 41 deletions(-) diff --git a/src/encode.rs b/src/encode.rs index babdda4..388043f 100644 --- a/src/encode.rs +++ b/src/encode.rs @@ -74,7 +74,7 @@ pub fn encode(value: &Value, options: &EncodeOptions) -> Result String { + if scalar_is_null_like(value, options) { + return String::new(); + } + if matches!(value, Value::Temporal(_)) { return encode_string_or_raw( &encoded_scalar_text(value, options) diff --git a/src/encode/scalar.rs b/src/encode/scalar.rs index ef23956..9eb53ac 100644 --- a/src/encode/scalar.rs +++ b/src/encode/scalar.rs @@ -62,12 +62,7 @@ pub(super) fn encode_string_or_raw(text: &str, options: &EncodeOptions) -> Strin } pub(super) fn encode_key_only_fragment(key: &str, options: &EncodeOptions) -> String { - let formatted = format_key_text(key, options); - if options.encode && matches!(options.format, Format::Rfc1738) { - formatted.replace('+', "%20") - } else { - formatted - } + format_key_text(key, options) } pub(super) fn format_key_text(text: &str, options: &EncodeOptions) -> String { diff --git a/src/encode/tests/helpers.rs b/src/encode/tests/helpers.rs index 1f91ab2..611b1ab 100644 --- a/src/encode/tests/helpers.rs +++ b/src/encode/tests/helpers.rs @@ -84,23 +84,61 @@ fn comma_arrays_emit_empty_list_suffixes_only_when_allowed() { } #[test] -fn comma_arrays_emit_key_only_fragments_for_skipped_nulls() { +fn comma_arrays_follow_node_null_joining_rules() { let path = super::KeyPathNode::from_raw("letters"); let base = EncodeOptions::new() .with_encode(false) - .with_list_format(ListFormat::Comma) - .with_skip_nulls(true) - .with_strict_null_handling(true); + .with_list_format(ListFormat::Comma); assert_eq!( - encode_comma_array(&[Value::Null, Value::Null], &path, &base), + encode_comma_array( + &[Value::Null, Value::String("x".to_owned())], + &path, + &base + .clone() + .with_skip_nulls(true) + .with_strict_null_handling(true), + ), + vec!["letters=,x".to_owned()] + ); + assert_eq!( + encode_comma_array( + &[Value::Null, Value::Null], + &path, + &base + .clone() + .with_skip_nulls(true) + .with_strict_null_handling(true), + ), + vec!["letters=,".to_owned()] + ); + assert_eq!( + encode_comma_array( + &[Value::Null], + &path, + &base + .clone() + .with_skip_nulls(true) + .with_strict_null_handling(true), + ), + Vec::::new() + ); + assert_eq!( + encode_comma_array( + &[Value::Null], + &path, + &base.clone().with_strict_null_handling(true), + ), vec!["letters".to_owned()] ); assert_eq!( encode_comma_array( &[Value::Null], &path, - &base.clone().with_comma_round_trip(true), + &base + .clone() + .with_strict_null_handling(true) + .with_comma_round_trip(true), ), vec!["letters[]".to_owned()] ); @@ -171,6 +209,17 @@ fn controlled_comma_arrays_can_compact_or_strictify_nulls() { .with_strict_null_handling(true) .with_comma_round_trip(true), ), + Vec::::new() + ); + assert_eq!( + encode_comma_array_controlled( + &items, + &path, + &base + .clone() + .with_strict_null_handling(true) + .with_comma_round_trip(true), + ), vec!["letters[]".to_owned()] ); } @@ -505,10 +554,10 @@ fn dot_escape_helper_matches_encode_flag() { } #[test] -fn strict_null_key_only_fragments_keep_percent_twenty_in_rfc1738_mode() { +fn strict_null_key_only_fragments_apply_rfc1738_formatting() { assert_eq!( encode_key_only_fragment("a b", &EncodeOptions::new().with_format(Format::Rfc1738)), - "a%20b" + "a+b" ); assert_eq!( encode_key_only_fragment("a b", &EncodeOptions::new().with_format(Format::Rfc3986)), diff --git a/tests/support/cases/csharp_encode.rs b/tests/support/cases/csharp_encode.rs index 37b7ff9..4893fe5 100644 --- a/tests/support/cases/csharp_encode.rs +++ b/tests/support/cases/csharp_encode.rs @@ -60,7 +60,7 @@ pub(crate) fn cases() -> Vec { CaseMeta::new( "csharp-qsnet", "EncodeTests.cs", - "charset sentinel uses ampersand before body even with custom delimiter", + "charset sentinel uses configured delimiter before body", "charset", true, ), diff --git a/tests/support/cases/dart_encode.rs b/tests/support/cases/dart_encode.rs index d92138f..251defc 100644 --- a/tests/support/cases/dart_encode.rs +++ b/tests/support/cases/dart_encode.rs @@ -8,7 +8,7 @@ pub(crate) fn cases() -> Vec { CaseMeta::new( "dart-qsdart", "encode_test.dart", - "strict null handling keeps percent-escaped spaces in key-only RFC1738 output", + "strict null handling applies RFC1738 formatting to key-only output", "null handling", true, ), diff --git a/tests/support/cases/node_stringify.rs b/tests/support/cases/node_stringify.rs index 76cdf0f..0934b68 100644 --- a/tests/support/cases/node_stringify.rs +++ b/tests/support/cases/node_stringify.rs @@ -153,6 +153,19 @@ pub(crate) fn cases() -> Vec { obj(vec![("a", Value::Null)]), EncodeOptions::new().with_strict_null_handling(true), ), + EncodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "strict null handling applies formatter to encoded key", + "null handling", + true, + ), + obj(vec![("a b", Value::Null), ("c d", s("e f"))]), + EncodeOptions::new() + .with_strict_null_handling(true) + .with_format(Format::Rfc1738), + ), EncodeParityCase::new( CaseMeta::new( "node-qs", @@ -202,6 +215,85 @@ pub(crate) fn cases() -> Vec { obj(vec![("a", arr(vec![s("x,y"), s("z")]))]), EncodeOptions::new().with_list_format(ListFormat::Comma), ), + EncodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "comma arrays with null keep an empty slot", + "arrays", + true, + ), + obj(vec![("a", arr(vec![Value::Null, s("b")]))]), + EncodeOptions::new().with_list_format(ListFormat::Comma), + ), + EncodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "comma arrays with null keep an empty slot under encode values only", + "arrays", + true, + ), + obj(vec![("a", arr(vec![Value::Null, s("b")]))]), + EncodeOptions::new() + .with_list_format(ListFormat::Comma) + .with_encode_values_only(true), + ), + EncodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "skip nulls does not compact mixed comma arrays", + "arrays", + true, + ), + obj(vec![("a", arr(vec![Value::Null, s("b")]))]), + EncodeOptions::new() + .with_list_format(ListFormat::Comma) + .with_encode_values_only(true) + .with_skip_nulls(true), + ), + EncodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "single null comma array emits empty value", + "arrays", + true, + ), + obj(vec![("a", arr(vec![Value::Null]))]), + EncodeOptions::new() + .with_list_format(ListFormat::Comma) + .with_encode_values_only(true), + ), + EncodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "single null comma array honors strict null handling", + "arrays", + true, + ), + obj(vec![("a", arr(vec![Value::Null]))]), + EncodeOptions::new() + .with_list_format(ListFormat::Comma) + .with_encode_values_only(true) + .with_strict_null_handling(true), + ), + EncodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "single null comma array is omitted with skip nulls", + "arrays", + true, + ), + obj(vec![("a", arr(vec![Value::Null]))]), + EncodeOptions::new() + .with_list_format(ListFormat::Comma) + .with_encode_values_only(true) + .with_skip_nulls(true), + ), EncodeParityCase::new( CaseMeta::new("node-qs", "stringify.js", "nested arrays", "arrays", true), obj(vec![("a", arr(vec![arr(vec![s("b")]), arr(vec![s("c")])]))]), @@ -302,6 +394,19 @@ pub(crate) fn cases() -> Vec { .with_charset(Charset::Iso88591) .with_charset_sentinel(true), ), + EncodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "charset sentinel uses configured delimiter", + "charset", + true, + ), + obj(vec![("a", Value::I64(1)), ("b", Value::I64(2))]), + EncodeOptions::new() + .with_charset_sentinel(true) + .with_delimiter(";"), + ), EncodeParityCase::new( CaseMeta::new( "node-qs", From 17b472843041e5e466386bdbea17468c8daafd5a Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 19 May 2026 14:44:48 +0100 Subject: [PATCH 4/8] docs: update qs semantic baseline to 6.15.2 --- README.md | 8 ++++---- docs/divergences.md | 6 +++--- tests/comparison/js/README.md | 2 +- tests/porting_ledger.md | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3e03997..81d593c 100644 --- a/README.md +++ b/README.md @@ -655,10 +655,10 @@ Before running the Node-backed tests, bootstrap the fixture environment: ```bash cd tests/comparison/js -npm ci +pnpm install --frozen-lockfile ``` -The checked-in `package-lock.json` pins `qs` to `6.15.1`. +The checked-in `pnpm-lock.yaml` pins `qs` to `6.15.2`. Rust-specific behavior lives alongside that parity layer: @@ -757,7 +757,7 @@ This repository now tracks the published `1.0.0` contract. The intended `1.x` co After `1.0.0`, changes should stay focused on bug fixes, test additions, documentation improvements, measurement-backed performance work, and additive features that keep the current `1.x` non-goals explicit. -- Node `qs` `6.15.1` remains the semantic baseline for shared public query-string behavior. +- Node `qs` `6.15.2` remains the semantic baseline for shared public query-string behavior. - C# remains the architectural reference for internal design decisions. Other sibling ports are informative, not normative. - The semantic core is shared across the dynamic API, the typed option/enums, the callback wrappers, and the optional `serde` bridge (`from_str` / `to_string`). - [docs/divergences.md](https://github.com/techouse/qs_rust/blob/main/docs/divergences.md) records the intentional `1.x` boundaries: host-object reflection, cycles, runtime bridge behavior, and other non-goals remain unsupported by design. @@ -791,4 +791,4 @@ Special thanks to the authors of [qs](https://www.npmjs.com/package/qs) for Java ## License -BSD 3-Clause © [techouse](https://github.com/techouse) \ No newline at end of file +BSD 3-Clause © [techouse](https://github.com/techouse) diff --git a/docs/divergences.md b/docs/divergences.md index 145ea1a..39f8dcc 100644 --- a/docs/divergences.md +++ b/docs/divergences.md @@ -1,6 +1,6 @@ # Divergence Matrix -`qs_rust` still uses Node `qs` `6.15.1` as the baseline for shared public query-string semantics, but it now tracks sibling-port behavior explicitly in three buckets: +`qs_rust` still uses Node `qs` `6.15.2` as the baseline for shared public query-string semantics, but it now tracks sibling-port behavior explicitly in three buckets: - `shared-port default`: Rust adopts the sibling-port extension or fix directly. - `Node-compatible default`: Rust intentionally keeps Node behavior even when another port diverges. @@ -11,7 +11,7 @@ | Case | Classification | Rust status | Coverage | | --- | --- | --- | --- | | Parameter counting includes skipped charset sentinel parameters and empty-key pairs | Node-compatible default | Kept | `tests/divergences.rs`, `tests/parity_decode.rs` | -| Top-level dotted keys remain raw at `depth = 0` even with `allow_dots = true` | Node-compatible default | Kept | `tests/divergences.rs`, `tests/porting_ledger.md` | +| Top-level dotted keys normalize before `depth = 0` preservation when `allow_dots = true` | Node-compatible default | Kept | `tests/divergences.rs`, `tests/parity_decode.rs` | | Negative or infinite limits (`depth`, `listLimit`, `parameterLimit`) from dynamic ports | Unsupported in Rust | Rejected at the type level (`usize`) | `tests/porting_ledger.md` | | Prototype/plain-object/null-prototype host behavior from Node | Unsupported in Rust | Not modeled | `tests/porting_ledger.md` | | Arbitrary host-object graphs, cycles, reflection-heavy map/object coercions | Unsupported in Rust | Not modeled | `tests/porting_ledger.md` | @@ -25,7 +25,7 @@ ## Notes -- For `1.x`, Node `qs` `6.15.1` remains the semantic baseline for shared public behavior, while the C# port remains the architectural reference for internal design choices. Other sibling ports are informative only. +- For `1.x`, Node `qs` `6.15.2` remains the semantic baseline for shared public behavior, while the C# port remains the architectural reference for internal design choices. Other sibling ports are informative only. - The public contract for `1.x` is the re-exported surface from `src/lib.rs` together with the intentional boundaries recorded in this matrix and the support/stability policy in `README.md`. - Optional features (`serde`, `chrono`, and `time`) follow the same MSRV and platform support policy as the core crate. - Rust does not silently inherit every sibling-port divergence. When sibling ports disagree and there is no clear shared correction, the crate stays Node-compatible by default. diff --git a/tests/comparison/js/README.md b/tests/comparison/js/README.md index 8dcee1f..6f1e9d3 100644 --- a/tests/comparison/js/README.md +++ b/tests/comparison/js/README.md @@ -1,4 +1,4 @@ Run `npm ci` in this directory before executing the Node comparison tests. -The lockfile is pinned to `qs` 6.15.1, and the Rust comparison harness shells out to +The lockfile is pinned to `qs` 6.15.2, and the Rust comparison harness shells out to `node tests/comparison/js/qs.js`. diff --git a/tests/porting_ledger.md b/tests/porting_ledger.md index 5141b5e..44c9505 100644 --- a/tests/porting_ledger.md +++ b/tests/porting_ledger.md @@ -5,7 +5,7 @@ | Behavior | Classification | Rust destination | Reason | | --- | --- | --- | --- | | Parameter counting still includes charset sentinel parameters and empty-key pairs | Node-compatible default | `tests/divergences.rs`, `tests/parity_decode.rs` | Sibling ports diverge here, but Rust keeps Node counting semantics as the public default. | -| Top-level dotted keys stay raw at `depth = 0` | Node-compatible default | `tests/divergences.rs` | C# and Kotlin carry special-case behavior here; Rust keeps the upstream Node result. | +| Top-level dotted keys normalize before `depth = 0` preservation | Node-compatible default | `tests/divergences.rs`, `tests/parity_decode.rs` | C# and Kotlin carried older special-case behavior here; Rust keeps the upstream Node result. | | Function filters, custom decoders, custom sorters, custom key/value token encoders, comma-null compaction, and encode depth guards | Shared-port default | `src/options.rs`, `src/decode.rs`, `src/encode.rs`, `tests/divergences.rs` | These are sibling-port extensions Rust now exposes directly in a Rust-idiomatic callback surface. | | Core temporal leaves plus native runtime conversions | Shared-port default | `src/temporal.rs`, `src/chrono_support.rs`, `src/time_support.rs`, `src/serde.rs`, `tests/feature_matrix.rs` | Rust now exposes `Value::Temporal`, a core `TemporalSerializer`, native `chrono` / `time` conversion helpers, and opt-in serde field helpers. Implicit host-object date detection outside the dynamic value model remains out of scope. | | Host-object reflection/prototype behavior, cycles, negative/infinite numeric limits, and runtime-specific bridge helpers | Unsupported in Rust | documented only | These depend on JS/Foundation/JVM/.NET runtime features or option ranges Rust does not expose. | @@ -43,7 +43,7 @@ | `qs.dart/test/e2e/e2e_test.dart` | not imported | skipped | no | The same round-trip territory is already covered by the Node smoke corpus, typed parity suites, and property tests. | | `QsNet/DecodeTests.cs` encoded-dot, top-level dot, comma-limit, nested-comma, and empty-list cases that agree with Node | `tests/support/cases/csharp_decode.rs`, `tests/parity_decode.rs` | ported | yes | These fill public decode gaps after verifying Node agrees on each case. | | `QsNet/DecodeTests.cs` enumerable/map-input `Qs.Decode(...)` behaviors for duplicates, normalized collisions, empty-key skipping, and object leaves | `tests/regressions.rs` | ported | no | These map to Rust `decode_pairs` rather than raw query-string parity. | -| `QsNet/DecodeTests.cs` top-level dot guardrail cases that preserve raw dotted keys at `depth=0` or recover malformed bracket roots differently from Node | not imported | skipped | no | Node remains the semantic oracle, and these C# expectations intentionally diverge from it. | +| `QsNet/DecodeTests.cs` top-level dot guardrail cases or malformed bracket root recovery cases that differ from Node | not imported | skipped | no | Node remains the semantic oracle, and these C# expectations intentionally diverge from it. | | `QsNet/EncodeTests.cs` nested dot encoding, allow-empty-list, strict-null, charset-sentinel, and comma `encodeValuesOnly` cases that agree with Node | `tests/support/cases/csharp_encode.rs`, `tests/parity_encode.rs` | ported | yes | These expose Rust-relevant encode edge cases and are verified against Node. | | `QsNet/EncodeTests.cs` byte-array, `encode=false`, and deep-chain fallback behaviors on the Rust public surface | `tests/regressions.rs`, `src/encode.rs` unit tests | ported | no | These are Rust-applicable behaviors that Node cannot represent directly. | | `QsNet/UtilsTests.cs` overflow append/shift and sparse compact behavior | `src/decode.rs`, `src/merge.rs`, `src/compact.rs` unit tests | ported | no | Internal overflow/compact mechanics are part of the semantic core. | From c7c1853ba26dcddc50280f9eaa8e4cdacf4bb067 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 19 May 2026 15:09:51 +0100 Subject: [PATCH 5/8] fix(docs): update installation instructions to use pnpm for Node comparison tests --- docs/release_checklist.md | 2 +- tests/comparison/js/README.md | 2 +- tests/support/mod.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/release_checklist.md b/docs/release_checklist.md index 97cfa29..92371f2 100644 --- a/docs/release_checklist.md +++ b/docs/release_checklist.md @@ -2,7 +2,7 @@ Before cutting any later `1.x` release: -- bootstrap the Node-backed comparison environment with `cd tests/comparison/js && npm ci` +- bootstrap the Node-backed comparison environment with `cd tests/comparison/js && pnpm install --frozen-lockfile` - run the feature-slice checks from CI: - `cargo test --locked` - `cargo test --locked --features serde` diff --git a/tests/comparison/js/README.md b/tests/comparison/js/README.md index 6f1e9d3..70dc9c1 100644 --- a/tests/comparison/js/README.md +++ b/tests/comparison/js/README.md @@ -1,4 +1,4 @@ -Run `npm ci` in this directory before executing the Node comparison tests. +Run `pnpm install --frozen-lockfile` in this directory before executing the Node comparison tests. The lockfile is pinned to `qs` 6.15.2, and the Rust comparison harness shells out to `node tests/comparison/js/qs.js`. diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 5f59fdd..dcdaeec 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -48,7 +48,7 @@ pub fn require_node_comparison(name: &str) -> bool { return true; } - eprintln!("skipping {name}; run npm ci in tests/comparison/js first"); + eprintln!("skipping {name}; run pnpm install --frozen-lockfile in tests/comparison/js first"); false } From e45e3f17933907d8500a04c61b790b52ae22137c Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 19 May 2026 15:11:11 +0100 Subject: [PATCH 6/8] fix(encode): remove redundant check for empty elements in encode_comma_array_controlled --- src/encode/comma.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/encode/comma.rs b/src/encode/comma.rs index 3329a23..45fffce 100644 --- a/src/encode/comma.rs +++ b/src/encode/comma.rs @@ -112,10 +112,6 @@ pub(super) fn encode_comma_array_controlled( return Vec::new(); } - if options.comma_compact_nulls && elements.is_empty() { - return Vec::new(); - } - if elements.is_empty() { return Vec::new(); } From 1683dbf719b6871fca947d582ab3dbc756217ae8 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 19 May 2026 15:17:58 +0100 Subject: [PATCH 7/8] fix(tests): add test for controlled_comma_arrays with mixed null and empty string values --- src/encode/comma.rs | 6 ------ src/encode/tests/helpers.rs | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/encode/comma.rs b/src/encode/comma.rs index 45fffce..92c4939 100644 --- a/src/encode/comma.rs +++ b/src/encode/comma.rs @@ -127,15 +127,9 @@ pub(super) fn encode_comma_array_controlled( if options.skip_nulls { return Vec::new(); } - let key_path = if options.comma_round_trip && kept_items == 1 { - path.append_empty_list_suffix() - } else { - path.clone() - }; if options.strict_null_handling { return vec![finalize_key_only_fragment(key_path.materialize(), options)]; } - let key = finalize_key_path(key_path.materialize(), options); return vec![format!("{key}=")]; } diff --git a/src/encode/tests/helpers.rs b/src/encode/tests/helpers.rs index 611b1ab..014b99a 100644 --- a/src/encode/tests/helpers.rs +++ b/src/encode/tests/helpers.rs @@ -222,6 +222,25 @@ fn controlled_comma_arrays_can_compact_or_strictify_nulls() { ), vec!["letters[]".to_owned()] ); + + let mixed_items = [Value::Null, Value::String(String::new())]; + assert_eq!( + encode_comma_array_controlled( + &mixed_items, + &path, + &EncodeOptions::new() + .with_encode(false) + .with_list_format(ListFormat::Comma) + .with_comma_compact_nulls(true) + .with_strict_null_handling(true) + .with_comma_round_trip(true) + .with_whitelist(Some(vec![ + WhitelistSelector::Index(0), + WhitelistSelector::Index(1), + ])), + ), + vec!["letters[]".to_owned()] + ); } #[test] From 9342b4df0a8a3b9ca13e08e4f3d5019ec971cfdb Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 19 May 2026 17:28:36 +0100 Subject: [PATCH 8/8] fix(tests): add test cases for percent-encoded brackets in top-level and nested keys --- tests/support/cases/node_stringify.rs | 77 +++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/support/cases/node_stringify.rs b/tests/support/cases/node_stringify.rs index 0934b68..18a568a 100644 --- a/tests/support/cases/node_stringify.rs +++ b/tests/support/cases/node_stringify.rs @@ -323,6 +323,83 @@ pub(crate) fn cases() -> Vec { .with_allow_dots(true) .with_encode_dot_in_keys(true), ), + EncodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "percent-encoded open bracket text in top-level key", + "encoded brackets", + true, + ), + obj(vec![("a%5Bb", s("c"))]), + EncodeOptions::new(), + ), + EncodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "percent-encoded close bracket text in top-level key", + "encoded brackets", + true, + ), + obj(vec![("a%5Db", s("c"))]), + EncodeOptions::new(), + ), + EncodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "double-encoded open bracket text in top-level key", + "encoded brackets", + true, + ), + obj(vec![("a%255Bb", s("c"))]), + EncodeOptions::new(), + ), + EncodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "double-encoded close bracket text in top-level key", + "encoded brackets", + true, + ), + obj(vec![("a%255Db", s("c"))]), + EncodeOptions::new(), + ), + EncodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "percent-encoded open bracket text in nested key", + "encoded brackets", + true, + ), + obj(vec![("a", obj(vec![("b%5Bc", s("d"))]))]), + EncodeOptions::new(), + ), + EncodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "double-encoded open bracket text in nested key", + "encoded brackets", + true, + ), + obj(vec![("a", obj(vec![("b%255Bc", s("d"))]))]), + EncodeOptions::new(), + ), + EncodeParityCase::new( + CaseMeta::new( + "node-qs", + "stringify.js", + "mixed encoded bracket text in top-level key", + "encoded brackets", + true, + ), + obj(vec![("a%5B%255Bb", s("c"))]), + EncodeOptions::new(), + ), EncodeParityCase::new( CaseMeta::new( "node-qs",