diff --git a/cmd/lint.go b/cmd/lint.go index f5c1412..10d0a25 100644 --- a/cmd/lint.go +++ b/cmd/lint.go @@ -12,8 +12,8 @@ import ( var lintCmd = &cobra.Command{ Use: "lint [files...]", Short: "Lint markdown files for structural issues", - Long: "Checks markdown files for heading hierarchy, duplicate headings, empty links, and other structural issues.", - Args: cobra.MinimumNArgs(1), + Long: "Checks markdown files for heading hierarchy, duplicate headings, empty links, and other structural issues. Reads from stdin if no files are provided.", + Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("11")) @@ -21,26 +21,49 @@ var lintCmd = &cobra.Command{ totalIssues := 0 - for _, path := range args { - issues, err := linter.LintFile(path) + if len(args) == 0 || (len(args) == 1 && args[0] == "-") { + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) != 0 { + return fmt.Errorf("no files provided and nothing on stdin") + } + issues, err := linter.LintReader(os.Stdin) if err != nil { - fmt.Fprintf(os.Stderr, "error reading %s: %v\n", path, err) - continue + return fmt.Errorf("error reading stdin: %w", err) } - if len(issues) == 0 { - continue + if len(issues) > 0 { + fmt.Println(fileStyle.Render("")) + for _, issue := range issues { + prefix := warnStyle.Render("warning") + if issue.Severity == linter.SeverityError { + prefix = errorStyle.Render("error") + } + fmt.Printf(" line %d: %s %s (%s)\n", issue.Line, prefix, issue.Message, issue.Rule) + } + fmt.Println() + totalIssues += len(issues) } + } else { + for _, path := range args { + issues, err := linter.LintFile(path) + if err != nil { + fmt.Fprintf(os.Stderr, "error reading %s: %v\n", path, err) + continue + } + if len(issues) == 0 { + continue + } - fmt.Println(fileStyle.Render(path)) - for _, issue := range issues { - prefix := warnStyle.Render("warning") - if issue.Severity == linter.SeverityError { - prefix = errorStyle.Render("error") + fmt.Println(fileStyle.Render(path)) + for _, issue := range issues { + prefix := warnStyle.Render("warning") + if issue.Severity == linter.SeverityError { + prefix = errorStyle.Render("error") + } + fmt.Printf(" line %d: %s %s (%s)\n", issue.Line, prefix, issue.Message, issue.Rule) } - fmt.Printf(" line %d: %s %s (%s)\n", issue.Line, prefix, issue.Message, issue.Rule) + fmt.Println() + totalIssues += len(issues) } - fmt.Println() - totalIssues += len(issues) } if totalIssues > 0 { diff --git a/cmd/stats.go b/cmd/stats.go new file mode 100644 index 0000000..f9ce7b2 --- /dev/null +++ b/cmd/stats.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + "github.com/tirthpatell/mdr/internal/stats" +) + +var statsCmd = &cobra.Command{ + Use: "stats [file]", + Short: "Show word count and document statistics for a markdown file", + Long: "Displays word count, line count, heading count, link count, and estimated reading time. Reads from stdin if no file is provided.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var s stats.Stats + var name string + var err error + + if len(args) == 0 || args[0] == "-" { + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) != 0 { + return fmt.Errorf("no file provided and nothing on stdin") + } + s, err = stats.FromReader(os.Stdin) + name = "" + } else { + s, err = stats.FromFile(args[0]) + name = args[0] + } + if err != nil { + return err + } + + titleStyle := lipgloss.NewStyle().Bold(true) + fmt.Println(titleStyle.Render(name)) + fmt.Println(s.String()) + return nil + }, +} + +func init() { + rootCmd.AddCommand(statsCmd) +} diff --git a/internal/editor/editor.go b/internal/editor/editor.go index 865ba57..e80c0a1 100644 --- a/internal/editor/editor.go +++ b/internal/editor/editor.go @@ -12,17 +12,19 @@ import ( ) type Model struct { - buffer *Buffer - filePath string - fileMode fs.FileMode - cursorRow int - cursorCol int - offsetRow int - width int - height int - editWidth int - showHelp bool - err error + buffer *Buffer + filePath string + fileMode fs.FileMode + cursorRow int + cursorCol int + offsetRow int + width int + height int + editWidth int + showHelp bool + confirmQuit bool + err error + saveMsg string } func NewModel(content string, filePath string) Model { @@ -60,12 +62,41 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: + // Handle quit confirmation dialog + if m.confirmQuit { + switch msg.String() { + case "y", "Y": + return m, tea.Quit + case "n", "N", "esc": + m.confirmQuit = false + return m, nil + default: + return m, nil + } + } + + // Clear transient messages on any non-save keypress + if msg.Type != tea.KeyCtrlS { + m.err = nil + m.saveMsg = "" + } + switch msg.Type { case tea.KeyCtrlC: + if m.buffer.Modified() { + m.confirmQuit = true + return m, nil + } return m, tea.Quit case tea.KeyCtrlS: m.err = m.save() + if m.err == nil { + m.buffer.ResetModified() + m.saveMsg = "Saved!" + } else { + m.saveMsg = "" + } return m, nil case tea.KeyCtrlH: @@ -205,8 +236,16 @@ var ( BorderLeft(true). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("241")) - helpStyle = lipgloss.NewStyle(). + editorHelpStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("241")) + errStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("9")). + Bold(true) + savedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("10")) + confirmStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("11")). + Bold(true) ) func (m Model) View() string { @@ -220,8 +259,13 @@ func (m Model) View() string { editW = m.width / 2 } + if m.confirmQuit { + prompt := confirmStyle.Render("Unsaved changes. Quit without saving? (y/n)") + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, prompt) + } + if m.showHelp { - help := helpStyle.Render(strings.Join([]string{ + help := editorHelpStyle.Render(strings.Join([]string{ "Editor Help", "", " Arrow keys Move cursor", @@ -259,7 +303,10 @@ func (m Model) View() string { editorPane := strings.Join(editorLines, "\n") previewW := m.width - editW - 1 - rendered, _ := markdown.Render(m.buffer.String()) + rendered, renderErr := markdown.Render(m.buffer.String()) + if renderErr != nil { + rendered = errStyle.Render("Preview error: " + renderErr.Error()) + } previewLines := strings.Split(rendered, "\n") if len(previewLines) > editHeight { previewLines = previewLines[:editHeight] @@ -278,8 +325,15 @@ func (m Model) View() string { if m.buffer.Modified() { modIndicator = " [+]" } - status := statusStyle.Render(fmt.Sprintf(" %s%s Ln %d, Col %d Ctrl+S: save Ctrl+H: help Ctrl+C: quit", - m.filePath, modIndicator, m.cursorRow+1, m.cursorCol+1)) + statusText := fmt.Sprintf(" %s%s Ln %d, Col %d Ctrl+S: save Ctrl+H: help Ctrl+C: quit", + m.filePath, modIndicator, m.cursorRow+1, m.cursorCol+1) + if m.err != nil { + statusText += " " + errStyle.Render("Error: "+m.err.Error()) + } + if m.saveMsg != "" { + statusText += " " + savedStyle.Render(m.saveMsg) + } + status := statusStyle.Render(statusText) return body + "\n" + status } diff --git a/internal/editor/editor_test.go b/internal/editor/editor_test.go index f248d5e..eb336e0 100644 --- a/internal/editor/editor_test.go +++ b/internal/editor/editor_test.go @@ -1,6 +1,9 @@ package editor import ( + "os" + "path/filepath" + "strings" "testing" tea "github.com/charmbracelet/bubbletea" @@ -70,3 +73,119 @@ func TestEditorModel_EnterKey(t *testing.T) { t.Fatalf("expected cursor at col 0, got %d", model.cursorCol) } } + +func TestEditorModel_SaveResetsModified(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "test.md") + os.WriteFile(path, []byte("hello"), 0644) + + m, err := NewModelFromFile(path) + if err != nil { + t.Fatal(err) + } + m.width = 80 + m.height = 24 + + // Type a character to set modified + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}}) + model := updated.(Model) + if !model.buffer.Modified() { + t.Fatal("buffer should be modified after typing") + } + + // Save + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + model = updated.(Model) + if model.buffer.Modified() { + t.Fatal("buffer should not be modified after save") + } + if model.saveMsg != "Saved!" { + t.Fatalf("expected save message 'Saved!', got %q", model.saveMsg) + } +} + +func TestEditorModel_SaveError_DisplayedInView(t *testing.T) { + m := NewModel("hello", "/nonexistent/path/file.md") + m.width = 80 + m.height = 24 + m.editWidth = 40 + + // Trigger save + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + model := updated.(Model) + if model.err == nil { + t.Fatal("expected save error for invalid path") + } + view := model.View() + if !strings.Contains(view, "Error:") { + t.Fatal("expected error message in view output") + } +} + +func TestEditorModel_QuitConfirmation_Modified(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + m.width = 80 + m.height = 24 + + // Type to set modified + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}}) + model := updated.(Model) + + // Press Ctrl+C — should NOT quit, should show confirmation + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + model = updated.(Model) + if cmd != nil { + t.Fatal("should not quit immediately with unsaved changes") + } + if !model.confirmQuit { + t.Fatal("expected confirmQuit to be true") + } + + // View should show the confirmation prompt + view := model.View() + if !strings.Contains(view, "Unsaved changes") { + t.Fatal("expected confirmation prompt in view") + } + + // Press 'n' to cancel + updated, cmd = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + model = updated.(Model) + if cmd != nil { + t.Fatal("pressing 'n' should not quit") + } + if model.confirmQuit { + t.Fatal("confirmQuit should be false after 'n'") + } +} + +func TestEditorModel_QuitConfirmation_Accept(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + m.width = 80 + m.height = 24 + + // Type to set modified + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}}) + model := updated.(Model) + + // Press Ctrl+C + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + model = updated.(Model) + + // Press 'y' to confirm quit + _, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) + if cmd == nil { + t.Fatal("pressing 'y' should quit") + } +} + +func TestEditorModel_QuitUnmodified(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + m.width = 80 + m.height = 24 + + // Press Ctrl+C on unmodified buffer — should quit immediately + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + if cmd == nil { + t.Fatal("should quit immediately when no unsaved changes") + } +} diff --git a/internal/linter/linter.go b/internal/linter/linter.go index 5c91c61..33a0e31 100644 --- a/internal/linter/linter.go +++ b/internal/linter/linter.go @@ -1,6 +1,7 @@ package linter import ( + "io" "os" "sort" ) @@ -11,6 +12,8 @@ func Lint(source []byte) []Issue { issues = append(issues, checkHeadingHierarchy(source)...) issues = append(issues, checkDuplicateHeadings(source)...) issues = append(issues, checkEmptyLinks(source)...) + issues = append(issues, checkTrailingWhitespace(source)...) + issues = append(issues, checkEmptySections(source)...) sort.Slice(issues, func(i, j int) bool { return issues[i].Line < issues[j].Line @@ -26,3 +29,12 @@ func LintFile(path string) ([]Issue, error) { } return Lint(data), nil } + +// LintReader reads from an io.Reader and lints the content. +func LintReader(r io.Reader) ([]Issue, error) { + data, err := io.ReadAll(r) + if err != nil { + return nil, err + } + return Lint(data), nil +} diff --git a/internal/linter/linter_test.go b/internal/linter/linter_test.go index dc88d97..f575d4a 100644 --- a/internal/linter/linter_test.go +++ b/internal/linter/linter_test.go @@ -1,6 +1,7 @@ package linter import ( + "strings" "testing" ) @@ -40,9 +41,20 @@ func TestLintFile_NotFound(t *testing.T) { } func TestLint_CleanFile(t *testing.T) { - input := []byte("# Title\n\n## Subtitle\n\n[Link](https://example.com)\n") + input := []byte("# Title\n\nSome intro text.\n\n## Subtitle\n\n[Link](https://example.com)\n") issues := Lint(input) if len(issues) != 0 { - t.Fatalf("expected no issues for clean file, got %d", len(issues)) + t.Fatalf("expected no issues for clean file, got %d: %v", len(issues), issues) + } +} + +func TestLintReader(t *testing.T) { + r := strings.NewReader("# Title\n\n### Skip\n") + issues, err := LintReader(r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(issues) == 0 { + t.Fatal("expected issues from reader input") } } diff --git a/internal/linter/rules.go b/internal/linter/rules.go index f94171b..84a232f 100644 --- a/internal/linter/rules.go +++ b/internal/linter/rules.go @@ -114,14 +114,73 @@ func checkEmptyLinks(source []byte) []Issue { func extractText(n ast.Node, source []byte) string { var buf bytes.Buffer - for child := n.FirstChild(); child != nil; child = child.NextSibling() { + ast.Walk(n, func(child ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } if t, ok := child.(*ast.Text); ok { buf.Write(t.Segment.Value(source)) } - } + return ast.WalkContinue, nil + }) return buf.String() } +func checkTrailingWhitespace(source []byte) []Issue { + var issues []Issue + lines := bytes.Split(source, []byte("\n")) + for i, line := range lines { + trimmed := bytes.TrimRight(line, " \t") + if len(trimmed) < len(line) { + issues = append(issues, Issue{ + Rule: "trailing-whitespace", + Message: "line has trailing whitespace", + Line: i + 1, + Severity: SeverityWarning, + }) + } + } + return issues +} + +func checkEmptySections(source []byte) []Issue { + doc := parseAST(source) + var issues []Issue + var prevHeading *ast.Heading + var prevLine int + + ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + if heading, ok := n.(*ast.Heading); ok { + if prevHeading != nil && prevHeading.NextSibling() == heading { + issues = append(issues, Issue{ + Rule: "no-empty-sections", + Message: fmt.Sprintf("empty section under %q", extractText(prevHeading, source)), + Line: prevLine, + Severity: SeverityWarning, + }) + } + prevHeading = heading + prevLine = lineNumber(source, n) + } + return ast.WalkContinue, nil + }) + + // Flag a heading at the end of the document with no content after it + if prevHeading != nil && prevHeading.NextSibling() == nil { + issues = append(issues, Issue{ + Rule: "no-empty-sections", + Message: fmt.Sprintf("empty section under %q", extractText(prevHeading, source)), + Line: prevLine, + Severity: SeverityWarning, + }) + } + + return issues +} + func lineNumber(source []byte, n ast.Node) int { // Only block nodes support Lines(); inline nodes panic if n.Type() == ast.TypeBlock { diff --git a/internal/linter/rules_test.go b/internal/linter/rules_test.go index dfeeaf3..122ffc8 100644 --- a/internal/linter/rules_test.go +++ b/internal/linter/rules_test.go @@ -46,3 +46,66 @@ func TestEmptyLinks_NoIssue(t *testing.T) { t.Fatalf("expected no issues, got %d", len(issues)) } } + +func TestDuplicateHeadings_WithFormattedText(t *testing.T) { + input := []byte("# Title with **bold** text\n\n# Title with **bold** text\n") + issues := checkDuplicateHeadings(input) + if len(issues) == 0 { + t.Fatal("expected duplicate heading issue for headings with bold text") + } +} + +func TestDuplicateHeadings_DifferentText(t *testing.T) { + input := []byte("# Hello\n\n# World\n") + issues := checkDuplicateHeadings(input) + if len(issues) != 0 { + t.Fatalf("expected no issues, got %d", len(issues)) + } +} + +func TestTrailingWhitespace(t *testing.T) { + input := []byte("# Title \n\nClean line\nTrailing space \n") + issues := checkTrailingWhitespace(input) + if len(issues) != 2 { + t.Fatalf("expected 2 trailing whitespace issues, got %d", len(issues)) + } + if issues[0].Rule != "trailing-whitespace" { + t.Fatalf("expected rule 'trailing-whitespace', got %q", issues[0].Rule) + } +} + +func TestTrailingWhitespace_NoIssue(t *testing.T) { + input := []byte("# Title\n\nClean content\n") + issues := checkTrailingWhitespace(input) + if len(issues) != 0 { + t.Fatalf("expected no issues, got %d", len(issues)) + } +} + +func TestEmptySections(t *testing.T) { + input := []byte("# Title\n\n## Empty Section\n\n## Next Section\n\nSome content\n") + issues := checkEmptySections(input) + if len(issues) == 0 { + t.Fatal("expected empty section issue") + } + if issues[0].Rule != "no-empty-sections" { + t.Fatalf("expected rule 'no-empty-sections', got %q", issues[0].Rule) + } +} + +func TestEmptySections_NoIssue(t *testing.T) { + input := []byte("# Title\n\nContent here.\n\n## Section\n\nMore content.\n") + issues := checkEmptySections(input) + if len(issues) != 0 { + t.Fatalf("expected no issues, got %d", len(issues)) + } +} + +func TestEmptySections_EndOfDocument(t *testing.T) { + input := []byte("# Title\n\nContent\n\n## Trailing\n") + issues := checkEmptySections(input) + if len(issues) == 0 { + t.Fatal("expected empty section issue for heading at end of document") + } +} + diff --git a/internal/stats/stats.go b/internal/stats/stats.go new file mode 100644 index 0000000..ed26481 --- /dev/null +++ b/internal/stats/stats.go @@ -0,0 +1,102 @@ +package stats + +import ( + "fmt" + "io" + "math" + "os" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" +) + +// Stats holds document statistics. +type Stats struct { + Words int + Lines int + Headings int + Links int + Images int + CodeBlocks int + ReadTime string +} + +// FromBytes computes stats from raw markdown source. +func FromBytes(source []byte) Stats { + s := Stats{} + + // Line count + s.Lines = strings.Count(string(source), "\n") + if len(source) > 0 && source[len(source)-1] != '\n' { + s.Lines++ + } + + // Word count from raw source + s.Words = len(strings.Fields(string(source))) + + // AST-based counts + md := goldmark.New() + reader := text.NewReader(source) + doc := md.Parser().Parse(reader) + + ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch n.(type) { + case *ast.Heading: + s.Headings++ + case *ast.Link: + s.Links++ + case *ast.Image: + s.Images++ + case *ast.FencedCodeBlock, *ast.CodeBlock: + s.CodeBlocks++ + } + return ast.WalkContinue, nil + }) + + // Reading time estimate at ~200 words per minute + minutes := float64(s.Words) / 200.0 + if minutes < 1 { + s.ReadTime = "< 1 min" + } else { + s.ReadTime = fmt.Sprintf("%d min", int(math.Ceil(minutes))) + } + + return s +} + +// FromFile reads a file and computes stats. +func FromFile(path string) (Stats, error) { + data, err := os.ReadFile(path) + if err != nil { + return Stats{}, err + } + return FromBytes(data), nil +} + +// FromReader reads from an io.Reader and computes stats. +func FromReader(r io.Reader) (Stats, error) { + data, err := io.ReadAll(r) + if err != nil { + return Stats{}, err + } + return FromBytes(data), nil +} + +// String returns a formatted summary. +func (s Stats) String() string { + return fmt.Sprintf( + " Words: %d\n"+ + " Lines: %d\n"+ + " Headings: %d\n"+ + " Links: %d\n"+ + " Images: %d\n"+ + " Code blocks: %d\n"+ + " Reading time: %s", + s.Words, s.Lines, s.Headings, s.Links, s.Images, s.CodeBlocks, s.ReadTime, + ) +} diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go new file mode 100644 index 0000000..6228694 --- /dev/null +++ b/internal/stats/stats_test.go @@ -0,0 +1,105 @@ +package stats + +import ( + "strings" + "testing" +) + +func TestFromBytes_Basic(t *testing.T) { + input := []byte("# Hello World\n\nThis is a paragraph with some words.\n\n## Section\n\n[Link](https://example.com)\n") + s := FromBytes(input) + + if s.Headings != 2 { + t.Fatalf("expected 2 headings, got %d", s.Headings) + } + if s.Links != 1 { + t.Fatalf("expected 1 link, got %d", s.Links) + } + if s.Lines < 5 { + t.Fatalf("expected at least 5 lines, got %d", s.Lines) + } + if s.Words == 0 { + t.Fatal("expected non-zero word count") + } + if s.ReadTime == "" { + t.Fatal("expected reading time to be set") + } +} + +func TestFromBytes_Empty(t *testing.T) { + s := FromBytes([]byte("")) + if s.Words != 0 { + t.Fatalf("expected 0 words, got %d", s.Words) + } + if s.Lines != 0 { + t.Fatalf("expected 0 lines, got %d", s.Lines) + } +} + +func TestFromBytes_CodeBlocks(t *testing.T) { + input := []byte("# Title\n\n```go\nfmt.Println(\"hello\")\n```\n\n```\nplain block\n```\n") + s := FromBytes(input) + if s.CodeBlocks != 2 { + t.Fatalf("expected 2 code blocks, got %d", s.CodeBlocks) + } +} + +func TestFromBytes_Images(t *testing.T) { + input := []byte("# Gallery\n\n![Alt text](image.png)\n\n![Another](pic.jpg)\n") + s := FromBytes(input) + if s.Images != 2 { + t.Fatalf("expected 2 images, got %d", s.Images) + } +} + +func TestFromFile(t *testing.T) { + s, err := FromFile("../../testdata/complex.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.Headings == 0 { + t.Fatal("expected headings in complex.md") + } +} + +func TestFromFile_NotFound(t *testing.T) { + _, err := FromFile("nonexistent.md") + if err == nil { + t.Fatal("expected error for nonexistent file") + } +} + +func TestFromReader(t *testing.T) { + r := strings.NewReader("# Title\n\nSome words here.\n") + s, err := FromReader(r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.Headings != 1 { + t.Fatalf("expected 1 heading, got %d", s.Headings) + } +} + +func TestStats_String(t *testing.T) { + s := FromBytes([]byte("# Hello\n\nWorld\n")) + output := s.String() + if !strings.Contains(output, "Words:") { + t.Fatal("expected 'Words:' in string output") + } + if !strings.Contains(output, "Reading time:") { + t.Fatal("expected 'Reading time:' in string output") + } +} + +func TestFromBytes_ReadingTime(t *testing.T) { + // 200 words should be ~1 min + words := make([]string, 200) + for i := range words { + words[i] = "word" + } + input := []byte(strings.Join(words, " ")) + s := FromBytes(input) + if s.ReadTime != "1 min" { + t.Fatalf("expected '1 min' for 200 words, got %q", s.ReadTime) + } +} diff --git a/internal/viewer/viewer.go b/internal/viewer/viewer.go index a8b5b0b..9240211 100644 --- a/internal/viewer/viewer.go +++ b/internal/viewer/viewer.go @@ -1,26 +1,39 @@ package viewer import ( + "fmt" + "regexp" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) +// ansiRe matches ANSI escape sequences for stripping before search comparison. +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*m`) + type Model struct { content string lines []string offset int height int width int + + // Search state + searching bool + searchInput string + searchQuery string + matchLines []int + matchIndex int } func NewModel(rendered string) Model { lines := strings.Split(rendered, "\n") return Model{ - content: rendered, - lines: lines, - offset: 0, + content: rendered, + lines: lines, + offset: 0, + matchIndex: -1, } } @@ -28,6 +41,23 @@ func (m Model) Init() tea.Cmd { return nil } +// visibleHeight returns the number of content lines visible (excluding the status bar). +func (m Model) visibleHeight() int { + if m.height <= 1 { + return m.height + } + return m.height - 1 +} + +// maxOffset returns the maximum scroll offset so the last line is still visible. +func (m Model) maxOffset() int { + max := len(m.lines) - m.visibleHeight() + if max < 0 { + return 0 + } + return max +} + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -36,6 +66,37 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: + // Handle search input mode + if m.searching { + switch msg.Type { + case tea.KeyEscape: + m.searching = false + m.searchInput = "" + return m, nil + case tea.KeyEnter: + m.searching = false + m.searchQuery = m.searchInput + m.searchInput = "" + m.findMatches() + if len(m.matchLines) > 0 { + m.matchIndex = 0 + m.scrollToMatch() + } + return m, nil + case tea.KeyBackspace: + if len(m.searchInput) > 0 { + m.searchInput = m.searchInput[:len(m.searchInput)-1] + } + return m, nil + case tea.KeyRunes: + m.searchInput += string(msg.Runes) + return m, nil + default: + return m, nil + } + } + + // Normal mode switch { case msg.Type == tea.KeyCtrlC || msg.String() == "q": return m, tea.Quit @@ -47,28 +108,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case msg.Type == tea.KeyDown || msg.String() == "j": - maxOffset := len(m.lines) - m.height - if maxOffset < 0 { - maxOffset = 0 - } - if m.offset < maxOffset { + if m.offset < m.maxOffset() { m.offset++ } return m, nil case msg.Type == tea.KeyPgDown || msg.String() == "d": - maxOffset := len(m.lines) - m.height - if maxOffset < 0 { - maxOffset = 0 - } - m.offset += m.height / 2 - if m.offset > maxOffset { - m.offset = maxOffset + m.offset += m.visibleHeight() / 2 + if m.offset > m.maxOffset() { + m.offset = m.maxOffset() } return m, nil case msg.Type == tea.KeyPgUp || msg.String() == "u": - m.offset -= m.height / 2 + m.offset -= m.visibleHeight() / 2 if m.offset < 0 { m.offset = 0 } @@ -79,17 +132,72 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case msg.String() == "G": - maxOffset := len(m.lines) - m.height - if maxOffset < 0 { - maxOffset = 0 + m.offset = m.maxOffset() + return m, nil + + case msg.String() == "/": + m.searching = true + m.searchInput = "" + return m, nil + + case msg.String() == "n": + if len(m.matchLines) > 0 { + m.matchIndex = (m.matchIndex + 1) % len(m.matchLines) + m.scrollToMatch() } - m.offset = maxOffset + return m, nil + + case msg.String() == "N": + if len(m.matchLines) > 0 { + m.matchIndex-- + if m.matchIndex < 0 { + m.matchIndex = len(m.matchLines) - 1 + } + m.scrollToMatch() + } + return m, nil + + case msg.Type == tea.KeyEscape: + m.searchQuery = "" + m.matchLines = nil + m.matchIndex = -1 return m, nil } } return m, nil } +func (m *Model) findMatches() { + m.matchLines = nil + m.matchIndex = -1 + if m.searchQuery == "" { + return + } + query := strings.ToLower(m.searchQuery) + for i, line := range m.lines { + // Strip ANSI codes before matching + plain := ansiRe.ReplaceAllString(line, "") + if strings.Contains(strings.ToLower(plain), query) { + m.matchLines = append(m.matchLines, i) + } + } +} + +func (m *Model) scrollToMatch() { + if m.matchIndex < 0 || m.matchIndex >= len(m.matchLines) { + return + } + targetLine := m.matchLines[m.matchIndex] + // Center the match on screen + m.offset = targetLine - m.visibleHeight()/2 + if m.offset < 0 { + m.offset = 0 + } + if m.offset > m.maxOffset() { + m.offset = m.maxOffset() + } +} + var helpStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("241")) @@ -98,7 +206,7 @@ func (m Model) View() string { return m.content } - end := m.offset + m.height - 1 // reserve 1 line for status + end := m.offset + m.visibleHeight() if end > len(m.lines) { end = len(m.lines) } @@ -109,6 +217,17 @@ func (m Model) View() string { visible := m.lines[m.offset:end] view := strings.Join(visible, "\n") - status := helpStyle.Render(" ↑/↓/j/k: scroll • d/u: half-page • g/G: top/bottom • q: quit") + var status string + if m.searching { + status = helpStyle.Render(fmt.Sprintf(" /%s█", m.searchInput)) + } else if m.searchQuery != "" { + matchInfo := "[no matches]" + if len(m.matchLines) > 0 { + matchInfo = fmt.Sprintf("[%d/%d]", m.matchIndex+1, len(m.matchLines)) + } + status = helpStyle.Render(fmt.Sprintf(" /%s %s n/N: next/prev • Esc: clear • q: quit", m.searchQuery, matchInfo)) + } else { + status = helpStyle.Render(" ↑/↓/j/k: scroll • d/u: half-page • g/G: top/bottom • /: search • q: quit") + } return view + "\n" + status } diff --git a/internal/viewer/viewer_test.go b/internal/viewer/viewer_test.go index dfe860b..fadfc21 100644 --- a/internal/viewer/viewer_test.go +++ b/internal/viewer/viewer_test.go @@ -1,6 +1,7 @@ package viewer import ( + "strings" "testing" tea "github.com/charmbracelet/bubbletea" @@ -38,6 +39,38 @@ func TestModel_ScrollUp(t *testing.T) { } } +func TestModel_ScrollToBottom(t *testing.T) { + // Create content with 20 lines + lines := make([]string, 20) + for i := range lines { + lines[i] = "line" + } + content := strings.Join(lines, "\n") + m := NewModel(content) + m.height = 10 + + // Press G to go to bottom + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) + model := updated.(Model) + + // The maxOffset should account for the status bar + if model.offset != model.maxOffset() { + t.Fatalf("expected offset %d to equal maxOffset %d", model.offset, model.maxOffset()) + } + + // Verify the view includes the last line of content + view := model.View() + viewLines := strings.Split(view, "\n") + // Last visible content line (before the status line) should be "line" + if len(viewLines) < 2 { + t.Fatal("expected at least 2 view lines") + } + lastContentLine := viewLines[len(viewLines)-2] + if lastContentLine != "line" { + t.Fatalf("expected last content line to be 'line', got %q", lastContentLine) + } +} + func TestModel_Quit(t *testing.T) { m := NewModel("# Hello") @@ -47,3 +80,168 @@ func TestModel_Quit(t *testing.T) { t.Fatal("expected quit command") } } + +func TestModel_SearchMode(t *testing.T) { + m := NewModel("Line one\nLine two\nLine three") + m.height = 10 + m.width = 80 + + // Enter search mode + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model := updated.(Model) + if !model.searching { + t.Fatal("expected searching to be true") + } + + // Type query + for _, r := range "two" { + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + model = updated.(Model) + } + if model.searchInput != "two" { + t.Fatalf("expected search input 'two', got %q", model.searchInput) + } + + // Press enter to execute search + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(Model) + if model.searching { + t.Fatal("searching should be false after enter") + } + if model.searchQuery != "two" { + t.Fatalf("expected search query 'two', got %q", model.searchQuery) + } + if len(model.matchLines) != 1 { + t.Fatalf("expected 1 match, got %d", len(model.matchLines)) + } +} + +func TestModel_SearchNavigate(t *testing.T) { + // Create content with "match" appearing on lines 0, 10, 20 + lines := make([]string, 30) + for i := range lines { + if i%10 == 0 { + lines[i] = "match line" + } else { + lines[i] = "other line" + } + } + content := strings.Join(lines, "\n") + m := NewModel(content) + m.height = 10 + m.width = 80 + + // Enter search, type, and execute + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model := Model{ + content: m.content, + lines: m.lines, + height: m.height, + width: m.width, + searching: true, + matchIndex: -1, + } + for _, r := range "match" { + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + model = updated.(Model) + } + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(Model) + + if len(model.matchLines) != 3 { + t.Fatalf("expected 3 matches, got %d", len(model.matchLines)) + } + if model.matchIndex != 0 { + t.Fatalf("expected match index 0, got %d", model.matchIndex) + } + + // Press 'n' to go to next match + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + model = updated.(Model) + if model.matchIndex != 1 { + t.Fatalf("expected match index 1 after 'n', got %d", model.matchIndex) + } + + // Press 'N' to go to previous match + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'N'}}) + model = updated.(Model) + if model.matchIndex != 0 { + t.Fatalf("expected match index 0 after 'N', got %d", model.matchIndex) + } + + // Press 'N' again — should wrap to last match + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'N'}}) + model = updated.(Model) + if model.matchIndex != 2 { + t.Fatalf("expected match index 2 after wrap, got %d", model.matchIndex) + } +} + +func TestModel_SearchEscapeClear(t *testing.T) { + m := NewModel("Line one\nLine two\nLine three") + m.height = 10 + m.width = 80 + + // Set up a search result + m.searchQuery = "two" + m.matchLines = []int{1} + m.matchIndex = 0 + + // Press Escape to clear search + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEscape}) + model := updated.(Model) + if model.searchQuery != "" { + t.Fatal("expected search query to be cleared") + } + if model.matchLines != nil { + t.Fatal("expected match lines to be nil") + } +} + +func TestModel_SearchCancelInput(t *testing.T) { + m := NewModel("Line one\nLine two") + m.height = 10 + m.width = 80 + + // Enter search mode + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model := updated.(Model) + + // Type something + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + model = updated.(Model) + + // Cancel with Escape + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEscape}) + model = updated.(Model) + if model.searching { + t.Fatal("expected searching to be false after escape") + } + if model.searchInput != "" { + t.Fatal("expected search input to be cleared") + } +} + +func TestModel_SearchViewStatus(t *testing.T) { + m := NewModel("Line one\nLine two\nLine three") + m.height = 10 + m.width = 80 + + // During search, View should show search prompt + m.searching = true + m.searchInput = "test" + view := m.View() + if !strings.Contains(view, "/test") { + t.Fatal("expected search prompt in view during search") + } + + // With active results, View should show match count + m.searching = false + m.searchQuery = "Line" + m.matchLines = []int{0, 1, 2} + m.matchIndex = 1 + view = m.View() + if !strings.Contains(view, "[2/3]") { + t.Fatal("expected match count in view") + } +}