miller/pkg/entrypoint/entrypoint.go
John Kerl 2f3e54d1f6
Remove os.Exit callsites below the entrypoint: phase 4 (plans/exit.md) (#2205)
Phase 4 of plans/exit.md: the aux/terminal sub-entrypoints. pkg/auxents and
pkg/terminals are now os.Exit-free; even the dispatchers return exit codes
instead of exiting.

- auxents.Dispatch returns (handled bool, exitCode int); entrypoint.Main --
  the one sanctioned exit point -- exits with the code.
- terminals.Dispatch returns an exit code; the climain caller wraps it in a
  lib.ExitRequest, which also retires the 'terminal did not exit the process'
  panic.
- Usage functions (hex, unhex, lecat, termcvt, repl, script, regtest) no
  longer take an exitCode parameter and exit; call sites print usage and
  return the code explicitly.
- Inner I/O helpers (hexDumpFile, unhexFile, lecatFile, termcvtFile) return
  errors; each sub-main prints 'mlr {verb}: ...' as before and returns 1.
  lecatFile formerly looped forever on a non-EOF read error; it now returns
  the error.
- The regtest directory walk (Execute / executeSinglePath /
  executeSingleDirectory / hasCaseSubdirectories) plumbs errors up to
  RegTestMain; regression_test.go updated for the new Execute signature.

Stderr messages and exit codes are unchanged across mlr aux-list, hex, unhex,
lecat, termcvt, help, version, repl, script, and regtest surfaces (checked by
hand); all 4779 regression cases pass; make lint is clean; docs rebuild with
zero churn.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:27:26 -04:00

240 lines
6.9 KiB
Go

// All the usual contents of main() are put into this package for ease of
// testing.
package entrypoint
import (
"errors"
"fmt"
"os"
"path"
"strings"
"github.com/johnkerl/miller/v6/pkg/auxents"
"github.com/johnkerl/miller/v6/pkg/cli"
"github.com/johnkerl/miller/v6/pkg/climain"
"github.com/johnkerl/miller/v6/pkg/lib"
"github.com/johnkerl/miller/v6/pkg/platform"
"github.com/johnkerl/miller/v6/pkg/stream"
"github.com/johnkerl/miller/v6/pkg/transformers"
)
type MainReturn struct {
PrintElapsedTime bool
}
func Main() MainReturn {
// Special handling for Windows so we can do things like:
//
// mlr put '$a = $b . "cd \"efg\" hi"' foo.dat
//
// as on Linux/Unix/MacOS. (On the latter platforms, this is just os.Args
// as-is.)
os.Args = platform.GetArgs()
// Enable ANSI escape sequence processing on the Windows pseudo terminal,
// otherwise, we only raw ANSI escape sequences like ←[0;30m 0←[0m ←[0;31m 1
platform.EnableAnsiEscapeSequences()
// 'mlr repl' or 'mlr lecat' or any other non-miller-per-se toolery which
// is delivered (for convenience) within the mlr executable. If argv[1] is
// an auxent name, it's run here and its exit code is the process's.
if handled, exitCode := auxents.Dispatch(os.Args); handled {
os.Exit(exitCode)
}
if lib.IsTruthyEnvValue(os.Getenv("MLR_NO_SHELL")) {
lib.DisableShellOut()
}
wantJSON := climain.WantErrorsJSON(os.Args)
options, recordTransformers, err := climain.ParseCommandLine(os.Args)
if err != nil {
exitOnError(err, wantJSON)
}
if !options.DoInPlace {
err = processToStdout(options, recordTransformers)
} else {
err = processFilesInPlace(options)
}
if err != nil {
exitOnError(err, false)
}
return MainReturn{
PrintElapsedTime: options.PrintElapsedTime,
}
}
// exitOnError terminates the process for a non-nil error from command-line
// parsing or stream processing. Sentinel errors carry their own exit codes
// and have already produced whatever output they wanted; any other error is
// printed (structured if --errors-json is active) and exits 1. This is
// intended to be the single os.Exit point below main; see plans/exit.md.
func exitOnError(err error, wantJSON bool) {
var exitRequest *lib.ExitRequest
switch {
case errors.Is(err, cli.ErrHelpRequested):
os.Exit(0)
case errors.Is(err, cli.ErrUsagePrinted):
os.Exit(1)
case errors.As(err, &exitRequest):
os.Exit(exitRequest.Code)
case wantJSON:
climain.EmitStructuredError(err)
os.Exit(1)
default:
printError(err)
os.Exit(1)
}
}
// printError prints err to stderr. Errors that already carry an "mlr" prefix
// (e.g. "mlr stats1: ..." or "mlr: verb not found") are printed as-is to
// avoid double-prefixing.
func printError(err error) {
msg := err.Error()
if strings.HasPrefix(msg, "mlr") {
fmt.Fprintf(os.Stderr, "%v\n", msg)
} else {
fmt.Fprintf(os.Stderr, "mlr: %v\n", msg)
}
}
// processToStdout is normal processing without mlr -I.
func processToStdout(
options *cli.TOptions,
recordTransformers []transformers.RecordTransformer,
) error {
return stream.Stream(options.FileNames, options, recordTransformers, os.Stdout, true)
}
// processFilesInPlace is in-place processing without mlr -I.
//
// For in-place mode, reconstruct the transformers on each input file. E.g.
// 'mlr -I head -n 2 foo bar' should do head -n 2 on foo as well as on bar.
//
// I could have implemented this with a single construction of the transformers
// and having each transformers implement a Reset() method. However, having
// effectively two initializers per transformers -- constructor and reset method
// -- I'd surely miss some logic somewhere. With in-place mode being a less
// frequently used code path, this would likely lead to latent bugs. So this
// approach leads to greater code stability.
func processFilesInPlace(
originalOptions *cli.TOptions,
) error {
// This should have been already checked by the CLI parser when validating
// the -I flag.
lib.InternalCodingErrorIf(originalOptions.FileNames == nil)
lib.InternalCodingErrorIf(len(originalOptions.FileNames) == 0)
// Save off the file names from the command line.
fileNames := make([]string, len(originalOptions.FileNames))
copy(fileNames, originalOptions.FileNames)
for _, fileName := range fileNames {
err := processFileInPlace(fileName, originalOptions)
if err != nil {
return err
}
}
return nil
}
func processFileInPlace(
fileName string,
originalOptions *cli.TOptions,
) error {
if _, err := os.Stat(fileName); os.IsNotExist(err) {
return err
}
// Reconstruct the transformers for each file name, and allocate
// reader, mappers, and writer individually for each file name. This
// way CSV headers appear in each file, head -n 10 puts 10 rows for
// each output file, and so on.
options, recordTransformers, err := climain.ParseCommandLine(os.Args)
if err != nil {
return err
}
// We can't in-place update http://, https://, etc. Also, anything with
// --prepipe or --prepipex, we won't try to guess how to invert that
// command to produce re-compressed output.
err = lib.IsUpdateableInPlace(fileName, options.ReaderOptions.Prepipe)
if err != nil {
return err
}
// Get the original file's mode so we can preserve it.
fileInfo, err := os.Stat(fileName)
if err != nil {
return err
}
originalMode := fileInfo.Mode()
containingDirectory := path.Dir(fileName)
// Names like ./mlr-in-place-2148227797 and ./mlr-in-place-1792078347,
// as revealed by printing handle.Name().
handle, err := os.CreateTemp(containingDirectory, "mlr-in-place-")
if err != nil {
return err
}
tempFileName := handle.Name()
// If the input file is compressed and we'll be doing in-process
// decompression as we read the input file, try to do in-process
// compression as we write the output.
inputFileEncoding := lib.FindInputEncoding(fileName, options.ReaderOptions.FileInputEncoding)
// Get a handle with, perhaps, a recompression wrapper around it.
wrappedHandle, isNew, err := lib.WrapOutputHandle(handle, inputFileEncoding)
if err != nil {
_ = os.Remove(tempFileName)
return err
}
// Run the Miller processing stream from the input file to the temp-output file.
err = stream.Stream([]string{fileName}, options, recordTransformers, wrappedHandle, false)
if err != nil {
_ = os.Remove(tempFileName)
return err
}
// Close the recompressor handle, if any recompression is being applied.
if isNew {
err = wrappedHandle.Close()
if err != nil {
_ = os.Remove(tempFileName)
return err
}
}
// Close the handle to the output file. This may force final writes, so
// it must be error-checked.
err = handle.Close()
if err != nil {
_ = os.Remove(tempFileName)
return err
}
// Rename the temp-output file on top of the input file.
err = os.Rename(tempFileName, fileName)
if err != nil {
_ = os.Remove(tempFileName)
return err
}
// Set the mode to match the original.
err = os.Chmod(fileName, originalMode)
if err != nil {
return err
}
return nil
}