diff --git a/.gitignore b/.gitignore index e660fd9..2da0f38 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ bin/ +repl.log diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..2e8dc59 --- /dev/null +++ b/commands.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "os" + "sort" +) + +type context struct { + commands map[string]cliCommand +} + +func (r *context) register(c cliCommand) { + if c.name != "" { + r.commands[c.name] = c + } +} + +func NewContext() *context { + var exitCommand = cliCommand{ + name: "exit", + description: "Exit the Pokedex", + callback: commandExit, + } + + var helpCommand = cliCommand{ + name: "help", + description: "Displays a help message", + callback: commandHelp, + } + + c := &context{ + commands: map[string]cliCommand{}, + } + + c.register(exitCommand) + c.register(helpCommand) + + return c +} + +type commandFunc func(c *context) error + +type cliCommand struct { + name string + description string + callback commandFunc +} + +func commandExit(c *context) error { + fmt.Println("Closing the Pokedex... Goodbye!") + os.Exit(0) + return nil +} + +func commandHelp(c *context) error { + fmt.Println("Welcome to the Pokedex!") + fmt.Println("Usage:") + fmt.Println("") + + sortedKeys := []string{} + + for k := range c.commands { + sortedKeys = append(sortedKeys, k) + } + + sort.Strings(sortedKeys) + + for _, key := range sortedKeys { + command := c.commands[key] + fmt.Printf("%s: %s\n", command.name, command.description) + } + + return nil +} diff --git a/commands_test.go b/commands_test.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/commands_test.go @@ -0,0 +1 @@ +package main diff --git a/main.go b/main.go index a3dd973..4c127aa 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,10 @@ package main -import "fmt" +import ( + "bufio" + "os" +) func main() { - fmt.Println("Hello, World!") + runREPL(bufio.NewScanner(os.Stdin), os.Stdout, cleanInput) } diff --git a/repl.go b/repl.go new file mode 100644 index 0000000..3628560 --- /dev/null +++ b/repl.go @@ -0,0 +1,67 @@ +package main + +import ( + "fmt" + "io" + "regexp" + "strings" +) + +func cleanInput(input string) []string { + normalized := strings.ToLower(input) + trimmed := strings.TrimSpace(normalized) + + re := regexp.MustCompile(`[\s]+`) + + words := re.Split(trimmed, -1) + return words +} + +type inputScanner interface { + Scan() bool + Err() error + Text() string +} + +func runREPL(s inputScanner, w io.Writer, sanitizer func(string) []string) { + context := NewContext() + + for { + fmt.Fprint(w, "Pokedex > ") + + s.Scan() + err := s.Err() + + if err != nil { + fmt.Fprintf(w, "Pokedex > Sorry I didn't get that.\n") + continue + } + + input := s.Text() + + if input == "" { + break + } + + cleanedInput := sanitizer(input) + + if len(cleanedInput) < 1 { + fmt.Fprintf(w, "Pokedex > Sorry I couldn't understand that.\n") + continue + } + + requestedCommand := cleanedInput[0] + command, exists := context.commands[requestedCommand] + + if !exists { + fmt.Fprintln(w, "Unknown command") + continue + } + + err = command.callback(context) + + if err != nil { + fmt.Fprintf(w, "Pokedex > Error occurred executing command: %s", err) + } + } +} diff --git a/repl_test.go b/repl_test.go new file mode 100644 index 0000000..ae1a075 --- /dev/null +++ b/repl_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "bufio" + "bytes" + "errors" + "slices" + "strings" + "testing" +) + +func TestCleanInput(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + expected []string + }{ + { + name: "it should split words at whitepspace", + input: "stevan reece freeborn", + expected: []string{"stevan", "reece", "freeborn"}, + }, + { + name: "it should trim leading and or training whitespace", + input: " hello world ", + expected: []string{"hello", "world"}, + }, + { + name: "it should normalize words to lowercase", + input: "got to catch them ALL", + expected: []string{"got", "to", "catch", "them", "all"}, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + result := cleanInput(c.input) + + if slices.Equal(result, c.expected) { + return + } + + t.Errorf("received %v but expected %v", result, c.expected) + }) + } +} + +type erroringInputScanner struct { + timesCalled int +} + +func (m *erroringInputScanner) Err() error { + if m.timesCalled == 0 { + m.timesCalled += 1 + return errors.New("oh no!") + } + + return nil +} + +func (m *erroringInputScanner) Text() string { + return "" +} + +func (m *erroringInputScanner) Scan() bool { + return true +} + +func TestRunRepl(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + expected string + getScanner func(*strings.Reader) inputScanner + sanitizer func(string) []string + }{ + { + name: "it should print custom prompt", + input: "", + expected: "Pokedex > ", + getScanner: func(r *strings.Reader) inputScanner { + return bufio.NewScanner(r) + }, + sanitizer: cleanInput, + }, + { + name: "it should print appropriate error message when scan fails", + input: "", + expected: "Pokedex > Sorry I didn't get that.", + getScanner: func(r *strings.Reader) inputScanner { + return &erroringInputScanner{} + }, + sanitizer: cleanInput, + }, + { + name: "it should print appropriate error message when sanitizer returns no words", + input: "Hi! my command is hello\n", + expected: "Pokedex > Sorry I couldn't understand that.", + getScanner: func(r *strings.Reader) inputScanner { + return bufio.NewScanner(r) + }, + sanitizer: func(s string) []string { + return []string{} + }, + }, + { + name: "it should print expected message for unknown command", + input: "STFU please\n", + expected: "Unknown command", + getScanner: func(r *strings.Reader) inputScanner { + return bufio.NewScanner(r) + }, + sanitizer: cleanInput, + }, + // TODO: Need to refactor after considering os.exit call + // { + // name: "it should execute command if found", + // input: "EXIT\n", + // expected: "Closing the Pokedex... Goodbye!\n", + // getScanner: func(r *strings.Reader) inputScanner { + // return bufio.NewScanner(r) + // }, + // sanitizer: cleanInput, + // }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + in := strings.NewReader(c.input) + out := new(bytes.Buffer) + + runREPL(c.getScanner(in), out, c.sanitizer) + + result := out.String() + + if strings.Contains(result, c.expected) { + return + } + + t.Errorf("expected %q to contain %q", result, c.expected) + }) + } +} diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..8091974 --- /dev/null +++ b/test.sh @@ -0,0 +1 @@ +go test ./... -v