Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Goblin logo

Goblin is a dynamically typed language with Go-style braces and a compact, Python-like feel. It is useful for experimenting with programs and for learning how interpreters, runtimes, and code generation fit together.

This book assumes that you can use a command line and already know the basic ideas of variables, functions, and control flow. Its aim is not to catalogue every API, but to get you comfortably reading and writing Goblin programs.

Goblin has two ways to run a program:

  • goblin run interprets a source file directly. Use it while developing and experimenting.
  • goblin build-exe transpiles source to Go and compiles a native executable. Use it when you want a standalone program.

Both paths parse the same language and run semantic checks before executing. During development, start with goblin run; use build-exe when you need an executable without the Goblin CLI. The generated executable still uses the Goblin runtime, so the language behavior is intended to be the same.

Here is a complete Goblin program:

func greet(name) {
    print("Hello,", name)
}

for name in ["world", "Goblin"] {
    greet(name)
}

Source files use the .goblin extension. Comments start with #. When you are ready, continue with Installation.

What this book covers

The chapters first cover values and control flow, then functions and collections, followed by custom types, modules, and errors. The examples are complete fragments that can be pasted into a .goblin file. The Book focuses on the language and its standard runtime; see the repository's examples/ directory for larger executable programs.

For the current implementation boundaries and features deliberately absent from the language, see Language model and limits.

Language model and limits

Goblin is a dynamically typed language with a tree-walking interpreter and a Go transpilation backend. Both backends use the same parser, semantic checks, and runtime object model. Use goblin run for iteration and goblin build-exe when a standalone executable is needed.

The language is intentionally small. These boundaries are useful to know before designing a larger program:

AreaCurrent behavior
TypesDynamic; functions and fields have no annotations
FunctionsRequired and defaulted parameters plus *args and **kwargs
StringsUnicode-aware iteration and size; no string[index] syntax
ListsDirect indexes are non-negative; some methods such as pop() accept negative indexes
ImportsModule scope only; local paths are relative to the importing file
ConcurrencyGoroutines, channels, and joinable Goblin handles; no select or cancellation
ErrorsExplicit raise and try/catch; spawned-function errors are not propagated

Goblin favors direct, explicit code over a large amount of syntax. When a feature is absent, compose the available pieces: use a channel instead of a join operation, or an explicit loop instead of a specialized expression form.

Choosing a backend

Start with goblin run while developing. It gives direct source tracebacks and does not need to invoke Go. Use goblin build-exe only once the program works; it generates Go code and requires a working Go toolchain. A generated executable still uses Goblin's runtime behavior, rather than turning a Goblin value into a native Go primitive everywhere.

Installation

Goblin is installed through the Go toolchain. Install Go 1.20 or later, then verify that it is available:

$ go version

Install with go install

$ go install github.com/aisk/goblin@latest

This downloads, builds, and installs goblin into $GOBIN, normally $HOME/go/bin when GOBIN is unset. Ensure that directory is on your PATH.

For a typical Unix shell, add the following line to your shell profile if goblin is not found after installation:

export PATH="$PATH:$(go env GOPATH)/bin"

Verify the installation

$ goblin --help

If help text is printed, the installation is ready to use.

The CLI provides three subcommands:

CommandPurpose
goblin run file.goblin [args...]Interpret a source file (trailing args become os.argv(); put the file before any flags)
goblin build-exe file.goblinBuild a native executable
goblin replStart an interactive session

For goblin run, CLI help is goblin run -h or goblin help run. Script flags such as -v must come after the source file.

Build from source

Alternatively, clone the repository and build it yourself:

$ git clone https://github.com/aisk/goblin.git
$ cd goblin
$ go build .

This produces a goblin executable in the current directory. While working from a clone of the repository, you can also run it directly:

$ go run . run hello.goblin

Run the project's checks before contributing a change:

$ go test ./...

To verify the complete programs mirrored in the Book, run:

$ bash docs/check-examples.sh

Updating

To update an installation made with go install, run the same command again:

$ go install github.com/aisk/goblin@latest

Your first program

Create hello.goblin:

print("Hello, world!")

To write the same kind of line to stderr (for warnings or diagnostics), use eprint instead of print.

Goblin does not require a main function. Statements at the top level run in order. Add a comment with # when you need to explain a line:

# A program may have more than one top-level statement.
var audience = "world"
print("Hello,", audience)

Run it directly

$ goblin run hello.goblin
Hello, world!

run parses and interprets the file. It is the usual command during development. If parsing, semantic checking, or execution fails, the command reports the source location and exits with an error.

Compile an executable

$ goblin build-exe hello.goblin
$ ./hello
Hello, world!

By default the output name is derived from the source file. Specify a path with -o when needed:

$ goblin build-exe -o bin/hello hello.goblin

build-exe requires a working Go toolchain because it invokes go build after generating Go source. The output path may be absolute or relative to the current directory.

Use the REPL

Goblin also includes an interactive REPL:

$ goblin repl
>>> print("Hello, world!")
Hello, world!

The REPL saves history. Its prompt changes to ... while you enter a multi-line function or block. Press Ctrl-D to exit.

Use the REPL for small experiments. Definitions stay available for the rest of the session:

>>> var answer = 42
>>> answer + 1
43

Using the REPL

Start a persistent interactive Goblin session with:

$ goblin repl
Goblin REPL. Press Ctrl-D to exit.
>>>

Expressions print their value automatically; declarations and statements do not. Names, imports, functions, and types stay available until the session exits.

>>> 1 + 2 * 3
7
>>> var values = [1, 2, 3]
>>> values.push(4)
>>> values
[1, 2, 3, 4]
>>> import "math"
>>> math.sqrt(81)
9

Relative imports are resolved from the directory where the REPL starts, so start it from a project directory when testing a local module.

os.argv() in the REPL returns [""]. It does not expose the goblin binary's process arguments. Use goblin run when you need real script argv.

Multi-line input

When parentheses, brackets, or braces are unbalanced, the prompt changes to ... and the REPL keeps collecting lines. This is how to enter functions, types, loops, and try/catch blocks.

>>> func square(x) {
...     return x * x
... }
>>> square(12)
144

Enter a blank line to force evaluation of a malformed fragment, or press Ctrl-C to discard the input currently being collected.

Completion and history

Press Tab to complete visible names, keywords, and member paths. For example, after import "json", type json. and press Tab to see members. Completion only reads attributes; it does not call Goblin functions.

History is stored in ~/.goblin_history; use Up/Down to revisit entries and Ctrl-D to exit. Use the REPL for small experiments and API discovery, then move repeatable work into a .goblin file.

Syntax and call rules

Goblin uses braces for blocks and does not use semicolons. A newline ends a statement, so each statement sits on its own line. Comments begin with # and continue to the end of the line.

Literals and collection syntax

Integer literals such as 42 and float literals such as 3.14 are decimal only. Strings use double quotes. They support \n, \t, \r, \", and \\. Any other escape sequence is a syntax error.

var names = ["Ada", "Linus"]
var user = {"name": "Ada", "active": true}

List, dictionary, call, parameter, and field lists do not accept a trailing comma. Dictionary keys and values are expressions, but keys should be stable values such as strings, integers, or booleans.

Statements and line breaks

Any expression can stand alone as a statement, but only a call, index, or member access may do so in a program: user.save(), "a,b".split(","), and func() { ... }() are valid statements, while a + b or a lone - 2 is rejected with expression value is not used, since a value that is computed and dropped is almost always a mistake. The REPL is the exception: there a bare expression such as 1 + 2 is evaluated and displayed.

Because a newline ends a statement, a line can only continue the previous one in places where the expression is visibly unfinished: after a binary operator, a comma, a dictionary colon, or inside an open (, [, or { of a literal. Ending a line with a complete expression and starting the next with an operator does not continue it. var a = 10 followed by a line - 2 is two statements, and the second is the unused-value error above rather than a silent 10 - 2.

var total = price * quantity +
    shipping
var discounted = (
    total - coupon
)
var files = {
    "a.txt": "first",
    "b.txt": "second"
}
print(files["a.txt"],
      files["b.txt"])

The same rule means else must follow the closing brace of its if on the same line, and two statements cannot share a line.

Names

Identifiers may not be Go keywords: names such as map, range, select, struct, go, or defer are reserved and rejected at check time, because compiled programs emit them as Go identifiers. A handful of names the generated code itself relies on — object, extension, builtin, fmt, _registry, Execute, and main — are reserved for the same reason, as is the whole family of identifiers shaped like _name_N (a leading underscore with a trailing _<digits> suffix, e.g. _err_0), which the transpiler uses for its own temporaries. Built-in function names like print or max are not reserved — see Scope and declarations for how user declarations shadow them.

Functions and calls

Function parameters are required unless they declare a default value with = or are captured by *args or **kwargs.

func report(name, limit = 10, *values, **options) {
    print(name, limit, values, options)
}

report("scores", 1, 2, visible=true)

Calls can use positional arguments, named arguments, * list expansion, and ** dictionary expansion. Positional arguments must come before named ones. Whether a particular built-in or method accepts names is API-specific: many small methods are positional-only. Use the documented parameter names or value.attributes() in the REPL to discover a value's available methods.

Values, variables, and expressions

Goblin is dynamically typed: variables do not declare a type, and values carry their types at runtime. Use var to bind a name to a value.

var project = "Goblin Book"
var published = false

Assign to the name to update it later.

var score = 10
score = score + 5
print(score) # 15

print separates multiple arguments with spaces and ends the line; it writes to stdout. Use eprint for the same formatting on stderr. A comment starts with # and runs to the end of its line.

Operators

The ordinary arithmetic operators are +, -, *, /, and %. The % operator returns the remainder and has the same precedence as multiplication and division. Use parentheses to make grouping explicit.

print(1 + 2 * 3)       # 7
print((1 + 2) * 3)     # 9
print("go" + "blin")  # goblin
print("ha" * 3)        # hahaha
print(10 % 3)          # 1

Comparison operators are ==, !=, <, <=, >, and >=; each produces a boolean. Equality is total across types: values of unrelated types are simply unequal, so x == nil is always a safe test, and lists and dictionaries compare element by element. Ordering comparisons (<, <=, >, >=) raise TypeError when the operands cannot be ordered. Comparisons do not chain: a < b < c is a syntax error rather than the surprising (a < b) < c, so write a < b && b < c, or parenthesize explicitly when you really mean to compare a boolean result. Logical operators are !, &&, and ||. && and || short-circuit, so their right-hand side is evaluated only when needed, and they produce the operand that decided the result rather than a boolean: a || b is a when a is truthy and b otherwise, a && b is a when a is falsy and b otherwise. That makes || the way to write "use this, or a default". Wrap the expression in Bool() when you need a plain true/false. ! always produces a boolean.

var allowed = age >= 18 && !banned
var nickname = supplied || "anonymous"

&& binds tighter than ||, as in most languages: ready || retry && connected evaluates as ready || (retry && connected), and true || false && false produces true.

Conditions use truthiness. false, nil, numeric zero, and empty strings or collections are false. Continue with Built-in types for the values that Goblin provides.

Scope and declarations

var creates a name in the current lexical scope. A name declared in a block is visible inside nested blocks, but not after the block ends. Assignment updates the nearest existing name; use var when a new local binding is intended.

var message = "outside"
if true {
    var detail = "inside"
    message = "changed"
    print(detail)
}
print(message) # changed
# detail is not visible here

Functions capture the surrounding lexical scope. Each for iteration has its own loop binding, so functions created in a loop retain that iteration's value.

var readers = []
for value in [1, 2, 3] {
    readers.push(func() { return value })
}
print(readers[0](), readers[1](), readers[2]()) # 1 2 3

Declarations at module scope

import, type, and export are allowed only at module scope. Module-level import, func, and type names are hoisted: they are visible throughout the module regardless of where the definition appears, so functions may call functions defined later — including mutually recursive pairs.

func is_even(n) {
    if n == 0 { return true }
    return is_odd(n - 1)
}

func is_odd(n) {
    if n == 0 { return false }
    return is_even(n - 1)
}

print(is_even(10))

Other names must be declared before use: local var declarations (and nested function definitions) are not available before their declaration. Keep declarations near the beginning of the block when that makes a function easier to read.

Shadowing built-in functions

A user declaration may reuse a built-in function's name. The innermost binding wins, and the built-in becomes visible again once the shadowing scope ends:

func min(a, b) {
    return "user min"
}
print(min(1, 2)) # user min

if true {
    var max = 100
    print(max)   # 100
}
print(max(1, 2)) # 2 — the built-in is back in scope

Go keywords such as map or range cannot be used as names at all; see Syntax and call rules.

Built-in functions

These names are available without an import.

FunctionPurpose
print(values...)Write values to stdout, separated by spaces, ending with a newline
eprint(values...)Same as print, but write to stderr
range(start, end)Create integer values from start through end-exclusive
min(values...) / max(values...)Choose the smallest or largest numeric value
Int(value) / Float(value) / Str(value) / Bool(value)Convert a value
Bytes(value) / List(iterable) / Dict(key=value, ...)Construct a collection value
Chan([size])Create a channel; no size means unbuffered
Function(value)Validate and return a callable value unchanged
spawn(function, args...)Run a function concurrently, fire-and-forget
Goblin(function, args...)Run a function concurrently, returning a joinable handle
Error(message)Create an error value

print and eprint return nil. range needs both start and end; it has no one-argument form. min and max require at least one numeric argument. Constructors that use an argument parser, including range, numeric conversions, and Dict, accept their documented keyword names; print, eprint, and spawn are positional-only.

Use eprint for diagnostics and warnings so they stay off the program's normal stdout stream (the same role as Python's print(..., file=sys.stderr), Rust's eprintln!, or Go's fmt.Fprintln(os.Stderr, ...)).

var limits = [3, 8, 5]
print(min(*limits), max(*limits))
print(range(start=2, end=5))
print(Dict(host="127.0.0.1", port=8080))
eprint("ready on port", 8080)

See Built-in types for value-specific methods, and Concurrency for channels and spawn.

Constructors and type identity

Runtime values expose their constructor through value.constructor when the value has a constructible type. The constructor is the same callable that is used to create or convert that type:

var values = [1, "text", Bytes("raw"), [1], {"x": 1}, Chan(1)]
for value in values {
    print(value.constructor)
}

func identity(value) {
    return value
}
print(Function(identity) == identity) # true
print(identity.constructor == Function) # true

Goblin-defined instances and native standard-library values follow the same rule: instance.constructor is identical to the type callable that created them. nil has no constructor because it represents the absence of a value.

Built-in types

Goblin values have runtime types; variables do not need type annotations. An operation succeeds only when its value types are compatible.

TypeExamplesNotes
Integer0, -42Signed whole number
Float3.14, -0.5Floating-point number
Booltrue, falseLogical value
NilnilAbsence of a value; it prints as nil
String"hello"Immutable Unicode text
BytesBytes("data")Immutable raw byte sequence
List[1, "two"]Ordered, mutable collection
Dict{"name": "Ada"}Mutable key/value collection
ChanChan(0)Channel for concurrent functions
GoblinGoblin(func() {})Handle to a function running concurrently
Functionfunc() {}Callable value

Custom types are covered in Types and methods.

Numbers

Integer literals have no decimal point; float literals do. Arithmetic preserves an integer result when both operands are integers. If either operand is a float, the result is a float. Integer division truncates its fractional part. The % operator returns the remainder after truncating division; the result has the dividend's sign and is a float if either operand is a float.

print(7 / 2)     # 3
print(7 / 2.0)   # 3.5
print(2 + 0.5)   # 2.5
print(-3 * 4)    # -12
print(-7 % 3)    # -1
print(7.5 % 2)   # 1.5

Numbers can be compared across integer and float values. Division or modulo by zero raises ZeroDivisionError. Int() and Float() convert numbers, booleans, and numeric strings; converting a float to Int removes its fractional portion.

print(Int("42"))   # 42
print(Int(3.9))     # 3
print(Float(true))  # 1
print(max(3, 5, 4))

When to convert and when to calculate

Use Int() or Float() at the edge of a program, where a value arrives as text or where an integer operation must become floating-point. Keep calculations in their natural numeric form after that. For example, parse a configuration value once, then use min() and max() to keep it within a permitted range.

var requested_workers = Int("12")
var workers = min(max(requested_workers, 1), 8)
print(workers) # 8

Int() rejects non-numeric text with ValueError. This makes it suitable for validating numeric input inside a try/catch block.

Booleans and nil

Use Bool(value) to convert any value by truthiness. False, nil, numeric zero, and empty strings, lists, dictionaries, or bytes are false.

print(Bool(""))       # false
print(Bool([1]))      # true
print(!nil)           # true
print(true && false)  # false

A function with no explicit result returns nil. Comparing any value against nil with == is safe — it is true only for nil itself — but arithmetic and indexing on nil are errors.

Using truthiness for optional values

Truthiness is convenient for choosing a fallback or guarding an optional collection. Use an explicit comparison with nil when zero, false, or an empty collection must still be treated as a present value.

var nickname = ""
print(nickname || "anonymous") # anonymous

var limit = 0
if limit == nil {
    print("no limit supplied")
}

Strings and bytes

Strings are immutable Unicode text. They can be iterated by character, combined with +, and repeated with *, but they are not indexable with []. Use index() or last_index() to find a character position. See Strings for conversions and typical methods.

Bytes are immutable raw byte sequences. Bytes("ABC") has size 3 and its first element is the integer 65. Common byte methods mirror string operations: decode(), contains(), has_prefix(), split(), replace(), and trim(). Use Bytes for raw data and strings for text.

Working with bytes

Use Bytes when reading or sending binary-oriented data, or when indexing must produce numeric byte values. Use decode() when that data should become text.

var header = Bytes("GET")
print(header[0])          # 71
print(header.contains("E"))
print(header.decode())    # GET

Lists and dictionaries

Lists and dictionaries are mutable collections. A list is ordered and uses integer indexes; a dictionary maps keys to values. Empty collections are false in conditions. See Collections for creation, indexing, and method guides.

Channels

Chan(capacity) creates a channel. Send with send(), receive with recv(), and close with close(). Capacity 0 creates an unbuffered channel. Use spawn() to start a concurrent function.

var done = Chan(0)
spawn(func() {
    done.send("finished")
})
print(done.recv())
done.close()

Coordinating concurrent work

An unbuffered channel makes send() wait until another function calls recv(). This makes it useful for returning one result from spawned work. A buffered channel can accept up to its capacity before a receiver is ready.

var results = Chan(2)
spawn(func() { results.send(2 * 2) })
spawn(func() { results.send(3 * 3) })
print(results.recv() + results.recv())
results.close()

Close a channel only when no more values will be sent. Receiving from a closed, drained channel raises ValueError, rather than producing a special nil value.

Goblin handles

Goblin(function, args...) starts the function in a new goroutine and returns a handle. wait() joins it: the function's result is returned, an error it raised is re-raised, and the outcome is cached across repeated calls. done() reports completion without blocking, and wait(timeout = seconds) raises TimeoutError when the function outlives the timeout.

func square(value) {
    return value * value
}

var worker = Goblin(square, 6)
print(worker.wait()) # 36

See Concurrency for how handles, channels, and spawn fit together.

Common operations

TypeCommon constructors and operations
Integer / FloatInt(value), Float(value), max(...), min(...)
Bool / NilBool(value), !value, value && other, value || other
StringStr(value), size, contains(), split(), replace()
BytesBytes(value), size, decode(), contains(), split()
ListList(value), size, first, last, push(), pop(), sort(), copy()
DictDict(), get(), set_default(), keys(), items(), update()
ChanChan(size), send(value), recv(), close()
GoblinGoblin(function, args...), wait(), done()
FunctionFunction(value), value(...)

Use value.attributes() in the REPL to inspect all operations provided by a runtime value. Constructible values also expose value.constructor; see Built-in functions.

Control flow

Goblin uses braces for blocks and does not need semicolons.

An indented style makes nested blocks easy to scan, but indentation is not part of the grammar: braces determine the block.

Conditions

Use if, else if, and else to select a branch:

if score >= 90 {
    print("excellent")
} else if score >= 60 {
    print("passed")
} else {
    print("try again")
}

Every condition is evaluated with the truthiness rules described in Built-in types. This lets you test optional values and collections directly:

var users = []
if users {
    print("have users")
} else {
    print("no users")
}

while loops

A while loop repeats while its condition is true. break leaves the nearest loop and continue skips to its next iteration.

var n = 0
while n < 10 {
    n = n + 1
    if n == 3 {
        continue
    }
    if n == 6 {
        break
    }
    print(n)
}

Be sure that a while condition eventually changes or that the loop reaches a break; Goblin does not impose a loop limit. In nested loops, break and continue apply only to the innermost loop.

for ... in loops

for iterates over lists, strings, dictionaries, and other iterable values. Iterating over a dictionary produces its keys; do not depend on their order.

for name in ["Ada", "Linus"] {
    print(name)
}

for i in range(0, 3) {
    print(i) # 0, 1, 2
}

range(start, end) creates integers from start up to, but excluding, end. It also accepts named arguments, such as range(start=0, end=3).

Strings iterate by character, lists by element, and dictionaries by key:

var scores = {"Ada": 10, "Linus": 9}
for name in scores {
    print(name, scores[name])
}

for character in "go" {
    print(character)
}

Dictionary order is unspecified. If output order matters, do not build it by iterating a dictionary directly.

Functions

Define a function with func. Parameters do not have declared types, and return sends a result back to the caller. A function without a value returns nil.

func add(a, b) {
    return a + b
}

print(add(2, 3))

Use a bare return to exit early. It returns nil.

func describe(value) {
    if value == nil {
        return
    }
    print(value)
}

Functions are values: assign them, pass them to another function, or return them. An anonymous function omits the name.

var square = func(x) { return x * x }

func apply(f, value) {
    return f(value)
}

print(apply(square, 5))

An anonymous function captures variables in its surrounding scope, so it can form a closure:

func multiplier(n) {
    return func(x) { return x * n }
}

var double = multiplier(2)
print(double(21))

The captured value belongs to the closure's surrounding scope. Each call to multiplier above creates a separate function value, so multiplier(3) would produce a different closure.

Parameters

Calls may use positional arguments or parameter names:

print(add(a=2, b=3))

Positional arguments must precede named arguments. A parameter can receive a value only once; passing the same parameter positionally and by name is an error.

A parameter can declare a default value with =, making it optional at the call site:

func greet(name, msg = "hi") {
    print(msg, name)
}

greet("bob")                # hi bob
greet("bob", msg = "yo")    # yo bob

A required parameter cannot follow one with a default. The default expression is evaluated in the function's defining scope — it cannot reference other parameters — and it is evaluated on each call that omits the argument, so a mutable default like [] produces a fresh value every time.

A * parameter collects extra positional arguments, while a ** parameter collects extra named arguments. They receive a list and a dictionary, respectively.

func show(first, *rest, **options) {
    print(first)
    print(rest)
    print(options)
}

show("a", "b", "c", color="green")

The *rest parameter must be last, unless a final **options follows it. **options must always be last. Expand a list or dictionary at a call site with f(*items) or f(**options):

func add3(a, b, c) {
    return a + b + c
}

var values = [1, 2, 3]
print(add3(*values))

A **-expanded value must be a dictionary with string keys, and each keyword name may only be supplied once per call: naming an argument twice — whether by two explicit keywords, two ** dictionaries that share a key, or one of each — raises a TypeError.

Recursion and callbacks

A named function can call itself. Functions also work naturally as callbacks because they are ordinary values.

func factorial(n) {
    if n <= 1 {
        return 1
    }
    return n * factorial(n - 1)
}

func map_values(values, transform) {
    var result = []
    for value in values {
        result.push(transform(value))
    }
    return result
}

print(factorial(5))
print(map_values([1, 2, 3], func(x) { return x * x }))

Strings

Strings are immutable Unicode text values written with double quotes. Escape a double quote or a backslash with a backslash.

var message = "say: \"hello\""
print(message)        # say: "hello"
print(message.size) # 12

size counts Unicode characters, not bytes; it is a property, not a method. Strings iterate by character, but they cannot be indexed with []; use index() or last_index() when a character position is needed.

var language = "Goblin"

for character in language {
    print(character)
}

Combining and converting text

Use + to concatenate strings. A string can also concatenate an integer or boolean, and * repeats it by an integer count, in either operand order.

print("go" + "blin") # goblin
print("item-" + 3)   # item-3
print("ha" * 3)      # hahaha
print(3 * "ha")      # hahaha

Str(value) converts any value to its display text. This is useful when building a string from a float, list, custom type, or nil.

var label = "port=" + Str(8080)
print(label)

Common string methods

MethodPurpose
sizeCharacter count (property)
upper() / lower() / title()Change letter case
contains(substring)Test for a substring
has_prefix(prefix) / has_suffix(suffix)Test the beginning or end
index(substring) / last_index(substring)Find a substring; returns -1 if absent
count(substring)Count non-overlapping occurrences
replace(old, new, count=-1)Replace all occurrences by default
split(separator, count=-1)Split into a list
split_after(separator, count=-1)Split while retaining separators
trim(cutset=nil)Trim Unicode whitespace or supplied characters
trim_prefix(prefix) / trim_suffix(suffix)Remove one matching edge
cut(separator)Split once, returning a three-element result
repeat(count)Repeat text

Methods accept named arguments as well as positional ones.

var title = "  Goblin book  "
print(title.trim().upper())
print("a,b,c".split(sep=",", count=2))
print("one one one".replace(old="one", new="two", count=2))
print("config.toml".trim_suffix(".toml"))

trim() with no argument removes Unicode whitespace. Supplying a string trims any of its characters from both ends; it does not remove an exact substring. Use trim_prefix() or trim_suffix() when that distinction matters.

Common text-processing patterns

Use trim() before validating user-supplied text, split() to turn a delimited setting into a list, and replace() when normalizing a known spelling or format.

var raw_tags = " go, language,tools "
var tags = []
for tag in raw_tags.split(",") {
    tags.push(tag.trim())
}
print(tags)

var filename = "report.TXT"
if filename.lower().has_suffix(".txt") {
    print("text file")
}

Use contains() when a yes/no answer is enough. Use index() or last_index() when you need the position, such as separating a filename from its final extension.

Collections

Lists and dictionaries are mutable collections. Their elements can have mixed runtime types. Empty collections are false in conditions.

Lists

Create a list with square brackets. Direct list indexes begin at zero and must be non-negative.

var tasks = ["write", "test"]
print(tasks[0])
tasks[1] = "review"
tasks.push("ship")
print(tasks.pop())

An index outside the list raises IndexError. The + operator makes a new concatenated list; push(), reverse(), sort(), and clear() change the current list.

var first = [1, 2]
var combined = first + [3, 4]
print(combined) # [1, 2, 3, 4]

List method guide

MethodPurpose
sizeNumber of elements (property)
push(value, ...)Append one or more values
pop(index=-1)Remove and return an element; defaults to the last
first / lastRead the first or last element without removing it (properties)
insert(index, value)Insert before an index
remove(value)Remove the first matching value
contains(value) / count(value)Test for or count a value
index(value, start=0)Find a value from an offset
join(separator)Convert elements to text with a separator
reverse() / sort()Reorder the list in place
copy() / clear()Duplicate or empty the list

pop(), first, and last raise IndexError when the requested element is unavailable. pop() accepts a negative index, so its default -1 removes the last element. index() returns -1 when a value is absent, and remove() returns true when it removed a value or false when it did not.

Common list patterns

Use push() while building a result, pop() when processing a work stack, and copy() before an operation that should not change the original list.

var names = ["Ada", "Linus", "Grace"]
var greetings = []
for name in names {
    greetings.push("Hello, " + name)
}
print(greetings.join(" | "))

var original = [3, 1, 2]
var ordered = original.copy()
ordered.sort()
print(original) # [3, 1, 2]
print(ordered)  # [1, 2, 3]

Use contains() for membership tests. Use index() when the position matters; check for a missing value before using its result as an index.

Transforming lists with callbacks

Lists also provide callback-based helpers. map() returns transformed values, filter() keeps values whose callback is truthy, and reduce() combines values from left to right. each() runs a callback for its side effect; find() returns the first matching value or nil.

var values = [1, 2, 3, 4]
var squares = values.map(func(value) { return value * value })
var large = values.filter(func(value) { return value > 2 })
var total = values.reduce(func(acc, value) { return acc + value }, 0)

print(squares) # [1, 4, 9, 16]
print(large)   # [3, 4]
print(total)   # 10

Callbacks receive one list element, except reduce(), whose callback receives the accumulator followed by the element. Without an initial value, reduce() uses the first list element and raises TypeError for an empty list. any() and all() accept an optional callback; with none, they test each element's truthiness. sum() adds all elements.

Dictionaries

Create a dictionary with key: value pairs. Read and write through a key.

var user = {"name": "Ada", "age": 36}
print(user["name"])
user["active"] = true
print(user.get("role", default="reader"))

Keys may be strings, integers, floats, booleans, or nil. Key identity follows ==: keys of different types stay distinct (d[1] and d["1"] are separate entries), while equal numbers name the same entry (d[1] and d[1.0] are one key). Using a mutable value (a list, dictionary, or custom instance) as a key raises TypeError.

Looking up a missing key with dictionary[key] raises KeyError. Use get() when a missing value is expected. Dictionary iteration yields keys; items() yields two-element [key, value] lists. Dictionary iteration order is unspecified.

var settings = {}
settings.set_default("theme", "dark")
for pair in settings.items() {
    print(pair[0], pair[1])
}

Dictionary method guide

MethodPurpose
sizeNumber of entries (property)
contains(key)Test whether a key exists
get(key, default=nil)Read a key without raising for a missing key
set_default(key, default=nil)Get an existing value or insert a default
keys() / values() / items()Return lists of keys, values, or key/value pairs
pop(key)Remove and return a value
update(other)Copy entries from another dictionary
copy() / clear()Duplicate or empty the dictionary

List() and Dict() construct the corresponding collections. See Control flow for iteration.

Common dictionary patterns

Use get() to read optional configuration, set_default() to initialize a value once, and update() to merge a set of overrides into a base dictionary.

var defaults = {"host": "127.0.0.1", "port": 8080}
var overrides = {"port": 3000}
defaults.update(overrides)
print(defaults["port"]) # 3000

var counts = {}
for word in ["go", "goblin", "go"] {
    var current = counts.get(word, default=0)
    counts[word] = current + 1
}
print(counts["go"]) # 2

Use keys(), values(), or items() only when a list snapshot is useful. For a simple dictionary traversal, iterate the dictionary itself and look up each value by its key.

Types and methods

type defines a custom type with fields and methods. The parentheses list fields supplied at construction; a field may have a default value. Every method must declare self as its first parameter. Type definitions belong at module scope, not inside a function or a control-flow block.

type Point(x, y=0) {
    func move(self, dx, dy) {
        self.x = self.x + dx
        self.y = self.y + dy
    }

    func text(self) {
        return "(" + Str(self.x) + ", " + Str(self.y) + ")"
    }
}

var p = Point(1)
p.move(2, 3)
print(p.text())

Instance fields can be read and updated directly. Construction accepts both positional and named arguments:

var origin = Point(x=0, y=0)
origin.x = 10

Required fields must come before fields with defaults. Calling a type requires all required fields exactly once; named arguments make construction clearer when a type has several fields.

Methods and state

Methods are ordinary functions attached to a type. They can read or replace fields through self, and methods may call other methods on the same instance.

type Counter(value=0) {
    func increment(self) {
        self.value = self.value + 1
        return self.value
    }
}

var counter = Counter()
print(counter.increment()) # 1
print(counter.increment()) # 2

Protocol methods

Goblin lets custom types participate in operations and protocols through conventionally named methods such as __add, __cmp, __bool, __str, __iter, and __getitem. These names have leading double underscores only; there are no trailing underscores. Most programs can start with ordinary fields and methods.

The most useful protocol methods are shown below. Their parameter shapes are fixed: use self alone for conversion and iteration methods, self, other for binary operators and comparison, and self, index, value for __setitem.

MethodEnables
__add, __sub, __mul, __div, __modArithmetic operators
__radd, __rsub, __rmul, __rdiv, __rmodThe same operators with the instance on the right
__notLogical ! operator; without it ! negates truthiness
__cmp==, !=, <, <=, >, >=; return -1, 0, or 1. Consulted from either side of a comparison
__strPrinting and Str(value)
__boolConditions, Bool(value), and the truthiness test in && / `
__iterfor value in instance
__getitem, __setiteminstance[index] read and assignment

If a protocol method is absent, the corresponding operation raises TypeError. Equality is the exception: without __cmp, == and != fall back to identity, so an instance is equal only to itself and never raises. Ordering comparisons (<, <=, >, >=) still require __cmp.

With __cmp, a type mismatch inside it still reads as "unequal" — money == nil stays false for a __cmp written only for numbers — but any other failure is reported rather than swallowed, so == raises whatever the method raised.

A comparison consults both operands, so the instance does not have to be on the left. When the left operand has no ordering for the right one — as a built-in number never has for a custom type — the right operand is asked to compare itself against the left and the result is flipped:

type Money(amount) {
    func __cmp(self, other) {
        return self.amount - other
    }
}

var m = Money(5)
print(m < 10) # true
print(10 > m) # true, answered by Money.__cmp

Arithmetic cannot flip its operands the same way — a - b is not b - a — so it uses a second set of methods instead. When the left operand does not know the right one, the right operand's __radd, __rsub, __rmul, __rdiv or __rmod is called with the left operand as its argument:

type Scaled(factor) {
    func __mul(self, other) {
        return Scaled(self.factor * other)
    }
    func __rmul(self, other) {
        return Scaled(other * self.factor)
    }
}

var s = Scaled(3)
print(s * 2) # Scaled(6)
print(2 * s) # Scaled(6), answered by __rmul

A reflected method is only reached after the left operand reports that the types do not fit. Anything else it raises — a division by zero, a failure inside its own body — propagates instead, and an operator whose reflected method is not defined keeps reporting the error of its left operand.

The built-in sequences follow the same rule, so repetition reads in either order: 3 * "ab" and 2 * [1, 2] work like "ab" * 3 and [1, 2] * 2.

For example, this type supports + and printing:

type Vector(x, y) {
    func __add(self, other) {
        return Vector(self.x + other.x, self.y + other.y)
    }

    func __str(self) {
        return "Vector(" + Str(self.x) + ", " + Str(self.y) + ")"
    }
}

print(Vector(1, 2) + Vector(3, 4))

Errors

Errors are values. Use raise to stop normal execution with an error and try / catch to recover at a boundary that can make a useful decision.

func divide(a, b) {
    if b == 0 {
        raise ZeroDivisionError.wrap("divide")
    }
    return a / b
}

try {
    print(divide(10, 0))
} catch err {
    print(err.message)
    print(err.is(ZeroDivisionError))
}

The name after catch is local to its catch block. When no error is raised, the catch block is skipped. An unhandled error stops the program and prints a traceback.

Creating and matching errors

Error("message") creates a plain error. Use a sentinel error when callers need to distinguish a particular failure from unrelated errors.

var not_found = Error("not found")

func load_user(id) {
    if id == 0 {
        raise not_found.wrap("loading user")
    }
    return {"id": id}
}

try {
    load_user(0)
} catch err {
    if err.is(not_found) {
        print("choose another user")
    }
}

wrap("context") adds a message while preserving the original error. unwrap() returns the direct cause, and is() tests the complete error chain.

var base = Error("connection failed")
var err = base.wrap("loading profile")
print(err.message)
print(err.unwrap().message)
print(err.is(base))

Error kinds

The runtime uses named error kinds, which can be raised directly or wrapped.

KindTypical cause
TypeErrorAn operation receives an incompatible value
ValueErrorA valid type has an invalid value
IndexError / KeyErrorA missing list index or dictionary key
ZeroDivisionErrorDivision or modulo by zero
AttributeError / NameErrorA missing member or identifier
ImportErrorAn unavailable module
ParseErrorInvalid JSON or other parsed input
IOErrorGeneric file, network, or operating-system failure
NotExistError / ExistError / PermissionErrorA missing, existing, or inaccessible filesystem path
TimeoutError / NetworkErrorA timed-out or other network operation
NotImplementedErrorAn operation the runtime does not implement

Kinds are hierarchical. IndexError and KeyError are LookupErrors; ZeroDivisionError is an ArithmeticError; ParseError is a ValueError. Therefore err.is(LookupError) can handle more than one specific lookup failure.

Wrap errors where you can add useful context. Do not catch an error merely to discard it: either recover with a meaningful alternative, or use raise err to let an outer layer handle it.

Concurrency

There are two ways to start a function in a new goroutine. Goblin(function, args...) starts it immediately and returns a handle; wait() on the handle joins the function and delivers its result. spawn(function, args...) is fire-and-forget: it returns nil immediately, and the function's return value and unhandled error are discarded. Use a Goblin handle when the caller cares about the outcome, and spawn when it does not.

func square(value) {
    return value * value
}

var worker = Goblin(square, 6)
print(worker.wait()) # 36

Goblin handles

Construction starts the function right away; there is no separate start step. Arguments after the function are forwarded to it.

OperationBehavior
Goblin(function, args...)Start the function in a new goroutine and return a handle
handle.wait()Block until the function finishes, then return its result
handle.wait(timeout = seconds)Same, but raise TimeoutError if the function is still running when the timeout expires
handle.done()Report whether the function has finished, without blocking

The outcome is computed once and cached, so wait() can be called any number of times — and from several places at once — with the same answer. An error raised inside the function is not printed anywhere; it is stored and re-raised by every wait() call, where the normal try/catch machinery applies:

func fail() {
    raise ValueError.wrap("bad input")
}

var failing = Goblin(fail)
try {
    failing.wait()
} catch err {
    print(err.is(ValueError)) # true
}

A handle that is never waited on is abandoned: when the program ends, running goblins are stopped mid-flight, and a stored error nobody asked for is discarded silently. If a function's failure matters, wait() for it; if the result never matters, spawn is the honest way to say so (an uncaught error in a spawned function is at least reported on stderr).

Sharing data with a goblin

Pass data into a goblin through its arguments, and out through its return value or a channel. Two goblins — or a goblin and the top-level program — reading and writing the same variable concurrently is a data race: no ordering is defined, and the compiled backend inherits Go's undefined behavior for races. Reading surrounding names that nothing writes concurrently (module imports, top-level functions, builtins) is safe.

The same rule covers the inside of a shared mutable value: lists and dictionaries are not synchronized, and Goblin never locks them for you. The consequence of a race is not limited to stale or torn data — two goblins writing to the same dictionary can terminate the whole process at once, and try/catch cannot intercept that crash. When several goblins must work on the same data, keep one owner that mutates it and let the others send requests or results over a channel, as in the examples below.

goblin build-exe --race compiles the executable with Go's race detector: when a race actually occurs at run time, the program reports both racing operations with stack traces and exits with a failure status. The interpreter has no equivalent switch — race instrumentation happens when Go code is compiled — so a program that only ever ran under goblin run may hide a race that the compiled backend hits. Build the racy suspect with --race and exercise it when in doubt.

Channels

Chan() and Chan(0) create an unbuffered channel. A send waits until some goroutine receives it, and a receive waits until a value is sent. Chan(size) creates a buffer that can hold up to size values before sends block.

OperationBehavior
channel.send(value)Blocks until a receiver is ready or buffer space exists
channel.recv()Blocks until a value is available
channel.close()Prevents future sends; buffered values can still be received
Chan(size)Requires a non-negative integer; omitting size means zero

Sending on a closed channel, closing a channel twice, or receiving after a channel is closed and drained raises ValueError. Channels are not iterable and there is no special end-of-stream value, so a receiver must know how many values to expect or use a separate completion signal.

Buffering a known number of results

Use a buffered channel when several workers can finish before the caller starts receiving. The buffer capacity here matches the number of results.

func square(value, result) {
    result.send(value * value)
}

var results = Chan(3)
for value in [2, 3, 4] {
    spawn(square, value, results)
}

var total = 0
for ignored in range(0, 3) {
    total = total + results.recv()
}
results.close()
print(total) # 29

An unbuffered results channel also works in this example because the caller begins receiving after it starts the workers. Buffering changes when sends block; it does not guarantee a result order. Do not rely on spawned work finishing in the order it was started.

Returning errors explicitly

wait() re-raises a goblin's error at the call site, which covers the common case of one job whose failure the caller handles. When workers stream results through a channel instead, an error raised inside a spawned function is not delivered to the caller. Catch it in the worker and send a result record when the caller needs to handle failures.

func load_number(text, result) {
    try {
        result.send({"value": Int(text), "error": nil})
    } catch err {
        result.send({"value": nil, "error": err})
    }
}

var result = Chan()
spawn(load_number, "not-a-number", result)
var outcome = result.recv()
if outcome["error"] {
    print(outcome["error"].message)
}
result.close()

Use a dictionary only as a small result record like this. For a repeated or larger protocol, define a custom type so the fields and methods are explicit.

Ownership and deadlocks

The code that knows no more values will be sent should close the channel. Do not close a channel while a spawned sender may still use it. A common deadlock is sending to an unbuffered channel in the same goroutine before starting a receiver:

var messages = Chan()
# messages.send("hello") would block here: no receiver can run yet.
spawn(func() { messages.send("hello") })
print(messages.recv())
messages.close()

Goblin has no select operation, no cancellation primitive, and no timeout on channel operations; wait(timeout = seconds) on a Goblin handle is the only time-bounded wait, and functions started with spawn cannot be joined at all. Design each concurrent operation so every blocking send has a receiver, every expected result is received, and the owner can decide when it is safe to close its channel.

Troubleshooting

When a program fails, first identify which stage reported the problem. The message and traceback include the source location.

SymptomLikely causeWhat to check
Parse errorSyntax the grammar does not acceptBraces, commas, string escapes, and function parameter syntax
Semantic errorA name, declaration, or statement is invalid in its scopeDeclare names before use; keep import/type/export at module scope
NameError / AttributeErrorA runtime name or member is unavailableSpelling, module import, and value.attributes() in the REPL
TypeErrorAn operation received an unsupported value typeConstructor inputs, callback return values, and operator operands
IndexError / KeyErrorA list index or dictionary key is absentBounds checks, dict.get(), and list.index() returning -1
IOError / NetworkErrorA filesystem or HTTP operation failedPaths, permissions, connectivity, and a try/catch recovery boundary

A reliable debugging loop

Reduce the failing code to a small .goblin file, then run it through the interpreter:

goblin run failing.goblin

Use goblin repl for inspecting values. value.attributes() lists the operations an object exposes, which is particularly useful for module values, responses, files, and custom types.

If build-exe fails after a program works with run, verify that the Go toolchain is installed and run the command again with the generated-build message visible. Report the Goblin source, command, full error text, and whether the interpreter path succeeds when filing an issue.

build-exe compiles against the same published Goblin runtime version as the installed CLI unless it finds a local source checkout (from the working directory or the executable's location). Development builds without embedded module-version information use a pinned runtime fallback. When developing Goblin itself, set GOBLIN_ROOT to the checkout path so compiled programs use your local runtime.

Standard library

Goblin's standard library provides modules for common program boundaries: files, environment variables, external commands, paths, time, data formats, networking, random values, mathematics, and MIME metadata. Import a module at module scope, then access its members with dot notation.

import "json"
import "path"

var cwd = path.cwd()
var text = json.marshal({"cwd": cwd})
print(text)

The standard library is separate from Goblin's built-in functions and types. For example, print(), eprint(), range(), Int(), List(), Dict(), and Chan() are available without an import. Import a module only when its capabilities are needed.

Two tiers: core and x/

The standard library has two tiers. Core modules have curated, Goblin-shaped APIs and simple names such as "json" and "fs". Modules under the x/ prefix are direct adaptations of Go packages and keep Go's package hierarchy in their import path, so compress/gzip becomes x/compress/gzip. In both tiers the imported name is the last path component:

import "x/compress/gzip"

var packed = gzip.compress("hello")

Core modules

ModuleMain purposeStart with
jsonEncode and decode JSONmarshal(), unmarshal()
fsRead, write, inspect, and remove filesread(), write(), exists()
osRead environment and process informationargv(), getenv(), getwd(), hostname()
execConfigure and execute external commandsCommand()
pathFind the current or home directorycwd(), home()
timeWork with time and durationsnow(), sleep(), parse()
randGenerate reproducible random values and permutationsRand(), int(), shuffle()
mathNumeric constants and functionspi, sqrt(), pow(), abs()
httpMake HTTP requestsget(), post(), put()
uuidConstruct, generate, and validate UUID valuesUUID(), new(), is_valid()
regexpSearch, capture, replace, and split text with RE2 expressionscompile(), escape()
urlParse, resolve, join, and escape URLsparse(), query_escape()
csvRead and write comma-separated recordsread_all(), write_all()

x/ modules

ModuleMain purposeStart with
x/encoding/base64Encode and decode Base64 textencode(), decode()
x/encoding/base32, ascii85, html, quotedprintableEncode text and escape HTMLencode(), escape()
x/encoding/hexEncode, decode, and dump hexadecimal dataencode(), decode()
x/encoding/pemEncode and decode PEM blocksBlock(), decode()
x/mimeLook up MIME types and extensionstype_by_extension()
x/crypto/sha256 and sha512Compute fixed-size SHA-2 digestssum(), hex()
x/crypto/md5, sha1, x/hash/crc32, adler32Compute compatibility digests and checksumshex(), checksum()
x/crypto/hmac, x/hash/crc64, fnvCompute keyed and non-cryptographic hashessum(), hex()
x/compress/gzip, zlib, flate, bzip2Compress and decompress complete byte valuescompress(), decompress()
x/compress/lzwCompress and decompress LZW datacompress(), decompress()
x/archive/tar and zipRead and write complete in-memory archivesread_all(), write_all()
x/net/mailConstruct and parse email addressesparse_address()
x/net/netipParse and calculate with IP addresses and prefixesAddr(), Prefix()
x/unicode and x/unicode/utf8Validate UTF-8 and classify Unicode charactersvalid(), is_letter()

Imports and errors

Standard-library module names never start with "./" or "../"; core names are plain ("json", "fs") and x/ names carry their Go-style path ("x/compress/gzip"). Local source modules use a relative import such as "./modules/greeter"; those are documented in Modules and imports because they use the same import syntax.

Most standard-library operations that touch the outside world can fail. JSON parsing may raise ParseError, a missing file may raise an I/O-related error, and HTTP requests may fail. Use try/catch around work that your program can recover from; see Errors.

Choosing a module

Use json whenever a program boundary expects JSON rather than trying to build JSON text manually. Prefer fs for simple whole-file reads and writes, and use its open() function when a file object is needed. Use path.cwd() or path.home() instead of assuming a current directory. Use time.sleep() only for intentional delays, and use Chan plus spawn() for communication between concurrent Goblin functions.

Each module has its own chapter in this section, with a focused API reference and example.

Reading API signatures

Examples and tables use name(required, optional=value) to show argument order and defaults. Square brackets mean an argument may be omitted, as in Chan([size]). They do not promise that named arguments are accepted: a function's chapter calls out positional-only APIs where that matters.

Unless a chapter says otherwise, a function that touches files, the operating system, or the network can raise an error value. Wrap the smallest useful boundary in try/catch, then add context or recover deliberately.

Modules and imports

A module groups related values. Import statements belong at module scope. An imported name is the final component of its path.

import "json"
import "./modules/greeter"

print(json.marshal({"ok": true}))
greeter.greet("world")

Local modules

Local paths must start with ./ or ../ and are resolved relative to the file that imports them; any other path, slashes included, names a standard-library module (like "x/compress/gzip"). Omit the .goblin suffix. A local module chooses its public names with export.

# modules/greeter.goblin
var greeting = "Hello"

func greet(name) {
    print(greeting, name)
}

export greet

Names without export remain private. Define a module-level name before code that uses it; this also applies to names referenced from an exported function. Named functions may call themselves recursively.

A complete local module

For a small project, keep the entry point and local module in separate files:

project/
├── main.goblin
└── modules/
    └── greeter.goblin
# modules/greeter.goblin
var greeting = "Hello"

func greet(name) {
    return greeting + ", " + name
}

export greet
# main.goblin
import "./modules/greeter"

print(greeter.greet("world"))

Run the entry point from project/ with goblin run main.goblin. Local import paths are resolved from the importing file, not from the shell's current directory. A local import's final path component becomes its module name, so ./modules/greeter is available as greeter.

A module is loaded once and cached; later imports of the same file reuse the loaded module. Two modules importing each other is a cycle, reported as circular import detected.

The remaining chapters document each built-in module separately.

json

Import json to exchange data with JSON APIs and files. marshal(value, indent=0) produces JSON text; unmarshal(data) parses JSON text (str or Bytes) into Goblin values.

import "json"

var text = json.marshal({"name": "Ada", "scores": [90, 95]})
var user = json.unmarshal(text)
print(user["name"])
print(user["scores"][0])

Objects become dictionaries, arrays become lists, JSON numbers become Int or Float, and JSON null becomes nil. Pass a positive indent to marshal for readable output.

print(json.marshal({"ok": true}, 2))

unmarshal raises ParseError for invalid JSON. Catch it when parsing external input.

Values and formatting

marshal accepts every standard Goblin value that has a JSON equivalent: dictionaries, lists, strings, integers, floats, booleans, and nil. Dictionary keys must be strings and are encoded as JSON object keys; a non-string key raises TypeError. The optional indent argument controls pretty printing; omit it for compact data sent over a network.

var payload = {
    "name": "Ada",
    "tags": ["math", "logic"],
    "enabled": true,
    "note": nil
}
print(json.marshal(payload, 2))

When decoding, inspect the resulting values with ordinary list and dictionary operations. A JSON integer becomes Int while a decimal number becomes Float.

Handling untrusted input

JSON from a file or HTTP response is external input. Keep parsing and validation separate: first catch malformed JSON, then verify the fields your program requires.

try {
    var config = json.unmarshal("{\"port\": 8080}")
    var port = config.get("port", default=8080)
    print(port)
} catch err {
    if err.is(ParseError) {
        print("configuration is not valid JSON")
    } else {
        raise err
    }
}

fs

The fs module reads, writes, and inspects files and directories. Most functions accept a string or Path as their path argument.

import "fs"

fs.write("notes.txt", "remember this")
print(fs.read("notes.txt"))
print(fs.exists("notes.txt"))
fs.append("notes.txt", "\nnext line")
fs.remove("notes.txt")

Use read() and write() for whole-file work. write() replaces a file, while append() adds text and returns the number of bytes written.

FunctionPurpose
open(path) / create(path)Open an existing file or create one
read(path) / write(path, text) / append(path, text)Whole-file text I/O
exists(path)Check whether a path exists
stat(path)Return name, size, and directory information
read_dir(path)Return file-information entries
mkdir(path) / remove(path)Create a directory or remove a path

Files returned by open() or create() should be closed after use. Filesystem operations can raise IOError.

File objects

open(path) returns a read-oriented file object and create(path) returns a file that can be written. File objects expose name, closed, read(size), write(content), stat(), and close(). read() follows the same Reader protocol as an HTTP response body: with no argument it consumes all remaining data, read(size) returns a chunk of up to size bytes, end of file is an empty Bytes, and the result is always Bytes — call .decode() for text. write() accepts str or Bytes and returns the number of bytes written.

Because a File has a write(data) method, it already satisfies the writer stream shape: it can be passed wherever the standard library accepts a writer object, such as exec.Command(stdout=...) or the dest= keyword of csv.write_all, tar.write_all, and gzip.compress.

var file = fs.create("log.txt")
file.write("started\n")
print(file.name)
file.close()

var reader = fs.open("log.txt")
print(reader.read().decode())
print(reader.stat().size)
reader.close()
fs.remove("log.txt")

Always close a file once its work is finished, including after a try/catch block. For one small text file, fs.read() and fs.write() are simpler and avoid managing a file object.

Inspecting directories

stat(path) and read_dir(path) return FileInfo values. Their common fields are name, size, is_dir, mode, and mod_time.

var entries = fs.read_dir(".")
for entry in entries {
    if entry.is_dir {
        print("directory:", entry.name)
    } else {
        print("file:", entry.name, entry.size)
    }
}

mkdir() creates one directory only; it fails when the parent is missing or the path already exists. remove() removes one file or empty directory.

os

The os module provides process and environment information. Use it for configuration supplied by the operating system rather than hard-coding secrets or deployment settings.

import "os"

var port = os.getenv("PORT")
if port == "" {
    port = "8080"
}
print(port)
print(os.getwd())
print(os.hostname())

# argv() is the program command line (index 0 is the invocation name)
for arg in os.argv() {
    print(arg)
}
FunctionPurpose
argv()Return the program command-line arguments as a list of strings
getenv(key, default=nil) / setenv(key, value) / unsetenv(key)Read or change environment values
environ()Return all environment values as a dictionary
getwd() / hostname()Current directory and machine name
getpid() / getppid()Process identifiers
tempdir([dir, pattern]) / tempfile([dir, pattern])Create temporary paths
exit(code=0)End the process

Avoid using exit() inside reusable library code. Environment and temporary-file operations can raise IOError. argv() does not accept arguments and returns a fresh list each call; mutating that list does not change the program arguments.

Command-line arguments

argv() presents the command line from the Goblin program's point of view. Index 0 is its invocation name; remaining elements are the arguments passed after it. With goblin run, the source path is the invocation name — for example goblin run app.goblin foo bar makes argv() return ["app.goblin", "foo", "bar"]. Arguments that look like flags (such as -v or --help) are forwarded only when they appear after the source file. Put the source file first. Leading flags are rejected. For CLI help use goblin run -h or goblin help run (alone, with no source file). Compiled executables from build-exe see the real process argv (the binary path at index 0). In the REPL, argv() is [""] so interactive sessions do not expose the goblin process arguments. Use argv() when a program needs flags or positional inputs from the shell; prefer getenv() for configuration that should not be visible on the command line.

var args = os.argv()
if args.size < 2 {
    print("usage:", args[0], "<file>")
    os.exit(1)
}
print("input:", args[1])

Configuration from the environment

getenv() returns nil for a missing key; pass default= to supply a fallback value (for example os.getenv("PORT", default="8080")), or use environ() when a program needs to inspect the complete environment dictionary.

var env = os.environ()
var mode = env.get("APP_MODE", default="development")
var debug = mode == "development"
print(debug)

setenv() changes only the environment of the current Goblin process and processes it starts. It does not persist after the program exits.

Temporary paths and process identity

tempdir() and tempfile() accept optional directory and pattern arguments in that positional order, and return created paths. They are helpful for generated output and tests. These functions do not accept named arguments.

var directory = os.tempdir("", "goblin-")
var filename = os.tempfile(directory, "data-")
print(directory)
print(filename)

getpid(), getppid(), getuid(), geteuid(), getgid(), getegid(), and getgroups() expose identity information supplied by the host operating system; getpagesize() reports the memory page size. Availability and exact values can vary by platform.

exec

Import exec to configure and execute external commands. Commands are invoked directly: arguments are never parsed by a shell.

import "exec"

var cmd = exec.Command(
    "git",
    ["status", "--short"],
    stdout=exec.CAPTURE,
    stderr=exec.CAPTURE
)

var result = cmd.run()
if result.success {
    print(result.stdout.decode())
}

Command

Command(name, args=[], cwd=unit, env=unit,
        stdin=INHERIT, stdout=INHERIT, stderr=INHERIT)

name and every element of args must be strings. cwd accepts unit, a string, or a Path. An omitted env inherits the current process environment; a dictionary replaces it completely, so env={} starts the command with an empty environment. Environment keys and values must be strings.

The standard-stream policies are:

PolicyMeaning
INHERITUse the corresponding Goblin process stream
DISCARDProvide EOF for stdin or discard output
CAPTURECapture stdout or stderr into the result

stdin also accepts Str or Bytes. CAPTURE is valid only for stdout and stderr. Captured values are Bytes, because command output is not necessarily UTF-8; call decode() when text is expected.

Besides the policies, stdout and stderr accept any writer object — an object with a write(data) method, such as an open fs file. The command's output streams into it as Bytes chunks, so large output never has to be captured in memory:

import "exec"
import "fs"

var log = fs.create("build.log")
exec.Command("make", stdout=log, stderr=log).run()
log.close()

exec never calls the writer's close(); close the target yourself when the command finishes. With start(), chunks may arrive while your program is doing other work, so do not write to the same object from Goblin code until wait() returns.

Executing a command

cmd.run() starts, waits for, and reaps a command. Output behavior comes only from the stream configuration on Command.

var result = exec.Command(
    "gofmt",
    stdin="package main\nfunc main(){}",
    stdout=exec.CAPTURE,
    stderr=exec.CAPTURE
).run()

For explicit asynchronous control, use start() followed by wait():

var cmd = exec.Command("worker", ["--once"])
cmd.start()
print(cmd.pid)
var result = cmd.wait()

A command can be started only once. wait() before start() and a second execution attempt raise ValueError. Repeated calls to wait() return the same cached result. kill() terminates a started command; call wait() afterward to obtain its result. cmd.pid is unit before startup, and cmd.running() reports whether the command has not yet been reaped.

Result

AttributeTypeMeaning
codeIntExit code; a signal may produce -1
successBoolWhether the exit code is zero
stdoutBytes or unitCaptured stdout, if configured
stderrBytes or unitCaptured stderr, if configured

A non-zero exit code is a normal result, not an exception. Failures to start or wait for the command raise an I/O-related error. Inspect code or success and implement any command-specific failure policy in Goblin code.

Shell commands

exec does not interpret pipes, redirects, glob patterns, or shell variables. Pass every argument as a separate list element. If shell syntax is explicitly required, invoke a platform shell yourself, for example exec.Command("sh", ["-c", script]); do not insert untrusted text into such a script.

path

The path module provides a Path value and directory factories. Use Path rather than manually joining strings when code must work with filesystem paths.

import "path"
import "fs"

var config = path.home().join("myapp", "config.json")
print(config)
print(fs.exists(config))

path.cwd() returns the current working directory and path.home() returns the user home directory. path.Path(text) constructs a Path explicitly.

Path values expose operations such as join(), name, parent, suffix, exists(), is_dir(), and read_text() or write_text() when working directly with a path. Use fs when a program prefers functional whole-file operations; use Path when several operations are derived from one base location.

Building derived paths

join() makes a child path without manually inserting separators. name, stem, suffix, parent, parts, and is_absolute describe a path without touching the filesystem.

var source = path.Path("reports/monthly.csv")
print(source.name)   # monthly.csv
print(source.stem)   # monthly
print(source.suffix) # .csv
print(source.parent)

with_name(name) and with_suffix(suffix) create adjusted paths. relative_to() and as_posix are useful when producing portable display strings.

Filesystem operations on Path

Path can directly test exists(), is_file(), is_dir(), and is_symlink(). It can read_text(), write_text(text), read_bytes(), write_bytes(bytes), mkdir(), unlink(), rename(target), iterdir(), and glob(pattern).

var output = path.cwd().join("output.txt")
output.write_text("generated")
if output.exists() {
    print(output.read_text())
}
output.unlink()

These operations can raise IOError. Use fs.read_dir() for simple directory listing or Path.glob() when a pattern is the clearest expression.

time

The time module creates, parses, formats, and measures time values.

import "time"

var started = time.now()
time.sleep(0.1)
print(started.elapsed())
print(started.year)
print(started.format("2006-01-02"))

now() returns the current local time. sleep(seconds) pauses for an integer or float number of seconds. t.elapsed() returns the seconds elapsed since t as a Float.

Time(year, month, day, hour=0, minute=0, second=0, nanosecond=0) constructs a Time from calendar components in local time.

var launch = time.Time(2026, 7, 19, hour=9, minute=30)
print(launch.format("2006-01-02 15:04"))

Use parse(layout, text) to parse formatted text. Layouts use Go reference time formatting, so 2006-01-02 represents a year-month-day format. unix(seconds, nanoseconds=0) creates a time from a Unix timestamp.

var day = time.parse("2006-01-02", "2026-07-19")
print(day.weekday)
print(time.unix(day.unix))

Invalid parsing raises ParseError.

Time fields and formatting

Time values provide year, month, day, hour, minute, second, nanosecond, unix, unix_nano, and weekday fields. format(layout) turns a Time into text using the same Go reference-layout convention as parse().

var now = time.now()
print(now.year, now.month, now.day)
print(now.weekday)
print(now.format("2006-01-02 15:04:05"))

Time values can be compared with the ordinary comparison operators. Use this for expiration and scheduling checks; use elapsed() when the needed result is a duration in seconds.

var deadline = time.unix(2000000000)
if time.now() > deadline {
    print("expired")
}

sleep() blocks the current execution path. It is appropriate for a deliberate delay or simple retry loop, but it is not a substitute for channel-based coordination between spawned functions.

rand

The rand module provides pseudo-random values backed by Go's math/rand package. It is suitable for simulations, games, and reproducible tests, but not for passwords, tokens, keys, or other security-sensitive work.

import "rand"

print(rand.int(10))
print(rand.float())

Module-level functions use a private, automatically seeded generator.

Module API

FunctionDescription
Rand(seed=...)Create an independent generator; seed defaults to an automatic seed.
int(max=nil)Return a non-negative integer, optionally below max.
float()Return a Float in [0.0, 1.0).
perm(n)Return a random permutation of the integers [0, n).
shuffle(list)Rearrange a list in place and return unit.
norm_float()Draw from the standard normal distribution.
exp_float()Draw from the exponential distribution with rate 1.

int() corresponds to Go's Int63. Passing max selects the bounded Int63n behavior; merging those two Go functions is possible because Goblin supports optional arguments. max must be positive.

shuffle accepts a Goblin list instead of Go's length-and-callback pair. The callback exists only to let Go swap values of arbitrary static types; exposing it would leak that implementation constraint into Goblin.

Independent generators

Rand(seed=...) wraps Go's rand.Rand. Two instances created with the same seed produce the same sequence:

var first = rand.Rand(42)
var second = rand.Rand(42)

print(first.int(1000) == second.int(1000)) # true

A Rand provides the same int, float, perm, shuffle, norm_float, and exp_float methods as the module. It is safe to share between spawned Goblin functions. Concurrent scheduling still determines which caller receives each successive value, so concurrent sequences are not reproducible.

Unlike the old random.Generator, construction always requires a seed. This keeps independent generator creation conceptually aligned with Go's rand.New(rand.NewSource(seed)); use the module functions when reproducibility is not required.

Deliberately omitted Go API

  • Source, Source64, and New are replaced by Rand(seed=...). Go's source interfaces are extension points for Go implementations and should not leak into the Goblin value API.
  • Read is omitted because its Go signature fills a caller-owned byte slice. A future byte-stream API should follow Goblin's common reader protocol rather than expose Go buffer mutation.
  • Integer-width variants (Int31, Int63, Uint32, and Uint64) are not separate functions because Goblin has one signed 64-bit Int type.
  • Seed is omitted. Reproducible state belongs to an explicit Rand rather than mutable module-global state.
  • Zipf is omitted from the initial useful subset because it requires a specialized stateful distribution object.

The former random module and its Python-inspired choice, sample, bounded float, parameterized normal, and parameterized exponential functions were removed. They do not correspond to the Go package being wrapped.

math

The math module provides constants and floating-point functions. It is useful when the basic arithmetic operators are not enough.

import "math"

var radius = 3
var area = math.pi * math.pow(radius, 2)
print(area)
print(math.sqrt(81))

Common functions include abs(), ceil(), floor(), round(), trunc(), pow(), sqrt(), log(), exp(), sin(), cos(), tan(), min(), and max(). Constants include pi, e, inf, and nan.

The full trigonometric family is available: the inverses asin(), acos(), atan(), and two-argument atan2(y, x); the hyperbolic functions sinh(), cosh(), tanh() and their inverses asinh(), acosh(), atanh(). Logarithms come in three bases — natural log(), log2(), and log10() — alongside exp() and cbrt().

print(math.floor(3.8))
print(math.hypot(3, 4))
print(math.is_nan(math.nan))

The module accepts integers and floats where a numeric input is expected. Functions that fundamentally produce fractional results return Float.

Rounding and bounds

abs(), ceil(), floor(), round(), and trunc() are useful when converting a calculated value into a display or storage value. min() and max() choose among numeric arguments and are often used to clamp a value.

var requested = 1.8
var whole = math.ceil(requested)
var bounded = math.min(math.max(whole, 1), 4)
print(bounded)

Geometry and special values

pow(), sqrt(), cbrt(), and hypot() cover common geometric calculations. The trigonometric functions use radians. is_nan() and is_inf() help detect special floating-point values before they affect later calculations.

var distance = math.hypot(3, 4)
print(distance) # 5
print(math.sin(math.pi / 2))
print(math.is_inf(math.inf))

Some domain operations can produce nan instead of a conventional value. Check with is_nan() before serializing or displaying the result when inputs may be outside the mathematical domain.

http

The http module makes client requests. Convenience functions return a Response with status_code, header, body, and json() members.

import "http"

var response = http.get("https://example.com")
print(response.status_code)
var text = response.body.read().decode()
response.body.close()

Use response.json() when the response body contains JSON. It consumes the body, so choose either json() or body.read() for a response.

var response = http.get("https://api.example.com/items")
var items = response.json()

post(url, content_type, body), put(), and patch() send a String, Bytes, nil, or readable object as a request body. head(url) and delete(url) cover the remaining common methods without a body. Use http.Client(timeout=seconds) and its request methods when a non-default timeout is needed. Request(method, url, body) creates a custom request for client.do().

HTTP operations can raise NetworkError; JSON response parsing can raise ParseError. Always close a response body when it has not been fully consumed.

Sending JSON

Use json.marshal() to build a request body and set the matching content type. The response body is a stream, so close it after reading text or bytes.

import "http"
import "json"

var payload = json.marshal({"name": "Ada"})
var response = http.post(
    "https://api.example.com/users",
    "application/json",
    payload
)
print(response.status_code)
response.body.close()

Requests, clients, and headers

For custom methods or headers, construct Request(method, url, body=nil), then send it through Client(timeout=seconds). Request.header supports get(), values(), set(), add(), and del().

var client = http.Client(timeout=5)
var request = http.Request("GET", "https://api.example.com/items")
request.header.set("Accept", "application/json")
var response = client.do(request)
print(response.status)
response.body.close()

The module-level functions use a finite default timeout. Treat non-success HTTP status codes as application-level results: inspect status_code before assuming that a response body contains the expected data.

Reader protocol

HTTP response bodies expose read(size=nil), close(), and the read-only closed attribute. With no size, read() consumes all remaining bytes. With a non-negative integer size, it returns at most that many bytes; an empty Bytes value signals end of stream.

Objects supplied as request bodies use the same duck-typed protocol. They must provide a callable read(size) method. Each call must return Bytes, Str, or nil; an empty byte/string value or nil signals end of stream. A callable close() method is optional and is invoked when the HTTP client closes the request body. A request-body reader should therefore look like:

type Reader(chunks) {
    func read(self, size) {
        if self.chunks.size == 0 {
            return Bytes("")
        }
        return self.chunks.pop(0)
    }

    func close(self) {
        self.chunks.clear()
    }
}

The size argument is a requested upper bound. A custom reader may return a smaller chunk, but must not require callers to omit it. fs.File currently has a separate whole-file read() API and cannot be passed directly as an HTTP request body.

UUID

The uuid module creates and validates UUID values using github.com/google/uuid. UUIDs are a distinct Goblin type; converting one to a string produces its canonical lowercase representation.

import "uuid"

var id = uuid.new()
print(id)
print(uuid.is_valid("550e8400-e29b-41d4-a716-446655440000"))

var stable_id = uuid.new(
    version=5,
    namespace=uuid.NAMESPACE_DNS,
    data="example.com",
)

API

FunctionDescription
UUID(value)Constructs a UUID from a UUID, string, or 16-byte Bytes value. Raises ParseError when invalid.
new(version=4, namespace=nil, data=nil)Creates a UUID of version 1, 3, 4, 5, 6, or 7.
is_valid(value)Returns whether a string is a valid UUID representation.

new() defaults to a random version 4 UUID. Versions 3 and 5 require both a UUID namespace and data; data may be a string (encoded as UTF-8) or Bytes. Those arguments are rejected for all other versions. The predefined namespaces are NAMESPACE_DNS, NAMESPACE_URL, NAMESPACE_OID, and NAMESPACE_X500.

UUID values expose these attributes:

AttributeDescription
bytesThe UUID's 16 raw bytes.
urnThe UUID in urn:uuid:... form.
versionThe numeric UUID version.
variantThe UUID variant name.
timeCreation time as a Time, for versions 1, 6, and 7.
clock_sequenceClock sequence, for version 1.
nodeSix node bytes, for versions 1 and 6.

Accessing an attribute that is not defined for the UUID's version raises ValueError. Functions accept both positional and keyword arguments; is_valid() requires a string.

regexp

The regexp module provides reusable regular expressions backed by Go's RE2-based regexp package. Matching is linear in the size of the input. The syntax deliberately excludes backreferences, lookaround, and other backtracking-only features.

import "regexp"

var assignment = regexp.compile("(?P<key>[a-z]+)=(\\d+)")
var match = assignment.find("count=12")
print(match.group("key"))
print(match.group(2))

Only Str patterns, input, and replacements are accepted. Bytes is not implicitly decoded or mixed with text. Compile errors are raised as ParseError, with the Go engine's diagnostic wrapped as context.

Module API

FunctionDescription
compile(pattern)Compiles pattern and returns an immutable, reusable Pattern.
escape(text)Quotes every metacharacter in text, so the result matches text literally.

There are no module-level matching shortcuts. Compile once and use the resulting object, especially in loops or concurrent work.

Use escape whenever part of a pattern comes from data rather than from source code; it is the only safe way to search for text that is not itself a pattern.

var needle = regexp.compile(regexp.escape("a.c"))
print(needle.match("abc"))
print(needle.match("a.c"))

Pattern

Attribute or methodResult
patternThe source text this pattern was compiled from.
group_namesCapture-group names by number, excluding group 0.
match(text, full=false)Reports whether a match exists. With full=true, the entire text must match.
find(text, full=false)Returns the first Match, or nil. With full=true, the entire text must match.
find_all(text, count=-1)Returns non-overlapping Match values.
replace(text, replacement, count=-1)Replaces matches using a template and returns a new string.
split(text, count=-1)Splits around matches and returns strings.

find means leftmost substring search, and match is the same search reduced to a boolean — the name matches path.match, which is also a boolean pattern test. Requiring the whole text to match is an explicit option rather than a separate method. The anchored engine full=true needs is compiled on first use, so patterns that never ask for it pay nothing.

group_names lines up element for element with a Match's groups: entry i is the name of group i + 1, or nil when that group is unnamed.

count means exactly what it means on the built-in str methods, so the two families stay interchangeable: for split it is the number of pieces returned, for replace the number of replacements made, and for find_all the number of matches returned. 0 produces nothing, and any negative value means "no limit".

print(regexp.compile(",\\s*").split("a, b,c", count=2))
print("a, b,c".split(sep=", ", count=2))

Replacement templates use Go regexp expansion syntax: $1 and ${1} name a numbered group, while $name and ${name} name a named group, and $$ is a literal $. A reference to a group the pattern does not have raises ValueError rather than silently expanding to the empty string, which would drop text without a word. A malformed $ that begins no reference at all is kept as literal text, following Go.

Because $1x parses as a reference to a group named 1x, it is rejected; use ${1}x to follow group 1 with literal text.

The initial API does not support callback replacements; keeping replacement deterministic and template-based avoids introducing a second execution and error-propagation model.

Match

Match is an immutable snapshot. It retains the source text and copied match indices, so it remains usable independently of later Pattern operations.

Attribute or methodDescription
sourceThe full text this match was found in.
textText matched by group 0.
start, endHalf-open offsets of group 0, measured in UTF-8 bytes.
groupsNumbered capture groups excluding group 0.
named_groupsDict mapping each capture-group name to its text.
group(key=0)Returns one capture by non-negative number or name.
span(key=0)Returns the [start, end] offsets of one capture.

An optional group that did not participate is represented by nil, preserving the distinction from a participating group that matched an empty string. span returns nil for the same reason. An unknown number or name raises IndexError. Group 0 is available only by number. If a pattern repeats a capture name, name lookup returns the first participating group with that name in numeric order; it returns nil when groups with that name exist but none participated. Numbered lookup remains unambiguous.

named_groups is a Dict, so its iteration order is unspecified; look names up rather than printing it when output must be stable.

Offsets intentionally match Go's regexp indices and Goblin's UTF-8 string storage: they are byte offsets, not character counts. Empty matches are kept according to Go's FindAll rules; an empty match immediately adjacent to a previous match is omitted. Splitting and template expansion inherit Go regexp's empty-match behavior.

Compiled Pattern values contain no Goblin-side lock. Go's regexp.Regexp is safe for concurrent use, and Pattern operations do not mutate it.

url

The url module follows Go's net/url parsing and escaping behavior.

FunctionGo equivalent
parse(raw_url)url.Parse
join_path(base, elements)url.JoinPath
query_escape(s) / query_unescape(s)url.QueryEscape / url.QueryUnescape
path_escape(s) / path_unescape(s)url.PathEscape / url.PathUnescape

parse returns a URL with scheme, host, path, raw_query, fragment, hostname, port, and escaped_path attributes. Its resolve_reference(reference) method mirrors Go's URL.ResolveReference. The reference argument must itself be a value returned by url.parse(). Malformed input raises ParseError.

import "url"

var endpoint = url.parse("https://example.com:8443/api?q=goblin#result")
print(endpoint.scheme)   # https
print(endpoint.hostname) # example.com
print(endpoint.port)     # 8443
print(endpoint.path)     # /api
print(endpoint.raw_query)

var next = endpoint.resolve_reference(url.parse("../status"))
print(Str(next))

join_path(base, elements) accepts a base URL and a list of path elements. It normalizes path separators while preserving the URL's scheme and host:

print(url.join_path("https://example.com/api", ["v1", "items"]))
print(url.query_escape("name=Goblin language"))
print(url.path_escape("folder/item"))

Query escaping is for a query component; path escaping is for one path segment. Neither function builds a complete query string from a dictionary. The corresponding unescape functions raise ParseError for invalid percent escapes.

csv

The csv module follows Go's encoding/csv package and exposes its whole-data operations.

FunctionGo equivalent
read_all(text, ...)Reader.ReadAll
write_all(records, ...)Writer.WriteAll

read_all returns a list of string lists. Keyword arguments configure the corresponding Go Reader fields: comma=",", comment="", fields_per_record=0, lazy_quotes=false, and trim_leading_space=false.

write_all accepts a list of string lists. It supports comma="," and use_crlf=false, corresponding to Go's Writer fields. Parsing errors raise ParseError.

By default write_all returns the CSV text. Passing dest= streams the output into any writer object — an object with a write(data) method, such as an open fs file — instead, and the function returns unit:

import "csv"
import "fs"

var file = fs.create("scores.csv")
csv.write_all([["name", "score"], ["Ada", "10"]], dest=file)
file.close()
import "csv"

var rows = csv.read_all("name,score\nAda,10\nGoblin,12\n")
print(rows[1][0]) # Ada

var output = csv.write_all([
    ["name", "score"],
    ["Ada", "10"],
    ["Goblin", "12"],
])
print(output)

The delimiter arguments must be a single valid character. With fields_per_record=0, the reader infers the field count from the first record; a negative value allows records of varying lengths. An empty comment disables comments. CSV values are always strings: numeric conversion, header handling, and mapping rows into dictionaries remain explicit application work.

base64

The base64 module converts text or bytes to Base64 text and decodes Base64 text back to Bytes. The alphabet (standard or URL-safe) and padding are independent keyword arguments.

import "x/encoding/base64"

var encoded = base64.encode("hello")
print(encoded)
print(base64.decode(encoded).decode())

API

FunctionResultDescription
encode(data, url=false, padding=true)strEncode a str or Bytes value
decode(value, url=false, padding=true)BytesDecode Base64 text

Set url=true for the URL-safe alphabet and padding=false to omit = padding; the two compose freely (JWT-style tokens use both). decode() raises ParseError when the input is malformed, and returns Bytes because Base64 can represent arbitrary binary data; call .decode() on the result only when the decoded bytes are known to contain UTF-8 text.

var token = base64.encode(Bytes([251, 255]), url=true, padding=false)
print(token)
print(base64.decode(token, url=true, padding=false))

Base64 is an encoding, not encryption. Do not use it to conceal passwords, tokens, or other secrets.

base32, ascii85, html, and quotedprintable

These modules adapt Go's value-oriented text encoding packages. They process a complete value and do not expose Go readers or writers.

base32

base32 wraps encoding/base32.

FunctionResult
encode(data, hex=false, padding=true)Encode Bytes or Str
decode(data, hex=false, padding=true)Decode a string to Bytes

Set hex=true to use the extended-hex alphabet and padding=false for the unpadded form; the two keywords compose freely and must match between encoding and decoding. Malformed input raises ParseError.

ascii85

ascii85.encode(data) accepts Bytes or Str and returns encoded text. ascii85.decode(data) accepts encoded text and returns Bytes. Whitespace in encoded input follows Go's encoding/ascii85 rules. Malformed input raises ParseError.

html

html.escape(s) replaces the five characters significant in HTML text with entities. html.unescape(s) resolves named and numeric HTML entities. Both accept and return Str and mirror html.EscapeString and html.UnescapeString; this module does not parse or sanitize HTML documents.

quotedprintable

quotedprintable.encode(data) accepts Bytes or Str and returns encoded text. quotedprintable.decode(data) accepts Bytes or Str and returns decoded Bytes. The module wraps Go's mime/quotedprintable package using an internal buffer so no writer object appears in the Goblin API. Invalid input raises ParseError.

hex

The hex module follows Go's encoding/hex package.

FunctionGo equivalent
encode(data)hex.EncodeToString
decode(s)hex.DecodeString
dump(data)hex.Dump

Encoding accepts Bytes or Str and returns lowercase hexadecimal text. Decoding returns Bytes and raises ParseError for malformed input.

import "x/encoding/hex"

var encoded = hex.encode(Bytes("Goblin"))
print(encoded)                    # 476f626c696e
print(hex.decode(encoded).decode()) # Goblin
print(hex.dump(Bytes("Goblin")))

Use encode() for compact machine-readable text. dump() instead produces a multi-line, offset-labelled representation intended for diagnostics. An odd number of hexadecimal digits or a non-hexadecimal character makes decode() raise ParseError; it never returns a partial result.

pem

The pem module encodes and decodes PEM blocks using Go's encoding/pem package.

import "x/encoding/pem"

var block = pem.Block("MESSAGE", "Goblin", {"Source": "example"})
var encoded = block.encode()
var result = pem.decode(encoded)
print(result[0].label)
print(result[1])

Block

Block(label, data, headers={}) constructs a block. data accepts str or Bytes and header keys and values must be strings.

MemberTypeDescription
labelstrPEM block label such as CERTIFICATE
dataBytesDecoded block contents
headersdictOptional PEM headers
encode()BytesEncode the block, including delimiters

The member is named label, rather than Go's Block.Type, because type is a Goblin language keyword.

decode(data) returns [block, rest]. block is nil when no PEM block was found; rest contains all bytes after the decoded block, allowing repeated decoding of concatenated input.

mime

The mime module maps filename extensions and MIME types. It is useful when constructing HTTP Content-Type headers or categorizing uploaded files.

import "x/mime"

print(mime.type_by_extension(".json"))
print(mime.extensions_by_type("application/json"))

type_by_extension(extension) returns a string, or nil when the extension is unknown. Include the leading dot in the extension.

extensions_by_type(type) returns a list of known extensions. It can raise ParseError when the supplied MIME type is invalid.

Using MIME information with files and HTTP

Pass a suffix, including its leading dot, to type_by_extension(). The returned type may include a charset parameter and is nil when no mapping is known.

var filename = "report.json"
var content_type = mime.type_by_extension(".json")
if !content_type {
    content_type = "application/octet-stream"
}
print(content_type)

extensions_by_type() is useful when a program accepts a declared content type and needs to show or validate the associated filename suffixes.

var image_extensions = mime.extensions_by_type("image/png")
print(image_extensions)

MIME lookup only identifies a probable media type. Do not use a filename extension alone as a security check for untrusted content.

sha256 and sha512

The sha256 and sha512 modules expose Go's fixed-size SHA-2 functions. Every function accepts Bytes or Str. sum functions return raw digest Bytes; the corresponding hex functions return lowercase hexadecimal text.

Goblin functionGo equivalent
sha256.sum(data) / sha256.hex(data)sha256.Sum256
sha256.sum224(data)sha256.Sum224
sha256.hex224(data)sha256.Sum224, as text
sha512.sum(data) / sha512.hex(data)sha512.Sum512
sha512.sum384(data)sha512.Sum384
sha512.hex384(data)sha512.Sum384, as text
sha512.sum224(data) / sha512.hex224(data)sha512.Sum512_224
sha512.sum256(data) / sha512.hex256(data)sha512.Sum512_256

Use a module's hex variant for hexadecimal text or base64.encode for Base64 text. SHA-2 hashes do not authenticate data or securely store passwords by themselves.

import "x/crypto/sha256"
import "x/crypto/sha512"

var digest = sha256.sum("Goblin")
print(digest.size)       # 32
print(sha256.hex("Goblin"))
print(sha512.sum384("Goblin").size) # 48

Use raw Bytes when a binary format has a fixed digest field, and use a hex function when a textual protocol explicitly expects hexadecimal. For message authentication or password storage, use a purpose-built construction rather than a bare hash; Goblin does not currently provide one in its standard library.

md5, sha1, crc32, and adler32

These modules calculate complete, in-memory digests and checksums. Every function accepts either Bytes or Str.

MD5 and SHA-1

md5.sum(data) and sha1.sum(data) return raw digest Bytes. md5.hex(data) and sha1.hex(data) return lowercase hexadecimal text.

MD5 and SHA-1 are provided for compatibility with existing formats and protocols. They are cryptographically broken and must not be used for password storage, signatures, certificates, or other security decisions.

import "x/crypto/md5"
import "x/crypto/sha1"

print(md5.hex("Goblin"))
print(sha1.sum(Bytes("Goblin")).size) # 20 bytes

CRC-32

crc32.checksum(data, polynomial=crc32.IEEE) returns an unsigned 32-bit checksum as a Goblin int. The module exports the IEEE, CASTAGNOLI, and KOOPMAN polynomial constants.

Adler-32

adler32.checksum(data) returns the Adler-32 checksum as a Goblin int.

CRC-32 and Adler-32 detect accidental corruption; they do not authenticate data and are not cryptographic hashes.

import "x/hash/crc32"
import "x/hash/adler32"

print(crc32.checksum("Goblin"))
print(crc32.checksum("Goblin", polynomial=crc32.CASTAGNOLI))
print(adler32.checksum("Goblin"))

The returned checksum integers are non-negative. Choose the CRC polynomial as part of the surrounding file or wire-format contract; values calculated with different polynomials are not comparable.

hmac, crc64, and fnv

These modules wrap Go's crypto/hmac, hash/crc64, and hash/fnv packages. They accept either str or Bytes input. Digest values are returned as Bytes by sum() and lowercase text by hex().

import "x/crypto/hmac"
import "x/hash/crc64"
import "x/hash/fnv"

var signature = hmac.sum("secret", "message")
print(hmac.verify(signature, "secret", "message"))
print(crc64.hex("123456789"))
print(fnv.hex("hello"))

HMAC

FunctionReturnsDescription
sum(key, data, algorithm="sha256")BytesCompute an HMAC digest
hex(key, data, algorithm="sha256")strCompute an HMAC as hexadecimal text
verify(signature, key, data, algorithm="sha256")boolCompare a raw signature in constant time

Supported algorithms are sha256, sha512, sha1, and md5. SHA-1 and MD5 are provided only for compatibility with existing protocols. verify() expects the raw Bytes returned by sum(), not hexadecimal text.

CRC-64

crc64.sum(data, polynomial=crc64.ECMA) and crc64.hex(...) support the crc64.ECMA and crc64.ISO polynomial constants. Unlike crc32.checksum(), CRC-64 returns Bytes because a Goblin int is signed and cannot represent every unsigned 64-bit checksum.

FNV

fnv.sum(data, variant=fnv.FNV_64A) and fnv.hex(...) support the variant constants FNV_32, FNV_32A, FNV_64, FNV_64A, FNV_128, and FNV_128A. FNV is a non-cryptographic hash; do not use it for passwords, signatures, or integrity checks against an attacker.

gzip, zlib, flate, and bzip2

The gzip, zlib, and flate modules adapt Go's corresponding compress packages to whole Goblin values.

All three modules expose compress(data, level=DEFAULT_COMPRESSION, dest=unit) and decompress(data). Input may be Bytes or Str; output is always Bytes. Malformed compressed input raises ParseError.

Compression-level constants mirror compress/flate: NO_COMPRESSION, BEST_SPEED, BEST_COMPRESSION, DEFAULT_COMPRESSION, and HUFFMAN_ONLY.

By default compress returns the compressed Bytes. Passing dest= streams the compressed output into any writer object — an object with a write(data) method, such as an open fs file — and the function returns unit:

import "x/compress/gzip"
import "fs"

var report = "line 1\nline 2\n"
var file = fs.create("report.txt.gz")
gzip.compress(report, dest=file)
file.close()

The bzip2 module exposes only decompress(data), returning Bytes. This mirrors Go's compress/bzip2, which provides a reader but no compressor.

import "x/compress/gzip"
import "x/compress/zlib"

var source = "Goblin Goblin Goblin"
var gz = gzip.compress(source, level=gzip.BEST_SPEED)
print(gzip.decompress(gz).decode())

var zl = zlib.compress(Bytes(source))
print(zlib.decompress(zl).decode())

The format used for decompression must match the format used for compression; gzip, zlib, and raw flate data are not interchangeable. An unsupported level raises ValueError, while corrupt or truncated compressed input raises ParseError. These APIs buffer the complete input (and, without dest=, the complete output), so they are best suited to bounded values rather than very large files or network streams.

lzw

The lzw module wraps Go's compress/lzw package as whole-value operations. It deliberately does not expose Go readers and writers.

import "x/compress/lzw"

var compressed = lzw.compress("Goblin data")
print(lzw.decompress(compressed))
FunctionReturnsDescription
compress(data, order=lzw.LSB, lit_width=8, dest=unit)Bytes or unitCompress str or Bytes data
decompress(data, order=lzw.LSB, lit_width=8)BytesDecompress a complete LZW value

order is the lzw.LSB or lzw.MSB constant; lit_width must be from 2 through 8. Both options must match the format being read. Invalid compressed input raises ParseError.

Passing dest= streams the compressed output into any writer object — an object with a write(data) method, such as an open fs file — and compress then returns unit.

tar and zip

The tar and zip modules adapt Go's archive/tar and archive/zip packages to complete in-memory archives.

Both modules provide write_all(files) and read_all(data). files is a dictionary whose string keys are archive paths and whose values are Bytes or Str. write_all returns archive Bytes; read_all returns a dictionary of file names to Bytes. Directory and other non-regular entries are skipped when reading.

write_all also accepts dest=: the archive then streams into any writer object — an object with a write(data) method, such as an open fs file — and the function returns unit instead of Bytes:

import "x/archive/tar"
import "fs"

var file = fs.create("backup.tar")
tar.write_all({"notes.txt": "remember"}, dest=file)
file.close()

zip.write_all accepts method=zip.DEFLATE and also supports zip.STORE. Malformed archives raise ParseError.

import "x/archive/zip"

var archive = zip.write_all({
    "README.txt": "Goblin archive",
    "data/raw.bin": Bytes("abc"),
})
var files = zip.read_all(archive)
print(files["README.txt"].decode())

Use method=zip.STORE when entries are already compressed or must be stored verbatim; the default zip.DEFLATE generally produces smaller archives. tar.write_all(files) has the same dictionary input but no compression-method argument.

Archive paths are taken from the dictionary keys. Validate untrusted names before writing returned entries to disk: read_all() keeps archive names and does not choose a safe extraction directory for the application. Duplicate entry names collapse to one dictionary key when reading.

These whole-archive operations correspond to iterating Go Reader and Writer entries. Entry contents are still assembled in memory even with dest=; a future streaming reader protocol can add incremental access without changing the archive format or these convenience operations.

mail

The mail module wraps the address parsing portion of Go's net/mail package. It deliberately omits message parsing: Go's message API is stream-shaped and a larger Goblin mail-message abstraction has not been designed.

MemberDescription
Address(name, address)Construct an address value
parse_address(s)Parse one RFC 5322-style address
parse_address_list(s)Parse a comma-separated address list into a List

Malformed input raises ParseError.

An Address exposes read-only name and address attributes. Converting it to text produces Go's correctly quoted and encoded mailbox representation. Its constructor attribute is the same callable as mail.Address.

import "x/net/mail"

var recipient = mail.parse_address("Goblin <goblin@example.com>")
print(recipient.name)
print(recipient.address)
print(Str(recipient)) # Goblin <goblin@example.com>

Address(name, address) does not send mail or validate that the destination exists. It constructs a correctly formatted mailbox value. Use an empty name for a bare address:

var sender = mail.Address("", "sender@example.com")
var recipients = mail.parse_address_list(
    "Ada <ada@example.com>, goblin@example.com"
)
for recipient in recipients {
    print(recipient.name, recipient.address)
}

Address parsing accepts the syntax supported by Go's net/mail parser, including quoted display names and encoded words. Invalid mailbox syntax raises ParseError. The module intentionally provides address values only; composing, parsing, or transmitting complete messages is outside its current API.

netip

The netip module wraps Go's immutable net/netip address and prefix values. It performs parsing and address calculations without opening network sockets.

import "x/net/netip"

var addr = netip.Addr("192.168.1.10")
var network = netip.Prefix("192.168.1.5/24")
print(addr.is_private)
print(network.masked())
print(network.contains(addr))

Addr

Construct an address with Addr(text). Its members are bits, bytes, zone, is4, is6, is_loopback, is_multicast, is_private, is_unspecified, is_link_local_unicast, and is_link_local_multicast. Methods next(), prev(), and unmap() return new Addr values.

Addr values compare in Go's canonical address order. Moving beyond the first or last address with prev() or next() raises ValueError.

Prefix

Construct a prefix with Prefix(text). It exposes addr, bits, and is_single_ip, plus these methods:

MethodReturnsDescription
contains(addr)boolReport whether the address lies in the prefix
overlaps(prefix)boolReport whether two prefixes overlap
masked()PrefixReturn the canonical network prefix

utf8 and unicode

The utf8 module works with UTF-8 byte sequences, while unicode classifies and maps one Unicode character at a time.

UTF-8

FunctionReturnsDescription
valid(data)boolCheck whether str or Bytes contains valid UTF-8
rune_count(data)intCount decoded Unicode code points
encode(codepoint)BytesEncode an integer Unicode code point
decode(data)listDecode the first code point as [codepoint, byte_count]

decode() raises ValueError for empty input and ParseError when the first byte sequence is invalid.

Unicode characters

The predicates is_letter, is_digit, is_number, is_space, is_upper, is_lower, and is_control accept a string containing exactly one Unicode character. The mapping functions to_upper, to_lower, and to_title return one mapped character.

import "x/unicode/utf8"
import "x/unicode"

print(utf8.rune_count("Goblin 👺"))
print(unicode.is_letter("界"))
print(unicode.to_upper("é"))

Extending Goblin with Go

Goblin is implemented in Go, and its runtime values are Go values. This makes the standard library extension model straightforward: write Go code that implements the runtime contracts, expose it as a module or built-in function, then make both Goblin execution backends aware of it.

This chapter is for contributors extending the Goblin repository, not for ordinary Goblin programs. The extension code imports the repository packages:

import (
    "github.com/aisk/goblin/object"
)

The runtime boundary

Every Goblin value implements object.Object. Integer, Float, String, List, Dict, module values, functions, and user-defined Goblin types all appear to the Go runtime through this interface. It supplies conversion, comparison, operators, iteration, indexing, and attribute access.

This single interface is why a Go extension can participate naturally in Goblin expressions. A custom value can decide how it prints, whether it is truthy, what an attribute lookup returns, and which operators are valid. Custom object types explains the contract in detail.

Adding a function or module

A Go-callable Goblin function has this shape:

func(args object.CallArgs) (object.Object, error)

Place related functions in an extension package and return them from an object.Module:

func ExecuteExample() (object.Object, error) {
    return &object.Module{
        Members: map[string]object.Object{
            "greet": &object.Function{Name: "greet", Fn: greet},
        },
    }, nil
}

For a new built-in module, register its executor in both interpreter/imports.go and transpiler/transpiler.go. The interpreter registry makes import "example" work with goblin run. The transpiler knownModules table ensures goblin build-exe imports and initializes the same module. Keeping both registrations in sync is essential for behavior parity.

Add a focused Go test for the extension and a Goblin example when its user visible behavior needs end-to-end coverage.

Choosing an extension shape

Use a plain object.Function for a stateless operation such as a conversion or utility. Use object.Module for a named collection of functions and constants. Use a custom object.Object type when the feature has its own state, methods, or protocol behavior, such as a Path, HTTP response, file, or time value.

The next chapters show custom values and safe argument parsing.

Custom object types

Every runtime value in Goblin is a Go implementation of object.Object. The interface is the contract between a value and the interpreter or transpiler. It includes these groups of methods:

GroupObject methods
Display and conversionToString(), ToBool()
Comparison and operatorsEquals(), Compare(), Add(), Minus(), Multiply(), Divide(), Modulo(), Not()
Reflected operatorsRAdd(), RMinus(), RMultiply(), RDivide(), RModulo()
Collection protocolsIter(), Index()
MembersGetAttr(), Attributes(), SetIndex(), SetAttr()
IdentityTypeName()

The reflected operators are required, but most types have nothing to say through them: embed object.NoReflectedOps and they all answer "not handled".

Types should also implement String() string, satisfying fmt.Stringer. It is not part of the interface, but diagnostics and formatting use fmt.Stringer and fall back to TypeName() when String is not available. ToString is the failing counterpart that may run a user-defined __str.

Assignment is part of the interface too: SetIndex and SetAttr return a bool saying whether the value accepts that form of assignment at all. A value that accepts neither embeds object.NoAssignment and says nothing.

Start with a Go struct

This excerpt shows the state, conversion, and member portion of a Counter value. A complete implementation must also provide every remaining object.Object method listed above. Unsupported operators should return the standard TypeError rather than silently accepting an operation.

type Counter struct {
    object.NoReflectedOps // nothing completes an operation from Counter's right
    object.NoAssignment   // counter[i] = x and counter.f = x are not accepted
    Value int64
}

func (c *Counter) TypeName() string { return "Counter" }
func (c *Counter) String() string { return fmt.Sprintf("Counter(%d)", c.Value) }
func (c *Counter) ToString() (string, error) { return c.String(), nil }
func (c *Counter) ToBool() (bool, error) { return c.Value != 0, nil }

func (c *Counter) GetAttr(name string) (object.Object, error) {
    switch name {
    case "value":
        return object.Integer(c.Value), nil
    case "increment":
        return &object.Function{Name: "increment", Fn: c.increment}, nil
    case "attributes":
        return object.AttributesFunction(c), nil
    default:
        return nil, object.NewAttributeError("Counter has no attribute '%s'", name)
    }
}

func (c *Counter) Attributes() []string {
    return []string{"attributes", "increment", "value"}
}

func (c *Counter) increment(args object.CallArgs) (object.Object, error) {
    if err := object.RequireNoKeyword("increment", args); err != nil {
        return nil, err
    }
    if len(args.Positional) != 0 {
        return nil, object.NewTypeError("increment() takes no arguments")
    }
    c.Value++
    return object.Integer(c.Value), nil
}

The receiver-bound object.Function is the key pattern: Goblin evaluates counter.increment() by looking up increment and then calling the returned function. It can safely mutate the Go receiver.

For example, a Counter that does not support addition should implement Add by returning object.NewTypeError. Apply the same principle to the other unsupported protocol methods.

func (c *Counter) Add(other object.Object) (object.Object, error) {
    return nil, object.NewTypeError("cannot add Counter and %s", other.TypeName())
}

Define protocol behavior deliberately

Return a useful result for supported operations and a TypeError for unsupported ones. For example, a Vector can implement Add and Compare while a Counter may only need display, truthiness, and members.

func (v Vector) Add(other object.Object) (object.Object, error) {
    right, ok := other.(Vector)
    if !ok {
        return nil, object.NewTypeError("cannot add Vector and %s", other.TypeName())
    }
    return Vector{X: v.X + right.X, Y: v.Y + right.Y}, nil
}

Compare returns a negative value, zero, or a positive value. Iter returns a slice of object.Object values. Index must verify that its index is an object.Integer and return IndexError for an invalid position.

Name types in messages with TypeName

Use value.TypeName(), not %T, when an error mentions the type of a value. A Go type name is an implementation detail that differs between the two backends — a user-defined Point is *interpreter.instance under goblin run and *main.Point in a compiled program — so %T makes the same program report different messages depending on how it was executed. TypeName returns the Goblin-level name, which is why every type declares it:

func (v Vector) TypeName() string { return "Vector" }

Embedding is not enough here: a type that embeds another for its defaults inherits that type's name too, which is exactly the wrong answer.

Operators dispatch through package-level helpers

The operators do not call Equals, Compare, Add and friends on the left operand directly: both backends go through object.Equals(a, b), object.Compare(a, b), object.Add(a, b), object.Minus(a, b), object.Multiply(a, b) and object.Divide(a, b), and object.Modulo(a, b), which also give the right operand a chance to answer. A custom type therefore only has to recognize the types it knows, and its Compare is reached even when it appears on the right of 1 < value. Report an unfit pair with object.NewTypeError so the reflected attempt is made; any other error propagates as-is. Equals follows the same rule with its own error return: a TypeError from it means "not this type" and leaves == total, while any other error fails the comparison.

Arithmetic reaches the right operand through RAdd, RMinus, RMultiply, RDivide and RModulo, the Go side of __radd and friends. Their argument is the LEFT operand — RMinus(left) computes left - receiver — and the bool they return reports whether the receiver handled this operand at all; returning false leaves the left operand's error in place. Embed object.NoReflectedOps and override only the operator a type completes from the right, the way object.String and object.List override RMultiply to make 3 * "ab" work:

type Vector struct {
    object.NoReflectedOps
    X, Y float64
}

// `2 * v` — scaling reads the same in either operand order.
func (v Vector) RMultiply(left object.Object) (object.Object, bool, error) {
    n, ok := left.(object.Integer)
    if !ok {
        return nil, false, nil
    }
    return Vector{X: v.X * float64(n), Y: v.Y * float64(n)}, true, nil
}

For a complete reference implementation, read the existing runtime types in object/, especially path.go, list.go, dict.go, bytes.go, and chan.go. They show how to report errors consistently and how to expose methods through GetAttr.

Expose a constructor with stable type identity

Most custom values need a constructor added to a module or to the built-ins map. Use object.NewNativeConstructor so the exported callable and every instance share one stable identity, matching Goblin-defined types:

var CounterType = object.NewNativeConstructor(
	"Counter",
	func(args object.CallArgs) (object.Object, error) {
		p := object.NewArgParser("Counter", args)
		start := p.IntOr("start", 0)
		if err := p.Finish(); err != nil {
			return nil, err
		}
		return &Counter{Value: int64(start)}, nil
	},
)

Place CounterType.Function in the module members map. The value's GetAttr and Attributes methods must delegate the constructor member to the same helper:

func (c *Counter) GetAttr(name string) (object.Object, error) {
	if value, ok := CounterType.Attribute(name); ok {
		return value, nil
	}
	switch name {
	case "value":
		return object.Integer(c.Value), nil
	case "increment":
		return &object.Function{Name: "increment", Fn: c.increment}, nil
	case "attributes":
		return object.AttributesFunction(c), nil
	default:
		return nil, object.NewAttributeError("Counter has no attribute '%s'", name)
	}
}

func (c *Counter) Attributes() []string {
	return CounterType.Attributes("attributes", "increment", "value")
}

Goblin can then use the same constructor identity check for native, built-in, and source-defined values:

var counter = module.Counter(start=10)
print(counter.constructor == module.Counter) # true

Create the helper once at package scope. Constructing it during every module load would give the same native type multiple identities. See Functions and arguments for the argument parser used by the constructor.

Functions and arguments

A Go function visible to Goblin is an object.Function. Its Fn callback receives object.CallArgs and returns either a runtime value or an error.

var Greet = &object.Function{
    Name: "greet",
    Fn: greet,
}

func greet(args object.CallArgs) (object.Object, error) {
    p := object.NewArgParser("greet", args)
    name := p.Str("name")
    excited := p.BoolOr("excited", false)
    if err := p.Finish(); err != nil {
        return nil, err
    }
    suffix := "."
    if excited {
        suffix = "!"
    }
    return object.String("Hello, " + string(name) + suffix), nil
}

This accepts both greet("Ada") and greet(name="Ada", excited=true). The order of parser calls defines the positional argument order. Finish is mandatory: it reports unconsumed positional arguments and unexpected keyword arguments.

ArgParser accessors

NewArgParser accumulates the first argument error, letting the function extract all of its inputs before checking once at Finish.

AccessorMeaning
Any(name) / AnyOr(name, default)Required or optional arbitrary Object
Int, Float, Str, BoolRequired typed value
IntOr, FloatOr, StrOr, BoolOrOptional typed value with a default
Number / NumberOrInteger or Float
Float64Numeric value converted to Go float64
FuncA Goblin function
OptionalAnyValue plus whether it was supplied
RestAll remaining positional values

Use OptionalAny when omitted and explicitly passing nil have different meanings. Use Rest for an open-ended positional tail.

func sum(args object.CallArgs) (object.Object, error) {
    p := object.NewArgParser("sum", args)
    values := p.Rest()
    if err := p.Finish(); err != nil {
        return nil, err
    }
    var total int64
    for _, value := range values {
        n, ok := value.(object.Integer)
        if !ok {
            return nil, object.NewTypeError("sum() values must be int")
        }
        total += int64(n)
    }
    return object.Integer(total), nil
}

Other binding helpers

For a function that permits positional arguments only, call object.RequireNoKeyword before checking the positional count. This is useful for small no-options methods.

BindArguments is useful when an extension needs a declared parameter list plus varargs or keyword captures. It binds positional and named values, detects duplicates, and returns a map of parameter names to Object values.

bound, err := object.BindArguments(
    "inspect",
    []string{"name"},
    "rest",
    "options",
    args,
)
if err != nil {
    return nil, err
}
name := bound["name"].(object.String)
rest := bound["rest"].(*object.List)
options := bound["options"].(*object.Dict)

Always return object.NewTypeError or another runtime error constructor for user-facing failures. This preserves Goblin error handling and produces useful tracebacks in both the interpreter and compiled executable.