nixbuild/instantiated.go
Ophestra 61c6b5d78e
treewide: use internal pipe for nix command
This essentially does the same thing underneath the hood but the API is less painful to use, and it makes more sense in this use case.
2025-07-15 01:14:27 +09:00

190 lines
5.4 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 << 13
)
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
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.
func NewInstantiatedDecoder(r io.Reader) *InstantiatedDecoder {
return &InstantiatedDecoder{scanner: bufio.NewScanner(r)}
}
// 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.scanner.Scan() {
v := d.scanner.Text()
if !strings.HasPrefix(v, instantiatedPrefix) {
// write skipped lines to stderr
if Stderr != nil {
_, _ = Stderr.Write([]byte(v + "\n"))
}
continue
}
fields := strings.SplitN(v, instantiatedSeparator, instantiatedFields)
if len(fields) != instantiatedFields {
// no more than a single -> is expected
d.err = &MalformedInstantiatedError{v, InstantiatedBadFields}
break
}
// 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}
break
}
drv := fields[1][1 : len(fields[1])-1]
if !path.IsAbs(drv) {
d.err = &MalformedInstantiatedError{v, InstantiatedNotAbsolute}
break
}
if !yield(drv) {
break
}
}
}
}
// 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)<<1)
}
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.Context, installable string) (*InstantiatedEvaluator, error) {
c, cancel := context.WithCancel(ctx)
e := &InstantiatedEvaluator{
cmd: 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,
),
}
e.cmd.Stdout = Stdout
// verbose output ends up on stderr in the current nix implementation
er, ew := io.Pipe()
e.cmd.Stderr = ew
e.InstantiatedDecoder = NewInstantiatedDecoder(er)
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(); _ = ew.Close(); e.waitMu.Unlock() }()
return e, nil
}
// EvalInstantiated calls the underlying [InstantiatedDecoder.Decode] of a new [InstantiatedEvaluator].
func EvalInstantiated(ctx context.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()
}