Home. Documentation for Goal. Last update: 2024-11-13.

7 Writing an extension #

This chapter is an introduction to the more advanced topic of writing an extension for Goal in Go.

7.1 Introduction #

Goal is designed to be easily embedded and extended in Go, using a relatively high-level API. The Go API for Goal is provided by a set of packages belonging to a single module, with no dependencies outside Go’s standard library.

As an example, we’ll implement an extension for reading zip files as Goal file system values, based on Go’s standard archive/zip package. Once done, we’ll be able to write the following code in Goal:

import"fs" / assuming GOALLIB points to goal's lib/ dir
/ open the epub file made from Goal docs (epub is zip)
'epubfs:zip.open"goal-docs-gen.epub"
/ read and display the "mimetype" file
say"mimetype: "+ 'epubfs read"mimetype"
/ use fs.ls to get all non-dir file paths in the file system
fpaths:(fs.ls epubfs)..path@&~dir
/ display number of files and list of paths
say"total %d files:\n%s"$(#fpaths),"\n"/fpaths
close epubfs

Executing the script with the extended interpreter will produce:

mimetype: application/epub+zip
total 14 files:
mimetype
EPUB/chap-FAQ.xhtml
EPUB/chap-from-k.xhtml
EPUB/chap-help.xhtml
EPUB/chap-intro.xhtml
EPUB/chap-tutorial.xhtml
EPUB/chap-working-with-tables.xhtml
EPUB/chap-writing-an-extension.xhtml
EPUB/content.opf
EPUB/index.xhtml
EPUB/nav.xhtml
EPUB/stylesheet.css
EPUB/toc.ncx
META-INF/container.xml

7.2 Setting up an interpreter #

We first create a goalzipfs directory somewhere. There, in a main.go file, we write:

package main

import (
	"os"

	"codeberg.org/anaseto/goal"
	"codeberg.org/anaseto/goal/cmd"
	"codeberg.org/anaseto/goal/help"
	gos "codeberg.org/anaseto/goal/os"
)

func main() {
	ctx := goal.NewContext() // new evaluation context for Goal code
	ctx.Log = os.Stderr      // configure logging with \x to stderr
	gos.Import(ctx, "")      // register all IO/OS primitives with prefix ""
	cmd.Exit(cmd.Run(ctx, cmd.Config{Help: help.HelpFunc(), Man: "goal"}))
}

The code above is a commented copy of cmd/goal/main.go as found in Goal’s repository, and produces a goal interpreter equivalent to the default one in just four short lines of code. We’ll use this as a basis for our extension.

As you can see, other than importing the relevant packages, the code creates a new evaluation context with goal.NewContext, then configures and registers any extensions (like the IO/OS primitives), and finally the cmd.Run function runs an interpreter using the created context, handling command-line arguments too, with some optional extra configuration for REPL help (using Goal’s default help here). The cmd.Exit function handles the return value of cmd.Run to format any errors on stderr, and exits the program with the appropriate status code.

Before actually compiling and running our interpreter, we need to provide a go.mod file for it by running:

$ go mod init goalzipfs

The latest released version of Goal will automatically be downloaded and added as a dependency in go.mod with the following command:

$ go get codeberg.org/anaseto/goal@latest

You can then build a goalzipfs executable in the current directory with:

$ go build

Because we haven’t implemented any extensions yet, that goalzipfs executable is still the same as the upstream goal.

7.3 Defining a zip file-system value #

Goal values are represented by the opaque struct type goal.V. That type can represent both unboxed and boxed values. Examples of unboxed values are integers and floats: they fit into a struct’s field, without extra indirection. Boxed values represent most other values, including strings, arrays, projections, and so on. They don’t fit into the struct: they are stored in a field as a Go interface value type, which points to heap-allocated memory.

Goal allows extensions to define new kinds of boxed value types. In order to do so, one has to define a Go type satisfying the goal.BV interface, which is the interface implemented by all boxed Goal values. That interface is described by a set of three methods.

Boxed value types can implement extra methods when more functionality is desired: in our case, we’ll inherit methods from the *zip.ReadCloser type from Go’s standard archive/zip package, which satisfies the fs.FS file system interface, as well as the io.Closer interface.

// zipFS is a wrapper around zip.ReadCloser that implements the goal.BV, fs.FS
// and io.Closer interfaces.
type zipFS struct {
	*zip.ReadCloser
	s string // program string representation
}

// Append appends a program representation of the value to dst.
func (fsys *zipFS) Append(ctx *goal.Context, dst []byte, compact bool) []byte {
	return append(dst, fsys.s...)
}

// Matches reports whether fsys~y.
func (fsys *zipFS) Matches(y goal.BV) bool {
	yv, ok := y.(*zipFS)
	return ok && fsys == yv
}

// Type returns "/" for file system types.
func (fsys *zipFS) Type() string {
	return "/"
}

As you can see, we defined a struct type with two fields: one of them simply embeds the *zip.ReadCloser type, while the extra s field is there simply to provide a program string representation. Without any extra work, the *zipFS type represents a Goal generic value that is supported by many primitives, with specific behavior for $x, @x, and matching. It is also automatically supported by close and all the IO primitives working on file system values, just because it inherits Open and Close methods from the *zip.ReadCloser type. To make the code compile we need to add "archive/zip" to the list of imports, along "os" and the others.

7.4 Defining a variadic function #

Unlike user-defined lambdas, new Goal functions implemented in Go are variadic, like all the built-in verbs. We’ll hence define the function that opens a zip file and produces a *zipFS value as a variadic function. We’ll also need to add the "fmt" package to the list of imports.

Variadic functions take a *goal.Context argument, and a list of Goal values in stack order (last argument is the first one).

// VFZipOpen implements the zip.open variadic function.
func VFZipOpen(ctx *goal.Context, args []goal.V) goal.V {
	if len(args) > 1 {
		return goal.Panicf("zip.open : too many arguments (%d)", len(args))
	}
	s, ok := args[0].BV().(goal.S)
	if !ok {
		return goal.Panicf("zip.open s : bad type %q in s", args[0].Type())
	}
	r, err := zip.OpenReader(string(s))
	if err != nil {
		return gos.NewOSError(err)
	}
	return goal.NewV(&zipFS{r, fmt.Sprintf("zip.open[%q]", s)})
}

The function is a straightforward wrapper around zip.OpenReader that does some additional argument type checking and error processing, and provides a concrete program string representation too (mainly useful in REPL for a file system value). The goal.NewV function is used to produce a generic goal.V value from a Go type satisfying the goal.BV interface of boxed values.

All that’s left is registering the VFZipOpen variadic function into our main context ctx in the main function, before cmd.Run.

ctx.RegisterMonad("zip.open", VFZipOpen)

This registers a new zip.open monadic verb. Often, introducing new syntax is not desirable, so storing the function into a global instead is preferable:

ctx.AssignGlobal("zip.open", ctx.RegisterMonad(".zip.open", VFZipOpen))

In that case, introducing new syntax with RegisterMonad is avoided by using an invalid identifier ".zip.open", which is only used for identifying and display purposes.

Running go build again will produce an extended interpreter that can run the Goal code shown in introduction, so you can open an EPUB file and process it as any other kind of file system value!

7.5 The whole code #

As a summary, we reproduce the whole code below:

package main

import (
	"archive/zip"
	"fmt"
	"os"

	"codeberg.org/anaseto/goal"
	"codeberg.org/anaseto/goal/cmd"
	"codeberg.org/anaseto/goal/help"
	gos "codeberg.org/anaseto/goal/os"
)

func main() {
	ctx := goal.NewContext() // new evaluation context for Goal code
	ctx.Log = os.Stderr      // configure logging with \x to stderr
	gos.Import(ctx, "")      // register all IO/OS primitives with prefix ""
	ctx.AssignGlobal("zip.open", ctx.RegisterMonad(".zip.open", VFZipOpen))
	cmd.Exit(cmd.Run(ctx, cmd.Config{Help: help.HelpFunc(), Man: "goal"}))
}

// zipFS is a wrapper around zip.ReadCloser that implements the goal.BV, fs.FS
// and io.Closer interfaces.
type zipFS struct {
	*zip.ReadCloser
	s string // program string representation
}

// Append appends a program representation of the value to dst.
func (fsys *zipFS) Append(ctx *goal.Context, dst []byte, compact bool) []byte {
	return append(dst, fsys.s...)
}

// Matches reports whether fsys~y.
func (fsys *zipFS) Matches(y goal.BV) bool {
	yv, ok := y.(*zipFS)
	return ok && fsys == yv
}

// Type returns "/" for file system types.
func (fsys *zipFS) Type() string {
	return "/"
}

// VFZipOpen implements the zip.open variadic function.
func VFZipOpen(ctx *goal.Context, args []goal.V) goal.V {
	if len(args) > 1 {
		return goal.Panicf("zip.open : too many arguments (%d)", len(args))
	}
	s, ok := args[0].BV().(goal.S)
	if !ok {
		return goal.Panicf("zip.open s : bad type %q in s", args[0].Type())
	}
	r, err := zip.OpenReader(string(s))
	if err != nil {
		return gos.NewOSError(err)
	}
	return goal.NewV(&zipFS{r, fmt.Sprintf("zip.open[%q]", s)})
}

7.6 Learn more #

At this point, you might want to look at the API docs of the module’s various packages. As for examples of extensions, the os built-in package is actually written as an extension: it can be a good first place to look at. Moreover, there are currently math and io/fs packages in the repos, as well as an archive/zip package that provides the functionality we implemented in this tutorial. We could just have imported it in a similar way as the os one.

If you’re also interested in the internals, the docs/implementation.md file in goal’s repository gives a short introduction.