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
82 changes: 74 additions & 8 deletions internal/formula/formula.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ import (
"io/fs"
"path/filepath"
"reflect"
"slices"
"strings"

"github.com/goplus/ixgo"
"github.com/goplus/ixgo/xgobuild"
"github.com/goplus/llar/formula"
"golang.org/x/tools/go/ssa"
"golang.org/x/tools/go/ssa/ssautil"

_ "github.com/goplus/llar/internal/ixgo"
)
Expand All @@ -28,12 +31,59 @@ type Formula struct {
// the method declaration of ModuleF in formula/classfile.go
ModPath string
FromVer string
Matrix formula.Matrix
OnRequire func(proj *formula.Project, deps *formula.ModuleDeps)
OnBuild func(ctx *formula.Context, proj *formula.Project, out *formula.BuildResult)
OnTest func(ctx *formula.Context, proj *formula.Project, out *formula.TestResult)
Filter func() bool
}

type ssaState struct {
blocks map[*ssa.BasicBlock][]ssa.Instruction
referrers map[ssa.Value][]ssa.Instruction
}

func saveSSAState(prog *ssa.Program) ssaState {
state := ssaState{
blocks: make(map[*ssa.BasicBlock][]ssa.Instruction),
referrers: make(map[ssa.Value][]ssa.Instruction),
}
for fn := range ssautil.AllFunctions(prog) {
for _, block := range fn.Blocks {
state.blocks[block] = slices.Clone(block.Instrs)
for _, instr := range block.Instrs {
if value, ok := instr.(ssa.Value); ok {
state.saveReferrers(value)
}
for _, operand := range instr.Operands(nil) {
if operand != nil && *operand != nil {
state.saveReferrers(*operand)
}
}
}
}
}
return state
}

func (s ssaState) saveReferrers(value ssa.Value) {
if _, ok := s.referrers[value]; ok {
return
}
if refs := value.Referrers(); refs != nil {
s.referrers[value] = slices.Clone(*refs)
}
}

func (s ssaState) restore() {
for block, instrs := range s.blocks {
block.Instrs = instrs
}
for value, refs := range s.referrers {
*value.Referrers() = refs
}
}

// loadFS is the internal implementation for loading a formula from a filesystem.
// It builds and interprets the formula file using the xgo classfile mechanism,
// then extracts the struct fields containing module metadata and callbacks.
Expand Down Expand Up @@ -94,8 +144,30 @@ func loadFS(fs fs.ReadFileFS, path string) (*Formula, error) {
if err != nil {
return nil, err
}
state := saveSSAState(pkgs.Prog)
tr := newTracker()
tracked := tr.track(ctx, pkgs)

// Extract struct name from filename: "hello_llar.gox" -> "hello"
// The classfile mechanism generates a struct with this name
structName, _, ok := strings.Cut(filepath.Base(path), "_")
if !ok {
return nil, fmt.Errorf("failed to load formula: file name is not valid: %s", path)
}

var matrix formula.Matrix
if tracked {
matrix, err = probeFormula(ctx, pkgs, structName, tr)
if err != nil {
return nil, err
}
}

// NewInterp translates SSA eagerly. The probe interpreter retains the
// instrumented instructions, while the production interpreter below sees
// the original SSA restored from this snapshot.
state.restore()

// Create a new interpreter for the loaded package
interp, err := ctx.NewInterp(pkgs)
if err != nil {
return nil, err
Expand All @@ -106,13 +178,6 @@ func loadFS(fs fs.ReadFileFS, path string) (*Formula, error) {
return nil, err
}

// Extract struct name from filename: "hello_llar.gox" -> "hello"
// The classfile mechanism generates a struct with this name
structName, _, ok := strings.Cut(filepath.Base(path), "_")
if !ok {
return nil, fmt.Errorf("failed to load formula: file name is not valid: %s", path)
}

// Get the generated struct type from the interpreter
typ, ok := interp.GetType(structName)
if !ok {
Expand All @@ -136,6 +201,7 @@ func loadFS(fs fs.ReadFileFS, path string) (*Formula, error) {
structElem: class,
ModPath: valueOf(class, "modPath").(string),
FromVer: valueOf(class, "modFromVer").(string),
Matrix: matrix,
OnBuild: valueOf(class, "fOnBuild").(func(*formula.Context, *formula.Project, *formula.BuildResult)),
OnTest: valueOf(class, "fOnTest").(func(*formula.Context, *formula.Project, *formula.TestResult)),
OnRequire: valueOf(class, "fOnRequire").(func(*formula.Project, *formula.ModuleDeps)),
Expand Down
26 changes: 26 additions & 0 deletions internal/formula/formula_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package formula
import (
"io/fs"
"os"
"reflect"
"testing"

formulapkg "github.com/goplus/llar/formula"
Expand Down Expand Up @@ -79,6 +80,14 @@ func TestLoadFS_TargetSurface(t *testing.T) {
if !f.Filter() {
t.Fatal("Filter() = false, want true")
}
wantRequire := map[string][]string{"os": nil}
if !reflect.DeepEqual(f.Matrix.Require, wantRequire) {
t.Fatalf("Matrix.Require = %#v, want %#v", f.Matrix.Require, wantRequire)
}
wantOptions := map[string][]string{"debug": nil, "zlib": nil}
if !reflect.DeepEqual(f.Matrix.Options, wantOptions) {
t.Fatalf("Matrix.Options = %#v, want %#v", f.Matrix.Options, wantOptions)
}

var deps formulapkg.ModuleDeps
f.OnRequire(&formulapkg.Project{}, &deps)
Expand All @@ -89,6 +98,23 @@ func TestLoadFS_TargetSurface(t *testing.T) {
f.OnBuild(&formulapkg.Context{}, &formulapkg.Project{}, &formulapkg.BuildResult{})
}

func TestLoadFSProbesMatrixKeys(t *testing.T) {
fsys := os.DirFS("testdata/formula").(fs.ReadFileFS)
f, err := LoadFS(fsys, "matrix_llar.gox")
if err != nil {
t.Fatalf("LoadFS failed: %v", err)
}

wantRequire := map[string][]string{"os": nil}
if !reflect.DeepEqual(f.Matrix.Require, wantRequire) {
t.Fatalf("Matrix.Require = %#v, want %#v", f.Matrix.Require, wantRequire)
}
wantOptions := map[string][]string{"debug": nil, "ssl": nil}
if !reflect.DeepEqual(f.Matrix.Options, wantOptions) {
t.Fatalf("Matrix.Options = %#v, want %#v", f.Matrix.Options, wantOptions)
}
}

func TestFormula_SetStdout(t *testing.T) {
formula, err := loadFS(os.DirFS("testdata/formula").(fs.ReadFileFS), "hello_llar.gox")
if err != nil {
Expand Down
92 changes: 92 additions & 0 deletions internal/formula/probe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package formula

import (
"fmt"
"io/fs"
"reflect"

"github.com/goplus/ixgo"
classfile "github.com/goplus/llar/formula"
"github.com/goplus/llar/mod/module"
"golang.org/x/tools/go/ssa"
)

type probeFS struct{}

func (probeFS) Open(string) (fs.File, error) {
return nil, fs.ErrNotExist
}

func (probeFS) ReadFile(string) ([]byte, error) {
return nil, fs.ErrNotExist
}

func probeFormula(ctx *ixgo.Context, pkg *ssa.Package, structName string, tr *tracker) (classfile.Matrix, error) {
interp, err := ctx.NewInterp(pkg)
if err != nil {
return classfile.Matrix{}, err
}
if err = interp.RunInit(); err != nil {
return classfile.Matrix{}, err
}
typ, ok := interp.GetType(structName)
if !ok {
return classfile.Matrix{}, fmt.Errorf("failed to load formula: struct name not found: %s", structName)
}
val := reflect.New(typ)
class := val.Elem()
val.Interface().(interface{ Main() }).Main()
f := &Formula{
structElem: class,
OnBuild: valueOf(class, "fOnBuild").(func(*classfile.Context, *classfile.Project, *classfile.BuildResult)),
OnRequire: valueOf(class, "fOnRequire").(func(*classfile.Project, *classfile.ModuleDeps)),
}

originalTarget := valueOf(f.structElem, "target").(classfile.Matrix)
// Probe with empty maps because discovery does not require a valid matrix.
// A formula may read several independent options while configuring a build:
//
// if has(target.options["zlib"], "ON") { ... }
// shared := has(target.options["shared"], "ON")
// debug := has(target.options["debug"], "ON")
//
// Each lookup returns an empty value, but the SSA tracker still records zlib,
// shared, and debug before the formula eventually succeeds or fails. The maps
// must be non-nil because their runtime identities also let the tracker follow
// aliases and helper arguments.
fakeTarget := classfile.Matrix{
Require: make(map[string][]string),
Options: make(map[string][]string),
}
setValue(f.structElem, "target", fakeTarget)
defer setValue(f.structElem, "target", originalTarget)

project := &classfile.Project{SourceFS: probeFS{}}
if f.OnRequire != nil {
var deps classfile.ModuleDeps
safeProbeCall(func() {
f.OnRequire(project, &deps)
})
}
if f.OnBuild != nil {
ctx := classfile.NewContext("", "", "", func(string, module.Version) (string, error) {
return "", nil
})
var out classfile.BuildResult
safeProbeCall(func() {
f.OnBuild(ctx, project, &out)
})
}
return tr.matrix(), nil
}

func safeProbeCall(call func()) {
defer func() {
_ = recover()
}()
call()
}
20 changes: 20 additions & 0 deletions internal/formula/testdata/formula/matrix_llar.gox
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
func lookup(values map[string][]string, key string) []string {
return values[key]
}

id "test/matrix"

fromVer "v1.0.0"

onRequire (proj, deps) => {
require := target.require
if len(lookup(require, "os")) == 0 {
options := target.options
_ = lookup(options, "ssl")
}
}

onBuild (ctx, proj, out) => {
options := target.options
_ = len(lookup(options, "debug"))
}
77 changes: 77 additions & 0 deletions internal/formula/testdata/formula/trackcases_llar.gox
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
type namedOptions map[string][]string

type aliasedOptions = map[string][]string

type matrixHolder struct {
values map[string][]string
}

func matrixLookup(values map[string][]string, key string) []string {
return values[key]
}

func matrixPassthrough(values map[string][]string) map[string][]string {
return values
}

func dynamicMatrixKey() string {
return "dynamic"
}

id "test/track-cases"

fromVer "v1.0.0"

onRequire (proj, deps) => {
_ = target.require["direct-require"]

require := target.require
_ = matrixLookup(require, "helper-require")
_ = require["same"]

unrelated := map[string][]string{"ignored-require": []string{"value"}}
_ = matrixLookup(unrelated, "ignored-require")
}

onBuild (ctx, proj, out) => {
_ = target.options["direct-option"]

options := target.options
alias := options
_ = alias["alias"]
_ = alias["alias"]
_ = matrixLookup(options, "helper-option")
_ = matrixLookup(matrixPassthrough(options), "returned")

named := namedOptions(options)
_ = named["named"]
aliased := aliasedOptions(options)
_ = aliased["type-alias"]

optionsPtr := &options
_ = (*optionsPtr)["pointer"]

key := dynamicMatrixKey()
_ = options[key]

_, ok := options["comma-ok"]
_ = ok

holder := matrixHolder{values: options}
_ = holder.values["struct"]

var boxed any = options
fromInterface := boxed.(map[string][]string)
_ = fromInterface["interface"]

func() {
_ = options["closure"]
}()

_ = options["same"]

unrelated := map[string][]string{"ignored-option": []string{"value"}}
_ = matrixLookup(unrelated, "ignored-option")
var nilMap map[string][]string
_ = nilMap["ignored-nil"]
}
8 changes: 8 additions & 0 deletions internal/formula/testdata/formula/trackfilter_llar.gox
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
id "test/track-filter"

fromVer "v1.0.0"

filter => {
_ = target.options["filter-only"]
return false
}
12 changes: 12 additions & 0 deletions internal/formula/testdata/formula/trackpanic_llar.gox
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
id "test/track-panic"

fromVer "v1.0.0"

onRequire (proj, deps) => {
_ = target.options["before-panic"]
panic("stop require probe")
}

onBuild (ctx, proj, out) => {
_ = target.options["after-panic"]
}
Loading
Loading