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() }