// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause package asm import ( "os" "os/exec" "path/filepath" "regexp" "strings" "testing" "sourcedock.dev/petrbalvin/gasm-sdk/parser" ) // riscv64ErrorCatalogues are the toolchain's own negative-case files for // riscv64, relative to cmd/asm's testdata: riscv64error.s carries the // preprocess-stage rejections and riscv64validation.s the validate-stage // ones (register banks, compressed constraints, vector shapes). var riscv64ErrorCatalogues = []string{"riscv64error.s", "riscv64validation.s"} // riscv64AcceptedErrorShapes lists the toolchain's catalogue spellings gasm // still accepts, each an acceptance superset with a documented reason. The // list only shrinks: every tightening of the encoder moves spellings out of // it, and a spelling reappearing here means a regression. var riscv64AcceptedErrorShapes = []string{} // TestRISCVToolchainErrorParity walks the toolchain's riscv64error.s and // riscv64validation.s (Go 1.27) 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 // files; the test skips without one or in -short. func TestRISCVToolchainErrorParity(t *testing.T) { goroot := riscv64Goroot(t) dir := filepath.Join(goroot, "src", "cmd", "asm", "internal", "asm", "testdata") allowed := map[string]bool{} for _, s := range riscv64AcceptedErrorShapes { allowed[s] = true } for _, name := range riscv64ErrorCatalogues { data, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { t.Skip(err) } 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), " ") // A symbol reference needs the file's own declarations, which a // one-line probe cannot carry. if strings.Contains(body, "(SB)") { 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 := AssembleFileRISCV(f) if aerr == nil { if !allowed[body] { t.Errorf("gasm accepts what the toolchain rejects: %s", body) } continue } if want != "" && !riscv64DiagEquivalent(want, aerr.Error()) { t.Errorf("gasm rejects %s with an inequivalent diagnostic:\n toolchain: %s\n gasm: %s", body, want, aerr) } } } } // riscv64DiagEquivalent 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 "immediate out of range 0 to 31" // where gasm may spell the field's own name, and gasm prefixes the mnemonic. // Canonicalisation drops the subjects and connectives and compares the // remaining token sequence. func riscv64DiagEquivalent(want, got string) bool { return riscv64DiagSubseq(riscv64DiagTokens(want), riscv64DiagTokens(got)) } // riscv64DiagTokens lower-cases a diagnostic, strips punctuation and the // connective tokens, and returns its words. "to" survives only between // digits, where the range wording uses it. func riscv64DiagTokens(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", "an", "for": // subjects and articles carry no constraint case "to": if i > 0 && i+1 < len(fields) && riscv64IsNumeric(fields[i-1]) && riscv64IsNumeric(fields[i+1]) { continue // the range connective } out = append(out, w) default: out = append(out, w) } } return out } // riscv64DiagSubseq answers whether want is a subsequence of got. func riscv64DiagSubseq(want, got []string) bool { i := 0 for _, w := range got { if i < len(want) && w == want[i] { i++ } } return i == len(want) } // riscv64IsNumeric reports whether s parses as a signed decimal number, the // token shape the range connective sits between. func riscv64IsNumeric(s string) bool { if s == "" { return false } if s[0] == '-' || s[0] == '+' { s = s[1:] } if s == "" { return false } for _, r := range s { if r < '0' || r > '9' { return false } } return true } // riscv64Goroot resolves the live toolchain root, the environment's own value // first, `go env GOROOT` as the fallback. func riscv64Goroot(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)) } // riscv64RejectedNames pins the names the toolchain's riscv64 table carries // but refuses to encode: each is a "no encoding for instruction" under every // operand shape GOARCH=riscv64 go tool asm accepts. gasm rejects them too, // and the pair of rejections is the parity this catalogue asserts. var riscv64RejectedNames = []string{"DUFFCOPY", "DUFFZERO", "PCALIGNMAX"} // TestRISCVBacklogNameParity walks the audit's known-but-unencodable // backlog: the names go tool asm recognises on riscv64 yet refuses to encode // must be refused by gasm as well. func TestRISCVBacklogNameParity(t *testing.T) { if testing.Short() { t.Skip("live toolchain backlog: skipped in -short mode") } for _, name := range riscv64RejectedNames { if riscv64ProbeAssembles(name + " X5") { t.Errorf("gasm encodes %s, which the toolchain refuses under every shape", name) } if riscv64ProbeAssembles(name) { t.Errorf("gasm encodes %s, which the toolchain refuses under every shape", name) } } } // riscv64ProbeAssembles answers whether a single statement wrapped in its // own function parses and assembles on riscv64. func riscv64ProbeAssembles(body string) bool { src := "#include \"textflag.h\"\n\nTEXT ·probe(SB), NOSPLIT, $0-0\n\t" + body + "\n\tRET\n" f, perr := parser.Parse("backlogparity.s", src) if len(perr) > 0 { return false // the parser already rejects the line } _, aerr := AssembleFileRISCV(f) return aerr == nil } // riscv64CatalogueSelfCheck guards the catalogue walk itself: every ERROR // line in the two files must yield a probe body, so a formatting change in // the toolchain's files cannot silently empty the parity set. func TestRISCVErrorCatalogueSelfCheck(t *testing.T) { if testing.Short() { t.Skip("live toolchain catalogue: skipped in -short mode") } goroot := riscv64Goroot(t) dir := filepath.Join(goroot, "src", "cmd", "asm", "internal", "asm", "testdata") total := 0 for _, name := range riscv64ErrorCatalogues { data, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { t.Skip(err) } n := strings.Count(string(data), "ERROR") total += n if n == 0 { t.Errorf("%s carries no ERROR lines; the catalogue walk is empty", name) } } if total < 1000 { t.Errorf("catalogue carries %d ERROR lines, want at least 1000", total) } }