Files
gasm-sdk/asm/loong64_errorparity_test.go
T
2026-10-07 21:42:14 +02:00

454 lines
17 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package asm
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"sourcedock.dev/petrbalvin/gasm-sdk/parser"
)
// loong64AcceptedErrorShapes lists the toolchain's loong64error.s spellings
// gasm still accepts, each an acceptance superset with a known shape. The
// list only shrinks: every tightening of the encoder moves spellings out of
// it, and a spelling reappearing here means a regression.
var loong64AcceptedErrorShapes = []string{}
// TestLoong64ToolchainErrorParity walks the toolchain's loong64error.s (Go
// 1.27, loong64) and requires gasm to reject every case the toolchain rejects
// with an equivalent diagnostic, the documented acceptance supersets above
// excepted. A live Go toolchain is needed for the source file; the test
// skips without one or in -short.
func TestLoong64ToolchainErrorParity(t *testing.T) {
goroot := loong64Goroot(t)
path := filepath.Join(goroot, "src", "cmd", "asm", "internal", "asm", "testdata", "loong64error.s")
data, err := os.ReadFile(path)
if err != nil {
t.Skip(err)
}
allowed := map[string]bool{}
for _, s := range loong64AcceptedErrorShapes {
allowed[s] = true
}
for raw := range strings.SplitSeq(string(data), "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "TEXT") || !strings.Contains(line, "ERROR") {
continue
}
body := line
if i := strings.Index(body, "//"); i >= 0 {
body = strings.TrimSpace(body[:i])
}
want := ""
if m := regexp.MustCompile(`ERROR "([^"]*)"`).FindStringSubmatch(line); m != nil {
want = m[1]
}
body = strings.ReplaceAll(body, "\t", " ")
body = strings.Join(strings.Fields(body), " ")
// Label-relative and non-operand lines need their function context.
if strings.Contains(body, "(PC)") || strings.Contains(body, "(SB)") || strings.Contains(body, "PCALIGN") ||
strings.HasPrefix(body, "RET") || strings.HasPrefix(body, "NOP") || strings.HasPrefix(body, "BRK") ||
strings.Contains(body, "again") || strings.Contains(body, "next") || strings.Contains(body, "loop") {
continue
}
src := "#include \"textflag.h\"\n\nTEXT ·f(SB), NOSPLIT, $0-0\n\t" + body + "\n\tRET\n"
f, perr := parser.Parse("errorparity.s", src)
if len(perr) > 0 {
continue // the parser already rejects the spelling
}
_, aerr := AssembleFileLOONG64(f)
if aerr == nil {
if !allowed[body] {
t.Errorf("gasm accepts what the toolchain rejects: %s", body)
}
continue
}
if want != "" && !loong64DiagEquivalent(want, aerr.Error()) {
t.Errorf("gasm rejects %s with an inequivalent diagnostic:\n toolchain: %s\n gasm: %s", body, want, aerr)
}
}
}
// loong64DiagEquivalent answers whether gasm's rejection carries the same
// information as the toolchain's expected message. The two word the same
// facts differently: the toolchain writes "operand out of range 0 to 15"
// where gasm writes "immediate out of range [0, 15]", and both terminate
// sentences the toolchain punctuates. Canonicalisation drops the subject
// word and the connectives and compares the remaining token sequence.
func loong64DiagEquivalent(want, got string) bool {
return loong64DiagSubseq(loong64DiagTokens(want), loong64DiagTokens(got))
}
// loong64DiagTokens lower-cases a diagnostic, strips punctuation and the
// connective tokens, and returns its words. "to" survives only between
// digits, where the toolchain's range wording uses it; gasm's bracketed
// form never produces it.
func loong64DiagTokens(msg string) []string {
msg = strings.ToLower(msg)
for _, r := range []string{"[", "]", ",", ".", ":", "\n"} {
msg = strings.ReplaceAll(msg, r, " ")
}
fields := strings.Fields(msg)
out := make([]string, 0, len(fields))
for i, w := range fields {
switch w {
case "the", "a", "operand", "immediate":
// subjects and articles carry no constraint
case "to":
if i > 0 && i+1 < len(fields) && isDigits(fields[i-1]) && isDigits(fields[i+1]) {
continue // the range connective
}
out = append(out, w)
default:
out = append(out, w)
}
}
return out
}
// loong64DiagSubseq answers whether want is a subsequence of got.
func loong64DiagSubseq(want, got []string) bool {
i := 0
for _, w := range got {
if i < len(want) && w == want[i] {
i++
}
}
return i == len(want)
}
func isDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return len(s) > 0
}
// loong64ForeignErrorCorpus lists the corpus directories whose .s files the
// loong64 build attempts: the toolchain's own assembler testdata (the
// foreign-architecture files the corpus audit indicts on loong64) and the
// go/build malformed-source probes.
var loong64ForeignErrorCorpus = []string{
filepath.Join("cmd", "asm", "internal", "asm", "testdata"),
filepath.Join("go", "build", "testdata", "bads"),
}
// loong64ForeignAcceptedLines lists toolchain rejections gasm still
// accepts, keyed by corpus-relative path and line, each with its reason.
// The list only shrinks: every tightening moves lines out of it, and a line
// reappearing here means a regression.
var loong64ForeignAcceptedLines = map[string]string{
// duperror.s rejections are whole-file semantics: the toolchain indicts
// the second declaration against the first in the same file, and a
// single TEXT or GLOBL line is legal on its own. gasm rejects the
// whole file with the same redeclaration diagnostic.
"cmd/asm/internal/asm/testdata/duperror.s:7": "symbol foo redeclared is a whole-file judgement",
"cmd/asm/internal/asm/testdata/duperror.s:11": "symbol bar redeclared is a whole-file judgement",
// The toolchain deletes the R22 spelling from the loong64 register map
// (its comment: avoid unintentionally clobbering g) and keeps g alone;
// gasm resolves R22 to register 22 as part of its deliberate ABI-alias
// superset (A0, SP, T0, ... share the table), so the three mips64.s
// spellings that name R22 assemble here.
"cmd/asm/internal/asm/testdata/mips64.s:135": "R22 spelling: gasm's deliberate ABI-alias superset",
"cmd/asm/internal/asm/testdata/mips64.s:138": "R22 spelling: gasm's deliberate ABI-alias superset",
"cmd/asm/internal/asm/testdata/mips64.s:218": "R22 spelling: gasm's deliberate ABI-alias superset",
}
// TestLoong64ForeignErrorParity takes go tool asm's own per-line rejections
// on GOARCH=loong64 as the ground truth over the foreign corpus files and
// requires gasm to reject every one of those lines too, the documented
// acceptance supersets above excepted. The toolchain is run once per file;
// the test skips without a live toolchain or in -short.
func TestLoong64ForeignErrorParity(t *testing.T) {
goroot := loong64Goroot(t)
files := loong64CorpusFiles(t, goroot)
if len(files) == 0 {
t.Skip("no corpus files")
}
for _, path := range files {
if filepath.Base(path) == "loong64error.s" {
continue // the native catalogue above walks it deeply
}
rel := loong64CorpusRel(goroot, path)
rejected := loong64ToolchainRejects(t, goroot, path)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
lines := strings.Split(string(data), "\n")
for _, ln := range rejected {
if ln < 1 || ln > len(lines) {
t.Fatalf("%s: diagnostic names line %d beyond the file", rel, ln)
}
body := strings.TrimSpace(lines[ln-1])
if body == "" || strings.HasPrefix(body, "//") {
continue
}
if i := strings.Index(body, "//"); i >= 0 {
body = strings.TrimSpace(body[:i])
}
body = strings.Join(strings.Fields(strings.ReplaceAll(body, "\t", " ")), " ")
key := rel + ":" + strconv.Itoa(ln)
if _, ok := loong64ForeignAcceptedLines[key]; ok {
continue
}
if loong64ProbeAssembles(body) {
t.Errorf("gasm accepts what the toolchain rejects at %s: %s", key, body)
}
}
}
}
// loong64RejectedNames pins the names the toolchain's loong64 table carries
// but refuses to encode: each is an "illegal combination" under every
// operand shape GOARCH=loong64 go tool asm accepts. gasm rejects them too,
// and the pair of rejections is the parity this catalogue asserts.
var loong64RejectedNames = []string{"RFE", "DUFFCOPY", "DUFFZERO", "PCALIGNMAX"}
// loong64PairErrorShapes walks the register-pair sugar's negative shapes,
// every one measured against GOARCH=loong64 go tool asm. want carries the
// toolchain's diagnostic where gasm's wording matches it (checked through
// the same equivalence as the catalogue above); an empty want pins the
// rejection alone, and the row's comment records the toolchain's own words
// for the divergence.
var loong64PairErrorShapes = []struct {
body string
want string
}{
// A pair never splits inside an address; the toolchain's parse stage
// refuses it outright.
{"MULV (R4:R5), R6", "indirect through register pair"},
// The reordered list fails the instruction's operand shape. The
// toolchain words these "illegal combination ..." at the class match;
// gasm words them at the operand count.
{"MOVV R4:R5, R6", ""},
{"BEQ R4:R5, R6", ""},
{"JMP R4:R5", ""},
{"MULV R4:R5, R7:R8, R6", ""},
{"MULV R4:R5, (R6)", ""},
{"BEQ R4:R5, 2(PC)", ""},
// A right half outside the register table; the toolchain's parse stage
// again ("illegal or missing addressing mode for symbol label").
{"MULV R4:label", "illegal or missing addressing mode for symbol label"},
// An immediate half on either side. The toolchain reorders the pair
// first and rejects at the class match ("illegal combination MULV
// U3CON ...", "MULV: expected register; found $4"); gasm rejects the
// shape without the reorder.
{"MOVV $4:R5", ""},
{"MULV R4:$5", ""},
}
// TestLoong64PairErrorParity requires gasm to refuse every register-pair
// spelling the toolchain refuses, with an equivalent diagnostic where the
// row carries one. The toolchain's accept side of the sugar is pinned
// byte for byte by TestLOONG64PairSugar; the empty-want rows are documented
// wording divergences, not acceptance divergences.
func TestLoong64PairErrorParity(t *testing.T) {
for _, tt := range loong64PairErrorShapes {
src := "#include \"textflag.h\"\n\nTEXT ·f(SB), NOSPLIT, $0-0\n\t" + tt.body + "\n\tRET\n"
f, perr := parser.Parse("pairparity.s", src)
if len(perr) > 0 {
continue // the parser already rejects the spelling
}
_, aerr := AssembleFileLOONG64(f)
if aerr == nil {
t.Errorf("gasm accepts what the toolchain rejects: %s", tt.body)
continue
}
if tt.want != "" && !loong64DiagEquivalent(tt.want, aerr.Error()) {
t.Errorf("gasm rejects %s with an inequivalent diagnostic:\n toolchain: %s\n gasm: %s", tt.body, tt.want, aerr)
}
}
}
// TestLoong64BacklogNameParity walks the audit's known-but-unencodable
// backlog: the names go tool asm recognises on loong64 yet refuses to
// encode must be refused by gasm as well, and the one name the audit
// misses because its probe battery lacks the shape (SCQ, whose only form
// is the three-operand store-conditional) must encode to the toolchain's
// own bytes. The refusal claim is walked under the full operand-shape
// battery below, against the live toolchain and against gasm alike.
func TestLoong64BacklogNameParity(t *testing.T) {
if testing.Short() {
t.Skip("live toolchain backlog: skipped in -short mode")
}
for _, name := range loong64RejectedNames {
if loong64ProbeAssembles(name) {
t.Errorf("gasm encodes %s, which the toolchain refuses under every shape", name)
}
}
// The shapes the plain audit probes, walked here so the backlog's
// claim holds under any operand shape, not only the bare mnemonic:
// the toolchain must refuse every combination, and gasm must refuse
// it too.
goroot := loong64Goroot(t)
for _, name := range loong64RejectedNames {
for _, shape := range loong64BacklogShapes {
body := strings.TrimSpace(name + " " + shape)
if loong64ProbeAssembles(body) {
t.Errorf("gasm encodes %s, which the toolchain refuses under every shape", body)
}
// The toolchain side of the same row: the file holds the one
// instruction, and the live assembler must report it. A
// spelling the toolchain accepts would open the name for
// encoding and close nothing here but the parity.
src := "#include \"textflag.h\"\n\nTEXT ·f(SB), NOSPLIT, $0-0\np2:\n\t" + body + "\n\tRET\n"
dir := t.TempDir()
path := filepath.Join(dir, "backlogshape.s")
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if lines := loong64ToolchainRejects(t, goroot, path); len(lines) == 0 {
t.Errorf("the toolchain assembles %q, against the backlog's claim", body)
}
}
}
// sc.q: loong64enc1.s pins the encoding as c4145738
// (SCQ R4, R5, (R6): sc.q rd, rk, (rj)).
src := "#include \"textflag.h\"\n\nTEXT ·f(SB), NOSPLIT, $0-0\n\tSCQ R4, R5, (R6)\n\tRET\n"
f, perr := parser.Parse("backlog.s", src)
if len(perr) > 0 {
t.Fatalf("parse SCQ: %v", perr[0])
}
img, aerr := AssembleFileLOONG64(f)
if aerr != nil {
t.Fatalf("assemble SCQ: %v", aerr)
}
want := []byte{0xc4, 0x14, 0x57, 0x38, 0x20, 0x00, 0x00, 0x4c}
if !slices.Equal(img.Code, want) {
t.Errorf("SCQ encodes to % X, want % X (RET included)", img.Code, want)
}
}
// loong64BacklogShapes is the operand-shape battery the backlog walk runs
// every rejected name through: the register, immediate, memory, vector and
// label positions the loong64 grammar has, mirroring the plain audit's
// probe battery (cmd/gasm's probeShapes) so the two answer the same
// question.
var loong64BacklogShapes = []string{
"", "R4", "R4, R5", "R4, R5, R6", "$1, R4", "R4, (R5)", "(R4), R5",
"F0, F1", "F0, F1, F2", "V1, V2", "X1, X2", "V1, FCC0", "$65536, R4",
"R4, R5, p2", "p2(SB)", "R5, (R4), R6", "$1, $0", "R1, R5, 0",
"0(R7), $5, $0", "V1, V2, V3, V4", "R4, R5, (R6)", "$16", "(R4)",
}
// loong64ProbeAssembles answers whether the single corpus line, wrapped in
// its own function, parses and assembles on loong64.
func loong64ProbeAssembles(body string) bool {
src := "#include \"textflag.h\"\n\n"
if strings.HasPrefix(body, "TEXT ") {
src += body + "\n\tRET\n"
} else if strings.HasPrefix(body, "DATA ") || strings.HasPrefix(body, "GLOBL ") {
src += body + "\n\nTEXT ·probe(SB), NOSPLIT, $0-0\n\tRET\n"
} else {
src += "TEXT ·probe(SB), NOSPLIT, $0-0\n\t" + body + "\n\tRET\n"
}
f, perr := parser.Parse("foreignparity.s", src)
if len(perr) > 0 {
return false // the parser already rejects the line
}
_, aerr := AssembleFileLOONG64(f)
return aerr == nil
}
// loong64Goroot resolves the live toolchain root, the environment's own
// value first, `go env GOROOT` as the fallback.
func loong64Goroot(t *testing.T) string {
t.Helper()
if goroot := os.Getenv("GOROOT"); goroot != "" {
return goroot
}
out, err := exec.Command("go", "env", "GOROOT").Output()
if err != nil {
t.Skipf("no GOROOT: %v", err)
}
return strings.TrimSpace(string(out))
}
// loong64CorpusFiles lists every .s file under the corpus roots, the
// testdata tree walked recursively.
func loong64CorpusFiles(t *testing.T, goroot string) []string {
t.Helper()
var files []string
for _, rel := range loong64ForeignErrorCorpus {
root := filepath.Join(goroot, "src", rel)
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.HasSuffix(path, ".s") {
files = append(files, path)
}
return nil
})
if err != nil {
t.Skipf("walk %s: %v", root, err)
}
}
slices.Sort(files)
return files
}
// loong64CorpusRel renders a corpus file's path relative to GOROOT/src, the
// key the accepted-lines catalogue uses.
func loong64CorpusRel(goroot, path string) string {
rel, err := filepath.Rel(filepath.Join(goroot, "src"), path)
if err != nil {
return path
}
return rel
}
var (
// loong64ParseDiag matches the parse-stage "file:line: message".
loong64ParseDiag = regexp.MustCompile(`(?m)^(?:\S*asm: )?([^:\s]+\.s):(\d+):`)
// loong64EncodeDiag matches the encode-stage
// "asm: message: 003c (file.s:61)".
loong64EncodeDiag = regexp.MustCompile(`\(([^:\s()]+\.s):(\d+)\)`)
)
// loong64ToolchainRejects runs go tool asm on the file for GOARCH=loong64
// and returns the line numbers it rejects, both diagnostic shapes
// collected. An infrastructure failure fails the test: without the ground
// truth there is no parity to assert.
func loong64ToolchainRejects(t *testing.T, goroot, path string) []int {
t.Helper()
obj := filepath.Join(t.TempDir(), "probe.o")
cmd := exec.Command("go", "tool", "asm", "-I", filepath.Join(goroot, "pkg", "include"), "-o", obj, path)
cmd.Env = append(os.Environ(), "GOARCH=loong64")
out, err := cmd.CombinedOutput()
var lines []int
seen := map[int]bool{}
for _, m := range loong64ParseDiag.FindAllStringSubmatch(string(out), -1) {
if strings.HasSuffix(m[1], ".s") {
ln, perr := strconv.Atoi(m[2])
if perr == nil && !seen[ln] {
seen[ln] = true
lines = append(lines, ln)
}
}
}
for _, m := range loong64EncodeDiag.FindAllStringSubmatch(string(out), -1) {
ln, perr := strconv.Atoi(m[2])
if perr == nil && !seen[ln] {
seen[ln] = true
lines = append(lines, ln)
}
}
slices.Sort(lines)
if err != nil && len(lines) == 0 {
t.Fatalf("go tool asm %s: %v\n%s", filepath.Base(path), err, out)
}
return lines
}