454 lines
17 KiB
Go
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
|
|
}
|