Repository: ramda/ramda (JavaScript)

Issue:
`lensPath` doesn't accept negative indexing
Here's what I am trying to do. I have a list of pages from `@tanstack/react-query`. When I take some action, I want to either append or update an item in _the last page_. I wanted to do something like this:

```typescript
interface PaginatedResponse<T> {
  items: T[];
  total: number;
  page: number;
  size: number;
}

const lastPageLens = lensPath(["pages", -1, "items"]);

const upsert = curry(function upsert(item: T, data: T[]) {
  const index = findIndex(propEq('id', item.id), data);
  return index < 0
    ? append(item, data)
    ? adjust<T>(index, mergeLeft(item), items)
});

function optimisticInsert<T>(item: T, data: InfiniteData<PaginatedResponse<T>>) {
  return over(lastPageLens, upsert(item));
}
```
However, when I run this, it will attempt to set the lens on `-1` in pages, i.e.
```typescript
{
  pages: {
    0: [some pages],
    -1: [the thing I wanted to upsert]
  }
}
```
I don't think this is intentional, as when I looked at `update` and `adjust`, they allowed for negative indexing


Discussion:
> I don't think this is intentional, as when I looked at update and adjust, they allowed for negative indexing

You're correct. `lensPath()`'s `getter` [function](https://github.com/ramda/ramda/blob/master/source/internal/_path.js#L13) utilizes `R.nth`, which works with negative indexes, but the `setter` [function](https://github.com/ramda/ramda/blob/master/source/internal/_assoc.js#L19) is doing regular index accessing



Patch:
diff --git a/source/adjust.js b/source/adjust.js
index f59385e8c..fa091eed0 100644
--- a/source/adjust.js
+++ b/source/adjust.js
@@ -7,6 +7,8 @@ import _curry3 from './internal/_curry3.js';
  * new copy of the array with the element at the given index replaced with the
  * result of the function application.
  *
+ * When `idx < -list.length || idx >= list.length`, the original list is returned.
+ *
  * @func
  * @memberOf R
  * @since v0.14.0
@@ -24,6 +26,10 @@ import _curry3 from './internal/_curry3.js';
  *
  *      R.adjust(1, R.toUpper, ['a', 'b', 'c', 'd']);      //=> ['a', 'B', 'c', 'd']
  *      R.adjust(-1, R.toUpper, ['a', 'b', 'c', 'd']);     //=> ['a', 'b', 'c', 'D']
+ *
+ *      // out-of-range returns original list
+ *      R.adjust(4, R.toUpper, ['a', 'b', 'c', 'd']);      //=> ['a', 'b', 'c', 'd']
+ *      R.adjust(-5, R.toUpper, ['a', 'b', 'c', 'd']);     //=> ['a', 'b', 'c', 'd']
  * @symb R.adjust(-1, f, [a, b]) = [a, f(b)]
  * @symb R.adjust(0, f, [a, b]) = [f(a), b]
  */
diff --git a/source/assoc.js b/source/assoc.js
index f7267eb27..d445c8492 100644
--- a/source/assoc.js
+++ b/source/assoc.js
@@ -21,6 +21,9 @@ import assocPath from './assocPath.js';
  * @example
  *
  *      R.assoc('c', 3, {a: 1, b: 2}); //=> {a: 1, b: 2, c: 3}
+ *
+ *      R.assoc(4, 3, [1, 2]); //=> [1, 2, undefined, undefined, 3]
+ *      R.assoc(-1, 3, [1, 2]); //=> [1, 3]
  */
 var assoc = _curry3(function assoc(prop, val, obj) { return assocPath([prop], val, obj); });
 export default assoc;
diff --git a/source/assocPath.js b/source/assocPath.js
index b32eb9035..719660686 100644
--- a/source/assocPath.js
+++ b/source/assocPath.js
@@ -1,8 +1,8 @@
 import _curry3 from './internal/_curry3.js';
-import _has from './internal/_has.js';
 import _isInteger from './internal/_isInteger.js';
 import _assoc from './internal/_assoc.js';
 import isNil from './isNil.js';
+import _prop from './internal/_prop.js';
 
 /**
  * Makes a shallow clone of an object, setting or overriding the nodes required
@@ -27,6 +27,8 @@ import isNil from './isNil.js';
  *
  *      // Any missing or non-object keys in path will be overridden
  *      R.assocPath(['a', 'b', 'c'], 42, {a: 5}); //=> {a: {b: {c: 42}}}
+ *      R.assocPath(['a', 1, 'c'], 42, {a: []}); // => {a: [undefined, {c: 42}]}
+ *      R.assocPath(['a', -1], 42, {a: [1, 2]}); // => {a: [1, 42]}
  */
 var assocPath = _curry3(function assocPath(path, val, obj) {
   if (path.length === 0) {
@@ -34,7 +36,10 @@ var assocPath = _curry3(function assocPath(path, val, obj) {
   }
   var idx = path[0];
   if (path.length > 1) {
-    var nextObj = (!isNil(obj) && _has(idx, obj) && typeof obj[idx] === 'object') ? obj[idx] : _isInteger(path[1]) ? [] : {};
+    var nextObj = _prop(idx, obj);
+    if (isNil(nextObj) || typeof nextObj !== 'object') {
+      nextObj = _isInteger(path[1]) ? [] : {};
+    }
     val = assocPath(Array.prototype.slice.call(path, 1), val, nextObj);
   }
   return _assoc(idx, val, obj);
diff --git a/source/internal/_assoc.js b/source/internal/_assoc.js
index a20d39e05..d4730d103 100644
--- a/source/internal/_assoc.js
+++ b/source/internal/_assoc.js
@@ -15,9 +15,12 @@ import _isInteger from './_isInteger.js';
  */
 export default function _assoc(prop, val, obj) {
   if (_isInteger(prop) && _isArray(obj)) {
+    var _idx = prop < 0 ? obj.length + prop : prop;
+
     var arr = [].concat(obj);
-    arr[prop] = val;
+    arr[_idx] = val;
     return arr;
+
   }
 
   var result = {};
diff --git a/source/internal/_prop.js b/source/internal/_prop.js
new file mode 100644
index 000000000..ceb83b263
--- /dev/null
+++ b/source/internal/_prop.js
@@ -0,0 +1,11 @@
+import _isInteger from './_isInteger.js';
+import _nth from './_nth.js';
+
+function _prop(p, obj) {
+  if (obj == null) {
+    return;
+  }
+  return _isInteger(p) ? _nth(p, obj) : obj[p];
+}
+
+export default _prop;
diff --git a/source/lensIndex.js b/source/lensIndex.js
index 4cdc57356..159996204 100644
--- a/source/lensIndex.js
+++ b/source/lensIndex.js
@@ -7,6 +7,8 @@ import update from './update.js';
 /**
  * Returns a lens whose focus is the specified index.
  *
+ * When `idx < -list.length || idx >= list.length`, `R.set` or `R.over`, the original list is returned.
+ *
  * @func
  * @memberOf R
  * @since v0.14.0
@@ -23,6 +25,10 @@ import update from './update.js';
  *      R.view(headLens, ['a', 'b', 'c']);            //=> 'a'
  *      R.set(headLens, 'x', ['a', 'b', 'c']);        //=> ['x', 'b', 'c']
  *      R.over(headLens, R.toUpper, ['a', 'b', 'c']); //=> ['A', 'b', 'c']
+ *
+ *      // out-of-range returns original list
+ *      R.set(R.lensIndex(3), 'x', ['a', 'b', 'c']);         //=> ['a', 'b', 'c']
+ *      R.over(R.lensIndex(-4), R.toUpper, ['a', 'b', 'c']); //=> ['a', 'b', 'c']
  */
 var lensIndex = _curry1(function lensIndex(n) {
   return lens(
diff --git a/source/prop.js b/source/prop.js
index e1be288c9..f562c5ed8 100644
--- a/source/prop.js
+++ b/source/prop.js
@@ -1,6 +1,5 @@
 import _curry2 from './internal/_curry2.js';
-import _isInteger from './internal/_isInteger.js';
-import _nth from './internal/_nth.js';
+import _prop from './internal/_prop.js';
 
 
 /**
@@ -25,10 +24,5 @@ import _nth from './internal/_nth.js';
  *      R.compose(R.inc, R.prop('x'))({ x: 3 }) //=> 4
  */
 
-var prop = _curry2(function prop(p, obj) {
-  if (obj == null) {
-    return;
-  }
-  return _isInteger(p) ? _nth(p, obj) : obj[p];
-});
+var prop = _curry2(_prop);
 export default prop;
diff --git a/source/update.js b/source/update.js
index 68d1b2f27..d85ab39b2 100644
--- a/source/update.js
+++ b/source/update.js
@@ -7,6 +7,8 @@ import always from './always.js';
  * Returns a new copy of the array with the element at the provided index
  * replaced with the given value.
  *
+ * When `idx < -list.length || idx >= list.length`, the original list is returned.
+ *
  * @func
  * @memberOf R
  * @since v0.14.0
@@ -21,6 +23,10 @@ import always from './always.js';
  *
  *      R.update(1, '_', ['a', 'b', 'c']);      //=> ['a', '_', 'c']
  *      R.update(-1, '_', ['a', 'b', 'c']);     //=> ['a', 'b', '_']
+ *
+ *      // out-of-range returns original list
+ *      R.update(3, '_', ['a', 'b', 'c']);      //=> ['a', 'b', 'c']
+ *      R.update(-4, '_', ['a', 'b', 'c']);     //=> ['a', 'b', 'c']
  * @symb R.update(-1, a, [b, c]) = [b, a]
  * @symb R.update(0, a, [b, c]) = [a, c]
  * @symb R.update(1, a, [b, c]) = [b, a]


Test patch:
diff --git a/test/assoc.js b/test/assoc.js
index aee925d74..2f7ba477b 100644
--- a/test/assoc.js
+++ b/test/assoc.js
@@ -49,4 +49,26 @@ describe('assoc', function() {
     assert.strictEqual(ary2[5], newValue);
   });
 
+  it('handles negative indexes from end of array', function() {
+    var newValue = 8;
+    var ary1 = [1, 2];
+    var ary2 = R.assoc(-2, 8, ary1);
+    eq(ary2, [8, 2]);
+    // Note: reference equality below!
+    assert.strictEqual(ary2[0], newValue);
+    assert.strictEqual(ary2[1], ary1[1]);
+  });
+
+  it('sets garbage key when negative indexes wraps to < 0', function() {
+    var newValue = 8;
+    var ary1 = [1, 2];
+    var ary2 = R.assoc(-3, 8, ary1);
+    var expected = [1, 2];
+    expected[-1] = 8;
+    eq(ary2, expected);
+    // Note: reference equality below!
+    assert.strictEqual(ary2[-1], newValue);
+    assert.strictEqual(ary2[0], ary1[0]);
+    assert.strictEqual(ary2[1], ary1[1]);
+  });
 });
diff --git a/test/assocPath.js b/test/assocPath.js
index 425d5056c..b2f30e93d 100644
--- a/test/assocPath.js
+++ b/test/assocPath.js
@@ -45,4 +45,18 @@ describe('assocPath', function() {
     eq(R.assocPath(['foo', 'bar', 'baz'], 42, {foo: null}), {foo: {bar: {baz: 42}}});
   });
 
+  it('sets in indexes regardless of length', function() {
+    eq(R.assocPath(['foo', 1, 0], 42, {foo : []}), {foo: [undefined, [42]]});
+  });
+
+  it('handles negative indexes from end of array', function() {
+    eq(R.assocPath(['foo', -1], 42, {foo : [1, 2, 3]}), {foo: [1, 2, 42]});
+    eq(R.assocPath(['foo', -1, 'X'], 42, {foo : [{a: 0}, {b: 0}]}), {foo: [{a: 0}, {b: 0, X: 42}]});
+  });
+
+  it('sets garbage key when negative indexes wraps to < 0', function() {
+    var expected = [1, 2, 3];
+    expected[-1] = 42;
+    eq(R.assocPath(['foo', -4], 42, {foo : [1, 2, 3]}), {foo: expected});
+  });
 });
diff --git a/test/lensPath.js b/test/lensPath.js
index 52cb3f3a6..ad31aa94c 100644
--- a/test/lensPath.js
+++ b/test/lensPath.js
@@ -29,6 +29,9 @@ describe('lensPath', function() {
       eq(R.set(R.lensPath(['X']), 0, testObj), {a: [{b: 1}, {b: 2}], d: 3, X: 0});
       eq(R.set(R.lensPath(['a', 0, 'X']), 0, testObj), {a: [{b: 1, X: 0}, {b: 2}], d: 3});
     });
+    it('treats negative index from the end of the array', function() {
+      eq(R.set(R.lensPath(['a', -1, 'X']), 0, testObj), {a: [{b: 1}, {b: 2, X: 0}], d: 3});
+    });
   });
   describe('over', function() {
     it('applies function to the value of the specified object property', function() {