Skip to content

Commit

Permalink
Merge pull request #263 from derbyjs/vararg-alternatives
Browse files Browse the repository at this point in the history
Add alternative signatures for Model#start and Model#on that don't use var-args
  • Loading branch information
ericyhwang committed Jul 11, 2019
2 parents 5e6ac76 + 055b8f3 commit aafd41d
Show file tree
Hide file tree
Showing 5 changed files with 454 additions and 20 deletions.
240 changes: 224 additions & 16 deletions lib/Model/events.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// @ts-check

var EventEmitter = require('events').EventEmitter;
var util = require('../util');
/** @type any */
var Model = require('./Model');

// These events are re-emitted as 'all' events, and they are queued up and
Expand Down Expand Up @@ -79,6 +82,11 @@ Model.prototype.emit = function(type) {
}
if (Model.MUTATOR_EVENTS[type]) {
if (this._silent) return this;
// `segments` is almost definitely an array of strings.
//
// A search for `.emit(` shows that `segments` is generated from either
// `Model#_splitPath` or `Model#_dereference`, both of which return an array
// of strings.
var segments = arguments[1];
var eventArgs = arguments[2];
this._emit(type + 'Immediate', segments, eventArgs);
Expand All @@ -105,14 +113,14 @@ Model.prototype.emit = function(type) {

Model.prototype._on = EventEmitter.prototype.on;
Model.prototype.addListener =
Model.prototype.on = function(type, pattern, cb) {
var listener = eventListener(this, pattern, cb);
Model.prototype.on = function(type, pattern, options, cb) {
var listener = eventListener(this, type, pattern, options, cb);
this._on(type, listener);
return listener;
};

Model.prototype.once = function(type, pattern, cb) {
var listener = eventListener(this, pattern, cb);
Model.prototype.once = function(type, pattern, options, cb) {
var listener = eventListener(this, type, pattern, options, cb);
function g() {
var matches = listener.apply(null, arguments);
if (matches) this.removeListener(type, g);
Expand All @@ -121,6 +129,13 @@ Model.prototype.once = function(type, pattern, cb) {
return g;
};

/**
* @typedef {Object} ModelOnOptions
* @property {boolean} [useEventObjects] - If true, the listener is called with
* `cb(event: ___Event, captures: string[])`, instead of the legacy var-args
* style `cb(captures..., [eventType], eventArgs..., passed)`.
*/

Model.prototype._removeAllListeners = EventEmitter.prototype.removeAllListeners;
Model.prototype.removeAllListeners = function(type, subpattern) {
// If a pattern is specified without an event type, remove all model event
Expand Down Expand Up @@ -221,28 +236,74 @@ Model.prototype.removeContextListeners = function(value) {
return this;
};

function eventListener(model, subpattern, cb) {
if (cb) {
// For signatures:
// model.on('change', 'example.subpath', callback)
/**
* @param {Model} model
* @param {string} eventType
*/
function eventListener(model, eventType, arg2, arg3, arg4) {
var subpattern, options, cb;
if (arg4) {
// on(eventType, path, options, cb)
subpattern = arg2;
options = arg3;
cb = arg4;
} else if (arg3) {
// on(eventType, path, cb)
// on(eventType, options, cb)
cb = arg3;
if (model.isPath(arg2)) {
subpattern = arg2;
} else {
options = arg2;
}
} else { // if (arg2)
// on(eventType, cb)
cb = arg2;
}
if (options) {
if (options.useEventObjects) {
var useEventObjects = true;
}
}

if (subpattern) {
// For signatures with pattern:
// model.on('change', 'example.subpath.**', callback)
// model.at('example').on('change', 'subpath', callback)
var pattern = model.path(subpattern);
return modelEventListener(pattern, cb, model._eventContext);
return (useEventObjects) ?
modelEventListener(eventType, pattern, cb, model._eventContext) :
modelEventListenerLegacy(pattern, cb, model._eventContext);
}
var path = model.path();
cb = arguments[1];
// For signature:
// For signature without explicit pattern:
// model.at('example').on('change', callback)
if (path) return modelEventListener(path, cb, model._eventContext);
/** @type string */
var path = model.path();
if (path) {
return (useEventObjects) ?
modelEventListener(eventType, path, cb, model._eventContext) :
modelEventListenerLegacy(path, cb, model._eventContext);
}
// For signature:
// model.on('normalEvent', callback)
return cb;
}

function modelEventListener(pattern, cb, eventContext) {
/**
* Legacy version of `modelEventListener` that calls `cb` with var-args
* `(captures..., [eventType], args..., passed)` instead of new-style
* `___Event` objects.
*
* @param {string} pattern
* @param {Function} cb
* @param {*} eventContext
* @return {ModelListenerFn & ModelListenerProps}
*/
function modelEventListenerLegacy(pattern, cb, eventContext) {
var patternSegments = util.castSegments(pattern.split('.'));
var testFn = testPatternFn(pattern, patternSegments);

/** @type ModelListenerFn */
function modelListener(segments, eventArgs) {
var captures = testFn(segments);
if (!captures) return;
Expand All @@ -260,6 +321,147 @@ function modelEventListener(pattern, cb, eventContext) {
return modelListener;
}

/**
* Returns a function that can be passed to `EventEmitter#on`, with some
* additional properties used for `Model#removeAllListeners`.
*
* When the function is called, it checks if the event matches `patternArg`, and
* if there's a match, it calls `cb`.
*
* @param {string} eventType
* @param {string} pattern
* @param {Function} cb
* @param {*} eventContext
* @return {ModelListenerFn & ModelListenerProps}
*/
function modelEventListener(eventType, pattern, cb, eventContext) {
var patternSegments = util.castSegments(pattern.split('.'));
var testFn = testPatternFn(pattern, patternSegments);

var eventFactory = getEventFactory(eventType);
/** @type ModelListenerFn */
function modelListener(segments, eventArgs) {
var captures = testFn(segments);
if (!captures) return;

var event = eventFactory(eventArgs);
cb(event, captures);
return true;
}

// Used in Model#removeAllListeners
modelListener.pattern = pattern;
modelListener.patternSegments = patternSegments;
modelListener.eventContext = eventContext;

return modelListener;
}

/** @typedef { (segments: string[], eventArgs: any[]) => (boolean | undefined) } ModelListenerFn */
/** @typedef { {pattern: string, patternSegments: Array<string | number>, eventContext: any} } ModelListenerProps */

/**
* Returns a factory function that creates an `___Event` object based on an
* old-style `eventArgs` array.
*
* @param {string} eventType
* @return {(eventArgs: any[]) => ChangeEvent | InsertEvent | RemoveEvent | MoveEvent | LoadEvent | UnloadEvent}
*/
function getEventFactory(eventType) {
switch (eventType) {
case 'change':
return function(eventArgs) {
return new ChangeEvent(eventArgs);
};
case 'insert':
return function(eventArgs) {
return new InsertEvent(eventArgs);
};
case 'remove':
return function(eventArgs) {
return new RemoveEvent(eventArgs);
};
case 'move':
return function(eventArgs) {
return new MoveEvent(eventArgs);
};
case 'load':
return function(eventArgs) {
return new LoadEvent(eventArgs);
};
case 'unload':
return function(eventArgs) {
return new UnloadEvent(eventArgs);
};
case 'all':
return function(eventArgs) {
var concreteEventType = eventArgs[0]; // 'change', 'insert', etc.
var concreteEventFactory = getEventFactory(concreteEventType);
return concreteEventFactory(eventArgs.slice(1));
};
default: throw new Error('Unknown event: ' + eventType);
}
}

// These constructors accept the `eventArgs` array format that Racer uses
// internally when calling `Model#emit`.
//
// Eventually, Racer should switch to passing these events around directly,
// but that will require updating all the places that parse the `eventArgs`
// array format, to extract things like `passed`.

function ChangeEvent(eventArgs) {
this.value = eventArgs[0];
this.previous = eventArgs[1];
this.passed = eventArgs[2];
}
ChangeEvent.prototype.type = 'change';

function InsertEvent(eventArgs) {
this.index = eventArgs[0];
this.values = eventArgs[1];
this.passed = eventArgs[2];
}
InsertEvent.prototype.type = 'insert';

function RemoveEvent(eventArgs) {
this.index = eventArgs[0];
this.removed = eventArgs[1];
this.passed = eventArgs[2];
}
RemoveEvent.prototype.type = 'remove';

function MoveEvent(eventArgs) {
this.from = eventArgs[0];
this.to = eventArgs[1];
this.howMany = eventArgs[2];
this.passed = eventArgs[3];
}
MoveEvent.prototype.type = 'move';

function LoadEvent(eventArgs) {
this.document = eventArgs[0];
this.passed = eventArgs[1];
}
LoadEvent.prototype.type = 'load';

function UnloadEvent(eventArgs) {
this.previousDocument = eventArgs[0];
this.passed = eventArgs[1];
}
UnloadEvent.prototype.type = 'unload';

/**
* Returns a function that tests an array of event segments against the
* `patternSegments`. (`pattern` only matters if it's exactly `'**'`.)
*
* @param {string?} pattern
* @param {Array<string | number>} patternSegments
* @return {(segments: string[]) => (string[] | undefined)} A function to test
* an array of event segments. If the event segments match, an array of 0 or
* more segments captured by `'*'` / `'**'` is returned, one per wildcard. If
* the event segments don't match, `undefined` is returned.
*/
function testPatternFn(pattern, patternSegments) {
if (pattern === '**') {
return function testPattern(segments) {
Expand All @@ -279,6 +481,7 @@ function testPatternFn(pattern, patternSegments) {
// if it ends in a rest wildcard and each of the corresponding
// segments are wildcards or equal.
if (patternLen === segments.length || endingRest) {
/** @type string[] */
var captures = [];
for (var i = 0; i < patternLen; i++) {
var patternSegment = patternSegments[i];
Expand All @@ -298,15 +501,20 @@ function testPatternFn(pattern, patternSegments) {
};
}

/**
* @param {Array<string | number>} segments
*/
function stripRestWildcard(segments) {
// ['example', '**'] -> ['example']; return true
var lastIndex = segments.length - 1;
if (segments[lastIndex] === '**') {
var lastSegment = segments[lastIndex];
if (lastSegment === '**') {
segments.pop();
return true;
}
// ['example', 'subpath**'] -> ['example', 'subpath']; return true
var match = /^([^\*]+)\*\*$/.exec(segments[lastIndex]);
if (typeof lastSegment !== 'string') return false;
var match = /^([^\*]+)\*\*$/.exec(lastSegment);
if (!match) return false;
segments[lastIndex] = match[1];
return true;
Expand Down
26 changes: 22 additions & 4 deletions lib/Model/fn.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,40 @@ function parseStartArguments(model, args, hasPath) {
} else {
fns = last;
}
// For `Model#start`, the first parameter is the output path.
var path;
if (hasPath) {
path = model.path(args.shift());
}
// The second-to-last original argument could be an options object.
// If it's not an array and not path-like, then it's an options object.
last = args[args.length - 1];
var options;
if (!model.isPath(args[args.length - 1])) {
if (!Array.isArray(last) && !model.isPath(last)) {
options = args.pop();
}
var i = args.length;

// `args` is just the input paths at this point.
var inputs;
if (args.length === 1 && Array.isArray(args[0])) {
// Inputs provided as one array:
// model.start(outPath, [inPath1, inPath2], fn);
inputs = args[0];
} else {
// Inputs provided as var-args:
// model.start(outPath, inPath1, inPath2, fn);
inputs = args;
}

// Normalize each input into a string path.
var i = inputs.length;
while (i--) {
args[i] = model.path(args[i]);
inputs[i] = model.path(inputs[i]);
}
return {
name: name,
path: path,
inputPaths: args,
inputPaths: inputs,
fns: fns,
options: options
};
Expand Down
4 changes: 4 additions & 0 deletions lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ AsyncGroup.prototype.add = function() {
};
};

/**
* @param {Array<string | number>} segments
* @return {Array<string | number>}
*/
function castSegments(segments) {
// Cast number path segments from strings to numbers
for (var i = segments.length; i--;) {
Expand Down
Loading

0 comments on commit aafd41d

Please sign in to comment.