feat: First working TypeDispatcher, supporting plain functions on Number (#4)

Resolves #1.

A hand test showed this code can add two plus two, always a major milestone. So we will skip review on this PR since there is currently no testing framework, and proceed immediately to addressing #3.

Reviewed-on: glen/nanomath#4
Co-authored-by: Glen Whitney <glen@studioinfinity.org>
Co-committed-by: Glen Whitney <glen@studioinfinity.org>
This commit is contained in:
Glen Whitney 2025-04-07 05:11:50 +00:00 committed by Glen Whitney
parent 69ef928b6e
commit 79c6d44fda
4 changed files with 426 additions and 18 deletions

View file

@ -1,5 +1,32 @@
import {Returns} from './helpers.js'
// Order matters here, because these items will merged into a new
// dispatcher in the order they are listed
const typeObject = {}
export const types = () => typeObject
export class Type {
constructor(f) {
this.test = f
}
}
export const Undefined = new Type(t => typeof t === 'undefined')
export const TypeOfTypes = new Type(t => t instanceof Type)
export const typeOf = Returns(TypeOfTypes, item => {
for (const type of Object.values(typeObject)) {
if (!(type instanceof Type)) continue
if (type.test(item)) return type
}
})
// bootstrapping order matters, but order of exports in a module isn't
// simply the order that the items are listed in the module. So we make
// an explicitly ordered export of implementations for this sake:
export const bootstrapTypes = {
types, Type, Undefined, TypeOfTypes, typeOf
}

View file

@ -1,11 +1,33 @@
import {Type} from './Type.js'
import {Implementations, isPlainObject} from './helpers.js'
import {typeOf, Type, bootstrapTypes} from './Type.js'
import {Implementations, isPlainObject, onType} from './helpers.js'
import {alwaysMatches, Any, Multiple} from './TypePatterns.js'
// helper that organizes a list into the same chunks as a pattern is
const collectLike = (list, pattern, state = {pos: 0}) => {
const result = []
for (const elt of pattern) {
if (Array.isArray(elt)) result.push(collectLike(list, elt, state))
else result.push(list[state.pos++])
}
return result
}
export class TypeDispatcher {
constructor(...specs) {
this._implementations = {} // maps key to list of [pattern, result] pairs
this._dependencies = {} // maps key to a map from type vectors to...
// ...a set of [key, types] that depend on it.
this._behaviors = {} // maps key to a map from type vectors to results
this._fallbacks = {} // maps key to a catchall result
this.merge(bootstrapTypes) // bootstrap the instance
for (const spec of specs) this.merge(spec)
}
// Only modify a TypeDispatcher via merge! Otherwise dependencies will
// not necessarily be updated properly. Actually I may have it set up so
// that's the only way to modify a TypeDispatcher... not sure, should
// probably test it.
merge(spec) {
if (!spec) return
if (typeof spec != 'object') {
@ -13,25 +35,271 @@ export class TypeDispatcher {
`TypeDispatcher specifications must be objects, not '${spec}'.`)
}
for (const key in spec) {
const val = spec[key]
let val = spec[key]
if (val instanceof Type) {
console.log(`Pretending to install type ${key}: ${val}`)
continue
}
if (val instanceof Implementations) {
console.log(`Pretending to install implementations for ${key}`)
console.log(` --> ${val}`)
// TODO: Need to wipe out any dependencies on types[key]!
this.types[key] = val
continue
}
if (typeof val === 'function') {
console.log(`Pretend install of catchall implementation for ${key}`)
val = onType(Multiple(Any), val)
}
if (val instanceof Implementations) {
if (!(key in this)) {
// need to set up the item
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get: () => {
let tryValue
let tryTypes = []
try {
tryValue = this.resolve(key, [])
} catch {
// Has no value for the empty type list, so
// find a type list it will have a value for
tryTypes =
this._implementations[key][0][0].sampleTypes()
tryValue = this.resolve(key, tryTypes)
}
// Redefine the property according to what sort of
// entity it is:
if (typeof tryValue === 'function') {
// the usual case: a method of the dispatcher
const standard = (...args) => {
const types = args.map(typeOf)
return this.resolve(key, types)(...args)
}
standard.resolve = (types) => this.resolve(key, types)
standard.isDispatcher = true
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get: () => standard
})
return standard
}
if (tryTypes.length) tryValue = undefined
if (typeof tryValue === 'object') {
if (!('resolve' in tryValue)) {
tryValue.resolve = types => this.resolve(key, types)
}
const get = () => {
if (this._dependencies[key]?.get([])?.size) {
return DependencyWatcher(tryValue, [key], this)
}
return tryValue
}
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get
})
return get()
}
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get: () => tryValue
})
return tryValue
}
})
this._implementations[key] = []
this._behaviors[key] = new Map()
this._dependencies[key] = new Map()
}
// Now add all of the patterns of this implementation:
for (const [pattern, result] of val.patterns) {
if (alwaysMatches(pattern)) {
if (key in this._fallbacks) this._disengageFallback(key)
this._fallbacks[key] = result
} else {
this._clearBehaviorsMatching(key, pattern)
// if it happens the same pattern is already in the
// implementations, remove it
const imps = this._implementations[key]
const have = imps.findIndex(elt => pattern.equal(elt[0]))
if (have >= 0) imps.splice(have, 1)
this._implementations[key].unshift([pattern, result])
}
}
continue
}
if (isPlainObject(val)) {
this.merge(val)
continue
}
console.log(`Pretend install of catchall value for ${key}: ${val}`)
// install value as a catchall value
this.merge({[key]: onType(Multiple(Any), val)})
}
}
_addToDeps(key, subkey) {
const depMap = this._dependencies[key]
if (!depMap.has(subkey)) depMap.set(subkey, new Set())
const depSet = depMap.get(subkey)
for (const pair of this.resolve._genDepsOf) depSet.add(pair)
}
resolve(key, types) {
if (!(key in this)) {
throw new ReferenceError(`no method or value for key '${key}'`)
}
if (this.resolve._genDepsOf?.length) _addToDeps(key, types)
const behave = this._behaviors[key]
if (behave.has(types)) return behave.get(types)
const imps = this._implementations[key]
let needItem = true
let item
let pattern
let template
if (imps.length) {
for ([pattern, item] of imps) {
let finalIndex
;[finalIndex, template] = pattern.match(types, 0)
if (finalIndex === types.length) {
needItem = false
break
}
}
}
if (needItem && key in this._fallbacks) {
needItem = false
item = this._fallbacks[key]
template = [types]
}
if (needItem) {
throw new TypeError(`no matching definition of '${key}' on '${types}'`)
}
if (typeof item !== 'function' || 'returns' in item) {
behave.set(types, item)
return item
}
// item is a Factory. We have to use it to build the behavior
// First set up to record dependencies
if (!('_genDepsOf' in this.resolve)) {
this.resolve._genDepsOf = []
}
this.resolve._genDepsOf.push([key, types])
let theBehavior = item(DependencyRecorder(this, [], this), pattern)
this.resolve._genDepsOf.pop()
if (typeof theBehavior === 'function'
&& theBehavior.length
&& template.some(elt => Array.isArray(elt))
) {
// have to wrap the behavior to collect the actual arguments
// in the way corresponding to the template
const wrappedBehavior = (...args) => {
const collectedArgs = collectLike(args, template)
return theBehavior(...collectedArgs)
}
theBehavior = wrappedBehavior
}
behave.set(types, theBehavior)
return theBehavior
}
// Method called to invalidate a set of behaviors
// I think all it has to do is throw them out; they should be
// regenerated properly by resolve, if all is well.
_invalidate(behaveSet) {
for (const [key, types] of behaveSet) this._behaviors[key].delete(types)
}
_disengageFallback(key) {
// We need to find all of the behaviors that currently rely on the
// fallback, invalidate their dependencies, and remove them.
const fallTypes = []
const behs = this._behaviors[key]
const imps = this._implementations[key]
const deps = this._dependencies[key]
for (const types of behs.keys()) {
let fallsback = true
for (const [pattern] of imps) {
const [finalIndex] = pattern.match(types)
if (finalIndex === types.length) {
fallsback = false
break
}
}
if (fallsback) fallTypes.push(types)
}
for (const types of fallTypes) {
const depSet = deps?.get(types)
if (depSet?.size) this._invalidate(depSet)
behs.delete(types)
}
}
_clearBehaviorsMatching(key, pattern) {
// like disengageFallback, just we have the offending pattern:
const behs = this._behaviors[key]
const deps = this._dependencies[key]
const patTypes = behs.keys().filter(types => {
const [finalIndex] = pattern.match(types)
return finalIndex === types.length
})
for (const types of patTypes) {
const depSet = deps?.get(types)
if (depSet?.size) this._invalidate(depSet)
behs.delete(types)
}
}
}
// Proxy that traps accesses and records dependencies on them
const DependencyRecorder = (object, path, repo) => new Proxy(object, {
get(target, prop, receiver) {
const result = Reflect.get(target, prop, receiver)
// pass resolve calls through, since we record dependencies therein:
if (prop === 'resolve' || 'isDispatcher' in result) return result
// OK, it's not a method on a TypeDispatcher, it's some other kind of
// value. So first record the dependency on prop at this path:
const newPath = path.slice()
newPath.push(prop)
const key = newPath[0]
const subpath = newPath.slice(1)
repo._addToDeps(key, subpath)
// Now, if the result is an object, we may need to record further
// dependencies on its properties (e.g. math.config.predictable)
// So proxy the return value:
if (typeof result === 'object') {
return DependencyRecorder(result, newPath, repo)
} else return result
}
})
// The flip side: proxy that traps setting properties and invalidates things
// that depend on them:
const DependencyWatcher = (object, path, repo) => new Proxy(object, {
set(target, prop, value, receiver) {
// First see if this setting has any dependencies:
const newPath = path.slice()
newPath.push(prop)
const key = newPath.unshift()
const depSet = repo._dependencies[key]?.get(newPath)
if (depSet?.size) {
// It does. So if we are changing it, invalidate them:
const oldValue = Reflect.get(target, prop, receiver)
if (value !== oldValue) repo._invalidate(depSet)
}
// Now we can just perform the setting
return Reflect.set(target, prop, value, receiver)
},
get(target, prop, receiver) {
// Only thing we need to do is push the watching down
const result = Reflect.get(target, prop, receiver)
if (typeof result === 'object') {
const newPath = path.slice()
newPath.push(prop)
return DependencyWatcher(result, newPath, repo)
}
return result
}
})

118
src/core/TypePatterns.js Normal file
View file

@ -0,0 +1,118 @@
import {Type, Undefined} from './Type.js'
class TypePattern {
match(typeSequence, position) {
throw new Error('Specific TypePatterns must implement match')
}
sampleTypes() {
throw new Error('Specific TypePatterns must implement sampleTypes')
}
equal(other) {return other.constructor === this.constructor}
}
class MatchTypePattern extends TypePattern {
constructor(typeToMatch) {
super()
this.type = typeToMatch
}
match(typeSequence, position) {
if (position < typeSequence.length
&& typeSequence[position] === this.type
) {
return [position + 1, typeSequence[position]]
}
return [-1, Undefined]
}
sampleTypes() {return [this.type]}
equal(other) {return super.equal(other) && this.type === other.type}
}
class SequencePattern extends TypePattern {
constructor(itemsToMatch) {
super()
this.patterns = itemsToMatch.map(pattern)
}
match(typeSequence, position) {
const matches = []
for (const pat of this.patterns) {
const [newPos, newMatch] = pat.match(typeSequence, position)
if (newPos < 0) return [-1, Undefined]
position = newPos
matches.push(newMatch)
}
return [position, matches]
}
sampleTypes() {
return this.patterns.map(pat => pat.sampleTypes()).flat()
}
equal(other) {
return super.equal(other)
&& this.patterns.length === other.patterns.length
&& this.patterns.every((elt, ix) => elt.equal(other.patterns[ix]))
}
}
export const pattern = patternOrSpec => {
if (patternOrSpec instanceof TypePattern) return patternOrSpec
if (patternOrSpec instanceof Type) {
return new MatchTypePattern(patternOrSpec)
}
if (Array.isArray(patternOrSpec)) return new SequencePattern(patternOrSpec)
throw new TypeError(`Can't interpret '${patternOrSpec}' as a type pattern`)
}
class AnyPattern extends TypePattern {
match(typeSequence, position) {
return position < typeSequence.length
? [position + 1, typeSequence[position]]
: [-1, Undefined]
}
sampleTypes() {return [Undefined]}
}
export const Any = new AnyPattern()
class OptionalPattern extends TypePattern {
constructor(item) {
this.pattern = pattern(item)
}
match(typeSequence, position) {
const matches = []
const [newPos, newMatch] = this.pattern.match(typeSequence, position)
if (newPos >= 0) {
position = newPos
matches.push(newMatch)
}
return [position, matches]
}
sampleTypes() {return []}
equal(other) {
return super.equal(other) && this.pattern.equal(other.pattern)
}
}
export const Optional = item => new OptionalPattern(item)
class MultiPattern extends TypePattern {
constructor(item) {
super()
this.pattern = pattern(item)
}
match(typeSequence, position) {
const matches = []
while (true) {
const [newPos, newMatch] = this.pattern.match(typeSequence, position)
if (newPos < 0) return [position, matches]
position = newPos
matches.push(newMatch)
}
}
sampleTypes() {return []}
equal(other) {
return super.equal(other) && this.pattern.equal(other.pattern)
}
}
export const Multiple = item => new MultiPattern(item)
export const alwaysMatches =
pat => pat instanceof MultiPattern && pat.pattern instanceof AnyPattern

View file

@ -1,16 +1,11 @@
import {Type} from './Type.js'
import {pattern} from './TypePatterns.js'
export class Implementations {
constructor(imps) {
this.patterns = new Map()
for (let i = 0; i < imps.length; ++i) {
let pattern = imps[i++]
if (!Array.isArray(pattern)) pattern = [pattern]
if (!pattern.every(item => item instanceof Type)) {
throw new TypeError(
`Implementation pattern ${pattern} contains non-Type entry`)
}
this.patterns.set(pattern, imps[i])
this.patterns.set(pattern(imps[i]), imps[++i])
}
}
}