diff --git a/internal/build/artifact_report.go b/internal/build/artifact_report.go new file mode 100644 index 0000000000..ed8272ab25 --- /dev/null +++ b/internal/build/artifact_report.go @@ -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 +} diff --git a/internal/build/artifact_report_test.go b/internal/build/artifact_report_test.go new file mode 100644 index 0000000000..39c3fd2d2b --- /dev/null +++ b/internal/build/artifact_report_test.go @@ -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) + } +} diff --git a/internal/build/build.go b/internal/build/build.go index 361cd57b97..058bb7d2d1 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -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 } @@ -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: