Skip to content

Commit 19aa357

Browse files
authored
Merge pull request #5 from tirthpatell/fix-and-feat/viewer-editor-linter
Fix viewer/editor/linter bugs and add new features
2 parents d0e3914 + 859954a commit 19aa357

File tree

12 files changed

+970
-58
lines changed

12 files changed

+970
-58
lines changed

cmd/lint.go

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,35 +12,58 @@ import (
1212
var lintCmd = &cobra.Command{
1313
Use: "lint [files...]",
1414
Short: "Lint markdown files for structural issues",
15-
Long: "Checks markdown files for heading hierarchy, duplicate headings, empty links, and other structural issues.",
16-
Args: cobra.MinimumNArgs(1),
15+
Long: "Checks markdown files for heading hierarchy, duplicate headings, empty links, and other structural issues. Reads from stdin if no files are provided.",
16+
Args: cobra.ArbitraryArgs,
1717
RunE: func(cmd *cobra.Command, args []string) error {
1818
errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
1919
warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("11"))
2020
fileStyle := lipgloss.NewStyle().Bold(true)
2121

2222
totalIssues := 0
2323

24-
for _, path := range args {
25-
issues, err := linter.LintFile(path)
24+
if len(args) == 0 || (len(args) == 1 && args[0] == "-") {
25+
stat, _ := os.Stdin.Stat()
26+
if (stat.Mode() & os.ModeCharDevice) != 0 {
27+
return fmt.Errorf("no files provided and nothing on stdin")
28+
}
29+
issues, err := linter.LintReader(os.Stdin)
2630
if err != nil {
27-
fmt.Fprintf(os.Stderr, "error reading %s: %v\n", path, err)
28-
continue
31+
return fmt.Errorf("error reading stdin: %w", err)
2932
}
30-
if len(issues) == 0 {
31-
continue
33+
if len(issues) > 0 {
34+
fmt.Println(fileStyle.Render("<stdin>"))
35+
for _, issue := range issues {
36+
prefix := warnStyle.Render("warning")
37+
if issue.Severity == linter.SeverityError {
38+
prefix = errorStyle.Render("error")
39+
}
40+
fmt.Printf(" line %d: %s %s (%s)\n", issue.Line, prefix, issue.Message, issue.Rule)
41+
}
42+
fmt.Println()
43+
totalIssues += len(issues)
3244
}
45+
} else {
46+
for _, path := range args {
47+
issues, err := linter.LintFile(path)
48+
if err != nil {
49+
fmt.Fprintf(os.Stderr, "error reading %s: %v\n", path, err)
50+
continue
51+
}
52+
if len(issues) == 0 {
53+
continue
54+
}
3355

34-
fmt.Println(fileStyle.Render(path))
35-
for _, issue := range issues {
36-
prefix := warnStyle.Render("warning")
37-
if issue.Severity == linter.SeverityError {
38-
prefix = errorStyle.Render("error")
56+
fmt.Println(fileStyle.Render(path))
57+
for _, issue := range issues {
58+
prefix := warnStyle.Render("warning")
59+
if issue.Severity == linter.SeverityError {
60+
prefix = errorStyle.Render("error")
61+
}
62+
fmt.Printf(" line %d: %s %s (%s)\n", issue.Line, prefix, issue.Message, issue.Rule)
3963
}
40-
fmt.Printf(" line %d: %s %s (%s)\n", issue.Line, prefix, issue.Message, issue.Rule)
64+
fmt.Println()
65+
totalIssues += len(issues)
4166
}
42-
fmt.Println()
43-
totalIssues += len(issues)
4467
}
4568

4669
if totalIssues > 0 {

cmd/stats.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/charmbracelet/lipgloss"
8+
"github.com/spf13/cobra"
9+
"github.com/tirthpatell/mdr/internal/stats"
10+
)
11+
12+
var statsCmd = &cobra.Command{
13+
Use: "stats [file]",
14+
Short: "Show word count and document statistics for a markdown file",
15+
Long: "Displays word count, line count, heading count, link count, and estimated reading time. Reads from stdin if no file is provided.",
16+
Args: cobra.MaximumNArgs(1),
17+
RunE: func(cmd *cobra.Command, args []string) error {
18+
var s stats.Stats
19+
var name string
20+
var err error
21+
22+
if len(args) == 0 || args[0] == "-" {
23+
stat, _ := os.Stdin.Stat()
24+
if (stat.Mode() & os.ModeCharDevice) != 0 {
25+
return fmt.Errorf("no file provided and nothing on stdin")
26+
}
27+
s, err = stats.FromReader(os.Stdin)
28+
name = "<stdin>"
29+
} else {
30+
s, err = stats.FromFile(args[0])
31+
name = args[0]
32+
}
33+
if err != nil {
34+
return err
35+
}
36+
37+
titleStyle := lipgloss.NewStyle().Bold(true)
38+
fmt.Println(titleStyle.Render(name))
39+
fmt.Println(s.String())
40+
return nil
41+
},
42+
}
43+
44+
func init() {
45+
rootCmd.AddCommand(statsCmd)
46+
}

internal/editor/editor.go

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,19 @@ import (
1212
)
1313

1414
type Model struct {
15-
buffer *Buffer
16-
filePath string
17-
fileMode fs.FileMode
18-
cursorRow int
19-
cursorCol int
20-
offsetRow int
21-
width int
22-
height int
23-
editWidth int
24-
showHelp bool
25-
err error
15+
buffer *Buffer
16+
filePath string
17+
fileMode fs.FileMode
18+
cursorRow int
19+
cursorCol int
20+
offsetRow int
21+
width int
22+
height int
23+
editWidth int
24+
showHelp bool
25+
confirmQuit bool
26+
err error
27+
saveMsg string
2628
}
2729

2830
func NewModel(content string, filePath string) Model {
@@ -60,12 +62,41 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6062
return m, nil
6163

6264
case tea.KeyMsg:
65+
// Handle quit confirmation dialog
66+
if m.confirmQuit {
67+
switch msg.String() {
68+
case "y", "Y":
69+
return m, tea.Quit
70+
case "n", "N", "esc":
71+
m.confirmQuit = false
72+
return m, nil
73+
default:
74+
return m, nil
75+
}
76+
}
77+
78+
// Clear transient messages on any non-save keypress
79+
if msg.Type != tea.KeyCtrlS {
80+
m.err = nil
81+
m.saveMsg = ""
82+
}
83+
6384
switch msg.Type {
6485
case tea.KeyCtrlC:
86+
if m.buffer.Modified() {
87+
m.confirmQuit = true
88+
return m, nil
89+
}
6590
return m, tea.Quit
6691

6792
case tea.KeyCtrlS:
6893
m.err = m.save()
94+
if m.err == nil {
95+
m.buffer.ResetModified()
96+
m.saveMsg = "Saved!"
97+
} else {
98+
m.saveMsg = ""
99+
}
69100
return m, nil
70101

71102
case tea.KeyCtrlH:
@@ -205,8 +236,16 @@ var (
205236
BorderLeft(true).
206237
BorderStyle(lipgloss.NormalBorder()).
207238
BorderForeground(lipgloss.Color("241"))
208-
helpStyle = lipgloss.NewStyle().
239+
editorHelpStyle = lipgloss.NewStyle().
209240
Foreground(lipgloss.Color("241"))
241+
errStyle = lipgloss.NewStyle().
242+
Foreground(lipgloss.Color("9")).
243+
Bold(true)
244+
savedStyle = lipgloss.NewStyle().
245+
Foreground(lipgloss.Color("10"))
246+
confirmStyle = lipgloss.NewStyle().
247+
Foreground(lipgloss.Color("11")).
248+
Bold(true)
210249
)
211250

212251
func (m Model) View() string {
@@ -220,8 +259,13 @@ func (m Model) View() string {
220259
editW = m.width / 2
221260
}
222261

262+
if m.confirmQuit {
263+
prompt := confirmStyle.Render("Unsaved changes. Quit without saving? (y/n)")
264+
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, prompt)
265+
}
266+
223267
if m.showHelp {
224-
help := helpStyle.Render(strings.Join([]string{
268+
help := editorHelpStyle.Render(strings.Join([]string{
225269
"Editor Help",
226270
"",
227271
" Arrow keys Move cursor",
@@ -259,7 +303,10 @@ func (m Model) View() string {
259303
editorPane := strings.Join(editorLines, "\n")
260304

261305
previewW := m.width - editW - 1
262-
rendered, _ := markdown.Render(m.buffer.String())
306+
rendered, renderErr := markdown.Render(m.buffer.String())
307+
if renderErr != nil {
308+
rendered = errStyle.Render("Preview error: " + renderErr.Error())
309+
}
263310
previewLines := strings.Split(rendered, "\n")
264311
if len(previewLines) > editHeight {
265312
previewLines = previewLines[:editHeight]
@@ -278,8 +325,15 @@ func (m Model) View() string {
278325
if m.buffer.Modified() {
279326
modIndicator = " [+]"
280327
}
281-
status := statusStyle.Render(fmt.Sprintf(" %s%s Ln %d, Col %d Ctrl+S: save Ctrl+H: help Ctrl+C: quit",
282-
m.filePath, modIndicator, m.cursorRow+1, m.cursorCol+1))
328+
statusText := fmt.Sprintf(" %s%s Ln %d, Col %d Ctrl+S: save Ctrl+H: help Ctrl+C: quit",
329+
m.filePath, modIndicator, m.cursorRow+1, m.cursorCol+1)
330+
if m.err != nil {
331+
statusText += " " + errStyle.Render("Error: "+m.err.Error())
332+
}
333+
if m.saveMsg != "" {
334+
statusText += " " + savedStyle.Render(m.saveMsg)
335+
}
336+
status := statusStyle.Render(statusText)
283337

284338
return body + "\n" + status
285339
}

internal/editor/editor_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package editor
22

33
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
47
"testing"
58

69
tea "github.com/charmbracelet/bubbletea"
@@ -70,3 +73,119 @@ func TestEditorModel_EnterKey(t *testing.T) {
7073
t.Fatalf("expected cursor at col 0, got %d", model.cursorCol)
7174
}
7275
}
76+
77+
func TestEditorModel_SaveResetsModified(t *testing.T) {
78+
tmp := t.TempDir()
79+
path := filepath.Join(tmp, "test.md")
80+
os.WriteFile(path, []byte("hello"), 0644)
81+
82+
m, err := NewModelFromFile(path)
83+
if err != nil {
84+
t.Fatal(err)
85+
}
86+
m.width = 80
87+
m.height = 24
88+
89+
// Type a character to set modified
90+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}})
91+
model := updated.(Model)
92+
if !model.buffer.Modified() {
93+
t.Fatal("buffer should be modified after typing")
94+
}
95+
96+
// Save
97+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
98+
model = updated.(Model)
99+
if model.buffer.Modified() {
100+
t.Fatal("buffer should not be modified after save")
101+
}
102+
if model.saveMsg != "Saved!" {
103+
t.Fatalf("expected save message 'Saved!', got %q", model.saveMsg)
104+
}
105+
}
106+
107+
func TestEditorModel_SaveError_DisplayedInView(t *testing.T) {
108+
m := NewModel("hello", "/nonexistent/path/file.md")
109+
m.width = 80
110+
m.height = 24
111+
m.editWidth = 40
112+
113+
// Trigger save
114+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
115+
model := updated.(Model)
116+
if model.err == nil {
117+
t.Fatal("expected save error for invalid path")
118+
}
119+
view := model.View()
120+
if !strings.Contains(view, "Error:") {
121+
t.Fatal("expected error message in view output")
122+
}
123+
}
124+
125+
func TestEditorModel_QuitConfirmation_Modified(t *testing.T) {
126+
m := NewModel("hello", "/tmp/test.md")
127+
m.width = 80
128+
m.height = 24
129+
130+
// Type to set modified
131+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}})
132+
model := updated.(Model)
133+
134+
// Press Ctrl+C — should NOT quit, should show confirmation
135+
updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
136+
model = updated.(Model)
137+
if cmd != nil {
138+
t.Fatal("should not quit immediately with unsaved changes")
139+
}
140+
if !model.confirmQuit {
141+
t.Fatal("expected confirmQuit to be true")
142+
}
143+
144+
// View should show the confirmation prompt
145+
view := model.View()
146+
if !strings.Contains(view, "Unsaved changes") {
147+
t.Fatal("expected confirmation prompt in view")
148+
}
149+
150+
// Press 'n' to cancel
151+
updated, cmd = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
152+
model = updated.(Model)
153+
if cmd != nil {
154+
t.Fatal("pressing 'n' should not quit")
155+
}
156+
if model.confirmQuit {
157+
t.Fatal("confirmQuit should be false after 'n'")
158+
}
159+
}
160+
161+
func TestEditorModel_QuitConfirmation_Accept(t *testing.T) {
162+
m := NewModel("hello", "/tmp/test.md")
163+
m.width = 80
164+
m.height = 24
165+
166+
// Type to set modified
167+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}})
168+
model := updated.(Model)
169+
170+
// Press Ctrl+C
171+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
172+
model = updated.(Model)
173+
174+
// Press 'y' to confirm quit
175+
_, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}})
176+
if cmd == nil {
177+
t.Fatal("pressing 'y' should quit")
178+
}
179+
}
180+
181+
func TestEditorModel_QuitUnmodified(t *testing.T) {
182+
m := NewModel("hello", "/tmp/test.md")
183+
m.width = 80
184+
m.height = 24
185+
186+
// Press Ctrl+C on unmodified buffer — should quit immediately
187+
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
188+
if cmd == nil {
189+
t.Fatal("should quit immediately when no unsaved changes")
190+
}
191+
}

0 commit comments

Comments
 (0)