sorcery-go/pkg/grimoire/parser.go

258 lines
9.4 KiB
Go
Executable File

// Package grimoire parses the real Source Mage spell files (DETAILS,
// DEPENDS, SUB_DEPENDS, CONFIGURE, BUILD) into typed Go structs.
//
// The parser is "hybrid": a small bash subprocess sources the DETAILS file
// (because real DETAILS files contain dynamic logic — `SOURCE_VERSION=$(...)`,
// `if [[ ... ]]; then SOURCE_URL[0]=...; fi`, multi-source arrays, etc.) and
// emits a JSON document on stdout. We then unmarshal that into a Spell
// struct. This is the only way to be 100% compatible with an existing
// a spell-format grimoire without reimplementing a Bash interpreter.
//
// For DEPENDS we use a pure-Go line parser because the format is simple
// and spawning bash per file is wasteful when IndexAll walks thousands of
// spells.
package grimoire
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
)
// Spell is the typed metadata extracted from a DETAILS file.
type Spell struct {
Name string `json:"name"`
Version string `json:"version"`
Patchlevel string `json:"patchlevel,omitempty"`
Source string `json:"source"`
SourceURLs []string `json:"source_urls"`
SourceHash string `json:"source_hash"`
SourceDir string `json:"source_directory"`
Website string `json:"website"`
Description string `json:"description"`
LongDesc string `json:"long_desc"`
License string `json:"license"`
Entered string `json:"entered"`
SecurityPatch string `json:"security_patch,omitempty"`
BuildDeps []string `json:"build_deps"`
RuntimeDeps []string `json:"runtime_deps"`
OptionalDeps []string `json:"optional_deps"`
SubDepends []string `json:"sub_depends"`
Directory string `json:"-"`
}
// bridgeScript is the bash we exec to source DETAILS and emit JSON.
// We deliberately list every standard SMGL variable so dynamic logic in
// the sourced file is respected. Strings are JSON-escaped by jq-like
// printf via python3 (available on every Source Mage box) — or, if
// python3 is missing, by a tiny bash escaper.
const bridgeScript = `#!/bin/bash
source "$1" 2>/dev/null || exit 1
emit() {
local v="$1"; shift
printf '"%s":"%s"\n' "$v" "${!v//\"/\\\"}"
}
{
printf '{'
emit SPELL; printf ','
emit VERSION; printf ','
emit PATCHLEVEL; printf ','
emit SOURCE; printf ','
emit SOURCE_HASH; printf ','
emit SOURCE_DIRECTORY; printf ','
emit WEB_SITE; printf ','
emit ENTERED; printf ','
emit SECURITY_PATCH; printf ','
printf '"source_urls":['
i=0
while [[ -n "${SOURCE_URL[$i]:-}" ]]; do
[ $i -gt 0 ] && printf ','
printf '"%s"' "${SOURCE_URL[$i]//\"/\\\"}"
i=$((i+1))
done
printf '],'
printf '"license":"%s",' "${LICENSE[0]:-}"
printf '"short":"%s",' "${SHORT//\"/\\\"}"
printf '"long_desc":"%s"' "$(awk 'BEGIN{getline}{printf "%s\\n",$0}' <<EOF
$LONG_DESC
EOF
)"
printf '}'
}`
// ParseDetails sources a spell's DETAILS file via bash and returns a typed
// Spell. The spell's Directory field is set to spellDir so callers can
// later find BUILD / CONFIGURE / DEPENDS next to it.
func ParseDetails(spellDir string) (*Spell, error) {
detailsPath := filepath.Join(spellDir, "DETAILS")
if _, err := os.Stat(detailsPath); err != nil {
return nil, fmt.Errorf("grimoire: DETAILS not found at %s: %w", detailsPath, err)
}
// Run the bridge script in bash. We pass the DETAILS path as $1.
cmd := exec.Command("bash", "-c", bridgeScript, "bridge", detailsPath)
out, err := cmd.Output()
if err != nil {
// bash sourcing failed — capture stderr for debugging
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("grimoire: bash sourcing failed for %s: %w (stderr: %s)",
spellDir, err, string(exitErr.Stderr))
}
return nil, fmt.Errorf("grimoire: bash sourcing failed for %s: %w", spellDir, err)
}
// Strip any leading non-JSON noise bash may have printed.
out = trimToJSON(out)
var s Spell
if err := json.Unmarshal(out, &s); err != nil {
return nil, fmt.Errorf("grimoire: cannot decode JSON for %s: %w (raw=%q)",
spellDir, err, string(out))
}
if s.Name == "" {
// DETAILS may set SPELL dynamically — fall back to dir name.
s.Name = filepath.Base(spellDir)
}
s.Directory = spellDir
// Parse DEPENDS / SUB_DEPENDS / CONFIGURE if present.
if deps, err := ParseDepends(filepath.Join(spellDir, "DEPENDS")); err == nil {
for _, d := range deps {
switch d.Type {
case "build":
s.BuildDeps = append(s.BuildDeps, d.Name)
case "optional":
s.OptionalDeps = append(s.OptionalDeps, d.Name)
default:
s.RuntimeDeps = append(s.RuntimeDeps, d.Name)
}
}
}
if subs, err := ParseSubDepends(filepath.Join(spellDir, "DEPENDS")); err == nil {
s.SubDepends = append(s.SubDepends, subs...)
}
return &s, nil
}
// trimToJSON drops any bytes before the first '{' so we tolerate stray
// bash output during sourcing (echo statements in DETAILS, etc.).
func trimToJSON(b []byte) []byte {
for i, c := range b {
if c == '{' {
return b[i:]
}
}
return b
}
// IndexAll walks the grimoire root and returns an in-memory map of every
// spell. Parsing is parallelised across a worker pool sized to NumCPU.
//
// This is the "Librarian" function — it replaces the thousands of
// find + grep calls the original Bash sorcery makes at startup.
func IndexAll(root string) (map[string]*Spell, error) {
var dirs []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // tolerate broken symlinks
}
if !info.IsDir() {
return nil
}
if _, statErr := os.Stat(filepath.Join(path, "DETAILS")); statErr == nil {
dirs = append(dirs, path)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("grimoire: walk %s: %w", root, err)
}
type result struct {
name string
spell *Spell
}
jobs := make(chan string, len(dirs))
results := make(chan result, len(dirs))
var wg sync.WaitGroup
workers := runtime.NumCPU()
if workers > len(dirs) {
workers = len(dirs)
}
if workers < 1 {
workers = 1
}
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for dir := range jobs {
s, err := ParseDetails(dir)
if err != nil || s == nil {
results <- result{}
continue
}
results <- result{name: s.Name, spell: s}
}
}()
}
for _, d := range dirs {
jobs <- d
}
close(jobs)
wg.Wait()
close(results)
index := make(map[string]*Spell, len(dirs))
for r := range results {
if r.spell != nil {
index[r.name] = r.spell
}
}
return index, nil
}
// FindSpell locates a spell by name in the grimoire. Returns the directory
// path or an error. Used by the CLI when the user runs `sorcery cast wget`.
func FindSpell(grimoireRoot, name string) (string, error) {
var found string
err := filepath.Walk(grimoireRoot, func(path string, info os.FileInfo, err error) error {
if err != nil || !info.IsDir() {
return nil
}
if filepath.Base(path) == name {
if _, statErr := os.Stat(filepath.Join(path, "DETAILS")); statErr == nil {
found = path
return filepath.SkipDir
}
}
return nil
})
if err != nil {
return "", err
}
if found == "" {
return "", fmt.Errorf("grimoire: spell %q not found under %s", name, grimoireRoot)
}
return found, nil
}
// Section returns the category (e.g., "libs", "utils") for a spell directory.
func Section(spell *Spell) string {
if spell.Directory == "" {
return ""
}
rel, err := filepath.Rel(filepath.Dir(filepath.Dir(spell.Directory)), spell.Directory)
if err != nil {
return ""
}
return strings.SplitN(rel, string(filepath.Separator), 2)[0]
}