From 04d4e93c1f57e05e1eec62517cc53b4769b10dd5 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Mon, 7 Apr 2025 01:13:55 -0700 Subject: [PATCH 01/37] pkg/calc: initial POC --- cmd/calc/calc.go | 19 ++++ pkg/calc/calculator.go | 52 ++++++++++ pkg/calc/command.go | 31 ++++++ pkg/calc/evaluate.go | 26 +++++ pkg/calc/lexer/lex_expression.go | 47 +++++++++ pkg/calc/lexer/lex_string.go | 43 ++++++++ pkg/calc/lexer/lex_text.go | 32 ++++++ pkg/calc/lexer/lexer.go | 170 +++++++++++++++++++++++++++++++ pkg/calc/lexer/predicates.go | 30 ++++++ pkg/calc/lexer/state.go | 3 + pkg/calc/tokens/position.go | 13 +++ pkg/calc/tokens/tokens.go | 77 ++++++++++++++ pkg/num/const.go | 3 + pkg/num/num.go | 36 +++++++ pkg/num/rra.go | 3 + 15 files changed, 585 insertions(+) create mode 100644 cmd/calc/calc.go create mode 100644 pkg/calc/calculator.go create mode 100644 pkg/calc/command.go create mode 100644 pkg/calc/evaluate.go create mode 100644 pkg/calc/lexer/lex_expression.go create mode 100644 pkg/calc/lexer/lex_string.go create mode 100644 pkg/calc/lexer/lex_text.go create mode 100644 pkg/calc/lexer/lexer.go create mode 100644 pkg/calc/lexer/predicates.go create mode 100644 pkg/calc/lexer/state.go create mode 100644 pkg/calc/tokens/position.go create mode 100644 pkg/calc/tokens/tokens.go create mode 100644 pkg/num/const.go create mode 100644 pkg/num/num.go create mode 100644 pkg/num/rra.go diff --git a/cmd/calc/calc.go b/cmd/calc/calc.go new file mode 100644 index 0000000..f422694 --- /dev/null +++ b/cmd/calc/calc.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + "os" + + "github.com/ripta/rt/pkg/calc" + "github.com/ripta/rt/pkg/version" +) + +func main() { + cmd := calc.NewCommand() + cmd.AddCommand(version.NewCommand()) + + if err := cmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %+v\n", err) + os.Exit(1) + } +} diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go new file mode 100644 index 0000000..d16e129 --- /dev/null +++ b/pkg/calc/calculator.go @@ -0,0 +1,52 @@ +package calc + +import ( + "fmt" + "os" + + "github.com/elk-language/go-prompt" + + "github.com/ripta/rt/pkg/num" +) + +type Calculator struct{} + +func (c *Calculator) Evaluate(expr string) (*num.Num, error) { + return Evaluate(expr) +} + +func (c *Calculator) Execute(expr string) { + defer fmt.Println() + + res, err := c.Evaluate(expr) + if err != nil { + c.DisplayError(err) + return + } + + c.DisplayResult(res) +} + +func (c *Calculator) DisplayError(err error) { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) +} + +func (c *Calculator) DisplayResult(res *num.Num) { + fmt.Printf("%s\n", res) +} + +func (c *Calculator) REPL() { + p := prompt.New( + c.Execute, + prompt.WithPrefix("calc> "), + // prompt.WithInitialText("ident = 2"), + prompt.WithExitChecker(func(in string, breakline bool) bool { + return breakline && (in == "exit" || in == "quit") + }), + ) + + fmt.Println("calc: ^D to exit") + p.Run() + + fmt.Println("calc: goodbye") +} diff --git a/pkg/calc/command.go b/pkg/calc/command.go new file mode 100644 index 0000000..b3d182a --- /dev/null +++ b/pkg/calc/command.go @@ -0,0 +1,31 @@ +package calc + +import "github.com/spf13/cobra" + +func NewCommand() *cobra.Command { + c := &Calculator{} + cmd := &cobra.Command{ + Use: "calc", + Short: "Calculate expressions", + Long: "Calculate expressions", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + c.REPL() + return nil + } + + for _, arg := range args { + res, err := c.Evaluate(arg) + if err != nil { + return err + } + + c.DisplayResult(res) + } + + return nil + }, + } + + return cmd +} diff --git a/pkg/calc/evaluate.go b/pkg/calc/evaluate.go new file mode 100644 index 0000000..741fb88 --- /dev/null +++ b/pkg/calc/evaluate.go @@ -0,0 +1,26 @@ +package calc + +import ( + "fmt" + "strings" + + "github.com/ripta/rt/pkg/calc/lexer" + "github.com/ripta/rt/pkg/num" +) + +func Evaluate(expr string) (*num.Num, error) { + return evaluate(strings.TrimSpace(expr)) +} + +func evaluate(expr string) (*num.Num, error) { + l := lexer.New("(eval)", expr) + for tok := range l.Tokens() { + fmt.Printf("%+v\n", tok) + } + + if l.Err() != nil { + return nil, l.Err() + } + + return num.Zero(), nil +} diff --git a/pkg/calc/lexer/lex_expression.go b/pkg/calc/lexer/lex_expression.go new file mode 100644 index 0000000..727521a --- /dev/null +++ b/pkg/calc/lexer/lex_expression.go @@ -0,0 +1,47 @@ +package lexer + +import ( + "unicode" + + "github.com/ripta/rt/pkg/calc/tokens" +) + +func lexExpression(l *L) lexingState { + switch r := l.Next(); { + + case r == EOF: + l.eof = true + return nil + + case unicode.IsSpace(r): + l.AcceptWhile(unicode.IsSpace) + l.Emit(tokens.WHITESPACE) + return lexExpression + + case r == '"': + return lexQuotedString + + case r == '`': + return lexRawString + + case r == '=': + l.Emit(tokens.ASSIGN) + return lexExpression + + case unicode.IsDigit(r): + l.Rewind() + return lexNumber + + case r == '-': + l.Emit(tokens.OP_MINUS) + return lexExpression + + case IsAlnum(r): + l.Rewind() + return lexIdent + + default: + l.Errorf("unexpected token %s", string(r)) + return nil + } +} diff --git a/pkg/calc/lexer/lex_string.go b/pkg/calc/lexer/lex_string.go new file mode 100644 index 0000000..6fa05e0 --- /dev/null +++ b/pkg/calc/lexer/lex_string.go @@ -0,0 +1,43 @@ +package lexer + +import "github.com/ripta/rt/pkg/calc/tokens" + +func lexQuotedString(l *L) lexingState { + for done := false; !done; { + switch l.Next() { + case '\\': + if r := l.Next(); r != EOF && r != '\n' { + break + } + fallthrough + + case EOF, '\n': + l.Errorf("unterminated quoted string") + return nil + + case '"': + done = true + break + } + } + + l.Emit(tokens.LIT_STRING) + return lexExpression +} + +func lexRawString(l *L) lexingState { + for done := false; !done; { + switch l.Next() { + case EOF, '\n': + l.Errorf("unterminated raw string") + return nil + + case '`': + done = true + break + } + } + + l.Emit(tokens.LIT_STRING) + return lexExpression +} diff --git a/pkg/calc/lexer/lex_text.go b/pkg/calc/lexer/lex_text.go new file mode 100644 index 0000000..9f101d4 --- /dev/null +++ b/pkg/calc/lexer/lex_text.go @@ -0,0 +1,32 @@ +package lexer + +import ( + "strings" + + "github.com/ripta/rt/pkg/calc/tokens" +) + +func lexIdent(l *L) lexingState { + l.AcceptWhile(IsAlnum) + l.Emit(tokens.IDENT) + return lexExpression +} + +func lexNumber(l *L) lexingState { + if !l.AcceptWhile(IsNumeric) { + l.Errorf("invalid number: %s", l.Current()) + return nil + } + + num := l.Current() + if dec := strings.Count(num, "."); dec > 1 { + l.Errorf("too many decimal points (%d) in number; expected 0 or 1", dec) + return nil + } else if dec == 1 { + l.Emit(tokens.LIT_FLOAT) + } else { + l.Emit(tokens.LIT_INT) + } + + return lexExpression +} diff --git a/pkg/calc/lexer/lexer.go b/pkg/calc/lexer/lexer.go new file mode 100644 index 0000000..35d05c1 --- /dev/null +++ b/pkg/calc/lexer/lexer.go @@ -0,0 +1,170 @@ +package lexer + +import ( + "fmt" + "strings" + "unicode/utf8" + + "github.com/ripta/rt/pkg/calc/tokens" +) + +const EOF rune = -1 + +type L struct { + // name is the name of the source, used in error reporting. + name string + // src is the source string to be lexed. + src string + + // start is the start position of the current token + start int + // pos is the current position of the current token + pos int + // eof is true if the end of the source has been reached + eof bool + // line is the current line number + line int + + // err is the error encountered during lexing + err error + // tokens is the channel of tokens emitted by the lexer + tokens chan tokens.Token +} + +func New(name, src string) *L { + l := &L{ + name: name, + src: src, + line: 1, + tokens: make(chan tokens.Token, 100), + } + + go l.run() + return l +} + +// AcceptOnce accepts a single rune if the predicate is true. Returns true when +// a rune is accepted. Otherwise, the state is rewound and false is returned. +func (l *L) AcceptOnce(pred func(rune) bool) bool { + if pred(l.Next()) { + return true + } + + l.Rewind() + return false +} + +// AcceptWhile accepts runes while the predicate is true. Returns true when at +// least one rune is accepted. Otherwise, the state is rewound and returns false. +func (l *L) AcceptWhile(pred func(rune) bool) bool { + count := 0 + for pred(l.Next()) { + count++ + } + + l.Rewind() + return count > 0 +} + +// Current returns the current token. +func (l *L) Current() string { + return l.src[l.start:l.pos] +} + +func (l *L) Emit(t tokens.TokenType) { + if l.start == l.pos { + l.Errorf("trying to emit empty %s token at %d:%d", t, l.line, l.start) + return + } + + l.tokens <- tokens.Token{ + Type: t, + Value: l.src[l.start:l.pos], + Pos: tokens.Position{ + File: l.name, + Line: l.line, + Column: l.start + 1, + }, + } + l.start = l.pos +} + +func (l *L) Errorf(format string, args ...any) { + err := fmt.Errorf(format, args...) + pos := tokens.Position{ + File: l.name, + Line: l.line, + Column: l.start + 1, + } + + l.err = fmt.Errorf("%s: %w", pos, err) + l.tokens <- tokens.Token{ + Type: tokens.ILLEGAL, + Value: l.src[l.start:l.pos], + Err: err, + Pos: pos, + } + + l.eof = true +} + +func (l *L) Err() error { + return l.err +} + +// Next returns the next rune from the source. It advances the position. +func (l *L) Next() rune { + if l.err != nil { + return EOF + } + if l.pos >= len(l.src) { + l.eof = true + return EOF + } + + // fmt.Printf("NEXT: start=%d, pos=%d, src=%q\n", l.start, l.pos, l.src[l.start:l.pos]) + rv, rl := utf8.DecodeRuneInString(l.src[l.pos:]) + l.pos += rl + if rv == '\n' { + l.line++ + } + return rv +} + +// Peek returns the next rune without advancing the position. +func (l *L) Peek() rune { + defer l.Rewind() + return l.Next() +} + +// Rewind unreads the last rune read from the source. +func (l *L) Rewind() { + // no rewinding past the start + if l.eof || l.pos == 0 { + return + } + + rv, rl := utf8.DecodeLastRuneInString(l.src[:l.pos]) + l.pos -= rl + if rv == '\n' { + l.line-- + } +} + +func (l *L) run() { + for st := lexExpression; st != nil; { + st = st(l) + } + close(l.tokens) +} + +func (l *L) Skip() { + l.line += strings.Count(l.src[l.start:l.pos], "\n") + l.start = l.pos +} + +// Tokens returns a channel of tokens. The channel is closed when the lexer +// reaches the end of the source. +func (l *L) Tokens() <-chan tokens.Token { + return l.tokens +} diff --git a/pkg/calc/lexer/predicates.go b/pkg/calc/lexer/predicates.go new file mode 100644 index 0000000..9aaf47a --- /dev/null +++ b/pkg/calc/lexer/predicates.go @@ -0,0 +1,30 @@ +package lexer + +import ( + "strings" + "unicode" +) + +func IsAlnum(r rune) bool { + if unicode.IsLetter(r) { + return true + } + if unicode.IsDigit(r) { + return true + } + return false +} + +func IsNumeric(r rune) bool { + if r >= unicode.MaxLatin1 { + return false + } + + return (r >= '0' && r <= '9') || r == '.' || r == '_' +} + +func StringPredicate(valid string) func(rune) bool { + return func(r rune) bool { + return strings.ContainsRune(valid, r) + } +} diff --git a/pkg/calc/lexer/state.go b/pkg/calc/lexer/state.go new file mode 100644 index 0000000..e8561ba --- /dev/null +++ b/pkg/calc/lexer/state.go @@ -0,0 +1,3 @@ +package lexer + +type lexingState func(*L) lexingState diff --git a/pkg/calc/tokens/position.go b/pkg/calc/tokens/position.go new file mode 100644 index 0000000..7fcf866 --- /dev/null +++ b/pkg/calc/tokens/position.go @@ -0,0 +1,13 @@ +package tokens + +import "fmt" + +type Position struct { + File string + Line int + Column int +} + +func (p Position) String() string { + return fmt.Sprintf("%s:%d:%d", p.File, p.Line, p.Column) +} diff --git a/pkg/calc/tokens/tokens.go b/pkg/calc/tokens/tokens.go new file mode 100644 index 0000000..135af62 --- /dev/null +++ b/pkg/calc/tokens/tokens.go @@ -0,0 +1,77 @@ +package tokens + +import "fmt" + +type Token struct { + Type TokenType `json:"type"` + Value string `json:"value"` + Pos Position `json:"pos"` + Err error `json:"error"` +} + +func (t Token) String() string { + switch t.Type { + case EOF: + return "" + case ILLEGAL: + return fmt.Sprintf("", t.Value, t.Pos, t.Err) + case IDENT, ASSIGN, LIT_INT, LIT_FLOAT: + return fmt.Sprintf("<%s:%q %s>", t.Type, t.Value, t.Pos) + default: + if len(t.Value) > 10 { + return fmt.Sprintf("<%s:%.10q len=%d>", t.Type, t.Value, len(t.Value)) + } + return fmt.Sprintf("<%s:%q len=%d>", t.Type, t.Value, len(t.Value)) + } +} + +type TokenType int + +const ( + EOF TokenType = iota // End of file + ILLEGAL // Illegal token + WHITESPACE // Whitespace + + IDENT // Identifier + ASSIGN // Assignment operator (=) + + LIT_INT // Integer literal + LIT_FLOAT // Float literal + LIT_DEGREE // Degree literal + LIT_STRING // String literal + + OP_PLUS // Infix addition (+) + OP_MINUS // Infix subtraction (-) + OP_STAR // Infix multiplication (*) + OP_SLASH // Infix division (/) + OP_PERCENT // Infix modulo (%) + OP_ROOT // Root operator (√) +) + +var tokenNames = map[TokenType]string{ + EOF: "EOF", + ILLEGAL: "ILLEGAL", + WHITESPACE: "WHITESPACE", + + IDENT: "IDENT", + ASSIGN: "ASSIGN", + + LIT_INT: "LIT_INT", + LIT_FLOAT: "LIT_FLOAT", + LIT_DEGREE: "LIT_DEGREE", + LIT_STRING: "LIT_STRING", + + OP_PLUS: "OP_PLUS", + OP_MINUS: "OP_MINUS", + OP_STAR: "OP_STAR", + OP_SLASH: "OP_SLASH", + OP_PERCENT: "OP_PERCENT", + OP_ROOT: "OP_ROOT", +} + +func (t TokenType) String() string { + if name, ok := tokenNames[t]; ok { + return name + } + return fmt.Sprintf("UNKNOWN(%d)", t) +} diff --git a/pkg/num/const.go b/pkg/num/const.go new file mode 100644 index 0000000..7636ea0 --- /dev/null +++ b/pkg/num/const.go @@ -0,0 +1,3 @@ +package num + +// func PI(accuracy *big.Float) {} diff --git a/pkg/num/num.go b/pkg/num/num.go new file mode 100644 index 0000000..90ee23a --- /dev/null +++ b/pkg/num/num.go @@ -0,0 +1,36 @@ +package num + +import "math/big" + +type Num struct { + rat *big.Rat + real *RRA +} + +func (n *Num) String() string { + if n.real != nil { + return "real" + } + if n.rat != nil { + return n.rat.String() + } + return "0" +} + +func FromInt(a int64) *Num { + return &Num{ + rat: big.NewRat(a, 1), + } +} + +func FromRat(a, b int64) *Num { + return &Num{ + rat: big.NewRat(a, b), + } +} + +func Zero() *Num { + return &Num{ + rat: big.NewRat(0, 1), + } +} diff --git a/pkg/num/rra.go b/pkg/num/rra.go new file mode 100644 index 0000000..8db8e04 --- /dev/null +++ b/pkg/num/rra.go @@ -0,0 +1,3 @@ +package num + +type RRA struct{} From b6383ca00b81ee32be12839151100b453f2fd577 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Thu, 25 Sep 2025 00:28:01 -0700 Subject: [PATCH 02/37] pkg/calc: sketching out parser --- pkg/calc/calculator.go | 16 +++++--- pkg/calc/command.go | 32 ++++++++++++--- pkg/calc/lexer/lex_expression.go | 3 +- pkg/calc/lexer/lex_string.go | 6 +-- pkg/calc/lexer/lex_text.go | 6 +-- pkg/calc/lexer/lexer.go | 11 ++++- pkg/calc/parser/parser.go | 44 ++++++++++++++++++++ pkg/calc/parser/state.go | 3 ++ pkg/calc/parser/tree.go | 69 ++++++++++++++++++++++++++++++++ 9 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 pkg/calc/parser/parser.go create mode 100644 pkg/calc/parser/state.go create mode 100644 pkg/calc/parser/tree.go diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index d16e129..5072468 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -9,14 +9,19 @@ import ( "github.com/ripta/rt/pkg/num" ) -type Calculator struct{} +type Calculator struct { + ExpressionCount int +} func (c *Calculator) Evaluate(expr string) (*num.Num, error) { return Evaluate(expr) } func (c *Calculator) Execute(expr string) { - defer fmt.Println() + defer func() { + c.ExpressionCount++ + fmt.Println() + }() res, err := c.Evaluate(expr) if err != nil { @@ -28,7 +33,7 @@ func (c *Calculator) Execute(expr string) { } func (c *Calculator) DisplayError(err error) { - fmt.Fprintf(os.Stderr, "Error: %s\n", err) + fmt.Fprintf(os.Stderr, "calc:%03d/ Error: %s\n", c.ExpressionCount, err) } func (c *Calculator) DisplayResult(res *num.Num) { @@ -38,8 +43,9 @@ func (c *Calculator) DisplayResult(res *num.Num) { func (c *Calculator) REPL() { p := prompt.New( c.Execute, - prompt.WithPrefix("calc> "), - // prompt.WithInitialText("ident = 2"), + prompt.WithPrefixCallback(func() string { + return fmt.Sprintf("calc:%03d> ", c.ExpressionCount) + }), prompt.WithExitChecker(func(in string, breakline bool) bool { return breakline && (in == "exit" || in == "quit") }), diff --git a/pkg/calc/command.go b/pkg/calc/command.go index b3d182a..4110c78 100644 --- a/pkg/calc/command.go +++ b/pkg/calc/command.go @@ -1,17 +1,37 @@ package calc -import "github.com/spf13/cobra" +import ( + "errors" + "fmt" + "os" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var ErrNotTTY = errors.New("STDIN is not a TTY") + +// NewCommand creates a new calculator command. +// +// Expressions can be passed as one or more arguments. If no arguments are +// provided and STDIN is a TTY, it will start a REPL. Otherwise, the command +// will return ErrNotTTY. func NewCommand() *cobra.Command { c := &Calculator{} cmd := &cobra.Command{ - Use: "calc", - Short: "Calculate expressions", - Long: "Calculate expressions", + Use: "calc", + Short: "Calculate expressions", + Long: "Calculate expressions", + SilenceErrors: true, + SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { - c.REPL() - return nil + if term.IsTerminal(int(os.Stdin.Fd())) { + c.REPL() + return nil + } + + return fmt.Errorf("%w: must specify expression as arguments", ErrNotTTY) } for _, arg := range args { diff --git a/pkg/calc/lexer/lex_expression.go b/pkg/calc/lexer/lex_expression.go index 727521a..9ca0fbc 100644 --- a/pkg/calc/lexer/lex_expression.go +++ b/pkg/calc/lexer/lex_expression.go @@ -41,7 +41,6 @@ func lexExpression(l *L) lexingState { return lexIdent default: - l.Errorf("unexpected token %s", string(r)) - return nil + return l.Errorf("unexpected token %q", string(r)) } } diff --git a/pkg/calc/lexer/lex_string.go b/pkg/calc/lexer/lex_string.go index 6fa05e0..f3f9776 100644 --- a/pkg/calc/lexer/lex_string.go +++ b/pkg/calc/lexer/lex_string.go @@ -12,8 +12,7 @@ func lexQuotedString(l *L) lexingState { fallthrough case EOF, '\n': - l.Errorf("unterminated quoted string") - return nil + return l.Errorf("unterminated quoted string") case '"': done = true @@ -29,8 +28,7 @@ func lexRawString(l *L) lexingState { for done := false; !done; { switch l.Next() { case EOF, '\n': - l.Errorf("unterminated raw string") - return nil + return l.Errorf("unterminated raw string") case '`': done = true diff --git a/pkg/calc/lexer/lex_text.go b/pkg/calc/lexer/lex_text.go index 9f101d4..275245d 100644 --- a/pkg/calc/lexer/lex_text.go +++ b/pkg/calc/lexer/lex_text.go @@ -14,14 +14,12 @@ func lexIdent(l *L) lexingState { func lexNumber(l *L) lexingState { if !l.AcceptWhile(IsNumeric) { - l.Errorf("invalid number: %s", l.Current()) - return nil + return l.Errorf("invalid number: %s", l.Current()) } num := l.Current() if dec := strings.Count(num, "."); dec > 1 { - l.Errorf("too many decimal points (%d) in number; expected 0 or 1", dec) - return nil + return l.Errorf("too many decimal points (%d) in number; expected 0 or 1", dec) } else if dec == 1 { l.Emit(tokens.LIT_FLOAT) } else { diff --git a/pkg/calc/lexer/lexer.go b/pkg/calc/lexer/lexer.go index 35d05c1..df27a39 100644 --- a/pkg/calc/lexer/lexer.go +++ b/pkg/calc/lexer/lexer.go @@ -73,7 +73,7 @@ func (l *L) Current() string { func (l *L) Emit(t tokens.TokenType) { if l.start == l.pos { - l.Errorf("trying to emit empty %s token at %d:%d", t, l.line, l.start) + _ = l.Errorf("trying to emit empty %s token at %d:%d", t, l.line, l.start) return } @@ -89,7 +89,7 @@ func (l *L) Emit(t tokens.TokenType) { l.start = l.pos } -func (l *L) Errorf(format string, args ...any) { +func (l *L) Errorf(format string, args ...any) lexingState { err := fmt.Errorf(format, args...) pos := tokens.Position{ File: l.name, @@ -106,6 +106,7 @@ func (l *L) Errorf(format string, args ...any) { } l.eof = true + return nil } func (l *L) Err() error { @@ -168,3 +169,9 @@ func (l *L) Skip() { func (l *L) Tokens() <-chan tokens.Token { return l.tokens } + +// NextToken returns the next token from the lexer. It blocks until a token is +// available. +func (l *L) NextToken() tokens.Token { + return <-l.tokens +} diff --git a/pkg/calc/parser/parser.go b/pkg/calc/parser/parser.go new file mode 100644 index 0000000..ccb4ea1 --- /dev/null +++ b/pkg/calc/parser/parser.go @@ -0,0 +1,44 @@ +package parser + +import ( + "github.com/ripta/rt/pkg/calc/lexer" + "github.com/ripta/rt/pkg/calc/tokens" +) + +type P struct { + lex *lexer.L + lit string + fn parsingState + peek int + poked []tokens.Token +} + +func New(name, src string) *P { + lex := lexer.New(name, src) + p := &P{ + lex: lex, + fn: parseInit, + poked: []tokens.Token{}, + } + + return p +} + +// Recursive descent parser for expressions +func parseExpr(p *P) parsingState { + return nil +} + +func (p *P) Peek() tokens.Token { + if p.peek > 0 { + return p.poked[p.peek-1] + } + + p.peek = 1 + p.poked[0] = p.lex.NextToken() + return p.poked[0] +} + +func parseInit(p *P) parsingState { + return parseExpr +} diff --git a/pkg/calc/parser/state.go b/pkg/calc/parser/state.go new file mode 100644 index 0000000..c90ce33 --- /dev/null +++ b/pkg/calc/parser/state.go @@ -0,0 +1,3 @@ +package parser + +type parsingState func(*P) parsingState diff --git a/pkg/calc/parser/tree.go b/pkg/calc/parser/tree.go new file mode 100644 index 0000000..a2d6744 --- /dev/null +++ b/pkg/calc/parser/tree.go @@ -0,0 +1,69 @@ +package parser + +import ( + "fmt" + + "github.com/ripta/rt/pkg/calc/tokens" +) + +type Node interface { + Eval() (float64, error) +} + +type NumberNode struct { + Value float64 +} + +func (n *NumberNode) Eval() (float64, error) { + return n.Value, nil +} + +type BinaryNode struct { + Op tokens.Token + Left Node + Right Node +} + +func (n *BinaryNode) Eval() (float64, error) { + l, err := n.Left.Eval() + if err != nil { + return 0, err + } + r, err := n.Right.Eval() + if err != nil { + return 0, err + } + switch n.Op { + case tokens.OP_PLUS: + return l + r, nil + case tokens.MINUS: + return l - r, nil + case tokens.MUL: + return l * r, nil + case tokens.DIV: + if r == 0 { + return 0, fmt.Errorf("division by zero") + } + return l / r, nil + default: + return 0, fmt.Errorf("unknown operator") + } +} + +type UnaryNode struct { + Op tokens.Token + Expr Node +} + +func (n *UnaryNode) Eval() (float64, error) { + val, err := n.Expr.Eval() + if err != nil { + return 0, err + } + switch n.Op { + case tokens.MINUS: + return -val, nil + default: + return 0, fmt.Errorf("unknown unary operator") + } +} From 26f436c5d3b21284495b9168cffd50f3995c7d08 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Thu, 27 Nov 2025 00:46:32 -0800 Subject: [PATCH 03/37] go.mod: add tty handling modules --- go.mod | 7 +++++++ go.sum | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/go.mod b/go.mod index 56b8c6d..de4a7c4 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.24.1 require ( github.com/BurntSushi/toml v1.5.0 github.com/containerd/console v1.0.5 + github.com/elk-language/go-prompt v1.3.1 github.com/go-logfmt/logfmt v0.6.1 github.com/google/cel-go v0.26.1 github.com/gosuri/uilive v0.0.4 @@ -23,6 +24,7 @@ require ( github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/zclconf/go-cty v1.17.0 golang.org/x/crypto v0.45.0 + golang.org/x/term v0.37.0 golang.org/x/text v0.31.0 golang.org/x/time v0.14.0 google.golang.org/protobuf v1.36.10 @@ -40,9 +42,14 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-tty v0.0.7 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/pkg/term v1.2.0-beta.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect diff --git a/go.sum b/go.sum index 6e9437a..81812bb 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elk-language/go-prompt v1.3.1 h1:p6CJNCKcPUwUB4vkIvlqQNzW7ScrBHHKfMdFyeoESbc= +github.com/elk-language/go-prompt v1.3.1/go.mod h1:u66CVjp31ldgU/Ok1q8fA2RUmy/a9ysdMj5IZckFWKg= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= @@ -39,8 +41,14 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q= +github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= @@ -49,6 +57,8 @@ github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/pkg/term v1.2.0-beta.2 h1:L3y/h2jkuBVFdWiJvNfYfKmzcCnILw7mJWm2JQuMppw= +github.com/pkg/term v1.2.0-beta.2/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/r3labs/diff/v3 v3.0.2 h1:yVuxAY1V6MeM4+HNur92xkS39kB/N+cFi2hMkY06BbA= @@ -57,6 +67,9 @@ github.com/ripta/hypercmd v0.5.0 h1:8wEZndeP/umK8xLgZD1aYOIsdWsxymweJSETnbF1Awo= github.com/ripta/hypercmd v0.5.0/go.mod h1:nffU7nnFN8yU/PIHbN35UCE5q0FSnDJ6ev45SFEIZ48= github.com/ripta/unihan v0.0.0-20250404091138-c307c698a880 h1:ZzDUYlZP/LHJmkh+PtgRZHEKa+eNVefq6YR8BnUCQ2I= github.com/ripta/unihan v0.0.0-20250404091138-c307c698a880/go.mod h1:ZLBfCas48lym/27GOsyFjRo7OGejoGHzOTdUdoRtDqU= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= @@ -97,10 +110,13 @@ golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= From 42edd72ca7f0ade1881ee08973b0795fb591d285 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Fri, 28 Nov 2025 01:04:40 -0800 Subject: [PATCH 04/37] pkg/calc: lexer unit test --- pkg/calc/lexer/lex_expression.go | 4 + pkg/calc/lexer/lex_string.go | 2 - pkg/calc/lexer/lexer_test.go | 151 +++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 pkg/calc/lexer/lexer_test.go diff --git a/pkg/calc/lexer/lex_expression.go b/pkg/calc/lexer/lex_expression.go index 9ca0fbc..bc6e850 100644 --- a/pkg/calc/lexer/lex_expression.go +++ b/pkg/calc/lexer/lex_expression.go @@ -32,6 +32,10 @@ func lexExpression(l *L) lexingState { l.Rewind() return lexNumber + case r == '+': + l.Emit(tokens.OP_PLUS) + return lexExpression + case r == '-': l.Emit(tokens.OP_MINUS) return lexExpression diff --git a/pkg/calc/lexer/lex_string.go b/pkg/calc/lexer/lex_string.go index f3f9776..cfca480 100644 --- a/pkg/calc/lexer/lex_string.go +++ b/pkg/calc/lexer/lex_string.go @@ -16,7 +16,6 @@ func lexQuotedString(l *L) lexingState { case '"': done = true - break } } @@ -32,7 +31,6 @@ func lexRawString(l *L) lexingState { case '`': done = true - break } } diff --git a/pkg/calc/lexer/lexer_test.go b/pkg/calc/lexer/lexer_test.go new file mode 100644 index 0000000..7d6d4cf --- /dev/null +++ b/pkg/calc/lexer/lexer_test.go @@ -0,0 +1,151 @@ +package lexer + +import ( + "strings" + "testing" + + "github.com/ripta/rt/pkg/calc/tokens" +) + +func collectTokens(t *testing.T, input string) ([]tokens.Token, error) { + t.Helper() + l := New("test", input) + + out := []tokens.Token{} + for tok := range l.Tokens() { + out = append(out, tok) + } + + return out, l.Err() +} + +func containsError(gotErr error, wantErr string) bool { + if wantErr == "" { + return gotErr == nil + } + return gotErr != nil && strings.Contains(gotErr.Error(), wantErr) +} + +type tokenExpectation struct { + Type tokens.TokenType + Value string + Col int +} + +type tokenTest struct { + name string + input string + want []tokenExpectation + wantErr string +} + +var tokenTests = []tokenTest{ + { + name: "blank", + input: "", + }, + { + name: "addition of two integers", + input: "12+34", + want: []tokenExpectation{ + {Type: tokens.LIT_INT, Value: "12", Col: 1}, + {Type: tokens.OP_PLUS, Value: "+", Col: 3}, + {Type: tokens.LIT_INT, Value: "34", Col: 4}, + }, + }, + { + name: "identifier assignment integer", + input: "foo=123", + want: []tokenExpectation{ + {Type: tokens.IDENT, Value: "foo", Col: 1}, + {Type: tokens.ASSIGN, Value: "=", Col: 4}, + {Type: tokens.LIT_INT, Value: "123", Col: 5}, + }, + }, + { + name: "whitespace preserved float literal", + input: "bar = 3.14", + want: []tokenExpectation{ + {Type: tokens.IDENT, Value: "bar", Col: 1}, + {Type: tokens.WHITESPACE, Value: " ", Col: 4}, + {Type: tokens.ASSIGN, Value: "=", Col: 6}, + {Type: tokens.WHITESPACE, Value: " ", Col: 7}, + {Type: tokens.LIT_FLOAT, Value: "3.14", Col: 9}, + }, + }, + { + name: "malformed float literal", + input: "bar = 3.14.15", + want: []tokenExpectation{ + {Type: tokens.IDENT, Value: "bar", Col: 1}, + {Type: tokens.WHITESPACE, Value: " ", Col: 4}, + {Type: tokens.ASSIGN, Value: "=", Col: 5}, + {Type: tokens.WHITESPACE, Value: " ", Col: 6}, + {Type: tokens.ILLEGAL, Value: "3.14.15", Col: 7}, + }, + wantErr: "too many decimal points", + }, + { + name: "minus operator and identifier", + input: "-foo", + want: []tokenExpectation{ + {Type: tokens.OP_MINUS, Value: "-", Col: 1}, + {Type: tokens.IDENT, Value: "foo", Col: 2}, + }, + }, + { + name: "quoted string literal with escape", + input: `name="va\"lue"`, + want: []tokenExpectation{ + {Type: tokens.IDENT, Value: "name", Col: 1}, + {Type: tokens.ASSIGN, Value: "=", Col: 5}, + {Type: tokens.LIT_STRING, Value: `"va\"lue"`, Col: 6}, + }, + }, + { + name: "raw string literal", + input: "path=`/tmp/foo`", + want: []tokenExpectation{ + {Type: tokens.IDENT, Value: "path", Col: 1}, + {Type: tokens.ASSIGN, Value: "=", Col: 5}, + {Type: tokens.LIT_STRING, Value: "`/tmp/foo`", Col: 6}, + }, + }, +} + +func TestLexerTokens(t *testing.T) { + t.Parallel() + + for _, tt := range tokenTests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := collectTokens(t, tt.input) + if !containsError(err, tt.wantErr) { + t.Fatalf("error mismatch: got %v\nwant %v", err, tt.wantErr) + } + + if len(got) != len(tt.want) { + t.Fatalf("token count mismatch: got %d, want %d\nactual: %#v", len(got), len(tt.want), got) + } + + for i, wantTok := range tt.want { + gotTok := got[i] + if gotTok.Type != wantTok.Type || gotTok.Value != wantTok.Value || gotTok.Pos.Column != wantTok.Col { + t.Fatalf("token %d mismatch:\n got %v\nwant %v", i, gotTok, tokens.Token{ + Type: wantTok.Type, + Value: wantTok.Value, + Pos: tokens.Position{ + File: gotTok.Pos.File, + Line: gotTok.Pos.Line, + Column: wantTok.Col, + }, + }) + } + if gotTok.Type == tokens.ILLEGAL && !containsError(gotTok.Err, tt.wantErr) { + t.Fatalf("error mismatch: got %v\nwant %v", err, tt.wantErr) + } + } + }) + } +} From 6528ba024b11ea863899e0b856b01655639f3139 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Fri, 28 Nov 2025 01:16:31 -0800 Subject: [PATCH 05/37] pkg/calc/parser: fix type switches --- pkg/calc/parser/tree.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/calc/parser/tree.go b/pkg/calc/parser/tree.go index a2d6744..2945193 100644 --- a/pkg/calc/parser/tree.go +++ b/pkg/calc/parser/tree.go @@ -33,14 +33,14 @@ func (n *BinaryNode) Eval() (float64, error) { if err != nil { return 0, err } - switch n.Op { + switch n.Op.Type { case tokens.OP_PLUS: return l + r, nil - case tokens.MINUS: + case tokens.OP_MINUS: return l - r, nil - case tokens.MUL: + case tokens.OP_STAR: return l * r, nil - case tokens.DIV: + case tokens.OP_SLASH: if r == 0 { return 0, fmt.Errorf("division by zero") } @@ -60,8 +60,8 @@ func (n *UnaryNode) Eval() (float64, error) { if err != nil { return 0, err } - switch n.Op { - case tokens.MINUS: + switch n.Op.Type { + case tokens.OP_MINUS: return -val, nil default: return 0, fmt.Errorf("unknown unary operator") From f3152bab1c412a2def476b48a02f82fa26608b03 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Fri, 28 Nov 2025 01:31:41 -0800 Subject: [PATCH 06/37] pkg/calc/tokens: add parens --- pkg/calc/tokens/tokens.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/calc/tokens/tokens.go b/pkg/calc/tokens/tokens.go index 135af62..c6f223c 100644 --- a/pkg/calc/tokens/tokens.go +++ b/pkg/calc/tokens/tokens.go @@ -46,6 +46,9 @@ const ( OP_SLASH // Infix division (/) OP_PERCENT // Infix modulo (%) OP_ROOT // Root operator (√) + + LPAREN // ( + RPAREN // ) ) var tokenNames = map[TokenType]string{ @@ -67,6 +70,9 @@ var tokenNames = map[TokenType]string{ OP_SLASH: "OP_SLASH", OP_PERCENT: "OP_PERCENT", OP_ROOT: "OP_ROOT", + + LPAREN: "LPAREN", + RPAREN: "RPAREN", } func (t TokenType) String() string { From 27acd4cc00a36ee09ad07cd18e74ef614b9e328b Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Fri, 28 Nov 2025 01:32:06 -0800 Subject: [PATCH 07/37] pkg/calc/lexer: add lexing for missing tokens --- pkg/calc/lexer/lex_expression.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pkg/calc/lexer/lex_expression.go b/pkg/calc/lexer/lex_expression.go index bc6e850..d04d1be 100644 --- a/pkg/calc/lexer/lex_expression.go +++ b/pkg/calc/lexer/lex_expression.go @@ -40,6 +40,22 @@ func lexExpression(l *L) lexingState { l.Emit(tokens.OP_MINUS) return lexExpression + case r == '*': + l.Emit(tokens.OP_STAR) + return lexExpression + + case r == '/': + l.Emit(tokens.OP_SLASH) + return lexExpression + + case r == '(': + l.Emit(tokens.LPAREN) + return lexExpression + + case r == ')': + l.Emit(tokens.RPAREN) + return lexExpression + case IsAlnum(r): l.Rewind() return lexIdent From 42f44eb33efe44914ed1461c815de325719b91a2 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Fri, 28 Nov 2025 01:35:34 -0800 Subject: [PATCH 08/37] pkg/calc/parser: initial implementation --- pkg/calc/parser/parser.go | 298 ++++++++++++++++++++++++++++++--- pkg/calc/parser/parser_test.go | 130 ++++++++++++++ pkg/calc/parser/tree.go | 76 ++++++++- 3 files changed, 477 insertions(+), 27 deletions(-) create mode 100644 pkg/calc/parser/parser_test.go diff --git a/pkg/calc/parser/parser.go b/pkg/calc/parser/parser.go index ccb4ea1..d3434c7 100644 --- a/pkg/calc/parser/parser.go +++ b/pkg/calc/parser/parser.go @@ -1,44 +1,302 @@ package parser import ( + "fmt" + "strconv" + "strings" + "github.com/ripta/rt/pkg/calc/lexer" "github.com/ripta/rt/pkg/calc/tokens" ) type P struct { - lex *lexer.L - lit string - fn parsingState - peek int - poked []tokens.Token + lex *lexer.L + fn parsingState + + root Node + err error + + buf []tokens.Token } func New(name, src string) *P { - lex := lexer.New(name, src) - p := &P{ - lex: lex, - fn: parseInit, - poked: []tokens.Token{}, + return &P{ + lex: lexer.New(name, src), + fn: parseInit, } +} - return p +func Parse(name, src string) (Node, error) { + return New(name, src).Parse() +} + +func (p *P) Parse() (Node, error) { + for state := p.fn; state != nil; { + state = state(p) + } + if p.err != nil { + return nil, p.err + } + if p.root == nil { + return nil, fmt.Errorf("no expression parsed") + } + return p.root, nil } -// Recursive descent parser for expressions func parseExpr(p *P) parsingState { + if p.err != nil { + return nil + } + node, err := p.parseAssignment() + if err != nil { + p.err = err + return nil + } + tok := p.next() + if p.err != nil { + return nil + } + if tok.Type != tokens.EOF { + p.err = p.errorf(tok, "unexpected token %s", tok.Type) + return nil + } + p.root = node return nil } -func (p *P) Peek() tokens.Token { - if p.peek > 0 { - return p.poked[p.peek-1] +func parseInit(p *P) parsingState { + return parseExpr +} + +func (p *P) parseAssignment() (Node, error) { + if p.err != nil { + return nil, p.err + } + left, err := p.parseAdditive() + if err != nil { + return nil, err + } + + ident, ok := left.(*IdentNode) + if !ok { + return left, nil + } + + tok := p.peek() + if p.err != nil { + return nil, p.err + } + if tok.Type == tokens.ASSIGN { + p.next() // consume = + right, err := p.parseAssignment() + if err != nil { + return nil, err + } + return &AssignNode{ + Name: ident.Name, + Value: right, + }, nil + } + + return left, nil +} + +func (p *P) parseAdditive() (Node, error) { + if p.err != nil { + return nil, p.err + } + node, err := p.parseMultiplicative() + if err != nil { + return nil, err + } + + for { + tok := p.peek() + if p.err != nil { + return nil, p.err + } + if tok.Type != tokens.OP_PLUS && tok.Type != tokens.OP_MINUS { + break + } + p.next() + right, err := p.parseMultiplicative() + if err != nil { + return nil, err + } + node = &BinaryNode{ + Op: tok, + Left: node, + Right: right, + } } - p.peek = 1 - p.poked[0] = p.lex.NextToken() - return p.poked[0] + return node, nil } -func parseInit(p *P) parsingState { - return parseExpr +func (p *P) parseMultiplicative() (Node, error) { + if p.err != nil { + return nil, p.err + } + node, err := p.parseUnary() + if err != nil { + return nil, err + } + + for { + tok := p.peek() + if p.err != nil { + return nil, p.err + } + if tok.Type != tokens.OP_STAR && tok.Type != tokens.OP_SLASH { + break + } + p.next() + right, err := p.parseUnary() + if err != nil { + return nil, err + } + node = &BinaryNode{ + Op: tok, + Left: node, + Right: right, + } + } + + return node, nil +} + +func (p *P) parseUnary() (Node, error) { + if p.err != nil { + return nil, p.err + } + + tok := p.peek() + if p.err != nil { + return nil, p.err + } + + if tok.Type == tokens.OP_MINUS { + p.next() + expr, err := p.parseUnary() + if err != nil { + return nil, err + } + return &UnaryNode{ + Op: tok, + Expr: expr, + }, nil + } + + return p.parsePrimary() +} + +func (p *P) parsePrimary() (Node, error) { + if p.err != nil { + return nil, p.err + } + + tok := p.next() + if p.err != nil { + return nil, p.err + } + + switch tok.Type { + case tokens.LIT_INT, tokens.LIT_FLOAT: + val, err := p.parseNumber(tok) + if err != nil { + return nil, err + } + return &NumberNode{Value: val}, nil + + case tokens.IDENT: + return &IdentNode{Name: tok}, nil + + case tokens.LPAREN: + node, err := p.parseAssignment() + if err != nil { + return nil, err + } + if _, err := p.expect(tokens.RPAREN); err != nil { + return nil, err + } + return node, nil + + case tokens.EOF: + return nil, p.errorf(tok, "unexpected EOF") + + default: + return nil, p.errorf(tok, "unexpected token %s", tok.Type) + } +} + +func (p *P) parseNumber(tok tokens.Token) (float64, error) { + cleaned := strings.ReplaceAll(tok.Value, "_", "") + val, err := strconv.ParseFloat(cleaned, 64) + if err != nil { + return 0, fmt.Errorf("%s: invalid number %q: %w", tok.Pos, tok.Value, err) + } + return val, nil +} + +func (p *P) next() tokens.Token { + tok := p.nextRaw() + for tok.Type == tokens.WHITESPACE { + tok = p.nextRaw() + } + + if tok.Type == tokens.ILLEGAL && p.err == nil { + if tok.Err != nil { + p.err = fmt.Errorf("%s: %w", tok.Pos, tok.Err) + } else { + p.err = fmt.Errorf("%s: illegal token %q", tok.Pos, tok.Value) + } + } + + return tok +} + +func (p *P) peek() tokens.Token { + tok := p.next() + p.unread(tok) + return tok +} + +func (p *P) unread(tok tokens.Token) { + p.buf = append(p.buf, tok) +} + +func (p *P) nextRaw() tokens.Token { + if n := len(p.buf); n > 0 { + tok := p.buf[n-1] + p.buf = p.buf[:n-1] + return tok + } + + tok := p.lex.NextToken() + if tok.Type == 0 && tok.Value == "" && tok.Pos == (tokens.Position{}) && tok.Err == nil { + tok.Type = tokens.EOF + } + + return tok +} + +func (p *P) expect(tt tokens.TokenType) (tokens.Token, error) { + tok := p.next() + if p.err != nil { + return tokens.Token{}, p.err + } + if tok.Type != tt { + return tok, p.errorf(tok, "expected %s, got %s", tt, tok.Type) + } + + return tok, nil +} + +func (p *P) errorf(tok tokens.Token, format string, args ...any) error { + msg := fmt.Sprintf(format, args...) + if !tok.Pos.IsZero() { + return fmt.Errorf("%s: %s", tok.Pos, msg) + } + + return fmt.Errorf("%s", msg) } diff --git a/pkg/calc/parser/parser_test.go b/pkg/calc/parser/parser_test.go new file mode 100644 index 0000000..5d663e5 --- /dev/null +++ b/pkg/calc/parser/parser_test.go @@ -0,0 +1,130 @@ +package parser + +import ( + "math" + "strings" + "testing" +) + +func TestParserExpressions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + exprs []string + want float64 + }{ + { + name: "precedence", + exprs: []string{"1 + 2 * 3"}, + want: 7, + }, + { + name: "parentheses", + exprs: []string{"(1 + 2) * 3"}, + want: 9, + }, + { + name: "unary minus", + exprs: []string{"-4 + 2"}, + want: -2, + }, + { + name: "assignment and reference", + exprs: []string{"foo = 2", "foo * 5"}, + want: 10, + }, + { + name: "right associative assignment", + exprs: []string{"a = b = 3", "a + b"}, + want: 6, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + env := NewEnv() + var got float64 + var err error + for _, expr := range tt.exprs { + got, err = parseAndEval(t, expr, env) + if err != nil { + t.Fatalf("parse/eval %q: %v", expr, err) + } + } + + if diff := math.Abs(got - tt.want); diff > 1e-9 { + t.Fatalf("result mismatch: got %v, want %v (diff=%v)", got, tt.want, diff) + } + }) + } +} + +func TestParserErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expr string + wantErr string + }{ + { + name: "dangling plus", + expr: "1 +", + wantErr: "unexpected EOF", + }, + { + name: "lonely close paren", + expr: ")", + wantErr: "unexpected token RPAREN", + }, + { + name: "illegal tokens", + expr: "$", + wantErr: "unexpected token", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := New("test", tt.expr) + _, err := p.Parse() + if err == nil { + t.Fatalf("expected error containing %q", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error mismatch: got %v want substring %q", err, tt.wantErr) + } + }) + } +} + +func TestEvalUndefinedIdentifier(t *testing.T) { + t.Parallel() + + p := New("test", "foo + 1") + node, err := p.Parse() + if err != nil { + t.Fatalf("parse error: %v", err) + } + + if _, err := node.Eval(NewEnv()); err == nil { + t.Fatalf("expected undefined identifier error") + } else if !strings.Contains(err.Error(), "undefined identifier") { + t.Fatalf("unexpected error: %v", err) + } +} + +func parseAndEval(t *testing.T, expr string, env *Env) (float64, error) { + t.Helper() + p := New("test", expr) + node, err := p.Parse() + if err != nil { + return 0, err + } + return node.Eval(env) +} diff --git a/pkg/calc/parser/tree.go b/pkg/calc/parser/tree.go index 2945193..c45a30e 100644 --- a/pkg/calc/parser/tree.go +++ b/pkg/calc/parser/tree.go @@ -7,14 +7,33 @@ import ( ) type Node interface { - Eval() (float64, error) + Eval(*Env) (float64, error) +} + +type Env struct { + vars map[string]float64 +} + +func NewEnv() *Env { + return &Env{ + vars: map[string]float64{}, + } +} + +func (e *Env) Get(name string) (float64, bool) { + val, ok := e.vars[name] + return val, ok +} + +func (e *Env) Set(name string, val float64) { + e.vars[name] = val } type NumberNode struct { Value float64 } -func (n *NumberNode) Eval() (float64, error) { +func (n *NumberNode) Eval(_ *Env) (float64, error) { return n.Value, nil } @@ -24,27 +43,33 @@ type BinaryNode struct { Right Node } -func (n *BinaryNode) Eval() (float64, error) { - l, err := n.Left.Eval() +func (n *BinaryNode) Eval(env *Env) (float64, error) { + l, err := n.Left.Eval(env) if err != nil { return 0, err } - r, err := n.Right.Eval() + + r, err := n.Right.Eval(env) if err != nil { return 0, err } + switch n.Op.Type { case tokens.OP_PLUS: return l + r, nil + case tokens.OP_MINUS: return l - r, nil + case tokens.OP_STAR: return l * r, nil + case tokens.OP_SLASH: if r == 0 { return 0, fmt.Errorf("division by zero") } return l / r, nil + default: return 0, fmt.Errorf("unknown operator") } @@ -55,15 +80,52 @@ type UnaryNode struct { Expr Node } -func (n *UnaryNode) Eval() (float64, error) { - val, err := n.Expr.Eval() +func (n *UnaryNode) Eval(env *Env) (float64, error) { + val, err := n.Expr.Eval(env) if err != nil { return 0, err } + switch n.Op.Type { case tokens.OP_MINUS: return -val, nil + default: return 0, fmt.Errorf("unknown unary operator") } } + +type IdentNode struct { + Name tokens.Token +} + +func (n *IdentNode) Eval(env *Env) (float64, error) { + if env == nil { + return 0, fmt.Errorf("%s: undefined identifier %q", n.Name.Pos, n.Name.Value) + } + + if val, ok := env.Get(n.Name.Value); ok { + return val, nil + } + + return 0, fmt.Errorf("%s: undefined identifier %q", n.Name.Pos, n.Name.Value) +} + +type AssignNode struct { + Name tokens.Token + Value Node +} + +func (n *AssignNode) Eval(env *Env) (float64, error) { + if env == nil { + env = NewEnv() + } + + val, err := n.Value.Eval(env) + if err != nil { + return 0, err + } + + env.Set(n.Name.Value, val) + return val, nil +} From 2e205fd3cd34f155f9f098fdbb0d793d95c1c62e Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Fri, 28 Nov 2025 01:47:44 -0800 Subject: [PATCH 09/37] pkg/calc/tokens: add Position.IsZero helper --- pkg/calc/tokens/position.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/calc/tokens/position.go b/pkg/calc/tokens/position.go index 7fcf866..efd8c3a 100644 --- a/pkg/calc/tokens/position.go +++ b/pkg/calc/tokens/position.go @@ -8,6 +8,10 @@ type Position struct { Column int } +func (p Position) IsZero() bool { + return p == Position{} +} + func (p Position) String() string { return fmt.Sprintf("%s:%d:%d", p.File, p.Line, p.Column) } From 4d0c2717cbea6bd9beb6420fb6659963d82e74bc Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Fri, 28 Nov 2025 01:50:14 -0800 Subject: [PATCH 10/37] pkg/calc: implement temporary evaluator --- pkg/calc/calculator.go | 7 ++++++- pkg/calc/evaluate.go | 29 +++++++++++++++++++---------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index 5072468..ac8870d 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -6,15 +6,20 @@ import ( "github.com/elk-language/go-prompt" + "github.com/ripta/rt/pkg/calc/parser" "github.com/ripta/rt/pkg/num" ) type Calculator struct { ExpressionCount int + env *parser.Env } func (c *Calculator) Evaluate(expr string) (*num.Num, error) { - return Evaluate(expr) + if c.env == nil { + c.env = parser.NewEnv() + } + return evaluate(expr, c.env) } func (c *Calculator) Execute(expr string) { diff --git a/pkg/calc/evaluate.go b/pkg/calc/evaluate.go index 741fb88..ec99f37 100644 --- a/pkg/calc/evaluate.go +++ b/pkg/calc/evaluate.go @@ -1,26 +1,35 @@ package calc import ( - "fmt" "strings" - "github.com/ripta/rt/pkg/calc/lexer" + "github.com/ripta/rt/pkg/calc/parser" "github.com/ripta/rt/pkg/num" ) func Evaluate(expr string) (*num.Num, error) { - return evaluate(strings.TrimSpace(expr)) + return evaluate(expr, parser.NewEnv()) } -func evaluate(expr string) (*num.Num, error) { - l := lexer.New("(eval)", expr) - for tok := range l.Tokens() { - fmt.Printf("%+v\n", tok) +func evaluate(expr string, env *parser.Env) (*num.Num, error) { + expr = strings.TrimSpace(expr) + if env == nil { + env = parser.NewEnv() + } + if expr == "" { + return num.Zero(), nil + } + + p := parser.New("(eval)", expr) + node, err := p.Parse() + if err != nil { + return nil, err } - if l.Err() != nil { - return nil, l.Err() + val, err := node.Eval(env) + if err != nil { + return nil, err } - return num.Zero(), nil + return num.FromFloat64(val), nil } From e966893d1477b2691e2715e7bc7c0cab65508f9c Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Thu, 27 Nov 2025 03:31:56 -0800 Subject: [PATCH 11/37] go.mod: add ripta/reals for constructive reals support --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index de4a7c4..8751fab 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/mr-tron/base58 v1.2.0 github.com/r3labs/diff/v3 v3.0.2 github.com/ripta/hypercmd v0.5.0 + github.com/ripta/reals v0.0.0-20251129121815-4fa2f223ded2 github.com/ripta/unihan v0.0.0-20250404091138-c307c698a880 github.com/spf13/cobra v1.10.1 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index 81812bb..d404899 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,8 @@ github.com/r3labs/diff/v3 v3.0.2 h1:yVuxAY1V6MeM4+HNur92xkS39kB/N+cFi2hMkY06BbA= github.com/r3labs/diff/v3 v3.0.2/go.mod h1:Cy542hv0BAEmhDYWtGxXRQ4kqRsVIcEjG9gChUlTmkw= github.com/ripta/hypercmd v0.5.0 h1:8wEZndeP/umK8xLgZD1aYOIsdWsxymweJSETnbF1Awo= github.com/ripta/hypercmd v0.5.0/go.mod h1:nffU7nnFN8yU/PIHbN35UCE5q0FSnDJ6ev45SFEIZ48= +github.com/ripta/reals v0.0.0-20251129121815-4fa2f223ded2 h1:QWeZ/uw8S951/qJQzg+wBAOpFhUx7yVJxyPRZdjJmuI= +github.com/ripta/reals v0.0.0-20251129121815-4fa2f223ded2/go.mod h1:WErCt40puDDQdpVq8Hg1DzjB0svufA8WboSYG4BI2+E= github.com/ripta/unihan v0.0.0-20250404091138-c307c698a880 h1:ZzDUYlZP/LHJmkh+PtgRZHEKa+eNVefq6YR8BnUCQ2I= github.com/ripta/unihan v0.0.0-20250404091138-c307c698a880/go.mod h1:ZLBfCas48lym/27GOsyFjRo7OGejoGHzOTdUdoRtDqU= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= From 7b32c42e0fbccc345c435a5c74efbbb3772f9191 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Fri, 28 Nov 2025 02:15:07 -0800 Subject: [PATCH 12/37] pkg/calc/parser: migrate from float64 representation to unified reals --- pkg/calc/parser/parser.go | 17 +++++++---- pkg/calc/parser/parser_test.go | 40 ++++++++++++++++++++++--- pkg/calc/parser/tree.go | 54 ++++++++++++++++++---------------- 3 files changed, 75 insertions(+), 36 deletions(-) diff --git a/pkg/calc/parser/parser.go b/pkg/calc/parser/parser.go index d3434c7..ba67c52 100644 --- a/pkg/calc/parser/parser.go +++ b/pkg/calc/parser/parser.go @@ -2,9 +2,13 @@ package parser import ( "fmt" - "strconv" + "math/big" "strings" + "github.com/ripta/reals/pkg/constructive" + "github.com/ripta/reals/pkg/rational" + "github.com/ripta/reals/pkg/unified" + "github.com/ripta/rt/pkg/calc/lexer" "github.com/ripta/rt/pkg/calc/tokens" ) @@ -229,13 +233,14 @@ func (p *P) parsePrimary() (Node, error) { } } -func (p *P) parseNumber(tok tokens.Token) (float64, error) { +func (p *P) parseNumber(tok tokens.Token) (*unified.Real, error) { cleaned := strings.ReplaceAll(tok.Value, "_", "") - val, err := strconv.ParseFloat(cleaned, 64) - if err != nil { - return 0, fmt.Errorf("%s: invalid number %q: %w", tok.Pos, tok.Value, err) + rat := new(big.Rat) + if _, ok := rat.SetString(cleaned); !ok { + return nil, fmt.Errorf("%s: invalid number %q", tok.Pos, tok.Value) } - return val, nil + + return unified.New(constructive.One(), rational.FromRational(rat)), nil } func (p *P) next() tokens.Token { diff --git a/pkg/calc/parser/parser_test.go b/pkg/calc/parser/parser_test.go index 5d663e5..1918ffc 100644 --- a/pkg/calc/parser/parser_test.go +++ b/pkg/calc/parser/parser_test.go @@ -2,8 +2,12 @@ package parser import ( "math" + "math/big" "strings" "testing" + + "github.com/ripta/reals/pkg/constructive" + "github.com/ripta/reals/pkg/unified" ) func TestParserExpressions(t *testing.T) { @@ -46,15 +50,16 @@ func TestParserExpressions(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() env := NewEnv() - var got float64 + var result *unified.Real var err error for _, expr := range tt.exprs { - got, err = parseAndEval(t, expr, env) + result, err = parseAndEval(t, expr, env) if err != nil { t.Fatalf("parse/eval %q: %v", expr, err) } } + got := realToFloat(t, result) if diff := math.Abs(got - tt.want); diff > 1e-9 { t.Fatalf("result mismatch: got %v, want %v (diff=%v)", got, tt.want, diff) } @@ -119,12 +124,39 @@ func TestEvalUndefinedIdentifier(t *testing.T) { } } -func parseAndEval(t *testing.T, expr string, env *Env) (float64, error) { +func parseAndEval(t *testing.T, expr string, env *Env) (*unified.Real, error) { t.Helper() p := New("test", expr) node, err := p.Parse() if err != nil { - return 0, err + return nil, err } return node.Eval(env) } + +const testPrecision = -100 + +func realToFloat(t *testing.T, r *unified.Real) float64 { + t.Helper() + rat := approximateRealForTest(t, r, testPrecision) + f, _ := rat.Float64() + return f +} + +func approximateRealForTest(t *testing.T, r *unified.Real, precision int) *big.Rat { + t.Helper() + if r == nil { + t.Fatalf("nil real result") + } + if !constructive.IsPrecisionValid(precision) { + t.Fatalf("invalid precision %d", precision) + } + approx := constructive.Approximate(r.Constructive(), precision) + if approx == nil { + t.Fatalf("approximation failed for precision %d", precision) + } + + exp := int64(-precision) + denom := new(big.Int).Exp(big.NewInt(2), big.NewInt(exp), nil) + return new(big.Rat).SetFrac(approx, denom) +} diff --git a/pkg/calc/parser/tree.go b/pkg/calc/parser/tree.go index c45a30e..bea0b5f 100644 --- a/pkg/calc/parser/tree.go +++ b/pkg/calc/parser/tree.go @@ -3,37 +3,39 @@ package parser import ( "fmt" + "github.com/ripta/reals/pkg/unified" + "github.com/ripta/rt/pkg/calc/tokens" ) type Node interface { - Eval(*Env) (float64, error) + Eval(*Env) (*unified.Real, error) } type Env struct { - vars map[string]float64 + vars map[string]*unified.Real } func NewEnv() *Env { return &Env{ - vars: map[string]float64{}, + vars: map[string]*unified.Real{}, } } -func (e *Env) Get(name string) (float64, bool) { +func (e *Env) Get(name string) (*unified.Real, bool) { val, ok := e.vars[name] return val, ok } -func (e *Env) Set(name string, val float64) { +func (e *Env) Set(name string, val *unified.Real) { e.vars[name] = val } type NumberNode struct { - Value float64 + Value *unified.Real } -func (n *NumberNode) Eval(_ *Env) (float64, error) { +func (n *NumberNode) Eval(_ *Env) (*unified.Real, error) { return n.Value, nil } @@ -43,35 +45,35 @@ type BinaryNode struct { Right Node } -func (n *BinaryNode) Eval(env *Env) (float64, error) { +func (n *BinaryNode) Eval(env *Env) (*unified.Real, error) { l, err := n.Left.Eval(env) if err != nil { - return 0, err + return nil, err } r, err := n.Right.Eval(env) if err != nil { - return 0, err + return nil, err } switch n.Op.Type { case tokens.OP_PLUS: - return l + r, nil + return l.Add(r), nil case tokens.OP_MINUS: - return l - r, nil + return l.Subtract(r), nil case tokens.OP_STAR: - return l * r, nil + return l.Multiply(r), nil case tokens.OP_SLASH: - if r == 0 { - return 0, fmt.Errorf("division by zero") + if r.IsZero() { + return nil, fmt.Errorf("division by zero") } - return l / r, nil + return l.Divide(r), nil default: - return 0, fmt.Errorf("unknown operator") + return nil, fmt.Errorf("unknown operator") } } @@ -80,18 +82,18 @@ type UnaryNode struct { Expr Node } -func (n *UnaryNode) Eval(env *Env) (float64, error) { +func (n *UnaryNode) Eval(env *Env) (*unified.Real, error) { val, err := n.Expr.Eval(env) if err != nil { - return 0, err + return nil, err } switch n.Op.Type { case tokens.OP_MINUS: - return -val, nil + return val.Negate(), nil default: - return 0, fmt.Errorf("unknown unary operator") + return nil, fmt.Errorf("unknown unary operator") } } @@ -99,16 +101,16 @@ type IdentNode struct { Name tokens.Token } -func (n *IdentNode) Eval(env *Env) (float64, error) { +func (n *IdentNode) Eval(env *Env) (*unified.Real, error) { if env == nil { - return 0, fmt.Errorf("%s: undefined identifier %q", n.Name.Pos, n.Name.Value) + return nil, fmt.Errorf("%s: undefined identifier %q", n.Name.Pos, n.Name.Value) } if val, ok := env.Get(n.Name.Value); ok { return val, nil } - return 0, fmt.Errorf("%s: undefined identifier %q", n.Name.Pos, n.Name.Value) + return nil, fmt.Errorf("%s: undefined identifier %q", n.Name.Pos, n.Name.Value) } type AssignNode struct { @@ -116,14 +118,14 @@ type AssignNode struct { Value Node } -func (n *AssignNode) Eval(env *Env) (float64, error) { +func (n *AssignNode) Eval(env *Env) (*unified.Real, error) { if env == nil { env = NewEnv() } val, err := n.Value.Eval(env) if err != nil { - return 0, err + return nil, err } env.Set(n.Name.Value, val) From 523856555a28239aa139db6b0f62fadb389ca6a0 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Fri, 28 Nov 2025 02:15:39 -0800 Subject: [PATCH 13/37] pkg/calc: wire up unified reals into the evaluator --- pkg/calc/calculator.go | 8 +++--- pkg/calc/evaluate.go | 11 ++++---- pkg/calc/reals.go | 57 ++++++++++++++++++++++++++++++++++++++++++ pkg/num/const.go | 3 --- pkg/num/num.go | 36 -------------------------- pkg/num/rra.go | 3 --- 6 files changed, 67 insertions(+), 51 deletions(-) create mode 100644 pkg/calc/reals.go delete mode 100644 pkg/num/const.go delete mode 100644 pkg/num/num.go delete mode 100644 pkg/num/rra.go diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index ac8870d..689d173 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -5,9 +5,9 @@ import ( "os" "github.com/elk-language/go-prompt" + "github.com/ripta/reals/pkg/unified" "github.com/ripta/rt/pkg/calc/parser" - "github.com/ripta/rt/pkg/num" ) type Calculator struct { @@ -15,7 +15,7 @@ type Calculator struct { env *parser.Env } -func (c *Calculator) Evaluate(expr string) (*num.Num, error) { +func (c *Calculator) Evaluate(expr string) (*unified.Real, error) { if c.env == nil { c.env = parser.NewEnv() } @@ -41,8 +41,8 @@ func (c *Calculator) DisplayError(err error) { fmt.Fprintf(os.Stderr, "calc:%03d/ Error: %s\n", c.ExpressionCount, err) } -func (c *Calculator) DisplayResult(res *num.Num) { - fmt.Printf("%s\n", res) +func (c *Calculator) DisplayResult(res *unified.Real) { + fmt.Printf("%s\n", formatReal(res)) } func (c *Calculator) REPL() { diff --git a/pkg/calc/evaluate.go b/pkg/calc/evaluate.go index ec99f37..29c88ba 100644 --- a/pkg/calc/evaluate.go +++ b/pkg/calc/evaluate.go @@ -3,21 +3,22 @@ package calc import ( "strings" + "github.com/ripta/reals/pkg/unified" + "github.com/ripta/rt/pkg/calc/parser" - "github.com/ripta/rt/pkg/num" ) -func Evaluate(expr string) (*num.Num, error) { +func Evaluate(expr string) (*unified.Real, error) { return evaluate(expr, parser.NewEnv()) } -func evaluate(expr string, env *parser.Env) (*num.Num, error) { +func evaluate(expr string, env *parser.Env) (*unified.Real, error) { expr = strings.TrimSpace(expr) if env == nil { env = parser.NewEnv() } if expr == "" { - return num.Zero(), nil + return unified.Zero(), nil } p := parser.New("(eval)", expr) @@ -31,5 +32,5 @@ func evaluate(expr string, env *parser.Env) (*num.Num, error) { return nil, err } - return num.FromFloat64(val), nil + return val, nil } diff --git a/pkg/calc/reals.go b/pkg/calc/reals.go new file mode 100644 index 0000000..37eeb69 --- /dev/null +++ b/pkg/calc/reals.go @@ -0,0 +1,57 @@ +package calc + +import ( + "fmt" + "math/big" + "strings" + + "github.com/ripta/reals/pkg/constructive" + "github.com/ripta/reals/pkg/unified" +) + +const ( + displayPrecisionBits = -100 + displayDecimalDigits = 24 + precisionFailureLabel = "" +) + +func formatReal(r *unified.Real) string { + if r == nil { + return "" + } + + rat, err := approximateReal(r, displayPrecisionBits) + if err != nil { + return fmt.Sprintf("%s: %v", precisionFailureLabel, err) + } + + s := rat.FloatString(displayDecimalDigits) + if strings.Contains(s, ".") { + s = strings.TrimRight(strings.TrimRight(s, "0"), ".") + if s == "" || s == "-" { + s += "0" + } + } + return s +} + +func approximateReal(r *unified.Real, precision int) (*big.Rat, error) { + if r == nil { + return nil, fmt.Errorf("nil real") + } + if precision >= 0 { + return nil, fmt.Errorf("precision must be negative (got %d)", precision) + } + if !constructive.IsPrecisionValid(precision) { + return nil, fmt.Errorf("precision %d is out of range", precision) + } + + approx := constructive.Approximate(r.Constructive(), precision) + if approx == nil { + return nil, fmt.Errorf("approximation failed at precision %d", precision) + } + + exp := int64(-precision) + denom := new(big.Int).Exp(big.NewInt(2), big.NewInt(exp), nil) + return new(big.Rat).SetFrac(approx, denom), nil +} diff --git a/pkg/num/const.go b/pkg/num/const.go deleted file mode 100644 index 7636ea0..0000000 --- a/pkg/num/const.go +++ /dev/null @@ -1,3 +0,0 @@ -package num - -// func PI(accuracy *big.Float) {} diff --git a/pkg/num/num.go b/pkg/num/num.go deleted file mode 100644 index 90ee23a..0000000 --- a/pkg/num/num.go +++ /dev/null @@ -1,36 +0,0 @@ -package num - -import "math/big" - -type Num struct { - rat *big.Rat - real *RRA -} - -func (n *Num) String() string { - if n.real != nil { - return "real" - } - if n.rat != nil { - return n.rat.String() - } - return "0" -} - -func FromInt(a int64) *Num { - return &Num{ - rat: big.NewRat(a, 1), - } -} - -func FromRat(a, b int64) *Num { - return &Num{ - rat: big.NewRat(a, b), - } -} - -func Zero() *Num { - return &Num{ - rat: big.NewRat(0, 1), - } -} diff --git a/pkg/num/rra.go b/pkg/num/rra.go deleted file mode 100644 index 8db8e04..0000000 --- a/pkg/num/rra.go +++ /dev/null @@ -1,3 +0,0 @@ -package num - -type RRA struct{} From daed028cf46f25edbcfd970917dbd81dde541102 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Fri, 28 Nov 2025 02:40:56 -0800 Subject: [PATCH 14/37] pkg/calc: add transcendental constants to the default evaluation environment --- pkg/calc/parser/parser_test.go | 53 ++++++++++++++++++++++++++++++++++ pkg/calc/parser/tree.go | 51 +++++++++++++++++++++++++++----- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/pkg/calc/parser/parser_test.go b/pkg/calc/parser/parser_test.go index 1918ffc..dbdeedf 100644 --- a/pkg/calc/parser/parser_test.go +++ b/pkg/calc/parser/parser_test.go @@ -124,6 +124,59 @@ func TestEvalUndefinedIdentifier(t *testing.T) { } } +func TestParserTranscendentalConstants(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expr string + want float64 + }{ + {name: "PI", expr: "PI", want: math.Pi}, + {name: "E", expr: "E", want: math.E}, + {name: "LN2", expr: "LN2", want: math.Ln2}, + {name: "PHI", expr: "PHI", want: math.Phi}, + {name: "SQRT2 squared", expr: "SQRT2 * SQRT2", want: 2}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + env := NewEnv() + val, err := parseAndEval(t, tt.expr, env) + if err != nil { + t.Fatalf("parse/eval %q: %v", tt.expr, err) + } + got := realToFloat(t, val) + if diff := math.Abs(got - tt.want); diff > 1e-9 { + t.Fatalf("result mismatch: got %v, want %v (diff=%v)", got, tt.want, diff) + } + }) + } +} + +func TestParserTranscendentalConstantsImmutable(t *testing.T) { + t.Parallel() + + env := NewEnv() + if _, err := parseAndEval(t, "PI = 3", env); err == nil { + t.Fatalf("expected error when assigning to PI") + } else if !strings.Contains(err.Error(), "constant") { + t.Fatalf("unexpected error: %v", err) + } + + val, err := parseAndEval(t, "PI", env) + if err != nil { + t.Fatalf("PI lookup failed after assignment error: %v", err) + } + + got := realToFloat(t, val) + if diff := math.Abs(got - math.Pi); diff > 1e-9 { + t.Fatalf("PI changed after failed assignment: got %v diff %v", got, diff) + } +} + func parseAndEval(t *testing.T, expr string, env *Env) (*unified.Real, error) { t.Helper() p := New("test", expr) diff --git a/pkg/calc/parser/tree.go b/pkg/calc/parser/tree.go index bea0b5f..ae8bb59 100644 --- a/pkg/calc/parser/tree.go +++ b/pkg/calc/parser/tree.go @@ -12,23 +12,58 @@ type Node interface { Eval(*Env) (*unified.Real, error) } +type binding struct { + value *unified.Real + mutable bool +} + type Env struct { - vars map[string]*unified.Real + vars map[string]*binding } func NewEnv() *Env { return &Env{ - vars: map[string]*unified.Real{}, + vars: seedConstants(), + } +} + +var transcendentalConstants = map[string]func() *unified.Real{ + "E": unified.E, + "PI": unified.Pi, + "PHI": unified.Phi, + "SQRT2": unified.Sqrt2, + "LN2": unified.Ln2, +} + +func seedConstants() map[string]*binding { + vars := map[string]*binding{} + for name, supplier := range transcendentalConstants { + vars[name] = &binding{ + value: supplier(), + mutable: false, + } } + + return vars } func (e *Env) Get(name string) (*unified.Real, bool) { - val, ok := e.vars[name] - return val, ok + if binding, ok := e.vars[name]; ok { + return binding.value, true + } + return nil, false } -func (e *Env) Set(name string, val *unified.Real) { - e.vars[name] = val +func (e *Env) Set(name string, val *unified.Real) error { + if binding, ok := e.vars[name]; ok && !binding.mutable { + return fmt.Errorf("cannot assign to constant %q", name) + } + + e.vars[name] = &binding{ + value: val, + mutable: true, + } + return nil } type NumberNode struct { @@ -128,6 +163,8 @@ func (n *AssignNode) Eval(env *Env) (*unified.Real, error) { return nil, err } - env.Set(n.Name.Value, val) + if err := env.Set(n.Name.Value, val); err != nil { + return nil, fmt.Errorf("%s: %w", n.Name.Pos, err) + } return val, nil } From 564bc125fada28cd2893f4156fc8971997d53620 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Fri, 28 Nov 2025 03:37:21 -0800 Subject: [PATCH 15/37] pkg/calc: implement sqrt --- pkg/calc/lexer/lex_expression.go | 4 ++++ pkg/calc/parser/parser.go | 2 +- pkg/calc/parser/parser_test.go | 20 ++++++++++++++++++++ pkg/calc/parser/tree.go | 6 ++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/pkg/calc/lexer/lex_expression.go b/pkg/calc/lexer/lex_expression.go index d04d1be..1915128 100644 --- a/pkg/calc/lexer/lex_expression.go +++ b/pkg/calc/lexer/lex_expression.go @@ -48,6 +48,10 @@ func lexExpression(l *L) lexingState { l.Emit(tokens.OP_SLASH) return lexExpression + case r == '√': + l.Emit(tokens.OP_ROOT) + return lexExpression + case r == '(': l.Emit(tokens.LPAREN) return lexExpression diff --git a/pkg/calc/parser/parser.go b/pkg/calc/parser/parser.go index ba67c52..8bb04fe 100644 --- a/pkg/calc/parser/parser.go +++ b/pkg/calc/parser/parser.go @@ -179,7 +179,7 @@ func (p *P) parseUnary() (Node, error) { return nil, p.err } - if tok.Type == tokens.OP_MINUS { + if tok.Type == tokens.OP_MINUS || tok.Type == tokens.OP_ROOT { p.next() expr, err := p.parseUnary() if err != nil { diff --git a/pkg/calc/parser/parser_test.go b/pkg/calc/parser/parser_test.go index dbdeedf..f928f0f 100644 --- a/pkg/calc/parser/parser_test.go +++ b/pkg/calc/parser/parser_test.go @@ -43,6 +43,26 @@ func TestParserExpressions(t *testing.T) { exprs: []string{"a = b = 3", "a + b"}, want: 6, }, + { + name: "square root", + exprs: []string{"√4"}, + want: 2, + }, + { + name: "square root of 2", + exprs: []string{"√2"}, + want: math.Sqrt2, + }, + { + name: "nested square root", + exprs: []string{"√√16"}, + want: 2, + }, + { + name: "complex expression", + exprs: []string{"a = 2.8", "b = 4.5", "c = √(a*a + b*b)", "c"}, + want: 5.3, + }, } for _, tt := range tests { diff --git a/pkg/calc/parser/tree.go b/pkg/calc/parser/tree.go index ae8bb59..d9cfc07 100644 --- a/pkg/calc/parser/tree.go +++ b/pkg/calc/parser/tree.go @@ -3,6 +3,8 @@ package parser import ( "fmt" + "github.com/ripta/reals/pkg/constructive" + "github.com/ripta/reals/pkg/rational" "github.com/ripta/reals/pkg/unified" "github.com/ripta/rt/pkg/calc/tokens" @@ -127,6 +129,10 @@ func (n *UnaryNode) Eval(env *Env) (*unified.Real, error) { case tokens.OP_MINUS: return val.Negate(), nil + case tokens.OP_ROOT: + cr := constructive.Sqrt(val.Constructive()) + return unified.New(cr, rational.One()), nil + default: return nil, fmt.Errorf("unknown unary operator") } From 8c89d36116869218de9c26ba02770288a85cd372 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Fri, 28 Nov 2025 13:32:35 -0800 Subject: [PATCH 16/37] pkg/calc: implement modulo --- pkg/calc/lexer/lex_expression.go | 4 ++ pkg/calc/parser/parser.go | 2 +- pkg/calc/parser/parser_test.go | 100 +++++++++++++++++++++---------- pkg/calc/parser/tree.go | 40 +++++++++++++ 4 files changed, 113 insertions(+), 33 deletions(-) diff --git a/pkg/calc/lexer/lex_expression.go b/pkg/calc/lexer/lex_expression.go index 1915128..751f4e5 100644 --- a/pkg/calc/lexer/lex_expression.go +++ b/pkg/calc/lexer/lex_expression.go @@ -48,6 +48,10 @@ func lexExpression(l *L) lexingState { l.Emit(tokens.OP_SLASH) return lexExpression + case r == '%': + l.Emit(tokens.OP_PERCENT) + return lexExpression + case r == '√': l.Emit(tokens.OP_ROOT) return lexExpression diff --git a/pkg/calc/parser/parser.go b/pkg/calc/parser/parser.go index 8bb04fe..c5ef153 100644 --- a/pkg/calc/parser/parser.go +++ b/pkg/calc/parser/parser.go @@ -151,7 +151,7 @@ func (p *P) parseMultiplicative() (Node, error) { if p.err != nil { return nil, p.err } - if tok.Type != tokens.OP_STAR && tok.Type != tokens.OP_SLASH { + if tok.Type != tokens.OP_STAR && tok.Type != tokens.OP_SLASH && tok.Type != tokens.OP_PERCENT { break } p.next() diff --git a/pkg/calc/parser/parser_test.go b/pkg/calc/parser/parser_test.go index f928f0f..7df4685 100644 --- a/pkg/calc/parser/parser_test.go +++ b/pkg/calc/parser/parser_test.go @@ -63,6 +63,61 @@ func TestParserExpressions(t *testing.T) { exprs: []string{"a = 2.8", "b = 4.5", "c = √(a*a + b*b)", "c"}, want: 5.3, }, + {name: "PI", exprs: []string{"PI"}, want: math.Pi}, + {name: "E", exprs: []string{"E"}, want: math.E}, + {name: "LN2", exprs: []string{"LN2"}, want: math.Ln2}, + {name: "PHI", exprs: []string{"PHI"}, want: math.Phi}, + {name: "SQRT2 squared", exprs: []string{"SQRT2 * SQRT2"}, want: 2}, + { + name: "basic modulo", + exprs: []string{"10 % 3"}, + want: 1, + }, + { + name: "another basic modulo", + exprs: []string{"17 % 5"}, + want: 2, + }, + { + name: "exact division", + exprs: []string{"15 % 5"}, + want: 0, + }, + { + name: "float modulo", + exprs: []string{"7.5 % 2"}, + want: 1.5, + }, + { + name: "negative dividend, floor division", + exprs: []string{"-10 % 3"}, + want: 2, + }, + { + name: "negative divisor, floor division", + exprs: []string{"10 % -3"}, + want: -2, + }, + { + name: "both negative, floor division", + exprs: []string{"-10 % -3"}, + want: -1, + }, + { + name: "larger modulo", + exprs: []string{"100 % 7"}, + want: 2, + }, + { + name: "modulo with precedence", + exprs: []string{"20 % 6 + 2"}, + want: 4, + }, + { + name: "modulo with multiplication", + exprs: []string{"5 * 3 % 7"}, + want: 1, // (5 * 3) % 7 = 15 % 7 = 1 + }, } for _, tt := range tests { @@ -144,38 +199,6 @@ func TestEvalUndefinedIdentifier(t *testing.T) { } } -func TestParserTranscendentalConstants(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - expr string - want float64 - }{ - {name: "PI", expr: "PI", want: math.Pi}, - {name: "E", expr: "E", want: math.E}, - {name: "LN2", expr: "LN2", want: math.Ln2}, - {name: "PHI", expr: "PHI", want: math.Phi}, - {name: "SQRT2 squared", expr: "SQRT2 * SQRT2", want: 2}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - env := NewEnv() - val, err := parseAndEval(t, tt.expr, env) - if err != nil { - t.Fatalf("parse/eval %q: %v", tt.expr, err) - } - got := realToFloat(t, val) - if diff := math.Abs(got - tt.want); diff > 1e-9 { - t.Fatalf("result mismatch: got %v, want %v (diff=%v)", got, tt.want, diff) - } - }) - } -} - func TestParserTranscendentalConstantsImmutable(t *testing.T) { t.Parallel() @@ -197,6 +220,19 @@ func TestParserTranscendentalConstantsImmutable(t *testing.T) { } } +func TestModuloByZero(t *testing.T) { + t.Parallel() + + env := NewEnv() + _, err := parseAndEval(t, "10 % 0", env) + if err == nil { + t.Fatalf("expected modulo by zero error") + } + if !strings.Contains(err.Error(), "modulo by zero") { + t.Fatalf("unexpected error: %v", err) + } +} + func parseAndEval(t *testing.T, expr string, env *Env) (*unified.Real, error) { t.Helper() p := New("test", expr) diff --git a/pkg/calc/parser/tree.go b/pkg/calc/parser/tree.go index d9cfc07..4dfa11e 100644 --- a/pkg/calc/parser/tree.go +++ b/pkg/calc/parser/tree.go @@ -2,6 +2,7 @@ package parser import ( "fmt" + "math/big" "github.com/ripta/reals/pkg/constructive" "github.com/ripta/reals/pkg/rational" @@ -10,6 +11,8 @@ import ( "github.com/ripta/rt/pkg/calc/tokens" ) +const precision = -100 + type Node interface { Eval(*Env) (*unified.Real, error) } @@ -109,6 +112,12 @@ func (n *BinaryNode) Eval(env *Env) (*unified.Real, error) { } return l.Divide(r), nil + case tokens.OP_PERCENT: + if r.IsZero() { + return nil, fmt.Errorf("modulo by zero") + } + return modulo(l, r) + default: return nil, fmt.Errorf("unknown operator") } @@ -174,3 +183,34 @@ func (n *AssignNode) Eval(env *Env) (*unified.Real, error) { } return val, nil } + +// modulo computes a % b = a - b * floor(a/b) for real numbers +func modulo(a, b *unified.Real) (*unified.Real, error) { + scale := new(big.Int).Exp(big.NewInt(2), big.NewInt(-precision), nil) + + aApproxInt := constructive.Approximate(a.Constructive(), precision) + if aApproxInt == nil { + return nil, fmt.Errorf("failed to approximate dividend for modulo") + } + aApproxRat := new(big.Rat).SetFrac(aApproxInt, scale) + + bApproxInt := constructive.Approximate(b.Constructive(), precision) + if bApproxInt == nil { + return nil, fmt.Errorf("failed to approximate divisor for modulo") + } + bApproxRat := new(big.Rat).SetFrac(bApproxInt, scale) + + quotientRat := new(big.Rat).Quo(aApproxRat, bApproxRat) + + floor := new(big.Int).Quo(quotientRat.Num(), quotientRat.Denom()) + + remainder := new(big.Int).Rem(quotientRat.Num(), quotientRat.Denom()) + if quotientRat.Sign() < 0 && remainder.Sign() != 0 { + floor.Sub(floor, big.NewInt(1)) + } + + floorRat := new(big.Rat).SetInt(floor) + floorReal := unified.New(constructive.One(), rational.FromRational(floorRat)) + + return a.Subtract(b.Multiply(floorReal)), nil +} From 684db066acf356b508581b68cef8746d94fc90c7 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Fri, 28 Nov 2025 20:44:46 -0800 Subject: [PATCH 17/37] pkg/calc: implement shift left and shift right operations --- pkg/calc/lexer/lex_expression.go | 16 ++++ pkg/calc/lexer/lexer_test.go | 60 ++++++++++++++ pkg/calc/parser/parser.go | 2 +- pkg/calc/parser/parser_test.go | 138 ++++++++++++++++++++++++++++++- pkg/calc/parser/tree.go | 43 ++++++++++ pkg/calc/tokens/tokens.go | 4 + 6 files changed, 261 insertions(+), 2 deletions(-) diff --git a/pkg/calc/lexer/lex_expression.go b/pkg/calc/lexer/lex_expression.go index 751f4e5..2d11567 100644 --- a/pkg/calc/lexer/lex_expression.go +++ b/pkg/calc/lexer/lex_expression.go @@ -52,6 +52,22 @@ func lexExpression(l *L) lexingState { l.Emit(tokens.OP_PERCENT) return lexExpression + case r == '<': + if l.Peek() == '<' { + l.Next() + l.Emit(tokens.OP_SHL) + return lexExpression + } + return l.Errorf("unexpected token %q", string(r)) + + case r == '>': + if l.Peek() == '>' { + l.Next() + l.Emit(tokens.OP_SHR) + return lexExpression + } + return l.Errorf("unexpected token %q", string(r)) + case r == '√': l.Emit(tokens.OP_ROOT) return lexExpression diff --git a/pkg/calc/lexer/lexer_test.go b/pkg/calc/lexer/lexer_test.go index 7d6d4cf..6892168 100644 --- a/pkg/calc/lexer/lexer_test.go +++ b/pkg/calc/lexer/lexer_test.go @@ -111,6 +111,66 @@ var tokenTests = []tokenTest{ {Type: tokens.LIT_STRING, Value: "`/tmp/foo`", Col: 6}, }, }, + { + name: "left shift operator", + input: "4<<2", + want: []tokenExpectation{ + {Type: tokens.LIT_INT, Value: "4", Col: 1}, + {Type: tokens.OP_SHL, Value: "<<", Col: 2}, + {Type: tokens.LIT_INT, Value: "2", Col: 4}, + }, + }, + { + name: "right shift operator", + input: "16>>3", + want: []tokenExpectation{ + {Type: tokens.LIT_INT, Value: "16", Col: 1}, + {Type: tokens.OP_SHR, Value: ">>", Col: 3}, + {Type: tokens.LIT_INT, Value: "3", Col: 5}, + }, + }, + { + name: "shift operators with whitespace", + input: "8 << 1", + want: []tokenExpectation{ + {Type: tokens.LIT_INT, Value: "8", Col: 1}, + {Type: tokens.WHITESPACE, Value: " ", Col: 2}, + {Type: tokens.OP_SHL, Value: "<<", Col: 3}, + {Type: tokens.WHITESPACE, Value: " ", Col: 5}, + {Type: tokens.LIT_INT, Value: "1", Col: 6}, + }, + }, + { + name: "mixed shift operators", + input: "32>>2<<1", + want: []tokenExpectation{ + {Type: tokens.LIT_INT, Value: "32", Col: 1}, + {Type: tokens.OP_SHR, Value: ">>", Col: 3}, + {Type: tokens.LIT_INT, Value: "2", Col: 5}, + {Type: tokens.OP_SHL, Value: "<<", Col: 6}, + {Type: tokens.LIT_INT, Value: "1", Col: 8}, + }, + }, + { + name: "single less-than is illegal", + input: "4 < 2", + want: []tokenExpectation{ + {Type: tokens.LIT_INT, Value: "4", Col: 1}, + {Type: tokens.WHITESPACE, Value: " ", Col: 2}, + {Type: tokens.ILLEGAL, Value: "<", Col: 3}, + }, + wantErr: "unexpected token", + }, + { + name: "single greater-than is illegal", + input: "8 > 2", + want: []tokenExpectation{ + {Type: tokens.LIT_INT, Value: "8", Col: 1}, + {Type: tokens.WHITESPACE, Value: " ", Col: 2}, + {Type: tokens.ILLEGAL, Value: ">", Col: 3}, + }, + wantErr: "unexpected token", + }, } func TestLexerTokens(t *testing.T) { diff --git a/pkg/calc/parser/parser.go b/pkg/calc/parser/parser.go index c5ef153..7a2703a 100644 --- a/pkg/calc/parser/parser.go +++ b/pkg/calc/parser/parser.go @@ -151,7 +151,7 @@ func (p *P) parseMultiplicative() (Node, error) { if p.err != nil { return nil, p.err } - if tok.Type != tokens.OP_STAR && tok.Type != tokens.OP_SLASH && tok.Type != tokens.OP_PERCENT { + if tok.Type != tokens.OP_STAR && tok.Type != tokens.OP_SLASH && tok.Type != tokens.OP_PERCENT && tok.Type != tokens.OP_SHL && tok.Type != tokens.OP_SHR { break } p.next() diff --git a/pkg/calc/parser/parser_test.go b/pkg/calc/parser/parser_test.go index 7df4685..746b701 100644 --- a/pkg/calc/parser/parser_test.go +++ b/pkg/calc/parser/parser_test.go @@ -116,7 +116,97 @@ func TestParserExpressions(t *testing.T) { { name: "modulo with multiplication", exprs: []string{"5 * 3 % 7"}, - want: 1, // (5 * 3) % 7 = 15 % 7 = 1 + want: 1, + }, + { + name: "basic left shift", + exprs: []string{"4 << 2"}, + want: 16, + }, + { + name: "basic right shift", + exprs: []string{"16 >> 2"}, + want: 4, + }, + { + name: "left shift by zero", + exprs: []string{"7 << 0"}, + want: 7, + }, + { + name: "right shift by zero", + exprs: []string{"7 >> 0"}, + want: 7, + }, + { + name: "shift with precedence same as multiplication", + exprs: []string{"2 + 4 << 1"}, + want: 10, + }, + { + name: "shift left associativity", + exprs: []string{"64 >> 2 >> 1"}, + want: 8, + }, + { + name: "mixed shift and multiplication", + exprs: []string{"2 * 3 << 2"}, + want: 24, + }, + { + name: "mixed shift and division", + exprs: []string{"32 >> 1 / 2"}, + want: 8, + }, + { + name: "shift with parentheses", + exprs: []string{"(1 + 1) << 3"}, + want: 16, + }, + { + name: "large left shift", + exprs: []string{"1 << 20"}, + want: 1048576, + }, + { + name: "large right shift", + exprs: []string{"1048576 >> 18"}, + want: 4, + }, + { + name: "non-integer first operand left shift", + exprs: []string{"3.5 << 2"}, + want: 14, + }, + { + name: "non-integer first operand right shift", + exprs: []string{"20 >> 4.0"}, + want: 1.25, + }, + { + name: "left shift non-integer left operand (sqrt)", + exprs: []string{"√2 << 1"}, + want: math.Sqrt2 * 2, + }, + { + name: "left shift non-integer left operand (transcendental)", + exprs: []string{"PI << 3"}, + want: math.Pi * 8, + }, + { + name: "shift with assignment", + exprs: []string{"a = 8", "a << 2"}, + want: 32, + }, + { + name: "shift both directions", + exprs: []string{"5 << 4 >> 2"}, + want: 20, + }, + { + name: "shift with modulo", + exprs: []string{"100 >> 2 % 7"}, + want: 4, }, } @@ -233,6 +323,52 @@ func TestModuloByZero(t *testing.T) { } } +func TestShiftErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expr string + wantErr string + }{ + { + name: "shift by non-integer (decimal)", + expr: "8 << 2.5", + wantErr: "shift count must be an integer", + }, + { + name: "shift by non-integer (sqrt)", + expr: "16 >> √2", + wantErr: "shift count must be an integer", + }, + { + name: "shift by transcendental constant", + expr: "4 << PI", + wantErr: "shift count must be an integer", + }, + { + name: "shift by expression result that is non-integer", + expr: "8 << (5 / 2)", + wantErr: "shift count must be an integer", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + env := NewEnv() + _, err := parseAndEval(t, tt.expr, env) + if err == nil { + t.Fatalf("expected error containing %q", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error mismatch: got %v want substring %q", err, tt.wantErr) + } + }) + } +} + func parseAndEval(t *testing.T, expr string, env *Env) (*unified.Real, error) { t.Helper() p := New("test", expr) diff --git a/pkg/calc/parser/tree.go b/pkg/calc/parser/tree.go index 4dfa11e..52a4554 100644 --- a/pkg/calc/parser/tree.go +++ b/pkg/calc/parser/tree.go @@ -118,6 +118,20 @@ func (n *BinaryNode) Eval(env *Env) (*unified.Real, error) { } return modulo(l, r) + case tokens.OP_SHL: + shiftCount, err := extractInteger(r, n.Op) + if err != nil { + return nil, err + } + return l.ShiftLeft(shiftCount), nil + + case tokens.OP_SHR: + shiftCount, err := extractInteger(r, n.Op) + if err != nil { + return nil, err + } + return l.ShiftRight(shiftCount), nil + default: return nil, fmt.Errorf("unknown operator") } @@ -214,3 +228,32 @@ func modulo(a, b *unified.Real) (*unified.Real, error) { return a.Subtract(b.Multiply(floorReal)), nil } + +// extractInteger validates that a Real number is an integer and extracts it as an int. +// Returns an error if the number is not an integer or is out of range. +func extractInteger(r *unified.Real, op tokens.Token) (int, error) { + scale := new(big.Int).Exp(big.NewInt(2), big.NewInt(-precision), nil) + + approxInt := constructive.Approximate(r.Constructive(), precision) + if approxInt == nil { + return 0, fmt.Errorf("%s: failed to approximate shift count", op.Pos) + } + + approxRat := new(big.Rat).SetFrac(approxInt, scale) + + if approxRat.Denom().Cmp(big.NewInt(1)) != 0 { + return 0, fmt.Errorf("%s: shift count must be an integer, got non-integer value", op.Pos) + } + + num := approxRat.Num() + if !num.IsInt64() { + return 0, fmt.Errorf("%s: shift count out of range", op.Pos) + } + + i64 := num.Int64() + if i64 > int64(int(^uint(0)>>1)) || i64 < int64(-int(^uint(0)>>1)-1) { + return 0, fmt.Errorf("%s: shift count out of range", op.Pos) + } + + return int(i64), nil +} diff --git a/pkg/calc/tokens/tokens.go b/pkg/calc/tokens/tokens.go index c6f223c..4211f0e 100644 --- a/pkg/calc/tokens/tokens.go +++ b/pkg/calc/tokens/tokens.go @@ -46,6 +46,8 @@ const ( OP_SLASH // Infix division (/) OP_PERCENT // Infix modulo (%) OP_ROOT // Root operator (√) + OP_SHL // Left shift (<<) + OP_SHR // Right shift (>>) LPAREN // ( RPAREN // ) @@ -70,6 +72,8 @@ var tokenNames = map[TokenType]string{ OP_SLASH: "OP_SLASH", OP_PERCENT: "OP_PERCENT", OP_ROOT: "OP_ROOT", + OP_SHL: "OP_SHL", + OP_SHR: "OP_SHR", LPAREN: "LPAREN", RPAREN: "RPAREN", From 06dc3a7a8a24f6d21a01360c3f14faa52b89994e Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Fri, 28 Nov 2025 20:49:56 -0800 Subject: [PATCH 18/37] =?UTF-8?q?pkg/calc/parser:=20document=20the=20messy?= =?UTF-8?q?=20implementation=20of=20modulo=20and=20shift=20operations=20?= =?UTF-8?q?=F0=9F=98=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/calc/parser/tree.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/pkg/calc/parser/tree.go b/pkg/calc/parser/tree.go index 52a4554..e8ea34a 100644 --- a/pkg/calc/parser/tree.go +++ b/pkg/calc/parser/tree.go @@ -200,60 +200,66 @@ func (n *AssignNode) Eval(env *Env) (*unified.Real, error) { // modulo computes a % b = a - b * floor(a/b) for real numbers func modulo(a, b *unified.Real) (*unified.Real, error) { + // scale = 2^(-precision) scale := new(big.Int).Exp(big.NewInt(2), big.NewInt(-precision), nil) + // Approximate a aApproxInt := constructive.Approximate(a.Constructive(), precision) if aApproxInt == nil { return nil, fmt.Errorf("failed to approximate dividend for modulo") } aApproxRat := new(big.Rat).SetFrac(aApproxInt, scale) + // Approximate b bApproxInt := constructive.Approximate(b.Constructive(), precision) if bApproxInt == nil { return nil, fmt.Errorf("failed to approximate divisor for modulo") } bApproxRat := new(big.Rat).SetFrac(bApproxInt, scale) + // Compute rational quotient a/b quotientRat := new(big.Rat).Quo(aApproxRat, bApproxRat) + // Floor the quotient, i.e. (num / denom) with truncation floor := new(big.Int).Quo(quotientRat.Num(), quotientRat.Denom()) + // For negative quotients with a remainder, subtract 1 to get floor remainder := new(big.Int).Rem(quotientRat.Num(), quotientRat.Denom()) if quotientRat.Sign() < 0 && remainder.Sign() != 0 { floor.Sub(floor, big.NewInt(1)) } + // Convert floor back to unified.Real floorRat := new(big.Rat).SetInt(floor) floorReal := unified.New(constructive.One(), rational.FromRational(floorRat)) + // Compute actual modulo: a - b * floor return a.Subtract(b.Multiply(floorReal)), nil } // extractInteger validates that a Real number is an integer and extracts it as an int. // Returns an error if the number is not an integer or is out of range. func extractInteger(r *unified.Real, op tokens.Token) (int, error) { + // scale = 2^(-precision) scale := new(big.Int).Exp(big.NewInt(2), big.NewInt(-precision), nil) + // Approximate r approxInt := constructive.Approximate(r.Constructive(), precision) if approxInt == nil { return 0, fmt.Errorf("%s: failed to approximate shift count", op.Pos) } + // Check if denominator is 1 (i.e., it's an integer) approxRat := new(big.Rat).SetFrac(approxInt, scale) - if approxRat.Denom().Cmp(big.NewInt(1)) != 0 { return 0, fmt.Errorf("%s: shift count must be an integer, got non-integer value", op.Pos) } + // Convert to int, checking for overflow num := approxRat.Num() if !num.IsInt64() { return 0, fmt.Errorf("%s: shift count out of range", op.Pos) } - i64 := num.Int64() - if i64 > int64(int(^uint(0)>>1)) || i64 < int64(-int(^uint(0)>>1)-1) { - return 0, fmt.Errorf("%s: shift count out of range", op.Pos) - } - - return int(i64), nil + return int(num.Int64()), nil } From 6ac50750fcb3d9d6c76a327108e3dc480de074a9 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Fri, 28 Nov 2025 23:40:51 -0800 Subject: [PATCH 19/37] pkg/calc: initial support for exponentiation --- pkg/calc/lexer/lex_expression.go | 5 + pkg/calc/lexer/lexer_test.go | 31 ++++++ pkg/calc/parser/parser.go | 34 ++++++- pkg/calc/parser/parser_test.go | 166 +++++++++++++++++++++++++++++++ pkg/calc/parser/tree.go | 83 ++++++++++++++++ pkg/calc/tokens/tokens.go | 2 + 6 files changed, 319 insertions(+), 2 deletions(-) diff --git a/pkg/calc/lexer/lex_expression.go b/pkg/calc/lexer/lex_expression.go index 2d11567..2dd392d 100644 --- a/pkg/calc/lexer/lex_expression.go +++ b/pkg/calc/lexer/lex_expression.go @@ -41,6 +41,11 @@ func lexExpression(l *L) lexingState { return lexExpression case r == '*': + if l.Peek() == '*' { + l.Next() + l.Emit(tokens.OP_POW) + return lexExpression + } l.Emit(tokens.OP_STAR) return lexExpression diff --git a/pkg/calc/lexer/lexer_test.go b/pkg/calc/lexer/lexer_test.go index 6892168..8db2b74 100644 --- a/pkg/calc/lexer/lexer_test.go +++ b/pkg/calc/lexer/lexer_test.go @@ -171,6 +171,37 @@ var tokenTests = []tokenTest{ }, wantErr: "unexpected token", }, + { + name: "exponentiation operator", + input: "2**3", + want: []tokenExpectation{ + {Type: tokens.LIT_INT, Value: "2", Col: 1}, + {Type: tokens.OP_POW, Value: "**", Col: 2}, + {Type: tokens.LIT_INT, Value: "3", Col: 4}, + }, + }, + { + name: "exponentiation with whitespace", + input: "5 ** 2", + want: []tokenExpectation{ + {Type: tokens.LIT_INT, Value: "5", Col: 1}, + {Type: tokens.WHITESPACE, Value: " ", Col: 2}, + {Type: tokens.OP_POW, Value: "**", Col: 3}, + {Type: tokens.WHITESPACE, Value: " ", Col: 5}, + {Type: tokens.LIT_INT, Value: "2", Col: 6}, + }, + }, + { + name: "multiplication vs exponentiation", + input: "2*3**4", + want: []tokenExpectation{ + {Type: tokens.LIT_INT, Value: "2", Col: 1}, + {Type: tokens.OP_STAR, Value: "*", Col: 2}, + {Type: tokens.LIT_INT, Value: "3", Col: 3}, + {Type: tokens.OP_POW, Value: "**", Col: 4}, + {Type: tokens.LIT_INT, Value: "4", Col: 6}, + }, + }, } func TestLexerTokens(t *testing.T) { diff --git a/pkg/calc/parser/parser.go b/pkg/calc/parser/parser.go index 7a2703a..33f49bb 100644 --- a/pkg/calc/parser/parser.go +++ b/pkg/calc/parser/parser.go @@ -141,7 +141,7 @@ func (p *P) parseMultiplicative() (Node, error) { if p.err != nil { return nil, p.err } - node, err := p.parseUnary() + node, err := p.parseExponential() if err != nil { return nil, err } @@ -155,7 +155,7 @@ func (p *P) parseMultiplicative() (Node, error) { break } p.next() - right, err := p.parseUnary() + right, err := p.parseExponential() if err != nil { return nil, err } @@ -169,6 +169,36 @@ func (p *P) parseMultiplicative() (Node, error) { return node, nil } +func (p *P) parseExponential() (Node, error) { + if p.err != nil { + return nil, p.err + } + node, err := p.parseUnary() + if err != nil { + return nil, err + } + + tok := p.peek() + if p.err != nil { + return nil, p.err + } + if tok.Type == tokens.OP_POW { + p.next() + // Right-associative: parse the right side recursively + right, err := p.parseExponential() + if err != nil { + return nil, err + } + return &BinaryNode{ + Op: tok, + Left: node, + Right: right, + }, nil + } + + return node, nil +} + func (p *P) parseUnary() (Node, error) { if p.err != nil { return nil, p.err diff --git a/pkg/calc/parser/parser_test.go b/pkg/calc/parser/parser_test.go index 746b701..ef3315f 100644 --- a/pkg/calc/parser/parser_test.go +++ b/pkg/calc/parser/parser_test.go @@ -208,6 +208,111 @@ func TestParserExpressions(t *testing.T) { exprs: []string{"100 >> 2 % 7"}, want: 4, }, + { + name: "basic exponentiation", + exprs: []string{"2 ** 3"}, + want: 8, + }, + { + name: "exponentiation to zero", + exprs: []string{"5 ** 0"}, + want: 1, + }, + { + name: "exponentiation to one", + exprs: []string{"7 ** 1"}, + want: 7, + }, + { + name: "negative exponent", + exprs: []string{"2 ** -1"}, + want: 0.5, + }, + { + name: "fractional exponent (square root)", + exprs: []string{"4 ** 0.5"}, + want: 2, + }, + { + name: "fractional exponent (cube root)", + exprs: []string{"8 ** (1/3)"}, + want: 2, + }, + { + name: "right associativity", + exprs: []string{"2 ** 3 ** 2"}, + want: 512, + }, + { + name: "precedence with addition", + exprs: []string{"2 + 3 ** 2"}, + want: 11, + }, + { + name: "precedence with multiplication", + exprs: []string{"2 * 3 ** 2"}, + want: 18, + }, + { + name: "precedence with division", + exprs: []string{"18 / 3 ** 2"}, + want: 2, + }, + { + name: "exponentiation with parentheses", + exprs: []string{"(2 + 3) ** 2"}, + want: 25, + }, + { + name: "exponentiation with unary minus in exponent", + exprs: []string{"4 ** -2"}, + want: 0.0625, + }, + { + name: "complex exponentiation expression", + exprs: []string{"a = 3", "b = 2", "a ** b + 1"}, + want: 10, + }, + { + name: "exponentiation with square root", + exprs: []string{"√4 ** 2"}, + want: 4, + }, + { + name: "large exponent", + exprs: []string{"2 ** 10"}, + want: 1024, + }, + { + name: "zero to positive power", + exprs: []string{"0 ** 5"}, + want: 0, + }, + { + name: "one to any power", + exprs: []string{"1 ** 100"}, + want: 1, + }, + { + name: "negative base to even integer power", + exprs: []string{"-2 ** 2"}, + want: 4, + }, + { + name: "negative base to odd integer power", + exprs: []string{"-2 ** 3"}, + want: -8, + }, + { + name: "negative base to zero power", + exprs: []string{"-5 ** 0"}, + want: 1, + }, + { + name: "negative base to negative integer power", + exprs: []string{"-2 ** -2"}, + want: 0.25, + }, } for _, tt := range tests { @@ -369,6 +474,67 @@ func TestShiftErrors(t *testing.T) { } } +func TestExponentiationErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expr string + wantErr string + }{ + { + name: "zero to negative power", + expr: "0 ** -5", + wantErr: "zero to negative power is undefined", + }, + { + name: "zero to negative fractional power", + expr: "0 ** -0.5", + wantErr: "zero to negative power is undefined", + }, + { + name: "negative base to fractional power", + expr: "-4 ** 0.5", + wantErr: "negative base to non-integer power is non-real", + }, + { + name: "negative base to decimal power", + expr: "-2 ** 2.5", + wantErr: "negative base to non-integer power is non-real", + }, + { + name: "negative base to irrational power", + expr: "-3 ** √2", + wantErr: "negative base to non-integer power is non-real", + }, + { + name: "negative base to transcendental power", + expr: "-2 ** PI", + wantErr: "negative base to non-integer power is non-real", + }, + { + name: "negative base via unary minus to fractional power", + expr: "-2 ** 0.5", + wantErr: "negative base to non-integer power is non-real", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + env := NewEnv() + _, err := parseAndEval(t, tt.expr, env) + if err == nil { + t.Fatalf("expected error containing %q", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error mismatch: got %v want substring %q", err, tt.wantErr) + } + }) + } +} + func parseAndEval(t *testing.T, expr string, env *Env) (*unified.Real, error) { t.Helper() p := New("test", expr) diff --git a/pkg/calc/parser/tree.go b/pkg/calc/parser/tree.go index e8ea34a..6d06448 100644 --- a/pkg/calc/parser/tree.go +++ b/pkg/calc/parser/tree.go @@ -112,6 +112,9 @@ func (n *BinaryNode) Eval(env *Env) (*unified.Real, error) { } return l.Divide(r), nil + case tokens.OP_POW: + return power(l, r) + case tokens.OP_PERCENT: if r.IsZero() { return nil, fmt.Errorf("modulo by zero") @@ -263,3 +266,83 @@ func extractInteger(r *unified.Real, op tokens.Token) (int, error) { return int(num.Int64()), nil } + +func power(l, r *unified.Real) (*unified.Real, error) { + // Approximate both operands to check for special cases + scale := new(big.Int).Exp(big.NewInt(2), big.NewInt(-precision), nil) + + // Approximate left (base + lApprox := constructive.Approximate(l.Constructive(), precision) + if lApprox == nil { + return nil, fmt.Errorf("failed to approximate base") + } + lRat := new(big.Rat).SetFrac(lApprox, scale) + + // Approximate right (exponent) + rApprox := constructive.Approximate(r.Constructive(), precision) + if rApprox == nil { + return nil, fmt.Errorf("failed to approximate exponent") + } + rRat := new(big.Rat).SetFrac(rApprox, scale) + + // Case 1: 0^exponent + // When exponent is negative, the result is undefined. + // When exponent is zero or positive, the result is zero. + if l.IsZero() { + if rRat.Sign() < 0 { + return nil, fmt.Errorf("zero to negative power is undefined") + } + return unified.Zero(), nil + } + + // Case 2: negative^non-integer = complex (non-real) + // constructive.Pow uses logarithms internally, so it can't handle negative bases at all + // We must handle negative bases specially + if lRat.Sign() < 0 { + // Base is negative, check if exponent is an integer + if rRat.Denom().Cmp(big.NewInt(1)) != 0 { + return nil, fmt.Errorf("negative base to non-integer power is non-real") + } + + // For integer exponents, compute using big.Rat since we know n is an integer + result := new(big.Rat).SetInt64(1) + base := new(big.Rat).Set(lRat) + exp := rRat.Num() // We know denom is 1 + + // Handle negative exponents + if exp.Sign() < 0 { + base.Inv(base) + exp = new(big.Int).Neg(exp) + } + + // Compute base^exp using repeated multiplication + for i := new(big.Int).Set(exp); i.Sign() > 0; i.Sub(i, big.NewInt(1)) { + result.Mul(result, base) + } + + return unified.New(constructive.One(), rational.FromRational(result)), nil + } + + // Positive base: check if we can compute exactly using rationals + // If both base and exponent are rational and exponent is a positive integer, use rational arithmetic + if rRat.Denom().Cmp(big.NewInt(1)) == 0 && rRat.Sign() >= 0 { + // Exponent is a non-negative integer + // Check if base is also rational (or can be approximated as such) + // For now, let's compute using big.Rat for integer exponents + result := new(big.Rat).SetInt64(1) + base := new(big.Rat).Set(lRat) + exp := rRat.Num() + + // Compute base^exp using repeated multiplication + for i := new(big.Int).Set(exp); i.Sign() > 0; i.Sub(i, big.NewInt(1)) { + result.Mul(result, base) + } + + return unified.New(constructive.One(), rational.FromRational(result)), nil + } + + // Exponent is negative integer or non-integer: use constructive.Pow + // This handles fractional powers, irrational powers, and negative powers + cr := constructive.Pow(l.Constructive(), r.Constructive()) + return unified.New(cr, rational.One()), nil +} diff --git a/pkg/calc/tokens/tokens.go b/pkg/calc/tokens/tokens.go index 4211f0e..91c0a2b 100644 --- a/pkg/calc/tokens/tokens.go +++ b/pkg/calc/tokens/tokens.go @@ -48,6 +48,7 @@ const ( OP_ROOT // Root operator (√) OP_SHL // Left shift (<<) OP_SHR // Right shift (>>) + OP_POW // Exponentiation (**) LPAREN // ( RPAREN // ) @@ -74,6 +75,7 @@ var tokenNames = map[TokenType]string{ OP_ROOT: "OP_ROOT", OP_SHL: "OP_SHL", OP_SHR: "OP_SHR", + OP_POW: "OP_POW", LPAREN: "LPAREN", RPAREN: "RPAREN", From 0097ed0f267dc30fa5a2de41221a327c2c246fdd Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sat, 29 Nov 2025 00:55:12 -0800 Subject: [PATCH 20/37] pkg/calc: use text-formatting routines from constructive reals --- pkg/calc/calculator.go | 3 ++- pkg/calc/reals.go | 57 ------------------------------------------ 2 files changed, 2 insertions(+), 58 deletions(-) delete mode 100644 pkg/calc/reals.go diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index 689d173..71e572b 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -5,6 +5,7 @@ import ( "os" "github.com/elk-language/go-prompt" + "github.com/ripta/reals/pkg/constructive" "github.com/ripta/reals/pkg/unified" "github.com/ripta/rt/pkg/calc/parser" @@ -42,7 +43,7 @@ func (c *Calculator) DisplayError(err error) { } func (c *Calculator) DisplayResult(res *unified.Real) { - fmt.Printf("%s\n", formatReal(res)) + fmt.Printf("%s\n", constructive.Text(res.Constructive(), 300, 10)) } func (c *Calculator) REPL() { diff --git a/pkg/calc/reals.go b/pkg/calc/reals.go deleted file mode 100644 index 37eeb69..0000000 --- a/pkg/calc/reals.go +++ /dev/null @@ -1,57 +0,0 @@ -package calc - -import ( - "fmt" - "math/big" - "strings" - - "github.com/ripta/reals/pkg/constructive" - "github.com/ripta/reals/pkg/unified" -) - -const ( - displayPrecisionBits = -100 - displayDecimalDigits = 24 - precisionFailureLabel = "" -) - -func formatReal(r *unified.Real) string { - if r == nil { - return "" - } - - rat, err := approximateReal(r, displayPrecisionBits) - if err != nil { - return fmt.Sprintf("%s: %v", precisionFailureLabel, err) - } - - s := rat.FloatString(displayDecimalDigits) - if strings.Contains(s, ".") { - s = strings.TrimRight(strings.TrimRight(s, "0"), ".") - if s == "" || s == "-" { - s += "0" - } - } - return s -} - -func approximateReal(r *unified.Real, precision int) (*big.Rat, error) { - if r == nil { - return nil, fmt.Errorf("nil real") - } - if precision >= 0 { - return nil, fmt.Errorf("precision must be negative (got %d)", precision) - } - if !constructive.IsPrecisionValid(precision) { - return nil, fmt.Errorf("precision %d is out of range", precision) - } - - approx := constructive.Approximate(r.Constructive(), precision) - if approx == nil { - return nil, fmt.Errorf("approximation failed at precision %d", precision) - } - - exp := int64(-precision) - denom := new(big.Int).Exp(big.NewInt(2), big.NewInt(exp), nil) - return new(big.Rat).SetFrac(approx, denom), nil -} From ef6a3bf010da1d891bafbb7546376849589b5979 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sat, 29 Nov 2025 01:56:39 -0800 Subject: [PATCH 21/37] pkg/calc: add configurable decimal places and construction verbosity --- pkg/calc/calculator.go | 20 ++++++++++++++------ pkg/calc/command.go | 8 +++++++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index 71e572b..a379b62 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -12,8 +12,11 @@ import ( ) type Calculator struct { - ExpressionCount int - env *parser.Env + DecimalPlaces int + Verbose bool + + count int + env *parser.Env } func (c *Calculator) Evaluate(expr string) (*unified.Real, error) { @@ -25,7 +28,7 @@ func (c *Calculator) Evaluate(expr string) (*unified.Real, error) { func (c *Calculator) Execute(expr string) { defer func() { - c.ExpressionCount++ + c.count++ fmt.Println() }() @@ -39,18 +42,23 @@ func (c *Calculator) Execute(expr string) { } func (c *Calculator) DisplayError(err error) { - fmt.Fprintf(os.Stderr, "calc:%03d/ Error: %s\n", c.ExpressionCount, err) + fmt.Fprintf(os.Stderr, "calc:%03d/ Error: %s\n", c.count, err) } func (c *Calculator) DisplayResult(res *unified.Real) { - fmt.Printf("%s\n", constructive.Text(res.Constructive(), 300, 10)) + cons := res.Constructive() + + if c.Verbose { + fmt.Printf("calc:%03d/ Construction: %s\n", c.count, constructive.AsConstruction(cons)) + } + fmt.Printf("%s\n", constructive.Text(cons, c.DecimalPlaces, 10)) } func (c *Calculator) REPL() { p := prompt.New( c.Execute, prompt.WithPrefixCallback(func() string { - return fmt.Sprintf("calc:%03d> ", c.ExpressionCount) + return fmt.Sprintf("calc:%03d> ", c.count) }), prompt.WithExitChecker(func(in string, breakline bool) bool { return breakline && (in == "exit" || in == "quit") diff --git a/pkg/calc/command.go b/pkg/calc/command.go index 4110c78..87a87cd 100644 --- a/pkg/calc/command.go +++ b/pkg/calc/command.go @@ -17,7 +17,10 @@ var ErrNotTTY = errors.New("STDIN is not a TTY") // provided and STDIN is a TTY, it will start a REPL. Otherwise, the command // will return ErrNotTTY. func NewCommand() *cobra.Command { - c := &Calculator{} + c := &Calculator{ + DecimalPlaces: 30, + Verbose: false, + } cmd := &cobra.Command{ Use: "calc", Short: "Calculate expressions", @@ -47,5 +50,8 @@ func NewCommand() *cobra.Command { }, } + cmd.Flags().IntVarP(&c.DecimalPlaces, "decimal-places", "d", c.DecimalPlaces, "Number of decimal places to display") + cmd.Flags().BoolVarP(&c.Verbose, "verbose", "v", c.Verbose, "Verbose output") + return cmd } From 08c6d1f79e0569b941e64e4798bd3eae5a0151b9 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sat, 29 Nov 2025 03:03:36 -0800 Subject: [PATCH 22/37] pkg/calc: add configurable trailing zero formatting --- pkg/calc/calculator.go | 23 ++++++++++++++++++++--- pkg/calc/command.go | 2 ++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index a379b62..f62f24c 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -3,6 +3,7 @@ package calc import ( "fmt" "os" + "strings" "github.com/elk-language/go-prompt" "github.com/ripta/reals/pkg/constructive" @@ -12,8 +13,10 @@ import ( ) type Calculator struct { - DecimalPlaces int - Verbose bool + DecimalPlaces int + KeepTrailingZeros bool + UnderscoreZeros bool + Verbose bool count int env *parser.Env @@ -51,7 +54,21 @@ func (c *Calculator) DisplayResult(res *unified.Real) { if c.Verbose { fmt.Printf("calc:%03d/ Construction: %s\n", c.count, constructive.AsConstruction(cons)) } - fmt.Printf("%s\n", constructive.Text(cons, c.DecimalPlaces, 10)) + + // Format the output to the specified number of decimal places. Insert an + // underscore after all zeroes for readability. + t := constructive.Text(cons, c.DecimalPlaces, 10) + if strings.Contains(t, ".") { + if t2 := strings.TrimRight(t, "0"); len(t2) < len(t) { + if c.UnderscoreZeros { + t = t2 + "_" + strings.Repeat("0", len(t)-len(t2)) + } else if !c.KeepTrailingZeros { + t = strings.TrimRight(t2, ".") + } + } + } + + fmt.Printf("%s\n", t) } func (c *Calculator) REPL() { diff --git a/pkg/calc/command.go b/pkg/calc/command.go index 87a87cd..897dcbc 100644 --- a/pkg/calc/command.go +++ b/pkg/calc/command.go @@ -51,6 +51,8 @@ func NewCommand() *cobra.Command { } cmd.Flags().IntVarP(&c.DecimalPlaces, "decimal-places", "d", c.DecimalPlaces, "Number of decimal places to display") + cmd.Flags().BoolVarP(&c.KeepTrailingZeros, "keep-trailing-zeros", "k", c.KeepTrailingZeros, "Keep trailing zeros in decimal output") + cmd.Flags().BoolVarP(&c.UnderscoreZeros, "underscore-zeros", "u", c.UnderscoreZeros, "Insert underscore before trailing zeros, implies --keep-trailing-zeros") cmd.Flags().BoolVarP(&c.Verbose, "verbose", "v", c.Verbose, "Verbose output") return cmd From 69b000776ee22c1e274039620d9937f3526c4eb2 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sat, 29 Nov 2025 03:49:48 -0800 Subject: [PATCH 23/37] pkg/calc: add test coverage on operations on fractions --- pkg/calc/lexer/lexer_test.go | 37 ++++++++++++++++++++++++++++++++++ pkg/calc/parser/parser_test.go | 15 ++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/pkg/calc/lexer/lexer_test.go b/pkg/calc/lexer/lexer_test.go index 8db2b74..e442f73 100644 --- a/pkg/calc/lexer/lexer_test.go +++ b/pkg/calc/lexer/lexer_test.go @@ -202,6 +202,43 @@ var tokenTests = []tokenTest{ {Type: tokens.LIT_INT, Value: "4", Col: 6}, }, }, + { + name: "fraction", + input: "1/2", + want: []tokenExpectation{ + {Type: tokens.LIT_INT, Value: "1", Col: 1}, + {Type: tokens.OP_SLASH, Value: "/", Col: 2}, + {Type: tokens.LIT_INT, Value: "2", Col: 3}, + }, + }, + { + name: "fraction additions", + input: "1/2 + 3/4", + want: []tokenExpectation{ + {Type: tokens.LIT_INT, Value: "1", Col: 1}, + {Type: tokens.OP_SLASH, Value: "/", Col: 2}, + {Type: tokens.LIT_INT, Value: "2", Col: 3}, + {Type: tokens.WHITESPACE, Value: " ", Col: 4}, + {Type: tokens.OP_PLUS, Value: "+", Col: 5}, + {Type: tokens.WHITESPACE, Value: " ", Col: 6}, + {Type: tokens.LIT_INT, Value: "3", Col: 7}, + {Type: tokens.OP_SLASH, Value: "/", Col: 8}, + {Type: tokens.LIT_INT, Value: "4", Col: 9}, + }, + }, + { + name: "fraction additions with integer", + input: "1/2 + 3", + want: []tokenExpectation{ + {Type: tokens.LIT_INT, Value: "1", Col: 1}, + {Type: tokens.OP_SLASH, Value: "/", Col: 2}, + {Type: tokens.LIT_INT, Value: "2", Col: 3}, + {Type: tokens.WHITESPACE, Value: " ", Col: 4}, + {Type: tokens.OP_PLUS, Value: "+", Col: 5}, + {Type: tokens.WHITESPACE, Value: " ", Col: 6}, + {Type: tokens.LIT_INT, Value: "3", Col: 7}, + }, + }, } func TestLexerTokens(t *testing.T) { diff --git a/pkg/calc/parser/parser_test.go b/pkg/calc/parser/parser_test.go index ef3315f..5db87bf 100644 --- a/pkg/calc/parser/parser_test.go +++ b/pkg/calc/parser/parser_test.go @@ -238,6 +238,21 @@ func TestParserExpressions(t *testing.T) { exprs: []string{"8 ** (1/3)"}, want: 2, }, + { + name: "fractional", + exprs: []string{"1/2"}, + want: 0.5, + }, + { + name: "fractional additions", + exprs: []string{"1/3 + 1/6"}, + want: 0.5, + }, + { + name: "fractional multiplication", + exprs: []string{"2/3 * 3/4"}, + want: 0.5, + }, { name: "right associativity", exprs: []string{"2 ** 3 ** 2"}, From 381966f479a7a9b872fd3cfe488fdb5be9ff3bfb Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sat, 29 Nov 2025 14:03:11 -0800 Subject: [PATCH 24/37] pkg/calc: use calculator decimal display digit to determine evaluation precision --- pkg/calc/calculator.go | 4 +-- pkg/calc/evaluate.go | 15 +++++------ pkg/calc/parser/tree.go | 55 ++++++++++++++++++++++++++++++----------- 3 files changed, 51 insertions(+), 23 deletions(-) diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index f62f24c..f820719 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -24,9 +24,9 @@ type Calculator struct { func (c *Calculator) Evaluate(expr string) (*unified.Real, error) { if c.env == nil { - c.env = parser.NewEnv() + c.env = parser.NewEnvWithDecimalPlaces(c.DecimalPlaces) } - return evaluate(expr, c.env) + return Evaluate(expr, c.env) } func (c *Calculator) Execute(expr string) { diff --git a/pkg/calc/evaluate.go b/pkg/calc/evaluate.go index 29c88ba..da87170 100644 --- a/pkg/calc/evaluate.go +++ b/pkg/calc/evaluate.go @@ -1,6 +1,7 @@ package calc import ( + "errors" "strings" "github.com/ripta/reals/pkg/unified" @@ -8,19 +9,19 @@ import ( "github.com/ripta/rt/pkg/calc/parser" ) -func Evaluate(expr string) (*unified.Real, error) { - return evaluate(expr, parser.NewEnv()) -} +var ErrEnvironmentMissing = errors.New("environment missing") -func evaluate(expr string, env *parser.Env) (*unified.Real, error) { +// Evaluate parses expr and evaluates it in the given environment. +func Evaluate(expr string, env *parser.Env) (*unified.Real, error) { expr = strings.TrimSpace(expr) - if env == nil { - env = parser.NewEnv() - } if expr == "" { return unified.Zero(), nil } + if env == nil { + return nil, ErrEnvironmentMissing + } + p := parser.New("(eval)", expr) node, err := p.Parse() if err != nil { diff --git a/pkg/calc/parser/tree.go b/pkg/calc/parser/tree.go index 6d06448..db2a784 100644 --- a/pkg/calc/parser/tree.go +++ b/pkg/calc/parser/tree.go @@ -11,8 +11,6 @@ import ( "github.com/ripta/rt/pkg/calc/tokens" ) -const precision = -100 - type Node interface { Eval(*Env) (*unified.Real, error) } @@ -23,12 +21,41 @@ type binding struct { } type Env struct { - vars map[string]*binding + precision int + vars map[string]*binding } +// NewEnv creates a new environment with default precision (-100). func NewEnv() *Env { return &Env{ - vars: seedConstants(), + precision: -100, + vars: seedConstants(), + } +} + +// convertDecimalPlacesToPrecision computes the binary precision needed to +// represent the specified number of decimal places. +func convertDecimalPlacesToPrecision(decimalPlaces int) int { + if decimalPlaces <= 0 { + return 0 + } + + // log2(10) ~ 3.32193 + return -(decimalPlaces*332193 + 99999) / 100000 +} + +// NewEnvWithDecimalPlaces creates a new environment with the specified number +// of decimal places of precision. +func NewEnvWithDecimalPlaces(decimalPlaces int) *Env { + return NewEnvWithPrecision(convertDecimalPlacesToPrecision(decimalPlaces)) +} + +// NewEnvWithPrecision creates a new environment with the specified binary +// precision. +func NewEnvWithPrecision(precision int) *Env { + return &Env{ + precision: precision, + vars: seedConstants(), } } @@ -113,23 +140,23 @@ func (n *BinaryNode) Eval(env *Env) (*unified.Real, error) { return l.Divide(r), nil case tokens.OP_POW: - return power(l, r) + return power(l, r, env.precision) case tokens.OP_PERCENT: if r.IsZero() { return nil, fmt.Errorf("modulo by zero") } - return modulo(l, r) + return modulo(l, r, env.precision) case tokens.OP_SHL: - shiftCount, err := extractInteger(r, n.Op) + shiftCount, err := extractInteger(r, n.Op, env.precision) if err != nil { return nil, err } return l.ShiftLeft(shiftCount), nil case tokens.OP_SHR: - shiftCount, err := extractInteger(r, n.Op) + shiftCount, err := extractInteger(r, n.Op, env.precision) if err != nil { return nil, err } @@ -202,9 +229,9 @@ func (n *AssignNode) Eval(env *Env) (*unified.Real, error) { } // modulo computes a % b = a - b * floor(a/b) for real numbers -func modulo(a, b *unified.Real) (*unified.Real, error) { +func modulo(a, b *unified.Real, precision int) (*unified.Real, error) { // scale = 2^(-precision) - scale := new(big.Int).Exp(big.NewInt(2), big.NewInt(-precision), nil) + scale := new(big.Int).Exp(big.NewInt(2), big.NewInt(int64(-precision)), nil) // Approximate a aApproxInt := constructive.Approximate(a.Constructive(), precision) @@ -242,9 +269,9 @@ func modulo(a, b *unified.Real) (*unified.Real, error) { // extractInteger validates that a Real number is an integer and extracts it as an int. // Returns an error if the number is not an integer or is out of range. -func extractInteger(r *unified.Real, op tokens.Token) (int, error) { +func extractInteger(r *unified.Real, op tokens.Token, precision int) (int, error) { // scale = 2^(-precision) - scale := new(big.Int).Exp(big.NewInt(2), big.NewInt(-precision), nil) + scale := new(big.Int).Exp(big.NewInt(2), big.NewInt(int64(-precision)), nil) // Approximate r approxInt := constructive.Approximate(r.Constructive(), precision) @@ -267,9 +294,9 @@ func extractInteger(r *unified.Real, op tokens.Token) (int, error) { return int(num.Int64()), nil } -func power(l, r *unified.Real) (*unified.Real, error) { +func power(l, r *unified.Real, precision int) (*unified.Real, error) { // Approximate both operands to check for special cases - scale := new(big.Int).Exp(big.NewInt(2), big.NewInt(-precision), nil) + scale := new(big.Int).Exp(big.NewInt(2), big.NewInt(int64(-precision)), nil) // Approximate left (base lApprox := constructive.Approximate(l.Constructive(), precision) From 42d43c52fd221e4501418d38cbcc4e3a6f779890 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sat, 29 Nov 2025 14:19:26 -0800 Subject: [PATCH 25/37] pkg/calc: provide a bit more context in unexpected token errors, and standardize the error type --- pkg/calc/lexer/lex_expression.go | 9 ++++++--- pkg/calc/parser/parser.go | 10 +++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/pkg/calc/lexer/lex_expression.go b/pkg/calc/lexer/lex_expression.go index 2dd392d..b7362d6 100644 --- a/pkg/calc/lexer/lex_expression.go +++ b/pkg/calc/lexer/lex_expression.go @@ -1,11 +1,14 @@ package lexer import ( + "errors" "unicode" "github.com/ripta/rt/pkg/calc/tokens" ) +var ErrUnexpectedToken = errors.New("unexpected token") + func lexExpression(l *L) lexingState { switch r := l.Next(); { @@ -63,7 +66,7 @@ func lexExpression(l *L) lexingState { l.Emit(tokens.OP_SHL) return lexExpression } - return l.Errorf("unexpected token %q", string(r)) + return l.Errorf("%w %q in expression, expecting another '<'", ErrUnexpectedToken, string(r)) case r == '>': if l.Peek() == '>' { @@ -71,7 +74,7 @@ func lexExpression(l *L) lexingState { l.Emit(tokens.OP_SHR) return lexExpression } - return l.Errorf("unexpected token %q", string(r)) + return l.Errorf("%w %q in expression, expecting another '>'", ErrUnexpectedToken, string(r)) case r == '√': l.Emit(tokens.OP_ROOT) @@ -90,6 +93,6 @@ func lexExpression(l *L) lexingState { return lexIdent default: - return l.Errorf("unexpected token %q", string(r)) + return l.Errorf("%w %q in expression", ErrUnexpectedToken, string(r)) } } diff --git a/pkg/calc/parser/parser.go b/pkg/calc/parser/parser.go index 33f49bb..a09dd73 100644 --- a/pkg/calc/parser/parser.go +++ b/pkg/calc/parser/parser.go @@ -1,6 +1,7 @@ package parser import ( + "errors" "fmt" "math/big" "strings" @@ -13,6 +14,8 @@ import ( "github.com/ripta/rt/pkg/calc/tokens" ) +var ErrUnexpectedToken = errors.New("unexpected token") + type P struct { lex *lexer.L fn parsingState @@ -51,19 +54,23 @@ func parseExpr(p *P) parsingState { if p.err != nil { return nil } + node, err := p.parseAssignment() if err != nil { p.err = err return nil } + tok := p.next() if p.err != nil { return nil } + if tok.Type != tokens.EOF { - p.err = p.errorf(tok, "unexpected token %s", tok.Type) + p.err = p.errorf(tok, "%w %s, expecting EOF", ErrUnexpectedToken, tok.Type) return nil } + p.root = node return nil } @@ -76,6 +83,7 @@ func (p *P) parseAssignment() (Node, error) { if p.err != nil { return nil, p.err } + left, err := p.parseAdditive() if err != nil { return nil, err From a3f0f643bcb8fe7368b32f63269d214830bfbfda Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sat, 29 Nov 2025 14:57:44 -0800 Subject: [PATCH 26/37] pkg/calc: initial implementation of tracing with strings Is this even a good idea? Probably not. --- pkg/calc/calculator.go | 6 +- pkg/calc/command.go | 1 + pkg/calc/parser/comment_test.go | 111 ++++++++++++++++++++++++++++++++ pkg/calc/parser/parser.go | 76 +++++++++++++++++++--- pkg/calc/parser/parser_test.go | 30 +++++++++ pkg/calc/parser/tree.go | 61 +++++++++++++----- 6 files changed, 261 insertions(+), 24 deletions(-) create mode 100644 pkg/calc/parser/comment_test.go diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index f820719..ef9b3b5 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -17,6 +17,7 @@ type Calculator struct { KeepTrailingZeros bool UnderscoreZeros bool Verbose bool + Trace bool count int env *parser.Env @@ -24,8 +25,11 @@ type Calculator struct { func (c *Calculator) Evaluate(expr string) (*unified.Real, error) { if c.env == nil { - c.env = parser.NewEnvWithDecimalPlaces(c.DecimalPlaces) + c.env = parser.NewEnv() } + + c.env.SetDecimalPlaces(c.DecimalPlaces) + c.env.SetTrace(c.Trace) return Evaluate(expr, c.env) } diff --git a/pkg/calc/command.go b/pkg/calc/command.go index 897dcbc..ef52448 100644 --- a/pkg/calc/command.go +++ b/pkg/calc/command.go @@ -54,6 +54,7 @@ func NewCommand() *cobra.Command { cmd.Flags().BoolVarP(&c.KeepTrailingZeros, "keep-trailing-zeros", "k", c.KeepTrailingZeros, "Keep trailing zeros in decimal output") cmd.Flags().BoolVarP(&c.UnderscoreZeros, "underscore-zeros", "u", c.UnderscoreZeros, "Insert underscore before trailing zeros, implies --keep-trailing-zeros") cmd.Flags().BoolVarP(&c.Verbose, "verbose", "v", c.Verbose, "Verbose output") + cmd.Flags().BoolVarP(&c.Trace, "trace", "t", c.Trace, "Enable trace mode to print comments during evaluation") return cmd } diff --git a/pkg/calc/parser/comment_test.go b/pkg/calc/parser/comment_test.go new file mode 100644 index 0000000..f2f718e --- /dev/null +++ b/pkg/calc/parser/comment_test.go @@ -0,0 +1,111 @@ +package parser + +import ( + "bytes" + "strings" + "testing" +) + +type commentTraceTest struct { + name string + input string + traceEnabled bool + wantResult float64 + wantTraceOutput []string +} + +var commentTraceTests = []commentTraceTest{ + { + name: "trace enabled with multiple comments", + input: `"first" 3 + "second" 4`, + traceEnabled: true, + wantResult: 7, + wantTraceOutput: []string{"# first", "# second"}, + }, + { + name: "trace disabled", + input: `"note" 3 + 4`, + traceEnabled: false, + wantResult: 7, + wantTraceOutput: nil, // expect empty output + }, + { + name: "nested comments", + input: `"outer" "inner" 5`, + traceEnabled: true, + wantResult: 5, + wantTraceOutput: []string{"# outer", "# inner"}, + }, + { + name: "unicode comment", + input: `"コメント" 42 "논평"`, + traceEnabled: true, + wantResult: 42, + wantTraceOutput: []string{"# コメント", "# 논평"}, + }, + { + name: "empty comment", + input: `"" 10`, + traceEnabled: true, + wantResult: 10, + wantTraceOutput: []string{"# "}, + }, + { + name: "raw string comment", + input: "`backtick comment` 20", + traceEnabled: true, + wantResult: 20, + wantTraceOutput: []string{"# backtick comment"}, + }, + { + name: "comment with special characters", + input: `"hello world! @#$%" 15`, + traceEnabled: true, + wantResult: 15, + wantTraceOutput: []string{"# hello world! @#$%"}, + }, +} + +func TestCommentTrace(t *testing.T) { + t.Parallel() + + for _, tt := range commentTraceTests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + env := NewEnv() + env.SetTrace(tt.traceEnabled) + env.SetTraceOutput(&buf) + + node, err := Parse("test", tt.input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + result, err := node.Eval(env) + if err != nil { + t.Fatalf("eval error: %v", err) + } + + output := buf.String() + if tt.wantTraceOutput == nil { + if output != "" { + t.Errorf("expected no trace output, got: %q", output) + } + } else { + for _, want := range tt.wantTraceOutput { + if !strings.Contains(output, want) { + t.Errorf("missing %q in trace output: %q", want, output) + } + } + } + + got := realToFloat(t, result) + if got != tt.wantResult { + t.Errorf("result mismatch: got %v, want %v", got, tt.wantResult) + } + }) + } +} diff --git a/pkg/calc/parser/parser.go b/pkg/calc/parser/parser.go index a09dd73..000c442 100644 --- a/pkg/calc/parser/parser.go +++ b/pkg/calc/parser/parser.go @@ -67,7 +67,7 @@ func parseExpr(p *P) parsingState { } if tok.Type != tokens.EOF { - p.err = p.errorf(tok, "%w %s, expecting EOF", ErrUnexpectedToken, tok.Type) + p.err = p.errorf(tok, "%s %s, expecting EOF", ErrUnexpectedToken, tok.Type) return nil } @@ -89,7 +89,22 @@ func (p *P) parseAssignment() (Node, error) { return nil, err } - ident, ok := left.(*IdentNode) + // Check if left is an identifier (possibly wrapped in comments) + var ident *IdentNode + var comments []*CommentNode + node := left + + // Unwrap any comment layers + for { + if comment, ok := node.(*CommentNode); ok { + comments = append(comments, comment) + node = comment.Expr + } else { + break + } + } + + ident, ok := node.(*IdentNode) if !ok { return left, nil } @@ -104,15 +119,42 @@ func (p *P) parseAssignment() (Node, error) { if err != nil { return nil, err } - return &AssignNode{ + assignNode := &AssignNode{ Name: ident.Name, Value: right, - }, nil + } + + // Re-wrap with comments in reverse order + var result Node = assignNode + for i := len(comments) - 1; i >= 0; i-- { + result = &CommentNode{ + Text: comments[i].Text, + Tok: comments[i].Tok, + Expr: result, + } + } + return result, nil } return left, nil } +func (p *P) parseComment() (Node, error) { + commentTok := p.next() // Consume LIT_STRING + + // Parse the expression this comment wraps + expr, err := p.parsePrimary() + if err != nil { + return nil, err + } + + return &CommentNode{ + Text: extractCommentText(commentTok), + Tok: commentTok, + Expr: expr, + }, nil +} + func (p *P) parseAdditive() (Node, error) { if p.err != nil { return nil, p.err @@ -237,31 +279,37 @@ func (p *P) parsePrimary() (Node, error) { return nil, p.err } + // Check for leading comment first + if p.peek().Type == tokens.LIT_STRING { + return p.parseComment() + } + tok := p.next() if p.err != nil { return nil, p.err } + var node Node switch tok.Type { case tokens.LIT_INT, tokens.LIT_FLOAT: val, err := p.parseNumber(tok) if err != nil { return nil, err } - return &NumberNode{Value: val}, nil + node = &NumberNode{Value: val} case tokens.IDENT: - return &IdentNode{Name: tok}, nil + node = &IdentNode{Name: tok} case tokens.LPAREN: - node, err := p.parseAssignment() + var err error + node, err = p.parseAssignment() if err != nil { return nil, err } if _, err := p.expect(tokens.RPAREN); err != nil { return nil, err } - return node, nil case tokens.EOF: return nil, p.errorf(tok, "unexpected EOF") @@ -269,6 +317,18 @@ func (p *P) parsePrimary() (Node, error) { default: return nil, p.errorf(tok, "unexpected token %s", tok.Type) } + + // Check for trailing comment + if p.peek().Type == tokens.LIT_STRING { + commentTok := p.next() + node = &CommentNode{ + Text: extractCommentText(commentTok), + Tok: commentTok, + Expr: node, + } + } + + return node, nil } func (p *P) parseNumber(tok tokens.Token) (*unified.Real, error) { diff --git a/pkg/calc/parser/parser_test.go b/pkg/calc/parser/parser_test.go index 5db87bf..b03f984 100644 --- a/pkg/calc/parser/parser_test.go +++ b/pkg/calc/parser/parser_test.go @@ -328,6 +328,36 @@ func TestParserExpressions(t *testing.T) { exprs: []string{"-2 ** -2"}, want: 0.25, }, + { + name: "leading comment", + exprs: []string{`"note" 3 + 4`}, + want: 7, + }, + { + name: "inline comments", + exprs: []string{`3 "first" + 4 "second" * 5 "third"`}, + want: 23, + }, + { + name: "raw string comment", + exprs: []string{"3 `note` + 4"}, + want: 7, + }, + { + name: "multiple leading comments", + exprs: []string{`"note1" "note2" 5`}, + want: 5, + }, + { + name: "comment in parentheses", + exprs: []string{`("note" 2 + 3) * 4`}, + want: 20, + }, + { + name: "comment in assignment", + exprs: []string{`a "assign" = 5`, `a * 2`}, + want: 10, + }, } for _, tt := range tests { diff --git a/pkg/calc/parser/tree.go b/pkg/calc/parser/tree.go index db2a784..869c5ef 100644 --- a/pkg/calc/parser/tree.go +++ b/pkg/calc/parser/tree.go @@ -2,7 +2,9 @@ package parser import ( "fmt" + "io" "math/big" + "os" "github.com/ripta/reals/pkg/constructive" "github.com/ripta/reals/pkg/rational" @@ -23,6 +25,8 @@ type binding struct { type Env struct { precision int vars map[string]*binding + trace bool + traceOut io.Writer } // NewEnv creates a new environment with default precision (-100). @@ -30,6 +34,7 @@ func NewEnv() *Env { return &Env{ precision: -100, vars: seedConstants(), + traceOut: os.Stdout, } } @@ -44,21 +49,6 @@ func convertDecimalPlacesToPrecision(decimalPlaces int) int { return -(decimalPlaces*332193 + 99999) / 100000 } -// NewEnvWithDecimalPlaces creates a new environment with the specified number -// of decimal places of precision. -func NewEnvWithDecimalPlaces(decimalPlaces int) *Env { - return NewEnvWithPrecision(convertDecimalPlacesToPrecision(decimalPlaces)) -} - -// NewEnvWithPrecision creates a new environment with the specified binary -// precision. -func NewEnvWithPrecision(precision int) *Env { - return &Env{ - precision: precision, - vars: seedConstants(), - } -} - var transcendentalConstants = map[string]func() *unified.Real{ "E": unified.E, "PI": unified.Pi, @@ -98,6 +88,34 @@ func (e *Env) Set(name string, val *unified.Real) error { return nil } +func (e *Env) SetDecimalPlaces(decimalPlaces int) { + e.precision = convertDecimalPlacesToPrecision(decimalPlaces) +} + +func (e *Env) SetPrecision(precision int) { + e.precision = precision +} + +func (e *Env) SetTrace(enabled bool) { + e.trace = enabled +} + +func (e *Env) SetTraceOutput(w io.Writer) { + e.traceOut = w +} + +func extractCommentText(tok tokens.Token) string { + val := tok.Value + if len(val) >= 2 { + // Remove surrounding quotes: "text" → text, `text` → text + if (val[0] == '"' && val[len(val)-1] == '"') || + (val[0] == '`' && val[len(val)-1] == '`') { + return val[1 : len(val)-1] + } + } + return val +} + type NumberNode struct { Value *unified.Real } @@ -228,6 +246,19 @@ func (n *AssignNode) Eval(env *Env) (*unified.Real, error) { return val, nil } +type CommentNode struct { + Text string + Tok tokens.Token + Expr Node +} + +func (n *CommentNode) Eval(env *Env) (*unified.Real, error) { + if env.trace && env.traceOut != nil { + fmt.Fprintf(env.traceOut, "# %s\n", n.Text) + } + return n.Expr.Eval(env) +} + // modulo computes a % b = a - b * floor(a/b) for real numbers func modulo(a, b *unified.Real, precision int) (*unified.Real, error) { // scale = 2^(-precision) From b8511db0a6eaa5c7d24151bb94eef6875671f9dc Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sat, 29 Nov 2025 17:15:58 -0800 Subject: [PATCH 27/37] pkg/calc: add meta commands to change settings at runtime --- pkg/calc/calculator.go | 174 ++++++++++++++++++++++++++++++++++- pkg/calc/calculator_test.go | 177 ++++++++++++++++++++++++++++++++++++ 2 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 pkg/calc/calculator_test.go diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index ef9b3b5..aa84ebb 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -1,8 +1,11 @@ package calc import ( + "errors" "fmt" "os" + "runtime/debug" + "strconv" "strings" "github.com/elk-language/go-prompt" @@ -12,6 +15,11 @@ import ( "github.com/ripta/rt/pkg/calc/parser" ) +var ( + ErrInvalidMetaCommand = errors.New("invalid meta-command") + ErrInvalidMetaValue = errors.New("invalid value") +) + type Calculator struct { DecimalPlaces int KeepTrailingZeros bool @@ -39,6 +47,14 @@ func (c *Calculator) Execute(expr string) { fmt.Println() }() + expr = strings.TrimSpace(expr) + if strings.HasPrefix(expr, ".") { + if err := c.handleMetaCommand(expr); err != nil { + c.DisplayError(err) + } + return + } + res, err := c.Evaluate(expr) if err != nil { c.DisplayError(err) @@ -86,8 +102,164 @@ func (c *Calculator) REPL() { }), ) - fmt.Println("calc: ^D to exit") + fmt.Printf("calc: version %s\n", version()) + fmt.Println(`calc: type an expression to calculate, ".help" for help, or ^D to exit`) p.Run() fmt.Println("calc: goodbye") } + +// handleMetaCommand routes meta-commands to handlers +func (c *Calculator) handleMetaCommand(cmd string) error { + parts := strings.Fields(cmd) + if len(parts) == 0 { + return fmt.Errorf("empty command") + } + + switch parts[0] { + case ".set": + return c.handleSet(parts[1:]) + case ".show": + c.handleShow() + return nil + case ".help": + c.handleHelp() + return nil + default: + } + + return fmt.Errorf("%w: %s", ErrInvalidMetaCommand, parts[0]) +} + +// handleSet changes a setting value +func (c *Calculator) handleSet(args []string) error { + if len(args) != 2 { + return fmt.Errorf("usage: .set ") + } + + setting := strings.ToLower(args[0]) + value := args[1] + + switch setting { + case "trace": + v, err := parseBool(value) + if err != nil { + return fmt.Errorf("invalid value for trace: %s (use on/off, true/false, yes/no)", value) + } + c.Trace = v + fmt.Printf("Trace mode %s\n", formatBool(v)) + + case "decimal_places": + v, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid number: %s", value) + } + if v < 0 { + return fmt.Errorf("decimal_places must be non-negative") + } + c.DecimalPlaces = v + fmt.Printf("Decimal places set to %d\n", v) + + case "keep_trailing_zeros": + v, err := parseBool(value) + if err != nil { + return fmt.Errorf("invalid value for keep_trailing_zeros: %s (use on/off, true/false, yes/no)", value) + } + c.KeepTrailingZeros = v + fmt.Printf("Keep trailing zeros %s\n", formatBool(v)) + + case "underscore_zeros": + v, err := parseBool(value) + if err != nil { + return fmt.Errorf("invalid value for underscore_zeros: %s (use on/off, true/false, yes/no)", value) + } + c.UnderscoreZeros = v + fmt.Printf("Underscore zeros %s\n", formatBool(v)) + + case "verbose": + v, err := parseBool(value) + if err != nil { + return fmt.Errorf("invalid value for verbose: %s (use on/off, true/false, yes/no)", value) + } + c.Verbose = v + fmt.Printf("Verbose mode %s\n", formatBool(v)) + + default: + return fmt.Errorf("unknown setting: %s", setting) + } + + return nil +} + +// handleShow displays current settings +func (c *Calculator) handleShow() { + fmt.Println("settings:") + fmt.Printf(" trace: %s\n", formatBool(c.Trace)) + fmt.Printf(" decimal_places: %d\n", c.DecimalPlaces) + fmt.Printf(" keep_trailing_zeros: %s\n", formatBool(c.KeepTrailingZeros)) + fmt.Printf(" underscore_zeros: %s\n", formatBool(c.UnderscoreZeros)) + fmt.Printf(" verbose: %s\n", formatBool(c.Verbose)) +} + +// handleHelp displays available meta-commands +func (c *Calculator) handleHelp() { + fmt.Println("Available commands:") + fmt.Println(" .set - Change a setting") + fmt.Println(" .show - Show current settings") + fmt.Println(" .help - Show this help message") + fmt.Println() + fmt.Println("Available settings:") + fmt.Println(" trace - Enable/disable trace output (on/off)") + fmt.Println(" decimal_places - Number of decimal places to display (integer)") + fmt.Println(" keep_trailing_zeros - Keep trailing zeros in output (on/off)") + fmt.Println(" underscore_zeros - Insert underscore before trailing zeros (on/off)") + fmt.Println(" verbose - Enable verbose output (on/off)") +} + +// parseBool parses boolean values from strings +func parseBool(s string) (bool, error) { + switch s := strings.ToLower(s); s { + case "on", "true", "yes", "1": + return true, nil + case "off", "false", "no", "0": + return false, nil + default: + } + + return false, fmt.Errorf("%w: use on/off, true/false, yes/no, or 1/0", ErrInvalidMetaValue) +} + +// formatBool formats boolean as on/off +func formatBool(b bool) string { + if b { + return "on" + } + return "off" +} + +// version returns the current version of the calculator if set +func version() string { + bi, ok := debug.ReadBuildInfo() + if !ok { + return "(devel)" + } + + vstr := bi.Main.Version + dirty := false + if vstr == "(devel)" { + for _, s := range bi.Settings { + if s.Key == "vcs.revision" { + vstr = s.Value + } + if s.Key == "vcs.modified" && s.Value == "true" { + dirty = true + } + } + } + + if dirty { + vstr = vstr + "-dirty" + } + + return vstr +} diff --git a/pkg/calc/calculator_test.go b/pkg/calc/calculator_test.go new file mode 100644 index 0000000..9f922b4 --- /dev/null +++ b/pkg/calc/calculator_test.go @@ -0,0 +1,177 @@ +package calc + +import ( + "testing" +) + +type handleMetaCommandTest struct { + name string + cmd string + wantErr bool +} + +var handleMetaCommandTests = []handleMetaCommandTest{ + { + name: "help command", + cmd: ".help", + wantErr: false, + }, + { + name: "show command", + cmd: ".show", + wantErr: false, + }, + { + name: "unknown command", + cmd: ".unknown", + wantErr: true, + }, + { + name: "empty command", + cmd: ".", + wantErr: true, + }, + { + name: ".s is ambiguous", + cmd: ".s", + wantErr: true, + }, + { + name: ".se alias for .set", + cmd: ".se", + wantErr: true, // will error because no setting args provided + }, +} + +func TestHandleMetaCommand(t *testing.T) { + t.Parallel() + + for _, tt := range handleMetaCommandTests { + t.Run(tt.name, func(t *testing.T) { + c := &Calculator{ + DecimalPlaces: 30, + } + + err := c.handleMetaCommand(tt.cmd) + if (err != nil) != tt.wantErr { + t.Errorf("handleMetaCommand() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +type parseBoolTest struct { + name string + input string + want bool + wantErr bool +} + +var parseBoolTests = []parseBoolTest{ + {"on", "on", true, false}, + {"off", "off", false, false}, + {"true", "true", true, false}, + {"false", "false", false, false}, + {"yes", "yes", true, false}, + {"no", "no", false, false}, + {"1", "1", true, false}, + {"0", "0", false, false}, + {"ON uppercase", "ON", true, false}, + {"OFF uppercase", "OFF", false, false}, + {"True mixed case", "True", true, false}, + {"False mixed case", "False", false, false}, + {"invalid", "maybe", false, true}, + {"empty", "", false, true}, +} + +func TestParseBool(t *testing.T) { + t.Parallel() + + for _, tt := range parseBoolTests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseBool(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseBool() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("parseBool() = %v, want %v", got, tt.want) + } + }) + } +} + +type formatBoolTest struct { + name string + input bool + want string +} + +var formatBoolTests = []formatBoolTest{ + {"true", true, "on"}, + {"false", false, "off"}, +} + +func TestFormatBool(t *testing.T) { + t.Parallel() + + for _, tt := range formatBoolTests { + t.Run(tt.name, func(t *testing.T) { + got := formatBool(tt.input) + if got != tt.want { + t.Errorf("formatBool() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestMetaCommandPersistence verifies that settings persist across evaluations +func TestMetaCommandPersistence(t *testing.T) { + c := &Calculator{ + DecimalPlaces: 30, + } + + err := c.handleSet([]string{"trace", "on"}) + if err != nil { + t.Fatalf("Failed to set trace: %v", err) + } + + if !c.Trace { + t.Error("Trace setting did not persist") + } + + err = c.handleSet([]string{"decimal_places", "5"}) + if err != nil { + t.Fatalf("Failed to set decimal_places: %v", err) + } + + if !c.Trace { + t.Error("Trace setting was lost") + } + if c.DecimalPlaces != 5 { + t.Errorf("DecimalPlaces = %d, want 5", c.DecimalPlaces) + } +} + +// TestIntegrationWithEvaluation tests meta-commands with actual expression evaluation +func TestIntegrationWithEvaluation(t *testing.T) { + c := &Calculator{ + DecimalPlaces: 30, + } + + if err := c.handleSet([]string{"decimal_places", "3"}); err != nil { + t.Fatalf("Failed to set decimal_places: %v", err) + } + + if c.DecimalPlaces != 3 { + t.Errorf("DecimalPlaces = %d, want 3", c.DecimalPlaces) + } + + if err := c.handleSet([]string{"verbose", "on"}); err != nil { + t.Fatalf("Failed to set verbose: %v", err) + } + + if !c.Verbose { + t.Error("Verbose should be enabled") + } +} From 2453859b46419f4a57ca79094a5e632bf0fefed6 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sat, 29 Nov 2025 19:30:57 -0800 Subject: [PATCH 28/37] pkg/calc: centralize settings management --- pkg/calc/calculator.go | 77 +++++++++++------------------- pkg/calc/settings.go | 106 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 48 deletions(-) create mode 100644 pkg/calc/settings.go diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index aa84ebb..f1b59f3 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -137,55 +137,35 @@ func (c *Calculator) handleSet(args []string) error { return fmt.Errorf("usage: .set ") } - setting := strings.ToLower(args[0]) + setting, err := findSetting(args[0]) + if err != nil { + return err + } + value := args[1] - switch setting { - case "trace": + switch setting.Type { + case SettingTypeBool: v, err := parseBool(value) if err != nil { - return fmt.Errorf("invalid value for trace: %s (use on/off, true/false, yes/no)", value) + return fmt.Errorf("invalid value for %s: %s (use on/off, true/false, yes/no)", + setting.Name, value) } - c.Trace = v - fmt.Printf("Trace mode %s\n", formatBool(v)) + setting.SetBool(c, v) + fmt.Printf("%s %s\n", formatSettingName(setting.Name), formatBool(v)) - case "decimal_places": + case SettingTypeInt: v, err := strconv.Atoi(value) if err != nil { return fmt.Errorf("invalid number: %s", value) } - if v < 0 { - return fmt.Errorf("decimal_places must be non-negative") - } - c.DecimalPlaces = v - fmt.Printf("Decimal places set to %d\n", v) - - case "keep_trailing_zeros": - v, err := parseBool(value) - if err != nil { - return fmt.Errorf("invalid value for keep_trailing_zeros: %s (use on/off, true/false, yes/no)", value) - } - c.KeepTrailingZeros = v - fmt.Printf("Keep trailing zeros %s\n", formatBool(v)) - - case "underscore_zeros": - v, err := parseBool(value) - if err != nil { - return fmt.Errorf("invalid value for underscore_zeros: %s (use on/off, true/false, yes/no)", value) - } - c.UnderscoreZeros = v - fmt.Printf("Underscore zeros %s\n", formatBool(v)) - - case "verbose": - v, err := parseBool(value) - if err != nil { - return fmt.Errorf("invalid value for verbose: %s (use on/off, true/false, yes/no)", value) + if setting.ValidateInt != nil { + if err := setting.ValidateInt(v); err != nil { + return err + } } - c.Verbose = v - fmt.Printf("Verbose mode %s\n", formatBool(v)) - - default: - return fmt.Errorf("unknown setting: %s", setting) + setting.SetInt(c, v) + fmt.Printf("%s set to %d\n", formatSettingName(setting.Name), v) } return nil @@ -194,11 +174,14 @@ func (c *Calculator) handleSet(args []string) error { // handleShow displays current settings func (c *Calculator) handleShow() { fmt.Println("settings:") - fmt.Printf(" trace: %s\n", formatBool(c.Trace)) - fmt.Printf(" decimal_places: %d\n", c.DecimalPlaces) - fmt.Printf(" keep_trailing_zeros: %s\n", formatBool(c.KeepTrailingZeros)) - fmt.Printf(" underscore_zeros: %s\n", formatBool(c.UnderscoreZeros)) - fmt.Printf(" verbose: %s\n", formatBool(c.Verbose)) + for _, setting := range settingsRegistry { + switch setting.Type { + case SettingTypeBool: + fmt.Printf(" %s: %s\n", setting.Name, formatBool(setting.GetBool(c))) + case SettingTypeInt: + fmt.Printf(" %s: %d\n", setting.Name, setting.GetInt(c)) + } + } } // handleHelp displays available meta-commands @@ -209,11 +192,9 @@ func (c *Calculator) handleHelp() { fmt.Println(" .help - Show this help message") fmt.Println() fmt.Println("Available settings:") - fmt.Println(" trace - Enable/disable trace output (on/off)") - fmt.Println(" decimal_places - Number of decimal places to display (integer)") - fmt.Println(" keep_trailing_zeros - Keep trailing zeros in output (on/off)") - fmt.Println(" underscore_zeros - Insert underscore before trailing zeros (on/off)") - fmt.Println(" verbose - Enable verbose output (on/off)") + for _, setting := range settingsRegistry { + fmt.Printf(" %-20s - %s\n", setting.Name, setting.Description) + } } // parseBool parses boolean values from strings diff --git a/pkg/calc/settings.go b/pkg/calc/settings.go new file mode 100644 index 0000000..a57e376 --- /dev/null +++ b/pkg/calc/settings.go @@ -0,0 +1,106 @@ +package calc + +import ( + "fmt" + "strings" +) + +// SettingType represents the data type of a setting +type SettingType int + +const ( + SettingTypeBool SettingType = iota + SettingTypeInt +) + +// SettingDescriptor contains all metadata for a setting +type SettingDescriptor struct { + Name string // e.g., "trace" + Type SettingType // bool or int + Description string // Help text + + // Type-safe accessors using closures + GetBool func(*Calculator) bool + SetBool func(*Calculator, bool) + GetInt func(*Calculator) int + SetInt func(*Calculator, int) + + // Optional validation for int types + ValidateInt func(int) error +} + +// settingsRegistry is the single source of truth for all settings +var settingsRegistry = []SettingDescriptor{ + { + Name: "trace", + Type: SettingTypeBool, + Description: "Enable/disable trace output (on/off)", + GetBool: func(c *Calculator) bool { return c.Trace }, + SetBool: func(c *Calculator, v bool) { c.Trace = v }, + }, + { + Name: "decimal_places", + Type: SettingTypeInt, + Description: "Number of decimal places to display (integer)", + GetInt: func(c *Calculator) int { return c.DecimalPlaces }, + SetInt: func(c *Calculator, v int) { c.DecimalPlaces = v }, + ValidateInt: func(v int) error { + if v < 0 { + return fmt.Errorf("decimal_places must be non-negative") + } + return nil + }, + }, + { + Name: "keep_trailing_zeros", + Type: SettingTypeBool, + Description: "Keep trailing zeros in output (on/off)", + GetBool: func(c *Calculator) bool { return c.KeepTrailingZeros }, + SetBool: func(c *Calculator, v bool) { c.KeepTrailingZeros = v }, + }, + { + Name: "underscore_zeros", + Type: SettingTypeBool, + Description: "Insert underscore before trailing zeros (on/off)", + GetBool: func(c *Calculator) bool { return c.UnderscoreZeros }, + SetBool: func(c *Calculator, v bool) { c.UnderscoreZeros = v }, + }, + { + Name: "verbose", + Type: SettingTypeBool, + Description: "Enable verbose output (on/off)", + GetBool: func(c *Calculator) bool { return c.Verbose }, + SetBool: func(c *Calculator, v bool) { c.Verbose = v }, + }, +} + +// settingsIndex provides fast O(1) lookup by name +var settingsIndex map[string]*SettingDescriptor + +func init() { + settingsIndex = make(map[string]*SettingDescriptor, len(settingsRegistry)) + for i := range settingsRegistry { + settingsIndex[settingsRegistry[i].Name] = &settingsRegistry[i] + } +} + +// findSetting looks up a setting by name (case-insensitive) +func findSetting(name string) (*SettingDescriptor, error) { + setting, ok := settingsIndex[strings.ToLower(name)] + if !ok { + return nil, fmt.Errorf("unknown setting: %s", name) + } + return setting, nil +} + +// formatSettingName converts snake_case to Title Case for display +// Example: "keep_trailing_zeros" -> "Keep trailing zeros" +func formatSettingName(name string) string { + parts := strings.Split(name, "_") + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + return strings.Join(parts, " ") +} From 2478ceff8eee0b9e675467d9ffc293d85132489e Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sat, 29 Nov 2025 19:44:59 -0800 Subject: [PATCH 29/37] pkg/calc: experiment with generics for managing settings --- pkg/calc/calculator.go | 38 +------- pkg/calc/settings.go | 195 ++++++++++++++++++++++++++++------------- 2 files changed, 138 insertions(+), 95 deletions(-) diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index f1b59f3..9d9e70b 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "runtime/debug" - "strconv" "strings" "github.com/elk-language/go-prompt" @@ -142,45 +141,14 @@ func (c *Calculator) handleSet(args []string) error { return err } - value := args[1] - - switch setting.Type { - case SettingTypeBool: - v, err := parseBool(value) - if err != nil { - return fmt.Errorf("invalid value for %s: %s (use on/off, true/false, yes/no)", - setting.Name, value) - } - setting.SetBool(c, v) - fmt.Printf("%s %s\n", formatSettingName(setting.Name), formatBool(v)) - - case SettingTypeInt: - v, err := strconv.Atoi(value) - if err != nil { - return fmt.Errorf("invalid number: %s", value) - } - if setting.ValidateInt != nil { - if err := setting.ValidateInt(v); err != nil { - return err - } - } - setting.SetInt(c, v) - fmt.Printf("%s set to %d\n", formatSettingName(setting.Name), v) - } - - return nil + return setting.SetValue(c, args[1]) } // handleShow displays current settings func (c *Calculator) handleShow() { fmt.Println("settings:") for _, setting := range settingsRegistry { - switch setting.Type { - case SettingTypeBool: - fmt.Printf(" %s: %s\n", setting.Name, formatBool(setting.GetBool(c))) - case SettingTypeInt: - fmt.Printf(" %s: %d\n", setting.Name, setting.GetInt(c)) - } + fmt.Printf(" %s: %s\n", setting.Name(), setting.GetValue(c)) } } @@ -193,7 +161,7 @@ func (c *Calculator) handleHelp() { fmt.Println() fmt.Println("Available settings:") for _, setting := range settingsRegistry { - fmt.Printf(" %-20s - %s\n", setting.Name, setting.Description) + fmt.Printf(" %-20s - %s\n", setting.Name(), setting.Description()) } } diff --git a/pkg/calc/settings.go b/pkg/calc/settings.go index a57e376..0b18843 100644 --- a/pkg/calc/settings.go +++ b/pkg/calc/settings.go @@ -2,90 +2,165 @@ package calc import ( "fmt" + "strconv" "strings" ) -// SettingType represents the data type of a setting -type SettingType int +// Setting is the interface that all settings must implement. +// This allows storing SettingDescriptor[bool], SettingDescriptor[int], etc. +// in the same registry. +type Setting interface { + Name() string + Description() string -const ( - SettingTypeBool SettingType = iota - SettingTypeInt -) + // SetValue parses and sets the value from a string + SetValue(c *Calculator, value string) error + + // GetValue gets the current value as a string for display + GetValue(c *Calculator) string +} + +// SettingDescriptor is a generic descriptor for a setting of type T +type SettingDescriptor[T any] struct { + name string + description string + + // Type-safe accessors + Get func(*Calculator) T + Set func(*Calculator, T) + + // Type-specific parsing and formatting + Parse func(string) (T, error) + Format func(T) string + Validate func(T) error +} + +// Name implements Setting interface +func (s *SettingDescriptor[T]) Name() string { + return s.name +} + +// Description implements Setting interface +func (s *SettingDescriptor[T]) Description() string { + return s.description +} + +// SetValue implements Setting interface +func (s *SettingDescriptor[T]) SetValue(c *Calculator, value string) error { + v, err := s.Parse(value) + if err != nil { + return fmt.Errorf("invalid value for %s: %s", s.name, value) + } + + if s.Validate != nil { + if err := s.Validate(v); err != nil { + return err + } + } + + s.Set(c, v) + + // Format confirmation message + displayName := formatSettingName(s.name) + displayValue := s.Format(v) + fmt.Printf("%s %s\n", displayName, displayValue) -// SettingDescriptor contains all metadata for a setting -type SettingDescriptor struct { - Name string // e.g., "trace" - Type SettingType // bool or int - Description string // Help text + return nil +} + +// GetValue implements Setting interface +func (s *SettingDescriptor[T]) GetValue(c *Calculator) string { + return s.Format(s.Get(c)) +} - // Type-safe accessors using closures - GetBool func(*Calculator) bool - SetBool func(*Calculator, bool) - GetInt func(*Calculator) int - SetInt func(*Calculator, int) +// NewBoolSetting creates a boolean setting +func NewBoolSetting(name, desc string, get func(*Calculator) bool, set func(*Calculator, bool)) Setting { + return &SettingDescriptor[bool]{ + name: name, + description: desc, + Get: get, + Set: set, + Parse: parseBool, + Format: func(v bool) string { + if v { + return "on" + } + return "off" + }, + } +} - // Optional validation for int types - ValidateInt func(int) error +// NewIntSetting creates an integer setting +func NewIntSetting(name, desc string, get func(*Calculator) int, set func(*Calculator, int), validate func(int) error) Setting { + return &SettingDescriptor[int]{ + name: name, + description: desc, + Get: get, + Set: set, + Parse: func(s string) (int, error) { + v, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("invalid number: %s", s) + } + return v, nil + }, + Format: func(v int) string { return fmt.Sprintf("%d", v) }, + Validate: validate, + } } // settingsRegistry is the single source of truth for all settings -var settingsRegistry = []SettingDescriptor{ - { - Name: "trace", - Type: SettingTypeBool, - Description: "Enable/disable trace output (on/off)", - GetBool: func(c *Calculator) bool { return c.Trace }, - SetBool: func(c *Calculator, v bool) { c.Trace = v }, - }, - { - Name: "decimal_places", - Type: SettingTypeInt, - Description: "Number of decimal places to display (integer)", - GetInt: func(c *Calculator) int { return c.DecimalPlaces }, - SetInt: func(c *Calculator, v int) { c.DecimalPlaces = v }, - ValidateInt: func(v int) error { +var settingsRegistry = []Setting{ + NewBoolSetting( + "trace", + "Enable/disable trace output (on/off)", + func(c *Calculator) bool { return c.Trace }, + func(c *Calculator, v bool) { c.Trace = v }, + ), + NewIntSetting( + "decimal_places", + "Number of decimal places to display (integer)", + func(c *Calculator) int { return c.DecimalPlaces }, + func(c *Calculator, v int) { c.DecimalPlaces = v }, + func(v int) error { if v < 0 { return fmt.Errorf("decimal_places must be non-negative") } return nil }, - }, - { - Name: "keep_trailing_zeros", - Type: SettingTypeBool, - Description: "Keep trailing zeros in output (on/off)", - GetBool: func(c *Calculator) bool { return c.KeepTrailingZeros }, - SetBool: func(c *Calculator, v bool) { c.KeepTrailingZeros = v }, - }, - { - Name: "underscore_zeros", - Type: SettingTypeBool, - Description: "Insert underscore before trailing zeros (on/off)", - GetBool: func(c *Calculator) bool { return c.UnderscoreZeros }, - SetBool: func(c *Calculator, v bool) { c.UnderscoreZeros = v }, - }, - { - Name: "verbose", - Type: SettingTypeBool, - Description: "Enable verbose output (on/off)", - GetBool: func(c *Calculator) bool { return c.Verbose }, - SetBool: func(c *Calculator, v bool) { c.Verbose = v }, - }, + ), + NewBoolSetting( + "keep_trailing_zeros", + "Keep trailing zeros in output (on/off)", + func(c *Calculator) bool { return c.KeepTrailingZeros }, + func(c *Calculator, v bool) { c.KeepTrailingZeros = v }, + ), + NewBoolSetting( + "underscore_zeros", + "Insert underscore before trailing zeros (on/off)", + func(c *Calculator) bool { return c.UnderscoreZeros }, + func(c *Calculator, v bool) { c.UnderscoreZeros = v }, + ), + NewBoolSetting( + "verbose", + "Enable verbose output (on/off)", + func(c *Calculator) bool { return c.Verbose }, + func(c *Calculator, v bool) { c.Verbose = v }, + ), } // settingsIndex provides fast O(1) lookup by name -var settingsIndex map[string]*SettingDescriptor +var settingsIndex map[string]Setting func init() { - settingsIndex = make(map[string]*SettingDescriptor, len(settingsRegistry)) - for i := range settingsRegistry { - settingsIndex[settingsRegistry[i].Name] = &settingsRegistry[i] + settingsIndex = make(map[string]Setting, len(settingsRegistry)) + for _, setting := range settingsRegistry { + settingsIndex[setting.Name()] = setting } } // findSetting looks up a setting by name (case-insensitive) -func findSetting(name string) (*SettingDescriptor, error) { +func findSetting(name string) (Setting, error) { setting, ok := settingsIndex[strings.ToLower(name)] if !ok { return nil, fmt.Errorf("unknown setting: %s", name) From 83188d857659f1dbc16921069a6ff6a3ac5118d6 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sat, 29 Nov 2025 20:07:04 -0800 Subject: [PATCH 30/37] Revert "pkg/calc: experiment with generics for managing settings" This reverts commit 32a92696d6e066f8a7e702b88c01b42bf82091ed. Thanks, I hated that actually. --- pkg/calc/calculator.go | 38 +++++++- pkg/calc/settings.go | 195 +++++++++++++---------------------------- 2 files changed, 95 insertions(+), 138 deletions(-) diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index 9d9e70b..f1b59f3 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "runtime/debug" + "strconv" "strings" "github.com/elk-language/go-prompt" @@ -141,14 +142,45 @@ func (c *Calculator) handleSet(args []string) error { return err } - return setting.SetValue(c, args[1]) + value := args[1] + + switch setting.Type { + case SettingTypeBool: + v, err := parseBool(value) + if err != nil { + return fmt.Errorf("invalid value for %s: %s (use on/off, true/false, yes/no)", + setting.Name, value) + } + setting.SetBool(c, v) + fmt.Printf("%s %s\n", formatSettingName(setting.Name), formatBool(v)) + + case SettingTypeInt: + v, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid number: %s", value) + } + if setting.ValidateInt != nil { + if err := setting.ValidateInt(v); err != nil { + return err + } + } + setting.SetInt(c, v) + fmt.Printf("%s set to %d\n", formatSettingName(setting.Name), v) + } + + return nil } // handleShow displays current settings func (c *Calculator) handleShow() { fmt.Println("settings:") for _, setting := range settingsRegistry { - fmt.Printf(" %s: %s\n", setting.Name(), setting.GetValue(c)) + switch setting.Type { + case SettingTypeBool: + fmt.Printf(" %s: %s\n", setting.Name, formatBool(setting.GetBool(c))) + case SettingTypeInt: + fmt.Printf(" %s: %d\n", setting.Name, setting.GetInt(c)) + } } } @@ -161,7 +193,7 @@ func (c *Calculator) handleHelp() { fmt.Println() fmt.Println("Available settings:") for _, setting := range settingsRegistry { - fmt.Printf(" %-20s - %s\n", setting.Name(), setting.Description()) + fmt.Printf(" %-20s - %s\n", setting.Name, setting.Description) } } diff --git a/pkg/calc/settings.go b/pkg/calc/settings.go index 0b18843..a57e376 100644 --- a/pkg/calc/settings.go +++ b/pkg/calc/settings.go @@ -2,165 +2,90 @@ package calc import ( "fmt" - "strconv" "strings" ) -// Setting is the interface that all settings must implement. -// This allows storing SettingDescriptor[bool], SettingDescriptor[int], etc. -// in the same registry. -type Setting interface { - Name() string - Description() string +// SettingType represents the data type of a setting +type SettingType int - // SetValue parses and sets the value from a string - SetValue(c *Calculator, value string) error - - // GetValue gets the current value as a string for display - GetValue(c *Calculator) string -} - -// SettingDescriptor is a generic descriptor for a setting of type T -type SettingDescriptor[T any] struct { - name string - description string - - // Type-safe accessors - Get func(*Calculator) T - Set func(*Calculator, T) - - // Type-specific parsing and formatting - Parse func(string) (T, error) - Format func(T) string - Validate func(T) error -} - -// Name implements Setting interface -func (s *SettingDescriptor[T]) Name() string { - return s.name -} - -// Description implements Setting interface -func (s *SettingDescriptor[T]) Description() string { - return s.description -} - -// SetValue implements Setting interface -func (s *SettingDescriptor[T]) SetValue(c *Calculator, value string) error { - v, err := s.Parse(value) - if err != nil { - return fmt.Errorf("invalid value for %s: %s", s.name, value) - } - - if s.Validate != nil { - if err := s.Validate(v); err != nil { - return err - } - } - - s.Set(c, v) - - // Format confirmation message - displayName := formatSettingName(s.name) - displayValue := s.Format(v) - fmt.Printf("%s %s\n", displayName, displayValue) - - return nil -} +const ( + SettingTypeBool SettingType = iota + SettingTypeInt +) -// GetValue implements Setting interface -func (s *SettingDescriptor[T]) GetValue(c *Calculator) string { - return s.Format(s.Get(c)) -} +// SettingDescriptor contains all metadata for a setting +type SettingDescriptor struct { + Name string // e.g., "trace" + Type SettingType // bool or int + Description string // Help text -// NewBoolSetting creates a boolean setting -func NewBoolSetting(name, desc string, get func(*Calculator) bool, set func(*Calculator, bool)) Setting { - return &SettingDescriptor[bool]{ - name: name, - description: desc, - Get: get, - Set: set, - Parse: parseBool, - Format: func(v bool) string { - if v { - return "on" - } - return "off" - }, - } -} + // Type-safe accessors using closures + GetBool func(*Calculator) bool + SetBool func(*Calculator, bool) + GetInt func(*Calculator) int + SetInt func(*Calculator, int) -// NewIntSetting creates an integer setting -func NewIntSetting(name, desc string, get func(*Calculator) int, set func(*Calculator, int), validate func(int) error) Setting { - return &SettingDescriptor[int]{ - name: name, - description: desc, - Get: get, - Set: set, - Parse: func(s string) (int, error) { - v, err := strconv.Atoi(s) - if err != nil { - return 0, fmt.Errorf("invalid number: %s", s) - } - return v, nil - }, - Format: func(v int) string { return fmt.Sprintf("%d", v) }, - Validate: validate, - } + // Optional validation for int types + ValidateInt func(int) error } // settingsRegistry is the single source of truth for all settings -var settingsRegistry = []Setting{ - NewBoolSetting( - "trace", - "Enable/disable trace output (on/off)", - func(c *Calculator) bool { return c.Trace }, - func(c *Calculator, v bool) { c.Trace = v }, - ), - NewIntSetting( - "decimal_places", - "Number of decimal places to display (integer)", - func(c *Calculator) int { return c.DecimalPlaces }, - func(c *Calculator, v int) { c.DecimalPlaces = v }, - func(v int) error { +var settingsRegistry = []SettingDescriptor{ + { + Name: "trace", + Type: SettingTypeBool, + Description: "Enable/disable trace output (on/off)", + GetBool: func(c *Calculator) bool { return c.Trace }, + SetBool: func(c *Calculator, v bool) { c.Trace = v }, + }, + { + Name: "decimal_places", + Type: SettingTypeInt, + Description: "Number of decimal places to display (integer)", + GetInt: func(c *Calculator) int { return c.DecimalPlaces }, + SetInt: func(c *Calculator, v int) { c.DecimalPlaces = v }, + ValidateInt: func(v int) error { if v < 0 { return fmt.Errorf("decimal_places must be non-negative") } return nil }, - ), - NewBoolSetting( - "keep_trailing_zeros", - "Keep trailing zeros in output (on/off)", - func(c *Calculator) bool { return c.KeepTrailingZeros }, - func(c *Calculator, v bool) { c.KeepTrailingZeros = v }, - ), - NewBoolSetting( - "underscore_zeros", - "Insert underscore before trailing zeros (on/off)", - func(c *Calculator) bool { return c.UnderscoreZeros }, - func(c *Calculator, v bool) { c.UnderscoreZeros = v }, - ), - NewBoolSetting( - "verbose", - "Enable verbose output (on/off)", - func(c *Calculator) bool { return c.Verbose }, - func(c *Calculator, v bool) { c.Verbose = v }, - ), + }, + { + Name: "keep_trailing_zeros", + Type: SettingTypeBool, + Description: "Keep trailing zeros in output (on/off)", + GetBool: func(c *Calculator) bool { return c.KeepTrailingZeros }, + SetBool: func(c *Calculator, v bool) { c.KeepTrailingZeros = v }, + }, + { + Name: "underscore_zeros", + Type: SettingTypeBool, + Description: "Insert underscore before trailing zeros (on/off)", + GetBool: func(c *Calculator) bool { return c.UnderscoreZeros }, + SetBool: func(c *Calculator, v bool) { c.UnderscoreZeros = v }, + }, + { + Name: "verbose", + Type: SettingTypeBool, + Description: "Enable verbose output (on/off)", + GetBool: func(c *Calculator) bool { return c.Verbose }, + SetBool: func(c *Calculator, v bool) { c.Verbose = v }, + }, } // settingsIndex provides fast O(1) lookup by name -var settingsIndex map[string]Setting +var settingsIndex map[string]*SettingDescriptor func init() { - settingsIndex = make(map[string]Setting, len(settingsRegistry)) - for _, setting := range settingsRegistry { - settingsIndex[setting.Name()] = setting + settingsIndex = make(map[string]*SettingDescriptor, len(settingsRegistry)) + for i := range settingsRegistry { + settingsIndex[settingsRegistry[i].Name] = &settingsRegistry[i] } } // findSetting looks up a setting by name (case-insensitive) -func findSetting(name string) (Setting, error) { +func findSetting(name string) (*SettingDescriptor, error) { setting, ok := settingsIndex[strings.ToLower(name)] if !ok { return nil, fmt.Errorf("unknown setting: %s", name) From 4ea51645c1e15cccfca87c80f9ed68204a95bbfc Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sat, 29 Nov 2025 20:33:15 -0800 Subject: [PATCH 31/37] pkg/calc: add fuzzy matching to meta commands --- pkg/calc/calculator.go | 54 +++++++++++++++++++++++++++-------- pkg/calc/calculator_test.go | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index f1b59f3..a8eddc2 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -109,6 +109,43 @@ func (c *Calculator) REPL() { fmt.Println("calc: goodbye") } +type metaCommandFunc func(*Calculator, []string) error + +// metaCommands is the list of available meta-commands +var metaCommands = map[string]metaCommandFunc{ + ".help": func(c *Calculator, args []string) error { + c.handleHelp() + return nil + }, + ".set": func(c *Calculator, args []string) error { + return c.handleSet(args) + }, + ".show": func(c *Calculator, args []string) error { + c.handleShow() + return nil + }, +} + +// findMetaCommand finds a meta-command by prefix matching. Returns the command +// function if exactly one match is found. Returns error if no matches or +// multiple (ambiguous) matches. +func findMetaCommand(prefix string) (metaCommandFunc, error) { + var matches []string + for cmd := range metaCommands { + if strings.HasPrefix(cmd, prefix) { + matches = append(matches, cmd) + } + } + + if len(matches) == 0 { + return nil, fmt.Errorf("%w: %s", ErrInvalidMetaCommand, prefix) + } else if len(matches) > 1 { + return nil, fmt.Errorf("ambiguous command %q, could be one of: %s", prefix, strings.Join(matches, ", ")) + } + + return metaCommands[matches[0]], nil +} + // handleMetaCommand routes meta-commands to handlers func (c *Calculator) handleMetaCommand(cmd string) error { parts := strings.Fields(cmd) @@ -116,19 +153,12 @@ func (c *Calculator) handleMetaCommand(cmd string) error { return fmt.Errorf("empty command") } - switch parts[0] { - case ".set": - return c.handleSet(parts[1:]) - case ".show": - c.handleShow() - return nil - case ".help": - c.handleHelp() - return nil - default: + meta, err := findMetaCommand(parts[0]) + if err != nil { + return err } - return fmt.Errorf("%w: %s", ErrInvalidMetaCommand, parts[0]) + return meta(c, parts[1:]) } // handleSet changes a setting value @@ -191,6 +221,8 @@ func (c *Calculator) handleHelp() { fmt.Println(" .show - Show current settings") fmt.Println(" .help - Show this help message") fmt.Println() + fmt.Println("Commands accept any unambiguous prefix (e.g., .se for .set, .sh for .show, .h for .help)") + fmt.Println() fmt.Println("Available settings:") for _, setting := range settingsRegistry { fmt.Printf(" %-20s - %s\n", setting.Name, setting.Description) diff --git a/pkg/calc/calculator_test.go b/pkg/calc/calculator_test.go index 9f922b4..523cb02 100644 --- a/pkg/calc/calculator_test.go +++ b/pkg/calc/calculator_test.go @@ -41,6 +41,26 @@ var handleMetaCommandTests = []handleMetaCommandTest{ cmd: ".se", wantErr: true, // will error because no setting args provided }, + { + name: ".h alias for .help", + cmd: ".h", + wantErr: false, + }, + { + name: ".he alias for .help", + cmd: ".he", + wantErr: false, + }, + { + name: ".sh alias for .show", + cmd: ".sh", + wantErr: false, + }, + { + name: ".sho alias for .show", + cmd: ".sho", + wantErr: false, + }, } func TestHandleMetaCommand(t *testing.T) { @@ -125,6 +145,42 @@ func TestFormatBool(t *testing.T) { } } +type findMetaCommandTest struct { + name string + prefix string + wantNil bool + wantErr bool +} + +var findMetaCommandTests = []findMetaCommandTest{ + {".s is ambiguous", ".s", true, true}, + {".se matches .set", ".se", false, false}, + {".set matches .set", ".set", false, false}, + {".sh matches .show", ".sh", false, false}, + {".show matches .show", ".show", false, false}, + {".h matches .help", ".h", false, false}, + {".help matches .help", ".help", false, false}, + {"unknown prefix errors", ".x", true, true}, + {"empty string errors", ".", true, true}, +} + +func TestFindMetaCommand(t *testing.T) { + t.Parallel() + + for _, tt := range findMetaCommandTests { + t.Run(tt.name, func(t *testing.T) { + got, err := findMetaCommand(tt.prefix) + if (err != nil) != tt.wantErr { + t.Errorf("findMetaCommand() error = %v, wantErr %v", err, tt.wantErr) + return + } + if (got == nil) != tt.wantNil { + t.Errorf("findMetaCommand() = %v, want %v", got, tt.wantNil) + } + }) + } +} + // TestMetaCommandPersistence verifies that settings persist across evaluations func TestMetaCommandPersistence(t *testing.T) { c := &Calculator{ From efcf4004be309dbf75bfe6622269f3610781758c Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sat, 29 Nov 2025 21:50:15 -0800 Subject: [PATCH 32/37] pkg/calc: add support for evaluating expressions from stdin without tty --- pkg/calc/calculator.go | 36 +++++++++++++++++++++++++++++++++++- pkg/calc/command.go | 33 +++++++++++++++------------------ 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index a8eddc2..37107a0 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -1,6 +1,7 @@ package calc import ( + "bufio" "errors" "fmt" "os" @@ -91,7 +92,7 @@ func (c *Calculator) DisplayResult(res *unified.Real) { fmt.Printf("%s\n", t) } -func (c *Calculator) REPL() { +func (c *Calculator) REPL() error { p := prompt.New( c.Execute, prompt.WithPrefixCallback(func() string { @@ -107,6 +108,39 @@ func (c *Calculator) REPL() { p.Run() fmt.Println("calc: goodbye") + return nil +} + +// ProcessSTDIN reads expressions from STDIN and evaluates them line by line. +// This is used for non-interactive mode (e.g., piped input). +func (c *Calculator) ProcessSTDIN() error { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + if strings.HasPrefix(line, ".") { + if err := c.handleMetaCommand(line); err != nil { + c.DisplayError(err) + } + c.count++ + continue + } + + res, err := c.Evaluate(line) + if err != nil { + c.DisplayError(err) + c.count++ + continue + } + + c.DisplayResult(res) + c.count++ + } + + return scanner.Err() } type metaCommandFunc func(*Calculator, []string) error diff --git a/pkg/calc/command.go b/pkg/calc/command.go index ef52448..d80a728 100644 --- a/pkg/calc/command.go +++ b/pkg/calc/command.go @@ -1,16 +1,12 @@ package calc import ( - "errors" - "fmt" "os" "github.com/spf13/cobra" "golang.org/x/term" ) -var ErrNotTTY = errors.New("STDIN is not a TTY") - // NewCommand creates a new calculator command. // // Expressions can be passed as one or more arguments. If no arguments are @@ -28,25 +24,26 @@ func NewCommand() *cobra.Command { SilenceErrors: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - if term.IsTerminal(int(os.Stdin.Fd())) { - c.REPL() - return nil + // mode 1: evaluate each arg + if len(args) > 0 { + for _, arg := range args { + res, err := c.Evaluate(arg) + if err != nil { + return err + } + + c.DisplayResult(res) } - - return fmt.Errorf("%w: must specify expression as arguments", ErrNotTTY) + return nil } - for _, arg := range args { - res, err := c.Evaluate(arg) - if err != nil { - return err - } - - c.DisplayResult(res) + // mode 2: start interactive REPL if STDIN is a TTY + if term.IsTerminal(int(os.Stdin.Fd())) { + return c.REPL() } - return nil + // otherwise, read from stdin + return c.ProcessSTDIN() }, } From c59aadea3a056dc2d5cf9222c649358bc73aa0a1 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sat, 29 Nov 2025 22:12:46 -0800 Subject: [PATCH 33/37] pkg/calc: implement .toggle meta command --- pkg/calc/calculator.go | 29 +++++++++- pkg/calc/calculator_test.go | 112 +++++++++++++++++++++++++++++++++++- 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index 37107a0..88f6bc4 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -158,6 +158,9 @@ var metaCommands = map[string]metaCommandFunc{ c.handleShow() return nil }, + ".toggle": func(c *Calculator, args []string) error { + return c.handleToggle(args) + }, } // findMetaCommand finds a meta-command by prefix matching. Returns the command @@ -235,6 +238,29 @@ func (c *Calculator) handleSet(args []string) error { return nil } +// handleToggle toggles a boolean setting +func (c *Calculator) handleToggle(args []string) error { + if len(args) != 1 { + return fmt.Errorf("usage: .toggle ") + } + + setting, err := findSetting(args[0]) + if err != nil { + return err + } + + if setting.Type != SettingTypeBool { + return fmt.Errorf("cannot toggle %s: not a boolean setting", setting.Name) + } + + currentValue := setting.GetBool(c) + newValue := !currentValue + setting.SetBool(c, newValue) + fmt.Printf("%s %s\n", formatSettingName(setting.Name), formatBool(newValue)) + + return nil +} + // handleShow displays current settings func (c *Calculator) handleShow() { fmt.Println("settings:") @@ -253,9 +279,10 @@ func (c *Calculator) handleHelp() { fmt.Println("Available commands:") fmt.Println(" .set - Change a setting") fmt.Println(" .show - Show current settings") + fmt.Println(" .toggle - Toggle a boolean setting") fmt.Println(" .help - Show this help message") fmt.Println() - fmt.Println("Commands accept any unambiguous prefix (e.g., .se for .set, .sh for .show, .h for .help)") + fmt.Println("Commands accept any unambiguous prefix, e.g., .se for .set, .sh for .show)") fmt.Println() fmt.Println("Available settings:") for _, setting := range settingsRegistry { diff --git a/pkg/calc/calculator_test.go b/pkg/calc/calculator_test.go index 523cb02..5a4737e 100644 --- a/pkg/calc/calculator_test.go +++ b/pkg/calc/calculator_test.go @@ -196,8 +196,7 @@ func TestMetaCommandPersistence(t *testing.T) { t.Error("Trace setting did not persist") } - err = c.handleSet([]string{"decimal_places", "5"}) - if err != nil { + if err := c.handleSet([]string{"decimal_places", "5"}); err != nil { t.Fatalf("Failed to set decimal_places: %v", err) } @@ -231,3 +230,112 @@ func TestIntegrationWithEvaluation(t *testing.T) { t.Error("Verbose should be enabled") } } + +type toggleTest struct { + name string + setting string + initialVal bool + expectedVal bool + wantErr bool +} + +var toggleTests = []toggleTest{ + { + name: "toggle trace from off to on", + setting: "trace", + initialVal: false, + expectedVal: true, + wantErr: false, + }, + { + name: "toggle verbose from on to off", + setting: "verbose", + initialVal: true, + expectedVal: false, + wantErr: false, + }, + { + name: "toggle keep_trailing_zeros", + setting: "keep_trailing_zeros", + initialVal: false, + expectedVal: true, + wantErr: false, + }, +} + +// TestToggle verifies that the toggle command works correctly +func TestToggle(t *testing.T) { + t.Parallel() + + for _, tt := range toggleTests { + t.Run(tt.name, func(t *testing.T) { + c := &Calculator{ + DecimalPlaces: 30, + } + + setting, _ := findSetting(tt.setting) + setting.SetBool(c, tt.initialVal) + + if err := c.handleToggle([]string{tt.setting}); (err != nil) != tt.wantErr { + t.Errorf("handleToggle() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr { + got := setting.GetBool(c) + if got != tt.expectedVal { + t.Errorf("After toggle, %s = %v, want %v", tt.setting, got, tt.expectedVal) + } + } + }) + } +} + +// TestToggleNonBoolean verifies that toggle fails on non-boolean settings +func TestToggleNonBoolean(t *testing.T) { + c := &Calculator{ + DecimalPlaces: 30, + } + + err := c.handleToggle([]string{"decimal_places"}) + if err == nil { + t.Error("Expected error when toggling non-boolean setting, got nil") + } +} + +type toggleInvalidUsageTest struct { + name string + args []string +} + +var toggleInvalidUsageTests = []toggleInvalidUsageTest{ + { + name: "no arguments", + args: []string{}, + }, + { + name: "too many arguments", + args: []string{"trace", "extra"}, + }, + { + name: "unknown setting", + args: []string{"nonexistent"}, + }, +} + +// TestToggleInvalidUsage tests error cases for toggle +func TestToggleInvalidUsage(t *testing.T) { + t.Parallel() + for _, tt := range toggleInvalidUsageTests { + t.Run(tt.name, func(t *testing.T) { + c := &Calculator{ + DecimalPlaces: 30, + } + + err := c.handleToggle(tt.args) + if err == nil { + t.Error("Expected error, got nil") + } + }) + } +} From 18e015ed294841f5792528eb95b3e93aa2ee72cf Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sat, 29 Nov 2025 23:00:06 -0800 Subject: [PATCH 34/37] pkg/calc: implement .save and .load to save and load workspace --- pkg/calc/calculator.go | 202 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 185 insertions(+), 17 deletions(-) diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index 88f6bc4..068c79d 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -5,9 +5,11 @@ import ( "errors" "fmt" "os" + "path/filepath" "runtime/debug" "strconv" "strings" + "time" "github.com/elk-language/go-prompt" "github.com/ripta/reals/pkg/constructive" @@ -28,8 +30,9 @@ type Calculator struct { Verbose bool Trace bool - count int - env *parser.Env + count int + env *parser.Env + history []string } func (c *Calculator) Evaluate(expr string) (*unified.Real, error) { @@ -56,6 +59,8 @@ func (c *Calculator) Execute(expr string) { return } + c.history = append(c.history, expr) + res, err := c.Evaluate(expr) if err != nil { c.DisplayError(err) @@ -129,6 +134,8 @@ func (c *Calculator) ProcessSTDIN() error { continue } + c.history = append(c.history, line) + res, err := c.Evaluate(line) if err != nil { c.DisplayError(err) @@ -146,21 +153,31 @@ func (c *Calculator) ProcessSTDIN() error { type metaCommandFunc func(*Calculator, []string) error // metaCommands is the list of available meta-commands -var metaCommands = map[string]metaCommandFunc{ - ".help": func(c *Calculator, args []string) error { - c.handleHelp() - return nil - }, - ".set": func(c *Calculator, args []string) error { - return c.handleSet(args) - }, - ".show": func(c *Calculator, args []string) error { - c.handleShow() - return nil - }, - ".toggle": func(c *Calculator, args []string) error { - return c.handleToggle(args) - }, +var metaCommands map[string]metaCommandFunc + +func init() { + metaCommands = map[string]metaCommandFunc{ + ".help": func(c *Calculator, args []string) error { + c.handleHelp() + return nil + }, + ".set": func(c *Calculator, args []string) error { + return c.handleSet(args) + }, + ".show": func(c *Calculator, args []string) error { + c.handleShow() + return nil + }, + ".toggle": func(c *Calculator, args []string) error { + return c.handleToggle(args) + }, + ".save": func(c *Calculator, args []string) error { + return c.handleSave(args) + }, + ".load": func(c *Calculator, args []string) error { + return c.handleLoad(args) + }, + } } // findMetaCommand finds a meta-command by prefix matching. Returns the command @@ -261,6 +278,155 @@ func (c *Calculator) handleToggle(args []string) error { return nil } +const defaultFilename = "session.txt" + +// getSessionPath resolves the session file path based on user input. +// Default: ~/.local/state/rt/calc/session.txt +// If arg is a directory, use default filename in that directory +// If arg is a file path, use it as-is +func getSessionPath(arg string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + if arg == "" { + stateDir := filepath.Join(home, ".local", "state", "rt", "calc") + if err := os.MkdirAll(stateDir, 0755); err != nil { + return "", fmt.Errorf("failed to create state directory: %w", err) + } + + return filepath.Join(stateDir, defaultFilename), nil + } + + if strings.HasPrefix(arg, "~/") { + arg = filepath.Join(home, arg[2:]) + } + if strings.Contains(arg, "$") { + arg = os.ExpandEnv(arg) + } + + // If arg is an existing directory, use default filename in that directory + if info, err := os.Stat(arg); err == nil && info.IsDir() { + return filepath.Join(arg, defaultFilename), nil + } + + return arg, nil +} + +// handleSave saves the current session to a file +func (c *Calculator) handleSave(args []string) error { + var argPath string + if len(args) > 0 { + argPath = args[0] + } + + filename, err := getSessionPath(argPath) + if err != nil { + return err + } + + f, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer f.Close() + + w := bufio.NewWriter(f) + defer w.Flush() + + // Write header + fmt.Fprintf(w, "# Calculator Session\n") + fmt.Fprintf(w, "# Saved: %s\n\n", time.Now().Format("2006-01-02 15:04:05")) + + // Write settings as .set commands + for _, setting := range settingsRegistry { + switch setting.Type { + case SettingTypeBool: + value := setting.GetBool(c) + fmt.Fprintf(w, ".set %s %s\n", setting.Name, formatBool(value)) + case SettingTypeInt: + value := setting.GetInt(c) + fmt.Fprintf(w, ".set %s %d\n", setting.Name, value) + } + } + + // Write blank line separator + if len(c.history) > 0 { + fmt.Fprintln(w) + } + + // Write expression history + for _, expr := range c.history { + fmt.Fprintln(w, expr) + } + + fmt.Printf("Session saved to %s\n", filename) + return nil +} + +// handleLoad loads a session from a file +func (c *Calculator) handleLoad(args []string) error { + var argPath string + if len(args) > 0 { + argPath = args[0] + } + + filename, err := getSessionPath(argPath) + if err != nil { + return err + } + + f, err := os.Open(filename) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer f.Close() + + // Clear current state + c.history = nil + c.env = parser.NewEnv() + // Note: settings are not reset, they'll be overwritten by .set commands + + scanner := bufio.NewScanner(f) + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if strings.HasPrefix(line, ".") { + if err := c.handleMetaCommand(line); err != nil { + fmt.Fprintf(os.Stderr, "Warning: line %d: %v\n", lineNum, err) + } + continue + } + + res, err := c.Evaluate(line) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: line %d: %v\n", lineNum, err) + continue + } + + // Add to history manually (since we're not calling Execute) + c.history = append(c.history, line) + + // Optionally display result (might be too verbose) + // c.DisplayResult(res) + _ = res + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("error reading file: %w", err) + } + + fmt.Printf("Session loaded from %s (%d expressions)\n", filename, len(c.history)) + return nil +} + // handleShow displays current settings func (c *Calculator) handleShow() { fmt.Println("settings:") @@ -280,6 +446,8 @@ func (c *Calculator) handleHelp() { fmt.Println(" .set - Change a setting") fmt.Println(" .show - Show current settings") fmt.Println(" .toggle - Toggle a boolean setting") + fmt.Println(" .save [path] - Save session (default: ~/.local/state/rt/calc/session.txt)") + fmt.Println(" .load [path] - Load session (default: ~/.local/state/rt/calc/session.txt)") fmt.Println(" .help - Show this help message") fmt.Println() fmt.Println("Commands accept any unambiguous prefix, e.g., .se for .set, .sh for .show)") From 5ac20b25d5af11c0f30219fba2c027fa2ef7de91 Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sun, 30 Nov 2025 00:17:36 -0800 Subject: [PATCH 35/37] pkg/calc: refactor line handling and expression evaluation across all modes (repl, stdin, and .load) --- pkg/calc/calculator.go | 136 ++++++++++++++++++++++------------------- pkg/calc/evaluate.go | 3 +- pkg/calc/settings.go | 12 ---- 3 files changed, 74 insertions(+), 77 deletions(-) diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index 068c79d..933afdf 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -23,6 +23,18 @@ var ( ErrInvalidMetaValue = errors.New("invalid value") ) +// ExecutionMode represents the context in which an expression is being evaluated +type ExecutionMode int + +const ( + // ModeREPL represents interactive REPL mode. Results are displayed and errors reported normally. + ModeREPL ExecutionMode = iota + // ModeSTDIN represents non-interactive mode reading from STDIN. Results are displayed and errors reported normally. + ModeSTDIN + // ModeLoad represents loading a saved session. Results are not displayed; errors are reported as warnings. + ModeLoad +) + type Calculator struct { DecimalPlaces int KeepTrailingZeros bool @@ -45,29 +57,68 @@ func (c *Calculator) Evaluate(expr string) (*unified.Real, error) { return Evaluate(expr, c.env) } -func (c *Calculator) Execute(expr string) { +// processLine processes a single line of input (expression or meta-command). +// Returns error if processing fails. +// +// mode determines error reporting style and whether results are displayed. +// lineNum is used for error messages in ModeLoad (ignored otherwise). +func (c *Calculator) processLine(expr string, mode ExecutionMode, lineNum int) error { defer func() { c.count++ - fmt.Println() }() expr = strings.TrimSpace(expr) + if expr == "" { + return nil + } + + // Handle meta-commands if strings.HasPrefix(expr, ".") { - if err := c.handleMetaCommand(expr); err != nil { - c.DisplayError(err) + err := c.handleMetaCommand(expr) + if err != nil { + c.reportError(err, mode, lineNum) } - return + + return err } - c.history = append(c.history, expr) + // ModeREPL and ModeSTDIN adds to history before evaluation + if mode == ModeREPL || mode == ModeSTDIN { + c.history = append(c.history, expr) + } res, err := c.Evaluate(expr) if err != nil { + c.reportError(err, mode, lineNum) + return err + } + + // ModeLoad adds to history after successful evaluation + if mode == ModeLoad { + c.history = append(c.history, expr) + } + + // Display results (except in Load mode) + if mode != ModeLoad { + c.DisplayResult(res) + } + + return nil +} + +// reportError reports an error using the appropriate method for the execution mode +func (c *Calculator) reportError(err error, mode ExecutionMode, lineNum int) { + switch mode { + case ModeLoad: + fmt.Fprintf(os.Stderr, "Warning: line %d: %v\n", lineNum, err) + default: c.DisplayError(err) - return } +} - c.DisplayResult(res) +func (c *Calculator) Execute(expr string) { + defer fmt.Println() + c.processLine(expr, ModeREPL, 0) } func (c *Calculator) DisplayError(err error) { @@ -121,30 +172,8 @@ func (c *Calculator) REPL() error { func (c *Calculator) ProcessSTDIN() error { scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - - if strings.HasPrefix(line, ".") { - if err := c.handleMetaCommand(line); err != nil { - c.DisplayError(err) - } - c.count++ - continue - } - - c.history = append(c.history, line) - - res, err := c.Evaluate(line) - if err != nil { - c.DisplayError(err) - c.count++ - continue - } - - c.DisplayResult(res) - c.count++ + line := scanner.Text() + c.processLine(line, ModeSTDIN, 0) } return scanner.Err() @@ -236,7 +265,7 @@ func (c *Calculator) handleSet(args []string) error { setting.Name, value) } setting.SetBool(c, v) - fmt.Printf("%s %s\n", formatSettingName(setting.Name), formatBool(v)) + fmt.Printf("%s %s\n", setting.Name, formatBool(v)) case SettingTypeInt: v, err := strconv.Atoi(value) @@ -249,7 +278,7 @@ func (c *Calculator) handleSet(args []string) error { } } setting.SetInt(c, v) - fmt.Printf("%s set to %d\n", formatSettingName(setting.Name), v) + fmt.Printf("%s set to %d\n", setting.Name, v) } return nil @@ -273,7 +302,7 @@ func (c *Calculator) handleToggle(args []string) error { currentValue := setting.GetBool(c) newValue := !currentValue setting.SetBool(c, newValue) - fmt.Printf("%s %s\n", formatSettingName(setting.Name), formatBool(newValue)) + fmt.Printf("calc:/ %s set to %s\n", setting.Name, formatBool(newValue)) return nil } @@ -282,6 +311,7 @@ const defaultFilename = "session.txt" // getSessionPath resolves the session file path based on user input. // Default: ~/.local/state/rt/calc/session.txt +// // If arg is a directory, use default filename in that directory // If arg is a file path, use it as-is func getSessionPath(arg string) (string, error) { @@ -335,7 +365,6 @@ func (c *Calculator) handleSave(args []string) error { w := bufio.NewWriter(f) defer w.Flush() - // Write header fmt.Fprintf(w, "# Calculator Session\n") fmt.Fprintf(w, "# Saved: %s\n\n", time.Now().Format("2006-01-02 15:04:05")) @@ -351,7 +380,6 @@ func (c *Calculator) handleSave(args []string) error { } } - // Write blank line separator if len(c.history) > 0 { fmt.Fprintln(w) } @@ -365,9 +393,9 @@ func (c *Calculator) handleSave(args []string) error { return nil } -// handleLoad loads a session from a file +// handleLoad loads a session from a file after first clearing current state. func (c *Calculator) handleLoad(args []string) error { - var argPath string + argPath := "" if len(args) > 0 { argPath = args[0] } @@ -383,47 +411,29 @@ func (c *Calculator) handleLoad(args []string) error { } defer f.Close() - // Clear current state c.history = nil c.env = parser.NewEnv() - // Note: settings are not reset, they'll be overwritten by .set commands scanner := bufio.NewScanner(f) lineNum := 0 for scanner.Scan() { lineNum++ - line := strings.TrimSpace(scanner.Text()) - - if line == "" || strings.HasPrefix(line, "#") { - continue - } - if strings.HasPrefix(line, ".") { - if err := c.handleMetaCommand(line); err != nil { - fmt.Fprintf(os.Stderr, "Warning: line %d: %v\n", lineNum, err) - } + line := scanner.Text() + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } - res, err := c.Evaluate(line) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: line %d: %v\n", lineNum, err) - continue - } - - // Add to history manually (since we're not calling Execute) - c.history = append(c.history, line) - - // Optionally display result (might be too verbose) - // c.DisplayResult(res) - _ = res + // Errors are reported as warnings but don't stop loading + c.processLine(line, ModeLoad, lineNum) } if err := scanner.Err(); err != nil { return fmt.Errorf("error reading file: %w", err) } - fmt.Printf("Session loaded from %s (%d expressions)\n", filename, len(c.history)) + fmt.Printf("Loaded from %s\n", filename) return nil } diff --git a/pkg/calc/evaluate.go b/pkg/calc/evaluate.go index da87170..42d688d 100644 --- a/pkg/calc/evaluate.go +++ b/pkg/calc/evaluate.go @@ -2,7 +2,6 @@ package calc import ( "errors" - "strings" "github.com/ripta/reals/pkg/unified" @@ -12,8 +11,8 @@ import ( var ErrEnvironmentMissing = errors.New("environment missing") // Evaluate parses expr and evaluates it in the given environment. +// The caller is responsible for trimming whitespace from expr. func Evaluate(expr string, env *parser.Env) (*unified.Real, error) { - expr = strings.TrimSpace(expr) if expr == "" { return unified.Zero(), nil } diff --git a/pkg/calc/settings.go b/pkg/calc/settings.go index a57e376..42cabaa 100644 --- a/pkg/calc/settings.go +++ b/pkg/calc/settings.go @@ -92,15 +92,3 @@ func findSetting(name string) (*SettingDescriptor, error) { } return setting, nil } - -// formatSettingName converts snake_case to Title Case for display -// Example: "keep_trailing_zeros" -> "Keep trailing zeros" -func formatSettingName(name string) string { - parts := strings.Split(name, "_") - for i, part := range parts { - if len(part) > 0 { - parts[i] = strings.ToUpper(part[:1]) + part[1:] - } - } - return strings.Join(parts, " ") -} From 68476f92182064a20a53cb274b9246ff504e262c Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sun, 30 Nov 2025 00:43:29 -0800 Subject: [PATCH 36/37] pkg/calc: add fuzzy matching to settings --- pkg/calc/settings.go | 32 +++++---- pkg/calc/settings_test.go | 139 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 12 deletions(-) create mode 100644 pkg/calc/settings_test.go diff --git a/pkg/calc/settings.go b/pkg/calc/settings.go index 42cabaa..1511e98 100644 --- a/pkg/calc/settings.go +++ b/pkg/calc/settings.go @@ -74,21 +74,29 @@ var settingsRegistry = []SettingDescriptor{ }, } -// settingsIndex provides fast O(1) lookup by name -var settingsIndex map[string]*SettingDescriptor +// findSetting looks up a setting by prefix matching (case-insensitive). +// Returns the setting if exactly one match is found. Returns error if no +// matches or multiple (ambiguous) matches. +func findSetting(prefix string) (*SettingDescriptor, error) { + var matches []*SettingDescriptor + prefix = strings.ToLower(prefix) -func init() { - settingsIndex = make(map[string]*SettingDescriptor, len(settingsRegistry)) for i := range settingsRegistry { - settingsIndex[settingsRegistry[i].Name] = &settingsRegistry[i] + if strings.HasPrefix(settingsRegistry[i].Name, prefix) { + matches = append(matches, &settingsRegistry[i]) + } } -} -// findSetting looks up a setting by name (case-insensitive) -func findSetting(name string) (*SettingDescriptor, error) { - setting, ok := settingsIndex[strings.ToLower(name)] - if !ok { - return nil, fmt.Errorf("unknown setting: %s", name) + if len(matches) == 0 { + return nil, fmt.Errorf("unknown setting: %s", prefix) + } else if len(matches) > 1 { + names := make([]string, len(matches)) + for i, m := range matches { + names[i] = m.Name + } + return nil, fmt.Errorf("ambiguous setting %q, could be one of: %s", + prefix, strings.Join(names, ", ")) } - return setting, nil + + return matches[0], nil } diff --git a/pkg/calc/settings_test.go b/pkg/calc/settings_test.go new file mode 100644 index 0000000..11fef80 --- /dev/null +++ b/pkg/calc/settings_test.go @@ -0,0 +1,139 @@ +package calc + +import ( + "strings" + "testing" +) + +type findSettingTest struct { + name string + prefix string + want string // expected setting name + wantErr bool + errMsg string // substring to check in error message +} + +var findSettingTests = []findSettingTest{ + // Exact matches + {"exact: trace", "trace", "trace", false, ""}, + {"exact: verbose", "verbose", "verbose", false, ""}, + {"exact: decimal_places", "decimal_places", "decimal_places", false, ""}, + {"exact: keep_trailing_zeros", "keep_trailing_zeros", "keep_trailing_zeros", false, ""}, + {"exact: underscore_zeros", "underscore_zeros", "underscore_zeros", false, ""}, + + // Case-insensitive exact matches + {"case: TRACE", "TRACE", "trace", false, ""}, + {"case: Verbose", "Verbose", "verbose", false, ""}, + {"case: Decimal_Places", "Decimal_Places", "decimal_places", false, ""}, + + // Single character prefixes (all currently unambiguous) + {"prefix: t", "t", "trace", false, ""}, + {"prefix: v", "v", "verbose", false, ""}, + {"prefix: d", "d", "decimal_places", false, ""}, + {"prefix: k", "k", "keep_trailing_zeros", false, ""}, + {"prefix: u", "u", "underscore_zeros", false, ""}, + + // Multi-character prefixes + {"prefix: tra", "tra", "trace", false, ""}, + {"prefix: ver", "ver", "verbose", false, ""}, + {"prefix: dec", "dec", "decimal_places", false, ""}, + {"prefix: keep", "keep", "keep_trailing_zeros", false, ""}, + {"prefix: under", "under", "underscore_zeros", false, ""}, + + // Case-insensitive prefixes + {"prefix case: T", "T", "trace", false, ""}, + {"prefix case: V", "V", "verbose", false, ""}, + {"prefix case: TRA", "TRA", "trace", false, ""}, + + // Unknown setting + {"unknown: xyz", "xyz", "", true, "unknown setting"}, + {"unknown: foo", "foo", "", true, "unknown setting"}, + {"unknown: x", "x", "", true, "unknown setting"}, + + // Empty string would match all settings if any existed + {"empty string", "", "", true, "ambiguous"}, +} + +func TestFindSetting(t *testing.T) { + t.Parallel() + + for _, tt := range findSettingTests { + t.Run(tt.name, func(t *testing.T) { + got, err := findSetting(tt.prefix) + if (err != nil) != tt.wantErr { + t.Errorf("findSetting(%q) error = %v, wantErr %v", tt.prefix, err, tt.wantErr) + return + } + + if tt.wantErr { + if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("findSetting(%q) error = %q, want error containing %q", tt.prefix, err.Error(), tt.errMsg) + } + return + } + + if got == nil { + t.Errorf("findSetting(%q) returned nil, want setting %q", tt.prefix, tt.want) + return + } + if got.Name != tt.want { + t.Errorf("findSetting(%q) = %q, want %q", tt.prefix, got.Name, tt.want) + } + }) + } +} + +// TestFindSettingAmbiguous tests that ambiguous prefixes are properly detected. +// Currently all settings start with different letters, so we test with hypothetical +// settings to ensure the ambiguity detection logic works correctly. +func TestFindSettingAmbiguous(t *testing.T) { + _, err := findSetting("") + if err == nil { + t.Error("findSetting should return error for ambiguous match, got nil") + return + } + + errMsg := err.Error() + if !strings.Contains(errMsg, "ambiguous") { + t.Errorf("findSetting error = %q, want error containing \"ambiguous\"", errMsg) + } + + // The error should list all the settings + for _, setting := range settingsRegistry { + if !strings.Contains(errMsg, setting.Name) { + t.Errorf("findSetting(\"\") error should list all settings, missing %q in: %q", setting.Name, errMsg) + } + } +} + +// TestFindSettingAllSettings verifies that all settings in the registry can be found +// by their full name and by their first character. +func TestFindSettingAllSettings(t *testing.T) { + t.Parallel() + + for _, setting := range settingsRegistry { + t.Run("full:"+setting.Name, func(t *testing.T) { + got, err := findSetting(setting.Name) + if err != nil { + t.Errorf("findSetting(%q) error = %v, want nil", setting.Name, err) + return + } + if got.Name != setting.Name { + t.Errorf("findSetting(%q) = %q, want %q", setting.Name, got.Name, setting.Name) + } + }) + + firstChar := string(setting.Name[0]) + t.Run("prefix:"+firstChar, func(t *testing.T) { + got, err := findSetting(firstChar) + // We expect this to succeed since current settings all start with different letters + if err != nil { + t.Errorf("findSetting(%q) error = %v, want nil (current settings have unique first chars)", firstChar, err) + return + } + if got.Name != setting.Name { + t.Errorf("findSetting(%q) = %q, want %q", firstChar, got.Name, setting.Name) + } + }) + } +} From 855b058213deb06100ac53c15a453b207ad74a8f Mon Sep 17 00:00:00 2001 From: Ripta Pasay Date: Sun, 30 Nov 2025 01:25:34 -0800 Subject: [PATCH 37/37] pkg/calc: implement generic findByPrefix that replaces findMetaCommand and findSetting --- pkg/calc/calculator.go | 55 +++++--------- pkg/calc/calculator_test.go | 38 +--------- pkg/calc/prefix.go | 36 ++++++++++ pkg/calc/prefix_test.go | 134 ++++++++++++++++++++++++++++++++++ pkg/calc/settings.go | 46 ++---------- pkg/calc/settings_test.go | 139 ------------------------------------ 6 files changed, 196 insertions(+), 252 deletions(-) create mode 100644 pkg/calc/prefix.go create mode 100644 pkg/calc/prefix_test.go delete mode 100644 pkg/calc/settings_test.go diff --git a/pkg/calc/calculator.go b/pkg/calc/calculator.go index 933afdf..4185710 100644 --- a/pkg/calc/calculator.go +++ b/pkg/calc/calculator.go @@ -209,26 +209,6 @@ func init() { } } -// findMetaCommand finds a meta-command by prefix matching. Returns the command -// function if exactly one match is found. Returns error if no matches or -// multiple (ambiguous) matches. -func findMetaCommand(prefix string) (metaCommandFunc, error) { - var matches []string - for cmd := range metaCommands { - if strings.HasPrefix(cmd, prefix) { - matches = append(matches, cmd) - } - } - - if len(matches) == 0 { - return nil, fmt.Errorf("%w: %s", ErrInvalidMetaCommand, prefix) - } else if len(matches) > 1 { - return nil, fmt.Errorf("ambiguous command %q, could be one of: %s", prefix, strings.Join(matches, ", ")) - } - - return metaCommands[matches[0]], nil -} - // handleMetaCommand routes meta-commands to handlers func (c *Calculator) handleMetaCommand(cmd string) error { parts := strings.Fields(cmd) @@ -236,7 +216,7 @@ func (c *Calculator) handleMetaCommand(cmd string) error { return fmt.Errorf("empty command") } - meta, err := findMetaCommand(parts[0]) + meta, err := findByPrefix(parts[0], metaCommands) if err != nil { return err } @@ -250,22 +230,24 @@ func (c *Calculator) handleSet(args []string) error { return fmt.Errorf("usage: .set ") } - setting, err := findSetting(args[0]) + setting, err := findByPrefix(args[0], settingsRegistry) if err != nil { return err } value := args[1] + settingName := args[0] + switch setting.Type { case SettingTypeBool: v, err := parseBool(value) if err != nil { return fmt.Errorf("invalid value for %s: %s (use on/off, true/false, yes/no)", - setting.Name, value) + settingName, value) } setting.SetBool(c, v) - fmt.Printf("%s %s\n", setting.Name, formatBool(v)) + fmt.Printf("%s %s\n", settingName, formatBool(v)) case SettingTypeInt: v, err := strconv.Atoi(value) @@ -278,7 +260,7 @@ func (c *Calculator) handleSet(args []string) error { } } setting.SetInt(c, v) - fmt.Printf("%s set to %d\n", setting.Name, v) + fmt.Printf("%s set to %d\n", settingName, v) } return nil @@ -290,19 +272,20 @@ func (c *Calculator) handleToggle(args []string) error { return fmt.Errorf("usage: .toggle ") } - setting, err := findSetting(args[0]) + settingName := args[0] + setting, err := findByPrefix(settingName, settingsRegistry) if err != nil { return err } if setting.Type != SettingTypeBool { - return fmt.Errorf("cannot toggle %s: not a boolean setting", setting.Name) + return fmt.Errorf("cannot toggle %s: not a boolean setting", settingName) } currentValue := setting.GetBool(c) newValue := !currentValue setting.SetBool(c, newValue) - fmt.Printf("calc:/ %s set to %s\n", setting.Name, formatBool(newValue)) + fmt.Printf("calc:/ %s set to %s\n", settingName, formatBool(newValue)) return nil } @@ -369,14 +352,14 @@ func (c *Calculator) handleSave(args []string) error { fmt.Fprintf(w, "# Saved: %s\n\n", time.Now().Format("2006-01-02 15:04:05")) // Write settings as .set commands - for _, setting := range settingsRegistry { + for name, setting := range settingsRegistry { switch setting.Type { case SettingTypeBool: value := setting.GetBool(c) - fmt.Fprintf(w, ".set %s %s\n", setting.Name, formatBool(value)) + fmt.Fprintf(w, ".set %s %s\n", name, formatBool(value)) case SettingTypeInt: value := setting.GetInt(c) - fmt.Fprintf(w, ".set %s %d\n", setting.Name, value) + fmt.Fprintf(w, ".set %s %d\n", name, value) } } @@ -440,12 +423,12 @@ func (c *Calculator) handleLoad(args []string) error { // handleShow displays current settings func (c *Calculator) handleShow() { fmt.Println("settings:") - for _, setting := range settingsRegistry { + for name, setting := range settingsRegistry { switch setting.Type { case SettingTypeBool: - fmt.Printf(" %s: %s\n", setting.Name, formatBool(setting.GetBool(c))) + fmt.Printf(" %s: %s\n", name, formatBool(setting.GetBool(c))) case SettingTypeInt: - fmt.Printf(" %s: %d\n", setting.Name, setting.GetInt(c)) + fmt.Printf(" %s: %d\n", name, setting.GetInt(c)) } } } @@ -463,8 +446,8 @@ func (c *Calculator) handleHelp() { fmt.Println("Commands accept any unambiguous prefix, e.g., .se for .set, .sh for .show)") fmt.Println() fmt.Println("Available settings:") - for _, setting := range settingsRegistry { - fmt.Printf(" %-20s - %s\n", setting.Name, setting.Description) + for name, setting := range settingsRegistry { + fmt.Printf(" %-20s - %s\n", name, setting.Description) } } diff --git a/pkg/calc/calculator_test.go b/pkg/calc/calculator_test.go index 5a4737e..9fcef67 100644 --- a/pkg/calc/calculator_test.go +++ b/pkg/calc/calculator_test.go @@ -145,42 +145,6 @@ func TestFormatBool(t *testing.T) { } } -type findMetaCommandTest struct { - name string - prefix string - wantNil bool - wantErr bool -} - -var findMetaCommandTests = []findMetaCommandTest{ - {".s is ambiguous", ".s", true, true}, - {".se matches .set", ".se", false, false}, - {".set matches .set", ".set", false, false}, - {".sh matches .show", ".sh", false, false}, - {".show matches .show", ".show", false, false}, - {".h matches .help", ".h", false, false}, - {".help matches .help", ".help", false, false}, - {"unknown prefix errors", ".x", true, true}, - {"empty string errors", ".", true, true}, -} - -func TestFindMetaCommand(t *testing.T) { - t.Parallel() - - for _, tt := range findMetaCommandTests { - t.Run(tt.name, func(t *testing.T) { - got, err := findMetaCommand(tt.prefix) - if (err != nil) != tt.wantErr { - t.Errorf("findMetaCommand() error = %v, wantErr %v", err, tt.wantErr) - return - } - if (got == nil) != tt.wantNil { - t.Errorf("findMetaCommand() = %v, want %v", got, tt.wantNil) - } - }) - } -} - // TestMetaCommandPersistence verifies that settings persist across evaluations func TestMetaCommandPersistence(t *testing.T) { c := &Calculator{ @@ -273,7 +237,7 @@ func TestToggle(t *testing.T) { DecimalPlaces: 30, } - setting, _ := findSetting(tt.setting) + setting, _ := findByPrefix(tt.setting, settingsRegistry) setting.SetBool(c, tt.initialVal) if err := c.handleToggle([]string{tt.setting}); (err != nil) != tt.wantErr { diff --git a/pkg/calc/prefix.go b/pkg/calc/prefix.go new file mode 100644 index 0000000..58ca554 --- /dev/null +++ b/pkg/calc/prefix.go @@ -0,0 +1,36 @@ +package calc + +import ( + "fmt" + "strings" +) + +var ( + ErrAmbiguousPrefix = fmt.Errorf("ambiguous prefix") + ErrPrefixNotFound = fmt.Errorf("prefix not found") +) + +// findByPrefix performs case-insensitive unambiguous prefix matching on a map. +// Returns the matched value if exactly one match is found. Returns an error if +// no matches or multiple (ambiguous) matches are found. +func findByPrefix[T any](prefix string, items map[string]T) (T, error) { + var zero T + prefix = strings.ToLower(prefix) + + var matches []T + var matchNames []string + for name, item := range items { + if strings.HasPrefix(strings.ToLower(name), prefix) { + matches = append(matches, item) + matchNames = append(matchNames, name) + } + } + + if len(matches) == 0 { + return zero, fmt.Errorf("%w %q", ErrPrefixNotFound, prefix) + } else if len(matches) > 1 { + return zero, fmt.Errorf("%w %q, coult be one of: %s", ErrAmbiguousPrefix, prefix, strings.Join(matchNames, ", ")) + } + + return matches[0], nil +} diff --git a/pkg/calc/prefix_test.go b/pkg/calc/prefix_test.go new file mode 100644 index 0000000..ac99382 --- /dev/null +++ b/pkg/calc/prefix_test.go @@ -0,0 +1,134 @@ +package calc + +import ( + "strings" + "testing" +) + +type findByPrefixTest struct { + name string + prefix string + want string // expected value + wantErr bool + errMsg string // substring to check in error message +} + +var findByPrefixTests = []findByPrefixTest{ + // Exact matches + {"exact: help", "help", "help_value", false, ""}, + {"exact: set", "set", "set_value", false, ""}, + {"exact: show", "show", "show_value", false, ""}, + {"exact: save", "save", "save_value", false, ""}, + {"exact: load", "load", "load_value", false, ""}, + + // Case-insensitive exact matches + {"case: HELP", "HELP", "help_value", false, ""}, + {"case: Help", "Help", "help_value", false, ""}, + {"case: SET", "SET", "set_value", false, ""}, + + // Single character prefixes + {"prefix: h", "h", "help_value", false, ""}, + {"prefix: l", "l", "load_value", false, ""}, + + // Multi-character prefixes + {"prefix: he", "he", "help_value", false, ""}, + {"prefix: hel", "hel", "help_value", false, ""}, + {"prefix: se", "se", "set_value", false, ""}, + {"prefix: sh", "sh", "show_value", false, ""}, + {"prefix: sho", "sho", "show_value", false, ""}, + {"prefix: sa", "sa", "save_value", false, ""}, + {"prefix: sav", "sav", "save_value", false, ""}, + {"prefix: lo", "lo", "load_value", false, ""}, + {"prefix: loa", "loa", "load_value", false, ""}, + + // Case-insensitive prefixes + {"prefix case: H", "H", "help_value", false, ""}, + {"prefix case: HE", "HE", "help_value", false, ""}, + {"prefix case: He", "He", "help_value", false, ""}, + {"prefix case: SE", "SE", "set_value", false, ""}, + + // Ambiguous prefixes + {"ambiguous: s", "s", "", true, "ambiguous"}, + {"ambiguous: sa vs se vs sh", "s", "", true, "set"}, + + // Unknown prefixes + {"unknown: x", "x", "", true, "prefix not found"}, + {"unknown: xyz", "xyz", "", true, "prefix not found"}, + {"unknown: foo", "foo", "", true, "prefix not found"}, + + // Empty prefix (matches all) + {"empty string", "", "", true, "ambiguous"}, +} + +func TestFindByPrefix(t *testing.T) { + t.Parallel() + + items := map[string]string{ + "help": "help_value", + "set": "set_value", + "show": "show_value", + "save": "save_value", + "load": "load_value", + } + + for _, tt := range findByPrefixTests { + t.Run(tt.name, func(t *testing.T) { + got, err := findByPrefix(tt.prefix, items) + if (err != nil) != tt.wantErr { + t.Errorf("findByPrefix(%q) error = %v, wantErr %v", tt.prefix, err, tt.wantErr) + return + } + + if tt.wantErr { + if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("findByPrefix(%q) error = %q, want error containing %q", tt.prefix, err.Error(), tt.errMsg) + } + return + } + + if got != tt.want { + t.Errorf("findByPrefix(%q) = %q, want %q", tt.prefix, got, tt.want) + } + }) + } +} + +func TestFindByPrefixAmbiguousErrorListing(t *testing.T) { + items := map[string]string{ + "help": "help_value", + "set": "set_value", + "show": "show_value", + "save": "save_value", + } + + _, err := findByPrefix("s", items) + if err == nil { + t.Fatal("findByPrefix(\"s\") should return error for ambiguous match, got nil") + } + + errMsg := err.Error() + if !strings.Contains(errMsg, "ambiguous") { + t.Errorf("error = %q, want error containing \"ambiguous\"", errMsg) + } + + expectedMatches := []string{"set", "show", "save"} + for _, match := range expectedMatches { + if !strings.Contains(errMsg, match) { + t.Errorf("error should list %q in: %q", match, errMsg) + } + } +} + +func TestFindByPrefixEmptyMap(t *testing.T) { + items := map[string]string{} + + _, err := findByPrefix("test", items) + if err == nil { + t.Error("findByPrefix on empty map should return error, got nil") + return + } + + if !strings.Contains(err.Error(), "prefix not found") { + t.Errorf("error = %q, want error containing \"prefix not found\"", err.Error()) + } +} diff --git a/pkg/calc/settings.go b/pkg/calc/settings.go index 1511e98..753503b 100644 --- a/pkg/calc/settings.go +++ b/pkg/calc/settings.go @@ -2,7 +2,6 @@ package calc import ( "fmt" - "strings" ) // SettingType represents the data type of a setting @@ -15,7 +14,6 @@ const ( // SettingDescriptor contains all metadata for a setting type SettingDescriptor struct { - Name string // e.g., "trace" Type SettingType // bool or int Description string // Help text @@ -30,16 +28,14 @@ type SettingDescriptor struct { } // settingsRegistry is the single source of truth for all settings -var settingsRegistry = []SettingDescriptor{ - { - Name: "trace", +var settingsRegistry = map[string]*SettingDescriptor{ + "trace": { Type: SettingTypeBool, Description: "Enable/disable trace output (on/off)", GetBool: func(c *Calculator) bool { return c.Trace }, SetBool: func(c *Calculator, v bool) { c.Trace = v }, }, - { - Name: "decimal_places", + "decimal_places": { Type: SettingTypeInt, Description: "Number of decimal places to display (integer)", GetInt: func(c *Calculator) int { return c.DecimalPlaces }, @@ -51,52 +47,22 @@ var settingsRegistry = []SettingDescriptor{ return nil }, }, - { - Name: "keep_trailing_zeros", + "keep_trailing_zeros": { Type: SettingTypeBool, Description: "Keep trailing zeros in output (on/off)", GetBool: func(c *Calculator) bool { return c.KeepTrailingZeros }, SetBool: func(c *Calculator, v bool) { c.KeepTrailingZeros = v }, }, - { - Name: "underscore_zeros", + "underscore_zeros": { Type: SettingTypeBool, Description: "Insert underscore before trailing zeros (on/off)", GetBool: func(c *Calculator) bool { return c.UnderscoreZeros }, SetBool: func(c *Calculator, v bool) { c.UnderscoreZeros = v }, }, - { - Name: "verbose", + "verbose": { Type: SettingTypeBool, Description: "Enable verbose output (on/off)", GetBool: func(c *Calculator) bool { return c.Verbose }, SetBool: func(c *Calculator, v bool) { c.Verbose = v }, }, } - -// findSetting looks up a setting by prefix matching (case-insensitive). -// Returns the setting if exactly one match is found. Returns error if no -// matches or multiple (ambiguous) matches. -func findSetting(prefix string) (*SettingDescriptor, error) { - var matches []*SettingDescriptor - prefix = strings.ToLower(prefix) - - for i := range settingsRegistry { - if strings.HasPrefix(settingsRegistry[i].Name, prefix) { - matches = append(matches, &settingsRegistry[i]) - } - } - - if len(matches) == 0 { - return nil, fmt.Errorf("unknown setting: %s", prefix) - } else if len(matches) > 1 { - names := make([]string, len(matches)) - for i, m := range matches { - names[i] = m.Name - } - return nil, fmt.Errorf("ambiguous setting %q, could be one of: %s", - prefix, strings.Join(names, ", ")) - } - - return matches[0], nil -} diff --git a/pkg/calc/settings_test.go b/pkg/calc/settings_test.go deleted file mode 100644 index 11fef80..0000000 --- a/pkg/calc/settings_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package calc - -import ( - "strings" - "testing" -) - -type findSettingTest struct { - name string - prefix string - want string // expected setting name - wantErr bool - errMsg string // substring to check in error message -} - -var findSettingTests = []findSettingTest{ - // Exact matches - {"exact: trace", "trace", "trace", false, ""}, - {"exact: verbose", "verbose", "verbose", false, ""}, - {"exact: decimal_places", "decimal_places", "decimal_places", false, ""}, - {"exact: keep_trailing_zeros", "keep_trailing_zeros", "keep_trailing_zeros", false, ""}, - {"exact: underscore_zeros", "underscore_zeros", "underscore_zeros", false, ""}, - - // Case-insensitive exact matches - {"case: TRACE", "TRACE", "trace", false, ""}, - {"case: Verbose", "Verbose", "verbose", false, ""}, - {"case: Decimal_Places", "Decimal_Places", "decimal_places", false, ""}, - - // Single character prefixes (all currently unambiguous) - {"prefix: t", "t", "trace", false, ""}, - {"prefix: v", "v", "verbose", false, ""}, - {"prefix: d", "d", "decimal_places", false, ""}, - {"prefix: k", "k", "keep_trailing_zeros", false, ""}, - {"prefix: u", "u", "underscore_zeros", false, ""}, - - // Multi-character prefixes - {"prefix: tra", "tra", "trace", false, ""}, - {"prefix: ver", "ver", "verbose", false, ""}, - {"prefix: dec", "dec", "decimal_places", false, ""}, - {"prefix: keep", "keep", "keep_trailing_zeros", false, ""}, - {"prefix: under", "under", "underscore_zeros", false, ""}, - - // Case-insensitive prefixes - {"prefix case: T", "T", "trace", false, ""}, - {"prefix case: V", "V", "verbose", false, ""}, - {"prefix case: TRA", "TRA", "trace", false, ""}, - - // Unknown setting - {"unknown: xyz", "xyz", "", true, "unknown setting"}, - {"unknown: foo", "foo", "", true, "unknown setting"}, - {"unknown: x", "x", "", true, "unknown setting"}, - - // Empty string would match all settings if any existed - {"empty string", "", "", true, "ambiguous"}, -} - -func TestFindSetting(t *testing.T) { - t.Parallel() - - for _, tt := range findSettingTests { - t.Run(tt.name, func(t *testing.T) { - got, err := findSetting(tt.prefix) - if (err != nil) != tt.wantErr { - t.Errorf("findSetting(%q) error = %v, wantErr %v", tt.prefix, err, tt.wantErr) - return - } - - if tt.wantErr { - if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("findSetting(%q) error = %q, want error containing %q", tt.prefix, err.Error(), tt.errMsg) - } - return - } - - if got == nil { - t.Errorf("findSetting(%q) returned nil, want setting %q", tt.prefix, tt.want) - return - } - if got.Name != tt.want { - t.Errorf("findSetting(%q) = %q, want %q", tt.prefix, got.Name, tt.want) - } - }) - } -} - -// TestFindSettingAmbiguous tests that ambiguous prefixes are properly detected. -// Currently all settings start with different letters, so we test with hypothetical -// settings to ensure the ambiguity detection logic works correctly. -func TestFindSettingAmbiguous(t *testing.T) { - _, err := findSetting("") - if err == nil { - t.Error("findSetting should return error for ambiguous match, got nil") - return - } - - errMsg := err.Error() - if !strings.Contains(errMsg, "ambiguous") { - t.Errorf("findSetting error = %q, want error containing \"ambiguous\"", errMsg) - } - - // The error should list all the settings - for _, setting := range settingsRegistry { - if !strings.Contains(errMsg, setting.Name) { - t.Errorf("findSetting(\"\") error should list all settings, missing %q in: %q", setting.Name, errMsg) - } - } -} - -// TestFindSettingAllSettings verifies that all settings in the registry can be found -// by their full name and by their first character. -func TestFindSettingAllSettings(t *testing.T) { - t.Parallel() - - for _, setting := range settingsRegistry { - t.Run("full:"+setting.Name, func(t *testing.T) { - got, err := findSetting(setting.Name) - if err != nil { - t.Errorf("findSetting(%q) error = %v, want nil", setting.Name, err) - return - } - if got.Name != setting.Name { - t.Errorf("findSetting(%q) = %q, want %q", setting.Name, got.Name, setting.Name) - } - }) - - firstChar := string(setting.Name[0]) - t.Run("prefix:"+firstChar, func(t *testing.T) { - got, err := findSetting(firstChar) - // We expect this to succeed since current settings all start with different letters - if err != nil { - t.Errorf("findSetting(%q) error = %v, want nil (current settings have unique first chars)", firstChar, err) - return - } - if got.Name != setting.Name { - t.Errorf("findSetting(%q) = %q, want %q", firstChar, got.Name, setting.Name) - } - }) - } -}