diff --git a/follow.go b/follow.go new file mode 100644 index 0000000..f755d03 --- /dev/null +++ b/follow.go @@ -0,0 +1,346 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glow/v2/utils" + "github.com/charmbracelet/lipgloss" + "github.com/fsnotify/fsnotify" +) + +const ( + // followDebounce coalesces the burst of write events a single save + // produces before reading the file. + followDebounce = 100 * time.Millisecond + // followIdleFlush renders a trailing block that has no terminating blank + // line yet, once the file has been quiet for this long. + followIdleFlush = 8 * time.Second +) + +// fenceState tracks whether a scan position is inside a fenced code block. +type fenceState struct { + open bool + char byte + length int + line string +} + +// trimIndent removes up to three leading spaces. Four or more means an +// indented code block, which cannot open or close a fence. +func trimIndent(line []byte) []byte { + for i := 0; i < 3 && len(line) > 0 && line[0] == ' '; i++ { + line = line[1:] + } + if len(line) > 0 && line[0] == ' ' { + return nil + } + return line +} + +// openingFence reports whether line opens a fenced code block, returning the +// fence character and length. +func openingFence(line []byte) (byte, int, bool) { + trimmed := trimIndent(line) + if len(trimmed) < 3 { + return 0, 0, false + } + ch := trimmed[0] + if ch != '`' && ch != '~' { + return 0, 0, false + } + n := 0 + for n < len(trimmed) && trimmed[n] == ch { + n++ + } + if n < 3 { + return 0, 0, false + } + // the info string of a backtick fence cannot contain backticks + if ch == '`' && bytes.IndexByte(trimmed[n:], '`') >= 0 { + return 0, 0, false + } + return ch, n, true +} + +// closesFence reports whether line closes the fence described by st. +func closesFence(line []byte, st fenceState) bool { + trimmed := trimIndent(line) + n := 0 + for n < len(trimmed) && trimmed[n] == st.char { + n++ + } + if n < st.length { + return false + } + return len(bytes.TrimSpace(trimmed[n:])) == 0 +} + +// lastBoundary scans the complete lines of data, starting in fence state st, +// and returns the offset just past the last blank line that sits outside a +// fenced code block (0 if there is none), along with the fence state after +// the last complete line. +func lastBoundary(data []byte, st fenceState) (int, fenceState) { + boundary, pos := 0, 0 + for { + nl := bytes.IndexByte(data[pos:], '\n') + if nl < 0 { + break + } + line := data[pos : pos+nl] + pos += nl + 1 + switch { + case st.open: + if closesFence(line, st) { + st = fenceState{} + } + default: + if ch, n, ok := openingFence(line); ok { + st = fenceState{open: true, char: ch, length: n, line: string(line)} + } else if len(bytes.TrimSpace(line)) == 0 { + boundary = pos + } + } + } + return boundary, st +} + +// follower tails a file and renders appended markdown in block-safe chunks. +type follower struct { + path string + w io.Writer + render func(string) (string, error) + isCode bool + ext string + + offset int64 + rewritten bool + pending []byte + st fenceState + // reopenLine is the opening fence line of a code block that a forced + // flush rendered before its closing fence arrived; it is prepended to + // the next chunk so the remainder still renders as code. + reopenLine string +} + +// emit renders a chunk and appends it to the output. +func (f *follower) emit(chunk []byte) error { + content := string(chunk) + if f.reopenLine != "" && !f.isCode { + content = f.reopenLine + "\n" + content + } + if f.isCode { + content = utils.WrapCodeBlock(content, f.ext) + } + out, err := f.render(content) + if err != nil { + return fmt.Errorf("unable to render markdown: %w", err) + } + out = strings.Trim(out, "\n") + if out == "" { + return nil + } + if _, err := fmt.Fprintf(f.w, "%s\n\n", out); err != nil { + return fmt.Errorf("unable to write to writer: %w", err) + } + return nil +} + +// flushComplete renders everything up to the last safe block boundary and +// keeps the remainder pending. +func (f *follower) flushComplete() error { + var boundary int + if f.isCode { + boundary = bytes.LastIndexByte(f.pending, '\n') + 1 + } else { + boundary, _ = lastBoundary(f.pending, f.st) + } + if boundary <= 0 { + return nil + } + chunk := f.pending[:boundary] + f.pending = append([]byte(nil), f.pending[boundary:]...) + err := f.emit(chunk) + // a boundary is always outside a fence, so any reopened fence is closed + f.st = fenceState{} + f.reopenLine = "" + return err +} + +// flushAll renders everything pending, including a trailing block that has no +// terminating blank line yet. If that leaves a fence open, the next chunk +// reopens it. +func (f *follower) flushAll() error { + if len(f.pending) == 0 { + return nil + } + chunk := f.pending + f.pending = nil + if !f.isCode { + _, st := lastBoundary(chunk, f.st) + err := f.emit(chunk) + f.st = st + if st.open { + f.reopenLine = st.line + } else { + f.reopenLine = "" + } + return err + } + return f.emit(chunk) +} + +// renderWhole renders the file from the beginning, as on startup or after the +// file was rewritten. +func (f *follower) renderWhole() error { + raw, err := os.ReadFile(f.path) + if err != nil { + return fmt.Errorf("unable to read file: %w", err) + } + f.offset = int64(len(raw)) + f.pending = utils.RemoveFrontmatter(raw) + f.st = fenceState{} + f.reopenLine = "" + return f.flushAll() +} + +// printDivider separates a rewritten file's fresh render from prior output. +func (f *follower) printDivider() error { + out, err := f.render("---") + if err != nil { + return fmt.Errorf("unable to render markdown: %w", err) + } + if out = strings.Trim(out, "\n"); out == "" { + return nil + } + if _, err := fmt.Fprintf(f.w, "%s\n\n", out); err != nil { + return fmt.Errorf("unable to write to writer: %w", err) + } + return nil +} + +// readNew ingests whatever the file gained since the last read. A shrunken or +// replaced file is treated like tail -f treats truncation: print a divider +// and render the whole file again. +func (f *follower) readNew() error { + fi, err := os.Stat(f.path) + if err != nil { + // the file is momentarily gone (e.g. mid atomic save); wait for it + // to reappear + return nil + } + if f.rewritten || fi.Size() < f.offset { + f.rewritten = false + if err := f.printDivider(); err != nil { + return err + } + return f.renderWhole() + } + if fi.Size() == f.offset { + return nil + } + file, err := os.Open(f.path) + if err != nil { + return fmt.Errorf("unable to read file: %w", err) + } + defer file.Close() //nolint:errcheck + if _, err := file.Seek(f.offset, io.SeekStart); err != nil { + return fmt.Errorf("unable to read file: %w", err) + } + data, err := io.ReadAll(file) + if err != nil { + return fmt.Errorf("unable to read file: %w", err) + } + f.offset += int64(len(data)) + f.pending = append(f.pending, data...) + return f.flushComplete() +} + +// runFollow renders path and then appends newly written content as it +// arrives, until interrupted. +func runFollow(path string, w io.Writer) error { + abs, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("unable to resolve path: %w", err) + } + + isCode := !utils.IsMarkdownFile(abs) + r, err := glamour.NewTermRenderer( + glamour.WithColorProfile(lipgloss.ColorProfile()), + utils.GlamourStyle(style, isCode), + glamour.WithWordWrap(int(width)), + glamour.WithPreservedNewLines(), + ) + if err != nil { + return fmt.Errorf("unable to create renderer: %w", err) + } + + f := &follower{ + path: abs, + w: w, + render: r.Render, + isCode: isCode, + ext: filepath.Ext(abs), + } + if err := f.renderWhole(); err != nil { + return err + } + + // watch the parent directory rather than the file itself so the watch + // survives editors that save atomically via rename + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("unable to watch file: %w", err) + } + defer watcher.Close() //nolint:errcheck + if err := watcher.Add(filepath.Dir(abs)); err != nil { + return fmt.Errorf("unable to watch file: %w", err) + } + + debounce := time.NewTimer(followDebounce) + debounce.Stop() + idle := time.NewTimer(followIdleFlush) + idle.Stop() + + for { + select { + case ev, ok := <-watcher.Events: + if !ok { + return nil + } + if filepath.Clean(ev.Name) != abs { + continue + } + if ev.Op&fsnotify.Create != 0 { + f.rewritten = true + } + if ev.Op&(fsnotify.Write|fsnotify.Create) != 0 { + debounce.Reset(followDebounce) + } + case err, ok := <-watcher.Errors: + if !ok { + return nil + } + return fmt.Errorf("unable to watch file: %w", err) + case <-debounce.C: + if err := f.readNew(); err != nil { + return err + } + if len(f.pending) > 0 { + idle.Reset(followIdleFlush) + } else { + idle.Stop() + } + case <-idle.C: + if err := f.flushAll(); err != nil { + return err + } + } + } +} diff --git a/follow_test.go b/follow_test.go new file mode 100644 index 0000000..66b876c --- /dev/null +++ b/follow_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestLastBoundary(t *testing.T) { + tests := []struct { + name string + data string + boundary int + open bool + }{ + { + name: "no blank line", + data: "a paragraph\nstill going", + boundary: 0, + }, + { + name: "blank line ends paragraph", + data: "para\n\nmore", + boundary: len("para\n\n"), + }, + { + name: "blank line inside fence is not a boundary", + data: "```\ncode\n\nmore code\n", + boundary: 0, + open: true, + }, + { + name: "boundary after closed fence", + data: "```go\ncode\n```\n\ntail", + boundary: len("```go\ncode\n```\n\n"), + }, + { + name: "short closing fence does not close", + data: "````\n```\n\n", + boundary: 0, + open: true, + }, + { + name: "tilde fence ignores backticks", + data: "~~~\n```\n\n~~~\n\n", + boundary: len("~~~\n```\n\n~~~\n\n"), + }, + { + name: "indented fence marker is code not fence", + data: " ```\n\nafter", + boundary: len(" ```\n\n"), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + boundary, st := lastBoundary([]byte(tc.data), fenceState{}) + if boundary != tc.boundary { + t.Errorf("boundary = %d, want %d", boundary, tc.boundary) + } + if st.open != tc.open { + t.Errorf("fence open = %v, want %v", st.open, tc.open) + } + }) + } +} + +// newTestFollower returns a follower whose renderer is the identity function, +// so output inspection sees the exact markdown each chunk rendered. +func newTestFollower(isCode bool) (*follower, *bytes.Buffer) { + var buf bytes.Buffer + return &follower{ + w: &buf, + render: func(s string) (string, error) { return s, nil }, + isCode: isCode, + ext: ".txt", + }, &buf +} + +func TestFlushCompleteHoldsPartialBlock(t *testing.T) { + f, buf := newTestFollower(false) + f.pending = []byte("done\n\npartial") + if err := f.flushComplete(); err != nil { + t.Fatal(err) + } + if got := buf.String(); got != "done\n\n" { + t.Errorf("output = %q, want %q", got, "done\n\n") + } + if got := string(f.pending); got != "partial" { + t.Errorf("pending = %q, want %q", got, "partial") + } +} + +func TestFlushCompleteWaitsForFenceClose(t *testing.T) { + f, buf := newTestFollower(false) + f.pending = []byte("```\ncode\n\n") + if err := f.flushComplete(); err != nil { + t.Fatal(err) + } + if buf.Len() != 0 { + t.Errorf("output = %q, want empty while fence is open", buf.String()) + } + + f.pending = append(f.pending, []byte("```\n\n")...) + if err := f.flushComplete(); err != nil { + t.Fatal(err) + } + if got := buf.String(); !strings.Contains(got, "code") { + t.Errorf("output = %q, want the closed fence rendered", got) + } +} + +func TestFlushAllReopensFence(t *testing.T) { + f, buf := newTestFollower(false) + f.pending = []byte("```go\nfirst half\n") + if err := f.flushAll(); err != nil { + t.Fatal(err) + } + if got := buf.String(); !strings.Contains(got, "first half") { + t.Errorf("output = %q, want forced flush of open fence", got) + } + if f.reopenLine != "```go" { + t.Errorf("reopenLine = %q, want %q", f.reopenLine, "```go") + } + + buf.Reset() + f.pending = []byte("second half\n```\n\n") + if err := f.flushComplete(); err != nil { + t.Fatal(err) + } + got := buf.String() + if !strings.HasPrefix(got, "```go\n") { + t.Errorf("output = %q, want chunk prefixed with reopened fence", got) + } + if f.reopenLine != "" || f.st.open { + t.Errorf("fence state not cleared after close: reopen=%q open=%v", f.reopenLine, f.st.open) + } +} + +func TestFlushCompletePlainTextFlushesWholeLines(t *testing.T) { + f, buf := newTestFollower(true) + f.pending = []byte("one\ntwo\npart") + if err := f.flushComplete(); err != nil { + t.Fatal(err) + } + got := buf.String() + if !strings.Contains(got, "one\ntwo") { + t.Errorf("output = %q, want complete lines flushed", got) + } + if strings.Contains(got, "part") { + t.Errorf("output = %q, must not contain the partial line", got) + } + if got := string(f.pending); got != "part" { + t.Errorf("pending = %q, want %q", got, "part") + } +} diff --git a/main.go b/main.go index b31ca15..62ecabc 100644 --- a/main.go +++ b/main.go @@ -44,6 +44,7 @@ var ( showLineNumbers bool preserveNewLines bool mouse bool + follow bool rootCmd = &cobra.Command{ Use: "glow [SOURCE|DIR]", @@ -222,6 +223,10 @@ func stdinIsPipe() (bool, error) { } func execute(cmd *cobra.Command, args []string) error { + if follow { + return executeFollow(cmd, args) + } + // if stdin is a pipe then use stdin for input. note that you can also // explicitly use a - to read from stdin. if yes, err := stdinIsPipe(); err != nil { @@ -262,6 +267,32 @@ func execute(cmd *cobra.Command, args []string) error { return nil } +func executeFollow(cmd *cobra.Command, args []string) error { + if pager || cmd.Flags().Changed("pager") || tui || cmd.Flags().Changed("tui") { + return errors.New("--follow cannot be combined with --pager or --tui") + } + if yes, err := stdinIsPipe(); err != nil { + return err + } else if yes { + return errors.New("--follow requires a local file, not stdin") + } + if len(args) != 1 { + return errors.New("--follow requires exactly one local file") + } + if isURL(args[0]) { + return errors.New("--follow requires a local file, not a URL") + } + path := utils.ExpandPath(args[0]) + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("unable to follow %s: %w", args[0], err) + } + if info.IsDir() { + return fmt.Errorf("unable to follow %s: it is a directory", args[0]) + } + return runFollow(path, os.Stdout) +} + func executeArg(cmd *cobra.Command, arg string, w io.Writer) error { // create an io.Reader from the markdown source in cli-args src, err := sourceFromArg(arg) @@ -407,6 +438,7 @@ func init() { rootCmd.Flags().BoolVarP(&showAllFiles, "all", "a", false, "show system files and directories (TUI-mode only)") rootCmd.Flags().BoolVarP(&showLineNumbers, "line-numbers", "l", false, "show line numbers (TUI-mode only)") rootCmd.Flags().BoolVarP(&preserveNewLines, "preserve-new-lines", "n", false, "preserve newlines in the output") + rootCmd.Flags().BoolVarP(&follow, "follow", "f", false, "render new content appended to a file as it grows (like tail -f)") rootCmd.Flags().BoolVarP(&mouse, "mouse", "m", false, "enable mouse wheel (TUI-mode only)") _ = rootCmd.Flags().MarkHidden("mouse") diff --git a/plans/follow-mode.md b/plans/follow-mode.md new file mode 100644 index 0000000..e73581b --- /dev/null +++ b/plans/follow-mode.md @@ -0,0 +1,67 @@ +# Plan: `-f` / `--follow` mode + +`tail -f` for markdown: append-only output to stdout, no repainting. Scrollback +stays intact and output works when piped. + +## Scope + +- Only valid for a single local file argument — error out for stdin, URLs, and + directories (`cannot follow non-file sources`). +- `-f` is a free short flag (taken: `-p -t -s -w -a -l -n -m`). + +## Design + +### Core loop + +- fsnotify watches the file's **parent directory** (survives atomic renames + from editors) and filters events for the target filename. +- Keep a read offset. On write events, read from offset to EOF and append the + new bytes to an input buffer. Nothing is printed yet. +- Debounce write bursts (~100ms) since saves often produce multiple events. + +### Chunker (flush only at safe boundaries) + +Markdown can't be rendered mid-block, so the buffer is only flushed at safe +boundaries. State to track is small: + +- **Fenced code blocks:** track opening ``` / ~~~ (fence char and length, per + CommonMark rules) and hold everything until the matching closing fence. +- **Outside a fence:** a blank line is the natural terminator — it ends a + paragraph, list, or table. + +A flushable chunk = everything up to the last blank line that isn't inside an +open fence. Render the chunk with glamour, print it, keep the remainder +buffered. Trim glamour's leading/trailing blank-line padding so consecutive +chunks read as one continuous document. + +### Startup + +Render the existing file content as the first chunk, then start following — +matching `tail -f` showing the tail before waiting. + +### Truncation / rewrite + +If file size < offset, the file was truncated or rewritten (e.g., an in-place +edit + save). Do what `tail -f` does: print a visible divider, re-render the +whole file below it, reset the offset. Scrolls, never repaints. + +## Caveats (documented behavior) + +1. **Append semantics, not edit semantics.** Ideal for files being appended to + (build logs, LLM output streaming, accumulated notes). Mid-file edits fall + back to the truncation path above. +2. **Trailing partial block.** A paragraph not yet followed by a blank line is + ambiguous — the next write might continue it, and we can't unprint. + Decision: flush complete blocks eagerly; a trailing unterminated block is + flushed after an 8-second idle timeout. If the idle flush splits an open + code fence, the opening fence line is re-emitted with the next chunk so + the remainder still renders as code. +3. **Cross-chunk features degrade slightly.** Reference-style links defined in + a later chunk and setext headings won't resolve across chunk boundaries, + since each chunk renders independently. Rare in appended markdown; worth a + line in the docs. + +## Pipeline summary + +fsnotify → offset reader → fence-aware blank-line chunker → per-chunk glamour +render → append to stdout, with truncation handled by divider-plus-rerender. diff --git a/summaries/follow-mode.md b/summaries/follow-mode.md new file mode 100644 index 0000000..c160509 --- /dev/null +++ b/summaries/follow-mode.md @@ -0,0 +1,47 @@ +# Summary: `-f` / `--follow` mode + +Implemented `tail -f` for markdown (plan: `plans/follow-mode.md`): `glow -f +file.md` renders the file, then appends newly written content to stdout as the +file grows. Output is append-only — no repainting — so scrollback survives and +piping works. + +## Changes + +- **`follow.go`** (new) — the feature: + - fsnotify watches the file's **parent directory**, so atomic-rename saves + from editors don't kill the watch; events are debounced 100ms. + - An offset reader pulls only appended bytes into a pending buffer. + - A chunker flushes at the last blank line outside a fenced code block, with + CommonMark-correct fence tracking (backtick/tilde, fence length, ≤3-space + indent, no backticks in a backtick fence's info string). + - A trailing block with no terminating blank line flushes after an **8s idle + timeout**. If that splits an open code fence, the opening fence line is + re-emitted with the next chunk so the remainder still renders as code + (goldmark treats the unclosed fence as code-to-EOF). + - A truncated or rewritten file (in-place edit, atomic save) gets a rendered + `---` divider and a full re-render below prior output. + - Non-markdown files render per complete line, wrapped as code blocks. +- **`main.go`** — registers `-f`/`--follow`; validates: exactly one local + file, with clear errors for stdin, URLs, directories, and combining with + `--pager`/`--tui`. +- **`follow_test.go`** (new) — unit tests for boundary detection (fences, + short closing fence, tilde fences, indented pseudo-fences) and the flush + paths (partial block held, fence held open, fence reopen after idle flush, + plain-text line flushing). + +## Verification + +- `go build`, `go vet`, `go test ./...` all pass. +- `golangci-lint run`: zero issues in the new code (remaining findings are + pre-existing in `ui/` and untouched parts of `main.go`). +- End-to-end script drove the built binary while appending to a watched file: + partial paragraph stayed hidden until its blank line; a fence with an + internal blank line held until closed; a rewrite produced divider + fresh + render; a trailing unterminated paragraph appeared only after the 8s idle + flush; all validation errors read correctly. + +## Known limitations (documented in the plan) + +- Append semantics: mid-file edits fall back to divider + full re-render. +- Reference-style links and setext headings don't resolve across chunk + boundaries.