Skip to content
Open
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
117 changes: 81 additions & 36 deletions lib/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,58 +187,103 @@ var parseObject = function (chain, val, options, valuesParsed) {
return leaf;
};

var parseKeys = function parseQueryStringKeys(givenKey, val, options, valuesParsed) {
if (!givenKey) {
return;
// Split a key like "a[b][c[]]" into ['a', '[b]', '[c[]]'] with depth,
// dot-notation, and strictDepth semantics similar to qs.dart / other ports.
var splitKeyIntoSegments = function (originalKey, options) {
// depth == 0 → no splitting (keeps dots literal, per qs semantics)
if (options.depth <= 0) {
return [originalKey];
}

// Transform dot notation to bracket notation
var key = options.allowDots ? givenKey.replace(/\.([^.[]+)/g, '[$1]') : givenKey;

// The regex chunks
// Normalize dots only when depth > 0 (matches existing behavior)
var key = options.allowDots ? originalKey.replace(/\.([^.[]+)/g, '[$1]') : originalKey;

var brackets = /(\[[^[\]]*])/;
var child = /(\[[^[\]]*])/g;
var segments = [];

// Get the parent
// parent before the first '[' (may be empty if key starts with '[')
var first = key.indexOf('[');
var parent = first >= 0 ? key.slice(0, first) : key;
if (parent) {
// prototype guard for parent
if (!options.plainObjects && has.call(Object.prototype, parent) && !options.allowPrototypes) {
return; // behave like current parseKeys: bail out
}
segments.push(parent);
}

var segment = options.depth > 0 && brackets.exec(key);
var parent = segment ? key.slice(0, segment.index) : key;
var n = key.length;
var open = first;
var collected = 0;
var lastClose = -1;

while (open >= 0 && collected < options.depth) {
var level = 1;
var i = open + 1;
var close = -1;

// balance nested '[' and ']' inside this bracket group without using 'break'
while (i < n && close < 0) {
var cu = key.charCodeAt(i);
if (cu === 0x5B) { // '['
level += 1;
} else if (cu === 0x5D) { // ']'
level -= 1;
if (level === 0) {
close = i; // found matching close; loop will exit by condition
}
}
i += 1;
}

// Stash the parent if it exists
if (close < 0) {
// Unterminated group: wrap the raw remainder once as a single literal segment
segments.push('[' + key.slice(open) + ']');
return segments;
}

var keys = [];
if (parent) {
// If we aren't using plain objects, optionally prefix keys that would overwrite object prototype properties
if (!options.plainObjects && has.call(Object.prototype, parent)) {
if (!options.allowPrototypes) {
return;
}
var seg = key.slice(open, close + 1);
// prototype guard for the content of this group
var content = seg.slice(1, -1);
if (!options.plainObjects && has.call(Object.prototype, content) && !options.allowPrototypes) {
return;
}

keys.push(parent);
}
segments.push(seg);
lastClose = close;
collected += 1;

// Loop through children appending to the array until we hit depth
// find the next '[' after this balanced group
open = key.indexOf('[', close + 1);
}

var i = 0;
while (options.depth > 0 && (segment = child.exec(key)) !== null && i < options.depth) {
i += 1;
if (!options.plainObjects && has.call(Object.prototype, segment[1].slice(1, -1))) {
if (!options.allowPrototypes) {
return;
// Trailing text after the last balanced group → one final bracket segment (unless it's just '.')
if (lastClose >= 0 && lastClose + 1 < n) {
var remainder = key.slice(lastClose + 1);
if (remainder !== '.') {
if (options.strictDepth && open >= 0) {
throw new RangeError('Input depth exceeded depth option of ' + options.depth + ' and strictDepth is true');
}
segments.push('[' + remainder + ']');
}
} else if (open >= 0) {
// There are more groups beyond collected depth
if (options.strictDepth) {
throw new RangeError('Input depth exceeded depth option of ' + options.depth + ' and strictDepth is true');
}
keys.push(segment[1]);
segments.push('[' + key.slice(open) + ']');
}

// If there's a remainder, check strictDepth option for throw, else just add whatever is left
return segments;
};

if (segment) {
if (options.strictDepth === true) {
throw new RangeError('Input depth exceeded depth option of ' + options.depth + ' and strictDepth is true');
}
keys.push('[' + key.slice(segment.index) + ']');
var parseKeys = function parseQueryStringKeys(givenKey, val, options, valuesParsed) {
if (!givenKey) {
return;
}

var keys = splitKeyIntoSegments(givenKey, options);
if (!keys) { // prototype guard early-outs
return;
}

return parseObject(keys, val, options, valuesParsed);
Expand Down
25 changes: 25 additions & 0 deletions test/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,31 @@ test('parse()', function (t) {
st.end();
});

t.test('parses keys with literal [] inside a bracket group (#493)', function (st) {
// A bracket pair inside a bracket group should be treated literally as part of the key
st.deepEqual(
qs.parse('search[withbracket[]]=foobar'),
{ search: { 'withbracket[]': 'foobar' } },
'treats inner [] literally when inside a bracket group'
);

// Single-level variant
st.deepEqual(
qs.parse('a[b[]]=c'),
{ a: { 'b[]': 'c' } },
'keeps "b[]" as a literal key'
);

// Nested with an array push on the outer level
st.deepEqual(
qs.parse('list[][x[]]=y'),
{ list: [{ 'x[]': 'y' }] },
'preserves inner [] while still treating outer [] as array push'
);

st.end();
});

t.test('allows to specify array indices', function (st) {
st.deepEqual(qs.parse('a[1]=c&a[0]=b&a[2]=d'), { a: ['b', 'c', 'd'] });
st.deepEqual(qs.parse('a[1]=c&a[0]=b'), { a: ['b', 'c'] });
Expand Down