diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..192a2e4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,139 @@ +name: ci +on: + push: + branches: + - master + pull_request: + branches: + - master +env: + GOPATH: ${{ github.workspace }} + WORKING_DIR: ./src/github.com/kevherro/bob/ +jobs: + test-mac: + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: ${{ env.WORKING_DIR }} + strategy: + fail-fast: false + matrix: + go: ['1.20', 'tip'] + os: ['macos-12'] + xcode-version: ['13.1.0', '14.2.0'] + steps: + - name: Update Go version using setup-go + uses: actions/setup-go@v4 + if: matrix.go != 'tip' + with: + go-version: ${{ matrix.go }} + + - name: Update Go version manually + if: matrix.go == 'tip' + working-directory: ${{ github.workspace }} + run: | + git clone https://go.googlesource.com/go $HOME/gotip + cd $HOME/gotip/src + ./make.bash + echo "GOROOT=$HOME/gotip" >> $GITHUB_ENV + echo "RUN_STATICCHECK=false" >> $GITHUB_ENV + echo "RUN_GOLANGCI_LINTER=false" >> $GITHUB_ENV + echo "$HOME/gotip/bin:$PATH" >> $GITHUB_PATH + + - name: Checkout the repo + uses: actions/checkout@v3 + with: + path: ${{ env.WORKING_DIR }} + + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.xcode-version }} + + - name: Fetch dependencies + run: | + # Do not let tools interfere with the main module's go.mod. + cd && go mod init tools + # TODO: Update to a specific version when https://github.com/dominikh/go-tools/issues/1362 is fixed. + go install honnef.co/go/tools/cmd/staticcheck@master + go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.51.0 + # Add PATH for installed tools. + echo "$GOPATH/bin:$PATH" >> $GITHUB_PATH + + - name: Run the script + run: | + go version + ./test.sh + + - name: Check to make sure that tests also work in GOPATH mode + env: + GO111MODULE: off + run: | + go get -d . + go test -v ./... + + - name: Code coverage + uses: codecov/codecov-action@v3 + with: + file: ./coverage.txt + + test-linux: + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: ${{ env.WORKING_DIR }} + strategy: + fail-fast: false + matrix: + go: ['1.20', 'tip'] + os: ['ubuntu-22.04', 'ubuntu-20.04'] + steps: + - name: Update Go version using setup-go + uses: actions/setup-go@v4 + if: matrix.go != 'tip' + with: + go-version: ${{ matrix.go }} + + - name: Update Go version manually + if: matrix.go == 'tip' + working-directory: ${{ github.workspace }} + run: | + git clone https://go.googlesource.com/go $HOME/gotip + cd $HOME/gotip/src + ./make.bash + echo "GOROOT=$HOME/gotip" >> $GITHUB_ENV + echo "RUN_STATICCHECK=false" >> $GITHUB_ENV + echo "RUN_GOLANGCI_LINTER=false" >> $GITHUB_ENV + echo "$HOME/gotip/bin" >> $GITHUB_PATH + + - name: Checkout the repo + uses: actions/checkout@v3 + with: + path: ${{ env.WORKING_DIR }} + + - name: Fetch dependencies + run: | + # Do not let tools interfere with the main module's go.mod. + cd && go mod init tools + # TODO: Update to a specific version when https://github.com/dominikh/go-tools/issues/1362 is fixed. + go install honnef.co/go/tools/cmd/staticcheck@master + go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.51.0 + # Add PATH for installed tools. + echo "$GOPATH/bin:$PATH" >> $GITHUB_PATH + + - name: Run the script + run: | + go version + ./test.sh + + - name: Check to make sure that tests also work in GOPATH mode + env: + GO111MODULE: off + run: | + go get -d . + go test -v ./... + + - name: Code coverage + uses: codecov/codecov-action@v3 + with: + file: ./coverage.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42bbd51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +*~ +*.orig +*.exe +.*.swp +core +coverage.txt +vyx +.idea/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..424af1b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Kevin Herro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2431639 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +[![Github Action CI](https://github.com/kevherro/vyx/workflows/ci/badge.svg)](https://github.com/kevherro/vyx/actions) + +# Introduction + +# Building vyx + +Prerequisites: + +- Go development kit of a [supported version](https://golang.org/doc/devel/release.html#policy). + Follow [these instructions](http://golang.org/doc/code.html) to prepare + the environment. + +To build and install it: + + go install github.com/kevherro/vyx@latest + +The binary will be installed `$GOPATH/bin` (`$HOME/go/bin` by default). + +# Basic Usage diff --git a/driver/driver.go b/driver/driver.go new file mode 100644 index 0000000..04cc225 --- /dev/null +++ b/driver/driver.go @@ -0,0 +1,66 @@ +// MIT License +// +// Copyright (c) 2023 Kevin Herro +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// Package driver provides an external entry point to the vyx driver. +package driver + +import ( + "io" + + internaldriver "github.com/kevherro/vyx/internal/driver" + "github.com/kevherro/vyx/internal/plugin" +) + +// Vyx ... +func Vyx(o *Options) error { + return internaldriver.Vyx(o.internalOptions()) +} + +func (o *Options) internalOptions() *plugin.Options { + return &plugin.Options{ + Writer: o.Writer, + UI: o.UI, + } +} + +// Options groups all the optional plugins into vyx. +type Options struct { + Writer Writer + UI UI +} + +// Writer provides a mechanism to write data under a certain name, +// typically a file name. +type Writer interface { + Open(name string) (io.WriteCloser, error) +} + +// A UI manages user interactions. +type UI interface { + // ReadLine returns a line of text (a command) read from the user. + // prompt is printed before reading the command. + ReadLine(prompt string) (string, error) + + // Print shows a message to the user. + // It formats the text as fmt.Print would and + // adds a final \n if not already present. + // For line-based UI, Print writes to STDERR. + Print(...any) + + // PrintErr shows a message to the user. + // It formats the text as fmt.Print would and + // adds a final \n if not already present. + // For line-based UI, Print writes to STDERR. + PrintErr(...any) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..405f6cb --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/kevherro/vyx + +go 1.20 + +require github.com/chzyer/readline v1.5.1 + +require golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b99eb42 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..32d8a8d --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,62 @@ +// MIT License +// +// Copyright (c) 2023 Kevin Herro +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// Package api implements models for the OpenAI API. +package api + +type CompletionRequest struct { + Prompt string `json:"prompt"` + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + Temperature float64 `json:"temperature"` +} + +type CompletionResponse struct { + ID string `json:"id"` + Object string `json:"object"` + CreatedAt int64 `json:"created_at"` + Model string `json:"model"` + Choices []Choice `json:"choices"` + Completion Completion `json:"completion"` + Conversation Conversation `json:"conversation"` +} + +type Choice struct { + Text string `json:"text"` + Index int `json:"index"` + LogProb float64 `json:"logproba"` +} + +type Completion struct { + ID string `json:"id"` + CreatedAt int64 `json:"created_at"` + Model string `json:"model"` + Prompt string `json:"prompt"` + Choices []Choice `json:"choices"` +} + +type Conversation struct { + ID string `json:"id"` + CreatedAt int64 `json:"created_at"` + Object string `json:"object"` + Messages []Message `json:"messages"` +} + +type Message struct { + ID string `json:"id"` + CreatedAt int64 `json:"created_at"` + Type string `json:"type"` + Author string `json:"author"` + Body string `json:"body"` +} diff --git a/internal/driver/config.go b/internal/driver/config.go new file mode 100644 index 0000000..aade1f7 --- /dev/null +++ b/internal/driver/config.go @@ -0,0 +1,226 @@ +// MIT License +// +// Copyright (c) 2023 Kevin Herro +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +package driver + +import ( + "fmt" + "reflect" + "strconv" + "strings" + "sync" +) + +// config holds settings for a single named config. +// The JSON tag name for a field is used both for JSON +// encoding and as a named variable. +type config struct { + // Filename for file-based output formats, stdout by default. + Output string `json:"-"` + + // OpenAI API options. + MaxTokens int `json:"max_tokens,omitempty"` // The maximum number of tokens to generate in the completion. + Temperature float64 `json:"temperature,omitempty"` // What sampling temperature to use, between 0 and 2. +} + +// fieldPtr returns a pointer to the field identified by f in c. +func (c *config) fieldPtr(f configField) any { + return reflect.ValueOf(c).Elem().FieldByIndex(f.field.Index).Addr().Interface() +} + +// get returns the value of field f in c. +func (c *config) get(f configField) string { + switch ptr := c.fieldPtr(f).(type) { + case *string: + return *ptr + case *bool: + return fmt.Sprint(*ptr) + case *int: + return fmt.Sprint(*ptr) + case *float64: + return fmt.Sprint(*ptr) + } + panic(fmt.Sprintf("unsupported config field type %v", f.field.Type)) +} + +// set sets the value of field f in c to value. +func (c *config) set(f configField, value string) error { + switch ptr := c.fieldPtr(f).(type) { + case *string: + if len(f.choices) > 0 { + // Verify that v is one of the allowed choices. + for _, choice := range f.choices { + if choice == value { + *ptr = value + return nil + } + } + return fmt.Errorf("invalid %q value %q", f.name, value) + } + *ptr = value + case *bool: + v, err := strconv.ParseBool(value) + if err != nil { + return err + } + *ptr = v + case *int: + v, err := strconv.Atoi(value) + if err != nil { + return err + } + *ptr = v + case *float64: + v, err := strconv.ParseFloat(value, 64) + if err != nil { + return err + } + *ptr = v + default: + panic(fmt.Sprintf("unsupported config field type %v", f.field.Type)) + } + return nil +} + +// defaultConfig returns the default configuration values. +// It is not affected by flags and interactive assignments. +func defaultConfig() config { + return config{ + MaxTokens: 2048, + Temperature: 1, + } +} + +// isBoolConfig returns true if name is either the name of a boolean config field, +// or a valid value for a multi-choice config field. +func isBoolConfig(name string) bool { + f, ok := configFieldMap[name] + if !ok { + return false + } + if name != f.name { + return true + } + var c config + _, ok = c.fieldPtr(f).(*bool) + return ok +} + +// isConfigurable returns true if name is either the name of a config field, +// or a valid value for a multi-choice config field. +func isConfigurable(name string) bool { + _, ok := configFieldMap[name] + return ok +} + +// configure stores the name=value mapping into the current config, +// correctly handling the case when name identifies a particular +// choice in a field. +func configure(name, value string) error { + currentCfgMu.Lock() + defer currentCfgMu.Unlock() + f, ok := configFieldMap[name] + if !ok { + return fmt.Errorf("unknown config field %q", name) + } + if f.name == name { + return currentCfg.set(f, value) + } + // name must be one of the choices. If value is true, + // set field-value to name. + if v, err := strconv.ParseBool(value); v && err == nil { + return currentCfg.set(f, name) + } + return fmt.Errorf("unknown config field %q", name) +} + +// currentCfg holds the current configuration values. +// It is affected by flags and interactive assignments. +var currentCfg = defaultConfig() +var currentCfgMu sync.Mutex + +func currentConfig() config { + currentCfgMu.Lock() + defer currentCfgMu.Unlock() + return currentCfg +} + +// configField contains metadata for a single configuration field. +type configField struct { + name string // JSON field name/key in variables. + urlParam string // URL parameter name. + saved bool // Is field saved in settings? + field reflect.StructField // Field in config. + choices []string // Name of variables in group. + defaultValue string // Default value for this field. +} + +var ( + configFields []configField // Precomputed metadata per config field. + + // configFieldMap holds an entry for every config field as well as + // an entry for every valid choice for fields with more than one choice. + configFieldMap map[string]configField +) + +func init() { + // Config names for fields that are NOT saved in settings and + // therefore do NOT have a JSON name. + notSaved := map[string]string{} + + // choices holds the list of allowed values for config fields that + // can take on one of a bounded set of values. + choices := map[string][]string{} + + // urlParam holds the mapping from a config field name to the URL + // parameter used to hold that config field. If no entry is present + // for a name, the corresponding field is not saved in URLs. + urlParam := map[string]string{ + "max_tokens": "maxtokens", + "temperature": "t", + } + + d := defaultConfig() + configFieldMap = map[string]configField{} + t := reflect.TypeOf(config{}) + for i, n := 0, t.NumField(); i < n; i++ { + field := t.Field(i) + json := strings.Split(field.Tag.Get("json"), ",") + if len(json) == 0 { + continue + } + // Get the configuration name for this field. + name := json[0] + if name == "-" { + name = notSaved[field.Name] + if name == "" { + // Not a configuration field. + continue + } + } + f := configField{ + name: name, + urlParam: urlParam[name], + saved: name == json[0], + field: field, + choices: choices[name], + } + f.defaultValue = d.get(f) + configFields = append(configFields, f) + configFieldMap[f.name] = f + for _, choice := range f.choices { + configFieldMap[choice] = f + } + } +} diff --git a/internal/driver/driver.go b/internal/driver/driver.go new file mode 100644 index 0000000..4b2737c --- /dev/null +++ b/internal/driver/driver.go @@ -0,0 +1,23 @@ +// MIT License +// +// Copyright (c) 2023 Kevin Herro +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// Package driver implements the core vyx functionality. +package driver + +import "github.com/kevherro/vyx/internal/plugin" + +func Vyx(eo *plugin.Options) error { + o := setDefaults(eo) + return interactive(o) +} diff --git a/internal/driver/interactive.go b/internal/driver/interactive.go new file mode 100644 index 0000000..abe8a9f --- /dev/null +++ b/internal/driver/interactive.go @@ -0,0 +1,186 @@ +// MIT License +// +// Copyright (c) 2023 Kevin Herro +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +package driver + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" + + "github.com/kevherro/vyx/internal/api" + "github.com/kevherro/vyx/internal/plugin" +) + +var commentStart = "//:" // Sentinel for comments on options. + +func interactive(o *plugin.Options) error { + // Enter the command processing loop. + greetings(o.UI) + for { + input, err := o.UI.ReadLine("(vyx) ") + if err != nil { + if err != io.EOF { + return err + } + if input == "" { + return nil + } + } + + // Process assignments of the form variable=value. + if s := strings.SplitN(input, "=", 2); len(s) > 0 { + name := strings.TrimSpace(s[0]) + var value string + if len(s) == 2 { + value = s[1] + if comment := strings.LastIndex(value, commentStart); comment != -1 { + value = value[:comment] + } + value = strings.TrimSpace(value) + } + if isConfigurable(name) { + // All non-bool options require inputs. + if len(s) == 1 && !isBoolConfig(name) { + o.UI.PrintErr(fmt.Errorf("please specify a value, e.g. %s=", name)) + continue + } + if err := configure(name, value); err != nil { + o.UI.PrintErr(err) + } + continue + } + } + + tokens := strings.Fields(input) + if len(tokens) == 0 { + continue + } + + switch tokens[0] { + case "o", "options": + printCurrentOptions(o.UI) + continue + case "help": + commandHelp(strings.Join(tokens[1:], " "), o.UI) + continue + case "exit", "quit", "q": + return nil + } + + reply, err := parseTokens(tokens) + if err == nil { + o.UI.Print(strings.Join(reply, " ")) + } + if err != nil { + o.UI.PrintErr(err) + } + } +} + +func greetings(ui plugin.UI) { + ui.Print(`Entering interactive mode (type "help" for commands, "o" for options)`) +} + +func printCurrentOptions(ui plugin.UI) { + var args []string + c := currentConfig() + for _, f := range configFields { + n := f.name + v := c.get(f) + comment := "" + switch { + case len(f.choices) > 0: + values := append([]string{}, f.choices...) + sort.Strings(values) + comment = "[" + strings.Join(values, " | ") + "]" + case n == "temperature" && v == "1": + comment = "default" + case n == "n" && v == "1": + comment = "default" + case v == "": + // Add quotes for empty values. + v = `""` + } + if comment != "" { + comment = commentStart + " " + comment + } + args = append(args, fmt.Sprintf(" %-25s = %-20s %s", n, v, comment)) + } + sort.Strings(args) + ui.Print(strings.Join(args, "\n")) +} + +func commandHelp(args string, ui plugin.UI) { + ui.Print(args) +} + +const ( + completionURL = "https://api.openai.com/v1/completions" + model = "text-davinci-003" +) + +func parseTokens(input []string) ([]string, error) { + prompt := strings.Join(input, " ") + cfg := currentConfig() + payload := &api.CompletionRequest{ + Prompt: prompt, + Model: model, + MaxTokens: cfg.MaxTokens, + Temperature: cfg.Temperature, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", completionURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", os.Getenv("OPENAI_API_KEY"))) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + var completionResponse api.CompletionResponse + err = json.Unmarshal(body, &completionResponse) + if err != nil { + return nil, err + } + + if len(completionResponse.Choices) == 0 { + if os.Getenv("OPENAI_API_KEY") == "" { + return strings.Fields("vyx: missing OPENAI_API_KEY"), nil + } + return strings.Fields("vyx: unable to generate a response"), nil + } + choice := completionResponse.Choices[0] + text := choice.Text + + return strings.Fields(text), nil +} diff --git a/internal/driver/options.go b/internal/driver/options.go new file mode 100644 index 0000000..96fc56c --- /dev/null +++ b/internal/driver/options.go @@ -0,0 +1,72 @@ +// MIT License +// +// Copyright (c) 2023 Kevin Herro +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +package driver + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" + + "github.com/kevherro/vyx/internal/plugin" +) + +func setDefaults(o *plugin.Options) *plugin.Options { + d := &plugin.Options{} + if o != nil { + *d = *o + } + if d.Writer == nil { + d.Writer = writer{} + } + if d.UI == nil { + d.UI = &stdUI{r: bufio.NewReader(os.Stdin)} + } + return d +} + +type stdUI struct { + r *bufio.Reader +} + +func (ui *stdUI) ReadLine(prompt string) (string, error) { + os.Stdout.WriteString(prompt) + return ui.r.ReadString('\n') +} + +func (ui *stdUI) Print(args ...any) { + ui.fPrintf(os.Stderr, args) +} + +func (ui *stdUI) PrintErr(args ...any) { + ui.fPrintf(os.Stderr, args) +} + +func (ui *stdUI) fPrintf(f *os.File, args []any) { + text := fmt.Sprint(args...) + if !strings.HasSuffix(text, "\n") { + text += "\n" + } + f.WriteString(text) +} + +// writer implements the Writer interface using a regular file. +type writer struct{} + +func (writer) Open(name string) (io.WriteCloser, error) { + f, err := os.Create(name) + return f, err +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go new file mode 100644 index 0000000..0a66637 --- /dev/null +++ b/internal/plugin/plugin.go @@ -0,0 +1,48 @@ +// MIT License +// +// Copyright (c) 2023 Kevin Herro +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +package plugin + +import "io" + +// Options groups all the optional plugins into vyx. +type Options struct { + Writer Writer + UI UI +} + +// Writer provides a mechanism to write data under a certain name, +// typically a file name. +type Writer interface { + Open(name string) (io.WriteCloser, error) +} + +// A UI manages user interactions. +type UI interface { + // ReadLine returns a line of text (a command) read from the user. + // prompt is printed before reading the command. + ReadLine(prompt string) (string, error) + + // Print shows a message to the user. + // It formats the text as fmt.Print would and + // adds a final \n if not already present. + // For line-based UI, Print writes to STDERR. + Print(...any) + + // PrintErr shows a message to the user. + // It formats the text as fmt.Print would and + // adds a final \n if not already present. + // For line-based UI, Print writes to STDERR. + PrintErr(...any) +} diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..b80468e --- /dev/null +++ b/test.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +# MIT License +# +# Copyright (c) 2023 Kevin Herro +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +set -e +set -x +MODE=atomic +echo "mode: $MODE" > coverage.txt + +if [ "$RUN_STATICCHECK" != "false" ]; then + staticcheck ./... +fi + +# Packages that have any tests. +PKG=$(go list -f '{{if .TestGoFiles}} {{.ImportPath}} {{end}}' ./...) + +go test -v "$PKG" + +for d in $PKG; do + go test -race -coverprofile=profile.out -covermode=$MODE "$d" + if [ -f profile.out ]; then + # shellcheck disable=SC2002 + cat profile.out | grep -v "^mode: " >> coverage.txt + rm profile.out + fi +done + +go vet -all ./... +if [ "$RUN_GOLANGCI_LINTER" != "false" ]; then + golangci-lint run -D errcheck ./... # TODO: Enable errcheck back. +fi + +gofmt -s -d . diff --git a/vyx.go b/vyx.go new file mode 100644 index 0000000..58a63dc --- /dev/null +++ b/vyx.go @@ -0,0 +1,85 @@ +// MIT License +// +// Copyright (c) 2023 Kevin Herro +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +package main + +import ( + "fmt" + "os" + "strings" + "syscall" + + "github.com/chzyer/readline" + "github.com/kevherro/vyx/driver" +) + +func main() { + if err := driver.Vyx(&driver.Options{UI: newUI()}); err != nil { + fmt.Fprintf(os.Stderr, "vyx: %v\n", err) + os.Exit(2) + } +} + +type readlineUI struct { + rl *readline.Instance +} + +func newUI() driver.UI { + rl, err := readline.New("") + if err != nil { + fmt.Fprintf(os.Stderr, "readline: %v", err) + return nil + } + return &readlineUI{ + rl: rl, + } +} + +// ReadLine returns a line of text (a command) read from the user. +// prompt is printed before reading the command. +func (r *readlineUI) ReadLine(prompt string) (string, error) { + r.rl.SetPrompt(prompt) + return r.rl.Readline() +} + +// Print shows a message to the user. +// It is printed over stderr as stdout is reserved for regular output. +func (r *readlineUI) Print(args ...any) { + text := fmt.Sprint(args...) + if !strings.HasSuffix(text, "\n") { + text += "\n" + } + fmt.Fprint(r.rl.Stderr(), text) +} + +// PrintErr shows a message to the user, colored in red for emphasis. +// It is printed over stderr as stdout is reserved for regular output. +func (r *readlineUI) PrintErr(args ...any) { + text := fmt.Sprint(args...) + if !strings.HasSuffix(text, "\n") { + text += "\n" + } + if readline.IsTerminal(syscall.Stderr) { + text = colorize(text) + } + fmt.Fprint(r.rl.Stderr(), text) +} + +// colorize the msg using ANSI color escapes. +func colorize(msg string) string { + var red = 31 + var colorEscape = fmt.Sprintf("\033[0;%dm", red) + var colorResetEscape = "\033[0m" + return colorEscape + msg + colorResetEscape +}