// Copyright (c) 2026 Petr Balvín (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(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 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, "") { t.Errorf("Raw = %q, want it to contain ", 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) } }