feat(arch/src): minimal-makepkg POSIX-sh build.sh generator

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
This commit is contained in:
2026-08-19 18:30:25 -04:00
co-authored by Sisyphus
parent 38ddc7cfb2
commit 8267b45888
3 changed files with 368 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
// Package src converts Arch Linux source packages (PKGBUILD/.SRCINFO) into
// Zeta recipes.
package src
import (
"errors"
"fmt"
"os"
"regexp"
"strings"
)
// SrcInfo holds the parsed metadata of an Arch source package, produced by
// the .SRCINFO parser / PKGBUILD fallback (todo 10). The field layout is
// fixed by the plan; the parallel task's srcinfo.go populates this struct.
type SrcInfo struct {
PkgBase string
PkgNames []string
Version string
Rel string
Epoch string
Desc string
URL string
Arch []string
Depends []string
Makedepends []string
Optdepends []string
Provides []string
Conflicts []string
Replaces []string
Sources []string
SHA256Sums []string
}
// Generate emits a self-contained POSIX-sh build script (build.sh) that
// reproduces the PKGBUILD's prepare/build/package steps under zeta-makepkg's
// custom build contract:
//
// cd <source_dir> && DESTDIR=<stage> sh build.sh
//
// so $PWD at script start is the extracted source root and $DESTDIR is the
// staging tree. Arch's package() installs into $pkgdir; mapping
// pkgdir="$DESTDIR" is the whole trick.
//
// Function bodies declared by the PKGBUILD at pkgbuildPath are embedded
// verbatim; missing functions become no-op stubs and are not called. If an
// embedded body uses bash-only constructs, a warning is printed to stderr but
// the script is still emitted.
func Generate(s *SrcInfo, pkgbuildPath string) (string, error) {
if s == nil {
return "", errors.New("arch/src: nil SrcInfo")
}
raw, err := os.ReadFile(pkgbuildPath)
if err != nil {
return "", fmt.Errorf("arch/src: read PKGBUILD %s: %w", pkgbuildPath, err)
}
funcs, err := extractFunctions(string(raw))
if err != nil {
return "", err
}
if hasBashOnly(funcs) {
fmt.Fprintln(os.Stderr, "warning: build.sh embeds bash-only constructs; may need manual adjustment under POSIX sh")
}
var b strings.Builder
b.WriteString("#!/bin/sh\n")
b.WriteString("set -eu\n")
b.WriteString("\n")
b.WriteString("pkgname=" + shQuote(pkgName(s)) + "\n")
b.WriteString("pkgver=" + shQuote(s.Version) + "\n")
b.WriteString("pkgrel=" + shQuote(s.Rel) + "\n")
b.WriteString("arch=" + shQuote(strings.Join(s.Arch, " ")) + "\n")
b.WriteString("url=" + shQuote(s.URL) + "\n")
b.WriteString("\n")
b.WriteString("srcdir=\"$PWD\"\n")
b.WriteString("pkgdir=\"$DESTDIR\"\n")
b.WriteString("\n")
b.WriteString("msg() { :; }\n")
b.WriteString("\n")
var calls []string
for _, f := range funcs {
if f.present {
b.WriteString(f.body)
calls = append(calls, f.name)
} else {
fmt.Fprintf(&b, "%s() { :; }\n", f.name)
}
b.WriteString("\n")
}
for _, name := range calls {
b.WriteString(name + "\n")
}
return b.String(), nil
}
// pkgName prefers the pkgname section value; fall back to pkgbase.
func pkgName(s *SrcInfo) string {
if len(s.PkgNames) > 0 {
return s.PkgNames[0]
}
return s.PkgBase
}
// shQuote single-quotes a value for safe embedding in sh source.
func shQuote(v string) string {
return "'" + strings.ReplaceAll(v, "'", `'\''`) + "'"
}
// pkgFunc is one extracted PKGBUILD function: its verbatim text (including
// the "name() {" opening and closing "}") and whether it was present.
type pkgFunc struct {
name string
body string
present bool
}
// extractFunctions pulls prepare/build/package bodies out of a PKGBUILD.
// Each body runs from the "name() {" opener to the first line starting with
// "}" at column 0, the standard makepkg layout heuristic.
func extractFunctions(src string) ([]pkgFunc, error) {
names := []string{"prepare", "build", "package"}
out := make([]pkgFunc, 0, len(names))
for _, name := range names {
body, present, err := extractFunc(src, name)
if err != nil {
return nil, err
}
out = append(out, pkgFunc{name: name, body: body, present: present})
}
return out, nil
}
func extractFunc(src, name string) (string, bool, error) {
startRe := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(name) + `\(\s*\)\s*\{`)
loc := startRe.FindStringIndex(src)
if loc == nil {
return "", false, nil
}
end := findClosingBrace(src, loc[1])
if end < 0 {
return "", false, fmt.Errorf("arch/src: %s() { has no closing '}' at column 0", name)
}
return src[loc[0]:end], true, nil
}
// findClosingBrace returns the offset just past the first line starting with
// "}" (column 0), scanning from from. Returns -1 if none is found.
func findClosingBrace(src string, from int) int {
lineStart := from
for lineStart <= len(src) {
lineEnd := strings.IndexByte(src[lineStart:], '\n')
if lineEnd < 0 {
if strings.HasPrefix(src[lineStart:], "}") {
return len(src)
}
return -1
}
line := src[lineStart : lineStart+lineEnd]
if strings.HasPrefix(line, "}") {
return lineStart + lineEnd + 1 // include the newline
}
lineStart += lineEnd + 1
}
return -1
}
// bashOnlyPatterns matches constructs that are valid in bash but not POSIX sh.
var bashOnlyPatterns = []*regexp.Regexp{
regexp.MustCompile(`\[\[`), // [[ test ]]
regexp.MustCompile(`\$\{[A-Za-z_][A-Za-z0-9_]*\[[^]\n]+\]\}`), // ${arr[@]} / ${arr[0]}
regexp.MustCompile(`(^|[^A-Za-z0-9_])local[ \t]+`), // local var
regexp.MustCompile(`(^|[^A-Za-z0-9_])declare[ \t]+`), // declare -a ...
regexp.MustCompile(`(^|[ \t;&])[A-Za-z_][A-Za-z0-9_]*=\(`), // arr=(...)
regexp.MustCompile(`<\(|>\(`), // process substitution
regexp.MustCompile(`\$\'`), // $'\n' ANSI-C quoting
}
// hasBashOnly reports whether any extracted function body uses a bash-only
// construct.
func hasBashOnly(funcs []pkgFunc) bool {
for _, f := range funcs {
if !f.present {
continue
}
for _, re := range bashOnlyPatterns {
if re.MatchString(f.body) {
return true
}
}
}
return false
}
+146
View File
@@ -0,0 +1,146 @@
package src
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
const (
mytoolSRCINFO = "../../../testdata/srcinfo/mytool/.SRCINFO"
mytoolPKGBUILD = "../../../testdata/srcinfo/mytool/PKGBUILD"
goldenPath = "../../../testdata/golden/mytool.build.sh"
)
// minimalSrcInfo is a test-local .SRCINFO reader. The real parser (todo 10,
// srcinfo.go) may not exist yet when this test compiles, so the test keeps a
// tiny reader for the fields Generate needs. It mirrors the documented SrcInfo
// layout so it converges with the real parser.
func minimalSrcInfo(t *testing.T, path string) *SrcInfo {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
s := &SrcInfo{}
for _, line := range strings.Split(string(data), "\n") {
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
switch key {
case "pkgbase":
s.PkgBase = value
case "pkgname":
s.PkgNames = append(s.PkgNames, value)
case "pkgver":
s.Version = value
case "pkgrel":
s.Rel = value
case "arch":
s.Arch = append(s.Arch, value)
case "url":
s.URL = value
}
}
return s
}
func generateMytool(t *testing.T) string {
t.Helper()
out, err := Generate(minimalSrcInfo(t, mytoolSRCINFO), mytoolPKGBUILD)
if err != nil {
t.Fatalf("Generate: %v", err)
}
return out
}
func TestBuildScriptGolden(t *testing.T) {
out := generateMytool(t)
if os.Getenv("UPDATE_GOLDEN") == "1" {
if err := os.WriteFile(goldenPath, []byte(out), 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
t.Logf("wrote golden %s", goldenPath)
return
}
want, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("read golden (run: UPDATE_GOLDEN=1 go test ./internal/arch/src/ -run TestBuildScript): %v", err)
}
if string(want) != out {
t.Errorf("generated build.sh does not match golden %s", goldenPath)
for i, line := range strings.Split(string(want), "\n") {
gotLine := ""
if ls := strings.Split(out, "\n"); i < len(ls) {
gotLine = ls[i]
}
if line != gotLine {
t.Errorf("first diff at line %d:\n golden: %q\n got: %q", i+1, line, gotLine)
break
}
}
}
}
func TestBuildScriptPassesShSyntax(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("sh not found in PATH")
}
out := generateMytool(t)
script := filepath.Join(t.TempDir(), "build.sh")
if err := os.WriteFile(script, []byte(out), 0o755); err != nil {
t.Fatalf("write script: %v", err)
}
cmd := exec.Command(sh, "-n", script)
if output, err := cmd.CombinedOutput(); err != nil {
t.Errorf("sh -n %s failed: %v\n%s", script, err, output)
}
}
func TestBuildScriptContainsDestdir(t *testing.T) {
out := generateMytool(t)
if !strings.Contains(out, `pkgdir="$DESTDIR"`) {
t.Errorf("generated script missing pkgdir=\"$DESTDIR\"")
}
if !strings.Contains(out, "$DESTDIR") {
t.Errorf("generated script missing literal $DESTDIR")
}
}
func TestBuildScriptNoFunctions(t *testing.T) {
dir := t.TempDir()
pkgbuild := filepath.Join(dir, "PKGBUILD")
if err := os.WriteFile(pkgbuild, []byte("pkgname=nofunc\npkgver=2.0\npkgrel=1\narch=(x86_64)\n"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
si := &SrcInfo{PkgNames: []string{"nofunc"}, Version: "2.0", Rel: "1", Arch: []string{"x86_64"}}
out, err := Generate(si, pkgbuild)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if !strings.Contains(out, `pkgdir="$DESTDIR"`) {
t.Errorf("script without functions must still define pkgdir=\"$DESTDIR\"")
}
if !strings.Contains(out, "package") {
t.Errorf("script without functions must still reference package (stub)")
}
}
func TestBuildScriptEmbedsBuildBody(t *testing.T) {
out := generateMytool(t)
const wantBuild = "build() {\n cd \"$srcdir\"\n ./configure --prefix=/usr\n make\n}"
const wantPackage = "package() {\n cd \"$srcdir\"\n make DESTDIR=\"$pkgdir\" install\n}"
if !strings.Contains(out, wantBuild) {
t.Errorf("generated script does not embed the fixture's build() body verbatim")
}
if !strings.Contains(out, wantPackage) {
t.Errorf("generated script does not embed the fixture's package() body verbatim")
}
}