diff --git a/asm/riscv_errorparity_test.go b/asm/riscv_errorparity_test.go new file mode 100644 index 0000000..56f69ea --- /dev/null +++ b/asm/riscv_errorparity_test.go @@ -0,0 +1,225 @@ +// 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) + } +}