Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 121 additions & 32 deletions cl/cltest/cltest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -79,11 +80,12 @@ func FromDir(t *testing.T, sel, relDir string) {
}

type runOptions struct {
conf *build.Config
filter func(string) string
conf *build.Config
filter func(string) string
checkIR bool
}

// RunOption customizes RunFromDir behavior.
// RunOption customizes directory-based test behavior.
type RunOption func(*runOptions)

// WithRunConfig uses the provided build config for test runs.
Expand All @@ -100,6 +102,13 @@ func WithOutputFilter(filter func(string) string) RunOption {
}
}

// 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")
Expand All @@ -117,9 +126,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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This function duplicates the directory traversal, ignore set handling, and test selection logic from RunFromDir. Consider refactoring the common logic into a shared helper function to improve maintainability and reduce code duplication.

rootDir, err := os.Getwd()
if err != nil {
t.Fatal("Getwd failed:", err)
Expand All @@ -129,7 +139,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}
for _, opt := range opts {
opt(&options)
}
Expand All @@ -155,7 +165,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)
})
}
}
Expand Down Expand Up @@ -211,37 +221,62 @@ 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)

expectedOutput, checkOutput, err := readGolden(filepath.Join(pkgDir, "expect.txt"))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return
}
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")
}
}

Expand All @@ -252,6 +287,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
Expand All @@ -269,9 +329,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
Expand Down Expand Up @@ -307,11 +364,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()
Expand Down Expand Up @@ -339,6 +391,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 {
lines := strings.Split(string(data), "\n")
if len(lines) == 0 {
return ""
}
const prefix = "; ModuleID = '"
line := strings.TrimSpace(lines[0])
if !strings.HasPrefix(line, prefix) || !strings.HasSuffix(line, "'") {
return ""
}
return strings.TrimSuffix(strings.TrimPrefix(line, prefix), "'")
Comment on lines +419 to +428
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

strings.Split(string(data), "\n") is inefficient for large IR files as it copies the entire buffer into a string and then creates a slice for every line. Since only the first line is needed to extract the ModuleID, it's better to use bytes operations to avoid unnecessary allocations.

References
  1. Optimize for efficiency and avoid unnecessary iterations or memory allocations.

}

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.
Expand Down
51 changes: 14 additions & 37 deletions cl/compile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -254,57 +251,37 @@ 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{
"./_testrt/asmfull", // Output is macOS-specific.
Comment on lines +272 to 276
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original TestFromTestrt called cl.SetDebug(cl.DbgFlagAll) before running IR tests, exercising the compiler's debug-logging paths. The merged test no longer enables debug mode, silently dropping that coverage. If the debug-mode testing was intentional, consider preserving it (e.g., via a WithDebug run option or a separate subtest).

"./_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) {
Expand Down
11 changes: 11 additions & 0 deletions internal/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading