nixbuild/instantiated.go
Ophestra d3a8aed237
exec: replace global state with interface
This is cleaner, and finally enables writing tests for the nix invoking functions.
2025-07-17 16:53:00 +09:00

217 lines
6.1 KiB
Go

package nixbuild
import (
"bufio"
"context"
"errors"
"io"
"iter"
"os/exec"
"path"
"slices"
"strings"
"sync"
)
const (
instantiatedPrefix = "instantiated '"
instantiatedSeparator = " -> "
instantiatedFields = 2
// a reasonable starting buffer capacity: pretty close to average of test cases
instantiatedInitialCap = 1 << 15
)
const (
InstantiatedBadFields = iota
InstantiatedUnexpectedQuotes
InstantiatedNotAbsolute
)
type MalformedInstantiatedError struct {
Message string
Type int
}
func (m *MalformedInstantiatedError) Error() string {
switch m.Type {
case InstantiatedBadFields:
return "incorrect amount of fields in instantiated message"
case InstantiatedUnexpectedQuotes:
return "unexpected quotes in final instantiated field"
case InstantiatedNotAbsolute:
return "instantiated derivation path is not absolute"
default:
panic("unreachable")
}
}
func (m *MalformedInstantiatedError) Is(err error) bool {
var e *MalformedInstantiatedError
if !errors.As(err, &e) {
return false
}
if e == nil || m == nil {
return e == m
}
return e.Type == m.Type
}
// InstantiatedDecoder interprets the verbose output of `nix build` and collects instantiated derivations.
type InstantiatedDecoder struct {
err error
pt func(string)
drv string
scanner *bufio.Scanner
}
// Err returns the first error encountered by the [InstantiatedDecoder].
func (d *InstantiatedDecoder) Err() error { return errors.Join(d.err, d.scanner.Err()) }
// NewInstantiatedDecoder returns a [InstantiatedDecoder] struct for collecting derivations from r.
// Skipped lines are written to w.
func NewInstantiatedDecoder(r io.Reader, w io.Writer) *InstantiatedDecoder {
passthrough := func(string) {}
if w != nil {
passthrough = func(v string) { _, _ = w.Write([]byte(v + "\n")) }
}
return &InstantiatedDecoder{scanner: bufio.NewScanner(r), pt: passthrough}
}
// Scan advances the decoder to the next instantiated path, skipping all unrelated lines in-between.
func (d *InstantiatedDecoder) Scan() bool {
if d.err != nil {
return false
}
rescan:
if !d.scanner.Scan() {
return false
}
v := d.scanner.Text()
if !strings.HasPrefix(v, instantiatedPrefix) {
// write skipped lines to stderr
d.pt(v)
goto rescan
}
fields := strings.SplitN(v, instantiatedSeparator, instantiatedFields)
if len(fields) != instantiatedFields {
// no more than a single -> is expected
d.err = &MalformedInstantiatedError{v, InstantiatedBadFields}
return false
}
// very basic validation here: the output format is not fully understood
if len(fields[1]) < 3 || fields[1][0] != '\'' || fields[1][len(fields[1])-1] != '\'' {
d.err = &MalformedInstantiatedError{v, InstantiatedUnexpectedQuotes}
return false
}
drv := fields[1][1 : len(fields[1])-1]
if !path.IsAbs(drv) {
d.err = &MalformedInstantiatedError{v, InstantiatedNotAbsolute}
return false
}
d.drv = drv
return true
}
// Text returns the most recent instantiated path generated by a call to [InstantiatedDecoder.Scan]
// as a newly allocated string holding its bytes.
func (d *InstantiatedDecoder) Text() string { return d.drv }
// Instantiated returns a non-reusable iterator over instantiated derivations collected from [io.Reader].
func (d *InstantiatedDecoder) Instantiated() iter.Seq[string] {
return func(yield func(string) bool) {
for d.Scan() {
if !yield(d.Text()) {
return
}
}
}
}
// Decode collects and deduplicates derivations emitted by [InstantiatedDecoder].
func (d *InstantiatedDecoder) Decode() ([]string, error) {
instantiated := make([]string, 0, instantiatedInitialCap)
for drv := range d.Instantiated() {
if len(instantiated) == cap(instantiated) {
// grow the buffer exponentially to minimise copies
instantiated = slices.Grow(instantiated, cap(instantiated))
}
instantiated = append(instantiated, drv)
}
slices.Sort(instantiated)
return slices.Compact(instantiated), d.Err()
}
// InstantiatedEvaluator evaluates a nix installable and collects its instantiated derivations via [InstantiatedDecoder].
// This interprets verbose output of `nix build` and is certainly not the correct way to do it, but so far its results
// are significantly more complete than `nix-store -qR` and there does not appear to be a better way.
type InstantiatedEvaluator struct {
// underlying nix program
cmd *exec.Cmd
// populated by Close
waitErr error
// synchronises access to waitErr
waitMu sync.RWMutex
*InstantiatedDecoder
}
// Err blocks until process exit and returns the first error encountered by the [InstantiatedEvaluator].
func (e *InstantiatedEvaluator) Err() error {
e.waitMu.RLock()
defer e.waitMu.RUnlock()
return errors.Join(e.waitErr, e.InstantiatedDecoder.Err())
}
// NewInstantiatedEvaluator initialises an [InstantiatedEvaluator] struct and its underlying nix process.
func NewInstantiatedEvaluator(ctx Context, installable string) (*InstantiatedEvaluator, error) {
c, cancel := context.WithCancel(ctx.Unwrap())
e := &InstantiatedEvaluator{
cmd: ctx.Nix(c, CommandBuild, installable,
// 'instantiated' messages are only emitted when actually evaluating something
FlagOption, OptionEvalCache, ValueFalse,
// do not actually build anything
FlagDryRun,
// increase verbosity so nix outputs 'instantiated' messages
FlagPrintBuildLogs, FlagDebug,
),
}
stdout, stderr := ctx.Streams()
e.cmd.Stdout = stdout
// verbose output ends up on stderr in the current nix implementation
if r, err := e.cmd.StderrPipe(); err != nil {
cancel()
return nil, err
} else {
e.InstantiatedDecoder = NewInstantiatedDecoder(r, stderr)
}
if err := e.cmd.Start(); err != nil {
cancel()
// have finalizer take care of the pipe
return nil, err
}
e.waitMu.Lock()
go func() { e.waitErr = e.cmd.Wait(); cancel(); e.waitMu.Unlock() }()
return e, nil
}
// EvalInstantiated calls the underlying [InstantiatedDecoder.Decode] of a new [InstantiatedEvaluator].
func EvalInstantiated(ctx Context, installable string) ([]string, error) {
evaluator, err := NewInstantiatedEvaluator(ctx, installable)
if err != nil {
return nil, err
}
instantiated, _ := evaluator.Decode() // error joined by Close
return instantiated, evaluator.Err()
}