refactor: rewrite CLI and internal architecture (#41)
## Description The old architecture used a monolithic `main()` with a custom arg parser, an event-emitter-based runtime, and a plugin system for optional features. This PR rewrites the CLI and internal architecture to be modular, extensible, and built around a registry of interchangeable components. - Replace custom CLI arg parsing with Cobra subcommands (`convert`, `reduce`, `engine list`). - Introduce a registry system (`internal/registry`) for marshalers, codecs, and engines, with BFS-based conversion path resolution. - Add type-erased adapter layer (`internal/cli`) with `Repr`, `Engine`, `Process`, `Marshaler`, and `Conversion` interfaces wrapping generic `pkg/` types. - Replace the event-emitter-based `Runtime` with a simpler `Engine`/`Process` model (`pkg/engine`). - Add generic `Codec[T, U]` and `Marshaler[T]` interfaces (`pkg/codec`). - Merge `saccharine/token` sub-package into `saccharine` and rename scanner functions from `parse*` to `scan*`. - Make saccharine-to-lambda conversion bidirectional (encode and decode). - Add `lambda.Marshaler` and `saccharine.Marshaler` implementing `codec.Marshaler`. - Remove old infrastructure: `pkg/runtime`, `pkg/expr`, `internal/plugins`, `internal/statistics`. - Add `make lint` target and update golangci-lint config. ### Decisions - Cobra was chosen for the CLI framework to support nested subcommands and standard flag handling. - The registry uses BFS to find conversion paths between representations, allowing multi-hop conversions without hardcoding routes. - Type erasure via `cli.Repr` (wrapping `any`) enables the registry to work with heterogeneous types while keeping `pkg/` generics type-safe. - The old plugin/event system was removed entirely rather than adapted, since the new `Process` model can support hooks differently in the future. ## Benefits - Subcommands make the CLI self-documenting and easier to extend with new functionality. - The registry pattern decouples representations, conversions, and engines, making it trivial to add new ones. - BFS conversion routing means adding a single codec automatically enables transitive conversions. - Simpler `Engine`/`Process` model reduces complexity compared to the event-emitter runtime. - Consolidating the `token` sub-package reduces import depth and package sprawl. ## Checklist - [x] Code follows conventional commit format. - [x] Branch follows naming convention (`<type>/<description>`). Always use underscores. - [ ] Tests pass (if applicable). - [ ] Documentation updated (if applicable). Reviewed-on: #41 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 #41.
This commit is contained in:
@@ -3,65 +3,29 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"git.maximhutz.com/max/lambda/internal/cli"
|
||||
"git.maximhutz.com/max/lambda/internal/config"
|
||||
"git.maximhutz.com/max/lambda/internal/plugins"
|
||||
"git.maximhutz.com/max/lambda/pkg/convert"
|
||||
"git.maximhutz.com/max/lambda/pkg/normalorder"
|
||||
"git.maximhutz.com/max/lambda/pkg/saccharine"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Parse CLI arguments.
|
||||
options, err := config.FromArgs()
|
||||
cli.HandleError(err)
|
||||
|
||||
logger := options.GetLogger()
|
||||
logger.Info("using program arguments", "args", os.Args)
|
||||
logger.Info("parsed CLI options", "options", options)
|
||||
|
||||
// Get input.
|
||||
input, err := options.Source.Extract()
|
||||
cli.HandleError(err)
|
||||
|
||||
// Parse code into syntax tree.
|
||||
ast, err := saccharine.Parse(input)
|
||||
cli.HandleError(err)
|
||||
logger.Info("parsed syntax tree", "tree", ast)
|
||||
|
||||
// Compile expression to lambda calculus.
|
||||
compiled := convert.SaccharineToLambda(ast)
|
||||
logger.Info("compiled λ expression", "tree", compiled.String())
|
||||
|
||||
// Create reducer with the compiled expression.
|
||||
runtime := normalorder.NewRuntime(compiled)
|
||||
|
||||
// If the user selected to track CPU performance, attach a profiler.
|
||||
if options.Profile != "" {
|
||||
plugins.NewPerformance(options.Profile, runtime)
|
||||
func Lambda() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "lambda",
|
||||
Short: "Lambda calculus interpreter",
|
||||
Long: "A lambda calculus interpreter supporting multiple representations.",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
// If the user selected to produce a step-by-step explanation, attach an
|
||||
// observer.
|
||||
if options.Explanation {
|
||||
plugins.NewExplanation(runtime)
|
||||
}
|
||||
cmd.AddCommand(LambdaConvert())
|
||||
cmd.AddCommand(LambdaEngine())
|
||||
cmd.AddCommand(LambdaReduce())
|
||||
|
||||
// If the user opted to track statistics, attach a tracker.
|
||||
if options.Statistics {
|
||||
plugins.NewStatistics(runtime)
|
||||
}
|
||||
|
||||
// If the user selected for verbose debug logs, attach a reduction tracker.
|
||||
if options.Verbose {
|
||||
plugins.NewLogs(logger, runtime)
|
||||
}
|
||||
|
||||
// Run reduction.
|
||||
runtime.Run()
|
||||
|
||||
// Return the final reduced result.
|
||||
result := runtime.Expression().String()
|
||||
err = options.Destination.Write(result)
|
||||
cli.HandleError(err)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func main() {
|
||||
lambda := Lambda()
|
||||
if err := lambda.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
94
cmd/lambda/lambda_convert.go
Normal file
94
cmd/lambda/lambda_convert.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// inferReprFromPath returns the repr type based on file extension.
|
||||
func inferReprFromPath(path string) (string, error) {
|
||||
switch ext := strings.ToLower(filepath.Ext(path)); ext {
|
||||
case ".lambda", ".lam", ".lc":
|
||||
return "lambda", nil
|
||||
case ".saccharine", ".sch":
|
||||
return "saccharine", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown file extension '%s'", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func LambdaConvert() *cobra.Command {
|
||||
var inputReprFlag, outputReprFlag string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "convert <input-file> <output-file>",
|
||||
Short: "Convert between lambda calculus representations",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return cmd.Help()
|
||||
}
|
||||
|
||||
var err error
|
||||
inputPath, outputPath := args[0], args[1]
|
||||
|
||||
// Use flag if provided, otherwise infer from extension.
|
||||
inputRepr := inputReprFlag
|
||||
if inputRepr == "" {
|
||||
if inputRepr, err = inferReprFromPath(inputPath); err != nil {
|
||||
return fmt.Errorf("input file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
outputRepr := outputReprFlag
|
||||
if outputRepr == "" {
|
||||
if outputRepr, err = inferReprFromPath(outputPath); err != nil {
|
||||
return fmt.Errorf("output file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Read input file.
|
||||
input, err := os.ReadFile(inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading input file: %w", err)
|
||||
}
|
||||
|
||||
r := GetRegistry()
|
||||
|
||||
// Parse input into syntax tree.
|
||||
repr, err := r.Unmarshal(string(input), inputRepr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing input: %w", err)
|
||||
}
|
||||
|
||||
// Convert to output repr if different.
|
||||
result, err := r.ConvertTo(repr, outputRepr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("converting %s to %s: %w", inputRepr, outputRepr, err)
|
||||
}
|
||||
|
||||
// Marshal output.
|
||||
output, err := r.Marshal(result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshaling output: %w", err)
|
||||
}
|
||||
|
||||
// Write output file.
|
||||
err = os.WriteFile(outputPath, []byte(output), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing output file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&inputReprFlag, "from", "", "Input representation (inferred from extension if unset)")
|
||||
cmd.Flags().StringVar(&outputReprFlag, "to", "", "Output representation (inferred from extension if unset)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
19
cmd/lambda/lambda_engine.go
Normal file
19
cmd/lambda/lambda_engine.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func LambdaEngine() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "engine",
|
||||
Short: "Information about available engines",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(LambdaEngineList())
|
||||
|
||||
return cmd
|
||||
}
|
||||
26
cmd/lambda/lambda_engine_list.go
Normal file
26
cmd/lambda/lambda_engine_list.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func LambdaEngineList() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List available engines",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
r := GetRegistry()
|
||||
|
||||
for engine := range r.ListEngines() {
|
||||
fmt.Println(engine.Name())
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
109
cmd/lambda/lambda_reduce.go
Normal file
109
cmd/lambda/lambda_reduce.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.maximhutz.com/max/lambda/internal/cli"
|
||||
"git.maximhutz.com/max/lambda/internal/config"
|
||||
)
|
||||
|
||||
func LambdaReduce() *cobra.Command {
|
||||
var inputReprFlag string
|
||||
var engineFlag string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "reduce <input-file>",
|
||||
Short: "Reduce a lambda calculus expression",
|
||||
SilenceUsage: true,
|
||||
Aliases: []string{"run"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var err error
|
||||
if len(args) != 1 {
|
||||
return cmd.Help()
|
||||
}
|
||||
|
||||
inputPath := args[0]
|
||||
|
||||
// Get input source.
|
||||
var source config.Source
|
||||
if inputPath == "-" {
|
||||
source = config.StdinSource{}
|
||||
} else {
|
||||
source = config.FileSource{Path: inputPath}
|
||||
}
|
||||
|
||||
destination := config.StdoutDestination{}
|
||||
|
||||
r := GetRegistry()
|
||||
|
||||
// Get input.
|
||||
input, err := source.Extract()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use flag if provided, otherwise infer from extension.
|
||||
inputRepr := inputReprFlag
|
||||
if inputRepr == "" {
|
||||
if inputRepr, err = inferReprFromPath(inputPath); err != nil {
|
||||
return fmt.Errorf("input file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Find engine.
|
||||
var engine cli.Engine
|
||||
if engineFlag == "" {
|
||||
if engine, err = r.GetDefaultEngine(inputRepr); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if engine, err = r.GetEngine(engineFlag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Parse code into syntax tree.
|
||||
repr, err := r.Unmarshal(input, inputRepr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Compile expression to lambda calculus.
|
||||
compiled, err := r.ConvertTo(repr, "lambda")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create process.
|
||||
process := engine.Load()
|
||||
err = process.Set(compiled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Run reduction.
|
||||
for process.Step(1) {
|
||||
}
|
||||
|
||||
// Return the final reduced result.
|
||||
result, err := process.Get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
output, err := r.Marshal(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return destination.Write(output)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&inputReprFlag, "from", "", "Input representation (inferred from extension if unset)")
|
||||
cmd.Flags().StringVarP(&engineFlag, "engine", "e", "", "Reduction engine (inferred from '--input' if unset)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.maximhutz.com/max/lambda/pkg/convert"
|
||||
"git.maximhutz.com/max/lambda/pkg/normalorder"
|
||||
"git.maximhutz.com/max/lambda/pkg/saccharine"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Helper function to run a single sample through the lambda runtime.
|
||||
func runSample(samplePath string) (string, error) {
|
||||
// Read the sample file.
|
||||
input, err := os.ReadFile(samplePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Parse code into syntax tree.
|
||||
ast, err := saccharine.Parse(string(input))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Compile expression to lambda calculus.
|
||||
compiled := convert.SaccharineToLambda(ast)
|
||||
|
||||
// Create and run the reducer.
|
||||
reducer := normalorder.NewRuntime(compiled)
|
||||
reducer.Run()
|
||||
|
||||
return reducer.Expression().String() + "\n", nil
|
||||
}
|
||||
|
||||
// Test that all samples produce expected output.
|
||||
func TestSamplesValidity(t *testing.T) {
|
||||
// Discover all .test files in the tests directory.
|
||||
testFiles, err := filepath.Glob("../../tests/*.test")
|
||||
assert.NoError(t, err, "Failed to read tests directory.")
|
||||
assert.NotEmpty(t, testFiles, "No '*.test' files found in directory.")
|
||||
|
||||
for _, testPath := range testFiles {
|
||||
// Build expected file path.
|
||||
expectedPath := strings.TrimSuffix(testPath, filepath.Ext(testPath)) + ".expected"
|
||||
|
||||
name := strings.TrimSuffix(filepath.Base(testPath), filepath.Ext(testPath))
|
||||
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// Run the sample and capture output.
|
||||
actual, err := runSample(testPath)
|
||||
assert.NoError(t, err, "Failed to run sample.")
|
||||
|
||||
// Read expected output.
|
||||
expectedBytes, err := os.ReadFile(expectedPath)
|
||||
assert.NoError(t, err, "Failed to read expected output.")
|
||||
expected := string(expectedBytes)
|
||||
|
||||
// Compare outputs.
|
||||
assert.Equal(t, expected, actual, "Output does not match expected.")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark all samples using sub-benchmarks.
|
||||
func BenchmarkSamples(b *testing.B) {
|
||||
// Discover all .test files in the tests directory.
|
||||
testFiles, err := filepath.Glob("../../tests/*.test")
|
||||
assert.NoError(b, err, "Failed to read tests directory.")
|
||||
assert.NotEmpty(b, testFiles, "No '*.test' files found in directory.")
|
||||
|
||||
for _, path := range testFiles {
|
||||
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
|
||||
b.Run(name, func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, err := runSample(path)
|
||||
assert.NoError(b, err, "Failed to run sample.")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
26
cmd/lambda/registry.go
Normal file
26
cmd/lambda/registry.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.maximhutz.com/max/lambda/internal/cli"
|
||||
"git.maximhutz.com/max/lambda/internal/registry"
|
||||
"git.maximhutz.com/max/lambda/pkg/convert"
|
||||
"git.maximhutz.com/max/lambda/pkg/engine/normalorder"
|
||||
"git.maximhutz.com/max/lambda/pkg/lambda"
|
||||
"git.maximhutz.com/max/lambda/pkg/saccharine"
|
||||
)
|
||||
|
||||
func GetRegistry() *registry.Registry {
|
||||
r := registry.New()
|
||||
|
||||
// Codecs
|
||||
r.MustAddConversions(cli.ConvertCodec(convert.Saccharine2Lambda{}, "saccharine", "lambda")...)
|
||||
|
||||
// Engines
|
||||
r.MustAddEngine(cli.ConvertEngine(normalorder.Engine{}, "normalorder", "lambda"))
|
||||
|
||||
// Marshalers
|
||||
r.MustAddMarshaler(cli.ConvertMarshaler(lambda.Marshaler{}, "lambda"))
|
||||
r.MustAddMarshaler(cli.ConvertMarshaler(saccharine.Marshaler{}, "saccharine"))
|
||||
|
||||
return r
|
||||
}
|
||||
Reference in New Issue
Block a user