minigo

package
v0.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 23, 2025 License: MIT Imports: 12 Imported by: 2

README

minigo

minigo is a simple, embeddable script engine for Go applications, designed primarily to serve as a powerful and type-safe configuration language. It interprets a subset of the Go language, allowing developers to write dynamic configurations with familiar syntax.

Core Concept

The primary goal of minigo is to replace static configuration files like YAML or JSON with dynamic Go-like scripts. Its key feature is the ability to execute a script and unmarshal the result directly into a Go struct in a type-safe manner. This provides the flexibility of a real programming language for your configurations, without sacrificing integration with your Go application's static types.

minigo is powered by go-scan, which allows it to understand Go source code without relying on the heavier go/types or go/packages libraries. It uses an AST-walking interpreter to execute scripts.

Key Features

  • Familiar Syntax: Write configurations using a subset of Go's syntax, including variables, functions, if statements, and for loops.
  • Type-Safe Unmarshaling: Directly populate your Go structs from script results.
  • Go Interoperability: Inject Go variables and functions from your host application into the script's environment.
  • Lazy Imports: To ensure fast startup and efficient execution, package imports are only read and parsed when a symbol from that package is accessed for the first time.
  • Special Forms (Macros): Register Go functions that receive the raw AST of their arguments, enabling the creation of custom DSLs and control structures without evaluating the arguments beforehand.
  • Generics: Supports generic structs, functions, and type aliases.
  • Clear Error Reporting: Provides formatted stack traces on runtime errors, making it easier to debug configuration scripts.

Standard Library Support

minigo can interact with Go's standard library using two primary methods, each with significant trade-offs. For most packages, FFI bindings are the most practical approach.

1. FFI Bindings (Primary Method)

The most reliable way to use standard library features is via the minigo gen-bindings command. This tool generates Go files that create a Foreign Function Interface (FFI) bridge between minigo and pre-compiled Go packages.

  • How it Works: The tool scans a compiled package and generates install.go code that registers the package's functions with the minigo interpreter.
2. Direct Source Interpretation (Experimental)

minigo has an experimental feature to directly load, parse, and interpret the Go source code of standard library packages at runtime. While powerful, this method is currently only suitable for simple, self-contained packages.

  • How it Works: The interpreter finds the stdlib source in your GOROOT, parses it into an AST, and makes the package available for import within your script.

Usage

Here is a conceptual example of how to use minigo to load an application configuration by calling a specific function within a script.

1. Define Your Go Types and Functions

First, define the Go struct you want to populate and any Go functions or variables you want to expose to the script.

// config.go
package main

// AppConfig is the Go struct we want to populate from the script.
type AppConfig struct {
    ListenAddr   string
    TimeoutSec   int
    FeatureFlags []string
}

// GetDefaultPort is a Go function we want to make available in the script.
func GetDefaultPort() string {
    return ":8080"
}
2. Write the Configuration Script

The script itself is written in a file (e.g., config.mgo) and uses Go-like syntax. It defines an entry point function, like GetConfig, that returns the configuration.

// config.mgo
package main

import "strings"

// GetConfig is the entry point function that returns the config.
// It can call Go functions (GetDefaultPort) and access Go variables (env)
// that are registered with the interpreter.
func GetConfig() {
    // The struct returned here will be matched with the Go AppConfig struct
    // by reflection during the `result.As()` call.
    return struct {
        ListenAddr   string
        TimeoutSec   int
        FeatureFlags []string
    }{
        ListenAddr:   GetDefaultPort(),
        TimeoutSec:   30,
        FeatureFlags: strings.Split(env.FEATURES, ","),
    }
}
3. Run the Interpreter

The main Go application creates an Interpreter, registers the Go functions and variables, loads and evaluates the script files, and then calls the desired entry point function. This entry point can be any function defined in the script, not just main. This allows for flexible script designs, such as having different configuration functions for different environments (e.g., GetDevConfig, GetProdConfig).

// main.go
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/podhmo/go-scan/minigo"
)

func main() {
    // Create a new interpreter.
    interp, err := minigo.NewInterpreter()
    if err != nil {
        log.Fatalf("Failed to create interpreter: %v", err)
    }

    // Register Go functions and variables to be accessible from the script.
    // Here, we expose `GetDefaultPort` and a map `env`.
    interp.Register("main", map[string]any{
        "GetDefaultPort": GetDefaultPort,
        "env": map[string]string{
            "FEATURES": "new_ui,enable_metrics",
        },
    })
    // We also register the `strings` package functions.
    interp.Register("strings", map[string]any{
        "Split": strings.Split,
    })

    // Load the script file into the interpreter's memory.
    // For multi-file scripts, call LoadFile for each file.
    script, err := os.ReadFile("config.mgo")
    if err != nil {
        log.Fatalf("Failed to read script: %v", err)
    }
    if err := interp.LoadFile("config.mgo", script); err != nil {
        log.Fatalf("Failed to load script: %v", err)
    }

    // First, evaluate all loaded files to process top-level declarations
    // (like function definitions).
    if _, err := interp.Eval(context.Background()); err != nil {
        log.Fatalf("Failed to eval script: %v", err)
    }

    // Now, call the specific entry point function.
    result, err := interp.Call(context.Background(), "GetConfig")
    if err != nil {
        log.Fatalf("Failed to call GetConfig: %v", err)
    }

    // Extract the result into our Go struct.
    var cfg AppConfig
    if err := result.As(&cfg); err != nil {
        log.Fatalf("Failed to unmarshal result: %v", err)
    }

    fmt.Printf("Configuration loaded: %+v\n", cfg)
    // Expected Output: Configuration loaded: {ListenAddr::8080 TimeoutSec:30 FeatureFlags:[new_ui enable_metrics]}
}

Advanced: Special Forms

A "special form" is a function that receives the abstract syntax tree (AST) of its arguments directly, instead of their evaluated results. This is a powerful, low-level feature that allows you to create custom Domain-Specific Languages (DSLs) or new control flow structures within the minigo language.

You can register a special form using interp.RegisterSpecial().

Example: An Assertion Special Form

Imagine you want a function assert(expression) that only evaluates the expression if assertions are enabled. A regular function would always evaluate the expression before it is called. A special form can inspect the expression's AST and decide whether to evaluate it.

1. Define the Special Form in Go

The special form function receives the raw []ast.Expr slice for its arguments.

// main.go
import (
    "go/ast"
    "go/token"
    "github.com/podhmo/go-scan/minigo/object"
)

var assertionsEnabled = true // Your application's toggle

// ... in your main function ...

// Register a special form named 'assert'.
interp.RegisterSpecial("assert", func(ctx *object.BuiltinContext, pos token.Pos, args []ast.Expr) object.Object {
    if !assertionsEnabled {
        return object.NIL // Do nothing if assertions are off.
    }
    if len(args) != 1 {
        return ctx.NewError(pos, "assert() requires exactly one argument")
    }

    // Since we have the AST, we can now choose to evaluate it.
    // This requires access to the interpreter's internal eval function.
    // (Note: Exposing the evaluator for this is an advanced use case.)
    // For simplicity, this example just returns a boolean based on the AST type.
    if _, ok := args[0].(*ast.BinaryExpr); ok {
        // In a real implementation, you would evaluate this expression.
        // For this example, we'll just confirm we received the AST.
        fmt.Println("Assertion expression is a binary expression!")
    }

    return object.NIL
})
2. Use it in a Script

The script can now call assert with any expression. The expression 1 + 1 == 2 is not evaluated by minigo before being passed to the Go implementation of assert.

// my_script.mgo
package main

func main() {
    assert(1 + 1 == 2)
}

Limitations

While minigo is a powerful tool for configuration and scripting, it is not a full-featured Go environment and has some important limitations:

  • No Concurrency: minigo does not support goroutines or channels. The interpreter is single-threaded. For a detailed analysis, see docs/analysis-minigo-goroutine.md.
  • Simplified Type System: To simplify the interpreter, all integer types (int, uint, byte, uint64, etc.) are treated as a single int64 type internally. Similarly, all floating-point types are treated as float64. This is sufficient for many scripting and configuration use cases but may not be suitable for tasks that require precise integer sizing or overflow behavior.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Interpreter

type Interpreter struct {
	Registry *object.SymbolRegistry
	// contains filtered or unexported fields
}

Interpreter is the main entry point for the minigo language. It holds the state of the interpreter, including the scanner for package resolution and the root environment for script execution.

func New

func New(scanner *goscan.Scanner, r io.Reader, stdout, stderr io.Writer) *Interpreter

New creates a new interpreter instance with default I/O streams. It panics if initialization fails.

func NewInterpreter

func NewInterpreter(scanner *goscan.Scanner, options ...Option) (*Interpreter, error)

NewInterpreter creates a new interpreter instance, configured with options.

func (*Interpreter) Eval

func (i *Interpreter) Eval(ctx context.Context) (*Result, error)

Eval executes the loaded files. It first processes all declarations and then runs the main function if it exists.

func (*Interpreter) EvalDeclarations

func (i *Interpreter) EvalDeclarations(ctx context.Context) error

EvalDeclarations evaluates all top-level declarations in the loaded files.

func (*Interpreter) EvalFileInREPL

func (i *Interpreter) EvalFileInREPL(ctx context.Context, filename string) error

EvalFileInREPL parses and evaluates a file's declarations within the persistent REPL scope. This allows loaded files to affect the REPL's state, including imports.

func (*Interpreter) EvalLine

func (i *Interpreter) EvalLine(ctx context.Context, line string) (object.Object, error)

EvalLine evaluates a single line of input for the REPL. It maintains state across calls by using a persistent, single FileScope.

func (*Interpreter) EvalString

func (i *Interpreter) EvalString(source string) (object.Object, error)

EvalString evaluates the given source code string as a complete file. It parses the source, evaluates all declarations, and then executes the main function if it exists.

func (*Interpreter) Execute

func (i *Interpreter) Execute(ctx context.Context, fn *object.Function, args []object.Object, fscope *object.FileScope) (*Result, error)

Execute runs a given function with the provided arguments using the interpreter's persistent evaluator.

func (*Interpreter) Files

func (i *Interpreter) Files() []*object.FileScope

Files returns the file scopes that have been loaded into the interpreter.

func (*Interpreter) FindFunction

func (i *Interpreter) FindFunction(name string) (*object.Function, *object.FileScope, error)

FindFunction finds a function in the global scope and the file scope it was defined in.

func (*Interpreter) GlobalEnvForTest

func (i *Interpreter) GlobalEnvForTest() *object.Environment

GlobalEnvForTest returns the interpreter's global environment. This method is intended for use in tests only.

func (*Interpreter) LoadFile

func (i *Interpreter) LoadFile(filename string, source []byte) error

LoadFile parses a file and adds it to the interpreter's state without evaluating it yet. This is the first stage of a multi-file evaluation.

func (*Interpreter) LoadGoSourceAsPackage

func (i *Interpreter) LoadGoSourceAsPackage(pkgName, source string) error

LoadGoSourceAsPackage parses and evaluates a single Go source file as a self-contained package.

func (*Interpreter) Register

func (i *Interpreter) Register(pkgPath string, symbols map[string]any)

Register makes Go symbols (variables or functions) available for import by a script. For example, `interp.Register("strings", map[string]any{"ToUpper": strings.ToUpper})` allows a script to `import "strings"` and call `strings.ToUpper()`.

func (*Interpreter) RegisterSpecial

func (i *Interpreter) RegisterSpecial(name string, fn evaluator.SpecialFormFunction)

RegisterSpecial registers a "special form" function. A special form receives the AST of its arguments directly, without them being evaluated first. This is useful for implementing DSLs or control structures. These functions are available in the global scope.

func (*Interpreter) Scanner

func (i *Interpreter) Scanner() *goscan.Scanner

Scanner returns the underlying goscan.Scanner instance.

type Option

type Option func(*Interpreter)

Option is a functional option for configuring the Interpreter.

func WithGlobals

func WithGlobals(globals map[string]any) Option

WithGlobals allows injecting Go variables into the script's global scope. The map key is the variable name in the script. The value can be any Go variable, which will be made available via reflection.

func WithStderr

func WithStderr(w io.Writer) Option

WithStderr sets the standard error for the interpreter.

func WithStdin

func WithStdin(r io.Reader) Option

WithStdin sets the standard input for the interpreter.

func WithStdout

func WithStdout(w io.Writer) Option

WithStdout sets the standard output for the interpreter.

type Result

type Result struct {
	Value object.Object
}

Result holds the outcome of a script execution.

func (*Result) As

func (r *Result) As(target any) error

As unmarshals the result of the script execution into a Go variable. The target must be a pointer to a Go variable. It uses reflection to populate the fields of the target, similar to how `json.Unmarshal` works.

Directories

Path Synopsis
Package ffibridge provides helper types for the Foreign Function Interface (FFI) between the MiniGo interpreter and native Go code.
Package ffibridge provides helper types for the Foreign Function Interface (FFI) between the MiniGo interpreter and native Go code.
stdlib
fmt
io
os

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL