258 lines
9.4 KiB
Go
Executable File
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]
|
|
}
|