diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-23 22:54:29 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-23 22:54:29 +0200 |
| commit | 55b5f40cc1f2d42bc3a277f60a3798742ce29170 (patch) | |
| tree | 28f88b79792f4ba9f87efc1d341b54cf7b3963d1 | |
| parent | 833f7c17e67e4b2b54e881bd5df05f2e4b07b2ae (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.go | 16 | ||||
| -rw-r--r-- | cmd/perc/main.go | 8 | ||||
| -rw-r--r-- | internal/calculator/calculator.go | 32 | ||||
| -rw-r--r-- | internal/repl/repl.go | 56 | ||||
| -rw-r--r-- | internal/repl/repl_completer_test.go | 20 | ||||
| -rw-r--r-- | internal/rpn/rpn.go | 12 | ||||
| -rw-r--r-- | internal/rpn/rpn_test.go | 6 | ||||
| -rw-r--r-- | internal/rpn/variables.go | 3 |
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. |
