diff options
Diffstat (limited to 'internal/cli')
| -rw-r--r-- | internal/cli/cli.go | 40 | ||||
| -rw-r--r-- | internal/cli/pager.go | 83 | ||||
| -rw-r--r-- | internal/cli/pager_test.go | 109 |
3 files changed, 223 insertions, 9 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 130683c..5ee4a34 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -38,6 +38,8 @@ Flags: -g, --lang LANG traditional lectionary language: pl|en -u, --update harvest sigla maximally to the horizon (idempotent) -C, --clean prune cached readings older than a year + -P, --pager page reading output (like git); default from config + --no-pager never page, even if config sets one -v, --version print the version and exit -h, --help this help @@ -88,7 +90,7 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 2 } - var all, raw, refresh, offline, update, clean bool + var all, raw, refresh, offline, update, clean, pagerFlag, noPager bool var bibleVer, compareList, lectionary, lang string var width int @@ -118,6 +120,9 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { fs.BoolVar(&update, "update", false, "harvest sigla maximally to the horizon") fs.BoolVar(&clean, "C", false, "prune cached readings older than a year") fs.BoolVar(&clean, "clean", false, "prune cached readings older than a year") + fs.BoolVar(&pagerFlag, "P", false, "page reading output (like git)") + fs.BoolVar(&pagerFlag, "pager", false, "page reading output (like git)") + fs.BoolVar(&noPager, "no-pager", false, "never page, even if config sets one") if err := fs.Parse(rest); err != nil { return 2 @@ -165,17 +170,34 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { effWidth = cfg.Width } - if compareList != "" { - return renderCompare(cfg, compareList, date, effAll, raw, effWidth, refresh, stdout, stderr) + // An invalid -b must exit 2 before any pager starts -- don't page an + // error. When compareList != "", bibleVer is unused (compare wins, as + // before) so it's deliberately left unvalidated in that case too. + if bibleVer != "" && compareList == "" && !config.ValidVersion(bibleVer) { + fmt.Fprintf(stderr, "lectio: unknown version %q (want one of pl, wuj, vul, grb, drb)\n", bibleVer) + return 2 } - if bibleVer != "" { - if !config.ValidVersion(bibleVer) { - fmt.Fprintf(stderr, "lectio: unknown version %q (want one of pl, wuj, vul, grb, drb)\n", bibleVer) - return 2 + + out := stdout + finish := func() {} + if pagerRequested(pagerFlag, noPager, cfg) && isTerminalWriter(stdout) { + if o, f, ok := startPager(pagerCommand(cfg), stdout, stderr); ok { + out = o + finish = f } - return fetchAndPrint(cfg, bibleVer, date, effAll, raw, effWidth, refresh, stdout, stderr) } - return fetchAndPrint(cfg, cfg.DefaultVersion, date, effAll, raw, effWidth, refresh, stdout, stderr) + + var code int + switch { + case compareList != "": + code = renderCompare(cfg, compareList, date, effAll, raw, effWidth, refresh, out, stderr) + case bibleVer != "": + code = fetchAndPrint(cfg, bibleVer, date, effAll, raw, effWidth, refresh, out, stderr) + default: + code = fetchAndPrint(cfg, cfg.DefaultVersion, date, effAll, raw, effWidth, refresh, out, stderr) + } + finish() + return code } // wantsHelp reports whether -h/--help appears anywhere in args. diff --git a/internal/cli/pager.go b/internal/cli/pager.go new file mode 100644 index 0000000..2c7365a --- /dev/null +++ b/internal/cli/pager.go @@ -0,0 +1,83 @@ +package cli + +import ( + "io" + "os" + "os/exec" + "strings" + + "github.com/lukaszkasprzak/lectio/internal/config" +) + +// pagerRequested decides whether the reading-output tail should be paged, +// given -P/--pager, --no-pager, and cfg.Pager. --no-pager always wins (an +// explicit "never page" beats everything); otherwise -P forces paging on; +// otherwise a non-empty cfg.Pager turns paging on by default. Pure and +// side-effect free -- the actual TTY gate lives in isTerminalWriter. +func pagerRequested(pagerFlag, noPager bool, cfg config.Config) bool { + if noPager { + return false + } + if pagerFlag { + return true + } + return cfg.Pager != "" +} + +// pagerCommand resolves the pager argv to run: cfg.Pager wins, then $PAGER, +// then the "less -R" built-in default. The resolved command string is split +// on whitespace via strings.Fields into an argv slice (so "less -R" or a +// $PAGER value with flags works without a shell). +func pagerCommand(cfg config.Config) []string { + cmd := cfg.Pager + if cmd == "" { + cmd = os.Getenv("PAGER") + } + if cmd == "" { + cmd = "less -R" + } + return strings.Fields(cmd) +} + +// isTerminalWriter reports whether w is a real terminal -- only then is +// paging worth doing. Reuses ttyWidth (termwidth_unix.go/termwidth_other.go) +// via the *os.File it already knows how to probe; anything else (notably +// the bytes.Buffer every test uses for stdout) is never a terminal, so +// paging never triggers in tests, and `lectio | cat` (stdout redirected to +// a pipe, not a *os.File terminal) writes directly too. +func isTerminalWriter(w io.Writer) bool { + if f, ok := w.(*os.File); ok { + return ttyWidth(f) > 0 + } + return false +} + +// startPager launches argv[0] as a pager subprocess wired to the real +// terminal (stdout/stderr passed through), returning a WriteCloser the +// caller renders reading output into. ok=false (with out, finish both nil) +// on any setup failure -- an empty argv, a StdinPipe error, or the pager +// binary not being found/startable -- so the caller can fall back to +// writing directly to stdout without crashing. +func startPager(argv []string, stdout, stderr io.Writer) (out io.WriteCloser, finish func(), ok bool) { + if len(argv) == 0 { + return nil, nil, false + } + + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Stdout = stdout + cmd.Stderr = stderr + + w, err := cmd.StdinPipe() + if err != nil { + return nil, nil, false + } + if err := cmd.Start(); err != nil { + return nil, nil, false + } + + finish = func() { + w.Close() + _ = cmd.Wait() + } + return w, finish, true +} diff --git a/internal/cli/pager_test.go b/internal/cli/pager_test.go new file mode 100644 index 0000000..3882162 --- /dev/null +++ b/internal/cli/pager_test.go @@ -0,0 +1,109 @@ +package cli + +import ( + "bytes" + "testing" + + "github.com/lukaszkasprzak/lectio/internal/config" +) + +// TestPagerRequested exercises pagerRequested's precedence: --no-pager beats +// -P beats a non-empty cfg.Pager; with nothing set, paging is off. +func TestPagerRequested(t *testing.T) { + cases := []struct { + name string + pagerFlag, noPager bool + cfgPager string + want bool + }{ + {"nothing set", false, false, "", false}, + {"cfg.Pager set alone", false, false, "less -R", true}, + {"-P alone", true, false, "", true}, + {"-P overrides empty cfg", true, false, "", true}, + {"--no-pager beats -P", true, true, "", false}, + {"--no-pager beats cfg.Pager", false, true, "less -R", false}, + {"--no-pager beats both", true, true, "less -R", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg := config.Config{Pager: c.cfgPager} + got := pagerRequested(c.pagerFlag, c.noPager, cfg) + if got != c.want { + t.Errorf("pagerRequested(%v, %v, Pager=%q) = %v, want %v", + c.pagerFlag, c.noPager, c.cfgPager, got, c.want) + } + }) + } +} + +// TestPagerCommand exercises pagerCommand's fallback chain: cfg.Pager wins, +// then $PAGER, then the "less -R" built-in default. No exec happens here. +func TestPagerCommand(t *testing.T) { + t.Run("cfg.Pager set", func(t *testing.T) { + t.Setenv("PAGER", "most") + cfg := config.Config{Pager: "more -c"} + got := pagerCommand(cfg) + want := []string{"more", "-c"} + if !equalStrings(got, want) { + t.Errorf("pagerCommand = %v, want %v", got, want) + } + }) + + t.Run("cfg empty, PAGER set", func(t *testing.T) { + t.Setenv("PAGER", "most") + cfg := config.Config{} + got := pagerCommand(cfg) + want := []string{"most"} + if !equalStrings(got, want) { + t.Errorf("pagerCommand = %v, want %v", got, want) + } + }) + + t.Run("both empty falls back to less -R", func(t *testing.T) { + t.Setenv("PAGER", "") + cfg := config.Config{} + got := pagerCommand(cfg) + want := []string{"less", "-R"} + if !equalStrings(got, want) { + t.Errorf("pagerCommand = %v, want %v", got, want) + } + }) +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// TestIsTerminalWriterBuffer confirms a bytes.Buffer (what every test uses +// for stdout) is never treated as a terminal, so Run never pages in tests. +func TestIsTerminalWriterBuffer(t *testing.T) { + if isTerminalWriter(&bytes.Buffer{}) { + t.Error("isTerminalWriter(&bytes.Buffer{}) = true, want false") + } +} + +// TestPagerFlagDoesNotBreakBufferOutput confirms -P set against a +// bytes.Buffer stdout (non-TTY) still writes plainly -- pagerRequested may +// be true, but isTerminalWriter gates it off, so Run's existing dispatch +// runs unchanged. +func TestPagerFlagDoesNotBreakBufferOutput(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var out, errb bytes.Buffer + code := Run([]string{"-P", "-o", "-l", "trad"}, nil, &out, &errb) + if code != 0 && code != 1 { + t.Fatalf("-P run code=%d (stderr=%q)", code, errb.String()) + } + // The key assertion: no hang, no panic, output goes to the buffer + // directly (a real pager exec would have blocked/failed differently). + if out.Len() == 0 && code == 0 { + t.Errorf("-P run produced no stdout output on success") + } +} |
