From 8a44d91c83b208d714fb8f212770570d76e9f7e7 Mon Sep 17 00:00:00 2001 From: "Alex A. Naanou" Date: Wed, 2 Jun 2021 11:52:42 +0300 Subject: [PATCH] cleanup + tweaking docs... Signed-off-by: Alex A. Naanou --- README.md | 90 +- features.js | 2285 ++++++++++++++++++++++++++------------------------- 2 files changed, 1192 insertions(+), 1183 deletions(-) diff --git a/README.md b/README.md index 9b40961..a4f2289 100755 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Features -Features is a module that helps build _features_ out of sets of actions -apply them to objects and manage sets of features via external criteria -and feature-to-feature dependencies. +`features.js` organizes sets of [actions](https://github.com/flynx/actions.js) +or _objects_ into features, apply them to objects, manage sets of features via +inter-feature dependencies and external criteria. ### The main entities: @@ -31,57 +31,57 @@ XXX **Feature** ```javascript feature_set.Feature({ - tag: 'minimal_feature_example', + tag: 'minimal_feature_example', }) feature_set.Feature({ - // documentation (optional)... - title: 'Example Feature', - doc: 'A feature to demo the base API...', + // documentation (optional)... + title: 'Example Feature', + doc: 'A feature to demo the base API...', - // feature unique identifier (required)... - tag: 'feature_example', + // feature unique identifier (required)... + tag: 'feature_example', - // applicability test (optional) - isApplicable: function(){ /* ... */ }, + // applicability test (optional) + isApplicable: function(){ /* ... */ }, - // feature load priority (optional) - priority: 'medium', + // feature load priority (optional) + priority: 'medium', - // list of feature tags to load if available (optional) - suggested: [], + // list of feature tags to load if available (optional) + suggested: [], - // list of feature tags required to load before this feature (optional) - depends: [], + // list of feature tags required to load before this feature (optional) + depends: [], - // Exclusive tag (optional) - // NOTE: a feature can be a member of more than one exclusive group, - // to list more than one use an Array... - exclusive: 'Example', + // Exclusive tag (optional) + // NOTE: a feature can be a member of more than one exclusive group, + // to list more than one use an Array... + exclusive: 'Example', - // feature configuration (optional) - // NOTE: if not present here this will be taken from .actions.config - // NOTE: this takes priority over .actions.config, it is not recommended - // to define both. - config: { - option: 'value', - // ... - }, - - // actions (optional) - actions: Actions({ - // alternative configuration location... + // feature configuration (optional) + // NOTE: if not present here this will be taken from .actions.config + // NOTE: this takes priority over .actions.config, it is not recommended + // to define both. config: { - // ... - } - // ... - }) + option: 'value', + // ... + }, - // action handlers (optional) - handlers: [ - ['action.pre', function(){ /* ... */ }], - // ... - ] + // actions (optional) + actions: Actions({ + // alternative configuration location... + config: { + // ... + }, + // ... + }), + + // action handlers (optional) + handlers: [ + ['action.pre', function(){ /* ... */ }], + // ... + ], }) ``` @@ -93,9 +93,9 @@ XXX ```javascript // meta-feature... feature_set.Feature('meta-feature-tag', [ - 'suggested-feature-tag', - 'other-suggested-feature-tag', - // ... + 'suggested-feature-tag', + 'other-suggested-feature-tag', + // ... ]) ``` diff --git a/features.js b/features.js index 1f6aec1..3fc378c 100755 --- a/features.js +++ b/features.js @@ -1,1138 +1,1147 @@ -/********************************************************************** -* -* -* -**********************************************************************/ -((typeof define)[0]=='u'?function(f){module.exports=f(require)}:define)( -function(require){ var module={} // makes module AMD/node compatible... -/*********************************************************************/ - -var object = require('ig-object') -var actions = module.actions = require('ig-actions') - - -/*********************************************************************/ - -// XXX use object.Error as base when ready... -var FeatureLinearizationError -module.FeatureLinearizationError = -object.Constructor('FeatureLinearizationError', Error, { - get name(){ - return this.constructor.name }, - toString: function(){ - return 'Failed to linearise' }, - __init__: function(data){ - this.data = data }, -}) - - - -//--------------------------------------------------------------------- -// Base feature... -// -// Feature(obj) -// -> feature -// -// Feature(feature-set, obj) -// -> feature -// -// Feature(tag, obj) -// -> feature -// -// Feature(tag, suggested) -// -> feature -// -// Feature(tag, actions) -// -> feature -// -// Feature(feature-set, tag, actions) -// -> feature -// -// -// Feature attributes: -// .tag - feature tag (string) -// this is used to identify the feature, its event -// handlers and DOM elements. -// -// .title - feature name (string | null) -// .doc - feature description (string | null) -// -// .priority - feature priority -// can be: -// - 'high' (99) | 'medium' (0) | 'low' (-99) -// - number -// - null (0, default) -// features with higher priority will be setup first, -// features with the same priority will be run in -// order of occurrence. -// .suggested - list of optional suggested features, these are not -// required but setup if available. -// This is useful for defining meta features but -// without making each sub-feature a strict dependency. -// .depends - feature dependencies -- tags of features that must -// setup before the feature (list | null) -// NOTE: a feature can depend on an exclusive tag, -// this will remove the need to track which -// specific exclusive tagged feature is loaded... -// .exclusive - feature exclusivity tags (list | null) -// an exclusivity group enforces that only one feature -// in it will be run, i.e. the first / highest priority. -// -// .actions - action object containing feature actions (ActionSet | null) -// this will be mixed into the base object on .setup() -// and mixed out on .remove() -// .config - feature configuration, will be merged with base -// object's .config -// NOTE: the final .config is an empty object with -// .__proto__ set to the merged configuration -// data... -// .handlers - feature event handlers (list | null) -// -// -// -// .handlers format: -// [ -// [ , ], -// ... -// ] -// -// NOTE: both and must be compatible with -// Action.on(..) -// -// -// Feature applicability: -// If feature.isApplicable(..) returns false then the feature will not be -// considered on setup... -// -// -var Feature = -module.Feature = -object.Constructor('Feature', { - //__featureset__: Features, - __featureset__: null, - - __verbose: null, - get __verbose__(){ - return this.__verbose == null - && (this.__featureset__ || {}).__verbose__ }, - set __verbose__(value){ - this.__verbose = value }, - - // Attributes... - tag: null, - - //title: null, - //doc: null, - //priority: null, - //exclusive: null, - //suggested: null, - //depends: null, - //actions: null, - //config: null, - //handlers: null, - - - isApplicable: function(actions){ return true }, - - - // API... - getPriority: function(human_readable){ - var res = this.priority || 0 - res = res == 'high' ? 99 - : res == 'low' ? -99 - : res == 'medium' ? 0 - : res == 'normal' ? 0 - : res - return human_readable ? - (res == 99 ? 'high' - : res == 0 ? 'normal' - : res == -99 ? 'low' - : res) - : res }, - - // XXX HANDLERS this could install the handlers in two locations: - // - the actions object... - // - mixin if available... - // - base object (currently implemented) - // ...the handlers should theoreticly be stored neither in the - // instance nor in the mixin but rather in the action-set itself - // on feature creation... (???) - // ...feels like user handlers and feature handlers should be - // isolated... - // XXX setting handlers on the .__proto__ breaks... - setup: function(actions){ - var that = this - - // mixin actions... - // NOTE: this will only mixin functions and actions... - if(this.actions != null){ - this.tag ? - // XXX HANDLERS - actions.mixin(this.actions, {source_tag: this.tag, action_handlers: true}) - : actions.mixin(this.actions, {action_handlers: true}) } - //actions.mixin(this.actions, {source_tag: this.tag}) - //: actions.mixin(this.actions) } - - /*/ XXX HANDLERS this is not needed if handlers are local to actions... - // install handlers... - if(this.handlers != null){ - this.handlers.forEach(function([a, h]){ - //actions.__proto__.on(a, that.tag, h) }) } - actions.on(a, that.tag, h) }) } - //*/ - - // merge config... - // NOTE: this will merge the actual config in .config.__proto__ - // keeping the .config clean for the user to lay with... - if(this.config != null - || (this.actions != null - && this.actions.config != null)){ - // sanity check -- warn of config shadowing... - // XXX do we need this??? - if(this.__verbose__ - && this.config && (this.actions || {}).config){ - console.warn('Feature config shadowed: ' - +'both .config (used) and .actions.config (ignored) are defined for:', - this.tag, - this) } - - var config = this.config = this.config || this.actions.config - - if(actions.config == null){ - actions.config = Object.create({}) } - Object.keys(config) - .forEach(function(n){ - // NOTE: this will overwrite existing values... - actions.config.__proto__[n] = config[n] }) } - - // custom setup... - // XXX is this the correct way??? - this.hasOwnProperty('setup') - && this.setup !== Feature.prototype.setup - && this.setup(actions) - - return this }, - - // XXX need to revise this... - // - .mixout(..) is available directly from the object while - // .remove(..) is not... - // - might be a good idea to add a specific lifecycle actions to - // enable feautures to handle their removal correctly... - remove: function(actions){ - this.actions != null - && actions.mixout(this.tag || this.actions) - - /*/ XXX HANDLERS do we need this if .handlers if local to action... - this.handlers != null - && actions.off('*', this.tag) - //*/ - - // XXX revise naming... - this.hasOwnProperty('remove') - && this.setup !== Feature.prototype.remove - && this.remove(actions) - - return this }, - - - // XXX EXPERIMENTAL: if called from a feature-set this will add self - // to that feature-set... - // XXX do we need this to be .__new__(..) and not .__init__(..) - __new__: function(context, feature_set, tag, obj){ - // NOTE: we need to account for context here -- inc length... - if(arguments.length == 3){ - // Feature(, ) - if(typeof(feature_set) == typeof('str')){ - obj = tag - tag = feature_set - //feature_set = Features - // XXX EXPERIMENTAL... - feature_set = context instanceof FeatureSet ? - context - : (this.__featureset__ || Features) - - // Feature(, ) - } else { - obj = tag - tag = null } - - // Feature() - // NOTE: we need to account for context here -- inc length... - } else if(arguments.length == 2){ - obj = feature_set - //feature_set = Features - // XXX EXPERIMENTAL... - feature_set = context instanceof FeatureSet ? - context - : (this.__featureset__ || Features) } - - if(tag != null && obj.tag != null && obj.tag != tag){ - throw new Error('tag and obj.tag mismatch, either use one or both must match.') } - - // actions... - if(obj instanceof actions.Action){ - if(tag == null){ - throw new Error('need a tag to make a feature out of an action') } - obj = { - tag: tag, - actions: obj, - } - - // meta-feature... - } else if(obj.constructor === Array){ - if(tag == null){ - throw new Error('need a tag to make a meta-feature') } - obj = { - tag: tag, - suggested: obj, - } } - - // XXX HANDLERS setup .handlers... - if(obj.handlers){ - obj.actions = obj.actions || {} - // NOTE: obj.actions does not have to be an action so w cheat \ - // a bit here, then copy the mindings... - var tmp = Object.create(actions.MetaActions) - obj.handlers - .forEach(function([a, h]){ - tmp.on(a, obj.tag, h) }) - Object.assign(obj.actions, tmp) } - - // feature-set... - if(feature_set){ - feature_set[obj.tag] = obj } - - return obj }, -}) - - - -//--------------------------------------------------------------------- - -var FeatureSet = -module.FeatureSet = -object.Constructor('FeatureSet', { - // if true, .setup(..) will report things it's doing... - __verbose__: null, - - - __actions__: actions.Actions, - - // NOTE: a feature is expected to write a reference to itself to the - // feature-set (context)... - Feature: Feature, - - - // List of registered features... - get features(){ - var that = this - return Object.keys(this) - .filter(function(e){ - return e != 'features' - && that[e] instanceof Feature }) }, - - // build exclusive groups... - // - // Get all exclusive tags... - // .getExclusive() - // .getExclusive('*') - // -> exclusive - // - // Get specific exclusive tags... - // .getExclusive(tag) - // .getExclusive([tag, ..]) - // -> exclusive - // - // If features is given, only consider the features in list. - // If rev_exclusive is given, also build a reverse exclusive feature - // list. - // - // output format: - // { - // exclusive-tag: [ - // feature-tag, - // ... - // ], - // ... - // } - // - getExclusive: function(tag, features, rev_exclusive, isDisabled){ - tag = tag == null || tag == '*' ? '*' - : tag instanceof Array ? tag - : [tag] - - features = features || this.features - rev_exclusive = rev_exclusive || {} - - var that = this - var exclusive = {} - features - .filter(function(f){ - return !!that[f].exclusive - && (!isDisabled || !isDisabled(f)) }) - .forEach(function(k){ - var e = that[k].exclusive - ;((e instanceof Array ? e : [e]) || []) - .forEach(function(e){ - // skip tags not explicitly requested... - if(tag != '*' && tag.indexOf(e) < 0){ - return - } - exclusive[e] = (exclusive[e] || []).concat([k]) - rev_exclusive[k] = (rev_exclusive[k] || []).concat([e]) }) }) - return exclusive }, - - // Build list of features in load order... - // - // .buildFeatureList() - // .buildFeatureList('*') - // -> data - // - // .buildFeatureList(feature-tag) - // -> data - // - // .buildFeatureList([feature-tag, .. ]) - // -> data - // - // .buildFeatureList(.., filter) - // -> data - // - // - // Requirements: - // - features are pre-sorted by priority, original order is kept - // where possible - // - a feature is loaded strictly after it's dependencies - // - features depending on inapplicable feature(s) are also - // inapplicable (recursive up) - // - inapplicable features are not loaded - // - missing dependency -> missing dependency error - // - suggested features (and their dependencies) do not produce - // dependency errors, unless explicitly included in dependency - // graph (i.e. explicitly depended on by some other feature) - // - features with the same exclusive tag are grouped into an - // exclusive set - // - only the first feature in an exclusive set is loaded, the rest - // are *excluded* - // - exclusive tag can be used to reference (alias) the loaded - // feature in exclusive set (i.e. exclusive tag can be used as - // a dependency) - // - // NOTE: an exclusive group name can be used as an alias. - // NOTE: if an alias is used and no feature from that exclusive group - // is explicitly included then the actual loaded feature will - // depend on the load order, which in an async world is not - // deterministic... - // - // - // Algorithm: - // - expand features: - // - handle dependencies (detect loops) - // - handle suggestions - // - handle explicitly disabled features (detect loops) - // - handle exclusive feature groups/aliases (handle conflicts) - // - sort list of features: - // - by priority - // - by dependency (detect loops/errors) - // - // - // Return format: - // { - // // input feature feature tags... - // input: [ .. ], - // - // // output list of feature tags... - // features: [ .. ], - // - // // disabled features... - // disabled: [ .. ], - // // exclusive features that got excluded... - // excluded: [ .. ], - // - // // Errors... - // error: null | { - // // fatal/recoverable error indicator... - // fatal: bool, - // - // // missing dependencies... - // // NOTE: this includes tags only included by .depends and - // // ignores tags from .suggested... - // missing: [ .. ], - // - // // exclusive feature conflicts... - // // This will include the explicitly required conflicting - // // exclusive features. - // // NOTE: this is not an error, but indicates that the - // // system tried to fix the state by disabling all - // // but the first feature. - // conflicts: { - // exclusive-tag: [ feature-tag, .. ], - // .. - // }, - // - // // detected dependency loops (if .length > 0 sets fatal)... - // loops: [ .. ], - // - // // sorting loop overflow error (if true sets fatal)... - // sort_loop_overflow: bool, - // }, - // - // // Introspection... - // // index of features and their list of dependencies... - // depends: { - // feature-tag: [ feature-tag, .. ], - // .. - // }, - // // index of features and list of features depending on them... - // // XXX should this include suggestions or should we do them - // // in a separate list... - // depended: { - // feature-tag: [ feature-tag, .. ], - // .. - // }, - // } - // - // XXX should exclusive conflicts resolve to first (current state) - // feature or last in an exclusive group??? - // XXX PROBLEM: exclusive feature trees should be resolved accounting - // feature applicablility... - // ...in this approach it is impossible... - // ...one way to fix this is to make this interactively check - // applicability, i.e. pass a context and check applicablility - // when needed... - buildFeatureList: function(lst, isDisabled){ - var all = this.features - lst = (lst == null || lst == '*') ? - all - : lst - lst = lst instanceof Array ? - lst - : [lst] - - //isDisabled = isDisabled || function(){ return false } - - var that = this - - // Pre-sort exclusive feature by their occurrence in dependency - // tree... - // - // NOTE: there can be a case when an exclusive alias is used but - // no feature in a group is loaded, in this case which - // feature is actually loaded depends on the load order... - var sortExclusive = function(features){ - var loaded = Object.keys(features) - Object.keys(exclusive) - .forEach(function(k){ - exclusive[k] = exclusive[k] - .map(function(e, i){ return [e, i] }) - .sort(function(a, b){ - var i = loaded.indexOf(a[0]) - var j = loaded.indexOf(b[0]) - // keep the missing at the end... - i = i < 0 ? Infinity : i - j = j < 0 ? Infinity : j - // NOTE: Infinity - Infinity is NaN, so we need - // to guard against it... - return i - j || 0 }) - .map(function(e){ return e[0] }) }) } - - // Expand feature references (recursive)... - // - // NOTE: closures are not used here as we need to pass different - // stuff into data in different situations... - var expand = function(target, lst, store, data, _seen){ - data = data || {} - _seen = _seen || [] - - // clear disabled... - // NOTE: we do as a separate stage to avoid loading a - // feature before it is disabled in the same list... - lst = data.disabled ? - lst - .filter(function(n){ - // feature disabled -> record and skip... - if(n[0] == '-'){ - n = n.slice(1) - if(_seen.indexOf(n) >= 0){ - // NOTE: a disable loop is when a feature tries to disable - // a feature up in the same chain... - // XXX should this break or accumulate??? - console.warn(`Disable loop detected at "${n}" in chain: ${_seen}`) - var loop = _seen.slice(_seen.indexOf(n)).concat([n]) - data.disable_loops = (data.disable_loops || []).push(loop) - return false } - // XXX STUB -- need to resolve actual loops and - // make the disable global... - if(n in store){ - console.warn('Disabling a feature after it is loaded:', n, _seen) } - data.disabled.push(n) - return false } - // skip already disabled features... - if(data.disabled.indexOf(n) >= 0){ - return false } - return true }) - : lst - - // traverse the tree... - lst - // normalize the list -- remove non-features and resolve aliases... - .map(function(n){ - var f = that[n] - // exclusive tags... - if(f == null && data.exclusive && n in data.exclusive){ - store[n] = null - return false } - // feature not defined or is not a feature... - if(f == null){ - data.missing - && data.missing.indexOf(n) < 0 - && data.missing.push(n) - return false } - return n }) - .filter(function(e){ return e }) - // traverse down... - .forEach(function(f){ - // dependency loop detection... - if(_seen.indexOf(f) >= 0){ - var loop = _seen.slice(_seen.indexOf(f)).concat([f]) - data.loops - && data.loops.push(loop) - return } - - // skip already done features... - if(f in store){ - return } - - //var feature = store[f] = that[f] - var feature = that[f] - if(feature){ - var _lst = [] - - // merge lists... - ;(target instanceof Array ? target : [target]) - .forEach(function(t){ - _lst = _lst.concat(feature[t] || []) - }) - store[f] = _lst - - // traverse down... - expand(target, _lst, store, data, _seen.concat([f])) } }) - - return store } - - // Expand feature dependencies and suggestions recursively... - // - // NOTE: this relies on the following values being in the closure: - // loops - list of loop chains found - // disable_loops - disable loops - // when a feature containing a disable - // directive gets disabled as a result - // disabled - list of disabled features - // missing - list of missing features - // missing_suggested - // - list of missing suggested features and - // suggested feature dependencies - // exclusive - exclusive feature index - // suggests - index of feature suggestions (full) - // suggested - suggested feature dependency index - // NOTE: the above containers will get updated as a side-effect. - // NOTE: all of the above values are defined near the location - // they are first used/initiated... - // NOTE: closures are used here purely for simplicity and conciseness - // as threading data would not add any flexibility but make - // the code more complex... - var expandFeatures = function(lst, features){ - features = features || {} - - // feature tree... - var expand_data = { - loops: loops, - disabled: disabled, - disable_loops: disable_loops, - missing: missing, - exclusive: exclusive, - } - - features = expand('depends', lst, features, expand_data) - - // suggestion list... - // ...this will be used to check if we need to break on missing - // features, e.g. if a feature is suggested we can silently skip - // it otherwise err... - // - // NOTE: this stage does not track suggested feature dependencies... - // NOTE: we do not need loop detection active here... - var s = expand('suggested', Object.keys(features), {}, - { - disabled: disabled, - missing: missing_suggested, - }) - s = Object.keys(s) - .filter(function(f){ - // populate the tree of feature suggestions... - suggests[f] = s[f] - // filter out what's in features already... - return !(f in features) }) - // get suggestion dependencies... - // NOTE: we do not care bout missing here... - s = expand('depends', s, {}, - { - loops: loops, - disabled: disabled, - disable_loops: disable_loops, - exclusive: exclusive, - missing: missing_suggested, - }) - Object.keys(s) - .forEach(function(f){ - // keep only suggested features -- diff with features... - if(f in features){ - delete s[f] - - // mix suggested into features... - } else { - features[f] = s[f] - suggested[f] = (s[f] || []).slice() } }) - - sortExclusive(features) - - return features } - - - //--------------------- Globals: filtering / exclusive tags --- - - var loops = [] - var disable_loops = [] - var disabled = [] - var missing = [] - var missing_suggested = [] - var suggests = {} - var suggested = {} - - // user filter... - // NOTE: we build this out of the full feature list... - disabled = disabled - .concat(isDisabled ? all.filter(isDisabled) : []) - - // build exclusive groups... - // XXX need to sort the values to the same order as given features... - var rev_exclusive = {} - var exclusive = this.getExclusive('*', all, rev_exclusive, isDisabled) - - - //-------------------------------- Stage 1: expand features --- - var features = expandFeatures(lst) - - - //-------------------------------- Exclusive groups/aliases --- - // Handle exclusive feature groups and aliases... - var conflicts = {} - var done = [] - var to_remove = [] - Object.keys(features) - .forEach(function(f){ - // alias... - while(f in exclusive && done.indexOf(f) < 0){ - var candidates = (exclusive[f] || []) - .filter(function(c){ return c in features }) - - // resolve alias to non-included feature... - if(candidates.length == 0){ - var target = exclusive[f][0] - - // expand target to features... - expandFeatures([target], features) - - // link alias to existing feature... - } else { - var target = candidates[0] } - - // remove the alias... - // NOTE: exclusive tag can match a feature tag, thus - // we do not want to delete such tags... - // NOTE: we are not removing to_remove here as they may - // get added/expanded back in by other features... - !(f in that) - && to_remove.push(f) - // replace dependencies... - Object.keys(features) - .forEach(function(e){ - var i = features[e] ? features[e].indexOf(f) : -1 - i >= 0 - && features[e].splice(i, 1, target) - }) - f = target - done.push(f) } - - // exclusive feature... - if(f in rev_exclusive){ - // XXX handle multiple groups... (???) - var group = rev_exclusive[f] - var candidates = (exclusive[group] || []) - .filter(function(c){ return c in features }) - - if(!(group in conflicts) && candidates.length > 1){ - conflicts[group] = candidates } } }) - // cleanup... - to_remove.forEach(function(f){ delete features[f] }) - // resolve any exclusivity conflicts found... - var excluded = [] - Object.keys(conflicts) - .forEach(function(group){ - // XXX should this resolve to the last of the first feature??? - excluded = excluded.concat(conflicts[group].slice(1))}) - disabled = disabled.concat(excluded) - - - //--------------------------------------- Disabled features --- - // Handle disabled features and cleanup... - - // reverse dependency index... - // ...this is used to clear out orphaned features later and for - // introspection... - var rev_features = {} - Object.keys(features) - .forEach(function(f){ - (features[f] || []) - .forEach(function(d){ - rev_features[d] = (rev_features[d] || []).concat([f]) }) }) - - // clear dependency trees containing disabled features... - var suggested_clear = [] - do { - var expanded_disabled = false - disabled - .forEach(function(d){ - // disable all features that depend on a disabled feature... - Object.keys(features) - .forEach(function(f){ - if(features[f] - && features[f].indexOf(d) >= 0 - && disabled.indexOf(f) < 0){ - expanded_disabled = true - disabled.push(f) } }) - - // delete the feature itself... - var s = suggests[d] || [] - delete suggests[d] - delete features[d] - - // clear suggested... - s - .forEach(function(f){ - if(disabled.indexOf(f) < 0 - // not depended/suggested by any of - // the non-disabled features... - && Object.values(features) - .concat(Object.values(suggests)) - .filter(n => n.indexOf(f) >= 0) - .length == 0){ - expanded_disabled = true - disabled.push(f) } }) }) - } while(expanded_disabled) - - // remove orphaned features... - // ...an orphan is a feature included by a disabled feature... - // NOTE: this should take care of missing features too... - Object.keys(rev_features) - .filter(function(f){ - return rev_features[f] - // keep non-disabled and existing sources only... - .filter(function(e){ - return !(e in features) || disabled.indexOf(e) < 0 }) - // keep features that have no sources left, i.e. orphans... - .length == 0 }) - .forEach(function(f){ - console.log('ORPHANED:', f) - disabled.push(f) - delete features[f] }) - - - //---------------------------------- Stage 2: sort features --- - - // Prepare for sort: expand dependency list in features... - // - // NOTE: this will expand lst in-place... - // NOTE: we are not checking for loops here -- mainly because - // the input is expected to be loop-free... - var expanddeps = function(lst, cur, seen){ - seen = seen || [] - if(features[cur] == null){ - return } - // expand the dep list recursively... - // NOTE: this will expand features[cur] in-place while - // iterating over it... - for(var i=0; i < features[cur].length; i++){ - var f = features[cur][i] - if(seen.indexOf(f) < 0){ - seen.push(f) - - expanddeps(features[cur], f, seen) - - features[cur].forEach(function(e){ - lst.indexOf(e) < 0 - && lst.push(e) }) } } } - // do the actual expansion... - var list = Object.keys(features) - list.forEach(function(f){ expanddeps(list, f) }) - - // sort by priority... - // - // NOTE: this will attempt to only move features with explicitly - // defined priorities and keep the rest in the same order - // when possible... - list = list - // format: - // [ , , ] - .map(function(e, i){ - return [e, i, (that[e] && that[e].getPriority) ? that[e].getPriority() : 0 ] }) - .sort(function(a, b){ - return a[2] - b[2] || a[1] - b[1] }) - // cleanup... - .map(function(e){ return e[0] }) - // sort by the order features should be loaded... - .reverse() - - // sort by dependency... - // - // NOTE: this requires the list to be ordered from high to low - // priority, i.e. the same order they should be loaded in... - // NOTE: dependency loops will throw this into and "infinite" loop... - var loop_limit = list.length + 1 - do { - var moves = 0 - if(list.length == 0){ - break } - list - .slice() - .forEach(function(e){ - var deps = features[e] - if(!deps){ - return - } - var from = list.indexOf(e) - var to = list - .map(function(f, i){ return [f, i] }) - .slice(from+1) - // keep only dependencies... - .filter(function(f){ return deps.indexOf(f[0]) >= 0 }) - .pop() - if(to){ - // place after last dependency... - list.splice(to[1]+1, 0, e) - list.splice(from, 1) - moves++ } }) - loop_limit-- - } while(moves > 0 && loop_limit > 0) - - - //------------------------------------------------------------- - - // remove exclusivity tags that were resolved... - var isMissing = function(f){ - return !( - // feature is a resolvable exclusive tag... - (exclusive[f] || []).length > 0 - // feature was resolved... - && exclusive[f] - .filter(function(f){ return list.indexOf(f) >= 0 }) - .length > 0) } - - return { - input: lst, - - features: list, - - disabled: disabled, - excluded: excluded, - - // errors and conflicts... - error: (loops.length > 0 - || Object.keys(conflicts).length > 0 - || loop_limit <= 0 - || missing.length > 0 - || missing_suggested.length > 0) ? - { - missing: missing.filter(isMissing), - missing_suggested: missing_suggested.filter(isMissing), - conflicts: conflicts, - - // fatal stuff... - fatal: loops.length > 0 || loop_limit <= 0, - loops: loops, - sort_loop_overflow: loop_limit <= 0, - } - : null, - - // introspection... - depends: features, - depended: rev_features, - suggests: suggests, - suggested: suggested, - //exclusive: exclusive, - } - }, - - // Setup features... - // - // Setup features on existing actions object... - // .setup(actions, [feature-tag, ...]) - // -> actions - // - // Setup features on a new actions object... - // .setup(feature-tag) - // .setup([feature-tag, ...]) - // -> actions - // - // - // This will set .features on the object. - // - // .features format: - // { - // // the current feature set object... - // // XXX not sure about this -- revise... - // FeatureSet: feature-set, - // - // // list of features not applicable in current context... - // // - // // i.e. the features that defined .isApplicable(..) and it - // // returned false when called. - // unapplicable: [ feature-tag, .. ], - // - // // output of .buildFeatureList(..)... - // ... - // } - // - // NOTE: this will store the build result in .features of the output - // actions object. - // NOTE: .features is reset even if a FeatureLinearizationError error - // is thrown. - setup: function(obj, lst){ - // no explicit object is given... - if(lst == null){ - lst = obj - obj = null } - obj = obj || (this.__actions__ || actions.Actions)() - lst = lst instanceof Array ? lst : [lst] - - var unapplicable = [] - var features = this.buildFeatureList(lst, - (function(n){ - // if we already tested unapplicable, no need to test again... - if(unapplicable.indexOf(n) >= 0){ - return true } - var f = this[n] - // check applicability if possible... - if(f && f.isApplicable && !f.isApplicable.call(this, obj)){ - unapplicable.push(n) - return true } - return false }).bind(this)) - features.unapplicable = unapplicable - // cleanup disabled -- filter out unapplicable and excluded features... - // NOTE: this is done mainly for cleaner and simpler reporting - // later on... - features.disabled = features.disabled - .filter(function(n){ - return unapplicable.indexOf(n) < 0 - && features.excluded.indexOf(n) < 0 }) - - // if we have critical errors and set verbose... - var fatal = features.error - && (features.error.loops.length > 0 || features.error.sort_loop_overflow) - - // report stuff... - if(this.__verbose__){ - var error = features.error - // report dependency loops... - error.loops.length > 0 - && error.loops - .forEach(function(loop){ - console.warn('Feature dependency loops detected:\n\t' - + loop.join('\n\t\t-> ')) }) - // report conflicts... - Object.keys(error.conflicts) - .forEach(function(group){ - console.error('Exclusive "'+ group +'" conflict at:', error.conflicts[group]) }) - // report loop limit... - error.sort_loop_overflow - && console.error('Hit loop limit while sorting dependencies!') } - - features.FeatureSet = this - - obj.features = features - - // fatal error -- can't load... - if(fatal){ - throw new FeatureLinearizationError(features) } - - // mixout everything... - this.remove(obj, - obj.mro('tag') - .filter(function(e){ - return !!e })) - - // do the setup... - var that = this - var setup = Feature.prototype.setup - features.features.forEach(function(n){ - // setup... - if(that[n] != null){ - this.__verbose__ - && console.log('Setting up feature:', n) - setup.call(that[n], obj) } }) - - return obj }, - - // XXX revise... - // ...the main problem here is that .mixout(..) is accesible - // directly from actions while the feature .remove(..) method - // is not... - // ...would be nice to expose the API to actions directly or - // keep it only in features... - remove: function(obj, lst){ - lst = lst.constructor !== Array ? [lst] : lst - var that = this - lst.forEach(function(n){ - if(that[n] != null){ - this.__verbose__ - && console.log('Removing feature:', n) - that[n].remove(obj) } }) }, - - - // Generate a Graphviz graph from features... - // - // XXX experimental... - gvGraph: function(lst, dep){ - lst = lst || this.features - dep = dep || this - - var graph = '' - graph += 'digraph ImageGrid {\n' - lst - .filter(function(f){ return f in dep }) - .forEach(function(f){ - var deps = dep[f].depends || [] - - deps.length > 0 ? - deps.forEach(function(d){ - graph += `\t"${f}" -> "${d}";\n` }) - : (graph += `\t"${f}";\n`) - }) - graph += '}' - - return graph }, -}) - - - - -/*********************************************************************/ -// default feature set... - -var Features = -module.Features = new FeatureSet() - - - - -/********************************************************************** -* vim:set ts=4 sw=4 : */ return module }) +/********************************************************************** +* +* +* +**********************************************************************/ +((typeof define)[0]=='u'?function(f){module.exports=f(require)}:define)( +function(require){ var module={} // makes module AMD/node compatible... +/*********************************************************************/ + +var object = require('ig-object') +var actions = module.actions = require('ig-actions') + + +/*********************************************************************/ + +// XXX use object.Error as base when ready... +var FeatureLinearizationError +module.FeatureLinearizationError = +object.Constructor('FeatureLinearizationError', Error, { + get name(){ + return this.constructor.name }, + toString: function(){ + return 'Failed to linearise' }, + __init__: function(data){ + this.data = data }, +}) + + + +//--------------------------------------------------------------------- +// Base feature... +// +// Feature(obj) +// -> feature +// +// Feature(feature-set, obj) +// -> feature +// +// Feature(tag, obj) +// -> feature +// +// Feature(tag, suggested) +// -> feature +// +// Feature(tag, actions) +// -> feature +// +// Feature(feature-set, tag, actions) +// -> feature +// +// +// Feature attributes: +// .tag - feature tag (string) +// this is used to identify the feature, its event +// handlers and DOM elements. +// +// .title - feature name (string | null) +// .doc - feature description (string | null) +// +// .priority - feature priority +// can be: +// - 'high' (99) | 'medium' (0) | 'low' (-99) +// - number +// - null (0, default) +// features with higher priority will be setup first, +// features with the same priority will be run in +// order of occurrence. +// .suggested - list of optional suggested features, these are not +// required but setup if available. +// This is useful for defining meta features but +// without making each sub-feature a strict dependency. +// .depends - feature dependencies -- tags of features that must +// setup before the feature (list | null) +// NOTE: a feature can depend on an exclusive tag, +// this will remove the need to track which +// specific exclusive tagged feature is loaded... +// .exclusive - feature exclusivity tags (list | null) +// an exclusivity group enforces that only one feature +// in it will be run, i.e. the first / highest priority. +// +// .actions - action object containing feature actions (ActionSet | null) +// this will be mixed into the base object on .setup() +// and mixed out on .remove() +// .config - feature configuration, will be merged with base +// object's .config +// NOTE: the final .config is an empty object with +// .__proto__ set to the merged configuration +// data... +// .handlers - feature event handlers (list | null) +// +// +// +// .handlers format: +// [ +// [ , ], +// ... +// ] +// +// NOTE: both and must be compatible with +// Action.on(..) +// +// +// Feature applicability: +// If feature.isApplicable(..) returns false then the feature will not be +// considered on setup... +// +// +var Feature = +module.Feature = +object.Constructor('Feature', { + //__featureset__: Features, + __featureset__: null, + + __verbose: null, + get __verbose__(){ + return this.__verbose == null + && (this.__featureset__ || {}).__verbose__ }, + set __verbose__(value){ + this.__verbose = value }, + + // Attributes... + tag: null, + + //title: null, + //doc: null, + //priority: null, + //exclusive: null, + //suggested: null, + //depends: null, + //actions: null, + //config: null, + //handlers: null, + + + isApplicable: function(actions){ return true }, + + + // API... + getPriority: function(human_readable){ + var res = this.priority || 0 + res = res == 'high' ? 99 + : res == 'low' ? -99 + : res == 'medium' ? 0 + : res == 'normal' ? 0 + : res + return human_readable ? + (res == 99 ? 'high' + : res == 0 ? 'normal' + : res == -99 ? 'low' + : res) + : res }, + + // XXX HANDLERS this could install the handlers in two locations: + // - the actions object... + // - mixin if available... + // - base object (currently implemented) + // ...the handlers should theoreticly be stored neither in the + // instance nor in the mixin but rather in the action-set itself + // on feature creation... (???) + // ...feels like user handlers and feature handlers should be + // isolated... + // XXX setting handlers on the .__proto__ breaks... + setup: function(actions){ + var that = this + + // mixin actions... + // NOTE: this will only mixin functions and actions... + if(this.actions != null){ + this.tag ? + // XXX HANDLERS + actions.mixin(this.actions, {source_tag: this.tag, action_handlers: true}) + : actions.mixin(this.actions, {action_handlers: true}) } + //actions.mixin(this.actions, {source_tag: this.tag}) + //: actions.mixin(this.actions) } + + /*/ XXX HANDLERS this is not needed if handlers are local to actions... + // install handlers... + if(this.handlers != null){ + this.handlers.forEach(function([a, h]){ + //actions.__proto__.on(a, that.tag, h) }) } + actions.on(a, that.tag, h) }) } + //*/ + + // merge config... + // NOTE: this will merge the actual config in .config.__proto__ + // keeping the .config clean for the user to lay with... + if(this.config != null + || (this.actions != null + && this.actions.config != null)){ + // sanity check -- warn of config shadowing... + // XXX do we need this??? + if(this.__verbose__ + && this.config && (this.actions || {}).config){ + console.warn('Feature config shadowed: ' + +'both .config (used) and .actions.config (ignored) are defined for:', + this.tag, + this) } + + var config = this.config = this.config || this.actions.config + + if(actions.config == null){ + actions.config = Object.create({}) } + Object.keys(config) + .forEach(function(n){ + // NOTE: this will overwrite existing values... + actions.config.__proto__[n] = config[n] }) } + + // custom setup... + // XXX is this the correct way??? + this.hasOwnProperty('setup') + && this.setup !== Feature.prototype.setup + && this.setup(actions) + + return this }, + + // XXX need to revise this... + // - .mixout(..) is available directly from the object while + // .remove(..) is not... + // - might be a good idea to add a specific lifecycle actions to + // enable feautures to handle their removal correctly... + remove: function(actions){ + this.actions != null + && actions.mixout(this.tag || this.actions) + + /*/ XXX HANDLERS do we need this if .handlers if local to action... + this.handlers != null + && actions.off('*', this.tag) + //*/ + + // XXX revise naming... + this.hasOwnProperty('remove') + && this.setup !== Feature.prototype.remove + && this.remove(actions) + + return this }, + + + // XXX EXPERIMENTAL: if called from a feature-set this will add self + // to that feature-set... + // XXX do we need this to be .__new__(..) and not .__init__(..) + __new__: function(context, feature_set, tag, obj){ + // NOTE: we need to account for context here -- inc length... + if(arguments.length == 3){ + // Feature(, ) + if(typeof(feature_set) == typeof('str')){ + obj = tag + tag = feature_set + //feature_set = Features + // XXX EXPERIMENTAL... + feature_set = context instanceof FeatureSet ? + context + : (this.__featureset__ || Features) + + // Feature(, ) + } else { + obj = tag + tag = null } + + // Feature() + // NOTE: we need to account for context here -- inc length... + } else if(arguments.length == 2){ + obj = feature_set + //feature_set = Features + // XXX EXPERIMENTAL... + feature_set = context instanceof FeatureSet ? + context + : (this.__featureset__ || Features) } + + if(tag != null && obj.tag != null && obj.tag != tag){ + throw new Error('tag and obj.tag mismatch, either use one or both must match.') } + + // actions... + if(obj instanceof actions.Action){ + if(tag == null){ + throw new Error('need a tag to make a feature out of an action') } + obj = { + tag: tag, + actions: obj, + } + + // meta-feature... + } else if(obj.constructor === Array){ + if(tag == null){ + throw new Error('need a tag to make a meta-feature') } + obj = { + tag: tag, + suggested: obj, + } } + + // XXX HANDLERS setup .handlers... + if(obj.handlers){ + obj.actions = obj.actions || {} + // NOTE: obj.actions does not have to be an action so w cheat \ + // a bit here, then copy the mindings... + var tmp = Object.create(actions.MetaActions) + obj.handlers + .forEach(function([a, h]){ + tmp.on(a, obj.tag, h) }) + Object.assign(obj.actions, tmp) } + + // feature-set... + if(feature_set){ + feature_set[obj.tag] = obj } + + return obj }, +}) + + + +//--------------------------------------------------------------------- + +var FeatureSet = +module.FeatureSet = +object.Constructor('FeatureSet', { + // if true, .setup(..) will report things it's doing... + __verbose__: null, + + + __actions__: actions.Actions, + + // NOTE: a feature is expected to write a reference to itself to the + // feature-set (context)... + Feature: Feature, + + + // List of registered features... + get features(){ + var that = this + return Object.keys(this) + .filter(function(e){ + return e != 'features' + && that[e] instanceof Feature }) }, + + // build exclusive groups... + // + // Get all exclusive tags... + // .getExclusive() + // .getExclusive('*') + // -> exclusive + // + // Get specific exclusive tags... + // .getExclusive(tag) + // .getExclusive([tag, ..]) + // -> exclusive + // + // If features is given, only consider the features in list. + // If rev_exclusive is given, also build a reverse exclusive feature + // list. + // + // output format: + // { + // exclusive-tag: [ + // feature-tag, + // ... + // ], + // ... + // } + // + getExclusive: function(tag, features, rev_exclusive, isDisabled){ + tag = tag == null || tag == '*' ? '*' + : tag instanceof Array ? tag + : [tag] + + features = features || this.features + rev_exclusive = rev_exclusive || {} + + var that = this + var exclusive = {} + features + .filter(function(f){ + return !!that[f].exclusive + && (!isDisabled || !isDisabled(f)) }) + .forEach(function(k){ + var e = that[k].exclusive + ;((e instanceof Array ? e : [e]) || []) + .forEach(function(e){ + // skip tags not explicitly requested... + if(tag != '*' && tag.indexOf(e) < 0){ + return } + exclusive[e] = + (exclusive[e] || []).concat([k]) + rev_exclusive[k] = + (rev_exclusive[k] || []).concat([e]) }) }) + return exclusive }, + + // Build list of features in load order... + // + // .buildFeatureList() + // .buildFeatureList('*') + // -> data + // + // .buildFeatureList(feature-tag) + // -> data + // + // .buildFeatureList([feature-tag, .. ]) + // -> data + // + // .buildFeatureList(.., filter) + // -> data + // + // + // Requirements: + // - features are pre-sorted by priority, original order is kept + // where possible + // - a feature is loaded strictly after it's dependencies + // - features depending on inapplicable feature(s) are also + // inapplicable (recursive up) + // - inapplicable features are not loaded + // - missing dependency -> missing dependency error + // - suggested features (and their dependencies) do not produce + // dependency errors, unless explicitly included in dependency + // graph (i.e. explicitly depended on by some other feature) + // - features with the same exclusive tag are grouped into an + // exclusive set + // - only the first feature in an exclusive set is loaded, the rest + // are *excluded* + // - exclusive tag can be used to reference (alias) the loaded + // feature in exclusive set (i.e. exclusive tag can be used as + // a dependency) + // + // NOTE: an exclusive group name can be used as an alias. + // NOTE: if an alias is used and no feature from that exclusive group + // is explicitly included then the actual loaded feature will + // depend on the load order, which in an async world is not + // deterministic... + // + // + // Algorithm: + // - expand features: + // - handle dependencies (detect loops) + // - handle suggestions + // - handle explicitly disabled features (detect loops) + // - handle exclusive feature groups/aliases (handle conflicts) + // - sort list of features: + // - by priority + // - by dependency (detect loops/errors) + // + // + // Return format: + // { + // // input feature feature tags... + // input: [ .. ], + // + // // output list of feature tags... + // features: [ .. ], + // + // // disabled features... + // disabled: [ .. ], + // // exclusive features that got excluded... + // excluded: [ .. ], + // + // // Errors... + // error: null | { + // // fatal/recoverable error indicator... + // fatal: bool, + // + // // missing dependencies... + // // NOTE: this includes tags only included by .depends and + // // ignores tags from .suggested... + // missing: [ .. ], + // + // // exclusive feature conflicts... + // // This will include the explicitly required conflicting + // // exclusive features. + // // NOTE: this is not an error, but indicates that the + // // system tried to fix the state by disabling all + // // but the first feature. + // conflicts: { + // exclusive-tag: [ feature-tag, .. ], + // .. + // }, + // + // // detected dependency loops (if .length > 0 sets fatal)... + // loops: [ .. ], + // + // // sorting loop overflow error (if true sets fatal)... + // sort_loop_overflow: bool, + // }, + // + // // Introspection... + // // index of features and their list of dependencies... + // depends: { + // feature-tag: [ feature-tag, .. ], + // .. + // }, + // // index of features and list of features depending on them... + // // XXX should this include suggestions or should we do them + // // in a separate list... + // depended: { + // feature-tag: [ feature-tag, .. ], + // .. + // }, + // } + // + // XXX should exclusive conflicts resolve to first (current state) + // feature or last in an exclusive group??? + // XXX PROBLEM: exclusive feature trees should be resolved accounting + // feature applicablility... + // ...in this approach it is impossible... + // ...one way to fix this is to make this interactively check + // applicability, i.e. pass a context and check applicablility + // when needed... + buildFeatureList: function(lst, isDisabled){ + var all = this.features + lst = (lst == null || lst == '*') ? + all + : lst + lst = lst instanceof Array ? + lst + : [lst] + + //isDisabled = isDisabled || function(){ return false } + + var that = this + + // Pre-sort exclusive feature by their occurrence in dependency + // tree... + // + // NOTE: there can be a case when an exclusive alias is used but + // no feature in a group is loaded, in this case which + // feature is actually loaded depends on the load order... + var sortExclusive = function(features){ + var loaded = Object.keys(features) + Object.keys(exclusive) + .forEach(function(k){ + exclusive[k] = exclusive[k] + .map(function(e, i){ return [e, i] }) + .sort(function(a, b){ + var i = loaded.indexOf(a[0]) + var j = loaded.indexOf(b[0]) + // keep the missing at the end... + i = i < 0 ? Infinity : i + j = j < 0 ? Infinity : j + // NOTE: Infinity - Infinity is NaN, so we need + // to guard against it... + return i - j || 0 }) + .map(function(e){ return e[0] }) }) } + + // Expand feature references (recursive)... + // + // NOTE: closures are not used here as we need to pass different + // stuff into data in different situations... + var expand = function(target, lst, store, data, _seen){ + data = data || {} + _seen = _seen || [] + + // clear disabled... + // NOTE: we do as a separate stage to avoid loading a + // feature before it is disabled in the same list... + lst = data.disabled ? + lst + .filter(function(n){ + // feature disabled -> record and skip... + if(n[0] == '-'){ + n = n.slice(1) + if(_seen.indexOf(n) >= 0){ + // NOTE: a disable loop is when a feature tries to disable + // a feature up in the same chain... + // XXX should this break or accumulate??? + console.warn(`Disable loop detected at "${n}" in chain: ${_seen}`) + var loop = + _seen.slice(_seen.indexOf(n)).concat([n]) + data.disable_loops = + (data.disable_loops || []).push(loop) + return false } + // XXX STUB -- need to resolve actual loops and + // make the disable global... + if(n in store){ + console.warn('Disabling a feature after it is loaded:', n, _seen) } + data.disabled.push(n) + return false } + // skip already disabled features... + if(data.disabled.indexOf(n) >= 0){ + return false } + return true }) + : lst + + // traverse the tree... + lst + // normalize the list -- remove non-features and resolve aliases... + .map(function(n){ + var f = that[n] + // exclusive tags... + if(f == null && data.exclusive && n in data.exclusive){ + store[n] = null + return false } + // feature not defined or is not a feature... + if(f == null){ + data.missing + && data.missing.indexOf(n) < 0 + && data.missing.push(n) + return false } + return n }) + .filter(function(e){ return e }) + // traverse down... + .forEach(function(f){ + // dependency loop detection... + if(_seen.indexOf(f) >= 0){ + var loop = _seen.slice(_seen.indexOf(f)).concat([f]) + data.loops + && data.loops.push(loop) + return } + + // skip already done features... + if(f in store){ + return } + + //var feature = store[f] = that[f] + var feature = that[f] + if(feature){ + var _lst = [] + + // merge lists... + ;(target instanceof Array ? target : [target]) + .forEach(function(t){ + _lst = _lst.concat(feature[t] || []) }) + store[f] = _lst + + // traverse down... + expand(target, _lst, store, data, _seen.concat([f])) } }) + + return store } + + // Expand feature dependencies and suggestions recursively... + // + // NOTE: this relies on the following values being in the closure: + // loops - list of loop chains found + // disable_loops - disable loops + // when a feature containing a disable + // directive gets disabled as a result + // disabled - list of disabled features + // missing - list of missing features + // missing_suggested + // - list of missing suggested features and + // suggested feature dependencies + // exclusive - exclusive feature index + // suggests - index of feature suggestions (full) + // suggested - suggested feature dependency index + // NOTE: the above containers will get updated as a side-effect. + // NOTE: all of the above values are defined near the location + // they are first used/initiated... + // NOTE: closures are used here purely for simplicity and conciseness + // as threading data would not add any flexibility but make + // the code more complex... + var expandFeatures = function(lst, features){ + features = features || {} + + // feature tree... + var expand_data = { + loops: loops, + disabled: disabled, + disable_loops: disable_loops, + missing: missing, + exclusive: exclusive, + } + + features = expand('depends', lst, features, expand_data) + + // suggestion list... + // ...this will be used to check if we need to break on missing + // features, e.g. if a feature is suggested we can silently skip + // it otherwise err... + // + // NOTE: this stage does not track suggested feature dependencies... + // NOTE: we do not need loop detection active here... + var s = expand('suggested', Object.keys(features), {}, + { + disabled: disabled, + missing: missing_suggested, + }) + s = Object.keys(s) + .filter(function(f){ + // populate the tree of feature suggestions... + suggests[f] = s[f] + // filter out what's in features already... + return !(f in features) }) + // get suggestion dependencies... + // NOTE: we do not care bout missing here... + s = expand('depends', s, {}, + { + loops: loops, + disabled: disabled, + disable_loops: disable_loops, + exclusive: exclusive, + missing: missing_suggested, + }) + Object.keys(s) + .forEach(function(f){ + // keep only suggested features -- diff with features... + if(f in features){ + delete s[f] + + // mix suggested into features... + } else { + features[f] = s[f] + suggested[f] = (s[f] || []).slice() } }) + + sortExclusive(features) + + return features } + + + //--------------------- Globals: filtering / exclusive tags --- + + var loops = [] + var disable_loops = [] + var disabled = [] + var missing = [] + var missing_suggested = [] + var suggests = {} + var suggested = {} + + // user filter... + // NOTE: we build this out of the full feature list... + disabled = disabled + .concat(isDisabled ? + all.filter(isDisabled) + : []) + + // build exclusive groups... + // XXX need to sort the values to the same order as given features... + var rev_exclusive = {} + var exclusive = this.getExclusive('*', all, rev_exclusive, isDisabled) + + + //-------------------------------- Stage 1: expand features --- + var features = expandFeatures(lst) + + + //-------------------------------- Exclusive groups/aliases --- + // Handle exclusive feature groups and aliases... + var conflicts = {} + var done = [] + var to_remove = [] + Object.keys(features) + .forEach(function(f){ + // alias... + while(f in exclusive && done.indexOf(f) < 0){ + var candidates = (exclusive[f] || []) + .filter(function(c){ + return c in features }) + + // resolve alias to non-included feature... + if(candidates.length == 0){ + var target = exclusive[f][0] + + // expand target to features... + expandFeatures([target], features) + + // link alias to existing feature... + } else { + var target = candidates[0] } + + // remove the alias... + // NOTE: exclusive tag can match a feature tag, thus + // we do not want to delete such tags... + // NOTE: we are not removing to_remove here as they may + // get added/expanded back in by other features... + !(f in that) + && to_remove.push(f) + // replace dependencies... + Object.keys(features) + .forEach(function(e){ + var i = features[e] ? + features[e].indexOf(f) + : -1 + i >= 0 + && features[e].splice(i, 1, target) }) + f = target + done.push(f) } + + // exclusive feature... + if(f in rev_exclusive){ + // XXX handle multiple groups... (???) + var group = rev_exclusive[f] + var candidates = (exclusive[group] || []) + .filter(function(c){ return c in features }) + + if(!(group in conflicts) && candidates.length > 1){ + conflicts[group] = candidates } } }) + // cleanup... + to_remove.forEach(function(f){ delete features[f] }) + // resolve any exclusivity conflicts found... + var excluded = [] + Object.keys(conflicts) + .forEach(function(group){ + // XXX should this resolve to the last of the first feature??? + excluded = excluded.concat(conflicts[group].slice(1))}) + disabled = disabled.concat(excluded) + + + //--------------------------------------- Disabled features --- + // Handle disabled features and cleanup... + + // reverse dependency index... + // ...this is used to clear out orphaned features later and for + // introspection... + var rev_features = {} + Object.keys(features) + .forEach(function(f){ + (features[f] || []) + .forEach(function(d){ + rev_features[d] = (rev_features[d] || []).concat([f]) }) }) + + // clear dependency trees containing disabled features... + var suggested_clear = [] + do { + var expanded_disabled = false + disabled + .forEach(function(d){ + // disable all features that depend on a disabled feature... + Object.keys(features) + .forEach(function(f){ + if(features[f] + && features[f].indexOf(d) >= 0 + && disabled.indexOf(f) < 0){ + expanded_disabled = true + disabled.push(f) } }) + + // delete the feature itself... + var s = suggests[d] || [] + delete suggests[d] + delete features[d] + + // clear suggested... + s + .forEach(function(f){ + if(disabled.indexOf(f) < 0 + // not depended/suggested by any of + // the non-disabled features... + && Object.values(features) + .concat(Object.values(suggests)) + .filter(n => n.indexOf(f) >= 0) + .length == 0){ + expanded_disabled = true + disabled.push(f) } }) }) + } while(expanded_disabled) + + // remove orphaned features... + // ...an orphan is a feature included by a disabled feature... + // NOTE: this should take care of missing features too... + Object.keys(rev_features) + .filter(function(f){ + return rev_features[f] + // keep non-disabled and existing sources only... + .filter(function(e){ + return !(e in features) || disabled.indexOf(e) < 0 }) + // keep features that have no sources left, i.e. orphans... + .length == 0 }) + .forEach(function(f){ + console.log('ORPHANED:', f) + disabled.push(f) + delete features[f] }) + + + //---------------------------------- Stage 2: sort features --- + + // Prepare for sort: expand dependency list in features... + // + // NOTE: this will expand lst in-place... + // NOTE: we are not checking for loops here -- mainly because + // the input is expected to be loop-free... + var expanddeps = function(lst, cur, seen){ + seen = seen || [] + if(features[cur] == null){ + return } + // expand the dep list recursively... + // NOTE: this will expand features[cur] in-place while + // iterating over it... + for(var i=0; i < features[cur].length; i++){ + var f = features[cur][i] + if(seen.indexOf(f) < 0){ + seen.push(f) + + expanddeps(features[cur], f, seen) + + features[cur].forEach(function(e){ + lst.indexOf(e) < 0 + && lst.push(e) }) } } } + // do the actual expansion... + var list = Object.keys(features) + list.forEach(function(f){ expanddeps(list, f) }) + + // sort by priority... + // + // NOTE: this will attempt to only move features with explicitly + // defined priorities and keep the rest in the same order + // when possible... + list = list + // format: + // [ , , ] + .map(function(e, i){ + return [e, i, + (that[e] + && that[e].getPriority) ? + that[e].getPriority() + : 0 ] }) + .sort(function(a, b){ + return a[2] - b[2] + || a[1] - b[1] }) + // cleanup... + .map(function(e){ return e[0] }) + // sort by the order features should be loaded... + .reverse() + + // sort by dependency... + // + // NOTE: this requires the list to be ordered from high to low + // priority, i.e. the same order they should be loaded in... + // NOTE: dependency loops will throw this into and "infinite" loop... + var loop_limit = list.length + 1 + do { + var moves = 0 + if(list.length == 0){ + break } + list + .slice() + .forEach(function(e){ + var deps = features[e] + if(!deps){ + return } + var from = list.indexOf(e) + var to = list + .map(function(f, i){ return [f, i] }) + .slice(from+1) + // keep only dependencies... + .filter(function(f){ return deps.indexOf(f[0]) >= 0 }) + .pop() + if(to){ + // place after last dependency... + list.splice(to[1]+1, 0, e) + list.splice(from, 1) + moves++ } }) + loop_limit-- + } while(moves > 0 && loop_limit > 0) + + + //------------------------------------------------------------- + + // remove exclusivity tags that were resolved... + var isMissing = function(f){ + return !( + // feature is a resolvable exclusive tag... + (exclusive[f] || []).length > 0 + // feature was resolved... + && exclusive[f] + .filter(function(f){ return list.indexOf(f) >= 0 }) + .length > 0) } + + return { + input: lst, + + features: list, + + disabled: disabled, + excluded: excluded, + + // errors and conflicts... + error: (loops.length > 0 + || Object.keys(conflicts).length > 0 + || loop_limit <= 0 + || missing.length > 0 + || missing_suggested.length > 0) ? + { + missing: missing.filter(isMissing), + missing_suggested: missing_suggested.filter(isMissing), + conflicts: conflicts, + + // fatal stuff... + fatal: loops.length > 0 || loop_limit <= 0, + loops: loops, + sort_loop_overflow: loop_limit <= 0, + } + : null, + + // introspection... + depends: features, + depended: rev_features, + suggests: suggests, + suggested: suggested, + //exclusive: exclusive, + } + }, + + // Setup features... + // + // Setup features on existing actions object... + // .setup(actions, [feature-tag, ...]) + // -> actions + // + // Setup features on a new actions object... + // .setup(feature-tag) + // .setup([feature-tag, ...]) + // -> actions + // + // + // This will set .features on the object. + // + // .features format: + // { + // // the current feature set object... + // // XXX not sure about this -- revise... + // FeatureSet: feature-set, + // + // // list of features not applicable in current context... + // // + // // i.e. the features that defined .isApplicable(..) and it + // // returned false when called. + // unapplicable: [ feature-tag, .. ], + // + // // output of .buildFeatureList(..)... + // ... + // } + // + // NOTE: this will store the build result in .features of the output + // actions object. + // NOTE: .features is reset even if a FeatureLinearizationError error + // is thrown. + setup: function(obj, lst){ + // no explicit object is given... + if(lst == null){ + lst = obj + obj = null } + obj = obj || (this.__actions__ || actions.Actions)() + lst = lst instanceof Array ? lst : [lst] + + var unapplicable = [] + var features = this.buildFeatureList(lst, + (function(n){ + // if we already tested unapplicable, no need to test again... + if(unapplicable.indexOf(n) >= 0){ + return true } + var f = this[n] + // check applicability if possible... + if(f && f.isApplicable && !f.isApplicable.call(this, obj)){ + unapplicable.push(n) + return true } + return false }).bind(this)) + features.unapplicable = unapplicable + // cleanup disabled -- filter out unapplicable and excluded features... + // NOTE: this is done mainly for cleaner and simpler reporting + // later on... + features.disabled = features.disabled + .filter(function(n){ + return unapplicable.indexOf(n) < 0 + && features.excluded.indexOf(n) < 0 }) + + // if we have critical errors and set verbose... + var fatal = features.error + && (features.error.loops.length > 0 || features.error.sort_loop_overflow) + + // report stuff... + if(this.__verbose__){ + var error = features.error + // report dependency loops... + error.loops.length > 0 + && error.loops + .forEach(function(loop){ + console.warn('Feature dependency loops detected:\n\t' + + loop.join('\n\t\t-> ')) }) + // report conflicts... + Object.keys(error.conflicts) + .forEach(function(group){ + console.error('Exclusive "'+ group +'" conflict at:', error.conflicts[group]) }) + // report loop limit... + error.sort_loop_overflow + && console.error('Hit loop limit while sorting dependencies!') } + + features.FeatureSet = this + + obj.features = features + + // fatal error -- can't load... + if(fatal){ + throw new FeatureLinearizationError(features) } + + // mixout everything... + this.remove(obj, + obj.mro('tag') + .filter(function(e){ + return !!e })) + + // do the setup... + var that = this + var setup = Feature.prototype.setup + features.features.forEach(function(n){ + // setup... + if(that[n] != null){ + this.__verbose__ + && console.log('Setting up feature:', n) + setup.call(that[n], obj) } }) + + return obj }, + + // XXX revise... + // ...the main problem here is that .mixout(..) is accesible + // directly from actions while the feature .remove(..) method + // is not... + // ...would be nice to expose the API to actions directly or + // keep it only in features... + remove: function(obj, lst){ + lst = lst.constructor !== Array ? [lst] : lst + var that = this + lst.forEach(function(n){ + if(that[n] != null){ + this.__verbose__ + && console.log('Removing feature:', n) + that[n].remove(obj) } }) }, + + + // Generate a Graphviz graph from features... + // + // XXX experimental... + gvGraph: function(lst, dep){ + lst = lst || this.features + dep = dep || this + + var graph = '' + graph += 'digraph ImageGrid {\n' + lst + .filter(function(f){ return f in dep }) + .forEach(function(f){ + var deps = dep[f].depends || [] + + deps.length > 0 ? + deps.forEach(function(d){ + graph += `\t"${f}" -> "${d}";\n` }) + : (graph += `\t"${f}";\n`) }) + graph += '}' + + return graph }, +}) + + + + +/*********************************************************************/ +// default feature set... + +var Features = +module.Features = new FeatureSet() + + + + +/********************************************************************** +* vim:set ts=4 sw=4 : */ return module })