import {
each as _each, every, identity, isArray, isFunction, map as _map, mapObject,
reduce as _reduce, reduceRight
} from 'underscore';
import { StereoscopyError } from './errors.js';
import { at_maybe, cloneImpl } from '../src-cjs/constants.js';
import Optic from './optic.js';
import { index_maybe, isLens, lensCap } from './utils.js';
/**
* @extends Optic
* @hideconstructor
*/
export class AbstractNFocal extends Optic {
/**
* @summary Abstract base class for multifocal (i.e. n-focal) optics
* @param {Array.<Optic> | Object.<string,Optic>} lenses Optics to be aggregated
*
* @description
* **NOTE:** The *lenses* argument is captured by the new AbstractNFocal-derived
* object, meaning later changes to the object passed as *lenses* propagate
* to the constructed AbstractNFocal.
*
* AbstractNFocal objects (including those of derived classes) are, themselves,
* lensable containers of their constituent lenses.
*/
constructor(lenses) {
super();
this.lenses = lenses;
}
/**
* @member {Array.<Optic> | Object.<string,Optic>} AbstractNFocal#lenses
* @summary [Optics]{@link Optic} aggregated by this object
*/
[at_maybe](idx) {
return index_maybe(this.lenses, idx);
}
[cloneImpl](alteration) {
const lenses = this.lenses[cloneImpl](alteration);
return makeNFocal(lenses);
}
/**
* @summary Test which constituent lenses are present in a subject
* @param {*} subject The data to test
* @returns {Array.<number|string>} Array of keys to *this.lenses* where the presence-test result corresponds to *this.lenses* by key/index
*
* @description
* AbstractNFocals never produce `undefined` from their implementation of `#get`;
* at very least they produce and empty Array or empty Object, both of which
* are truthy and Objects. More helpfully, this method returns an Array of
* the keys/indexes in *this.lenses* where the slot of the corresponding lens
* is present in *subject*. This result is also invariably truthy, just like
* the result of `#get` is invariably *not* `undefined`.
*/
present(subject) {
return _reduce(
this.lenses,
(found, lens, idx) => lens.present(subject) ? found.concat(idx) : found,
[]
);
}
/**
* @typedef {Array} AbstractNFocal.TransformSpec
* @property {*} 0 - lens index/key
* @property {Function} 1 - transform function to apply
*
* @description
* Indicates a transform Function and the index/key of the Lens identifying
* the slot over which to apply the transform.
*/
/**
* @template T
* @summary Apply transforms to selected slots within this multifocal while making a clone
* @param {T} subject The input structured data
* @param {Iterable.<AbstractNFocal.TransformSpec>} xformPairs Iterable of constituent lens key and transform function pairs to apply
* @param {(Function|Object)} [opts] Options for the constituent optic's `xformInClone` or a function taking the slot key and returning the options
* @return {T} A minimally changed clone of *subject* with the slots of this multifocal selected by *xformPairs* transformed according to the corresponding Function
*
* @description
* An element of *xformPairs* that targets a lens not existing in this object
* is a no-op. Behavior for an *xformPairs* element targeting a non-existent
* slot in *subject* depends on *opts*.
*
* Transforms are applied in the order in which they occur in *xformPairs*.
*/
xformInClone(subject, xformArray, opts = {}) {
if (!isFunction(opts)) {
opts = identity.bind(null, opts);
}
return _reduce(
xformArray,
(cur, [key, xform]) => {
const lens = this.lenses[key];
return lens ? lens.xformInClone(cur, xform, opts(key)) : cur;
},
subject
);
}
/**
* @template T
* @summary Apply transforms to selected slots (using a Maybe monad) within this multifocal while making a clone
* @param {T} subject The input structured data
* @param {Iterable.<AbstractNFocal.TransformSpec>} xformPairs Iterable of constituent lens key and transform function pairs to apply
* @returns {T} A minimally changed clone of *subject* with the slots of this multifocal selected by keys in *xformPairs* transformed according to the corresponding Function
*
* @description
* An element of *xformPairs* that targets a lens not existing in this object
* is a no-op. Any transform function called will be called with the slot
* value in a {@link Maybe} monad and the result expected to provide the new value
* in a {@link Maybe} monad: the Nothing construction (`{}`) will be passed if the
* slot does not exist in *subject* and return of the Nothing construct
* will cause the clone to omit the targeted slot.
*
* Transforms are applied in the order in which they occur in *xformPairs*.
*/
xformInClone_maybe(subject, xformArray) {
return _reduce(
xformArray,
(cur, [key, xform]) => {
const lens = this.lenses[key];
return lens ? lens.xformInClone_maybe(cur, xform) : cur;
},
subject
);
}
}
/**
* @extends AbstractNFocal
* @summary Multifocal (i.e. n-focal) building an Array
* @hideconstructor
*/
export class ArrayNFocal extends AbstractNFocal {
/**
* @summary Return the length of Array of constituent lenses (also the length of the result)
*/
get length() {
return this.lenses.length;
}
/**
* @inheritdoc
*/
get(subject, ...tail) {
const subjResult = this.get_maybe(subject).just;
if (tail.length > 0) {
return new ArrayNFocal(
_map(subjResult, l => isLens(l) ? l : lensCap)
).get(...tail);
}
return subjResult;
}
/**
* @inheritdoc
*/
get_maybe(subject, ...tail) {
const subjResult = new Array(this.lenses.length);
for (var i = 0; i < this.lenses.length; i++) {
const iVal_maybe = this.lenses[i].get_maybe(subject);
if ('just' in iVal_maybe) {
subjResult[i] = iVal_maybe.just;
}
}
if (tail.length > 0) {
return new ArrayNFocal(
_map(subjResult, l => isLens(l) ? l : lensCap)
).get_maybe(...tail);
} else {
return {just: subjResult, multiFocal: true};
}
}
/* istanbul ignore next */
/**
* @summary Get the iterable value of this slot within some subject data
* @param {*} subject The data to query
* @returns {Array.<*>} An Array of values obtained from *subject* via *this.lenses*
*
* @description
* In this class, this method is synonymous with a call to [get]{@link ArrayNFocal#get} with
* a single parameter.
*/
getIterable(subject) {
return this.get(subject);
}
/**
* @template T
* @summary Clone *subject*, setting all values corresponding to elements of this multifocal within the clone
* @param {T} subject The input structured data
* @param {Array.<*>} newVals The new values corresponding to this multifocal's lenses
* @returns {T} A minimally changed clone of *subject* with *newVals* distributed via *this.lenses*
* @throws {StereoscopyError} If this object's view of *subject* cannot become *newVals*
* @see {@link AbstractNFocal#xformInClone_maybe}
*
* @description
* Similar in concept to {@link Lens#setInClone}, this method creates a modified
* clone of *subject* such that applying this optic to the new value produces
* a value deep-equal to *newVals*. Due to the multifocal nature, it is possible
* that no such result can be created, which results in a {@link StereoscopyError}.
*
* It is possible to delete the target of one or more of *this.lenses* by
* passing *newVals* with *empty* elements or with fewer elements than
* in *this.lenses*. The easiest way to accomplish this is to create the
* Array of new values, then use `delete` on the indexes whose corresponding
* slots should be removed from *subject*.
*/
setInClone(subject, newVals) {
const valSource = newVals || [];
const result = this.xformInClone_maybe(
subject,
_map(this.lenses, (l, i) =>
[i, () => (i in valSource) ? {just: valSource[i]} : {}]
)
);
checkSet.call(this, result, newVals);
return result;
}
}
/**
* @extends AbstractNFocal
* @summary Multifocal (i.e. n-focal) building an Object
*/
export class ObjectNFocal extends AbstractNFocal {
/**
* @inheritdoc
*/
get(subject, ...tail) {
const subjResult = {};
_each(this.lenses, (lens, prop) => {
const propVal_maybe = lens.get_maybe(subject);
if ('just' in propVal_maybe) {
subjResult[prop] = propVal_maybe.just;
}
});
if (tail.length > 0) {
return new ObjectNFocal(
mapObject(subjResult, l => isLens(l) ? l : lensCap)
).get(...tail);
}
return subjResult;
}
/**
* @inheritdoc
*/
get_maybe(subject, ...tail) {
const subjResult = this.get(subject);
if (tail.length > 0) {
return new ObjectNFocal(
mapObject(subjResult, l => isLens(l) ? l : lensCap)
).get_maybe(...tail);
}
return {just: subjResult, multiFocal: true};
}
/* istanbul ignore next */
/**
* @summary Get an empty Array
* @returns {Array} An empty array
*
* @description
* Because an ObjectNFocal always gets an Object, there is no way to create
* an iterable value from the "virtual slot" it accesses. Therefore, the
* result of the inherited implementation would always yield an empty Array
* and just returning that value is more efficient.
*/
getIterable() {
return [];
}
/**
* @template T
* @summary Clone *subject*, setting all values corresponding to elements of this multifocal within the clone
* @param {T} subject The input structured data
* @param {Object.<string,*>} newVals The new values corresponding to this multifocal's lenses
* @returns {T} A minimally changed clone of *subject* with *newVals* distributed via *this.lenses*
* @throws {StereoscopyError} If this object's view of *subject* cannot become *newVals*
* @see {@link AbstractNFocal#xformInClone_maybe}
*
* @description
* Similar in concept to {@link Lens#setInClone}, this method creates a modified
* clone of *subject* such that applying this optic to the new value produces
* a value deep-equal to *newVals*. Due to the multifocal nature, it is possible
* that no such result can be created, which results in a {@link StereoscopyError}.
*
* The slot corresponding to any constituent lens whose name is left out of
* *newVals* will be deleted from the clone of *subject*.
*/
setInClone(subject, newVal) {
const valSource = newVal || {};
const result = this.xformInClone_maybe(
subject,
mapObject(this.lenses, (l, k) =>
[k, () => (k in valSource) ? {just: valSource[k]} : {}]
)
);
checkSet.call(this, result, newVal);
return result;
}
}
export function makeNFocal(lenses) {
return new (isArray(lenses) ? ArrayNFocal : ObjectNFocal)(lenses);
}
/**
* @private
* @this AbstractNFocal
* @param {Object|Array} result
* @param {Object|Array} expectedVal
* @throws {StereoscopyError} Thrown if this object's view of *result* is inconsistent with *expectedVal*
* @description
* Many parts of this method are never expected to execute, but are in
* place in case of unexpected results from other operations.
*/
function checkSet(result, expectedVal) {
const checkVal_maybe = this.get_maybe(result);
/* istanbul ignore if */
if (!('just' in checkVal_maybe)) {
throw new StereoscopyError("Slot not present when it should be");
} else {
let sameValue = false;
switch (typeof expectedVal) {
/* istanbul ignore next */
case 'bigint':
/* istanbul ignore next */
case 'boolean':
/* istanbul ignore next */
case 'function':
/* istanbul ignore next */
case 'number':
/* istanbul ignore next */
case 'string':
/* istanbul ignore next */
case 'symbol':
/* istanbul ignore next */
case 'undefined':
sameValue = Object.is(expectedVal, checkVal_maybe.just);
break;
case 'object':
/* istanbul ignore if */
if (expectedVal === null) {
sameValue = (checkVal_maybe.just === null);
} else {
sameValue = every(expectedVal, (v, k) =>
Object.is(checkVal_maybe.just[k], v)
) && every(Object.keys(checkVal_maybe.just), k => k in expectedVal);
}
break;
/* istanbul ignore next */
default:
throw new Error(`Unrecognized value type '${typeof expectedVal}'`);
}
if (!sameValue) {
throw new StereoscopyError("Altered slot in clone has unexpected value");
}
}
}