Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add alternative signatures for Model#start and Model#on that don't use var-args #263

Merged
merged 5 commits into from
Jul 11, 2019
Merged
Show file tree
Hide file tree
Changes from 2 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
212 changes: 192 additions & 20 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 Down Expand Up @@ -106,13 +114,13 @@ 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);
var listener = eventListener(this, type, pattern, cb);
this._on(type, listener);
return listener;
};

Model.prototype.once = function(type, pattern, cb) {
var listener = eventListener(this, pattern, cb);
var listener = eventListener(this, type, pattern, cb);
function g() {
var matches = listener.apply(null, arguments);
if (matches) this.removeListener(type, g);
Expand Down Expand Up @@ -221,35 +229,88 @@ Model.prototype.removeContextListeners = function(value) {
return this;
};

function eventListener(model, subpattern, cb) {
/**
* @param {Model} model
* @param {string} eventType
* @param {string | Model} subpattern
* @param {Function} cb
*/
function eventListener(model, eventType, subpattern, cb) {
if (cb) {
// For signatures:
// model.on('change', 'example.subpath', callback)
// model.at('example').on('change', 'subpath', callback)
var pattern = model.path(subpattern);
return modelEventListener(pattern, cb, model._eventContext);
var pattern;
if (Array.isArray(subpattern)) {
pattern = model._splitPath().concat(subpattern);
} else {
pattern = model.path(subpattern);
}
return modelEventListener(eventType, pattern, cb, model._eventContext);
}
var path = model.path();
cb = arguments[1];
cb = arguments[2];
// For signature:
// model.at('example').on('change', callback)
if (path) return modelEventListener(path, cb, model._eventContext);
if (path) return modelEventListener(eventType, path, cb, model._eventContext);
// For signature:
// model.on('normalEvent', callback)
return cb;
}

function modelEventListener(pattern, cb, eventContext) {
var patternSegments = util.castSegments(pattern.split('.'));
var testFn = testPatternFn(pattern, patternSegments);

function modelListener(segments, eventArgs) {
var captures = testFn(segments);
if (!captures) return;
/**
* 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 | string[]} patternArg
* @param {Function} cb
* @param {*} eventContext
* @return {ModelListenerFn & ModelListenerProps}
*/
function modelEventListener(eventType, patternArg, cb, eventContext) {
ericyhwang marked this conversation as resolved.
Show resolved Hide resolved
ericyhwang marked this conversation as resolved.
Show resolved Hide resolved
var pattern, patternSegments, useEventClasses;
if (Array.isArray(patternArg)) {
// If `patternArg` was provided as an array, then the `cb` listener will be
// invoked with an `____Event` object.
useEventClasses = true;
patternSegments = util.castSegments(patternArg);
if (patternSegments.length === 1 && patternSegments[0] === '**') {
pattern = '**';
ericyhwang marked this conversation as resolved.
Show resolved Hide resolved
}
} else {
// Old-style: If `patternArg` was provided as a string, then the `cb`
// listener will invoked with a variable number of arguments.
useEventClasses = false;
pattern = patternArg;
patternSegments = util.castSegments(patternArg.split('.'));
}
var testFn = testPatternFn(patternSegments, pattern);

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

var event = eventFactory(eventArgs);
cb(event, captures);
return true;
};
} else {
modelListener = function(segments, eventArgs) {
var captures = testFn(segments);
if (!captures) return;

var args = (captures.length) ? captures.concat(eventArgs) : eventArgs;
cb.apply(null, args);
return true;
var args = (captures.length) ? captures.concat(eventArgs) : eventArgs;
cb.apply(null, args);
return true;
};
}

// Used in Model#removeAllListeners
Expand All @@ -260,7 +321,112 @@ function modelEventListener(pattern, cb, eventContext) {
return modelListener;
}

function testPatternFn(pattern, patternSegments) {
/** @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 {Array<string | number>} patternSegments
* @param {string?} pattern
* @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. If the event segments
* don't match, `undefined` is returned.
*/
function testPatternFn(patternSegments, pattern) {
if (pattern === '**') {
return function testPattern(segments) {
return [segments.join('.')];
Expand All @@ -279,6 +445,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 +465,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);
ericyhwang marked this conversation as resolved.
Show resolved Hide resolved
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