diff --git a/.github/actions/test-helloworld/action.yml b/.github/actions/test-helloworld/action.yml index 9020e7887b..54474a8447 100644 --- a/.github/actions/test-helloworld/action.yml +++ b/.github/actions/test-helloworld/action.yml @@ -58,6 +58,6 @@ runs: func main() { } EOL - llgo build -v -target esp32-coreboard-v2 -o demo.out . - test -f demo.out.elf && echo "ESP32 cross-compilation test passed: demo.out.elf generated" + llgo build -v -target esp32-coreboard-v2 -o demo.elf . + test -f demo.elf && echo "ESP32 cross-compilation test passed: demo.elf generated" exit $? diff --git a/.github/workflows/llgo.yml b/.github/workflows/llgo.yml index d37781368e..350c6dfa83 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -347,7 +347,7 @@ jobs: echo "Testing cross-compilation wasm with $(go env GOVERSION)" # Compile for wasm architecture - GOOS=wasip1 GOARCH=wasm llgo build -o hello -tags=nogc -v ./helloc + GOOS=wasip1 GOARCH=wasm llgo build -o hello.wasm -tags=nogc -v ./helloc # Check file type file hello.wasm diff --git a/_demo/embed/export/verify_export.sh b/_demo/embed/export/verify_export.sh index 41dd62b15f..275eb3c239 100755 --- a/_demo/embed/export/verify_export.sh +++ b/_demo/embed/export/verify_export.sh @@ -6,7 +6,7 @@ echo "Building for embedded target..." # Build for embedded target as executable # Use llgo directly instead of llgo.sh to avoid go.mod version check -llgo build -o test-verify --target=esp32 . +llgo build -o test-verify.elf --target=esp32 . echo "Checking exported symbols..." @@ -44,7 +44,7 @@ echo "" echo "Testing that non-embedded target rejects different export names..." # Build without --target should fail with panic -if llgo build -o test-notarget . 2>&1 | grep -q 'export comment has wrong name "LPSPI2_IRQHandler"'; then +if llgo build -o test-notarget.elf . 2>&1 | grep -q 'export comment has wrong name "LPSPI2_IRQHandler"'; then echo "✅ Correctly rejected different export name on non-embedded target" else echo "❌ Should have panicked with 'export comment has wrong name' error" diff --git a/_demo/embed/test_esp32c3_startup.sh b/_demo/embed/test_esp32c3_startup.sh index 647cb234d2..26558d862b 100755 --- a/_demo/embed/test_esp32c3_startup.sh +++ b/_demo/embed/test_esp32c3_startup.sh @@ -99,7 +99,7 @@ EOF echo "==> Building for ESP32-C3 target (ELF + BIN)..." cd "$TEMP_DIR" -llgo build -target=esp32c3 -o test -obin "$TEST_GO" +llgo build -target=esp32c3 -o test.elf -obin "$TEST_GO" if [ ! -f "$TEST_ELF" ]; then echo "✗ FAIL: Build failed, $TEST_ELF not found" diff --git a/_demo/go/export/test.sh b/_demo/go/export/test.sh index 2b292381b7..74a9d10afd 100755 --- a/_demo/go/export/test.sh +++ b/_demo/go/export/test.sh @@ -163,18 +163,22 @@ if [[ "$run_build_mode_tests" == true ]]; then # Test 1: c-shared mode print_status "=== Test 1: Building with -buildmode c-shared ===" -if $LLGO_SCRIPT build -buildmode c-shared -o export .; then +if [[ "$OSTYPE" == "darwin"* ]]; then + SHARED_LIB="libexport.dylib" +else + SHARED_LIB="libexport.so" +fi + +if $LLGO_SCRIPT build -buildmode c-shared -o "$SHARED_LIB" .; then print_status "Build succeeded" # Check generated files (different extensions on different platforms) if [[ "$OSTYPE" == "darwin"* ]]; then # macOS check_file "libexport.dylib" "Dynamic library (libexport.dylib)" - SHARED_LIB="libexport.dylib" else # Linux and others check_file "libexport.so" "Dynamic library (libexport.so)" - SHARED_LIB="libexport.so" fi check_file "libexport.h" "C header (libexport.h)" @@ -216,7 +220,7 @@ fi # Test 2: c-archive mode print_status "=== Test 2: Building with -buildmode c-archive ===" -if $LLGO_SCRIPT build -buildmode c-archive -o export .; then +if $LLGO_SCRIPT build -buildmode c-archive -o libexport.a .; then print_status "Build succeeded" # Check generated files @@ -314,6 +318,7 @@ else print_error "Go export demo execution failed" print_error "Error output:" cat /tmp/go_export_output.log | sed 's/^/ /' + build_failures=$((build_failures + 1)) fi # Cleanup temporary file diff --git a/_demo/go/export/use/Makefile b/_demo/go/export/use/Makefile index ee7dcee847..da66ac8688 100644 --- a/_demo/go/export/use/Makefile +++ b/_demo/go/export/use/Makefile @@ -48,7 +48,7 @@ all: build-go $(TARGET) # Build the Go library first build-go: @echo $(BUILD_MSG) - cd .. && ../../../dev/llgo.sh build -buildmode $(BUILDMODE) $(LLGOFLAGS) -o export . + cd .. && ../../../dev/llgo.sh build -buildmode $(BUILDMODE) $(LLGOFLAGS) -o $(notdir $(LIBRARY)) . # Build the C executable $(TARGET): $(SOURCES) $(LIBRARY) $(HEADER) diff --git a/doc/Embedded_Cmd.md b/doc/Embedded_Cmd.md index dc4dd762c8..a258567df8 100644 --- a/doc/Embedded_Cmd.md +++ b/doc/Embedded_Cmd.md @@ -2,7 +2,7 @@ ## Flags -- `-o ` - Specify output file name +- `-o ` - Specify output file name (used as-is; no prefix or extension is added) - `-target ` - Specify target platform for cross-compilation - `-obin` - Generate binary format output (requires `-target`) - `-ohex` - Generate Intel HEX format output (requires `-target`) diff --git a/internal/build/build.go b/internal/build/build.go index 6f870695c1..8458a321c2 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -733,8 +733,7 @@ func Build(inv Invocation) ([]Package, error) { // Generate C headers for c-archive and c-shared modes before linking if ctx.buildConf.BuildMode == BuildModeCArchive || ctx.buildConf.BuildMode == BuildModeCShared { - libname := strings.TrimSuffix(filepath.Base(outFmts.Out), conf.AppExt) - headerPath := filepath.Join(filepath.Dir(outFmts.Out), libname) + ".h" + libname, headerPath := cHeaderOutputPath(outFmts.Out) pkgs := cHeaderPackages(allPkgs) headerErr := header.GenHeaderFile(prog, pkgs, libname, headerPath, verbose) if headerErr != nil { @@ -801,6 +800,11 @@ func Build(inv Invocation) ([]Package, error) { return allPkgs, nil } +func cHeaderOutputPath(output string) (libname, headerPath string) { + libname = strings.TrimSuffix(filepath.Base(output), filepath.Ext(output)) + return libname, filepath.Join(filepath.Dir(output), libname) + ".h" +} + // cHeaderPackages excludes the patched standard runtime implementation. Its // //export callbacks are linker implementation details and may use internal C // types that are deliberately not representable in a public generated header. diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 05c28144ff..d2d38135c0 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -981,13 +981,33 @@ func TestRunPrintfWithStdioNobuf(t *testing.T) { mockRun([]string{"../../cl/_testdata/printf"}, &Config{Mode: ModeRun}) } +func TestCHeaderOutputPath(t *testing.T) { + tests := []struct { + output string + wantLibname string + wantHeaderPath string + }{ + {output: "libmylib.a", wantLibname: "libmylib", wantHeaderPath: "libmylib.h"}, + {output: "build/custom.so", wantLibname: "custom", wantHeaderPath: "build/custom.h"}, + {output: "/tmp/custom.dylib", wantLibname: "custom", wantHeaderPath: "/tmp/custom.h"}, + } + + for _, tt := range tests { + t.Run(tt.output, func(t *testing.T) { + libname, headerPath := cHeaderOutputPath(tt.output) + if libname != tt.wantLibname || headerPath != tt.wantHeaderPath { + t.Fatalf("cHeaderOutputPath(%q) = (%q, %q), want (%q, %q)", tt.output, libname, headerPath, tt.wantLibname, tt.wantHeaderPath) + } + }) + } +} + func TestTestOutputFileLogic(t *testing.T) { // Test output file path determination logic for test mode tests := []struct { name string pkgName string conf *Config - multiPkg bool wantBase string wantDir string description string @@ -996,34 +1016,14 @@ func TestTestOutputFileLogic(t *testing.T) { name: "compile only without -o", pkgName: "mypackage.test", conf: &Config{Mode: ModeTest, CompileOnly: true}, - multiPkg: false, wantBase: "mypackage.test", wantDir: ".", description: "-c without -o: write pkg.test in current directory", }, - { - name: "with -o absolute file path", - pkgName: "mypackage", - conf: &Config{Mode: ModeTest, OutFile: "/tmp/mytest.test", AppExt: ".test"}, - multiPkg: false, - wantBase: "mytest", - wantDir: "/tmp", - description: "-o with absolute file path: use specified file", - }, - { - name: "with -o relative file path", - pkgName: "mypackage", - conf: &Config{Mode: ModeTest, OutFile: "my.test", AppExt: ".test"}, - multiPkg: false, - wantBase: "my", - wantDir: ".", - description: "-o with relative file path: use specified file in current dir", - }, { name: "with -o directory", pkgName: "mypackage.test", conf: &Config{Mode: ModeTest, OutFile: "/tmp/build/", AppExt: ".test"}, - multiPkg: false, wantBase: "mypackage.test", wantDir: "/tmp/build/", description: "-o with directory: write pkg.test in that directory", @@ -1032,7 +1032,6 @@ func TestTestOutputFileLogic(t *testing.T) { name: "default test mode", pkgName: "mypackage", conf: &Config{Mode: ModeTest, AppExt: ".test"}, - multiPkg: false, wantBase: "mypackage", wantDir: "", description: "default test mode: use temp file", @@ -1041,7 +1040,7 @@ func TestTestOutputFileLogic(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - baseName, dir := determineBaseNameAndDir(tt.pkgName, tt.conf, tt.multiPkg) + baseName, dir := determineBaseNameAndDir(tt.pkgName, tt.conf) if baseName != tt.wantBase { t.Errorf("%s: got baseName=%q, want %q", tt.description, baseName, tt.wantBase) } diff --git a/internal/build/outputs.go b/internal/build/outputs.go index 901268e364..aeb2013540 100644 --- a/internal/build/outputs.go +++ b/internal/build/outputs.go @@ -54,36 +54,17 @@ func setOutFmt(conf *Config, formatName string) { } } -// buildOutFmts creates OutFmtDetails based on package, configuration and multi-package status // determineBaseNameAndDir extracts the base name and directory from configuration -func determineBaseNameAndDir(pkgName string, conf *Config, multiPkg bool) (baseName, dir string) { +func determineBaseNameAndDir(pkgName string, conf *Config) (baseName, dir string) { switch conf.Mode { case ModeInstall: return pkgName, conf.BinPath case ModeBuild: - if !multiPkg && conf.OutFile != "" { - dir = filepath.Dir(conf.OutFile) - baseName = strings.TrimSuffix(filepath.Base(conf.OutFile), conf.AppExt) - if dir == "." { - dir = "" - } - return baseName, dir - } return pkgName, "" case ModeTest: if conf.OutFile != "" { - // Handle -o flag for test mode - if strings.HasSuffix(conf.OutFile, "/") || isDir(conf.OutFile) { - // If OutFile ends in / or is a directory, write pkg.test in that directory - // pkgName for test packages already includes .test suffix - return pkgName, conf.OutFile - } - // Otherwise, use the specified file path - dir = filepath.Dir(conf.OutFile) - baseName = strings.TrimSuffix(filepath.Base(conf.OutFile), conf.AppExt) - // Don't convert "." to "" for test mode with explicit output - // This preserves the information that user specified an output file - return baseName, dir + // Explicit file paths are handled by buildOutFmts before reaching here. + return pkgName, conf.OutFile } if conf.CompileOnly { // -c without -o: write pkg.test in current directory @@ -103,6 +84,15 @@ func isDir(path string) bool { return err == nil && info.IsDir() } +// explicitOutFile reports whether the user controls the final output path verbatim. +func explicitOutFile(conf *Config, multiPkg bool) bool { + if conf.OutFile == "" || multiPkg { + return false + } + return conf.Mode == ModeBuild || + (conf.Mode == ModeTest && !strings.HasSuffix(conf.OutFile, "/") && !isDir(conf.OutFile)) +} + // applyPrefix applies build mode specific naming conventions func applyPrefix(baseName string, buildMode BuildMode, target string, goos string) string { // Determine the effective OS for naming conventions @@ -140,7 +130,9 @@ func applyPrefix(baseName string, buildMode BuildMode, target string, goos strin // buildOutputPath creates the final output path from baseName, dir and other parameters func buildOutputPath(baseName, dir string, conf *Config, multiPkg bool, appExt string) (string, error) { - baseName = applyPrefix(baseName, conf.BuildMode, conf.Target, conf.Goos) + if conf.OutFile == "" { + baseName = applyPrefix(baseName, conf.BuildMode, conf.Target, conf.Goos) + } if dir != "" { // dir == "." means current directory (explicit user specification) @@ -161,18 +153,21 @@ func buildOutputPath(baseName, dir string, conf *Config, multiPkg bool, appExt s } } +// buildOutFmts creates OutFmtDetails based on package, configuration and multi-package status. func buildOutFmts(pkgName string, conf *Config, multiPkg bool, crossCompile *crosscompile.Export) (*OutFmtDetails, error) { details := &OutFmtDetails{} - // Determine base name and directory - baseName, dir := determineBaseNameAndDir(pkgName, conf, multiPkg) - - // Build output path - outputPath, err := buildOutputPath(baseName, dir, conf, multiPkg, conf.AppExt) - if err != nil { - return nil, err + if explicitOutFile(conf, multiPkg) { + // An explicit -o file name is authoritative: do not rewrite its prefix or extension. + details.Out = conf.OutFile + } else { + baseName, dir := determineBaseNameAndDir(pkgName, conf) + outputPath, err := buildOutputPath(baseName, dir, conf, multiPkg, conf.AppExt) + if err != nil { + return nil, err + } + details.Out = outputPath } - details.Out = outputPath if conf.PCLNMode == PCLNExternal { details.PCLN = pclnSidecarPath(details.Out) } diff --git a/internal/build/outputs_test.go b/internal/build/outputs_test.go index 53686d8950..f8137ac7ae 100644 --- a/internal/build/outputs_test.go +++ b/internal/build/outputs_test.go @@ -4,6 +4,8 @@ package build import ( + "os" + "path/filepath" "strings" "testing" @@ -451,7 +453,18 @@ func TestBuildOutFmtsBuildModes(t *testing.T) { target: "", goos: "linux", appExt: ".a", - expectedOut: "libcustom.a", + expectedOut: "custom.a", + }, + { + name: "c_archive_build_with_outfile_without_extension", + pkgName: "mylib", + buildMode: BuildModeCArchive, + outFile: "custom", + mode: ModeBuild, + target: "", + goos: "linux", + appExt: ".a", + expectedOut: "custom", }, { name: "c_archive_build_with_path", @@ -462,7 +475,7 @@ func TestBuildOutFmtsBuildModes(t *testing.T) { target: "", goos: "linux", appExt: ".a", - expectedOut: "build/libcustom.a", + expectedOut: "build/custom.a", }, // C-Shared tests @@ -499,6 +512,17 @@ func TestBuildOutFmtsBuildModes(t *testing.T) { appExt: ".dylib", expectedOut: "libmylib.dylib", }, + { + name: "c_shared_build_with_outfile", + pkgName: "mylib", + buildMode: BuildModeCShared, + outFile: "custom.so", + mode: ModeBuild, + target: "", + goos: "darwin", + appExt: ".dylib", + expectedOut: "custom.so", + }, { name: "c_shared_embedded_target", pkgName: "mylib", @@ -545,6 +569,28 @@ func TestBuildOutFmtsBuildModes(t *testing.T) { appExt: "", expectedOut: "myapp", }, + { + name: "exe_preserve_outfile_lib_prefix", + pkgName: "myapp", + buildMode: BuildModeExe, + outFile: "libmyapp", + mode: ModeBuild, + target: "", + goos: "linux", + appExt: "", + expectedOut: "libmyapp", + }, + { + name: "exe_preserve_outfile_extension", + pkgName: "myapp", + buildMode: BuildModeExe, + outFile: "myapp.bin", + mode: ModeBuild, + target: "", + goos: "windows", + appExt: ".exe", + expectedOut: "myapp.bin", + }, } for _, tt := range tests { @@ -573,6 +619,53 @@ func TestBuildOutFmtsBuildModes(t *testing.T) { } } +func TestBuildOutFmtsExplicitOutFile(t *testing.T) { + tests := []struct { + name string + pkgName string + conf *Config + multiPkg bool + wantOut string + wantTempExt string + }{ + { + name: "test explicit file path", + pkgName: "mypackage.test", + conf: &Config{ + Mode: ModeTest, OutFile: "custom.test", AppExt: ".test", + }, + wantOut: "custom.test", + }, + { + name: "multiple build packages ignore explicit output file", + pkgName: "mylib", + conf: &Config{ + Mode: ModeBuild, OutFile: "custom.a", BuildMode: BuildModeCArchive, AppExt: ".a", + }, + multiPkg: true, + wantTempExt: ".a", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildOutFmts(tt.pkgName, tt.conf, tt.multiPkg, &crosscompile.Export{}) + if err != nil { + t.Fatal(err) + } + if tt.wantOut != "" && got.Out != tt.wantOut { + t.Fatalf("buildOutFmts().Out = %q, want %q", got.Out, tt.wantOut) + } + if tt.wantTempExt != "" { + t.Cleanup(func() { _ = os.Remove(got.Out) }) + if got.Out == tt.conf.OutFile || !strings.HasPrefix(filepath.Base(got.Out), tt.pkgName+"-") || !strings.HasSuffix(got.Out, tt.wantTempExt) { + t.Fatalf("buildOutFmts().Out = %q, want a temporary %s file for %q", got.Out, tt.wantTempExt, tt.pkgName) + } + } + }) + } +} + func TestApplyBuildModeNaming(t *testing.T) { tests := []struct { name string