feat: gasm-devkit 0.1.0 — GAsm lexer, parser, linter, formatter, LSP and amd64 assembler
Assisted-by: Qwen 3.8 Max Preview
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Command gasm is the developer frontend for GAsm — Go's Plan 9 assembler.
|
||||
// It bundles a token dumper, a parser, a formatter, a linter and a language
|
||||
// server into one binary. Every subcommand works headlessly so it can be
|
||||
// driven from scripts and CI as well as from an editor.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/asm"
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/ast"
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/format"
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/lexer"
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/lint"
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/lsp"
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
|
||||
)
|
||||
|
||||
// version is the release version, stamped at build time via
|
||||
// -ldflags "-X main.version=…" (defaulting to the current release).
|
||||
var version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage(os.Stderr)
|
||||
os.Exit(2)
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "tokens":
|
||||
os.Exit(cmdTokens(os.Args[2:]))
|
||||
case "parse":
|
||||
os.Exit(cmdParse(os.Args[2:]))
|
||||
case "fmt":
|
||||
os.Exit(cmdFmt(os.Args[2:]))
|
||||
case "lint":
|
||||
os.Exit(cmdLint(os.Args[2:]))
|
||||
case "asm":
|
||||
os.Exit(cmdAsm(os.Args[2:]))
|
||||
case "lsp":
|
||||
os.Exit(cmdLSP(os.Args[2:]))
|
||||
case "version", "--version", "-V":
|
||||
fmt.Printf("gasm %s\n", version)
|
||||
case "help", "-h", "--help":
|
||||
usage(os.Stdout)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "gasm: unknown command %q\n\n", os.Args[1])
|
||||
usage(os.Stderr)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func usage(w io.Writer) {
|
||||
fmt.Fprintf(w, `gasm %s — developer tooling for Go's Plan 9 assembler
|
||||
|
||||
Usage:
|
||||
gasm tokens <file> print the lexical token stream
|
||||
gasm parse <file> parse and report syntax errors
|
||||
gasm fmt [-w] <file...> canonicalise formatting (-w writes in place)
|
||||
gasm lint <file...> run static checks
|
||||
gasm asm [-o out.bin] <file> assemble to machine code (amd64, Phase 2)
|
||||
gasm lsp run the language server over stdio
|
||||
gasm version print the version
|
||||
`, version)
|
||||
}
|
||||
|
||||
// readSource returns the contents of path, or stdin when path is "-".
|
||||
func readSource(path string) (string, error) {
|
||||
if path == "-" {
|
||||
b, err := io.ReadAll(os.Stdin)
|
||||
return string(b), err
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func cmdTokens(args []string) int {
|
||||
fs := flag.NewFlagSet("tokens", flag.ExitOnError)
|
||||
fs.Parse(args)
|
||||
if fs.NArg() != 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: gasm tokens <file>")
|
||||
return 2
|
||||
}
|
||||
src, err := readSource(fs.Arg(0))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gasm:", err)
|
||||
return 1
|
||||
}
|
||||
for _, tok := range lexer.Tokenize(src) {
|
||||
fmt.Printf("%s\t%s\t%q\n", tok.Pos, tok.Kind, tok.Text)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdParse(args []string) int {
|
||||
fs := flag.NewFlagSet("parse", flag.ExitOnError)
|
||||
fs.Parse(args)
|
||||
if fs.NArg() != 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: gasm parse <file>")
|
||||
return 2
|
||||
}
|
||||
path := fs.Arg(0)
|
||||
src, err := readSource(path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gasm:", err)
|
||||
return 1
|
||||
}
|
||||
file, errs := parser.Parse(path, src)
|
||||
for _, e := range errs {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", path, e)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return 1
|
||||
}
|
||||
funcs := 0
|
||||
for _, d := range file.Decls {
|
||||
if _, ok := d.(*ast.Text); ok {
|
||||
funcs++
|
||||
}
|
||||
}
|
||||
fmt.Printf("%s: OK — %d declarations, %d functions\n", path, len(file.Decls), funcs)
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdFmt(args []string) int {
|
||||
fs := flag.NewFlagSet("fmt", flag.ExitOnError)
|
||||
write := fs.Bool("w", false, "write result to the source file")
|
||||
fs.Parse(args)
|
||||
if fs.NArg() == 0 {
|
||||
fmt.Fprintln(os.Stderr, "usage: gasm fmt [-w] <file...>")
|
||||
return 2
|
||||
}
|
||||
rc := 0
|
||||
for _, path := range fs.Args() {
|
||||
src, err := readSource(path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gasm:", err)
|
||||
rc = 1
|
||||
continue
|
||||
}
|
||||
out := format.Source(path, src)
|
||||
if *write {
|
||||
if out != src {
|
||||
if err := os.WriteFile(path, []byte(out), 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gasm:", err)
|
||||
rc = 1
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Print(out)
|
||||
}
|
||||
return rc
|
||||
}
|
||||
|
||||
func cmdLint(args []string) int {
|
||||
fs := flag.NewFlagSet("lint", flag.ExitOnError)
|
||||
disable := fs.String("disable", "", "comma-separated rule codes to disable")
|
||||
fs.Parse(args)
|
||||
if fs.NArg() == 0 {
|
||||
fmt.Fprintln(os.Stderr, "usage: gasm lint <file...>")
|
||||
return 2
|
||||
}
|
||||
disabled := map[string]bool{}
|
||||
for _, code := range strings.Split(*disable, ",") {
|
||||
if code = strings.TrimSpace(code); code != "" {
|
||||
disabled[code] = true
|
||||
}
|
||||
}
|
||||
hadError := false
|
||||
for _, path := range fs.Args() {
|
||||
src, err := readSource(path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gasm:", err)
|
||||
hadError = true
|
||||
continue
|
||||
}
|
||||
file, errs := parser.Parse(path, src)
|
||||
for _, e := range errs {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", path, e)
|
||||
hadError = true
|
||||
}
|
||||
diags := lint.File(file, lint.Config{Arch: arch.FromFilename(path), Disable: disabled})
|
||||
for _, d := range diags {
|
||||
fmt.Printf("%s:%d:%d: %s: %s [%s]\n", path, d.Pos.Line, d.Pos.Column, d.Severity, d.Message, d.Code)
|
||||
if d.Severity == lint.Error {
|
||||
hadError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if hadError {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdLSP(args []string) int {
|
||||
fs := flag.NewFlagSet("lsp", flag.ExitOnError)
|
||||
fs.Parse(args)
|
||||
srv := lsp.New(os.Stdin, os.Stdout)
|
||||
if err := srv.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gasm lsp:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdAsm(args []string) int {
|
||||
fs := flag.NewFlagSet("asm", flag.ExitOnError)
|
||||
out := fs.String("o", "", "write the concatenated machine code to this file")
|
||||
fs.Parse(args)
|
||||
if fs.NArg() != 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: gasm asm [-o out.bin] <file>")
|
||||
return 2
|
||||
}
|
||||
path := fs.Arg(0)
|
||||
if arch.FromFilename(path) != arch.AMD64 {
|
||||
fmt.Fprintln(os.Stderr, "gasm asm: only amd64 is supported in this Phase 2 increment")
|
||||
return 1
|
||||
}
|
||||
src, err := readSource(path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gasm:", err)
|
||||
return 1
|
||||
}
|
||||
f, errs := parser.Parse(path, src)
|
||||
for _, e := range errs {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", path, e)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return 1
|
||||
}
|
||||
|
||||
var all []byte
|
||||
functions := 0
|
||||
for _, d := range f.Decls {
|
||||
txt, ok := d.(*ast.Text)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
code, _, err := asm.Assemble(txt)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: %s: %v\n", path, txt.Name.Name, err)
|
||||
return 1
|
||||
}
|
||||
functions++
|
||||
fmt.Printf("%s: %d bytes\n", txt.Name.Name, len(code))
|
||||
for i := 0; i < len(code); i += 16 {
|
||||
end := i + 16
|
||||
if end > len(code) {
|
||||
end = len(code)
|
||||
}
|
||||
fmt.Printf(" %04x:", i)
|
||||
for _, b := range code[i:end] {
|
||||
fmt.Printf(" %02x", b)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
all = append(all, code...)
|
||||
}
|
||||
if functions == 0 {
|
||||
fmt.Fprintln(os.Stderr, "gasm asm: no assemblable TEXT functions found")
|
||||
return 1
|
||||
}
|
||||
if *out != "" {
|
||||
if err := os.WriteFile(*out, all, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gasm asm:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("wrote %d bytes to %s\n", len(all), *out)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const clean = "#include \"textflag.h\"\n" +
|
||||
"TEXT ·f(SB), NOSPLIT, $0\n" +
|
||||
"\tMOVQ AX, BX\n" +
|
||||
"loop:\n" +
|
||||
"\tJMP loop\n" +
|
||||
"\tRET\n"
|
||||
|
||||
const buggy = "#include \"textflag.h\"\n" +
|
||||
"TEXT ·f(SB), NOSPLIT, $0\n" +
|
||||
"\tBOGUS AX, BX\n" +
|
||||
"\tJMP nowhere\n" +
|
||||
"\tRET\n"
|
||||
|
||||
func writeTemp(t *testing.T, name, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// capture runs fn with stdout and stderr redirected and returns both plus the
|
||||
// exit code fn produced.
|
||||
func capture(fn func() int) (stdout, stderr string, code int) {
|
||||
oldOut, oldErr := os.Stdout, os.Stderr
|
||||
rOut, wOut, _ := os.Pipe()
|
||||
rErr, wErr, _ := os.Pipe()
|
||||
os.Stdout, os.Stderr = wOut, wErr
|
||||
|
||||
code = fn()
|
||||
|
||||
wOut.Close()
|
||||
wErr.Close()
|
||||
os.Stdout, os.Stderr = oldOut, oldErr
|
||||
ob, _ := io.ReadAll(rOut)
|
||||
eb, _ := io.ReadAll(rErr)
|
||||
return string(ob), string(eb), code
|
||||
}
|
||||
|
||||
func TestCmdTokens(t *testing.T) {
|
||||
path := writeTemp(t, "f_amd64.s", clean)
|
||||
out, _, code := capture(func() int { return cmdTokens([]string{path}) })
|
||||
if code != 0 {
|
||||
t.Fatalf("code = %d", code)
|
||||
}
|
||||
if !strings.Contains(out, "IDENT") || !strings.Contains(out, "TEXT") {
|
||||
t.Errorf("token dump missing expected tokens:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdParseOK(t *testing.T) {
|
||||
path := writeTemp(t, "f_amd64.s", clean)
|
||||
out, _, code := capture(func() int { return cmdParse([]string{path}) })
|
||||
if code != 0 {
|
||||
t.Fatalf("code = %d", code)
|
||||
}
|
||||
if !strings.Contains(out, "OK") || !strings.Contains(out, "1 functions") {
|
||||
t.Errorf("parse output = %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdParseError(t *testing.T) {
|
||||
path := writeTemp(t, "bad_amd64.s", "TEXT ·f(SB), NOSPLIT, $0\n) (\n\tRET\n")
|
||||
_, errOut, code := capture(func() int { return cmdParse([]string{path}) })
|
||||
if code != 1 {
|
||||
t.Fatalf("code = %d, want 1", code)
|
||||
}
|
||||
if errOut == "" {
|
||||
t.Error("expected a parse error on stderr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdParseMissingFile(t *testing.T) {
|
||||
_, _, code := capture(func() int { return cmdParse([]string{"/nonexistent/file.s"}) })
|
||||
if code != 1 {
|
||||
t.Fatalf("code = %d, want 1", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdLintClean(t *testing.T) {
|
||||
path := writeTemp(t, "f_amd64.s", clean)
|
||||
_, _, code := capture(func() int { return cmdLint([]string{path}) })
|
||||
if code != 0 {
|
||||
t.Fatalf("clean file should lint with code 0, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdLintErrors(t *testing.T) {
|
||||
path := writeTemp(t, "f_amd64.s", buggy)
|
||||
out, _, code := capture(func() int { return cmdLint([]string{path}) })
|
||||
if code != 1 {
|
||||
t.Fatalf("code = %d, want 1", code)
|
||||
}
|
||||
if !strings.Contains(out, "unknown-instruction") || !strings.Contains(out, "undefined-label") {
|
||||
t.Errorf("lint output missing expected codes:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdLintDisable(t *testing.T) {
|
||||
path := writeTemp(t, "f_amd64.s", buggy)
|
||||
args := []string{"-disable", "unknown-instruction,undefined-label", path}
|
||||
_, _, code := capture(func() int { return cmdLint(args) })
|
||||
if code != 0 {
|
||||
t.Fatalf("disabling both rules should yield code 0, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdFmtStdout(t *testing.T) {
|
||||
path := writeTemp(t, "f_amd64.s", "TEXT ·f(SB),NOSPLIT,$0\nMOVQ AX,BX\nRET\n")
|
||||
out, _, code := capture(func() int { return cmdFmt([]string{path}) })
|
||||
if code != 0 {
|
||||
t.Fatalf("code = %d", code)
|
||||
}
|
||||
if !strings.Contains(out, "TEXT ·f(SB), NOSPLIT, $0") || !strings.Contains(out, "\tMOVQ AX, BX") {
|
||||
t.Errorf("formatted output unexpected:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdFmtWrite(t *testing.T) {
|
||||
path := writeTemp(t, "f_amd64.s", "TEXT ·f(SB),NOSPLIT,$0\nMOVQ AX,BX\nRET\n")
|
||||
_, _, code := capture(func() int { return cmdFmt([]string{"-w", path}) })
|
||||
if code != 0 {
|
||||
t.Fatalf("code = %d", code)
|
||||
}
|
||||
b, _ := os.ReadFile(path)
|
||||
if !strings.Contains(string(b), "\tMOVQ AX, BX") {
|
||||
t.Errorf("file not rewritten:\n%s", b)
|
||||
}
|
||||
// Idempotent: a second -w pass leaves the file unchanged.
|
||||
_, _, _ = capture(func() int { return cmdFmt([]string{"-w", path}) })
|
||||
b2, _ := os.ReadFile(path)
|
||||
if string(b) != string(b2) {
|
||||
t.Error("fmt -w is not idempotent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsage(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
usage(&b)
|
||||
if !strings.Contains(b.String(), "gasm") {
|
||||
t.Errorf("usage text unexpected:\n%s", b.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdArgErrors(t *testing.T) {
|
||||
// Missing file arguments produce a usage error (code 2).
|
||||
if _, _, code := capture(func() int { return cmdFmt(nil) }); code != 2 {
|
||||
t.Errorf("cmdFmt() code = %d, want 2", code)
|
||||
}
|
||||
if _, _, code := capture(func() int { return cmdLint(nil) }); code != 2 {
|
||||
t.Errorf("cmdLint() code = %d, want 2", code)
|
||||
}
|
||||
if _, _, code := capture(func() int { return cmdTokens(nil) }); code != 2 {
|
||||
t.Errorf("cmdTokens() code = %d, want 2", code)
|
||||
}
|
||||
if _, _, code := capture(func() int { return cmdParse(nil) }); code != 2 {
|
||||
t.Errorf("cmdParse() code = %d, want 2", code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user