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
144 changes: 144 additions & 0 deletions internal/build/artifact_report.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package build

import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
)

// ArtifactRole distinguishes debugger-owned metadata from bytes delivered to
// a target and from optional runtime symbolization data.
type ArtifactRole string

const (
ArtifactRoleDebug ArtifactRole = "debug"
ArtifactRoleDeployment ArtifactRole = "deployment"
ArtifactRoleDebugDeployment ArtifactRole = "debug+deployment"
ArtifactRoleRuntimeSymbols ArtifactRole = "runtime-symbols"
)

// Artifact describes one final build output. Size is the on-disk byte size;
// deployment formats therefore remain distinct from their host debug image.
type Artifact struct {
Role ArtifactRole
Format string
Path string
Size int64
}

// CollectArtifacts returns the final artifacts represented by out. It should
// be called after post-link packaging and target format conversion complete.
func CollectArtifacts(conf *Config, out *OutFmtDetails) ([]Artifact, error) {
if conf == nil || out == nil {
return nil, nil
}
artifacts := make([]Artifact, 0, 9)
add := func(role ArtifactRole, format, path string) error {
if path == "" {
return nil
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("stat %s artifact %q: %w", role, path, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("%s artifact %q is not a regular file", role, path)
}
artifacts = append(artifacts, Artifact{Role: role, Format: format, Path: path, Size: info.Size()})
return nil
}

var primaryRole ArtifactRole
switch conf.DebugArtifactMode {
case DebugArtifactEmbedded:
primaryRole = ArtifactRoleDebugDeployment
case DebugArtifactExternal, DebugArtifactNone:
primaryRole = ArtifactRoleDeployment
case DebugArtifactHost:
primaryRole = ArtifactRoleDebug
default:
return nil, fmt.Errorf("unresolved debug artifact mode %s", conf.DebugArtifactMode)
}
if out.Out == "" {
return nil, fmt.Errorf("primary artifact path is empty")
}
if err := add(primaryRole, primaryArtifactFormat(conf, out.Out), out.Out); err != nil {
return nil, err
}
if err := add(ArtifactRoleDebug, "wasm-dwarf", out.DWARF); err != nil {
return nil, err
}
if err := add(ArtifactRoleRuntimeSymbols, "pclntab", out.PCLN); err != nil {
return nil, err
}
for _, deployment := range []struct {
format string
path string
}{
{"bin", out.Bin},
{"hex", out.Hex},
{"img", out.Img},
{"uf2", out.Uf2},
{"zip", out.Zip},
} {
if err := add(ArtifactRoleDeployment, deployment.format, deployment.path); err != nil {
return nil, err
}
}
return artifacts, nil
}

func primaryArtifactFormat(conf *Config, path string) string {
if conf.BuildMode == BuildModeCArchive {
return "archive"
}
if conf.Goarch == "wasm" || strings.EqualFold(filepath.Ext(path), ".wasm") {
return "wasm"
}
if conf.Target != "" {
return "elf"
}
switch conf.Goos {
case "darwin":
return "macho"
case "windows":
return "pe"
case "linux", "android", "freebsd", "netbsd", "openbsd", "dragonfly", "solaris":
return "elf"
default:
return "executable"
}
}

func reportBuildArtifacts(conf *Config, out *OutFmtDetails, w io.Writer) error {
if conf == nil || (!conf.DebugArtifactModeSet && conf.Target == "") {
return nil
}
artifacts, err := CollectArtifacts(conf, out)
if err != nil {
return err
}
for _, artifact := range artifacts {
fmt.Fprintf(w, "llgo: artifact role=%s format=%s size=%d path=%q\n",
artifact.Role, artifact.Format, artifact.Size, artifact.Path)
}
return nil
}
189 changes: 189 additions & 0 deletions internal/build/artifact_report_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
//go:build !llgo

package build

import (
"bytes"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
)

func TestCollectArtifacts(t *testing.T) {
dir := t.TempDir()
write := func(name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
main := write("app.wasm", "main")
elf := write("app.elf", "elf-data")
dwarf := write("app.debug.wasm", "debug")
pcln := write("app.wasm.pclntab", "pcln")
bin := write("app.bin", "bin")
hex := write("app.hex", "hex-data")

tests := []struct {
name string
conf Config
out OutFmtDetails
want []Artifact
}{
{
name: "embedded",
conf: Config{Goarch: "wasm", DebugArtifactMode: DebugArtifactEmbedded},
out: OutFmtDetails{Out: main},
want: []Artifact{{Role: ArtifactRoleDebugDeployment, Format: "wasm", Path: main, Size: 4}},
},
{
name: "external with runtime symbols",
conf: Config{Goarch: "wasm", DebugArtifactMode: DebugArtifactExternal},
out: OutFmtDetails{Out: main, DWARF: dwarf, PCLN: pcln},
want: []Artifact{
{Role: ArtifactRoleDeployment, Format: "wasm", Path: main, Size: 4},
{Role: ArtifactRoleDebug, Format: "wasm-dwarf", Path: dwarf, Size: 5},
{Role: ArtifactRoleRuntimeSymbols, Format: "pclntab", Path: pcln, Size: 4},
},
},
{
name: "host and deployment formats",
conf: Config{Target: "cortex-m-qemu", DebugArtifactMode: DebugArtifactHost},
out: OutFmtDetails{Out: elf, Bin: bin, Hex: hex},
want: []Artifact{
{Role: ArtifactRoleDebug, Format: "elf", Path: elf, Size: 8},
{Role: ArtifactRoleDeployment, Format: "bin", Path: bin, Size: 3},
{Role: ArtifactRoleDeployment, Format: "hex", Path: hex, Size: 8},
},
},
{
name: "none",
conf: Config{DebugArtifactMode: DebugArtifactNone},
out: OutFmtDetails{Out: main},
want: []Artifact{{Role: ArtifactRoleDeployment, Format: "wasm", Path: main, Size: 4}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := CollectArtifacts(&tt.conf, &tt.out)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("CollectArtifacts() = %#v, want %#v", got, tt.want)
}
})
}
}

func TestCollectArtifactsValidation(t *testing.T) {
if got, err := CollectArtifacts(nil, nil); err != nil || got != nil {
t.Fatalf("CollectArtifacts(nil) = %#v, %v", got, err)
}
if _, err := CollectArtifacts(&Config{}, &OutFmtDetails{}); err == nil || !strings.Contains(err.Error(), "unresolved") {
t.Fatalf("unresolved mode error = %v", err)
}
if _, err := CollectArtifacts(&Config{DebugArtifactMode: DebugArtifactNone}, &OutFmtDetails{}); err == nil || !strings.Contains(err.Error(), "path is empty") {
t.Fatalf("empty primary path error = %v", err)
}
if _, err := CollectArtifacts(
&Config{DebugArtifactMode: DebugArtifactNone},
&OutFmtDetails{Out: filepath.Join(t.TempDir(), "missing")},
); err == nil || !strings.Contains(err.Error(), "stat deployment artifact") {
t.Fatalf("missing artifact error = %v", err)
}
dir := t.TempDir()
if _, err := CollectArtifacts(
&Config{DebugArtifactMode: DebugArtifactNone},
&OutFmtDetails{Out: dir},
); err == nil || !strings.Contains(err.Error(), "not a regular file") {
t.Fatalf("directory artifact error = %v", err)
}

main := filepath.Join(t.TempDir(), "app")
if err := os.WriteFile(main, []byte("main"), 0o644); err != nil {
t.Fatal(err)
}
for _, tt := range []struct {
name string
mode DebugArtifactMode
out OutFmtDetails
role ArtifactRole
}{
{name: "missing DWARF", mode: DebugArtifactExternal, out: OutFmtDetails{Out: main, DWARF: main + ".debug.wasm"}, role: ArtifactRoleDebug},
{name: "missing runtime symbols", mode: DebugArtifactNone, out: OutFmtDetails{Out: main, PCLN: main + ".pclntab"}, role: ArtifactRoleRuntimeSymbols},
{name: "missing deployment format", mode: DebugArtifactNone, out: OutFmtDetails{Out: main, Bin: main + ".bin"}, role: ArtifactRoleDeployment},
} {
t.Run(tt.name, func(t *testing.T) {
_, err := CollectArtifacts(&Config{DebugArtifactMode: tt.mode}, &tt.out)
if err == nil || !strings.Contains(err.Error(), "stat "+string(tt.role)+" artifact") {
t.Fatalf("CollectArtifacts() error = %v", err)
}
})
}
}

func TestPrimaryArtifactFormat(t *testing.T) {
tests := []struct {
name string
conf Config
path string
want string
}{
{name: "wasm architecture", conf: Config{Goarch: "wasm"}, path: "app", want: "wasm"},
{name: "wasm extension", path: "app.WASM", want: "wasm"},
{name: "target ELF", conf: Config{Target: "cortex-m-qemu"}, path: "app.elf", want: "elf"},
{name: "archive", conf: Config{Goarch: "wasm", BuildMode: BuildModeCArchive}, path: "libapp.a", want: "archive"},
{name: "Mach-O", conf: Config{Goos: "darwin", BuildMode: BuildModeCShared}, path: "libapp.dylib", want: "macho"},
{name: "PE", conf: Config{Goos: "windows"}, path: "app.exe", want: "pe"},
{name: "ELF", conf: Config{Goos: "linux", BuildMode: BuildModeCShared}, path: "libapp.so", want: "elf"},
{name: "executable", conf: Config{BuildMode: BuildModeExe}, path: "app", want: "executable"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := primaryArtifactFormat(&tt.conf, tt.path); got != tt.want {
t.Fatalf("primaryArtifactFormat() = %q, want %q", got, tt.want)
}
})
}
}

func TestReportBuildArtifacts(t *testing.T) {
path := filepath.Join(t.TempDir(), "app with space")
if err := os.WriteFile(path, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
var report bytes.Buffer
conf := &Config{DebugArtifactMode: DebugArtifactNone, DebugArtifactModeSet: true}
if err := reportBuildArtifacts(conf, &OutFmtDetails{Out: path}, &report); err != nil {
t.Fatal(err)
}
want := "llgo: artifact role=deployment format=executable size=4 path=" + strconv.Quote(path) + "\n"
if got := report.String(); got != want {
t.Fatalf("artifact report = %q, want %q", got, want)
}

report.Reset()
conf.DebugArtifactModeSet = false
if err := reportBuildArtifacts(conf, &OutFmtDetails{Out: path}, &report); err != nil || report.Len() != 0 {
t.Fatalf("implicit artifact report = %q, %v", report.String(), err)
}
conf.DebugArtifactModeSet = true
if err := reportBuildArtifacts(conf, &OutFmtDetails{Out: path + ".missing"}, &report); err == nil {
t.Fatal("reportBuildArtifacts() succeeded with a missing artifact")
}

conf.Target = "cortex-m-qemu"
conf.DebugArtifactMode = DebugArtifactHost
if err := reportBuildArtifacts(conf, &OutFmtDetails{Out: path}, &report); err != nil {
t.Fatal(err)
}
if got := report.String(); !strings.Contains(got, "role=debug format=elf size=4") {
t.Fatalf("target artifact report = %q", got)
}
}
6 changes: 6 additions & 0 deletions internal/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,9 @@ func Build(inv Invocation) ([]Package, error) {
if headerErr != nil {
return nil, headerErr
}
if err := reportBuildArtifacts(conf, outFmts, os.Stderr); err != nil {
return nil, err
}
continue
}

Expand All @@ -702,6 +705,9 @@ func Build(inv Invocation) ([]Package, error) {
return nil, err
}
}
if err := reportBuildArtifacts(conf, outFmts, os.Stderr); err != nil {
return nil, err
}

switch mode {
case ModeBuild:
Expand Down
Loading