pocomath/src/core/PocomathInstance.mjs

334 lines
12 KiB
JavaScript

/* Core of pocomath: create an instance */
import typed from 'typed-function'
import dependencyExtractor from './dependencyExtractor.mjs'
import {subsetOfKeys, typesOfSignature} from './utils.mjs'
export default class PocomathInstance {
/* Disallowed names for ops; beware, this is slightly non-DRY
* in that if a new top-level PocomathInstance method is added, its name
* must be added to this list.
*/
static reserved = new Set([
'config', 'importDependencies', 'install', 'name', 'Types'])
constructor(name) {
this.name = name
this._imps = {}
this._affects = {}
this._typed = typed.create()
this._typed.clear()
this.Types = {any: {}} // dummy entry to track the default 'any' type
this._doomed = new Set() // for detecting circular reference
this._config = {predictable: false}
const self = this
this.config = new Proxy(this._config, {
get: (target, property) => target[property],
set: (target, property, value) => {
if (value !== target[property]) {
target[property] = value
self._invalidateDependents('config')
}
return true // successful
}
})
}
/**
* (Partially) define one or more operations of the instance:
*
* @param {Object<string, Object<Signature, ({deps})=> implementation>>} ops
* The only parameter ops gives the semantics of the operations to install.
* The keys are operation names. The value for a key is an object
* mapping each desired (typed-function) signature to a function taking
* a dependency object to an implementation.
*
* For more detail, such functions should have the format
* ```
* ({depA, depB, depC: aliasC, ...}) => (opArg1, opArg2) => <result>
* ```
* where the `depA`, `depB` etc. are the names of the
* operations this implementation depends on; those operations can
* then be referred to directly by the identifiers `depA` and `depB`
* in the code for the '<result>`, or when an alias has been given
* as in the case of `depC`, by the identifier `aliasC`.
* Given an object that has these dependencies with these keys, the
* function returns a function taking the operation arguments to the
* desired result of the operation.
*
* You can specify that an operation depends on itself by using the
* special dependency identifier 'self'.
*
* You can specify that an implementation depends on just a specific
* signature of the given operation by suffixing the dependency name
* with the signature in parentheses, e.g. `add(number,number)` to
* refer to just adding two numbers. In this case, it is of course
* necessary to specify an alias to be able to refer to the supplied
* operation in the body of the implementation. [NOTE: this signature-
* specific reference is not yet implemented.]
*
* Note that any "operation" whose name begins with `Type_` is special:
* it defines a types that must be installed in the instance.
* The remainder of the "operation" name following the `_` is the
* name of the type. The value of the "operation" should be a plain
* object with the following properties:
*
* - test: the predicate for the type
* - from: a plain object mapping the names of types that can be converted
* **to** this type to the corresponding conversion functions
* - before: [optional] a list of types this should be added
* before, in priority order
*/
install(ops) {
for (const [item, spec] of Object.entries(ops)) {
if (item.slice(0,5) === 'Type_') {
this._installType(item.slice(5), spec)
} else {
this._installOp(item, spec)
}
}
}
/**
* Import (and install) all dependencies of previously installed functions,
* for the specified types.
*
* @param {string[]} types A list of type names
*/
async importDependencies(types) {
const typeSet = new Set(types)
typeSet.add('generic')
const doneSet = new Set(['self']) // nothing to do for self dependencies
while (true) {
const requiredSet = new Set()
/* Grab all of the known deps */
for (const func in this._imps) {
if (func === 'Types') continue
for (const {uses} of Object.values(this._imps[func])) {
for (const dependency of uses) {
const depName = dependency.split('(',1)[0]
if (doneSet.has(depName)) continue
requiredSet.add(depName)
}
}
}
if (requiredSet.size === 0) break
for (const name of requiredSet) {
for (const type of typeSet) {
try {
const modName = `../${type}/${name}.mjs`
const mod = await import(modName)
this.install(mod)
} catch (err) {
// No such module, but that's OK
}
}
doneSet.add(name)
}
}
}
/* Used internally by install, see the documentation there.
* Note that unlike _installOp below, we can do this immediately
*/
_installType(type, spec) {
if (type in this.Types) {
if (spec !== this.Types[type]) {
throw new SyntaxError(`Conflicting definitions of type ${type}`)
}
return
}
let beforeType = 'any'
for (const other of spec.before || []) {
if (other in this.Types) {
beforeType = other
break
}
}
this._typed.addTypes([{name: type, test: spec.test}], beforeType)
/* Now add conversions to this type */
for (const from in (spec.from || {})) {
if (from in this.Types) {
this._typed.addConversion(
{from, to: type, convert: spec.from[from]})
}
}
/* And add conversions from this type */
for (const to in this.Types) {
if (type in (this.Types[to].from || {})) {
this._typed.addConversion(
{from: type, to, convert: this.Types[to].from[type]})
}
}
this.Types[type] = spec
// rebundle anything that uses the new type:
this._invalidateDependents(':' + type)
}
/* Used internally by install, see the documentation there */
_installOp(name, implementations) {
if (name.charAt(0) === '_') {
throw new SyntaxError(
`Pocomath: Cannot install ${name}, `
+ 'initial _ reserved for internal use.')
}
if (PocomathInstance.reserved.has(name)) {
throw new SyntaxError(
`Pocomath: the meaning of function '${name}' cannot be modified.`)
}
// new implementations, so set the op up to lazily recreate itself
this._invalidate(name)
const opImps = this._imps[name]
for (const [signature, does] of Object.entries(implementations)) {
if (signature in opImps) {
if (does !== opImps[signature].does) {
throw new SyntaxError(
`Conflicting definitions of ${signature} for ${name}`)
}
} else {
const uses = new Set()
does(dependencyExtractor(uses))
opImps[signature] = {uses, does}
for (const dep of uses) {
const depname = dep.split('(', 1)[0]
if (depname === 'self') continue
this._addAffect(depname, name)
}
for (const type of typesOfSignature(signature)) {
this._addAffect(':' + type, name)
}
}
}
}
_addAffect(dependency, dependent) {
if (dependency in this._affects) {
this._affects[dependency].add(dependent)
} else {
this._affects[dependency] = new Set([dependent])
}
}
/**
* Reset an operation to require creation of typed-function,
* and if it has no implementations so far, set them up.
*/
_invalidate(name) {
if (this._doomed.has(name)) {
/* In the midst of a circular invalidation, so do nothing */
return
}
if (!(name in this._imps)) {
this._imps[name] = {}
}
this._doomed.add(name)
this._invalidateDependents(name)
this._doomed.delete(name)
const self = this
Object.defineProperty(this, name, {
configurable: true,
get: () => self._bundle(name)
})
}
/**
* Invalidate all the dependents of a given property of the instance
*/
_invalidateDependents(name) {
if (name in this._affects) {
for (const ancestor of this._affects[name]) {
this._invalidate(ancestor)
}
}
}
/**
* Create a typed-function from the signatures for the given name and
* assign it to the property with that name, returning it as well
*/
_bundle(name) {
const imps = this._imps[name]
if (!imps) {
throw new SyntaxError(`No implementations for ${name}`)
}
const usableEntries = Object.entries(imps).filter(
([signature]) => subsetOfKeys(typesOfSignature(signature), this.Types))
if (usableEntries.length === 0) {
throw new SyntaxError(
`Every implementation for ${name} uses an undefined type;\n`
+ ` signatures: ${Object.keys(imps)}`)
}
Object.defineProperty(this, name, {configurable: true, value: 'limbo'})
const tf_imps = {}
for (const [signature, {uses, does}] of usableEntries) {
if (uses.length === 0) {
tf_imps[signature] = does()
} else {
const refs = {}
let full_self_referential = false
let part_self_references = []
for (const dep of uses) {
const [func, needsig] = dep.split(/[()]/)
if (func === 'self') {
if (needsig) {
if (full_self_referential) {
throw new SyntaxError(
'typed-function does not support mixed full and '
+ 'partial self-reference')
}
if (subsetOfKeys(typesOfSignature(needsig), this.Types)) {
part_self_references.push(needsig)
}
} else {
if (part_self_references.length) {
throw new SyntaxError(
'typed-function does not support mixed full and '
+ 'partial self-reference')
}
full_self_referential = true
}
} else {
if (this[func] === 'limbo') {
/* We are in the midst of bundling func, so have to use
* an indirect reference to func. And given that, there's
* really no helpful way to extract a specific signature
*/
const self = this
refs[dep] = function () { // is this the most efficient?
return self[func].apply(this, arguments)
}
} else {
// can bundle up func, and grab its signature if need be
let destination = this[func]
if (needsig) {
destination = this._typed.find(destination, needsig)
}
refs[dep] = destination
}
}
}
if (full_self_referential) {
tf_imps[signature] = this._typed.referToSelf(self => {
refs.self = self
return does(refs)
})
} else if (part_self_references.length) {
tf_imps[signature] = this._typed.referTo(
...part_self_references, (...impls) => {
for (let i = 0; i < part_self_references.length; ++i) {
refs[`self(${part_self_references[i]})`] = impls[i]
}
return does(refs)
}
)
} else {
tf_imps[signature] = does(refs)
}
}
}
const tf = this._typed(name, tf_imps)
Object.defineProperty(this, name, {configurable: true, value: tf})
return tf
}
}