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
8 changes: 4 additions & 4 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@
{
"name": "es5-full",
"path": "lib/dist/es5/mod/ts-utils.js",
"limit": "37.5 kb",
"limit": "37.75 kb",
"brotli": false,
"running": false
},
{
"name": "es6-full",
"path": "lib/dist/es6/mod/ts-utils.js",
"limit": "36 kb",
"limit": "36.5 kb",
"brotli": false,
"running": false
},
{
"name": "es5-full-brotli",
"path": "lib/dist/es5/mod/ts-utils.js",
"limit": "12.75 kb",
"limit": "13 kb",
"brotli": true,
"running": false
},
Expand All @@ -30,7 +30,7 @@
{
"name": "es5-zip",
"path": "lib/dist/es5/mod/ts-utils.js",
"limit": "14 Kb",
"limit": "14.5 Kb",
"gzip": true,
"running": false
},
Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# Unreleased
# Unreleased (Proposed 0.17.0)

## Changelog

### Features

- feat(array): add `arrIndexOfSubset()` and `arrLastIndexOfSubset()` to locate the first/last index at which an array of values matches contiguously within another, re-searching later/earlier occurrences of the leading value when it repeats (eg. matching one stack trace against another) instead of stopping at the first, wrong occurrence

### Bug Fixes

# v0.16.0 July 18th, 2026
Expand Down
4 changes: 2 additions & 2 deletions README.md

Large diffs are not rendered by default.

197 changes: 197 additions & 0 deletions lib/src/array/arrIndexOfSubset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/*
* @nevware21/ts-utils
* https://github.com/nevware21/ts-utils
*
* Copyright (c) 2026 NevWare21 Solutions LLC
* Licensed under the MIT license.
*/

import { isArrayLike } from "../helpers/base";
import { mathTrunc } from "../math/trunc";
import { arrIndexOf, arrLastIndexOf } from "./indexOf";

/**
* Normalizes a fromIndex argument the same way the native `indexOf()` / `lastIndexOf()` do -- an
* omitted (`undefined`) value falls back to defValue, while `null` and `NaN` both coerce to `0`
* (matching `ToInteger(null) === 0`), and any other finite value is truncated towards zero.
*/
function _normalizeFromIndex(value: number | null | undefined, defValue: number): number {
if (value === undefined) {
return defValue;
}

if (value === null || isNaN(value)) {
return 0;
}

return mathTrunc(value);
}

/**
* The arrIndexOfSubset() method returns the index within {@link theArray} at which {@link subset}
* first matches contiguously, comparing elements using strict equality (the same method used by the
* === or triple-equals operator).
*
* Unlike a simple `arrIndexOf()` of the first element of {@link subset}, this continues searching
* later occurrences of that first element until it finds one where the whole sequence lines up (or
* exhausts {@link theArray}), which avoids landing on the wrong occurrence when values repeat -- for
* example matching one stack trace against another when recursive calls repeat identical lines.
*
* If {@link theArray} has fewer remaining elements than {@link subset} from a candidate position, the
* comparison is limited to just those available elements -- so a {@link subset} that "runs off the end"
* of {@link theArray} is still considered a match against its available prefix. This makes it possible
* to match a shorter (eg. truncated) array against a longer one, or vice-versa.
* @function
* @since 0.17.0
* @group Array
* @group ArrayLike
* @typeParam T - Identifies the type of array elements
* @param theArray - The array or array-like object of elements to be searched.
* @param subset - The (ordered) array or array-like object of elements to locate within theArray.
* @param fromIndex - The index to start the search at. If the provided index value is a negative
* number, it is taken as the offset from the end of theArray. Default: 0 (search from the start).
* @returns The index within theArray at which subset fully matches (or matches theArray's available
* elements from that position), or -1 if no such position exists. Returns fromIndex (normalized to a
* valid index, or the length of theArray) if subset is empty.
* @example
* ```ts
* arrIndexOfSubset([1, 2, 3, 4, 5], [3, 4]); // 2
* arrIndexOfSubset([1, 2, 3, 4, 5], [4, 3]); // -1
* arrIndexOfSubset(["a", "b", "a", "b", "c"], ["b", "c"]); // 3
* arrIndexOfSubset([1, 2, 3], [3, 4, 5]); // 2 (matches the available "3")
* arrIndexOfSubset([1, 2, 3], []); // 0
*
* // Repeated values -- a plain indexOf() of the first element would land on index 0
* let haystack = ["at foo", "at bar", "at foo", "at bar", "at baz"];
* arrIndexOfSubset(haystack, ["at foo", "at bar", "at baz"]); // 2
* ```
*/
/*#__NO_SIDE_EFFECTS__*/
export function arrIndexOfSubset<T>(theArray: ArrayLike<T>, subset: ArrayLike<T>, fromIndex?: number): number {
if (!isArrayLike(theArray) || !isArrayLike(subset)) {
return -1;
}

let haystackLen = theArray.length;
let subsetLen = subset.length;

let searchFrom = _normalizeFromIndex(fromIndex, 0);
if (searchFrom < 0) {
searchFrom = haystackLen + searchFrom;
}

if (searchFrom < 0) {
searchFrom = 0;
}

if (subsetLen === 0) {
return searchFrom < haystackLen ? searchFrom : haystackLen;
}

while (searchFrom < haystackLen) {
let pos = arrIndexOf(theArray, subset[0], searchFrom);
if (pos === -1) {
return -1;
}

// Only compare as many elements as are actually available in theArray from pos
let matchLen = subsetLen < (haystackLen - pos) ? subsetLen : (haystackLen - pos);
let isMatch = true;
for (let lp = 1; isMatch && lp < matchLen; lp++) {
isMatch = theArray[pos + lp] === subset[lp];
}

if (isMatch) {
return pos;
}

searchFrom = pos + 1;
}

return -1;
}

/**
* The arrLastIndexOfSubset() method returns the last index within {@link theArray} at which
* {@link subset} matches contiguously, comparing elements using strict equality (the same method
* used by the === or triple-equals operator). It is the mirror of {@link arrIndexOfSubset}, searching
* backwards from {@link fromIndex} (or the end of theArray) towards the start.
*
* As with {@link arrIndexOfSubset}, if {@link theArray} has fewer remaining elements than
* {@link subset} from a candidate position, the comparison is limited to just those available
* elements -- so a {@link subset} that "runs off the end" of {@link theArray} is still considered a
* match against its available prefix.
* @function
* @since 0.17.0
* @group Array
* @group ArrayLike
* @typeParam T - Identifies the type of array elements
* @param theArray - The array or array-like object of elements to be searched.
* @param subset - The (ordered) array or array-like object of elements to locate within theArray.
* @param fromIndex - The index to start searching backwards from (the position at which subset's
* first element may occur). If the provided index value is a negative number, it is taken as the
* offset from the end of theArray. Default: theArray's length - 1 (search from the end).
* @returns The last index within theArray at which subset fully matches (or matches theArray's
* available elements from that position), or -1 if no such position exists. Returns fromIndex
* (normalized to a valid index, or theArray's length) if subset is empty.
* @example
* ```ts
* arrLastIndexOfSubset([1, 2, 3, 4, 5], [3, 4]); // 2
* arrLastIndexOfSubset([1, 2, 3, 4, 5], [4, 3]); // -1
* arrLastIndexOfSubset(["a", "b", "a", "b", "c"], ["a", "b"]); // 2 (the later occurrence)
* arrLastIndexOfSubset([1, 2, 3], [3, 4, 5]); // 2 (matches the available "3")
* arrLastIndexOfSubset([1, 2, 3], []); // 3
*
* // Repeated values -- returns the later occurrence, unlike arrIndexOfSubset()
* let haystack = ["at foo", "at bar", "at foo", "at bar", "at baz"];
* arrLastIndexOfSubset(haystack, ["at foo", "at bar"]); // 2
* ```
*/
/*#__NO_SIDE_EFFECTS__*/
export function arrLastIndexOfSubset<T>(theArray: ArrayLike<T>, subset: ArrayLike<T>, fromIndex?: number): number {
if (!isArrayLike(theArray) || !isArrayLike(subset)) {
return -1;
}

let haystackLen = theArray.length;
let subsetLen = subset.length;

let searchFrom = _normalizeFromIndex(fromIndex, haystackLen);
if (searchFrom < 0) {
searchFrom = haystackLen + searchFrom;
}

if (searchFrom > haystackLen) {
searchFrom = haystackLen;
}

if (subsetLen === 0) {
return searchFrom < 0 ? 0 : searchFrom;
}

if (searchFrom > haystackLen - 1) {
searchFrom = haystackLen - 1;
}

while (searchFrom >= 0) {
let pos = arrLastIndexOf(theArray, subset[0], searchFrom);
if (pos === -1) {
return -1;
}

// Only compare as many elements as are actually available in theArray from pos
let matchLen = subsetLen < (haystackLen - pos) ? subsetLen : (haystackLen - pos);
let isMatch = true;
for (let lp = 1; isMatch && lp < matchLen; lp++) {
isMatch = theArray[pos + lp] === subset[lp];
}

if (isMatch) {
return pos;
}

searchFrom = pos - 1;
}

return -1;
}
1 change: 1 addition & 0 deletions lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export { arrFrom } from "./array/from";
export { ArrGroupByCallbackFn, arrGroupBy } from "./array/groupBy";
export { arrContains, arrIncludes } from "./array/includes";
export { arrIndexOf, arrLastIndexOf } from "./array/indexOf";
export { arrIndexOfSubset, arrLastIndexOfSubset } from "./array/arrIndexOfSubset";
export { arrIntersection } from "./array/intersection";
export { arrIndexKeys } from "./array/arrIndexKeys";
export { arrMap } from "./array/map";
Expand Down
4 changes: 2 additions & 2 deletions lib/test/bundle-size-check.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ const configs = [
{
name: "es5-min-full",
path: "../bundle/es5/umd/ts-utils.min.js",
limit: 40.5 * 1024, // 40.5 kb in bytes
limit: 41.5 * 1024, // 41.5 kb in bytes
compress: false
},
{
name: "es6-min-full",
path: "../bundle/es6/umd/ts-utils.min.js",
limit: 39.5 * 1024, // 39.5 kb in bytes
limit: 40.5 * 1024, // 40.5 kb in bytes
compress: false
},
{
Expand Down
Loading