refactor: replace string-based emitter with type-safe generic event system (#28)
## Description This PR refactors the event emitter system from a string-based message passing approach to a type-safe generic implementation using typed events. The previous system relied on string message names which were error-prone and lacked compile-time safety. This refactoring introduces a generic `BaseEmitter[E comparable]` that provides type safety while consolidating the various tracker packages into a unified plugins architecture. Key changes: - Replace `Emitter` with generic `BaseEmitter[E comparable]` for type-safe event handling. - Add `Event` type enumeration with `StartEvent`, `StepEvent`, and `StopEvent` constants. - Create `Listener[E]` interface with `BaseListener` implementation for better abstraction. - Consolidate `explanation`, `performance`, and `statistics` packages into unified `internal/plugins` package. - Simplify CLI initialization by using plugin constructors that handle their own event subscriptions. - Add `Items()` iterator method to `Set` for idiomatic Go 1.23+ range loops over sets. ### Decisions Use generics for type-safe event handling. This provides compile-time guarantees that event types match their handlers while maintaining flexibility for future event types. Consolidate trackers into plugins architecture. Previously separate packages (`explanation`, `performance`, `statistics`) now live under `internal/plugins`, making the plugin pattern explicit and easier to extend. Plugin constructors self-register with engine. Each plugin's `New*` constructor now handles its own event subscriptions, reducing boilerplate in the main CLI. ## Benefits Type safety prevents runtime errors from typos in event names. The compiler now catches mismatched event types at compile time rather than failing silently at runtime. Cleaner plugin architecture makes adding new features easier. New plugins follow a consistent pattern and live in a single location. Reduced boilerplate in main CLI. Plugin initialization is now a single function call rather than manual event registration. Better testability through interface-based design. The `Listener[E]` interface allows for easier mocking and testing of event handlers. ## Checklist - [x] Code follows conventional commit format. - [x] Branch follows naming convention (`<type>/<description>`). - [x] Tests pass (if applicable). - [x] Documentation updated (if applicable). Reviewed-on: #28 Co-authored-by: M.V. Hutz <git@maximhutz.me> Co-committed-by: M.V. Hutz <git@maximhutz.me>
This commit was merged in pull request #28.
This commit is contained in:
@@ -6,9 +6,7 @@ import (
|
|||||||
"git.maximhutz.com/max/lambda/internal/cli"
|
"git.maximhutz.com/max/lambda/internal/cli"
|
||||||
"git.maximhutz.com/max/lambda/internal/config"
|
"git.maximhutz.com/max/lambda/internal/config"
|
||||||
"git.maximhutz.com/max/lambda/internal/engine"
|
"git.maximhutz.com/max/lambda/internal/engine"
|
||||||
"git.maximhutz.com/max/lambda/internal/explanation"
|
"git.maximhutz.com/max/lambda/internal/plugins"
|
||||||
"git.maximhutz.com/max/lambda/internal/performance"
|
|
||||||
"git.maximhutz.com/max/lambda/internal/statistics"
|
|
||||||
"git.maximhutz.com/max/lambda/pkg/convert"
|
"git.maximhutz.com/max/lambda/pkg/convert"
|
||||||
"git.maximhutz.com/max/lambda/pkg/lambda"
|
"git.maximhutz.com/max/lambda/pkg/lambda"
|
||||||
"git.maximhutz.com/max/lambda/pkg/saccharine"
|
"git.maximhutz.com/max/lambda/pkg/saccharine"
|
||||||
@@ -42,30 +40,23 @@ func main() {
|
|||||||
// If the user selected to track CPU performance, attach a profiler to the
|
// If the user selected to track CPU performance, attach a profiler to the
|
||||||
// process.
|
// process.
|
||||||
if options.Profile != "" {
|
if options.Profile != "" {
|
||||||
profiler := performance.Track(options.Profile)
|
plugins.NewPerformance(options.Profile, process)
|
||||||
process.On("start", profiler.Start)
|
|
||||||
process.On("end", profiler.End)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the user selected to produce a step-by-step explanation, attach an
|
// If the user selected to produce a step-by-step explanation, attach an
|
||||||
// observer here.
|
// observer here.
|
||||||
if options.Explanation {
|
if options.Explanation {
|
||||||
explanation.Track(process)
|
plugins.NewExplanation(process)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the user opted to track statistics, attach a tracker here, too.
|
// If the user opted to track statistics, attach a tracker here, too.
|
||||||
if options.Statistics {
|
if options.Statistics {
|
||||||
statistics := statistics.Track()
|
plugins.NewStatistics(process)
|
||||||
process.On("start", statistics.Start)
|
|
||||||
process.On("step", statistics.Step)
|
|
||||||
process.On("end", statistics.End)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the user selected for verbose debug logs, attach a reduction tracker.
|
// If the user selected for verbose debug logs, attach a reduction tracker.
|
||||||
if options.Verbose {
|
if options.Verbose {
|
||||||
process.On("step", func() {
|
plugins.NewLogs(logger, process)
|
||||||
logger.Info("reduction", "tree", lambda.Stringify(compiled))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
process.Run()
|
process.Run()
|
||||||
|
|||||||
@@ -12,21 +12,25 @@ import (
|
|||||||
type Engine struct {
|
type Engine struct {
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Expression *lambda.Expression
|
Expression *lambda.Expression
|
||||||
emitter.Emitter
|
emitter.BaseEmitter[Event]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new engine, given an unreduced λ-expression.
|
// Create a new engine, given an unreduced λ-expression.
|
||||||
func New(config *config.Config, expression *lambda.Expression) *Engine {
|
func New(config *config.Config, expression *lambda.Expression) *Engine {
|
||||||
return &Engine{Config: config, Expression: expression}
|
return &Engine{
|
||||||
|
Config: config,
|
||||||
|
Expression: expression,
|
||||||
|
BaseEmitter: *emitter.New[Event](),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Begin the reduction process.
|
// Begin the reduction process.
|
||||||
func (e Engine) Run() {
|
func (e Engine) Run() {
|
||||||
e.Emit("start")
|
e.Emit(StartEvent)
|
||||||
|
|
||||||
lambda.ReduceAll(e.Expression, func() {
|
lambda.ReduceAll(e.Expression, func() {
|
||||||
e.Emit("step")
|
e.Emit(StepEvent)
|
||||||
})
|
})
|
||||||
|
|
||||||
e.Emit("end")
|
e.Emit(StopEvent)
|
||||||
}
|
}
|
||||||
|
|||||||
9
internal/engine/events.go
Normal file
9
internal/engine/events.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
type Event int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StartEvent Event = iota
|
||||||
|
StepEvent
|
||||||
|
StopEvent
|
||||||
|
)
|
||||||
25
internal/plugins/debug.go
Normal file
25
internal/plugins/debug.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"git.maximhutz.com/max/lambda/internal/engine"
|
||||||
|
"git.maximhutz.com/max/lambda/pkg/lambda"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Logs struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
process *engine.Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogs(logger *slog.Logger, process *engine.Engine) *Logs {
|
||||||
|
plugin := &Logs{logger, process}
|
||||||
|
process.On(engine.StopEvent, plugin.Step)
|
||||||
|
|
||||||
|
return plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Logs) Step() {
|
||||||
|
stringified := lambda.Stringify(*t.process.Expression)
|
||||||
|
t.logger.Info("reduction", "tree", stringified)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package "explanation" provides a observer to gather the reasoning during the
|
// Package "explanation" provides a observer to gather the reasoning during the
|
||||||
// reduction, and present a thorough explanation to the user for each step.
|
// reduction, and present a thorough explanation to the user for each step.
|
||||||
package explanation
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,23 +10,23 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Track the reductions made by a reduction proess.
|
// Track the reductions made by a reduction proess.
|
||||||
type Tracker struct {
|
type Explanation struct {
|
||||||
process *engine.Engine
|
process *engine.Engine
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attaches a new explanation tracker to a process.
|
// Attaches a new explanation tracker to a process.
|
||||||
func Track(process *engine.Engine) *Tracker {
|
func NewExplanation(process *engine.Engine) *Explanation {
|
||||||
tracker := &Tracker{process: process}
|
plugin := &Explanation{process: process}
|
||||||
process.On("start", tracker.Start)
|
process.On(engine.StartEvent, plugin.Start)
|
||||||
process.On("step", tracker.Step)
|
process.On(engine.StepEvent, plugin.Step)
|
||||||
|
|
||||||
return tracker
|
return plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Tracker) Start() {
|
func (t *Explanation) Start() {
|
||||||
fmt.Println(lambda.Stringify(*t.process.Expression))
|
fmt.Println(lambda.Stringify(*t.process.Expression))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Tracker) Step() {
|
func (t *Explanation) Step() {
|
||||||
fmt.Println(" =", lambda.Stringify(*t.process.Expression))
|
fmt.Println(" =", lambda.Stringify(*t.process.Expression))
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,34 @@
|
|||||||
// Package "performance" provides a tracker to observer CPU performance during
|
// Package "performance" provides a tracker to observer CPU performance during
|
||||||
// execution.
|
// execution.
|
||||||
package performance
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime/pprof"
|
"runtime/pprof"
|
||||||
|
|
||||||
|
"git.maximhutz.com/max/lambda/internal/engine"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Observes a reduction process, and publishes a CPU performance profile on
|
// Observes a reduction process, and publishes a CPU performance profile on
|
||||||
// completion.
|
// completion.
|
||||||
type Tracker struct {
|
type Performance struct {
|
||||||
File string
|
File string
|
||||||
filePointer *os.File
|
filePointer *os.File
|
||||||
Error error
|
Error error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a performance tracker that outputs a profile to "file".
|
// Create a performance tracker that outputs a profile to "file".
|
||||||
func Track(file string) *Tracker {
|
func NewPerformance(file string, process *engine.Engine) *Performance {
|
||||||
return &Tracker{File: file}
|
plugin := &Performance{File: file}
|
||||||
|
process.On(engine.StartEvent, plugin.Start)
|
||||||
|
process.On(engine.StopEvent, plugin.Stop)
|
||||||
|
|
||||||
|
return plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
// Begin profiling.
|
// Begin profiling.
|
||||||
func (t *Tracker) Start() {
|
func (t *Performance) Start() {
|
||||||
var absPath string
|
var absPath string
|
||||||
|
|
||||||
absPath, t.Error = filepath.Abs(t.File)
|
absPath, t.Error = filepath.Abs(t.File)
|
||||||
@@ -47,7 +53,7 @@ func (t *Tracker) Start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stop profiling.
|
// Stop profiling.
|
||||||
func (t *Tracker) End() {
|
func (t *Performance) Stop() {
|
||||||
pprof.StopCPUProfile()
|
pprof.StopCPUProfile()
|
||||||
t.filePointer.Close()
|
t.filePointer.Close()
|
||||||
}
|
}
|
||||||
44
internal/plugins/statistics.go
Normal file
44
internal/plugins/statistics.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.maximhutz.com/max/lambda/internal/engine"
|
||||||
|
"git.maximhutz.com/max/lambda/internal/statistics"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An observer, to track reduction performance.
|
||||||
|
type Statistics struct {
|
||||||
|
start time.Time
|
||||||
|
steps uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new reduction performance Statistics.
|
||||||
|
func NewStatistics(process *engine.Engine) *Statistics {
|
||||||
|
plugin := &Statistics{}
|
||||||
|
process.On(engine.StartEvent, plugin.Start)
|
||||||
|
process.On(engine.StepEvent, plugin.Step)
|
||||||
|
process.On(engine.StopEvent, plugin.Step)
|
||||||
|
|
||||||
|
return plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Statistics) Start() {
|
||||||
|
t.start = time.Now()
|
||||||
|
t.steps = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Statistics) Step() {
|
||||||
|
t.steps++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Statistics) Stop() {
|
||||||
|
results := statistics.Results{
|
||||||
|
StepsTaken: t.steps,
|
||||||
|
TimeElapsed: uint64(time.Since(t.start).Milliseconds()),
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(os.Stderr, results.String())
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package statistics
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// An observer, to track reduction performance.
|
|
||||||
type Tracker struct {
|
|
||||||
start time.Time
|
|
||||||
steps uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new reduction performance tracker.
|
|
||||||
func Track() *Tracker {
|
|
||||||
return &Tracker{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Tracker) Start() {
|
|
||||||
t.start = time.Now()
|
|
||||||
t.steps = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Tracker) Step() {
|
|
||||||
t.steps++
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Tracker) End() {
|
|
||||||
results := Results{
|
|
||||||
StepsTaken: t.steps,
|
|
||||||
TimeElapsed: uint64(time.Since(t.start).Milliseconds()),
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprint(os.Stderr, results.String())
|
|
||||||
}
|
|
||||||
@@ -2,53 +2,38 @@ package emitter
|
|||||||
|
|
||||||
import "git.maximhutz.com/max/lambda/pkg/set"
|
import "git.maximhutz.com/max/lambda/pkg/set"
|
||||||
|
|
||||||
type Observer struct {
|
type Emitter[E comparable] interface {
|
||||||
fn func()
|
On(string, func()) Listener[E]
|
||||||
message string
|
Off(Listener[E])
|
||||||
emitter *Emitter
|
Emit(E)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Emitter struct {
|
type BaseEmitter[E comparable] struct {
|
||||||
listeners map[string]*set.Set[*Observer]
|
listeners map[E]*set.Set[Listener[E]]
|
||||||
}
|
}
|
||||||
|
|
||||||
func Ignore[T any](fn func()) func(T) {
|
func (e *BaseEmitter[E]) On(kind E, fn func()) Listener[E] {
|
||||||
return func(T) { fn() }
|
if e.listeners[kind] == nil {
|
||||||
|
e.listeners[kind] = set.New[Listener[E]]()
|
||||||
|
}
|
||||||
|
|
||||||
|
listener := &BaseListener[E]{kind, fn}
|
||||||
|
e.listeners[kind].Add(listener)
|
||||||
|
return listener
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Emitter) On(message string, fn func()) *Observer {
|
func (e *BaseEmitter[E]) Emit(event E) {
|
||||||
observer := &Observer{
|
if e.listeners[event] == nil {
|
||||||
fn: fn,
|
e.listeners[event] = set.New[Listener[E]]()
|
||||||
message: message,
|
|
||||||
emitter: e,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if e.listeners == nil {
|
for listener := range e.listeners[event].Items() {
|
||||||
e.listeners = map[string]*set.Set[*Observer]{}
|
listener.Run()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if e.listeners[message] == nil {
|
|
||||||
e.listeners[message] = set.New[*Observer]()
|
func New[E comparable]() *BaseEmitter[E] {
|
||||||
}
|
return &BaseEmitter[E]{
|
||||||
|
listeners: map[E]*set.Set[Listener[E]]{},
|
||||||
e.listeners[message].Add(observer)
|
|
||||||
return observer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *Observer) Off() {
|
|
||||||
if o.emitter.listeners[o.message] == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
o.emitter.listeners[o.message].Remove(o)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Emitter) Emit(message string) {
|
|
||||||
if e.listeners[message] == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for listener := range *e.listeners[message] {
|
|
||||||
listener.fn()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
pkg/emitter/listener.go
Normal file
19
pkg/emitter/listener.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
type Listener[E comparable] interface {
|
||||||
|
Kind() E
|
||||||
|
Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseListener[E comparable] struct {
|
||||||
|
kind E
|
||||||
|
fn func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l BaseListener[E]) Kind() E {
|
||||||
|
return l.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l BaseListener[E]) Run() {
|
||||||
|
l.fn()
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package set
|
package set
|
||||||
|
|
||||||
|
import "iter"
|
||||||
|
|
||||||
type Set[T comparable] map[T]bool
|
type Set[T comparable] map[T]bool
|
||||||
|
|
||||||
func (s *Set[T]) Add(items ...T) {
|
func (s *Set[T]) Add(items ...T) {
|
||||||
@@ -34,6 +36,16 @@ func (s Set[T]) ToList() []T {
|
|||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s Set[T]) Items() iter.Seq[T] {
|
||||||
|
return func(yield func(T) bool) {
|
||||||
|
for item := range s {
|
||||||
|
if !yield(item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func New[T comparable](items ...T) *Set[T] {
|
func New[T comparable](items ...T) *Set[T] {
|
||||||
result := &Set[T]{}
|
result := &Set[T]{}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user