Files
gasm-sdk/parser/parser_test.go
T
petrbalvin 75e9fd771b
Test / test (push) Failing after 2m30s
feat(parser): split plain statements on semicolons in the raw parse
Assisted-by: GLM 5.3 Flash
2026-09-20 19:15:35 +02:00

500 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package parser
import (
"math"
"os"
"slices"
"strings"
"testing"
"sourcedock.dev/petrbalvin/gasm-devkit/ast"
)
func mustParse(t *testing.T, path string) *ast.File {
t.Helper()
src, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
file, errs := Parse(path, string(src))
if len(errs) > 0 {
t.Fatalf("parse %s: %v", path, errs)
}
return file
}
func texts(f *ast.File) []*ast.Text {
var out []*ast.Text
for _, d := range f.Decls {
if t, ok := d.(*ast.Text); ok {
out = append(out, t)
}
}
return out
}
// TestNegativeDisplacement is a regression test for a leading negative
// displacement with a base and index: the sign pushed the parenthesis one
// token further out than the lookahead expected, and the whole address used
// to parse empty.
func TestNegativeDisplacement(t *testing.T) {
f, errs := Parse("neg_amd64.s", `
#include "textflag.h"
TEXT ·f(SB), NOSPLIT, $0
LEAQ -4(DX)(R9*4), R9
MOVQ +8(AX), BX
RET
`)
if len(errs) > 0 {
t.Fatalf("parse: %v", errs)
}
fn := texts(f)[0]
var leaq, movq *ast.Instr
for _, s := range fn.Body {
if in, ok := s.(*ast.Instr); ok {
switch in.Mnemonic.Text {
case "LEAQ":
leaq = in
case "MOVQ":
movq = in
}
}
}
if leaq == nil || movq == nil {
t.Fatalf("instructions not parsed: leaq=%v movq=%v", leaq, movq)
}
a := leaq.Operands[0].Addr
if a.Base != "DX" || a.Index != "R9" || a.Scale != 4 || a.Offset != -4 || !a.HasOff {
t.Errorf("LEAQ addr = %+v, want -4(DX)(R9*4)", a)
}
b := movq.Operands[0].Addr
if b.Base != "AX" || b.Offset != 8 || !b.HasOff {
t.Errorf("MOVQ addr = %+v, want +8(AX)", b)
}
}
func TestParseSample(t *testing.T) {
f := mustParse(t, "../testdata/sample_amd64.s")
// Includes, GLOBL/DATA and two TEXT functions.
var includes, globls, datas int
for _, d := range f.Decls {
switch d.(type) {
case *ast.Include:
includes++
case *ast.Globl:
globls++
case *ast.Data:
datas++
}
}
if includes != 1 {
t.Errorf("includes = %d, want 1", includes)
}
if globls != 2 {
t.Errorf("globls = %d, want 2", globls)
}
if datas != 4 {
t.Errorf("datas = %d, want 4", datas)
}
txts := texts(f)
if len(txts) != 2 {
t.Fatalf("text functions = %d, want 2", len(txts))
}
fn := txts[0]
if fn.Name.Name != "analyzeO1RangeAVX2" {
t.Errorf("name = %q, want analyzeO1RangeAVX2", fn.Name.Name)
}
if fn.Name.Pseudo != "SB" {
t.Errorf("pseudo = %q, want SB", fn.Name.Pseudo)
}
if len(fn.Flags) != 1 || fn.Flags[0] != "NOSPLIT" {
t.Errorf("flags = %v, want [NOSPLIT]", fn.Flags)
}
if fn.Frame == nil || !fn.Frame.Imm.HasVal || fn.Frame.Imm.Val != 0 {
t.Errorf("frame = %+v, want $0", fn.Frame)
}
if fn.Args == nil || fn.Args.Imm.Val != 65 {
t.Errorf("args = %+v, want 65", fn.Args)
}
if fn.Doc == "" {
t.Error("expected a doc comment on the first TEXT")
}
// The body must contain the two labels vec1 and vec1done.
labels := map[string]bool{}
for _, s := range fn.Body {
if l, ok := s.(*ast.Label); ok {
labels[l.Name.Text] = true
}
}
for _, want := range []string{"vec1", "vec1done"} {
if !labels[want] {
t.Errorf("missing label %q", want)
}
}
}
func TestOperandStructure(t *testing.T) {
f := mustParse(t, "../testdata/sample_amd64.s")
fn := texts(f)[0]
// Index instructions by mnemonic for targeted checks.
byMnem := map[string]*ast.Instr{}
for _, s := range fn.Body {
if in, ok := s.(*ast.Instr); ok {
byMnem[in.Mnemonic.Text] = in
}
}
// MOVQ swin_base+0(FP), SI; the first MOVQ in the body.
var mov *ast.Instr
for _, s := range fn.Body {
if in, ok := s.(*ast.Instr); ok && in.Mnemonic.Text == "MOVQ" {
mov = in
break
}
}
if mov == nil {
t.Fatal("MOVQ not found")
}
src := mov.Operands[0]
if src.Kind != ast.OpAddr || src.Addr.Sym == nil {
t.Fatalf("src operand = %+v, want symbol address", src)
}
if src.Addr.Sym.Name != "swin_base" || src.Addr.Sym.Pseudo != "FP" || src.Addr.Sym.Offset != 0 {
t.Errorf("src symbol = %+v, want swin_base+0(FP)", src.Addr.Sym)
}
if mov.Operands[1].Addr.Sym.Name != "SI" {
t.Errorf("dst = %+v, want SI", mov.Operands[1].Addr)
}
// LEAQ (SI)(BX*4), R9
leaq := byMnem["LEAQ"]
if leaq == nil {
t.Fatal("LEAQ not found")
}
mem := leaq.Operands[0].Addr
if mem.Base != "SI" || mem.Index != "BX" || mem.Scale != 4 {
t.Errorf("LEAQ addr = %+v, want base SI index BX scale 4", mem)
}
// ANDQ $-8, R10
andq := byMnem["ANDQ"]
if andq == nil {
t.Fatal("ANDQ not found")
}
imm := andq.Operands[0]
if imm.Kind != ast.OpImmediate || !imm.Imm.Neg || imm.Imm.Val != 8 {
t.Errorf("ANDQ imm = %+v, want -8", imm.Imm)
}
}
func TestAVX512Operands(t *testing.T) {
f := mustParse(t, "../testdata/sample_amd64.s")
fn := texts(f)[1]
byMnem := map[string]*ast.Instr{}
for _, s := range fn.Body {
if in, ok := s.(*ast.Instr); ok {
byMnem[in.Mnemonic.Text] = in
}
}
// VALIGND $15, Z9, Z0, Z1; four operands.
val := byMnem["VALIGND"]
if val == nil {
t.Fatal("VALIGND not found")
}
if len(val.Operands) != 4 {
t.Errorf("VALIGND operands = %d, want 4", len(val.Operands))
}
if val.Operands[0].Kind != ast.OpImmediate || val.Operands[0].Imm.Val != 15 {
t.Errorf("VALIGND first operand = %+v, want $15", val.Operands[0])
}
// VMOVDQU32 Z0, 4(SI)(AX*1)
vmov := byMnem["VMOVDQU32"]
if vmov == nil {
t.Fatal("VMOVDQU32 not found")
}
dst := vmov.Operands[len(vmov.Operands)-1].Addr
if dst.Offset != 4 || dst.Base != "SI" || dst.Index != "AX" || dst.Scale != 1 {
t.Errorf("VMOVDQU32 dst = %+v, want 4(SI)(AX*1)", dst)
}
// KTESTW K1, K1; mask registers parse as bare names.
kt := byMnem["KTESTW"]
if kt == nil || len(kt.Operands) != 2 {
t.Fatalf("KTESTW = %+v, want two operands", kt)
}
}
func TestDataWidthAndStatic(t *testing.T) {
f := mustParse(t, "../testdata/sample_amd64.s")
var datas []*ast.Data
for _, d := range f.Decls {
if dd, ok := d.(*ast.Data); ok {
datas = append(datas, dd)
}
}
if datas[0].Width != 4 {
t.Errorf("first DATA width = %d, want 4", datas[0].Width)
}
if datas[0].Name.Pseudo != "SB" || datas[0].Name.Offset != 0 {
t.Errorf("first DATA name = %+v, want +0(SB)", datas[0].Name)
}
if datas[0].Value.Kind != ast.OpImmediate || datas[0].Value.Imm.Val != 1 {
t.Errorf("first DATA value = %+v, want $1", datas[0].Value)
}
// The mask24<> entries are static.
if !datas[2].Name.Static {
t.Errorf("mask24 DATA should be static, got %+v", datas[2].Name)
}
}
// TestTruncatedFrameDollar is a regression test for a TEXT directive whose
// frame size is missing after the $: the parser used to slice past the end
// of the token slice and panic. It must report a diagnostic instead.
func TestTruncatedFrameDollar(t *testing.T) {
for _, src := range []string{
"TEXT $\n",
"TEXT \u00b7foo(SB), $\n",
"TEXT \u00b7foo(SB), NOSPLIT, $\n",
} {
var file *ast.File
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("Parse(%q) panicked: %v", src, r)
}
}()
file, _ = Parse("t.s", src)
}()
if file == nil {
t.Fatalf("Parse(%q) returned no file", src)
}
if len(file.Decls) != 1 {
t.Fatalf("Parse(%q) decls = %d, want 1", src, len(file.Decls))
}
txt := file.Decls[0].(*ast.Text)
if txt.Frame != nil {
t.Errorf("Parse(%q) frame = %v, want nil", src, txt.Frame)
}
}
}
// TestFrameAndArgs parses a well-formed TEXT header and checks that the
// frame and args operands are picked up.
func TestFrameAndArgs(t *testing.T) {
file, errs := Parse("t.s", "TEXT \u00b7foo(SB), $32-16\n\tRET\n")
if len(errs) > 0 {
t.Fatalf("parse errors: %v", errs)
}
txt := file.Decls[0].(*ast.Text)
if txt.Frame == nil || !txt.Frame.Imm.HasVal || txt.Frame.Imm.Val != 32 {
t.Errorf("frame = %+v, want $32", txt.Frame)
}
if txt.Args == nil || !txt.Args.Imm.HasVal || txt.Args.Imm.Val != 16 {
t.Errorf("args = %+v, want -16", txt.Args)
}
}
// TestSignedZeroFrame covers the Go runtime's "$-0-24" spelling: a zero
// frame with an explicit sign plus the argument area.
func TestSignedZeroFrame(t *testing.T) {
file, errs := Parse("t.s", "TEXT \u00b7foo<ABIInternal>(SB), NOSPLIT, $-0-24\n\tRET\n")
if len(errs) > 0 {
t.Fatalf("parse errors: %v", errs)
}
txt := file.Decls[0].(*ast.Text)
if txt.Frame == nil || !txt.Frame.Imm.HasVal || txt.Frame.Imm.Val != 0 {
t.Errorf("frame = %+v, want $-0", txt.Frame)
}
if txt.Args == nil || !txt.Args.Imm.HasVal || txt.Args.Imm.Val != 24 {
t.Errorf("args = %+v, want -24", txt.Args)
}
// The <ABIInternal> marker belongs to the symbol: the pseudo-register is
// consumed, the marker is recorded, and neither leaks into the flags.
if txt.Name.Pseudo != "SB" {
t.Errorf("pseudo = %q, want SB", txt.Name.Pseudo)
}
if txt.Name.ABI != "ABIInternal" {
t.Errorf("ABI = %q, want ABIInternal", txt.Name.ABI)
}
if !strings.Contains(txt.Name.Raw, "<ABIInternal>") {
t.Errorf("Raw = %q, want it to contain <ABIInternal>", txt.Name.Raw)
}
if want := []string{"NOSPLIT"}; !slices.Equal(txt.Flags, want) {
t.Errorf("flags = %v, want %v", txt.Flags, want)
}
}
// TestPipedFlags covers TEXT and GLOBL flag lists joined by '|': the bars are
// their own token kind, skipped by the flag loop, and only the identifiers
// are collected as flags.
func TestPipedFlags(t *testing.T) {
file, errs := Parse("t.s", "TEXT \u00b7f(SB), NOSPLIT|NOFRAME|DUPOK, $0\n\tRET\n")
if len(errs) > 0 {
t.Fatalf("parse errors: %v", errs)
}
txt := file.Decls[0].(*ast.Text)
if want := []string{"NOSPLIT", "NOFRAME", "DUPOK"}; !slices.Equal(txt.Flags, want) {
t.Errorf("flags = %v, want %v", txt.Flags, want)
}
g, errs := Parse("t.s", "GLOBL \u00b7mask(SB), RODATA|NOPTR, $8\n")
if len(errs) > 0 {
t.Fatalf("parse errors: %v", errs)
}
gl := g.Decls[0].(*ast.Globl)
if want := []string{"RODATA", "NOPTR"}; !slices.Equal(gl.Flags, want) {
t.Errorf("flags = %v, want %v", gl.Flags, want)
}
}
// TestTextMissingSymbolKeepsDecl covers a TEXT with no symbol at all: the
// decl must stay in the tree with a non-nil placeholder name, because the
// linter and the LSP dereference Name on every parsed TEXT.
func TestTextMissingSymbolKeepsDecl(t *testing.T) {
file, errs := Parse("t.s", "// func f(a int) int\nTEXT $0\n\tMOVQ AX, BX\n")
if len(errs) == 0 {
t.Fatal("expected a diagnostic for the missing symbol")
}
if file == nil || len(file.Decls) != 1 {
t.Fatalf("file = %v, want the TEXT decl kept", file)
}
txt := file.Decls[0].(*ast.Text)
if txt.Name == nil {
t.Fatal("Name must never be nil: downstream tools dereference it")
}
if txt.Name.Name == "" {
t.Error("placeholder name is empty")
}
if txt.Frame == nil || !txt.Frame.Imm.HasVal || txt.Frame.Imm.Val != 0 {
t.Errorf("frame = %+v, want $0", txt.Frame)
}
if len(txt.Body) != 1 {
t.Errorf("body = %d statements, want 1", len(txt.Body))
}
}
// TestInt64MinimumImmediate covers $-0x8000000000000000: the digits overflow
// int64 when parsed directly, but the negated magnitude is exactly the int64
// minimum and must land in Val rather than the float fallback.
func TestInt64MinimumImmediate(t *testing.T) {
file, errs := Parse("t.s", "TEXT \u00b7f(SB), $0\n\tMOVQ $-0x8000000000000000, AX\n\tRET\n")
if len(errs) > 0 {
t.Fatalf("parse errors: %v", errs)
}
txt := file.Decls[0].(*ast.Text)
instr := txt.Body[0].(*ast.Instr)
imm := instr.Operands[0].Imm
if !imm.HasVal || imm.Val != math.MinInt64 {
t.Errorf("imm = %+v, want Val = %d with HasVal set", imm, math.MinInt64)
}
if imm.Float != "" {
t.Errorf("imm.Float = %q, want empty", imm.Float)
}
}
// TestDivisionSlashPackagePath covers the runtime's package-path spelling:
// U+2215 DIVISION SLASH separates the elements of an import path inside a
// symbol (internal∕runtime∕atomic·Xchg), and the middle dot still separates
// the package from the name. The whole spelling must reach the symbol, not
// stop at the first slash.
func TestDivisionSlashPackagePath(t *testing.T) {
file, errs := Parse("t.s", "TEXT \u00b7f(SB), $0\n\tCALL internal∕runtime∕atomic·Xchg(SB)\n\tRET\n")
if len(errs) > 0 {
t.Fatalf("parse errors: %v", errs)
}
txt := file.Decls[0].(*ast.Text)
instr := txt.Body[0].(*ast.Instr)
sym := instr.Operands[0].Addr.Sym
if sym == nil {
t.Fatal("operand carries no symbol")
}
if sym.Pkg != "internal∕runtime∕atomic" {
t.Errorf("pkg = %q, want internal∕runtime∕atomic", sym.Pkg)
}
if sym.Name != "Xchg" {
t.Errorf("name = %q, want Xchg", sym.Name)
}
if sym.Raw != "internal∕runtime∕atomic·Xchg(SB)" {
t.Errorf("raw = %q", sym.Raw)
}
}
// TestSemicolonStatements covers the plain parse path: ';' separates
// statements on one line exactly as it does inside macro expansion, and a
// ';' inside a comment is comment text.
func TestSemicolonStatements(t *testing.T) {
file, errs := Parse("t.s", "TEXT \u00b7f(SB), $0\n\tROLQ $3, DI; ROLQ $13, DI\n\tMOVQ AX, BX // note; still comment\n\tRET\n")
if len(errs) > 0 {
t.Fatalf("parse errors: %v", errs)
}
txt := file.Decls[0].(*ast.Text)
if len(txt.Body) != 4 {
t.Fatalf("body = %d statements, want 4", len(txt.Body))
}
first := txt.Body[0].(*ast.Instr)
if first.Mnemonic.Text != "ROLQ" || len(first.Operands) != 2 {
t.Errorf("first statement = %+v, want ROLQ with two operands", first.Mnemonic)
}
second := txt.Body[1].(*ast.Instr)
if second.Mnemonic.Text != "ROLQ" || len(second.Operands) != 2 {
t.Errorf("second statement = %s, want ROLQ with two operands", second.Mnemonic.Text)
}
// The trailing comment belongs to the second MOVQ, semicolon included.
third := txt.Body[2].(*ast.Instr)
if third.Mnemonic.Text != "MOVQ" || third.Comment != "note; still comment" {
t.Errorf("third = %s, comment %q", third.Mnemonic.Text, third.Comment)
}
}
// TestSemicolonAfterLabel covers a label sharing its line with two
// statements.
func TestSemicolonAfterLabel(t *testing.T) {
file, errs := Parse("t.s", "TEXT \u00b7f(SB), $0\nloop: NOP; NOP\n\tRET\n")
if len(errs) > 0 {
t.Fatalf("parse errors: %v", errs)
}
txt := file.Decls[0].(*ast.Text)
if len(txt.Body) != 4 {
t.Fatalf("body = %d statements, want 4 (label, two instructions, RET)", len(txt.Body))
}
if _, ok := txt.Body[0].(*ast.Label); !ok {
t.Errorf("first statement = %T, want *ast.Label", txt.Body[0])
}
for i, want := range []string{"NOP", "NOP", "RET"} {
in, ok := txt.Body[i+1].(*ast.Instr)
if !ok || in.Mnemonic.Text != want {
t.Errorf("statement %d = %v, want %s", i+1, txt.Body[i+1], want)
}
}
}
// TestParseEqualsZeroOptions pins the contract that ParseWithOptions with
// the zero Options reproduces Parse, here for the semicolon split.
func TestParseEqualsZeroOptions(t *testing.T) {
src := "TEXT \u00b7f(SB), $0\n\tNOP; NOP\n\tRET\n"
a, errsA := Parse("t.s", src)
b, errsB := ParseWithOptions("t.s", src, Options{})
if len(errsA) > 0 || len(errsB) > 0 {
t.Fatalf("errors: %v / %v", errsA, errsB)
}
ta, tb := texts(a), texts(b)
if len(ta) != len(tb) {
t.Fatalf("decl counts differ: %d vs %d", len(ta), len(tb))
}
for i := range ta {
if len(ta[i].Body) != len(tb[i].Body) {
t.Fatalf("TEXT %d: body lengths differ: %d vs %d", i, len(ta[i].Body), len(tb[i].Body))
}
}
}