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:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user