Skip to content

HilthonTT/LuaScript

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

44 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

luascript

A Lua-flavored language with a stack-based virtual machine and Luau-style gradual types, written in Go.

The surface syntax tracks Lua 5.4 as closely as possible — the same chunks, the same scoping rules, the same metatables, coroutines, and standard library shape. Optional type annotations on top, à la Luau, check at compile time and erase before bytecode so the runtime is unchanged.

The implementation is a clean-room rewrite focused on being readable end-to-end: lex → parse → typecheck → bytecode → stack VM. No LLVM, no JIT, no surprises.

Status

  • Lexer — Lua 5.4 tokens, long-bracket strings/comments, hex/exponent numbers, --!strict / --!nonstrict / --!nocheck mode directives.
  • Parser — full Lua 5.4 grammar including goto/labels, attributes (<const>, <close>), method-call sugar, numeric and generic for, plus Luau type-syntax (annotations, type aliases, type assertions, optionals, unions, function types, structural table types).
  • Type checker — gradual: untyped code is implicitly any; annotations opt in. Primitives, function types, optionals, unions, type aliases (including structural table shapes), type assertions. Stdlib has hand-written signatures so math.sqrt(true) is a compile error.
  • Bytecode — stack-based with closure upvalues, vararg passing, generic-for iteration, and a one-time scan that fills NumLocals at runtime where the generator left it blank. Types are erased before this stage — the VM never sees them.
  • VM — closures, metatables, coroutines (via goroutines + channels), pcall/error unwinding.
  • Standard libraryprint/tostring/tonumber, ipairs/pairs/next, pcall/assert/error, raw and metatable helpers, plus math, string (full Lua pattern surface: find/match/gmatch/gsub), table, io.write/read, coroutine, and package/require. __tostring is honoured by tostring, print, io.write, error, and the REPL.
  • REPL — readline-driven, history-backed, with continuation prompts for incomplete input. Top-level local declarations persist across REPL chunks (a deliberate convenience deviation from lua). Type-check errors are surfaced with a distinct type-error: prefix.

Quick start

The main package lives in ./cmd, so run the interpreter with go run ./cmd:

# Run the REPL
go run ./cmd

# Run a script
go run ./cmd examples/05_types.lsc

# Force the REPL even when a script is supplied
go run ./cmd -i examples/05_types.lsc

# Print version
go run ./cmd -v

Build a binary:

go build -o.lsc ./cmd
..lsc examples/01_basics.lsc

Bundling a script into a standalone .exe

.lsc build` produces a single executable that contains both the interpreter and your script — drop it on a machine that doesn't have.lsc installed and double-click it.

# Build.lsc first, then have it bundle your script:
go build -o.lsc ./cmd
..lsc build -o hello.exe examples/01_basics.lsc
./hello.exe                # runs the embedded script
Flag Effect
-o PATH Output path for the bundled binary (required).

Mechanics: the script is appended to a copy of the.lsc binary along with a 18-byte magic trailer. On startup the bundled .exe inspects its own tail, detects the trailer, and runs the embedded script in parser.NormalMode with the same VM and native modules the interpreter uses. Syntax is checked at bundle time, so you can't ship a broken .exe by accident.

Limitations (v1):

  • The bundled binary matches the host platform — no cross-compilation flag yet.
  • Bundled scripts don't see os.Args.
  • Antivirus heuristics occasionally flag self-modifying-style .exes; code-signing fixes it. This is the same trade-off PyInstaller and Bun's --compile have.

Bonsai mode

For a break from the language work, .lsc` ships with a small ASCII-bonsai grower. It is unrelated to the Lua runtime — just a fun side mode.

# Grow a tree in the alt-screen (press q or Ctrl+C to leave)
..lsc -bonsai

# Print a single tree to stdout instead
..lsc -bonsai -bonsai-print

# Animate growth step-by-step
..lsc -bonsai -bonsai-live

# Reproducible tree from a seed
..lsc -bonsai -seed 42

# Attach a message next to the tree
..lsc -bonsai -bonsai-msg "hello, world"
Flag Effect
-bonsai Grow an ASCII bonsai tree and exit.
-seed N RNG seed for reproducible trees (0 = random).
-bonsai-print Print the tree to stdout instead of staying in the alt-screen.
-bonsai-live Animate growth step-by-step.
-bonsai-msg S Attach a message next to the tree.

Examples

A walk-through set lives in examples/. Most are runnable straight from the repo root with go run ./cmd examples/<file>:

File What it shows
01_basics.lsc variables, control flow, primitive values, logical operators
02_functions.lsc recursion, closures and upvalues, multi-return, higher-order functions
03_tables_and_metatables.lsc records, arrays, methods, operator overloading via __add, __index
04_coroutines.lsc coroutine.create / resume / yield / wrap
05_types.lsc the full Luau-style type surface — primitives, optionals, unions, function types, type aliases, type assertions
06_strict_mode.lsc --!strict enforcement and what it rejects
07_modules.lsc require, package.path, package.loaded, searchpath — imports mathx.lsc next to it
08_stdlib.lsc a bundled-library set loaded via .lsc_LIB — flat modules, dotted submodules, package init files
09_native_module.lsc importing a host-provided native module (native/db)
10_os_module.lsc importing a host-provided native module (native/os)
11_compounds.lsc compound assignment operators (x op= e)
12_math_module.lsc the math native module
13_json_module.lsc the json native module
14_window.lsc the window GUI module (Fyne backend; -tags.lsc_no_window to opt out)
15_io.lsc the full Lua-5.4 io library (file handles, :read/:write/:lines/:seek)
16_bit_utf8.lsc the bit32 and utf8 native modules
17_os_full.lsc the expanded os parity surface (date, time, clock, execute, rename, tmpname, setlocale)
18_patterns.lsc full Lua-pattern surface (find/match/gmatch/gsub with %a %d %w classes, () captures, %b() balanced, %f[set] frontier)

Running the module examples

require resolves a module name against package.path. The two entry kinds that matter for these examples, searched in this order:

  1. The directory of the script being run — added automatically. So a module sitting next to your script is always found, no matter which directory you launched from. This is why 07_modules.lsc just works:

    go run ./cmd examples/07_modules.lsc     # mathx.lsc is found next to it
  2. .lsc_LIB — a bundled-library root, read once at startup. It is not on the path unless you set it. 08_stdlib.lsc is the demo for exactly this: its modules live under examples/stdlib/ (not next to the script), so it needs .lsc_LIB` pointed there. Run it from the repo root:

    # bash
    .lsc_LIB=./examples/stdlib go run ./cmd examples/08_stdlib.lsc
    # PowerShell
    $env.lsc_LIB="./examples/stdlib"; go run ./cmd examples/08_stdlib.lsc
    # cmd.exe
    set.lsc_LIB=./examples/stdlib && go run ./cmd examples/08_stdlib.lsc

    .lsc_LIB is resolved relative to your current working directory — if you run from somewhere other than the repo root, adjust the path accordingly (e.g. ../examples/stdlib from inside cmd/).

Between the two, the plain cwd-relative entries (./?.lsc, ./src/?.lsc, …) are searched as well, so a module under your working directory is still found even when it sits nowhere near the script.

The native-module examples (09, 10, 12, 13) pull their modules from the host via package.preload, so they need neither a path entry nor .lsc_LIB.

A taste, in case you don't want to open files:

-- factorial
local function fact(n)
    if n <= 1 then return 1 end
    return n * fact(n - 1)
end
print(fact(10))   -- 3628800

-- closures + upvalues
local function counter()
    local n = 0
    return function()
        n = n + 1
        return n
    end
end
local next = counter()
print(next(), next(), next())   -- 1   2   3

-- coroutines
local co = coroutine.create(function()
    for i = 1, 3 do coroutine.yield(i) end
end)
print(coroutine.resume(co))   -- true 1
print(coroutine.resume(co))   -- true 2

-- types
type Point = { x: number, y: number }

local function dist(p: Point): number
    return math.sqrt(p.x * p.x + p.y * p.y)
end

print(dist({ x = 3, y = 4 }))   -- 5.0

Type system

LuaScript's type system is gradual in the Luau sense: annotations are optional, untyped code is treated as any, and any flows into and out of any typed slot.

-- Annotations on locals, parameters, returns. Untyped slots stay any.
local count: number = 42
local name: string = "Ada"
local maybe: string? = nil           -- T?  ≡  T | nil
local id: number | string = "user-7" -- unions

-- Function types — params, returns, multi-return, varargs.
local function add(a: number, b: number): number
    return a + b
end

local function pair(x: number): (number, number)
    return x, x * 2
end

-- Type aliases — including structural table shapes.
type Point = { x: number, y: number }
type Callback = (number) -> string
type Numbers = { number }            -- array shorthand for {[number]: number}

local origin: Point = { x = 0, y = 0 }

-- Type assertions: programmer-controlled cast. Runtime is a no-op.
local raw: any = 7
local n: number = raw :: number

Mode directives

A leading --!strict, --!nonstrict, or --!nocheck on the first line of a file controls how strictly that file is checked.

Directive Effect
(none) Default. Gradual checking.
--!strict Implicit-any parameters become errors.
--!nonstrict Same as the default. Useful for explicitness.
--!nocheck Skip the type pass for this file entirely.

Not in v1 (deliberately)

  • Generics (function f<T>(x: T): T)
  • Intersection types (A & B)
  • Type refinements (narrowing inside if type(x) == "string")
  • String-singleton types ("foo" | "bar")
  • Cross-module type checking — require() returns any
  • Recursive type aliases (the parser accepts them; the resolver doesn't)

These are explicitly named in error messages where relevant, so users hit a clear wall instead of silent miscompiles.

REPL

Launch with go run ./cmd (no arguments). Built-in commands:

Command Effect
help print the help screen
exit, quit leave the REPL
reset rebuild the VM (clears all globals and user state)
clear clear the screen

Key bindings: Ctrl+C cancels the current input, Ctrl+D exits, Ctrl+R searches history.

Bare expressions print their value:

.lsc » 1 + 2
=> 3
.lsc » {1, 2, 3}
=> table: 0xc000...

Top-level local persists across REPL chunks (it's promoted to a global at compile time so subsequent inputs can read it):

.lsc » local greeting = "hi"
.lsc » print(greeting)
hi

Inside any nested scope (do/if/for/function body) local keeps standard Lua semantics.

Incomplete input opens a continuation prompt:

.lsc » function double(x)
   …      return x * 2
   …    end
.lsc » print(double(21))
42

Type errors land with a distinct prefix so they're easy to spot:

.lsc » local x: number = "hi"
type-error: Type "string" could not be converted into "number" at line 1

Project layout

.
├── compiler/
│   ├── lexer/         token stream from source text
│   ├── token/         token types and keyword table
│   ├── parser/        recursive-descent parser, Pratt-style for expressions
│   ├── ast/           AST node definitions (statements, expressions, types)
│   ├── typecheck/     gradual type system — Type representation, env, pass
│   ├── bytecode/      AST → instruction-set generator
│   └── compiler.go    top-level pipeline (lex → parse → typecheck → bytecode)
├── vm/                stack VM, closures, metatables, coroutines, stdlib
├── repl/              interactive REPL (readline + engine wrapper)
├── examples/          runnable .lsc programs that double as tutorials
├── version/           version string
└── cmd/               CLI entrypoint (main.go) + .lsc build` bundler

The compiler is designed so each stage is independently testable and the AST is the only contract between parser, type checker, and bytecode generator. The VM never sees source text or types; the parser never sees instructions.

Non-goals (for now)

  • The debug library.
  • Garbage-collection metamethods (__gc, __close enforcement).

These are deliberate omissions, not bugs — they're listed so contributors know what's out of scope rather than wondering whether to file an issue.

Previously listed but now shipped: Lua patterns, io.open + full file-handle stdlib, expanded os.

Contributing

Run the full test suite before sending a change:

go test ./...

Tests live next to the code they cover (*_test.go). The bytecode tests in particular are useful: they assert exact opcode sequences for representative source snippets, which catches accidental codegen drift early. The type checker has its own focused suite under compiler/typecheck/checker_test.go.

Inspirations

  • Lua 5.4 — the syntax and semantics target.
  • Luau — the type-system shape.
  • Goby — the original stack VM and bytecode-generator scaffolding (this project is a Goby fork in spirit, though much has been rewritten).

License

See LICENSE.

About

A Lua-flavored language with a stack-based virtual machine and Luau-style gradual types

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors