// Copyright (c) 2026 Petr Balvín (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 }