Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,36 +12,37 @@
"program": "${workspaceFolder}"
},
{
"name": "test calls",
"name": "test lisp files",
"type": "go",
"request": "launch",
"mode": "test",
"program": "${workspaceFolder}/lib/call"
"program": "${workspaceFolder}/",
"args": [
"-test.run",
"TestFileTests"
]
},
{
"name": "Lisp REPL",
"name": "test calls",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/lisp"
"mode": "test",
"program": "${workspaceFolder}/lib/call"
},
{
"name": "fibonacci",
"name": "Lisp REPL",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/debugger",
"args":[
"../../examples/fibonacci.lisp"
]
"program": "${workspaceFolder}/cmd/lisp"
},
{
"name": "MAL",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/lisp",
"args":[
"args": [
"./examples/mal.lisp"
]
},
Expand Down
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,48 @@ Requires Go 1.25.

This implementation uses [chzyer/readline](https://github.com/chzyer/readline) instead of C implented readline or libedit, making this implementation pure Go.

## Breaking Changes

### Position Tracking Improvements (2026-01-15)

**Breaking API Changes:**

1. **Constructor signatures changed** for programmatically created data structures:
- `types.NewList(cursor *Position, a ...MalType)` - now requires cursor as first parameter
- `types.NewHashMap(cursor *Position, seq MalType)` - now requires cursor as first parameter
- Pass `nil` for cursor if position tracking is not needed

2. **Error format changed**:
- `LispError.Error()` now includes stack trace with multiple lines
- Format: `error_message\n at position1\n at position2\n...`
- Code that parses error messages should use `strings.Contains()` instead of `strings.HasSuffix()`

3. **LispError struct changed**:
- Added `Stack []*Position` field for call stack tracking
- `NewLispError()` now preserves original cursor instead of overwriting it

**Migration Guide:**

```go
// Before:
list := types.NewList(elem1, elem2, elem3)
hm, _ := types.NewHashMap(listOfKeyValues)

// After:
list := types.NewList(nil, elem1, elem2, elem3) // nil if no position tracking needed
hm, _ := types.NewHashMap(nil, listOfKeyValues)

// Or with position tracking:
list := types.NewList(currentCursor, elem1, elem2, elem3)
hm, _ := types.NewHashMap(currentCursor, listOfKeyValues)
```

**Benefits:**
- Improved error messages with full stack traces
- Better debugging of nested function calls and macro expansions
- Preserved position information through try/catch blocks
- More accurate line numbers in quasiquote and generated code

# Changes

Changes respect to [kanaka/mal](https://github.com/kanaka/mal):
Expand Down Expand Up @@ -48,7 +90,6 @@ go test -benchmem -benchtime 5s -bench '^.+$' github.com/jig/lisp

# Additions

- Debugger: prefix program name with `--debug`. File to debug is the sole argument supported
- Errors return line position and stack trace
- `(range a b)` returns a vector of integers from `a` to `b-1`
- `(merge hm1 hm2)` returns the merge of two hash maps, second takes precedence
Expand Down
2 changes: 1 addition & 1 deletion castfunc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func TestCastFunc(t *testing.T) {
if err == nil {
t.Fatal(err)
}
if !strings.HasSuffix(err.Error(), "attempt to call non-function (was of type int)") {
if !strings.Contains(err.Error(), "attempt to call non-function (was of type int)") {
t.Fatal("test failed")
}
}
2 changes: 1 addition & 1 deletion cmd/lisp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func main() {
}{
{"core mal", nscore.Load},
{"core mal with input", nscore.LoadInput},
{"command line args", nscore.LoadCmdLineArgs},
{"command line args", nscore.LoadCmdLineArgs(command.PreParseArgs(os.Args))},
{"concurrent", nsconcurrent.Load},
{"core mal extended", nscoreextended.Load},
{"assert", nsassert.Load},
Expand Down
188 changes: 108 additions & 80 deletions command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,122 +2,150 @@ package command

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"runtime/debug"
"strings"

"github.com/alexflint/go-arg"
"github.com/jig/lisp"
"github.com/jig/lisp/debugger"
"github.com/jig/lisp/repl"
"github.com/jig/lisp/types"
)

func printHelp() {
fmt.Println(`Lisp
--version, -v provides the version number
--help, -h provides this help message
--test, -t runs the test suite
--debug, -d runs the debugger`)
// args represents command line arguments for the Lisp interpreter
type args struct {
Version bool `arg:"-v,--version" help:"show version information"`
Test string `arg:"-t,--test" help:"run test suite from directory" placeholder:"DIR"`
Debug bool `arg:"--debug" help:"enable DEBUG-EVAL support (may impact performance)"`
Script string `arg:"positional" help:"lisp script to execute"`
Args []string `arg:"positional" help:"arguments to pass to the script"`
}

func (args) Description() string {
return "Lisp interpreter"
}

// PreParseArgs does a preliminary parse of arguments to extract the script arguments
// before libraries are loaded. This is needed because if LoadCmdLineArgs is used it
// needs to know the script arguments before the main Execute runs.
func PreParseArgs(cmdArgs []string) []string {
var parsedArgs args
parser, err := arg.NewParser(arg.Config{Program: "lisp"}, &parsedArgs)
if err != nil {
// Silently ignore parsing errors at this stage
return []string{}
}

// Parse arguments (skip program name)
if len(cmdArgs) > 1 {
err = parser.Parse(cmdArgs[1:])
if err != nil {
// Silently ignore parsing errors at this stage
return []string{}
}
}

// Set scriptArgs from parsed Args (arguments after script name)
return parsedArgs.Args
}

// Execute is the main function of a command line MAL interpreter.
// args are usually the os.Args, and repl_env contains the environment filled
// with the symbols required for the interpreter.
func Execute(args []string, repl_env types.EnvType) error {
switch len(os.Args) {
case 0:
return errors.New("invalid arguments array")
case 1:
// repl loop
ctx := context.Background()
if _, err := lisp.REPL(ctx, repl_env, `(println (str "Lisp Mal [" *host-language* "]"))`, types.NewCursorFile("REPL")); err != nil {
return fmt.Errorf("internal error: %s", err)
func Execute(cmdArgs []string, repl_env types.EnvType) error {
var parsedArgs args
parser, err := arg.NewParser(arg.Config{Program: "lisp"}, &parsedArgs)
if err != nil {
return err
}

// Parse arguments (skip program name)
if len(cmdArgs) > 1 {
err = parser.Parse(cmdArgs[1:])
if err == arg.ErrHelp {
parser.WriteHelp(os.Stdout)
return nil
}
if err := repl.Execute(ctx, repl_env); err != nil {
if err != nil {
return err
}
return nil
default:
switch os.Args[1] {
case "--version", "-v":
versionInfo, ok := debug.ReadBuildInfo()
if !ok {
fmt.Println("Lisp versions error")
return nil
}
fmt.Printf("Lisp versions:\n%s\n", versionInfo)
return nil
case "--help", "-h":
printHelp()
return nil
case "--test", "-t":
if len(os.Args) != 3 {
printHelp()
return fmt.Errorf("too many args")
}
if err := filepath.Walk(os.Args[2], func(path string, info os.FileInfo, err error) error {
if !info.IsDir() {
if strings.HasSuffix(info.Name(), "_test.mal") {
testParams := fmt.Sprintf(`(def *test-params* {:test-file %q :test-absolute-path %q})`, info.Name(), path)

ctx := context.Background()
if _, err := lisp.REPL(ctx, repl_env, testParams, types.NewCursorFile(info.Name())); err != nil {
return err
}
if _, err := lisp.REPL(ctx, repl_env, `(load-file "`+path+`")`, types.NewCursorHere(path, -3, 1)); err != nil {
return err
}
}
}
return nil
}); err != nil {
return err
}
}

// Enable DEBUG-EVAL if flag is set
if parsedArgs.Debug {
lisp.DebugEvalEnabled = true
}

// Handle --version
if parsedArgs.Version {
versionInfo, ok := debug.ReadBuildInfo()
if !ok {
fmt.Println("Lisp version information unavailable")
return nil
case "--debug", "-d":
if len(os.Args) != 3 {
printHelp()
return fmt.Errorf("too many args")
}
}
fmt.Printf("Lisp:\n%s\n", versionInfo)
return nil
}

result, err := DebugFile(os.Args[2], repl_env)
if err != nil {
return err
// Handle --test
if parsedArgs.Test != "" {
return runTests(parsedArgs.Test, repl_env)
}

// Handle file execution or stdin
if parsedArgs.Script != "" {
// Special case: "-" means read from stdin (like Python/Ruby)
if parsedArgs.Script == "-" {
// Execute from stdin (interactive mode becomes REPL)
ctx := context.Background()
if _, err := lisp.REPL(ctx, repl_env, `(println (str "Lisp Mal [" *host-language* "]"))`, types.NewCursorFile("REPL")); err != nil {
return fmt.Errorf("internal error: %s", err)
}
fmt.Println(result)
return nil
return repl.Execute(ctx, repl_env)
}

// called with mal script to load and eval
result, err := ExecuteFile(os.Args[1], repl_env)
// Execute file
result, err := ExecuteFile(parsedArgs.Script, repl_env)
if err != nil {
return err
}
fmt.Println(result)
return nil
}
}

// ExecuteFile executes a file on the given path
func ExecuteFile(fileName string, ns types.EnvType) (types.MalType, error) {
// Default: start REPL
ctx := context.Background()
result, err := lisp.REPL(ctx, ns, `(load-file "`+fileName+`")`, types.NewCursorHere(fileName, -3, 1))
if err != nil {
return nil, err
if _, err := lisp.REPL(ctx, repl_env, `(println (str "Lisp Mal [" *host-language* "]"))`, types.NewCursorFile("REPL")); err != nil {
return fmt.Errorf("internal error: %s", err)
}
return result, nil
return repl.Execute(ctx, repl_env)
}

// ExecuteFile executes a file on the given path
func DebugFile(fileName string, ns types.EnvType) (types.MalType, error) {
deb := debugger.Engine(fileName, ns)
defer deb.Shutdown()
lisp.Stepper = deb.Stepper
// lisp.Trace = deb.Trace
// runTests executes all *_test.mal files in the given directory
func runTests(dir string, repl_env types.EnvType) error {
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), "_test.mal") {
testParams := fmt.Sprintf(`(def *test-params* {:test-file %q :test-absolute-path %q})`, info.Name(), path)

ctx := context.Background()
if _, err := lisp.REPL(ctx, repl_env, testParams, types.NewCursorFile(info.Name())); err != nil {
return err
}
if _, err := lisp.REPL(ctx, repl_env, `(load-file "`+path+`")`, types.NewCursorHere(path, -3, 1)); err != nil {
return err
}
}
return nil
})
}

// ExecuteFile executes a file on the given path
func ExecuteFile(fileName string, ns types.EnvType) (types.MalType, error) {
ctx := context.Background()
result, err := lisp.REPL(ctx, ns, `(load-file "`+fileName+`")`, types.NewCursorHere(fileName, -3, 1))
if err != nil {
Expand Down
Loading