Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 39 additions & 16 deletions cmd/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,58 @@ 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"))
fileStyle := lipgloss.NewStyle().Bold(true)

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("<stdin>"))
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 {
Expand Down
46 changes: 46 additions & 0 deletions cmd/stats.go
Original file line number Diff line number Diff line change
@@ -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 = "<stdin>"
} 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)
}
86 changes: 70 additions & 16 deletions internal/editor/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 {
Expand All @@ -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",
Expand Down Expand Up @@ -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]
Expand All @@ -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
}
Expand Down
119 changes: 119 additions & 0 deletions internal/editor/editor_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package editor

import (
"os"
"path/filepath"
"strings"
"testing"

tea "github.com/charmbracelet/bubbletea"
Expand Down Expand Up @@ -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")
}
}
Loading