Skip to content
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
BSD 3-Clause © [techouse](https://github.com/techouse)
6 changes: 3 additions & 3 deletions docs/divergences.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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` |
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/release_checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion src/decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
84 changes: 8 additions & 76 deletions src/decode/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@ pub(crate) fn split_key_into_segments(
max_depth: usize,
strict_depth: bool,
) -> Result<Vec<String>, 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]);
Expand All @@ -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 {
Expand All @@ -63,51 +61,25 @@ 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('[')
.map(|relative| close_index + 1 + relative);
}

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)
Expand Down Expand Up @@ -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", ".");
}
Expand Down Expand Up @@ -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<usize> {
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
}
14 changes: 7 additions & 7 deletions src/decode/tests/keys.rs
Original file line number Diff line number Diff line change
@@ -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()))
Expand All @@ -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]
Expand Down
6 changes: 3 additions & 3 deletions src/decode/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down
17 changes: 7 additions & 10 deletions src/decode/tests/scanner.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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()]
);
}

Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/encode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ pub fn encode(value: &Value, options: &EncodeOptions) -> Result<String, EncodeEr

if options.charset_sentinel {
output.push_str(options.charset.sentinel());
output.push('&');
output.push_str(&options.delimiter);
}

output.push_str(&body);
Expand Down
Loading