diff --git a/cl/cltest/cltest.go b/cl/cltest/cltest.go index 98da01ff40..afdcf7136c 100644 --- a/cl/cltest/cltest.go +++ b/cl/cltest/cltest.go @@ -40,6 +40,7 @@ import ( "github.com/goplus/llgo/internal/llgen" "github.com/goplus/llgo/internal/mockable" "github.com/goplus/llgo/ssa/ssatest" + gllvm "github.com/goplus/llvm" "github.com/qiniu/x/test" "golang.org/x/tools/go/ssa" "golang.org/x/tools/go/ssa/ssautil" @@ -57,33 +58,17 @@ func InitDebug() { } func FromDir(t *testing.T, sel, relDir string) { - dir, err := os.Getwd() - if err != nil { - t.Fatal("Getwd failed:", err) - } - dir = filepath.Join(dir, relDir) - fis, err := os.ReadDir(dir) - if err != nil { - t.Fatal("ReadDir failed:", err) - } - for _, fi := range fis { - name := fi.Name() - if !fi.IsDir() || strings.HasPrefix(name, "_") { - continue - } - pkgDir := filepath.Join(dir, name) - t.Run(name, func(t *testing.T) { - testFrom(t, pkgDir, sel) - }) - } + RunAndTestFromDir(t, sel, relDir, nil, WithOutputCheck(false)) } type runOptions struct { - conf *build.Config - filter func(string) string + conf *build.Config + filter func(string) string + checkIR bool + checkOutput bool } -// RunOption customizes RunFromDir behavior. +// RunOption customizes directory-based test behavior. type RunOption func(*runOptions) // WithRunConfig uses the provided build config for test runs. @@ -100,6 +85,20 @@ func WithOutputFilter(filter func(string) string) RunOption { } } +// WithOutputCheck enables or disables runtime output golden checks in RunAndTestFromDir. +func WithOutputCheck(enabled bool) RunOption { + return func(opts *runOptions) { + opts.checkOutput = enabled + } +} + +// WithIRCheck enables or disables IR golden checks in RunAndTestFromDir. +func WithIRCheck(enabled bool) RunOption { + return func(opts *runOptions) { + opts.checkIR = enabled + } +} + // FilterEmulatorOutput strips emulator boot logs by returning output after "entry 0x...". func FilterEmulatorOutput(output string) string { output = strings.ReplaceAll(output, "\r\n", "\n") @@ -117,9 +116,10 @@ func FilterEmulatorOutput(output string) string { return output } -// RunFromDir executes tests under relDir, skipping any relPkg entries in ignore. -// ignore entries should be relative package paths (e.g., "./_testgo/invoke"). -func RunFromDir(t *testing.T, sel, relDir string, ignore []string, opts ...RunOption) { +// RunAndTestFromDir executes tests under relDir and validates both runtime +// output and the pre-transform package IR snapshot when the corresponding +// golden files exist. +func RunAndTestFromDir(t *testing.T, sel, relDir string, ignore []string, opts ...RunOption) { rootDir, err := os.Getwd() if err != nil { t.Fatal("Getwd failed:", err) @@ -129,7 +129,7 @@ func RunFromDir(t *testing.T, sel, relDir string, ignore []string, opts ...RunOp for _, item := range ignore { ignoreSet[item] = struct{}{} } - options := runOptions{} + options := runOptions{checkIR: true, checkOutput: true} for _, opt := range opts { opt(&options) } @@ -155,7 +155,7 @@ func RunFromDir(t *testing.T, sel, relDir string, ignore []string, opts ...RunOp continue } t.Run(name, func(t *testing.T) { - testRunFrom(t, pkgDir, relPkg, sel, options) + testRunAndTestFrom(t, pkgDir, relPkg, sel, options) }) } } @@ -221,37 +221,69 @@ func testFrom(t *testing.T, pkgDir, sel string) { } } -func testRunFrom(t *testing.T, pkgDir, relPkg, sel string, opts runOptions) { +func testRunAndTestFrom(t *testing.T, pkgDir, relPkg, sel string, opts runOptions) { if sel != "" && !strings.Contains(pkgDir, sel) { return } - expectedPath := filepath.Join(pkgDir, "expect.txt") - expected, err := os.ReadFile(expectedPath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return + + var ( + expectedOutput []byte + checkOutput bool + err error + ) + if opts.checkOutput { + expectedOutput, checkOutput, err = readGolden(filepath.Join(pkgDir, "expect.txt")) + if err != nil { + t.Fatal("ReadFile failed:", err) } - t.Fatal("ReadFile failed:", err) } - if bytes.Equal(expected, []byte{';'}) { // expected == ";" means skipping expect.txt + if !checkOutput { + // IR-only mode: when expect.txt is not checked, use llgen.GenFrom via + // testFrom to compare this package's generated IR against out.ll. + if opts.checkIR { + testFrom(t, pkgDir, sel) + } return } - var output []byte - if opts.conf != nil { - output, err = RunAndCaptureWithConf(relPkg, pkgDir, opts.conf) - } else { - output, err = RunAndCapture(relPkg, pkgDir) + var goldenIR []byte + checkIR := false + moduleID := "" + if opts.checkIR { + goldenIR, checkIR, err = readGolden(filepath.Join(pkgDir, "out.ll")) + if err != nil { + t.Fatal("ReadFile failed:", err) + } + if checkIR { + moduleID = moduleIDFromIR(goldenIR) + if moduleID == "" { + t.Fatalf("missing ModuleID in golden IR for %s", pkgDir) + } + } + } + conf := opts.conf + var capturedIR *string + if checkIR { + conf, capturedIR = withModuleCapture(opts.conf, moduleID) } + + output, err := runWithConf(relPkg, pkgDir, conf) if err != nil { t.Logf("raw output:\n%s", string(output)) t.Fatalf("run failed: %v\noutput: %s", err, string(output)) } - if opts.filter != nil { - output = []byte(opts.filter(string(output))) + + if checkOutput { + assertExpectedOutput(t, pkgDir, expectedOutput, output, opts) } - if test.Diff(t, filepath.Join(pkgDir, "expect.txt.new"), output, expected) { - t.Fatal("unexpected output") + if !checkIR { + return + } + if capturedIR == nil || *capturedIR == "" { + t.Fatalf("module snapshot missing for package %s", moduleID) + } + if test.Diff(t, filepath.Join(pkgDir, "result.txt"), []byte(*capturedIR), goldenIR) { + t.Fatal("unexpected IR output") } } @@ -262,6 +294,31 @@ func RunAndCapture(relPkg, pkgDir string) ([]byte, error) { // RunAndCaptureWithConf runs llgo with a custom build config and captures output. func RunAndCaptureWithConf(relPkg, pkgDir string, conf *build.Config) ([]byte, error) { + return runWithConf(relPkg, pkgDir, conf) +} + +func withModuleCapture(conf *build.Config, targetPkgPath string) (*build.Config, *string) { + if conf == nil { + conf = build.NewDefaultConf(build.ModeRun) + } + localConf := *conf + var module string + prevHook := localConf.ModuleHook + localConf.ModuleHook = func(pkgPath string, mod gllvm.Module) { + if prevHook != nil { + prevHook(pkgPath, mod) + } + if pkgPath == targetPkgPath && module == "" { + module = mod.String() + } + } + return &localConf, &module +} + +func runWithConf(relPkg, pkgDir string, conf *build.Config) ([]byte, error) { + if conf == nil { + conf = build.NewDefaultConf(build.ModeRun) + } cacheDir, err := os.MkdirTemp("", "llgo-gocache-*") if err != nil { return nil, err @@ -279,9 +336,6 @@ func RunAndCaptureWithConf(relPkg, pkgDir string, conf *build.Config) ([]byte, e } }() - if conf == nil { - return nil, fmt.Errorf("build config is nil") - } localConf := *conf originalStdout := os.Stdout @@ -317,11 +371,6 @@ func RunAndCaptureWithConf(relPkg, pkgDir string, conf *build.Config) ([]byte, e } defer os.Chdir(origDir) relPkg = "." - if _, err := os.Stat(filepath.Join(pkgDir, "in.go")); err == nil { - relPkg = "in.go" - } else if _, err := os.Stat(filepath.Join(pkgDir, "main.go")); err == nil { - relPkg = "main.go" - } } mockable.EnableMock() @@ -349,6 +398,43 @@ func RunAndCaptureWithConf(relPkg, pkgDir string, conf *build.Config) ([]byte, e return output, nil } +func assertExpectedOutput(t *testing.T, pkgDir string, expectedOutput, output []byte, opts runOptions) { + t.Helper() + if opts.filter != nil { + output = []byte(opts.filter(string(output))) + } + if test.Diff(t, filepath.Join(pkgDir, "expect.txt.new"), output, expectedOutput) { + t.Fatal("unexpected output") + } +} + +func readGolden(file string) ([]byte, bool, error) { + data, err := os.ReadFile(file) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + return nil, false, err + } + if bytes.Equal(data, []byte{';'}) { + return data, false, nil + } + return data, true, nil +} + +func moduleIDFromIR(data []byte) string { + line := data + if idx := bytes.IndexByte(line, '\n'); idx >= 0 { + line = line[:idx] + } + line = bytes.TrimSpace(line) + const prefix = "; ModuleID = '" + if !bytes.HasPrefix(line, []byte(prefix)) || !bytes.HasSuffix(line, []byte{'\''}) { + return "" + } + return string(line[len(prefix) : len(line)-1]) +} + func filterRunOutput(in []byte) []byte { // Tests compare output with expect.txt. Some toolchain/environment warnings are // inherently machine-specific and should not be part of the golden output. diff --git a/cl/compile_test.go b/cl/compile_test.go index 892d96238c..d3f0134aae 100644 --- a/cl/compile_test.go +++ b/cl/compile_test.go @@ -162,18 +162,15 @@ func runEmbedTargetSuite(t *testing.T, target, relDir string, ignore []string) { conf := build.NewDefaultConf(build.ModeRun) conf.Target = target conf.Emulator = true - cltest.RunFromDir(t, "", relDir, ignore, + cltest.RunAndTestFromDir(t, "", relDir, ignore, cltest.WithRunConfig(conf), cltest.WithOutputFilter(cltest.FilterEmulatorOutput), + cltest.WithIRCheck(false), ) } -func TestFromTestgo(t *testing.T) { - cltest.FromDir(t, "", "./_testgo") -} - -func TestRunFromTestgo(t *testing.T) { - cltest.RunFromDir(t, "", "./_testgo", nil) +func TestRunAndTestFromTestgo(t *testing.T) { + cltest.RunAndTestFromDir(t, "", "./_testgo", nil) } func TestFilterEmulatorOutput(t *testing.T) { @@ -254,41 +251,25 @@ func TestRunFromTestgoSelectAllowsKnownInterleavings(t *testing.T) { } } -func TestFromTestpy(t *testing.T) { - cltest.FromDir(t, "", "./_testpy") -} - -func TestRunFromTestpy(t *testing.T) { - cltest.RunFromDir(t, "", "./_testpy", nil) -} - -func TestFromTestlibgo(t *testing.T) { - cltest.FromDir(t, "", "./_testlibgo") +func TestRunAndTestFromTestpy(t *testing.T) { + cltest.RunAndTestFromDir(t, "", "./_testpy", nil) } -func TestRunFromTestlibgo(t *testing.T) { - cltest.RunFromDir(t, "", "./_testlibgo", nil) +func TestRunAndTestFromTestlibgo(t *testing.T) { + cltest.RunAndTestFromDir(t, "", "./_testlibgo", nil) } -func TestFromTestlibc(t *testing.T) { - cltest.FromDir(t, "", "./_testlibc") -} - -func TestRunFromTestlibc(t *testing.T) { +func TestRunAndTestFromTestlibc(t *testing.T) { var ignore []string if runtime.GOOS == "linux" { ignore = []string{ "./_testlibc/demangle", // Linux demangle symbol differs (itaniumDemangle linkage mismatch). } } - cltest.RunFromDir(t, "", "./_testlibc", ignore) -} - -func TestFromTestrt(t *testing.T) { - cltest.FromDir(t, "", "./_testrt") + cltest.RunAndTestFromDir(t, "", "./_testlibc", ignore) } -func TestRunFromTestrt(t *testing.T) { +func TestRunAndTestFromTestrt(t *testing.T) { var ignore []string if runtime.GOOS == "linux" { ignore = []string{ @@ -296,15 +277,11 @@ func TestRunFromTestrt(t *testing.T) { "./_testrt/fprintf", // Linux uses different stderr symbol (no __stderrp). } } - cltest.RunFromDir(t, "", "./_testrt", ignore) -} - -func TestFromTestdata(t *testing.T) { - cltest.FromDir(t, "", "./_testdata") + cltest.RunAndTestFromDir(t, "", "./_testrt", ignore) } -func TestRunFromTestdata(t *testing.T) { - cltest.RunFromDir(t, "", "./_testdata", nil) +func TestRunAndTestFromTestdata(t *testing.T) { + cltest.RunAndTestFromDir(t, "", "./_testdata", nil) } func TestCgofullGeneratesC2func(t *testing.T) { diff --git a/internal/build/build.go b/internal/build/build.go index ae14ce01c8..fc8eade773 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -113,6 +113,13 @@ type OutFmtDetails struct { Zip string // ZIP/DFU output file path (.zip) } +// ModuleHook observes a package module immediately after it is generated and +// before TransformModule mutates it. The callback runs synchronously and +// receives the live llvm.Module, so callers that need a stable snapshot should +// consume it immediately (for example, by calling mod.String() inside the +// hook). +type ModuleHook func(pkgPath string, mod gllvm.Module) + type Config struct { Goos string Goarch string @@ -148,6 +155,7 @@ type Config struct { // string-typed globals are supported and "main" applies to all root main // packages in the current build. GlobalRewrites map[string]Rewrites + ModuleHook ModuleHook } type Rewrites map[string]string @@ -1230,6 +1238,9 @@ func buildPkg(ctx *context, aPkg *aPackage, verbose bool) error { check(err) aPkg.LPkg = ret + if hook := ctx.buildConf.ModuleHook; hook != nil { + hook(pkgPath, ret.Module()) + } // If cache hit, we only needed to register types - skip compilation if aPkg.CacheHit { diff --git a/internal/build/module_hook_test.go b/internal/build/module_hook_test.go new file mode 100644 index 0000000000..dc7a13baae --- /dev/null +++ b/internal/build/module_hook_test.go @@ -0,0 +1,39 @@ +//go:build !llgo +// +build !llgo + +package build + +import ( + "testing" + + gllvm "github.com/goplus/llvm" +) + +func TestModuleHookReceivesMainPackageModule(t *testing.T) { + conf := NewDefaultConf(ModeGen) + + counts := make(map[string]int) + snapshots := make(map[string]string) + conf.ModuleHook = func(pkgPath string, mod gllvm.Module) { + counts[pkgPath]++ + if _, ok := snapshots[pkgPath]; !ok { + snapshots[pkgPath] = mod.String() + } + } + + pkgs, err := Do([]string{"../../cl/_testgo/print"}, conf) + if err != nil { + t.Fatalf("Do failed: %v", err) + } + if len(pkgs) != 1 { + t.Fatalf("expected 1 initial package, got %d", len(pkgs)) + } + + mainPkg := pkgs[0].PkgPath + if counts[mainPkg] != 1 { + t.Fatalf("expected hook to fire once for %s, got %d", mainPkg, counts[mainPkg]) + } + if snapshots[mainPkg] == "" { + t.Fatalf("expected non-empty module snapshot for %s", mainPkg) + } +}