27b4151881c266383acd5ef4644dbbed20e02e03
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/AGENTS.md b/AGENTS.md
2new file mode 100644
3index 0000000000000000000000000000000000000000..e43cb0d1e3157e0b239453e2920def38abc9ec11
4--- /dev/null
5+++ b/AGENTS.md
6@@ -0,0 +1,27 @@
7+# RPN Calculator
8+
9+## Structure
10+
11+- `main.go` composes the calculator and terminal UI.
12+- `rpn/lexer.go` tokenizes calculator input.
13+- `rpn/rpn.go` is terminal-free: it evaluates tokens against the stack and exposes calculator events and stack snapshots.
14+- `rpn/stack.go`, `rpn/token.go`, and `rpn/parser.go` define the stack, token types, and input cursor.
15+- `rpn/rpn_test.go` covers calculator evaluation, events, and stack snapshots.
16+- `tui/application.go` renders the tview interface and owns terminal input, layout, status messages, help, and exit behavior.
17+
18+## Local Development
19+
20+Run the following from the repository root:
21+
22+```sh
23+go test ./...
24+go vet ./...
25+golangci-lint run
26+```
27+
28+## Gotchas
29+
30+- Do not run `go run .` or the compiled REPL from an agent loop. It waits indefinitely for terminal input. Use the compile, test, vet, and lint commands above for automated verification; test the REPL manually in a real terminal when necessary.
31+- The terminal UI uses `github.com/rivo/tview`. Do not combine it with readline or direct ANSI cursor handling: tview owns raw terminal input and rendering.
32+- Keep `rpn` independent of tview and terminal output. Exchange calculator events and copied stack snapshots through its public API instead.
33+- `golangci-lint` is the project lint command. Error strings must begin with lowercase letters to satisfy its staticcheck configuration.
34diff --git a/go.mod b/go.mod
35index 8b46595a7d1f65efbc4aeecb3842f251b19ef4b9..fa7e2600c9265f701286439ce7f9bc4a071064de 100644
36--- a/go.mod
37+++ b/go.mod
38@@ -1,3 +1,18 @@
39 module gitea.theedgeofrage.com/theedgeofrage/rpn
40
41 go 1.21
42+
43+require (
44+ github.com/gdamore/tcell/v2 v2.6.1-0.20231203215052-2917c3801e73
45+ github.com/rivo/tview v0.0.0-20231206124440-5f078138442e
46+)
47+
48+require (
49+ github.com/gdamore/encoding v1.0.0 // indirect
50+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
51+ github.com/mattn/go-runewidth v0.0.14 // indirect
52+ github.com/rivo/uniseg v0.4.3 // indirect
53+ golang.org/x/sys v0.11.0 // indirect
54+ golang.org/x/term v0.9.0 // indirect
55+ golang.org/x/text v0.12.0 // indirect
56+)
57diff --git a/go.sum b/go.sum
58new file mode 100644
59index 0000000000000000000000000000000000000000..fa81d26dbcf858046149f92e5ba66ea43b6c7194
60--- /dev/null
61+++ b/go.sum
62@@ -0,0 +1,50 @@
63+github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
64+github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
65+github.com/gdamore/tcell/v2 v2.6.1-0.20231203215052-2917c3801e73 h1:SeDV6ZUSVlTAUUPdMzPXgMyj96z+whQJRRUff8dIeic=
66+github.com/gdamore/tcell/v2 v2.6.1-0.20231203215052-2917c3801e73/go.mod h1:pwzJMyH4Hd0AZMJkWQ+/g01dDvYWEvmJuaiRU71Xl8k=
67+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
68+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
69+github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
70+github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
71+github.com/rivo/tview v0.0.0-20231206124440-5f078138442e h1:mPy47VW9tkqImnSPgcjnEHJuG3XHDBtXj2hDb1qBrRs=
72+github.com/rivo/tview v0.0.0-20231206124440-5f078138442e/go.mod h1:c0SPlNPXkM+/Zgjn/0vD3W0Ds1yxstN7lpquqLDpWCg=
73+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
74+github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
75+github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
76+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
77+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
78+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
79+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
80+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
81+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
82+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
83+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
84+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
85+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
86+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
87+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
88+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
89+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
90+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
91+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
92+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
93+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
94+golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
95+golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
96+golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
97+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
98+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
99+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
100+golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28=
101+golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
102+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
103+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
104+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
105+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
106+golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
107+golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
108+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
109+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
110+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
111+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
112+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
113diff --git a/main.go b/main.go
114index eca8fdcf42fd7e407a29d86f9a8dd6adad01f30e..2072d744a4ecbb300f1c42482c59ce9ce79538d0 100644
115--- a/main.go
116+++ b/main.go
117@@ -1,38 +1,15 @@
118 package main
119
120 import (
121- "bufio"
122- "errors"
123 "fmt"
124- "io"
125 "os"
126
127 "gitea.theedgeofrage.com/theedgeofrage/rpn/rpn"
128+ "gitea.theedgeofrage.com/theedgeofrage/rpn/tui"
129 )
130
131 func main() {
132- rpn := rpn.NewRPN()
133- reader := bufio.NewReader(os.Stdin)
134- for {
135- rpn.PrintStack()
136- // input := ""
137- // _, err := fmt.Scanln(&input)
138-
139- fmt.Print("> ")
140- input, err := reader.ReadString('\n')
141- if err != nil {
142- if errors.Is(err, io.EOF) {
143- fmt.Println()
144- return
145- }
146- fmt.Println(err)
147- continue
148- }
149-
150- err = rpn.Eval(input)
151- if err != nil {
152- fmt.Println(err)
153- continue
154- }
155+ if err := tui.New(rpn.NewRPN()).Run(); err != nil {
156+ fmt.Fprintln(os.Stderr, err)
157 }
158 }
159diff --git a/rpn/lexer.go b/rpn/lexer.go
160index 06f2abac88ac372850d4dc81cdab15faaed7c319..8b0fc7ecad1db41108436ca5932b8268783e03e7 100644
161--- a/rpn/lexer.go
162+++ b/rpn/lexer.go
163@@ -69,6 +69,9 @@ func (l *Lexer) Parse(input string) ([]*Token, error) {
164 var err error
165 var token *Token
166 l.input = NewInput(input)
167+ if l.input == nil {
168+ return nil, nil
169+ }
170 tokens := []*Token{}
171 for {
172 char := l.input.NextChar()
173diff --git a/rpn/rpn.go b/rpn/rpn.go
174index 2cc7bf0207eb348ec0eb4d8c8431d7b3f2f4c5d6..91736dbea84096134d8af0620d19dc9a632db310 100644
175--- a/rpn/rpn.go
176+++ b/rpn/rpn.go
177@@ -10,6 +10,29 @@ type RPN struct {
178 lexer *Lexer
179 }
180
181+type Event int
182+
183+const (
184+ EventNone Event = iota
185+ EventHelp
186+ EventExit
187+)
188+
189+const HelpText = `RPN Calculator
190+==============
191+Commands:
192+ +, -, *, /, %, ^, sqrt
193+ pop, swap
194+ help
195+ exit (or Ctrl-D)
196+Examples:
197+ 1 2 + == 3
198+ 2 3 4 + *
199+ 2 3 4 + * 5 /
200+ 9 sqrt
201+ 2 3 swap -
202+ exit`
203+
204 func NewRPN() *RPN {
205 return &RPN{
206 stack: &Stack{},
207@@ -17,29 +40,13 @@ func NewRPN() *RPN {
208 }
209 }
210
211-func printHelp() {
212- println("RPN Calculator")
213- println("==============")
214- println("Commands:")
215- println(" +, -, *, /, %, ^, sqrt")
216- println(" pop, swap")
217- println(" help")
218- println(" exit (or Ctrl-D)")
219- println("Examples:")
220- println(" 1 2 + == 3")
221- println(" 2 3 4 + *")
222- println(" 2 3 4 + * 5 /")
223- println(" 9 sqrt")
224- println(" 2 3 swap -")
225- println(" exit")
226-}
227-
228-func (r *RPN) Eval(input string) error {
229+func (r *RPN) Eval(input string) (Event, error) {
230 tokens, err := r.lexer.Parse(input)
231 if err != nil {
232- return err
233+ return EventNone, err
234 }
235
236+ event := EventNone
237 for _, token := range tokens {
238 switch token.Type {
239 case number:
240@@ -47,7 +54,7 @@ func (r *RPN) Eval(input string) error {
241 case binaryOp:
242 a, b, err := r.stack.Pop2()
243 if err != nil {
244- return err
245+ return EventNone, err
246 }
247 switch token.Operator {
248 case plus:
249@@ -60,14 +67,14 @@ func (r *RPN) Eval(input string) error {
250 if a == 0 {
251 r.stack.Push(b)
252 r.stack.Push(a)
253- return fmt.Errorf("can't divide by zero")
254+ return EventNone, fmt.Errorf("can't divide by zero")
255 }
256 r.stack.Push(b / a)
257 case mod:
258 if a == 0 {
259 r.stack.Push(b)
260 r.stack.Push(a)
261- return fmt.Errorf("can't divide by zero")
262+ return EventNone, fmt.Errorf("can't divide by zero")
263 }
264 r.stack.Push(float64(int64(b) % int64(a)))
265 case power:
266@@ -76,7 +83,7 @@ func (r *RPN) Eval(input string) error {
267 case unaryOp:
268 a, err := r.stack.Pop()
269 if err != nil {
270- return err
271+ return EventNone, err
272 }
273 switch token.Operator {
274 case sqrt:
275@@ -85,27 +92,27 @@ func (r *RPN) Eval(input string) error {
276 case pop:
277diff --git a/rpn/rpn_test.go b/rpn/rpn_test.go
278new file mode 100644
279index 0000000000000000000000000000000000000000..097828ff0d31ab02af054f4d219e220a60244dfb
280--- /dev/null
281+++ b/rpn/rpn_test.go
282@@ -0,0 +1,60 @@
283+package rpn
284+
285+import (
286+ "slices"
287+ "testing"
288+)
289+
290+func TestEvalUpdatesStack(t *testing.T) {
291+ calculator := NewRPN()
292+
293+ event, err := calculator.Eval("2 3 +")
294+ if err != nil {
295+ t.Fatalf("Eval() error = %v", err)
296+ }
297+ if event != EventNone {
298+ t.Fatalf("Eval() event = %v, want %v", event, EventNone)
299+ }
300+
301+ if got, want := calculator.Values(), []float64{5}; !slices.Equal(got, want) {
302+ t.Errorf("Values() = %v, want %v", got, want)
303+ }
304+}
305+
306+func TestEvalReturnsEvents(t *testing.T) {
307+ tests := []struct {
308+ input string
309+ want Event
310+ }{
311+ {input: "", want: EventNone},
312+ {input: "help", want: EventHelp},
313+ {input: "exit", want: EventExit},
314+ }
315+
316+ for _, test := range tests {
317+ t.Run(test.input, func(t *testing.T) {
318+ event, err := NewRPN().Eval(test.input)
319+ if err != nil {
320+ t.Fatalf("Eval() error = %v", err)
321+ }
322+ if event != test.want {
323+ t.Errorf("Eval() event = %v, want %v", event, test.want)
324+ }
325+ })
326+ }
327+}
328+
329+func TestValuesReturnsCopy(t *testing.T) {
330+ calculator := NewRPN()
331+ _, err := calculator.Eval("5")
332+ if err != nil {
333+ t.Fatalf("Eval() error = %v", err)
334+ }
335+
336+ values := calculator.Values()
337+ values[0] = 10
338+
339+ if got := calculator.Values()[0]; got != 5 {
340+ t.Errorf("Values()[0] = %v, want 5", got)
341+ }
342+}
343diff --git a/rpn/stack.go b/rpn/stack.go
344index c937c14aaf6102229ae91a807d5ae80e69c5adc7..da42405b0011b5ba1868ae9d623c96a1065bf820 100644
345--- a/rpn/stack.go
346+++ b/rpn/stack.go
347@@ -2,7 +2,6 @@ package rpn
348
349 import (
350 "fmt"
351- "math"
352 )
353
354 var ErrNotEnoughValuesTmpl = "Need at least %d values on the stack to perform this operation"
355@@ -56,12 +55,6 @@ func (s *Stack) Clear() {
356 s.values = []float64{}
357 }
358
359-func (s *Stack) Print() {
360- for _, value := range s.values {
361- if value == math.Trunc(value) {
362- fmt.Printf("%d\n", int64(value))
363- } else {
364- fmt.Printf("%f\n", value)
365- }
366- }
367+func (s *Stack) Values() []float64 {
368+ return append([]float64(nil), s.values...)
369 }
370diff --git a/tui/application.go b/tui/application.go
371new file mode 100644
372index 0000000000000000000000000000000000000000..d12102582fb220b3ba8936ad40ba6b893a1f45be
373--- /dev/null
374+++ b/tui/application.go
375@@ -0,0 +1,158 @@
376+package tui
377+
378+import (
379+ "fmt"
380+ "math"
381+ "strings"
382+
383+ "gitea.theedgeofrage.com/theedgeofrage/rpn/rpn"
384+ "github.com/gdamore/tcell/v2"
385+ "github.com/rivo/tview"
386+)
387+
388+const calculatorPage = "calculator"
389+
390+const helpPage = "help"
391+
392+type Calculator interface {
393+ Eval(input string) (rpn.Event, error)
394+ Values() []float64
395+}
396+
397+type Application struct {
398+ application *tview.Application
399+ calculator Calculator
400+ pages *tview.Pages
401+ layout *tview.Flex
402+ stack *tview.TextView
403+ status *tview.TextView
404+ input *tview.InputField
405+}
406+
407+func New(calculator Calculator) *Application {
408+ stack := tview.NewTextView().SetWrap(false)
409+ stack.SetBorder(true).SetTitle("Stack")
410+
411+ app := &Application{
412+ application: tview.NewApplication(),
413+ calculator: calculator,
414+ stack: stack,
415+ status: tview.NewTextView().
416+ SetWrap(false),
417+ input: tview.NewInputField().
418+ SetLabel("> ").
419+ SetFieldBackgroundColor(tcell.ColorBlack).
420+ SetFieldTextColor(tcell.ColorWhite),
421+ }
422+
423+ app.input.SetDoneFunc(app.submit)
424+
425+ app.layout = tview.NewFlex().
426+ SetDirection(tview.FlexRow).
427+ AddItem(nil, 0, 1, false).
428+ AddItem(app.stack, 0, 1, false).
429+ AddItem(app.status, 0, 0, false).
430+ AddItem(app.input, 1, 0, true)
431+
432+ app.pages = tview.NewPages().AddPage(calculatorPage, app.layout, true, true)
433+ app.application.
434+ SetRoot(app.pages, true).
435+ SetFocus(app.input).
436+ SetInputCapture(app.captureInput).
437+ SetBeforeDrawFunc(app.resizeStack)
438+ app.renderStack()
439+
440+ return app
441+}
442+
443+func (a *Application) Run() error {
444+ return a.application.Run()
445+}
446+
447+func (a *Application) captureInput(event *tcell.EventKey) *tcell.EventKey {
448+ if event.Key() == tcell.KeyCtrlD {
449+ a.application.Stop()
450+ return nil
451+ }
452+ return event
453+}
454+
455+func (a *Application) submit(key tcell.Key) {
456+ if key != tcell.KeyEnter {
457+ return
458+ }
459+
460+ input := strings.TrimSpace(a.input.GetText())
461+ a.input.SetText("")
462+ if input == "" {
463+ return
464+ }
465+
466+ event, err := a.calculator.Eval(input)
467+ a.renderStack()
468+ if err != nil {
469+ a.status.SetText(err.Error())
470+ return
471+ }
472+
473+ a.status.SetText("")
474+ switch event {