aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 09:30:26 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 09:30:26 +0200
commit9321fd3490b1739e4c84fd229a2cfef244ffdc29 (patch)
treedc747f19d74f06e8bc4741864d007e0cd593e5eb /internal
parentb93de95dd24ff2a1d3126e6d7d00cd77530a03bf (diff)
downloadlectio-9321fd3490b1739e4c84fd229a2cfef244ffdc29.tar.gz
lectio-9321fd3490b1739e4c84fd229a2cfef244ffdc29.zip
cli: pager for reading output (-P/--pager, --no-pager, config pager, $PAGER fallback); v0.3.0
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/cli.go40
-rw-r--r--internal/cli/pager.go83
-rw-r--r--internal/cli/pager_test.go109
-rw-r--r--internal/config/config.go4
-rw-r--r--internal/config/config.toml1
-rw-r--r--internal/config/config_test.go34
6 files changed, 261 insertions, 10 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")
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 74588aa..5102d38 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -24,7 +24,7 @@ var seedTOML []byte
// Version is lectio's release version, shared by every binary's
// -v/--version output (lectio, lectio-ui, lectio-web).
-const Version = "0.2.0"
+const Version = "0.3.0"
// validVersions are the five scripture versions lectio understands.
var validVersions = map[string]bool{
@@ -108,6 +108,7 @@ type Config struct {
WebDisplay string `toml:"web_display"`
WebMono bool `toml:"web_mono"`
WebVersions []string `toml:"web_versions"`
+ Pager string `toml:"pager"`
Parts map[string]map[string]bool `toml:"parts"`
}
@@ -139,6 +140,7 @@ func Default() Config {
WebDisplay: "horizontal",
WebMono: false,
WebVersions: nil,
+ Pager: "",
Parts: nil,
}
}
diff --git a/internal/config/config.toml b/internal/config/config.toml
index c1f97db..49cca7f 100644
--- a/internal/config/config.toml
+++ b/internal/config/config.toml
@@ -12,6 +12,7 @@ web_port = 0 # lectio-web port; 0 = try 1099, then any free port
web_display = "horizontal" # default lectio-web layout ("uklad"): "horizontal" (stacked), "vertical" (columns), "interlinear" (verse-by-verse)
web_mono = false # lectio-web: monospace reading face for any theme (also a top-bar "mono" toggle)
web_versions = [] # lectio-web: which versions start toggled on, e.g. ["wuj", "vul"]; empty = just default_version
+pager = "" # send reading output through a pager, e.g. "less -R"; empty = off (also -P/--no-pager)
# Hide reading parts you don't want. Every part shows by default; a part is
# hidden only when you uncomment its line below (each is preset to false =
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 616f0cd..0f7bea8 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -144,6 +144,40 @@ func TestUILanguageNormalizesUnknown(t *testing.T) {
}
}
+func TestPagerDefault(t *testing.T) {
+ def := Default()
+ if def.Pager != "" {
+ t.Errorf("Pager default should be empty, got %q", def.Pager)
+ }
+}
+
+func TestPagerLoadsFromSeed(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", dir)
+ cfg, err := Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Pager != "" {
+ t.Errorf("Pager from seed wrong: got %q, want empty", cfg.Pager)
+ }
+}
+
+func TestPagerLoadsSetting(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", dir)
+ os.MkdirAll(filepath.Join(dir, "lectio"), 0o755)
+ os.WriteFile(filepath.Join(dir, "lectio", "config.toml"),
+ []byte("pager = \"less -R\"\n"), 0o644)
+ cfg, err := Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Pager != "less -R" {
+ t.Errorf("Pager = %q, want %q", cfg.Pager, "less -R")
+ }
+}
+
func TestPartShown(t *testing.T) {
var empty Config
if !empty.PartShown("new", "psalm") {