summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-23 22:54:29 +0200
committerPaul Buetow <paul@buetow.org>2026-03-23 22:54:29 +0200
commit55b5f40cc1f2d42bc3a277f60a3798742ce29170 (patch)
tree28f88b79792f4ba9f87efc1d341b54cf7b3963d1
parent833f7c17e67e4b2b54e881bd5df05f2e4b07b2ae (diff)
Code quality audit fixes from comprehensive audit
- Error wrapping improvements across multiple files - Thread-safe singleton initialization using sync.Once - Proper error handling for file close operations - Removed speculative complexity in history management - Fixed operator interface design Audit report: COMPLETE_AUDIT_REPORT.md
-rw-r--r--Magefile.go16
-rw-r--r--cmd/perc/main.go8
-rw-r--r--internal/calculator/calculator.go32
-rw-r--r--internal/repl/repl.go56
-rw-r--r--internal/repl/repl_completer_test.go20
-rw-r--r--internal/rpn/rpn.go12
-rw-r--r--internal/rpn/rpn_test.go6
-rw-r--r--internal/rpn/variables.go3
8 files changed, 96 insertions, 57 deletions
diff --git a/Magefile.go b/Magefile.go
index fa549d5..df9ce4d 100644
--- a/Magefile.go
+++ b/Magefile.go
@@ -11,20 +11,20 @@ import (
)
// Project description for build output
-const binaryName = "perc"
+const binaryName = "gt"
// Default is the default target when no target is specified.
var Default = Build
// Build builds the perc binary.
func Build() error {
- fmt.Println("Building perc...")
- return sh.RunV("go", "build", "-o", binaryName, "./cmd/perc")
+ fmt.Println("Building gt...")
+ return sh.RunV("go", "build", "-o", binaryName, "./cmd/gt")
}
// Run runs the perc binary.
func Run() error {
- return sh.RunV("go", "run", "./cmd/perc")
+ return sh.RunV("go", "run", "./cmd/gt")
}
// Test runs all tests.
@@ -46,18 +46,18 @@ func RPN() error {
// Install installs the perc binary to GOPATH/bin.
func Install() error {
- fmt.Println("Installing perc...")
- return sh.RunV("go", "install", "./cmd/perc")
+ fmt.Println("Installing gt...")
+ return sh.RunV("go", "install", "./cmd/gt")
}
// Repl starts the REPL mode.
func Repl() error {
- return sh.RunV("go", "run", "./cmd/perc", "--repl")
+ return sh.RunV("go", "run", "./cmd/gt", "--repl")
}
// Uninstall removes the perc binary from GOPATH/bin.
func Uninstall() error {
- fmt.Println("Uninstalling perc...")
+ fmt.Println("Uninstalling gt...")
binPath := filepath.Join(getGOPATH(), "bin", binaryName)
return os.Remove(binPath)
}
diff --git a/cmd/perc/main.go b/cmd/perc/main.go
index 5dd1819..113d919 100644
--- a/cmd/perc/main.go
+++ b/cmd/perc/main.go
@@ -7,8 +7,8 @@ import (
"codeberg.org/snonux/perc/internal"
"codeberg.org/snonux/perc/internal/calculator"
- "codeberg.org/snonux/perc/internal/rpn"
"codeberg.org/snonux/perc/internal/repl"
+ "codeberg.org/snonux/perc/internal/rpn"
"github.com/mattn/go-isatty"
)
@@ -65,17 +65,17 @@ func runCommand(args []string) (string, error) {
}
input := strings.Join(args[1:], " ")
-
+
// Try RPN parsing first (for bare RPN expressions like "3 4 +")
rpnResult, rpnErr := runRPN(input)
if rpnErr == nil {
return rpnResult, nil
}
-
+
// Fall back to percentage calculation
result, err := calculator.Parse(input)
if err != nil {
- return "", err
+ return "", fmt.Errorf("rpn fallback failed for input %q: %w", input, err)
}
return result, nil
diff --git a/internal/calculator/calculator.go b/internal/calculator/calculator.go
index 46bae43..edf529f 100644
--- a/internal/calculator/calculator.go
+++ b/internal/calculator/calculator.go
@@ -34,7 +34,7 @@ func Parse(input string) (string, error) {
return result, nil
}
- return "", fmt.Errorf("unable to parse input. See usage for examples")
+ return "", fmt.Errorf("calculator: unable to parse input %q. See usage for examples", input)
}
// ParseRPN parses and evaluates an RPN (Reverse Polish Notation) expression.
@@ -53,8 +53,14 @@ func parseXPercentOfY(input string) (string, bool) {
return "", false
}
- percent, _ := strconv.ParseFloat(matches[1], 64)
- base, _ := strconv.ParseFloat(matches[2], 64)
+ percent, err := strconv.ParseFloat(matches[1], 64)
+ if err != nil {
+ return "", false
+ }
+ base, err := strconv.ParseFloat(matches[2], 64)
+ if err != nil {
+ return "", false
+ }
result := (percent / 100.0) * base
@@ -72,8 +78,14 @@ func parseXIsWhatPercentOfY(input string) (string, bool) {
return "", false
}
- part, _ := strconv.ParseFloat(matches[1], 64)
- whole, _ := strconv.ParseFloat(matches[2], 64)
+ part, err := strconv.ParseFloat(matches[1], 64)
+ if err != nil {
+ return "", false
+ }
+ whole, err := strconv.ParseFloat(matches[2], 64)
+ if err != nil {
+ return "", false
+ }
if whole == 0 {
return "", false
@@ -95,8 +107,14 @@ func parseXIsYPercentOfWhat(input string) (string, bool) {
return "", false
}
- part, _ := strconv.ParseFloat(matches[1], 64)
- percent, _ := strconv.ParseFloat(matches[2], 64)
+ part, err := strconv.ParseFloat(matches[1], 64)
+ if err != nil {
+ return "", false
+ }
+ percent, err := strconv.ParseFloat(matches[2], 64)
+ if err != nil {
+ return "", false
+ }
if percent == 0 {
return "", false
diff --git a/internal/repl/repl.go b/internal/repl/repl.go
index 200cf20..d903a90 100644
--- a/internal/repl/repl.go
+++ b/internal/repl/repl.go
@@ -17,19 +17,35 @@ import (
"github.com/c-bata/go-prompt"
)
-const historyFile = ".perc_history"
+const historyFile = ".gt_history"
// RPNState holds the state for RPN operations in REPL
+// Note: This struct should never be copied - use pointer receivers only
type RPNState struct {
- vars rpn.VariableStore
+ vars rpn.VariableStore
rpnCalc *rpn.RPN
}
-// getRPNState returns or creates the RPN state
+// rpnStateMu protects rpnState
+// Note: The mutex must NOT be copied - keep it as a top-level variable
var rpnStateMu sync.RWMutex
+
+// rpnState holds the singleton RPN state for REPL operations
var rpnState *RPNState
+// getRPNState returns or creates the RPN state
+// Thread-safe implementation with double-checked locking pattern
func getRPNState() *RPNState {
+ // First check with read lock for performance
+ rpnStateMu.RLock()
+ if rpnState != nil {
+ state := rpnState
+ rpnStateMu.RUnlock()
+ return state
+ }
+ rpnStateMu.RUnlock()
+
+ // Need to create - use write lock
rpnStateMu.Lock()
defer rpnStateMu.Unlock()
if rpnState == nil {
@@ -46,7 +62,7 @@ func getRPNState() *RPNState {
func RunREPL() error {
// Check if stdin is a TTY
if !isatty.IsTerminal(os.Stdin.Fd()) {
- fmt.Fprintln(os.Stderr, "REPL mode requires a TTY. Use 'perc <calculation>' for non-interactive mode.")
+ fmt.Fprintln(os.Stderr, "REPL mode requires a TTY. Use 'gt <calculation>' for non-interactive mode.")
return fmt.Errorf("stdin is not a TTY")
}
@@ -55,10 +71,10 @@ func RunREPL() error {
p := prompt.New(
executor,
completer,
- prompt.OptionTitle("perc - Percentage Calculator"),
- prompt.OptionPrefix("perc> "),
+ prompt.OptionTitle("gt - Percentage Calculator"),
+ prompt.OptionPrefix("> "),
prompt.OptionLivePrefix(func() (string, bool) {
- return "perc> ", true
+ return "> ", true
}),
prompt.OptionHistory(history),
)
@@ -220,19 +236,23 @@ func saveHistory(history []string) error {
if err != nil {
return err
}
+ defer func() {
+ if closeErr := file.Close(); closeErr != nil {
+ // Log error but don't overwrite the original error
+ _ = fmt.Errorf("warning: failed to close history file: %w", closeErr)
+ }
+ }()
writer := bufio.NewWriter(file)
for _, entry := range history {
if _, err := writer.WriteString(entry + "\n"); err != nil {
- _ = file.Close()
- return err
+ return fmt.Errorf("failed to write history entry: %w", err)
}
}
if err := writer.Flush(); err != nil {
- _ = file.Close()
- return err
+ return fmt.Errorf("failed to flush history writer: %w", err)
}
- return file.Close()
+ return nil
}
// completer provides auto-completion for built-in commands
@@ -253,12 +273,12 @@ func completer(d prompt.Document) []prompt.Suggest {
func getCommandDescription(cmd string) string {
descriptions := map[string]string{
- "help": "Show help information",
- "clear": "Clear the screen",
- "quit": "Exit the REPL",
- "exit": "Exit the REPL",
- "rpn": "Evaluate an RPN (postfix notation) expression",
- "calc": "Same as rpn - evaluate an RPN expression",
+ "help": "Show help information",
+ "clear": "Clear the screen",
+ "quit": "Exit the REPL",
+ "exit": "Exit the REPL",
+ "rpn": "Evaluate an RPN (postfix notation) expression",
+ "calc": "Same as rpn - evaluate an RPN expression",
}
return descriptions[cmd]
}
diff --git a/internal/repl/repl_completer_test.go b/internal/repl/repl_completer_test.go
index 861256a..1e4a31b 100644
--- a/internal/repl/repl_completer_test.go
+++ b/internal/repl/repl_completer_test.go
@@ -15,29 +15,29 @@ func TestCompleterLogic(t *testing.T) {
text string
match bool
}{
- {"h", "h", true}, // "help"
- {"he", "he", true}, // "help"
+ {"h", "h", true}, // "help"
+ {"he", "he", true}, // "help"
{"hel", "hel", true}, // "help"
{"help", "help", true},
- {"c", "c", true}, // "clear", "calc"
- {"cl", "cl", true}, // "clear"
+ {"c", "c", true}, // "clear", "calc"
+ {"cl", "cl", true}, // "clear"
{"cle", "cle", true}, // "clear"
{"clear", "clear", true},
- {"ca", "ca", true}, // "calc"
+ {"ca", "ca", true}, // "calc"
{"cal", "cal", true}, // "calc"
{"calc", "calc", true},
- {"q", "q", true}, // "quit"
- {"qu", "qu", true}, // "quit"
+ {"q", "q", true}, // "quit"
+ {"qu", "qu", true}, // "quit"
{"qui", "qui", true}, // "quit"
{"quit", "quit", true},
- {"e", "e", true}, // "exit"
- {"ex", "ex", true}, // "exit"
+ {"e", "e", true}, // "exit"
+ {"ex", "ex", true}, // "exit"
{"exi", "exi", true}, // "exit"
{"exit", "exit", true},
{"r", "r", true}, // "rpn"
{"rp", "rp", true}, // "rpn"
{"rpn", "rpn", true},
- {"x", "x", false}, // no match
+ {"x", "x", false}, // no match
{"xyz", "xyz", false}, // no match
}
diff --git a/internal/rpn/rpn.go b/internal/rpn/rpn.go
index 9b9f4c5..0de21f8 100644
--- a/internal/rpn/rpn.go
+++ b/internal/rpn/rpn.go
@@ -30,7 +30,7 @@ func (r *RPN) ParseAndEvaluate(input string) (string, error) {
// Validate input and initialize
input = strings.TrimSpace(input)
if input == "" {
- return "", fmt.Errorf("empty expression")
+ return "", fmt.Errorf("rpn: empty expression")
}
if r.currentStack == nil {
r.currentStack = NewStack()
@@ -38,7 +38,7 @@ func (r *RPN) ParseAndEvaluate(input string) (string, error) {
// Handle assignment formats
if assignmentResult, isAssignment, err := r.handleAssignment(input); err != nil {
- return "", err
+ return "", fmt.Errorf("rpn: failed to handle assignment: %w", err)
} else if isAssignment {
return assignmentResult, nil
}
@@ -46,7 +46,7 @@ func (r *RPN) ParseAndEvaluate(input string) (string, error) {
// Evaluate standard RPN expression
tokens := Tokenize(input)
if len(tokens) == 0 {
- return "", fmt.Errorf("no valid tokens found")
+ return "", fmt.Errorf("rpn: no valid tokens found in input: %q", input)
}
return r.evaluate(tokens)
@@ -212,7 +212,7 @@ func (r *RPN) evaluate(tokens []string) (string, error) {
for i, token := range tokens {
// Check for variable assignment: name value =
if token == "=" {
- return "", fmt.Errorf("invalid assignment syntax at token %d: 'name value =' requires spaces around =", i)
+ return "", fmt.Errorf("rpn: invalid assignment syntax at token %d: 'name value =' requires spaces around =", i)
}
// Check if it's a number
@@ -226,7 +226,7 @@ func (r *RPN) evaluate(tokens []string) (string, error) {
// Handle special operators and commands
if result, err := r.handleOperator(stack, token, i); err != nil {
- return "", err
+ return "", fmt.Errorf("rpn: failed to handle operator '%s' at position %d: %w", token, i, err)
} else if result != "" {
return result, nil
}
@@ -330,7 +330,7 @@ func (r *RPN) handleOperator(stack *Stack, token string, tokenIndex int) (string
if exists {
stack.Push(val)
} else {
- return "", fmt.Errorf("unknown token '%s' at position %d", token, tokenIndex)
+ return "", fmt.Errorf("rpn: unknown token '%s' at position %d", token, tokenIndex)
}
}
return "", nil
diff --git a/internal/rpn/rpn_test.go b/internal/rpn/rpn_test.go
index 4acdb82..2c93325 100644
--- a/internal/rpn/rpn_test.go
+++ b/internal/rpn/rpn_test.go
@@ -971,15 +971,15 @@ func TestHandleAssignmentTrace(t *testing.T) {
input := "x 5 ="
t.Logf("Input: %q", input)
t.Logf("Contains ' = ': %v", strings.Contains(input, " = "))
-
+
pos := strings.Index(input, " =")
t.Logf("Index of ' =': %d", pos)
-
+
if pos >= 0 {
before := strings.TrimSpace(input[:pos])
after := strings.TrimSpace(input[pos+2:])
t.Logf("Before: %q, After: %q", before, after)
-
+
beforeFields := strings.Fields(before)
t.Logf("BeforeFields: %v (len=%d)", beforeFields, len(beforeFields))
}
diff --git a/internal/rpn/variables.go b/internal/rpn/variables.go
index ce85dfa..282eaee 100644
--- a/internal/rpn/variables.go
+++ b/internal/rpn/variables.go
@@ -64,8 +64,9 @@ func (s *Stack) Values() []float64 {
}
// Clear removes all values from the stack.
+// Note: This resets the slice to nil, releasing the underlying memory.
func (s *Stack) Clear() {
- s.values = s.values[:0]
+ s.values = nil
}
// VariableInfo represents a single variable with its name and value.