From e7347b5928ee7d9e26699a4390d407a7331f886e Mon Sep 17 00:00:00 2001 From: Chmouel Boudjnah Date: Fri, 27 Jun 2025 23:43:34 +0200 Subject: [PATCH] feat: Add CEL expression evaluator command This commit introduces a new command `tkn pac cel` that allows users to interactively evaluate CEL (Common Expression Language) expressions. The command is designed to help users test and debug CEL expressions, which are commonly used in Pipelines-as-Code. Key features include: - Interactive and non-interactive modes. - Support for webhook payloads and headers. - Provider auto-detection (GitHub, GitLab, Bitbucket Cloud, Bitbucket Data Center, Gitea). - Direct access to variables as per PAC documentation. - Cross-platform history with readline experience. - Comprehensive help and example expressions. Signed-off-by: Chmouel Boudjnah feat: Support gosmee-generated shell scripts for headers - Allow the CLI to parse headers from gosmee scripts. - Add support for JSON, plain text and gosmee scripts header formats. - Update documentation to reflect the changes in the header file formats. - Add tests to validate the script and header parsing. Signed-off-by: Chmouel Boudjnah --- docs/content/docs/guide/cli.md | 105 + go.mod | 3 +- go.sum | 7 + pkg/cel/cel.go | 14 + pkg/cmd/tknpac/cel/cel.go | 1099 ++++++++++ pkg/cmd/tknpac/cel/cel_test.go | 1829 +++++++++++++++++ pkg/cmd/tknpac/cel/templates/help.tmpl | 38 + pkg/cmd/tknpac/root.go | 2 + vendor/github.com/chzyer/readline/.gitignore | 1 + vendor/github.com/chzyer/readline/.travis.yml | 8 + .../github.com/chzyer/readline/CHANGELOG.md | 58 + vendor/github.com/chzyer/readline/LICENSE | 22 + vendor/github.com/chzyer/readline/README.md | 114 + .../chzyer/readline/ansi_windows.go | 249 +++ vendor/github.com/chzyer/readline/complete.go | 285 +++ .../chzyer/readline/complete_helper.go | 165 ++ .../chzyer/readline/complete_segment.go | 82 + vendor/github.com/chzyer/readline/history.go | 330 +++ .../github.com/chzyer/readline/operation.go | 537 +++++ vendor/github.com/chzyer/readline/password.go | 33 + .../chzyer/readline/rawreader_windows.go | 125 ++ vendor/github.com/chzyer/readline/readline.go | 338 +++ vendor/github.com/chzyer/readline/remote.go | 475 +++++ vendor/github.com/chzyer/readline/runebuf.go | 629 ++++++ vendor/github.com/chzyer/readline/runes.go | 223 ++ vendor/github.com/chzyer/readline/search.go | 164 ++ vendor/github.com/chzyer/readline/std.go | 197 ++ .../github.com/chzyer/readline/std_windows.go | 9 + vendor/github.com/chzyer/readline/term.go | 123 ++ vendor/github.com/chzyer/readline/term_bsd.go | 29 + .../github.com/chzyer/readline/term_linux.go | 33 + .../chzyer/readline/term_nosyscall6.go | 32 + .../github.com/chzyer/readline/term_unix.go | 24 + .../chzyer/readline/term_windows.go | 171 ++ vendor/github.com/chzyer/readline/terminal.go | 254 +++ vendor/github.com/chzyer/readline/utils.go | 311 +++ .../github.com/chzyer/readline/utils_unix.go | 83 + .../chzyer/readline/utils_windows.go | 41 + vendor/github.com/chzyer/readline/vim.go | 176 ++ .../github.com/chzyer/readline/windows_api.go | 152 ++ vendor/modules.txt | 3 + 41 files changed, 8572 insertions(+), 1 deletion(-) create mode 100644 pkg/cmd/tknpac/cel/cel.go create mode 100644 pkg/cmd/tknpac/cel/cel_test.go create mode 100644 pkg/cmd/tknpac/cel/templates/help.tmpl create mode 100644 vendor/github.com/chzyer/readline/.gitignore create mode 100644 vendor/github.com/chzyer/readline/.travis.yml create mode 100644 vendor/github.com/chzyer/readline/CHANGELOG.md create mode 100644 vendor/github.com/chzyer/readline/LICENSE create mode 100644 vendor/github.com/chzyer/readline/README.md create mode 100644 vendor/github.com/chzyer/readline/ansi_windows.go create mode 100644 vendor/github.com/chzyer/readline/complete.go create mode 100644 vendor/github.com/chzyer/readline/complete_helper.go create mode 100644 vendor/github.com/chzyer/readline/complete_segment.go create mode 100644 vendor/github.com/chzyer/readline/history.go create mode 100644 vendor/github.com/chzyer/readline/operation.go create mode 100644 vendor/github.com/chzyer/readline/password.go create mode 100644 vendor/github.com/chzyer/readline/rawreader_windows.go create mode 100644 vendor/github.com/chzyer/readline/readline.go create mode 100644 vendor/github.com/chzyer/readline/remote.go create mode 100644 vendor/github.com/chzyer/readline/runebuf.go create mode 100644 vendor/github.com/chzyer/readline/runes.go create mode 100644 vendor/github.com/chzyer/readline/search.go create mode 100644 vendor/github.com/chzyer/readline/std.go create mode 100644 vendor/github.com/chzyer/readline/std_windows.go create mode 100644 vendor/github.com/chzyer/readline/term.go create mode 100644 vendor/github.com/chzyer/readline/term_bsd.go create mode 100644 vendor/github.com/chzyer/readline/term_linux.go create mode 100644 vendor/github.com/chzyer/readline/term_nosyscall6.go create mode 100644 vendor/github.com/chzyer/readline/term_unix.go create mode 100644 vendor/github.com/chzyer/readline/term_windows.go create mode 100644 vendor/github.com/chzyer/readline/terminal.go create mode 100644 vendor/github.com/chzyer/readline/utils.go create mode 100644 vendor/github.com/chzyer/readline/utils_unix.go create mode 100644 vendor/github.com/chzyer/readline/utils_windows.go create mode 100644 vendor/github.com/chzyer/readline/vim.go create mode 100644 vendor/github.com/chzyer/readline/windows_api.go diff --git a/docs/content/docs/guide/cli.md b/docs/content/docs/guide/cli.md index 39bd83c00..03381cadd 100644 --- a/docs/content/docs/guide/cli.md +++ b/docs/content/docs/guide/cli.md @@ -409,6 +409,111 @@ You can specify a different directory than the current one by using the -d/--dir {{< /details >}} +{{< details "tkn pac cel" >}} + +### CEL Expression Evaluator + +`tkn pac cel` — Evaluate CEL (Common Expression Language) expressions interactively with webhook payloads. + +This command allows you to test and debug CEL expressions as they would be evaluated by Pipelines-as-Code, using real webhook payloads and headers. It supports interactive and non-interactive modes, provider auto-detection, and persistent history. + +To be able to have the CEL evaluator working, you need to have the payload and the headers available in a file. The best way to do this is to go to the webhook configuration on your git provider and copy the payload and headers to different files. + +The payload is the JSON content of the webhook request, The headers file supports multiple formats: + +1. **Plain HTTP headers format** (as shown above) +2. **JSON format**: + + ```json + { + "X-GitHub-Event": "pull_request", + "Content-Type": "application/json", + "User-Agent": "GitHub-Hookshot/2d5e4d4" + } + ``` + +3. **Gosmee-generated shell scripts**: The command automatically detects and parses shell scripts generated by [gosmee](https://github.com/chmouel/gosmee) which are generated when using the `--save` feature, extracting headers from curl commands with `-H` flags: + + ```bash + #!/usr/bin/env bash + curl -X POST "http://localhost:8080/" \ + -H "X-GitHub-Event: pull_request" \ + -H "Content-Type: application/json" \ + -H "User-Agent: GitHub-Hookshot/2d5e4d4" \ + -d @payload.json + ``` + +#### Usage + +```shell +tkn pac cel -b -H +``` + +* `-b, --body`: Path to JSON body file (webhook payload) +* `-H, --headers`: Path to headers file (plain text, JSON, or gosmee script) +* `-p, --provider`: Provider (auto, github, gitlab, bitbucket-cloud, bitbucket-datacenter, gitea) + +#### Interactive Mode + +If run in a terminal, you'll get a prompt: + +```console +CEL expression> +``` + +* Use ↑/↓ arrows to navigate history. +* History is saved and loaded automatically. +* Press Enter on an empty line to exit. + +#### Non-Interactive Mode + +Pipe expressions via stdin: + +```shell +echo 'event == "pull_request"' | tkn pac cel -b body.json -H headers.txt +``` + +#### Available Variables + +* **Direct variables** (top-level, as per PAC documentation): + * `event` — event type (push, pull_request) + * `target_branch` — target branch name + * `source_branch` — source branch name + * `target_url` — target repository URL + * `source_url` — source repository URL + * `event_title` — PR title or commit message + +* **Webhook payload** (`body.*`): All fields from the webhook JSON. +* **HTTP headers** (`headers.*`): All HTTP headers. +* **Files** (`files.*`): Always empty in CLI mode. + **Note:** `fileChanged`, `fileDeleted`, `fileModified` and similar functions are **not implemented yet** in the CLI. +* **PAC Parameters** (`pac.*`): All variables for backward compatibility. + +#### Example Expressions + +```text +event == "pull_request" && target_branch == "main" +event == "pull_request" && source_branch.matches(".*feat/.*") +body.action == "synchronize" +!body.pull_request.draft +headers['x-github-event'] == "pull_request" +event == "pull_request" && target_branch != "experimental" +``` + +#### Limitations + +* `files.*` variables are always empty in CLI mode. +* Functions like `fileChanged`, `fileDeleted`, `fileModified` are **not implemented yet** in the CLI. + +#### Cross-Platform History + +* History is saved in a cache directory: + * Linux/macOS: `~/.cache/tkn-pac/cel-history` + * Windows: `%USERPROFILE%\.cache\tkn-pac\cel-history` +* The directory is created automatically if it does not exist. + +{{< /details >}} + ## Screenshot ![tkn-plug-in](/images/tkn-pac-cli.png) diff --git a/go.mod b/go.mod index 0e9c75b2b..15b5e3e51 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( code.gitea.io/sdk/gitea v0.21.0 github.com/AlecAivazis/survey/v2 v2.3.7 github.com/bradleyfalzon/ghinstallation/v2 v2.15.0 + github.com/chzyer/readline v1.5.1 github.com/cloudevents/sdk-go/v2 v2.16.0 github.com/fvbommel/sortorder v1.1.0 github.com/gobwas/glob v0.2.3 @@ -129,7 +130,7 @@ require ( golang.org/x/crypto v0.37.0 // indirect golang.org/x/net v0.39.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.31.0 // indirect + golang.org/x/term v0.31.0 golang.org/x/time v0.11.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/api v0.231.0 // indirect diff --git a/go.sum b/go.sum index fd54357b0..8abf870b9 100644 --- a/go.sum +++ b/go.sum @@ -88,8 +88,14 @@ github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +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 v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +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 v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudevents/sdk-go/observability/opencensus/v2 v2.15.2 h1:AbtPqiUDzKup5JpTZzO297/QXgL/TAdpdXQCNwLzlaM= github.com/cloudevents/sdk-go/observability/opencensus/v2 v2.15.2/go.mod h1:ZbYLE+yaEQ2j4vbRc9qzvGmg30A9LhwFt/1bSebNnbU= @@ -675,6 +681,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220708085239-5a0f0661e09d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/cel/cel.go b/pkg/cel/cel.go index e53a8034f..a4f316884 100644 --- a/pkg/cel/cel.go +++ b/pkg/cel/cel.go @@ -55,12 +55,26 @@ func Value(query string, body any, headers, pacParams map[string]string, changed decls.NewVariable("headers", mapStrDyn), decls.NewVariable("pac", mapStrDyn), decls.NewVariable("files", mapStrDyn), + // Direct variables as per documentation + decls.NewVariable("event", types.StringType), + decls.NewVariable("target_branch", types.StringType), + decls.NewVariable("source_branch", types.StringType), + decls.NewVariable("target_url", types.StringType), + decls.NewVariable("source_url", types.StringType), + decls.NewVariable("event_title", types.StringType), )) val, err := evaluate(query, celDec, map[string]any{ "body": jsonMap, "pac": pacParams, "headers": headers, "files": changedFiles, + // Direct variables + "event": pacParams["event"], + "target_branch": pacParams["target_branch"], + "source_branch": pacParams["source_branch"], + "target_url": pacParams["target_url"], + "source_url": pacParams["source_url"], + "event_title": pacParams["event_title"], }) if err != nil { return nil, err diff --git a/pkg/cmd/tknpac/cel/cel.go b/pkg/cmd/tknpac/cel/cel.go new file mode 100644 index 000000000..f7b316df1 --- /dev/null +++ b/pkg/cmd/tknpac/cel/cel.go @@ -0,0 +1,1099 @@ +package cel + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "os" + "path" + "path/filepath" + "strconv" + "strings" + + _ "embed" + + giteaStructs "code.gitea.io/gitea/modules/structs" + "github.com/AlecAivazis/survey/v2" + "github.com/chzyer/readline" + "github.com/google/go-github/v71/github" + pkgcel "github.com/openshift-pipelines/pipelines-as-code/pkg/cel" + "github.com/openshift-pipelines/pipelines-as-code/pkg/cli" + "github.com/openshift-pipelines/pipelines-as-code/pkg/formatting" + "github.com/openshift-pipelines/pipelines-as-code/pkg/params/info" + "github.com/openshift-pipelines/pipelines-as-code/pkg/params/triggertype" + "github.com/openshift-pipelines/pipelines-as-code/pkg/provider/bitbucketcloud/types" + providergh "github.com/openshift-pipelines/pipelines-as-code/pkg/provider/github" + "github.com/spf13/cobra" + gitlab "gitlab.com/gitlab-org/api/client-go" + "golang.org/x/term" +) + +const ( + bodyFileFlag = "body" + headersFileFlag = "headers" + providerFlag = "provider" + githubTokenFlag = "github-token" +) + +//go:embed templates/help.tmpl +var helpString string + +// getHistoryFilePath returns the cross-platform path for the CEL history file. +func getHistoryFilePath() (string, error) { + // Get user's home directory (works on Windows, macOS, Linux) + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + + // Create the cache directory path + cacheDir := filepath.Join(homeDir, ".cache", "tkn-pac") + + // Ensure the directory exists + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return "", err + } + + // Return the full path to the history file + return filepath.Join(cacheDir, "cel-history"), nil +} + +func parseHTTPHeaders(s string) (map[string]string, error) { + headers := make(map[string]string) + scanner := bufio.NewScanner(strings.NewReader(s)) + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "" { + continue + } + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue // or return error if strict + } + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + headers[key] = value + } + if err := scanner.Err(); err != nil { + return nil, err + } + return headers, nil +} + +// getHeaderCaseInsensitive performs case-insensitive header lookup. +func getHeaderCaseInsensitive(headers map[string]string, key string) string { + // First try exact match + if value, ok := headers[key]; ok { + return value + } + // Then try case-insensitive match + lowerKey := strings.ToLower(key) + for k, v := range headers { + if strings.ToLower(k) == lowerKey { + return v + } + } + return "" +} + +// parseCurlHeaders extracts headers from a curl command string. +// This function parses curl commands like those generated by gosmee, +// extracting -H "Header: Value" flags. +func parseCurlHeaders(curlCommand string) (map[string]string, error) { + headers := make(map[string]string) + + // Split the command into tokens, handling quoted strings + tokens, err := splitCurlCommand(curlCommand) + if err != nil { + return nil, err + } + + // Look for -H flags followed by header values + for i := 0; i < len(tokens); i++ { + if tokens[i] == "-H" && i+1 < len(tokens) { + headerValue := tokens[i+1] + // Parse "Header: Value" format + if parts := strings.SplitN(headerValue, ":", 2); len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + headers[key] = value + } + i++ // Skip the header value token + } + } + + return headers, nil +} + +// splitCurlCommand splits a curl command string into tokens, properly handling quoted strings. +func splitCurlCommand(command string) ([]string, error) { + var tokens []string + var current strings.Builder + inQuotes := false + quoteChar := byte(0) + + for i := 0; i < len(command); i++ { + char := command[i] + + switch { + case !inQuotes && (char == '"' || char == '\''): + inQuotes = true + quoteChar = char + case inQuotes && char == quoteChar: + inQuotes = false + quoteChar = 0 + case !inQuotes && (char == ' ' || char == '\t' || char == '\n'): + if current.Len() > 0 { + tokens = append(tokens, current.String()) + current.Reset() + } + default: + current.WriteByte(char) + } + } + + if inQuotes { + return nil, fmt.Errorf("unterminated quote in curl command") + } + + if current.Len() > 0 { + tokens = append(tokens, current.String()) + } + + // Ensure we always return a non-nil slice + if tokens == nil { + tokens = []string{} + } + + return tokens, nil +} + +// isGosmeeScript detects if the content appears to be a gosmee-generated shell script. +// It looks for patterns like "curl" commands with typical gosmee characteristics. +func isGosmeeScript(content string) bool { + lines := strings.Split(content, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + // Look for curl commands that contain -H flags (typical of webhook scripts) + if strings.HasPrefix(trimmed, "curl") && strings.Contains(trimmed, "-H") { + return true + } + } + return false +} + +// parseGosmeeScript extracts headers from a gosmee-generated shell script. +// It finds curl commands and extracts headers from their -H flags. +func parseGosmeeScript(content string) (map[string]string, error) { + headers := make(map[string]string) + lines := strings.Split(content, "\n") + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "curl") && strings.Contains(trimmed, "-H") { + // Parse headers from this curl command + curlHeaders, err := parseCurlHeaders(trimmed) + if err != nil { + continue // Skip malformed curl commands + } + // Merge headers (later commands override earlier ones) + maps.Copy(headers, curlHeaders) + } + } + + if len(headers) == 0 { + return nil, fmt.Errorf("no headers found in gosmee script") + } + + return headers, nil +} + +func eventFromGitHub(body []byte, headers map[string]string) (*info.Event, error) { + event := info.NewEvent() + event.EventType = getHeaderCaseInsensitive(headers, "X-GitHub-Event") + event.Request.Payload = body + event.Request.Header = http.Header{} + for k, v := range headers { + event.Request.Header.Set(k, v) + } + + ghEvent, err := github.ParseWebHook(event.EventType, body) + if err != nil { + return nil, err + } + + // Store the parsed GitHub event for CEL body access + event.Event = ghEvent + + switch e := ghEvent.(type) { + case *github.PushEvent: + event.TriggerTarget = triggertype.Push + event.Organization = e.GetRepo().GetOwner().GetLogin() + event.Repository = e.GetRepo().GetName() + event.DefaultBranch = e.GetRepo().GetDefaultBranch() + event.URL = e.GetRepo().GetHTMLURL() + sha := e.GetHeadCommit().GetID() + if sha == "" { + sha = e.GetAfter() + } + event.SHA = sha + event.SHAURL = e.GetHeadCommit().GetURL() + event.SHATitle = e.GetHeadCommit().GetMessage() + event.Sender = e.GetSender().GetLogin() + event.BaseBranch = e.GetRef() + event.HeadBranch = event.BaseBranch + event.BaseURL = event.URL + event.HeadURL = event.URL + case *github.PullRequestEvent: + event.TriggerTarget = triggertype.PullRequest + event.Organization = e.GetRepo().GetOwner().GetLogin() + event.Repository = e.GetRepo().GetName() + event.DefaultBranch = e.GetRepo().GetDefaultBranch() + event.URL = e.GetRepo().GetHTMLURL() + event.SHA = e.GetPullRequest().Head.GetSHA() + event.BaseBranch = e.GetPullRequest().Base.GetRef() + event.HeadBranch = e.GetPullRequest().Head.GetRef() + event.BaseURL = e.GetPullRequest().Base.GetRepo().GetHTMLURL() + event.HeadURL = e.GetPullRequest().Head.GetRepo().GetHTMLURL() + event.Sender = e.GetPullRequest().GetUser().GetLogin() + event.PullRequestNumber = e.GetPullRequest().GetNumber() + event.PullRequestTitle = e.GetPullRequest().GetTitle() + for _, l := range e.GetPullRequest().Labels { + event.PullRequestLabel = append(event.PullRequestLabel, l.GetName()) + } + case *github.IssueCommentEvent: + event.TriggerTarget = triggertype.PullRequest + if e.GetRepo() != nil { + event.Organization = e.GetRepo().GetOwner().GetLogin() + event.Repository = e.GetRepo().GetName() + event.DefaultBranch = e.GetRepo().GetDefaultBranch() + event.URL = e.GetRepo().GetHTMLURL() + } + event.Sender = e.GetSender().GetLogin() + event.TriggerComment = e.GetComment().GetBody() + if pr := e.GetIssue().GetPullRequestLinks(); pr != nil { + num, err := strconv.Atoi(path.Base(pr.GetHTMLURL())) + if err == nil { + event.PullRequestNumber = num + } + } + case *github.CommitCommentEvent: + event.TriggerTarget = triggertype.Push + event.Organization = e.GetRepo().GetOwner().GetLogin() + event.Repository = e.GetRepo().GetName() + event.DefaultBranch = e.GetRepo().GetDefaultBranch() + event.URL = e.GetRepo().GetHTMLURL() + event.Sender = e.GetSender().GetLogin() + event.SHA = e.GetComment().GetCommitID() + event.SHAURL = e.GetComment().GetHTMLURL() + event.HeadBranch = event.DefaultBranch + event.BaseBranch = event.DefaultBranch + event.HeadURL = event.URL + event.BaseURL = event.URL + event.TriggerComment = e.GetComment().GetBody() + default: + return nil, fmt.Errorf("unsupported github event %T", e) + } + return event, nil +} + +// eventFromGitHubWithProvider uses the actual GitHub provider to parse events with proper enrichment. +// This ensures consistency with the controller's event processing, but adapted for CLI usage. +func eventFromGitHubWithProvider(body []byte, headers map[string]string, githubToken string) (*info.Event, error) { + // If no token provided, fall back to basic parsing + if githubToken == "" { + return eventFromGitHub(body, headers) + } + + // Start with basic parsing to get initial event structure + event, err := eventFromGitHub(body, headers) + if err != nil { + return nil, err + } + + // Create a GitHub client for API calls + ctx := context.Background() + client, _, _ := providergh.MakeClient(ctx, "", githubToken) + + // Parse the GitHub webhook to get the typed event + ghEvent, err := github.ParseWebHook(event.EventType, body) + if err != nil { + return event, nil // nolint: nilerr // fallback to basic parsing + } + + // For specific event types that benefit from API enrichment, enhance them + switch e := ghEvent.(type) { + case *github.IssueCommentEvent: + // For issue comments, we need to fetch PR details to get proper branch info + if !e.GetIssue().IsPullRequest() { + return event, nil // not a PR comment + } + + prURL := e.GetIssue().GetPullRequestLinks().GetHTMLURL() + prNumber, err := strconv.Atoi(path.Base(prURL)) + if err != nil { + return event, nil // nolint: nilerr // fallback to basic parsing if URL parsing fails + } + + // Fetch PR details + pr, _, err := client.PullRequests.Get(ctx, event.Organization, event.Repository, prNumber) + if err != nil { + return event, nil // nolint: nilerr // fallback to basic parsing if API call fails + } + + // Populate the missing fields that would be enriched by the provider + event.SHA = pr.GetHead().GetSHA() + event.SHAURL = fmt.Sprintf("%s/commit/%s", pr.GetHTMLURL(), pr.GetHead().GetSHA()) + event.PullRequestTitle = pr.GetTitle() + event.HeadBranch = pr.GetHead().GetRef() + event.BaseBranch = pr.GetBase().GetRef() + event.HeadURL = pr.GetHead().GetRepo().GetHTMLURL() + event.BaseURL = pr.GetBase().GetRepo().GetHTMLURL() + event.DefaultBranch = pr.GetBase().GetRepo().GetDefaultBranch() + event.URL = pr.GetBase().GetRepo().GetHTMLURL() + + // Add PR labels + event.PullRequestLabel = nil // clear any existing labels + for _, label := range pr.Labels { + event.PullRequestLabel = append(event.PullRequestLabel, label.GetName()) + } + + return event, nil + default: + // For other events, basic parsing is sufficient + return event, nil + } +} + +func pacParamsFromEvent(event *info.Event) map[string]string { + repoURL := event.URL + if event.CloneURL != "" { + repoURL = event.CloneURL + } + gitTag := "" + if after, ok := strings.CutPrefix(event.BaseBranch, "refs/tags/"); ok { + gitTag = after + } + triggerComment := strings.ReplaceAll(strings.ReplaceAll(event.TriggerComment, "\r\n", "\\n"), "\n", "\\n") + pullRequestLabels := strings.Join(event.PullRequestLabel, "\n") + + // Get event title based on trigger type + eventTitle := event.PullRequestTitle + if event.TriggerTarget == triggertype.Push { + eventTitle = event.SHATitle + } + + return map[string]string{ + "revision": event.SHA, + "repo_url": repoURL, + "repo_owner": strings.ToLower(event.Organization), + "repo_name": strings.ToLower(event.Repository), + "target_branch": formatting.SanitizeBranch(event.BaseBranch), + "source_branch": formatting.SanitizeBranch(event.HeadBranch), + "git_tag": gitTag, + "source_url": event.HeadURL, + "target_url": event.BaseURL, + "sender": strings.ToLower(event.Sender), + "target_namespace": "", + "event_type": event.EventType, + "event": event.TriggerTarget.String(), + "event_title": eventTitle, + "trigger_comment": triggerComment, + "pull_request_labels": pullRequestLabels, + } +} + +// detectProvider automatically detects the provider from headers and payload. +func detectProvider(headers map[string]string, body []byte) (string, error) { + // Check for GitHub provider (most common) + if getHeaderCaseInsensitive(headers, "X-GitHub-Event") != "" { + // Check if it's actually Gitea (which also sets X-GitHub-Event) + if getHeaderCaseInsensitive(headers, "X-Gitea-Event-Type") != "" { + return "gitea", nil + } + return "github", nil + } + + // Check for GitLab provider + if getHeaderCaseInsensitive(headers, "X-Gitlab-Event") != "" { + return "gitlab", nil + } + + // Check for Bitbucket Cloud (uses User-Agent header) + if userAgent := getHeaderCaseInsensitive(headers, "User-Agent"); userAgent != "" { + if strings.Contains(strings.ToLower(userAgent), "bitbucket") { + // Try to distinguish between Cloud and Data Center by payload structure + var payload map[string]any + if json.Unmarshal(body, &payload) == nil { + if actor, ok := payload["actor"].(map[string]any); ok { + if _, hasAccountID := actor["account_id"]; hasAccountID { + return "bitbucket-cloud", nil + } + // Heuristic: if it has an `id` but not an `account_id`, assume it's Data Center + if _, hasID := actor["id"]; hasID { + return "bitbucket-datacenter", nil + } + } + } + // Default to cloud if we can't determine + return "bitbucket-cloud", nil + } + } + + // Check for Gitea provider (backup check in case header is missing) + if getHeaderCaseInsensitive(headers, "X-Gitea-Event-Type") != "" { + return "gitea", nil + } + + // Try to detect from payload structure as last resort + var payload map[string]any + if json.Unmarshal(body, &payload) == nil { + // GitHub-like structure + if repository, ok := payload["repository"].(map[string]any); ok { + if htmlURL, ok := repository["html_url"].(string); ok { + if strings.Contains(htmlURL, "github.com") { + return "github", nil + } + if strings.Contains(htmlURL, "gitlab.com") || strings.Contains(htmlURL, "gitlab") { + return "gitlab", nil + } + } + } + + // GitLab-specific structure + if project, ok := payload["project"].(map[string]any); ok { + if webURL, ok := project["web_url"].(string); ok { + if strings.Contains(webURL, "gitlab") { + return "gitlab", nil + } + } + } + + // Bitbucket-specific structure + if repository, ok := payload["repository"].(map[string]any); ok { + if links, ok := repository["links"].(map[string]any); ok { + if html, ok := links["html"].(map[string]any); ok { + if href, ok := html["href"].(string); ok { + if strings.Contains(href, "bitbucket") { + return "bitbucket-cloud", nil + } + } + } + } + } + } + + return "", fmt.Errorf("unable to detect provider from headers or payload") +} + +func eventFromGitLab(body []byte, headers map[string]string) (*info.Event, error) { + event := info.NewEvent() + event.EventType = getHeaderCaseInsensitive(headers, "X-Gitlab-Event") + event.Request.Payload = body + event.Request.Header = http.Header{} + for k, v := range headers { + event.Request.Header.Set(k, v) + } + + // Parse GitLab webhook payload + eventInt, err := gitlab.ParseWebhook(gitlab.EventType(event.EventType), body) + if err != nil { + return nil, fmt.Errorf("failed to parse GitLab webhook: %w", err) + } + + event.Event = eventInt + + // Extract common event information from GitLab payload + switch gitEvent := eventInt.(type) { + case *gitlab.MergeEvent: + event.Organization = extractOrgFromPath(gitEvent.Project.PathWithNamespace) + event.Repository = extractRepoFromPath(gitEvent.Project.PathWithNamespace) + event.Sender = gitEvent.User.Username + event.URL = gitEvent.Project.WebURL + event.SHA = gitEvent.ObjectAttributes.LastCommit.ID + event.HeadBranch = gitEvent.ObjectAttributes.SourceBranch + event.BaseBranch = gitEvent.ObjectAttributes.TargetBranch + event.PullRequestNumber = gitEvent.ObjectAttributes.IID + event.PullRequestTitle = gitEvent.ObjectAttributes.Title + event.TriggerTarget = triggertype.PullRequest + if gitEvent.ObjectAttributes.Action == "close" { + event.TriggerTarget = triggertype.PullRequestClosed + } + case *gitlab.PushEvent: + if len(gitEvent.Commits) == 0 { + return nil, fmt.Errorf("no commits attached to this push event") + } + lastCommitIdx := len(gitEvent.Commits) - 1 + event.Organization = extractOrgFromPath(gitEvent.Project.PathWithNamespace) + event.Repository = extractRepoFromPath(gitEvent.Project.PathWithNamespace) + event.Sender = gitEvent.UserUsername + event.URL = gitEvent.Project.WebURL + event.SHA = gitEvent.Commits[lastCommitIdx].ID + event.SHATitle = gitEvent.Commits[lastCommitIdx].Title + event.HeadBranch = gitEvent.Ref + event.BaseBranch = gitEvent.Ref + event.TriggerTarget = triggertype.Push + case *gitlab.TagEvent: + if len(gitEvent.Commits) == 0 { + return nil, fmt.Errorf("no commits attached to this tag event") + } + lastCommitIdx := len(gitEvent.Commits) - 1 + event.Organization = extractOrgFromPath(gitEvent.Project.PathWithNamespace) + event.Repository = extractRepoFromPath(gitEvent.Project.PathWithNamespace) + event.Sender = gitEvent.UserUsername + event.URL = gitEvent.Project.WebURL + event.SHA = gitEvent.Commits[lastCommitIdx].ID + event.SHATitle = gitEvent.Commits[lastCommitIdx].Title + event.HeadBranch = gitEvent.Ref + event.BaseBranch = gitEvent.Ref + event.TriggerTarget = triggertype.Push + default: + return nil, fmt.Errorf("unsupported GitLab event type: %T", gitEvent) + } + + return event, nil +} + +func eventFromBitbucketCloud(body []byte, headers map[string]string) (*info.Event, error) { + event := info.NewEvent() + event.Request.Payload = body + event.Request.Header = http.Header{} + for k, v := range headers { + event.Request.Header.Set(k, v) + } + + // Parse Bitbucket Cloud webhook event type from headers (X-Event-Key), case-insensitive + var eventType string + for k, v := range headers { + if strings.EqualFold(k, "X-Event-Key") { + eventType = v + break + } + } + if eventType == "" { + return nil, fmt.Errorf("missing X-Event-Key header for Bitbucket Cloud webhook") + } + + event.EventType = eventType + + switch { + case strings.HasPrefix(eventType, "pullrequest:"): + var prEvent types.PullRequestEvent + if err := json.Unmarshal(body, &prEvent); err != nil { + return nil, fmt.Errorf("failed to parse Bitbucket Cloud pull request event: %w", err) + } + event.Event = &prEvent + event.Organization = prEvent.Repository.Workspace.Slug + repoParts := strings.Split(prEvent.Repository.FullName, "/") + if len(repoParts) > 1 { + event.Repository = repoParts[1] + } else { + event.Repository = prEvent.Repository.FullName + } + event.Sender = prEvent.PullRequest.Author.Nickname + event.URL = prEvent.Repository.Links.HTML.HRef + event.SHA = prEvent.PullRequest.Source.Commit.Hash + event.HeadBranch = prEvent.PullRequest.Source.Branch.Name + event.BaseBranch = prEvent.PullRequest.Destination.Branch.Name + event.PullRequestNumber = prEvent.PullRequest.ID + event.PullRequestTitle = prEvent.PullRequest.Title + event.TriggerTarget = triggertype.PullRequest + if eventType == "pullrequest:rejected" || eventType == "pullrequest:fulfilled" { + event.TriggerTarget = triggertype.PullRequestClosed + } + case eventType == "repo:push": + var pushEvent types.PushRequestEvent + if err := json.Unmarshal(body, &pushEvent); err != nil { + return nil, fmt.Errorf("failed to parse Bitbucket Cloud push event: %w", err) + } + event.Event = &pushEvent + event.Organization = pushEvent.Repository.Workspace.Slug + repoParts := strings.Split(pushEvent.Repository.FullName, "/") + if len(repoParts) > 1 { + event.Repository = repoParts[1] + } else { + event.Repository = pushEvent.Repository.FullName + } + event.Sender = pushEvent.Actor.Nickname + event.URL = pushEvent.Repository.Links.HTML.HRef + if len(pushEvent.Push.Changes) > 0 { + event.SHA = pushEvent.Push.Changes[0].New.Target.Hash + event.HeadBranch = pushEvent.Push.Changes[0].New.Name + event.BaseBranch = pushEvent.Push.Changes[0].New.Name + } + event.TriggerTarget = triggertype.Push + default: + return nil, fmt.Errorf("unsupported Bitbucket Cloud event type: %s", eventType) + } + + return event, nil +} + +func eventFromBitbucketDataCenter(body []byte, headers map[string]string) (*info.Event, error) { + event := info.NewEvent() + event.Request.Payload = body + event.Request.Header = http.Header{} + for k, v := range headers { + event.Request.Header.Set(k, v) + } + + // Parse Bitbucket Data Center webhook event type from headers (X-Event-Key), case-insensitive + var eventType string + for k, v := range headers { + if strings.EqualFold(k, "X-Event-Key") { + eventType = v + break + } + } + if eventType == "" { + return nil, fmt.Errorf("missing X-Event-Key header for Bitbucket Data Center webhook") + } + + event.EventType = eventType + + switch { + case strings.HasPrefix(eventType, "pr:"): + // Parse as a generic pull request event structure + var prData map[string]any + if err := json.Unmarshal(body, &prData); err != nil { + return nil, fmt.Errorf("failed to parse Bitbucket Data Center pull request event: %w", err) + } + event.Event = prData + + // Extract basic information from the payload structure + if pullRequest, ok := prData["pullRequest"].(map[string]any); ok { + if toRef, ok := pullRequest["toRef"].(map[string]any); ok { + if repository, ok := toRef["repository"].(map[string]any); ok { + if project, ok := repository["project"].(map[string]any); ok { + if key, ok := project["key"].(string); ok { + event.Organization = key + } + } + if name, ok := repository["name"].(string); ok { + event.Repository = name + } + } + if displayID, ok := toRef["displayId"].(string); ok { + event.BaseBranch = displayID + } + } + if fromRef, ok := pullRequest["fromRef"].(map[string]any); ok { + if displayID, ok := fromRef["displayId"].(string); ok { + event.HeadBranch = displayID + } + if latestCommit, ok := fromRef["latestCommit"].(string); ok { + event.SHA = latestCommit + } + } + if id, ok := pullRequest["id"].(float64); ok { + event.PullRequestNumber = int(id) + } + if title, ok := pullRequest["title"].(string); ok { + event.PullRequestTitle = title + } + } + if actor, ok := prData["actor"].(map[string]any); ok { + if name, ok := actor["name"].(string); ok { + event.Sender = name + } + } + event.TriggerTarget = triggertype.PullRequest + case eventType == "repo:refs_changed": + // Parse as a generic push event structure + var pushData map[string]any + if err := json.Unmarshal(body, &pushData); err != nil { + return nil, fmt.Errorf("failed to parse Bitbucket Data Center push event: %w", err) + } + event.Event = pushData + + // Extract basic information + if repository, ok := pushData["repository"].(map[string]any); ok { + if project, ok := repository["project"].(map[string]any); ok { + if key, ok := project["key"].(string); ok { + event.Organization = key + } + } + if name, ok := repository["name"].(string); ok { + event.Repository = name + } + } + if actor, ok := pushData["actor"].(map[string]any); ok { + if name, ok := actor["name"].(string); ok { + event.Sender = name + } + } + if changes, ok := pushData["changes"].([]any); ok && len(changes) > 0 { + if change, ok := changes[0].(map[string]any); ok { + if toHash, ok := change["toHash"].(string); ok { + event.SHA = toHash + } + if refID, ok := change["refId"].(string); ok { + event.HeadBranch = refID + event.BaseBranch = refID + } + } + } + event.TriggerTarget = triggertype.Push + default: + return nil, fmt.Errorf("unsupported Bitbucket Data Center event type: %s", eventType) + } + + return event, nil +} + +func eventFromGitea(body []byte, headers map[string]string) (*info.Event, error) { + event := info.NewEvent() + event.EventType = getHeaderCaseInsensitive(headers, "X-Gitea-Event-Type") + if event.EventType == "" { + return nil, fmt.Errorf("missing X-Gitea-Event-Type header for Gitea webhook") + } + event.Request.Payload = body + event.Request.Header = http.Header{} + for k, v := range headers { + event.Request.Header.Set(k, v) + } + + // Parse Gitea webhook payload manually since parseWebhook is not exported + var eventInt any + switch event.EventType { + case "push": + eventInt = &giteaStructs.PushPayload{} + case "pull_request": + eventInt = &giteaStructs.PullRequestPayload{} + case "issue_comment", "pull_request_comment": + eventInt = &giteaStructs.IssueCommentPayload{} + default: + return nil, fmt.Errorf("unsupported Gitea event type: %s", event.EventType) + } + + // Parse the payload into the eventInt interface + if err := json.Unmarshal(body, &eventInt); err != nil { + return nil, fmt.Errorf("failed to unmarshal Gitea payload: %w", err) + } + + event.Event = eventInt + + // Extract common event information from Gitea payload + switch gitEvent := eventInt.(type) { + case *giteaStructs.PullRequestPayload: + event.Organization = gitEvent.Repository.Owner.UserName + event.Repository = gitEvent.Repository.Name + event.Sender = gitEvent.Sender.UserName + event.URL = gitEvent.Repository.HTMLURL + event.SHA = gitEvent.PullRequest.Head.Sha + event.HeadBranch = gitEvent.PullRequest.Head.Ref + event.BaseBranch = gitEvent.PullRequest.Base.Ref + event.PullRequestNumber = int(gitEvent.Index) + event.PullRequestTitle = gitEvent.PullRequest.Title + event.TriggerTarget = triggertype.PullRequest + if gitEvent.Action == giteaStructs.HookIssueClosed { + event.TriggerTarget = triggertype.PullRequestClosed + } + case *giteaStructs.PushPayload: + event.Organization = gitEvent.Repo.Owner.UserName + event.Repository = gitEvent.Repo.Name + event.Sender = gitEvent.Sender.UserName + event.URL = gitEvent.Repo.HTMLURL + event.SHA = gitEvent.HeadCommit.ID + if event.SHA == "" { + event.SHA = gitEvent.Before + } + event.SHATitle = gitEvent.HeadCommit.Message + event.HeadBranch = gitEvent.Ref + event.BaseBranch = gitEvent.Ref + event.TriggerTarget = triggertype.Push + case *giteaStructs.IssueCommentPayload: + if gitEvent.Issue.PullRequest == nil { + return nil, fmt.Errorf("issue comment is not from a pull request") + } + event.Organization = gitEvent.Repository.Owner.UserName + event.Repository = gitEvent.Repository.Name + event.Sender = gitEvent.Sender.UserName + event.URL = gitEvent.Repository.HTMLURL + event.TriggerTarget = triggertype.PullRequest + // For comments, we'll need to make additional API calls to get PR details + // For now, we'll set basic info + event.PullRequestNumber = extractPullRequestNumber(gitEvent.Issue.URL) + default: + return nil, fmt.Errorf("unsupported Gitea event type: %T", gitEvent) + } + + return event, nil +} + +// Helper functions. +func extractOrgFromPath(pathWithNamespace string) string { + parts := strings.Split(pathWithNamespace, "/") + if len(parts) >= 2 { + return strings.Join(parts[:len(parts)-1], "/") + } + return "" +} + +func extractRepoFromPath(pathWithNamespace string) string { + parts := strings.Split(pathWithNamespace, "/") + if len(parts) > 0 { + return parts[len(parts)-1] + } + return "" +} + +func extractPullRequestNumber(issueURL string) int { + // Extract pull request number from issue URL + // This is a simplified implementation + parts := strings.Split(issueURL, "/") + for i, part := range parts { + if part == "issues" && i+1 < len(parts) { + if num, err := strconv.Atoi(parts[i+1]); err == nil { + return num + } + } + } + return 0 +} + +func Command(ioStreams *cli.IOStreams) *cobra.Command { + var bodyFile, headersFile, provider, githubToken string + + cmd := &cobra.Command{ + Use: "cel", + Short: "Evaluate CEL expressions interactively with webhook payloads", + Long: `Evaluate CEL expressions interactively with webhook payloads. + +The command automatically detects the git provider from the webhook headers and payload structure. +Supported providers: GitHub, GitLab, Bitbucket Cloud, Bitbucket Data Center, and Gitea. + +You can provide webhook payload and headers from files to test CEL expressions +that would be used in PipelineRun configurations.`, + RunE: func(_ *cobra.Command, _ []string) error { + body := map[string]any{} + headers := map[string]string{} + var bodyBytes []byte + + if bodyFile != "" { + b, err := os.ReadFile(bodyFile) + if err != nil { + return err + } + bodyBytes = b + if err := json.Unmarshal(b, &body); err != nil { + return err + } + } + + if headersFile != "" { + b, err := os.ReadFile(headersFile) + if err != nil { + return err + } + bs := bytes.TrimSpace(b) + switch { + case len(bs) > 0 && (bs[0] == '{' || bs[0] == '['): + // JSON format headers + if err := json.Unmarshal(bs, &headers); err != nil { + return err + } + case isGosmeeScript(string(bs)): + // Gosmee-generated shell script with curl commands + h, err := parseGosmeeScript(string(bs)) + if err != nil { + return err + } + headers = h + default: + // Plain HTTP headers format + h, err := parseHTTPHeaders(string(bs)) + if err != nil { + return err + } + headers = h + } + } + // nolint:ineffassign,staticcheck + pacParams := map[string]string{} + // Auto-detect provider if not specified explicitly + if provider == "auto" { + detectedProvider, err := detectProvider(headers, bodyBytes) + if err != nil { + return fmt.Errorf("auto-detection failed: %w", err) + } + provider = detectedProvider + } + + switch provider { + case "github": + var event *info.Event + var err error + if githubToken != "" { + event, err = eventFromGitHubWithProvider(bodyBytes, headers, githubToken) + } else { + event, err = eventFromGitHub(bodyBytes, headers) + } + if err != nil { + return err + } + pacParams = pacParamsFromEvent(event) + case "gitlab": + event, err := eventFromGitLab(bodyBytes, headers) + if err != nil { + return err + } + pacParams = pacParamsFromEvent(event) + case "bitbucket-cloud": + event, err := eventFromBitbucketCloud(bodyBytes, headers) + if err != nil { + return err + } + pacParams = pacParamsFromEvent(event) + case "bitbucket-datacenter": + event, err := eventFromBitbucketDataCenter(bodyBytes, headers) + if err != nil { + return err + } + pacParams = pacParamsFromEvent(event) + case "gitea": + event, err := eventFromGitea(bodyBytes, headers) + if err != nil { + return err + } + pacParams = pacParamsFromEvent(event) + default: + return fmt.Errorf("unsupported provider %s", provider) + } + + fmt.Fprintln(ioStreams.Out, strings.TrimSpace( + fmt.Sprintf(helpString, provider))+"\n") + // Check if stdin is a terminal (interactive mode) or pipe/file (non-interactive mode) + if term.IsTerminal(int(os.Stdin.Fd())) { + // Get cross-platform history file path + historyFile, err := getHistoryFilePath() + if err != nil { + // If we can't get history file path, continue without history + historyFile = "" + } + + // Interactive mode: use readline with history + rl, err := readline.NewEx(&readline.Config{ + Prompt: "CEL expression> ", + HistoryFile: historyFile, + AutoComplete: nil, + InterruptPrompt: "^C", + EOFPrompt: "exit", + HistorySearchFold: true, + }) + if err != nil { + // Fallback to survey if readline fails + for { + var expr string + if err := survey.AskOne(&survey.Input{Message: "CEL expression"}, &expr); err != nil { + return err + } + if expr == "" { + break + } + + // Create files data structure (always empty in CLI mode) + filesData := map[string]any{ + "all": []string{}, + "added": []string{}, + "deleted": []string{}, + "modified": []string{}, + "renamed": []string{}, + } + + val, err := pkgcel.Value(expr, body, headers, pacParams, filesData) + if err != nil { + fmt.Fprintln(ioStreams.Out, err) + } else { + fmt.Fprintf(ioStreams.Out, "%v\n", val) + } + } + return nil + } + defer rl.Close() + + fmt.Fprintln(ioStreams.Out, "Type CEL expressions (use ↑/↓ for history, empty line to exit):") + for { + expr, err := rl.Readline() + if err != nil { + if errors.Is(err, readline.ErrInterrupt) || errors.Is(err, io.EOF) { + break + } + return err + } + + expr = strings.TrimSpace(expr) + if expr == "" { + break + } + + // Create files data structure (always empty in CLI mode) + filesData := map[string]any{ + "all": []string{}, + "added": []string{}, + "deleted": []string{}, + "modified": []string{}, + "renamed": []string{}, + } + + val, err := pkgcel.Value(expr, body, headers, pacParams, filesData) + if err != nil { + fmt.Fprintln(ioStreams.Out, err) + } else { + fmt.Fprintf(ioStreams.Out, "%v\n", val) + } + } + } else { + // Non-interactive mode: read from stdin + scanner := bufio.NewScanner(os.Stdin) + hasInput := false + for scanner.Scan() { + hasInput = true + expr := strings.TrimSpace(scanner.Text()) + if expr == "" { + continue + } + + // Create files data structure (always empty in CLI mode) + filesData := map[string]any{ + "all": []string{}, + "added": []string{}, + "deleted": []string{}, + "modified": []string{}, + "renamed": []string{}, + } + + val, err := pkgcel.Value(expr, body, headers, pacParams, filesData) + if err != nil { + fmt.Fprintln(ioStreams.Out, err) + } else { + fmt.Fprintf(ioStreams.Out, "%v\n", val) + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("error reading from stdin: %w", err) + } + // If no input was provided via stdin in non-interactive mode, just exit gracefully + // This allows the command to work in test scenarios + if !hasInput { + // Exit gracefully without error - this is expected when testing + return nil + } + } + return nil + }, + Annotations: map[string]string{"commandType": "main"}, + } + + cmd.Flags().StringVarP(&bodyFile, bodyFileFlag, "b", "", "path to JSON body file") + cmd.Flags().StringVarP(&headersFile, headersFileFlag, "H", "", "path to headers file (JSON, HTTP format, or gosmee-generated shell script)") + cmd.Flags().StringVarP(&provider, providerFlag, "p", "auto", "payload provider (auto, github, gitlab, bitbucket-cloud, bitbucket-datacenter, gitea)") + cmd.Flags().StringVarP(&githubToken, githubTokenFlag, "t", "", "GitHub personal access token for API enrichment (enables full event processing)") + return cmd +} diff --git a/pkg/cmd/tknpac/cel/cel_test.go b/pkg/cmd/tknpac/cel/cel_test.go new file mode 100644 index 000000000..0af301f47 --- /dev/null +++ b/pkg/cmd/tknpac/cel/cel_test.go @@ -0,0 +1,1829 @@ +package cel + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + pkgcel "github.com/openshift-pipelines/pipelines-as-code/pkg/cel" + "github.com/openshift-pipelines/pipelines-as-code/pkg/cli" + "github.com/openshift-pipelines/pipelines-as-code/pkg/params/info" + "github.com/openshift-pipelines/pipelines-as-code/pkg/params/triggertype" + "gotest.tools/v3/assert" + "gotest.tools/v3/fs" +) + +func newIOStream() (*cli.IOStreams, *bytes.Buffer, *bytes.Buffer) { + in := &bytes.Buffer{} + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + return &cli.IOStreams{ + In: io.NopCloser(in), + Out: out, + ErrOut: errOut, + }, out, errOut +} + +func TestParseHTTPHeaders(t *testing.T) { + tests := []struct { + name string + input string + want map[string]string + wantErr bool + }{ + { + name: "valid headers", + input: `Accept: */* +Content-Type: application/json +User-Agent: GitHub-Hookshot/2d5e4d4 +X-GitHub-Event: pull_request`, + want: map[string]string{ + "Accept": "*/*", + "Content-Type": "application/json", + "User-Agent": "GitHub-Hookshot/2d5e4d4", + "X-GitHub-Event": "pull_request", + }, + wantErr: false, + }, + { + name: "headers with extra spaces", + input: ` Accept : */* + Content-Type:application/json `, + want: map[string]string{ + "Accept": "*/*", + "Content-Type": "application/json", + }, + wantErr: false, + }, + { + name: "empty input", + input: "", + want: map[string]string{}, + wantErr: false, + }, + { + name: "headers with empty lines", + input: `Accept: */* + +Content-Type: application/json + +`, + want: map[string]string{ + "Accept": "*/*", + "Content-Type": "application/json", + }, + wantErr: false, + }, + { + name: "malformed header line ignored", + input: `Accept: */* +malformed-line-without-colon +Content-Type: application/json`, + want: map[string]string{ + "Accept": "*/*", + "Content-Type": "application/json", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseHTTPHeaders(tt.input) + if tt.wantErr { + assert.Assert(t, err != nil) + return + } + assert.NilError(t, err) + assert.DeepEqual(t, got, tt.want) + }) + } +} + +func TestSplitCurlCommand(t *testing.T) { + tests := []struct { + name string + input string + want []string + wantErr bool + }{ + { + name: "simple curl command", + input: `curl -X POST "http://localhost:8080"`, + want: []string{"curl", "-X", "POST", "http://localhost:8080"}, + }, + { + name: "curl with headers", + input: `curl -H "Content-Type: application/json" -H "X-GitHub-Event: pull_request"`, + want: []string{"curl", "-H", "Content-Type: application/json", "-H", "X-GitHub-Event: pull_request"}, + }, + { + name: "curl with single quotes", + input: `curl -H 'Content-Type: application/json' -H 'X-GitHub-Event: pull_request'`, + want: []string{"curl", "-H", "Content-Type: application/json", "-H", "X-GitHub-Event: pull_request"}, + }, + { + name: "complex curl command", + input: `curl -sSi -H "Content-Type: application/json" -X POST -d @payload.json "http://localhost:8080"`, + want: []string{"curl", "-sSi", "-H", "Content-Type: application/json", "-X", "POST", "-d", "@payload.json", "http://localhost:8080"}, + }, + { + name: "unterminated quote", + input: `curl -H "Content-Type: application/json`, + wantErr: true, + }, + { + name: "empty string", + input: "", + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := splitCurlCommand(tt.input) + if tt.wantErr { + assert.Assert(t, err != nil) + return + } + assert.NilError(t, err) + assert.DeepEqual(t, got, tt.want) + }) + } +} + +func TestParseCurlHeaders(t *testing.T) { + tests := []struct { + name string + input string + want map[string]string + wantErr bool + }{ + { + name: "simple curl with headers", + input: `curl -H "Content-Type: application/json" -H "X-GitHub-Event: pull_request"`, + want: map[string]string{ + "Content-Type": "application/json", + "X-GitHub-Event": "pull_request", + }, + }, + { + name: "curl with mixed arguments", + input: `curl -X POST -H "Content-Type: application/json" -d @payload.json -H "X-GitHub-Event: pull_request" "http://localhost:8080"`, + want: map[string]string{ + "Content-Type": "application/json", + "X-GitHub-Event": "pull_request", + }, + }, + { + name: "curl with no headers", + input: `curl -X POST "http://localhost:8080"`, + want: map[string]string{}, + }, + { + name: "curl with malformed header", + input: `curl -H "MalformedHeader" -H "Good-Header: value"`, + want: map[string]string{ + "Good-Header": "value", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseCurlHeaders(tt.input) + if tt.wantErr { + assert.Assert(t, err != nil) + return + } + assert.NilError(t, err) + assert.DeepEqual(t, got, tt.want) + }) + } +} + +func TestIsGosmeeScript(t *testing.T) { + tests := []struct { + name string + input string + expect bool + }{ + { + name: "typical gosmee script", + input: `#!/usr/bin/env bash +set -euxfo pipefail +curl -sSi -H "Content-Type: application/json" -H "X-GitHub-Event: pull_request" -X POST -d @payload.json http://localhost:8080`, + expect: true, + }, + { + name: "simple curl command", + input: `curl -H "Content-Type: application/json" http://localhost:8080`, + expect: true, + }, + { + name: "curl without headers", + input: `curl http://localhost:8080`, + expect: false, + }, + { + name: "plain text headers", + input: `Content-Type: application/json +X-GitHub-Event: pull_request`, + expect: false, + }, + { + name: "json headers", + input: `{ + "Content-Type": "application/json", + "X-GitHub-Event": "pull_request" +}`, + expect: false, + }, + { + name: "empty input", + input: "", + expect: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isGosmeeScript(tt.input) + assert.Equal(t, got, tt.expect) + }) + } +} + +func TestParseGosmeeScript(t *testing.T) { + tests := []struct { + name string + input string + want map[string]string + wantErr bool + }{ + { + name: "typical gosmee script", + input: `#!/usr/bin/env bash +# Copyright 2023 Chmouel Boudjnah +set -euxfo pipefail + +curl -sSi -H "Content-Type: application/json" -H "X-GitHub-Event: pull_request" -H "User-Agent: GitHub-Hookshot/2d5e4d4" -X POST -d @payload.json http://localhost:8080`, + want: map[string]string{ + "Content-Type": "application/json", + "X-GitHub-Event": "pull_request", + "User-Agent": "GitHub-Hookshot/2d5e4d4", + }, + }, + { + name: "real-world example from gosmee", + input: `#!/usr/bin/env bash +# Replay script with headers and JSON payload to the target controller. +set -euxfo pipefail +cd $(dirname $(readlink -f $0)) + +curl -sSi -H "Content-Type: application/json" -H 'X-Forwarded-Proto: https' -H 'Accept-Encoding: gzip' -H 'Content-Length: 17' -H 'X-Forwarded-Host: hook.pipelinesascode.com' -H 'Accept: */*' -X POST -d @./payload.json ${targetURL}`, + want: map[string]string{ + "Content-Type": "application/json", + "X-Forwarded-Proto": "https", + "Accept-Encoding": "gzip", + "Content-Length": "17", + "X-Forwarded-Host": "hook.pipelinesascode.com", + "Accept": "*/*", + }, + }, + { + name: "script without curl commands", + input: `#!/bin/bash +echo "No curl commands here" +exit 0`, + wantErr: true, + }, + { + name: "curl without headers", + input: `#!/bin/bash +curl http://localhost:8080`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseGosmeeScript(tt.input) + if tt.wantErr { + assert.Assert(t, err != nil) + return + } + assert.NilError(t, err) + assert.DeepEqual(t, got, tt.want) + }) + } +} + +func TestEventFromGitHub(t *testing.T) { + pullRequestPayload := `{ + "action": "synchronize", + "number": 1234, + "pull_request": { + "title": "fix something somewhere out there", + "number": 1234, + "user": { + "login": "pachito" + }, + "head": { + "ref": "parsepayload", + "sha": "178fd7ac8826595cffaa23e574eb0c02c3e76dcf", + "repo": { + "html_url": "https://github.com/pachito/pipelines-as-code" + } + }, + "base": { + "ref": "main", + "repo": { + "html_url": "https://github.com/openshift-pipelines/pipelines-as-code" + } + }, + "labels": [] + }, + "repository": { + "name": "pipelines-as-code", + "full_name": "openshift-pipelines/pipelines-as-code", + "owner": { + "login": "openshift-pipelines" + }, + "html_url": "https://github.com/openshift-pipelines/pipelines-as-code", + "default_branch": "main" + }, + "sender": { + "login": "pachito" + } +}` + + pushPayload := `{ + "ref": "refs/heads/main", + "after": "123abc456def", + "head_commit": { + "id": "123abc456def", + "message": "Update README", + "url": "https://github.com/owner/repo/commit/123abc456def" + }, + "repository": { + "name": "test-repo", + "owner": { + "login": "test-owner" + }, + "html_url": "https://github.com/test-owner/test-repo", + "default_branch": "main" + }, + "sender": { + "login": "test-user" + } +}` + + tests := []struct { + name string + body []byte + headers map[string]string + wantErr bool + checks func(t *testing.T, event *info.Event) + }{ + { + name: "pull request event", + body: []byte(pullRequestPayload), + headers: map[string]string{ + "X-GitHub-Event": "pull_request", + }, + wantErr: false, + checks: func(t *testing.T, event *info.Event) { + assert.Equal(t, event.EventType, "pull_request") + assert.Equal(t, event.Organization, "openshift-pipelines") + assert.Equal(t, event.Repository, "pipelines-as-code") + assert.Equal(t, event.BaseBranch, "main") + assert.Equal(t, event.HeadBranch, "parsepayload") + assert.Equal(t, event.Sender, "pachito") + assert.Equal(t, event.PullRequestTitle, "fix something somewhere out there") + assert.Equal(t, event.PullRequestNumber, 1234) + assert.Equal(t, event.SHA, "178fd7ac8826595cffaa23e574eb0c02c3e76dcf") + }, + }, + { + name: "push event", + body: []byte(pushPayload), + headers: map[string]string{ + "X-GitHub-Event": "push", + }, + wantErr: false, + checks: func(t *testing.T, event *info.Event) { + assert.Equal(t, event.EventType, "push") + assert.Equal(t, event.Organization, "test-owner") + assert.Equal(t, event.Repository, "test-repo") + assert.Equal(t, event.BaseBranch, "refs/heads/main") + assert.Equal(t, event.HeadBranch, "refs/heads/main") + assert.Equal(t, event.Sender, "test-user") + assert.Equal(t, event.SHATitle, "Update README") + assert.Equal(t, event.SHA, "123abc456def") + }, + }, + { + name: "invalid json", + body: []byte(`{"invalid": json}`), + headers: map[string]string{ + "X-GitHub-Event": "pull_request", + }, + wantErr: true, + }, + { + name: "unsupported event type", + body: []byte(`{"action": "test"}`), + headers: map[string]string{ + "X-GitHub-Event": "unsupported_event", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event, err := eventFromGitHub(tt.body, tt.headers) + if tt.wantErr { + assert.Assert(t, err != nil) + return + } + assert.NilError(t, err) + assert.Assert(t, event != nil) + if tt.checks != nil { + tt.checks(t, event) + } + }) + } +} + +func TestEventFromGitHubMoreEventTypes(t *testing.T) { + issueCommentPayload := `{ + "action": "created", + "issue": { + "number": 123, + "pull_request": { + "html_url": "https://github.com/owner/repo/pull/123" + } + }, + "comment": { + "body": "/ok-to-test" + }, + "repository": { + "name": "test-repo", + "owner": { + "login": "test-owner" + }, + "html_url": "https://github.com/test-owner/test-repo", + "default_branch": "main" + }, + "sender": { + "login": "test-user" + } +}` + + commitCommentPayload := `{ + "action": "created", + "comment": { + "commit_id": "abc123def456", + "body": "/retest", + "html_url": "https://github.com/owner/repo/commit/abc123def456#comment" + }, + "repository": { + "name": "test-repo", + "owner": { + "login": "test-owner" + }, + "html_url": "https://github.com/test-owner/test-repo", + "default_branch": "main" + }, + "sender": { + "login": "test-user" + } +}` + + tests := []struct { + name string + body []byte + headers map[string]string + wantErr bool + checks func(t *testing.T, event *info.Event) + }{ + { + name: "issue comment event", + body: []byte(issueCommentPayload), + headers: map[string]string{ + "X-GitHub-Event": "issue_comment", + }, + wantErr: false, + checks: func(t *testing.T, event *info.Event) { + assert.Equal(t, event.EventType, "issue_comment") + assert.Equal(t, event.Organization, "test-owner") + assert.Equal(t, event.Repository, "test-repo") + assert.Equal(t, event.Sender, "test-user") + assert.Equal(t, event.TriggerComment, "/ok-to-test") + assert.Equal(t, event.PullRequestNumber, 123) + assert.Equal(t, event.TriggerTarget, triggertype.PullRequest) + }, + }, + { + name: "commit comment event", + body: []byte(commitCommentPayload), + headers: map[string]string{ + "X-GitHub-Event": "commit_comment", + }, + wantErr: false, + checks: func(t *testing.T, event *info.Event) { + assert.Equal(t, event.EventType, "commit_comment") + assert.Equal(t, event.Organization, "test-owner") + assert.Equal(t, event.Repository, "test-repo") + assert.Equal(t, event.Sender, "test-user") + assert.Equal(t, event.TriggerComment, "/retest") + assert.Equal(t, event.SHA, "abc123def456") + assert.Equal(t, event.TriggerTarget, triggertype.Push) + }, + }, + { + name: "missing X-GitHub-Event header", + body: []byte(`{"action": "test"}`), + headers: map[string]string{ + "Content-Type": "application/json", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event, err := eventFromGitHub(tt.body, tt.headers) + if tt.wantErr { + assert.Assert(t, err != nil) + return + } + assert.NilError(t, err) + assert.Assert(t, event != nil) + if tt.checks != nil { + tt.checks(t, event) + } + }) + } +} + +// TestEventFromGitHubWithProvider tests the enhanced GitHub event processing +// that uses API calls to enrich events when a token is provided. +func TestEventFromGitHubWithProvider(t *testing.T) { + // This is a mock test since we don't want to make real API calls in unit tests. + // We test the fallback behavior when no token is provided and ensure + // the enhanced function doesn't break existing functionality. + + issueCommentPayload := `{ + "action": "created", + "issue": { + "number": 123, + "pull_request": { + "html_url": "https://github.com/test-owner/test-repo/pull/123" + } + }, + "comment": { + "body": "/ok-to-test" + }, + "repository": { + "name": "test-repo", + "owner": { + "login": "test-owner" + }, + "html_url": "https://github.com/test-owner/test-repo", + "default_branch": "main" + }, + "sender": { + "login": "test-user" + } +}` + + headers := map[string]string{ + "X-GitHub-Event": "issue_comment", + } + + t.Run("fallback to basic parsing when no token provided", func(t *testing.T) { + // Test with empty token (should fallback to basic parsing) + event, err := eventFromGitHubWithProvider([]byte(issueCommentPayload), headers, "") + assert.NilError(t, err) + assert.Assert(t, event != nil) + assert.Equal(t, event.EventType, "issue_comment") + assert.Equal(t, event.Organization, "test-owner") + assert.Equal(t, event.Repository, "test-repo") + assert.Equal(t, event.PullRequestNumber, 123) + + // With basic parsing, these fields should not be populated for issue_comment + assert.Equal(t, event.SHA, "") + assert.Equal(t, event.HeadBranch, "") + assert.Equal(t, event.BaseBranch, "") + }) + + t.Run("handles invalid token gracefully", func(t *testing.T) { + // Test with invalid token (should fallback to basic parsing) + event, err := eventFromGitHubWithProvider([]byte(issueCommentPayload), headers, "invalid-token") + assert.NilError(t, err) // Should not fail, just fallback + assert.Assert(t, event != nil) + assert.Equal(t, event.EventType, "issue_comment") + assert.Equal(t, event.Organization, "test-owner") + assert.Equal(t, event.Repository, "test-repo") + }) + + t.Run("handles pull request events without token", func(t *testing.T) { + // For PR events, both basic and enhanced parsing should work the same + prPayload := `{ + "action": "opened", + "pull_request": { + "number": 123, + "title": "Test PR", + "head": { + "sha": "abc123", + "ref": "feature-branch" + }, + "base": { + "ref": "main" + }, + "user": { + "login": "test-user" + } + }, + "repository": { + "name": "test-repo", + "owner": { + "login": "test-owner" + }, + "html_url": "https://github.com/test-owner/test-repo", + "default_branch": "main" + } +}` + prHeaders := map[string]string{ + "X-GitHub-Event": "pull_request", + } + + event, err := eventFromGitHubWithProvider([]byte(prPayload), prHeaders, "") + assert.NilError(t, err) + assert.Assert(t, event != nil) + assert.Equal(t, event.EventType, "pull_request") + assert.Equal(t, event.SHA, "abc123") + assert.Equal(t, event.HeadBranch, "feature-branch") + assert.Equal(t, event.BaseBranch, "main") + assert.Equal(t, event.PullRequestTitle, "Test PR") + }) +} + +func TestPacParamsFromEvent(t *testing.T) { + tests := []struct { + name string + event *info.Event + want map[string]string + }{ + { + name: "pull request event", + event: &info.Event{ + EventType: "pull_request", + Organization: "OpenShift-Pipelines", + Repository: "Pipelines-As-Code", + BaseBranch: "main", + HeadBranch: "feature-branch", + Sender: "TestUser", + SHA: "abc123", + URL: "https://github.com/openshift-pipelines/pipelines-as-code", + BaseURL: "https://github.com/openshift-pipelines/pipelines-as-code", + HeadURL: "https://github.com/user/pipelines-as-code", + PullRequestTitle: "Add new feature", + TriggerTarget: triggertype.PullRequest, + TriggerComment: "test comment\nwith newlines", + PullRequestLabel: []string{"bug", "enhancement"}, + }, + want: map[string]string{ + "revision": "abc123", + "repo_url": "https://github.com/openshift-pipelines/pipelines-as-code", + "repo_owner": "openshift-pipelines", + "repo_name": "pipelines-as-code", + "target_branch": "main", + "source_branch": "feature-branch", + "git_tag": "", + "source_url": "https://github.com/user/pipelines-as-code", + "target_url": "https://github.com/openshift-pipelines/pipelines-as-code", + "sender": "testuser", + "target_namespace": "", + "event_type": "pull_request", + "event": "pull_request", + "event_title": "Add new feature", + "trigger_comment": "test comment\\nwith newlines", + "pull_request_labels": "bug\nenhancement", + }, + }, + { + name: "push event with tag", + event: &info.Event{ + EventType: "push", + Organization: "test-org", + Repository: "test-repo", + BaseBranch: "refs/tags/v1.0.0", + HeadBranch: "refs/tags/v1.0.0", + Sender: "test-user", + SHA: "def456", + URL: "https://github.com/test-org/test-repo", + BaseURL: "https://github.com/test-org/test-repo", + HeadURL: "https://github.com/test-org/test-repo", + SHATitle: "Release v1.0.0", + TriggerTarget: triggertype.Push, + }, + want: map[string]string{ + "revision": "def456", + "repo_url": "https://github.com/test-org/test-repo", + "repo_owner": "test-org", + "repo_name": "test-repo", + "target_branch": "refs/tags/v1.0.0", + "source_branch": "refs/tags/v1.0.0", + "git_tag": "v1.0.0", + "source_url": "https://github.com/test-org/test-repo", + "target_url": "https://github.com/test-org/test-repo", + "sender": "test-user", + "target_namespace": "", + "event_type": "push", + "event": "push", + "event_title": "Release v1.0.0", + "trigger_comment": "", + "pull_request_labels": "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := pacParamsFromEvent(tt.event) + assert.DeepEqual(t, got, tt.want) + }) + } +} + +func TestPacParamsFromEventEdgeCases(t *testing.T) { + tests := []struct { + name string + event *info.Event + want map[string]string + }{ + { + name: "event with clone URL preference", + event: &info.Event{ + EventType: "push", + Organization: "test-org", + Repository: "test-repo", + URL: "https://github.com/test-org/test-repo", + CloneURL: "git@github.com:test-org/test-repo.git", + TriggerTarget: triggertype.Push, + }, + want: map[string]string{ + "revision": "", + "repo_url": "git@github.com:test-org/test-repo.git", // CloneURL takes precedence + "repo_owner": "test-org", + "repo_name": "test-repo", + "target_branch": "", + "source_branch": "", + "git_tag": "", + "source_url": "", + "target_url": "", + "sender": "", + "target_namespace": "", + "event_type": "push", + "event": "push", + "event_title": "", + "trigger_comment": "", + "pull_request_labels": "", + }, + }, + { + name: "event with carriage return in comment", + event: &info.Event{ + TriggerTarget: triggertype.PullRequest, + TriggerComment: "line1\r\nline2\nline3", + }, + want: map[string]string{ + "revision": "", + "repo_url": "", + "repo_owner": "", + "repo_name": "", + "target_branch": "", + "source_branch": "", + "git_tag": "", + "source_url": "", + "target_url": "", + "sender": "", + "target_namespace": "", + "event_type": "", + "event": "pull_request", + "event_title": "", + "trigger_comment": "line1\\nline2\\nline3", // newlines escaped + "pull_request_labels": "", + }, + }, + { + name: "empty event", + event: &info.Event{ + TriggerTarget: triggertype.Push, + }, + want: map[string]string{ + "revision": "", + "repo_url": "", + "repo_owner": "", + "repo_name": "", + "target_branch": "", + "source_branch": "", + "git_tag": "", + "source_url": "", + "target_url": "", + "sender": "", + "target_namespace": "", + "event_type": "", + "event": "push", + "event_title": "", + "trigger_comment": "", + "pull_request_labels": "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := pacParamsFromEvent(tt.event) + assert.DeepEqual(t, got, tt.want) + }) + } +} + +func TestCommandExecution(t *testing.T) { + pullRequestPayload := `{ + "action": "synchronize", + "number": 1234, + "pull_request": { + "title": "fix something somewhere out there", + "number": 1234, + "user": { + "login": "pachito" + }, + "head": { + "ref": "parsepayload", + "sha": "178fd7ac8826595cffaa23e574eb0c02c3e76dcf", + "repo": { + "html_url": "https://github.com/pachito/pipelines-as-code" + } + }, + "base": { + "ref": "main", + "repo": { + "html_url": "https://github.com/openshift-pipelines/pipelines-as-code" + } + }, + "labels": [], + "draft": false + }, + "repository": { + "name": "pipelines-as-code", + "full_name": "openshift-pipelines/pipelines-as-code", + "owner": { + "login": "openshift-pipelines" + }, + "html_url": "https://github.com/openshift-pipelines/pipelines-as-code", + "default_branch": "main" + }, + "sender": { + "login": "pachito" + } +}` + + headers := `Accept: */* +Content-Type: application/json +User-Agent: GitHub-Hookshot/2d5e4d4 +X-GitHub-Event: pull_request` + + tests := []struct { + name string + bodyContent string + headersContent string + provider string + wantErr bool + wantErrContains string + wantOutContains []string + }{ + { + name: "valid pull request payload", + bodyContent: pullRequestPayload, + headersContent: headers, + provider: "github", + wantErr: false, // Interactive mode should exit gracefully on EOF + wantOutContains: []string{ + "CEL Expression Evaluator for Pipelines as Code", + "PAC Parameters (pac.* - for backward compatibility):", + "pac.event", + "pac.target_branch", + "body.action", + "headers['x-github-event']", + }, + }, + { + name: "no files provided", + provider: "github", + wantErr: true, + wantErrContains: "unknown X-Github-Event", + }, + { + name: "no body file", + headersContent: headers, + provider: "github", + wantErr: true, + wantErrContains: "unexpected end of JSON input", + }, + { + name: "unsupported provider", + bodyContent: pullRequestPayload, + headersContent: headers, + provider: "invalid-provider", + wantErr: true, + wantErrContains: "unsupported provider invalid-provider", + }, + { + name: "invalid json body", + bodyContent: `{"invalid": json}`, + headersContent: headers, + provider: "github", + wantErr: true, + wantErrContains: "invalid character", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := fs.NewDir(t, "cel-test") + defer tempDir.Remove() + + var bodyFile, headersFile string + + if tt.bodyContent != "" { + bodyFile = tempDir.Join("payload.json") + err := os.WriteFile(bodyFile, []byte(tt.bodyContent), 0o600) + assert.NilError(t, err) + } + + if tt.headersContent != "" { + headersFile = tempDir.Join("headers.txt") + err := os.WriteFile(headersFile, []byte(tt.headersContent), 0o600) + assert.NilError(t, err) + } + + ioStreams, out, errOut := newIOStream() + + // Write empty input to stdin to exit the interactive loop immediately + ioStreams.In = io.NopCloser(strings.NewReader("\n")) + + cmd := Command(ioStreams) + cmd.SetArgs([]string{ + "--provider", tt.provider, + }) + + if bodyFile != "" { + if err := cmd.Flags().Set("body", bodyFile); err != nil { + t.Fatalf("failed to set body flag: %v", err) + } + } + if headersFile != "" { + if err := cmd.Flags().Set("headers", headersFile); err != nil { + t.Fatalf("failed to set headers flag: %v", err) + } + } + + err := cmd.Execute() + + if tt.wantErr { + assert.Assert(t, err != nil) + if tt.wantErrContains != "" { + assert.Assert(t, strings.Contains(err.Error(), tt.wantErrContains), + "error %q should contain %q", err.Error(), tt.wantErrContains) + } + // For EOF error (successful processing but empty input), check output still + if tt.wantErrContains == "EOF" && len(tt.wantOutContains) > 0 { + outStr := out.String() + errStr := errOut.String() + for _, want := range tt.wantOutContains { + assert.Assert(t, strings.Contains(outStr, want) || strings.Contains(errStr, want), + "output should contain %q, got out: %q, err: %q", want, outStr, errStr) + } + } + return + } + + assert.NilError(t, err) + + outStr := out.String() + errStr := errOut.String() + + for _, want := range tt.wantOutContains { + assert.Assert(t, strings.Contains(outStr, want) || strings.Contains(errStr, want), + "output should contain %q, got out: %q, err: %q", want, outStr, errStr) + } + }) + } +} + +func TestCommandFileHandling(t *testing.T) { + tests := []struct { + name string + headersContent string + isJSON bool + wantErr bool + wantErrContains string + }{ + { + name: "plain text headers", + headersContent: `Accept: */* +Content-Type: application/json +X-GitHub-Event: pull_request`, + isJSON: false, + wantErr: true, + wantErrContains: "unexpected end of JSON input", + }, + { + name: "json headers", + headersContent: `{ + "Accept": "*/*", + "Content-Type": "application/json", + "X-GitHub-Event": "pull_request" +}`, + isJSON: true, + wantErr: true, + wantErrContains: "unexpected end of JSON input", + }, + { + name: "invalid json headers", + headersContent: `{"invalid": json}`, + isJSON: true, + wantErr: true, + wantErrContains: "invalid character", + }, + { + name: "empty headers file", + headersContent: "", + wantErr: true, + wantErrContains: "unknown X-Github-Event", + }, + { + name: "gosmee script", + headersContent: `#!/usr/bin/env bash +set -euxfo pipefail +curl -sSi -H "Content-Type: application/json" -H "X-GitHub-Event: pull_request" -H "User-Agent: GitHub-Hookshot/2d5e4d4" -X POST -d @payload.json http://localhost:8080`, + wantErr: true, + wantErrContains: "unexpected end of JSON input", // Still expects body file + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := fs.NewDir(t, "cel-headers-test") + defer tempDir.Remove() + + headersFile := tempDir.Join("headers.txt") + err := os.WriteFile(headersFile, []byte(tt.headersContent), 0o600) + assert.NilError(t, err) + + ioStreams, _, _ := newIOStream() + // Write empty input to stdin to exit immediately + ioStreams.In = io.NopCloser(strings.NewReader("\n")) + + cmd := Command(ioStreams) + cmd.SetArgs([]string{ + "--provider", "github", + "--headers", headersFile, + }) + + err = cmd.Execute() + + if tt.wantErr { + assert.Assert(t, err != nil) + if tt.wantErrContains != "" { + assert.Assert(t, strings.Contains(err.Error(), tt.wantErrContains), + "error %q should contain %q", err.Error(), tt.wantErrContains) + } + } else { + assert.NilError(t, err) + } + }) + } +} + +func TestCommandFlags(t *testing.T) { + ioStreams, _, _ := newIOStream() + cmd := Command(ioStreams) + + // Test that flags are properly defined + bodyFlag := cmd.Flags().Lookup("body") + assert.Assert(t, bodyFlag != nil) + assert.Equal(t, bodyFlag.Shorthand, "b") + assert.Equal(t, bodyFlag.Usage, "path to JSON body file") + + headersFlag := cmd.Flags().Lookup("headers") + assert.Assert(t, headersFlag != nil) + assert.Equal(t, headersFlag.Shorthand, "H") + assert.Equal(t, headersFlag.Usage, "path to headers file (JSON, HTTP format, or gosmee-generated shell script)") + + providerFlag := cmd.Flags().Lookup("provider") + assert.Assert(t, providerFlag != nil) + assert.Equal(t, providerFlag.Shorthand, "p") + assert.Equal(t, providerFlag.Usage, "payload provider (auto, github, gitlab, bitbucket-cloud, bitbucket-datacenter, gitea)") + assert.Equal(t, providerFlag.DefValue, "auto") +} + +func TestInvalidFiles(t *testing.T) { + tests := []struct { + name string + createFile bool + fileContent string + fileFlag string + wantErrContains string + }{ + { + name: "non-existent body file", + createFile: false, + fileFlag: "body", + wantErrContains: "no such file or directory", + }, + { + name: "non-existent headers file", + createFile: false, + fileFlag: "headers", + wantErrContains: "no such file or directory", + }, + { + name: "invalid json in body file", + createFile: true, + fileContent: `{"invalid": json}`, + fileFlag: "body", + wantErrContains: "invalid character", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := fs.NewDir(t, "cel-invalid-test") + defer tempDir.Remove() + + var filePath string + if tt.createFile { + filePath = tempDir.Join("test-file") + err := os.WriteFile(filePath, []byte(tt.fileContent), 0o600) + assert.NilError(t, err) + } else { + filePath = filepath.Join(tempDir.Path(), "non-existent-file") + } + + ioStreams, _, _ := newIOStream() + ioStreams.In = io.NopCloser(strings.NewReader("\n")) + + cmd := Command(ioStreams) + args := []string{"--provider", "github"} + + if tt.fileFlag == "body" { + args = append(args, "--body", filePath) + } else { + args = append(args, "--headers", filePath) + } + + cmd.SetArgs(args) + + err := cmd.Execute() + assert.Assert(t, err != nil) + assert.Assert(t, strings.Contains(err.Error(), tt.wantErrContains), + "error %q should contain %q", err.Error(), tt.wantErrContains) + }) + } +} + +func TestCommandWithGosmeeScript(t *testing.T) { + // Real-world pull request payload + pullRequestPayload := `{ + "action": "opened", + "number": 1, + "pull_request": { + "id": 1, + "number": 1, + "title": "Test PR", + "user": { + "login": "testuser", + "id": 1 + }, + "body": "This is a test PR", + "head": { + "label": "testuser:feature", + "ref": "feature", + "sha": "abc123", + "repo": { + "id": 1, + "name": "test-repo", + "full_name": "testuser/test-repo", + "html_url": "https://github.com/testuser/test-repo" + } + }, + "base": { + "label": "testorg:main", + "ref": "main", + "sha": "def456", + "repo": { + "id": 2, + "name": "test-repo", + "full_name": "testorg/test-repo", + "html_url": "https://github.com/testorg/test-repo" + } + }, + "draft": false + }, + "repository": { + "id": 2, + "name": "test-repo", + "full_name": "testorg/test-repo", + "owner": { + "login": "testorg", + "id": 2 + }, + "html_url": "https://github.com/testorg/test-repo", + "default_branch": "main" + }, + "sender": { + "login": "testuser", + "id": 1 + } +}` + + // Gosmee script content + gosmeeScript := `#!/usr/bin/env bash +# Copyright 2023 Chmouel Boudjnah +# Replay script with headers and JSON payload to the target controller. +# +set -euxfo pipefail +cd $(dirname $(readlink -f $0)) + +targetURL="http://localhost:8082" +if [[ ${1:-""} == -l ]]; then + targetURL="http://localhost:8082" +elif [[ -n ${1:-""} ]]; then + targetURL=${1} +elif [[ -n ${GOSMEE_DEBUG_SERVICE:-""} ]]; then + targetURL=${GOSMEE_DEBUG_SERVICE} +fi + +curl -sSi -H "Content-Type: application/json" -H 'X-Forwarded-Proto: https' -H 'Accept-Encoding: gzip' -H 'Content-Length: 17' -H 'User-Agent: curl/8.7.1' -H 'X-Forwarded-Host: hook.pipelinesascode.com' -H 'X-Forwarded-For: 82.66.174.128' -H 'Accept: */*' -H 'X-GitHub-Event: pull_request' -H 'Via: 2.0 Caddy' -X POST -d @./payload.json ${targetURL}` + + tempDir := fs.NewDir(t, "cel-gosmee-test") + defer tempDir.Remove() + + // Create body file + bodyFile := tempDir.Join("payload.json") + err := os.WriteFile(bodyFile, []byte(pullRequestPayload), 0o600) + assert.NilError(t, err) + + // Create gosmee script file + headersFile := tempDir.Join("replay.sh") + err = os.WriteFile(headersFile, []byte(gosmeeScript), 0o700) // Executable + assert.NilError(t, err) + + ioStreams, out, _ := newIOStream() + // Write empty input to stdin to exit the interactive loop immediately + ioStreams.In = io.NopCloser(strings.NewReader("\n")) + + cmd := Command(ioStreams) + cmd.SetArgs([]string{ + "--provider", "github", + "--body", bodyFile, + "--headers", headersFile, + }) + + err = cmd.Execute() + // Interactive mode should exit gracefully on EOF + assert.NilError(t, err) + + outStr := out.String() + // Verify the help text was printed and gosmee headers were parsed + assert.Assert(t, strings.Contains(outStr, "CEL Expression Evaluator for Pipelines as Code")) + assert.Assert(t, strings.Contains(outStr, "pac.event")) + assert.Assert(t, strings.Contains(outStr, "body.action")) + assert.Assert(t, strings.Contains(outStr, "headers['x-github-event']")) + + // Test that the provider was auto-detected as GitHub + assert.Assert(t, strings.Contains(outStr, "Detected provider: github")) +} + +func TestCommandWithRealWorldPayloads(t *testing.T) { + // Real-world payload from a GitHub PR opened event + realPRPayload := `{ + "action": "opened", + "number": 1, + "pull_request": { + "id": 1, + "number": 1, + "title": "Test PR", + "user": { + "login": "testuser", + "id": 1 + }, + "body": "This is a test PR", + "created_at": "2023-01-01T00:00:00Z", + "head": { + "label": "testuser:feature", + "ref": "feature", + "sha": "abc123", + "repo": { + "id": 1, + "name": "test-repo", + "full_name": "testuser/test-repo", + "html_url": "https://github.com/testuser/test-repo" + } + }, + "base": { + "label": "testorg:main", + "ref": "main", + "sha": "def456", + "repo": { + "id": 2, + "name": "test-repo", + "full_name": "testorg/test-repo", + "html_url": "https://github.com/testorg/test-repo" + } + }, + "draft": false, + "labels": [ + { + "id": 1, + "name": "enhancement", + "color": "a2eeef" + } + ] + }, + "repository": { + "id": 2, + "name": "test-repo", + "full_name": "testorg/test-repo", + "owner": { + "login": "testorg", + "id": 2 + }, + "html_url": "https://github.com/testorg/test-repo", + "default_branch": "main" + }, + "sender": { + "login": "testuser", + "id": 1 + } +}` + + realHeaders := `Accept: */* +Accept-Encoding: gzip, deflate, br +Content-Type: application/json +User-Agent: GitHub-Hookshot/044aadd +X-GitHub-Delivery: 12345678-1234-1234-1234-123456789012 +X-GitHub-Event: pull_request +X-GitHub-Hook-ID: 123456789 +X-GitHub-Hook-Installation-Target-ID: 987654321 +X-GitHub-Hook-Installation-Target-Type: repository` + + tempDir := fs.NewDir(t, "cel-realworld-test") + defer tempDir.Remove() + + bodyFile := tempDir.Join("payload.json") + err := os.WriteFile(bodyFile, []byte(realPRPayload), 0o600) + assert.NilError(t, err) + + headersFile := tempDir.Join("headers.txt") + err = os.WriteFile(headersFile, []byte(realHeaders), 0o600) + assert.NilError(t, err) + + ioStreams, out, _ := newIOStream() + // Write empty input to stdin to exit the interactive loop immediately + ioStreams.In = io.NopCloser(strings.NewReader("\n")) + + cmd := Command(ioStreams) + cmd.SetArgs([]string{ + "--provider", "github", + "--body", bodyFile, + "--headers", headersFile, + }) + + err = cmd.Execute() + // Interactive mode should exit gracefully on EOF + assert.NilError(t, err) + + outStr := out.String() + // Verify the help text was printed + assert.Assert(t, strings.Contains(outStr, "CEL Expression Evaluator for Pipelines as Code")) + assert.Assert(t, strings.Contains(outStr, "pac.event")) + assert.Assert(t, strings.Contains(outStr, "body.action")) + assert.Assert(t, strings.Contains(outStr, "headers['x-github-event']")) +} + +func TestCELExpressionEvaluation(t *testing.T) { + // This test simulates the CEL evaluation by using the same components + // the command uses, but without the interactive prompt + pullRequestPayload := `{ + "action": "synchronize", + "number": 1234, + "pull_request": { + "title": "fix something somewhere out there", + "number": 1234, + "user": { + "login": "pachito" + }, + "head": { + "ref": "parsepayload", + "sha": "178fd7ac8826595cffaa23e574eb0c02c3e76dcf", + "repo": { + "html_url": "https://github.com/pachito/pipelines-as-code" + } + }, + "base": { + "ref": "main", + "repo": { + "html_url": "https://github.com/openshift-pipelines/pipelines-as-code" + } + }, + "labels": [], + "draft": false + }, + "repository": { + "name": "pipelines-as-code", + "full_name": "openshift-pipelines/pipelines-as-code", + "owner": { + "login": "openshift-pipelines" + }, + "html_url": "https://github.com/openshift-pipelines/pipelines-as-code", + "default_branch": "main" + }, + "sender": { + "login": "pachito" + } +}` + + headers := map[string]string{ + "X-GitHub-Event": "pull_request", + "Content-Type": "application/json", + } + + // Parse the event + event, err := eventFromGitHub([]byte(pullRequestPayload), headers) + assert.NilError(t, err) + + // Generate PAC parameters + pacParams := pacParamsFromEvent(event) + + // Parse the body for CEL access + var body map[string]any + err = json.Unmarshal([]byte(pullRequestPayload), &body) + assert.NilError(t, err) + + // Create files data structure (empty for CLI) + filesData := map[string]any{ + "all": []string{}, + "added": []string{}, + "deleted": []string{}, + "modified": []string{}, + "renamed": []string{}, + } + + // Test various CEL expressions that would be commonly used + tests := []struct { + name string + expression string + wantResult string // Using string to represent the expected result + wantErr bool + }{ + { + name: "pac event type", + expression: `pac.event == "pull_request"`, + wantResult: "true", + }, + { + name: "pac target branch", + expression: `pac.target_branch == "main"`, + wantResult: "true", + }, + { + name: "pac sender", + expression: `pac.sender == "pachito"`, + wantResult: "true", + }, + { + name: "body action", + expression: `body.action == "synchronize"`, + wantResult: "true", + }, + { + name: "body PR number", + expression: `body.number == 1234`, + wantResult: "true", + }, + { + name: "body PR not draft", + expression: `!body.pull_request.draft`, + wantResult: "true", + }, + { + name: "headers check", + expression: `headers['X-GitHub-Event'] == "pull_request"`, + wantResult: "true", + }, + { + name: "complex expression", + expression: `pac.event == "pull_request" && pac.target_branch == "main" && body.action == "synchronize"`, + wantResult: "true", + }, + { + name: "false condition", + expression: `pac.target_branch == "develop"`, + wantResult: "false", + }, + { + name: "invalid expression", + expression: `invalid.syntax...`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := pkgcel.Value(tt.expression, body, headers, pacParams, filesData) + if tt.wantErr { + assert.Assert(t, err != nil) + return + } + assert.NilError(t, err) + resultStr := fmt.Sprintf("%v", result) + assert.Equal(t, resultStr, tt.wantResult) + }) + } +} + +func TestDetectProvider(t *testing.T) { + tests := []struct { + name string + headers map[string]string + body []byte + expectedProvider string + expectedError bool + }{ + { + name: "GitHub provider", + headers: map[string]string{ + "X-GitHub-Event": "pull_request", + }, + body: []byte(`{"repository": {"html_url": "https://github.com/owner/repo"}}`), + expectedProvider: "github", + }, + { + name: "Gitea provider", + headers: map[string]string{ + "X-GitHub-Event": "pull_request", + "X-Gitea-Event-Type": "pull_request", + }, + body: []byte(`{"repository": {"html_url": "https://gitea.com/owner/repo"}}`), + expectedProvider: "gitea", + }, + { + name: "GitLab provider", + headers: map[string]string{ + "X-Gitlab-Event": "Merge Request Hook", + }, + body: []byte(`{"project": {"web_url": "https://gitlab.com/owner/repo"}}`), + expectedProvider: "gitlab", + }, + { + name: "Bitbucket Cloud provider", + headers: map[string]string{ + "User-Agent": "Bitbucket-Webhooks/2.0", + }, + body: []byte(`{"actor": {"account_id": "123"}}`), + expectedProvider: "bitbucket-cloud", + }, + { + name: "Bitbucket Data Center provider", + headers: map[string]string{ + "User-Agent": "Bitbucket-Webhooks/2.0", + }, + body: []byte(`{"actor": {"id": 123}}`), + expectedProvider: "bitbucket-datacenter", + }, + { + name: "Unknown provider", + headers: map[string]string{ + "X-Unknown-Header": "value", + }, + body: []byte(`{}`), + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, err := detectProvider(tt.headers, tt.body) + if tt.expectedError { + assert.Assert(t, err != nil) + } else { + assert.NilError(t, err) + assert.Equal(t, provider, tt.expectedProvider) + } + }) + } +} + +func TestEventFromGitLab(t *testing.T) { + tests := []struct { + name string + headers map[string]string + body []byte + expectedError bool + expectedOrg string + expectedRepo string + expectedSender string + expectedTrigger string + }{ + { + name: "GitLab merge request event", + headers: map[string]string{ + "X-Gitlab-Event": "Merge Request Hook", + }, + body: []byte(`{ + "object_kind": "merge_request", + "user": {"username": "testuser"}, + "project": { + "path_with_namespace": "testorg/testrepo", + "web_url": "https://gitlab.com/testorg/testrepo" + }, + "object_attributes": { + "iid": 1, + "target_branch": "main", + "source_branch": "feature", + "title": "Test MR" + } + }`), + expectedOrg: "testorg", + expectedRepo: "testrepo", + expectedSender: "testuser", + expectedTrigger: triggertype.PullRequest.String(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event, err := eventFromGitLab(tt.body, tt.headers) + if tt.expectedError { + assert.Assert(t, err != nil) + return + } + assert.NilError(t, err) + assert.Equal(t, event.Organization, tt.expectedOrg) + assert.Equal(t, event.Repository, tt.expectedRepo) + assert.Equal(t, event.Sender, tt.expectedSender) + assert.Equal(t, event.TriggerTarget.String(), tt.expectedTrigger) + }) + } +} + +func TestDirectCELVariables(t *testing.T) { + // Test that the direct CEL variables (as per PAC documentation) work correctly + pullRequestPayload := `{ + "action": "opened", + "number": 123, + "pull_request": { + "title": "Add feature", + "user": { + "login": "testuser" + }, + "head": { + "ref": "feature-branch", + "sha": "abc123", + "repo": { + "html_url": "https://github.com/testuser/test-repo" + } + }, + "base": { + "ref": "main", + "repo": { + "html_url": "https://github.com/testorg/test-repo" + } + }, + "draft": false + }, + "repository": { + "name": "test-repo", + "full_name": "testorg/test-repo", + "owner": { + "login": "testorg" + }, + "html_url": "https://github.com/testorg/test-repo", + "default_branch": "main" + }, + "sender": { + "login": "testuser" + } +}` + + headers := map[string]string{ + "X-GitHub-Event": "pull_request", + "Content-Type": "application/json", + } + + // Parse the event + event, err := eventFromGitHub([]byte(pullRequestPayload), headers) + assert.NilError(t, err) + + // Generate PAC parameters + pacParams := pacParamsFromEvent(event) + + // Parse the body for CEL access + var body map[string]any + err = json.Unmarshal([]byte(pullRequestPayload), &body) + assert.NilError(t, err) + + // Create files data structure (empty for CLI) + filesData := map[string]any{ + "all": []string{}, + "added": []string{}, + "deleted": []string{}, + "modified": []string{}, + "renamed": []string{}, + } + + // Test direct CEL variables as per PAC documentation + tests := []struct { + name string + expression string + wantResult string + wantErr bool + }{ + { + name: "direct event variable", + expression: `event == "pull_request"`, + wantResult: "true", + }, + { + name: "direct target_branch variable", + expression: `target_branch == "main"`, + wantResult: "true", + }, + { + name: "direct source_branch variable", + expression: `source_branch == "feature-branch"`, + wantResult: "true", + }, + { + name: "direct event_title variable", + expression: `event_title == "Add feature"`, + wantResult: "true", + }, + { + name: "direct target_url variable", + expression: `target_url.contains("testorg/test-repo")`, + wantResult: "true", + }, + { + name: "direct source_url variable", + expression: `source_url.contains("testuser/test-repo")`, + wantResult: "true", + }, + { + name: "combined expression like PAC docs", + expression: `event == "pull_request" && target_branch == "main"`, + wantResult: "true", + }, + { + name: "regex matching like PAC docs", + expression: `source_branch.matches(".*feature.*")`, + wantResult: "true", + }, + { + name: "negative condition like PAC docs", + expression: `event == "pull_request" && target_branch != "experimental"`, + wantResult: "true", + }, + { + name: "backward compatibility - pac variables still work", + expression: `pac.event == "pull_request"`, + wantResult: "true", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := pkgcel.Value(tt.expression, body, headers, pacParams, filesData) + if tt.wantErr { + assert.Assert(t, err != nil) + return + } + assert.NilError(t, err) + resultStr := fmt.Sprintf("%v", result) + assert.Equal(t, resultStr, tt.wantResult) + }) + } +} diff --git a/pkg/cmd/tknpac/cel/templates/help.tmpl b/pkg/cmd/tknpac/cel/templates/help.tmpl new file mode 100644 index 000000000..0717ce382 --- /dev/null +++ b/pkg/cmd/tknpac/cel/templates/help.tmpl @@ -0,0 +1,38 @@ +🔍 CEL Expression Evaluator for Pipelines as Code +🚰 Detected provider: %s + +📋 Available variables: + 🎯 Direct variables (as per PAC documentation): + • event - event type (push, pull_request) + • target_branch - target branch name + • source_branch - source branch name + • target_url - target repository URL + • source_url - source repository URL + • event_title - PR title or commit message + 📦 Webhook payload (body.*): + • body.action - PR action (opened, synchronize, etc.) + • body.number - PR number + • body.pull_request.user.login - PR author + • body.pull_request.draft - true if PR is draft + 📡 HTTP headers (headers.*): + • headers['x-github-event'] - GitHub event type + • headers['x-github-delivery'] - GitHub delivery ID + • headers['content-type'] - Request content type + • headers['user-agent'] - User agent (GitHub-Hookshot/...) + 📁 Files (files.*): + ⚠️ Note: File-related variables (e.g., files.all) and functions (e.g., fileChanged) are not supported in the CLI and will be empty or false. + 🔧 PAC Parameters (pac.* - for backward compatibility): + • pac.event, pac.target_branch, pac.source_branch, etc. + +� Headers file formats supported: + • JSON format: {"X-GitHub-Event": "pull_request", "Content-Type": "application/json"} + • HTTP format: X-GitHub-Event: pull_request + • Gosmee script: Shell scripts with curl commands and -H flags (automatically detected) + +�💡 Example expressions: + ✓ event == "pull_request" && target_branch == "main" + ✓ event == "pull_request" && source_branch.matches(".*feat/.*") + ✓ body.action == "synchronize" + ✓ !body.pull_request.draft + ✓ headers['x-github-event'] == "pull_request" + ✓ event == "pull_request" && target_branch != "experimental" diff --git a/pkg/cmd/tknpac/root.go b/pkg/cmd/tknpac/root.go index 9f4defc92..206b185a6 100644 --- a/pkg/cmd/tknpac/root.go +++ b/pkg/cmd/tknpac/root.go @@ -3,6 +3,7 @@ package tknpac import ( "github.com/openshift-pipelines/pipelines-as-code/pkg/cli" "github.com/openshift-pipelines/pipelines-as-code/pkg/cmd/tknpac/bootstrap" + "github.com/openshift-pipelines/pipelines-as-code/pkg/cmd/tknpac/cel" "github.com/openshift-pipelines/pipelines-as-code/pkg/cmd/tknpac/completion" "github.com/openshift-pipelines/pipelines-as-code/pkg/cmd/tknpac/create" "github.com/openshift-pipelines/pipelines-as-code/pkg/cmd/tknpac/deleterepo" @@ -43,6 +44,7 @@ func Root(clients *params.Run) *cobra.Command { cmd.AddCommand(completion.Command()) cmd.AddCommand(bootstrap.Command(clients, ioStreams)) cmd.AddCommand(generate.Command(clients, ioStreams)) + cmd.AddCommand(cel.Command(ioStreams)) cmd.AddCommand(webhook.Root(clients, ioStreams)) return cmd } diff --git a/vendor/github.com/chzyer/readline/.gitignore b/vendor/github.com/chzyer/readline/.gitignore new file mode 100644 index 000000000..a3062beae --- /dev/null +++ b/vendor/github.com/chzyer/readline/.gitignore @@ -0,0 +1 @@ +.vscode/* diff --git a/vendor/github.com/chzyer/readline/.travis.yml b/vendor/github.com/chzyer/readline/.travis.yml new file mode 100644 index 000000000..9c3595543 --- /dev/null +++ b/vendor/github.com/chzyer/readline/.travis.yml @@ -0,0 +1,8 @@ +language: go +go: + - 1.x +script: + - GOOS=windows go install github.com/chzyer/readline/example/... + - GOOS=linux go install github.com/chzyer/readline/example/... + - GOOS=darwin go install github.com/chzyer/readline/example/... + - go test -race -v diff --git a/vendor/github.com/chzyer/readline/CHANGELOG.md b/vendor/github.com/chzyer/readline/CHANGELOG.md new file mode 100644 index 000000000..14ff5be13 --- /dev/null +++ b/vendor/github.com/chzyer/readline/CHANGELOG.md @@ -0,0 +1,58 @@ +# ChangeLog + +### 1.4 - 2016-07-25 + +* [#60][60] Support dynamic autocompletion +* Fix ANSI parser on Windows +* Fix wrong column width in complete mode on Windows +* Remove dependent package "golang.org/x/crypto/ssh/terminal" + +### 1.3 - 2016-05-09 + +* [#38][38] add SetChildren for prefix completer interface +* [#42][42] improve multiple lines compatibility +* [#43][43] remove sub-package(runes) for gopkg compatibility +* [#46][46] Auto complete with space prefixed line +* [#48][48] support suspend process (ctrl+Z) +* [#49][49] fix bug that check equals with previous command +* [#53][53] Fix bug which causes integer divide by zero panicking when input buffer is empty + +### 1.2 - 2016-03-05 + +* Add a demo for checking password strength [example/readline-pass-strength](https://github.com/chzyer/readline/blob/master/example/readline-pass-strength/readline-pass-strength.go), , written by [@sahib](https://github.com/sahib) +* [#23][23], support stdin remapping +* [#27][27], add a `UniqueEditLine` to `Config`, which will erase the editing line after user submited it, usually use in IM. +* Add a demo for multiline [example/readline-multiline](https://github.com/chzyer/readline/blob/master/example/readline-multiline/readline-multiline.go) which can submit one SQL by multiple lines. +* Supports performs even stdin/stdout is not a tty. +* Add a new simple apis for single instance, check by [here](https://github.com/chzyer/readline/blob/master/std.go). It need to save history manually if using this api. +* [#28][28], fixes the history is not working as expected. +* [#33][33], vim mode now support `c`, `d`, `x (delete character)`, `r (replace character)` + +### 1.1 - 2015-11-20 + +* [#12][12] Add support for key ``/``/`` +* Only enter raw mode as needed (calling `Readline()`), program will receive signal(e.g. Ctrl+C) if not interact with `readline`. +* Bugs fixed for `PrefixCompleter` +* Press `Ctrl+D` in empty line will cause `io.EOF` in error, Press `Ctrl+C` in anytime will cause `ErrInterrupt` instead of `io.EOF`, this will privodes a shell-like user experience. +* Customable Interrupt/EOF prompt in `Config` +* [#17][17] Change atomic package to use 32bit function to let it runnable on arm 32bit devices +* Provides a new password user experience(`readline.ReadPasswordEx()`). + +### 1.0 - 2015-10-14 + +* Initial public release. + +[12]: https://github.com/chzyer/readline/pull/12 +[17]: https://github.com/chzyer/readline/pull/17 +[23]: https://github.com/chzyer/readline/pull/23 +[27]: https://github.com/chzyer/readline/pull/27 +[28]: https://github.com/chzyer/readline/pull/28 +[33]: https://github.com/chzyer/readline/pull/33 +[38]: https://github.com/chzyer/readline/pull/38 +[42]: https://github.com/chzyer/readline/pull/42 +[43]: https://github.com/chzyer/readline/pull/43 +[46]: https://github.com/chzyer/readline/pull/46 +[48]: https://github.com/chzyer/readline/pull/48 +[49]: https://github.com/chzyer/readline/pull/49 +[53]: https://github.com/chzyer/readline/pull/53 +[60]: https://github.com/chzyer/readline/pull/60 diff --git a/vendor/github.com/chzyer/readline/LICENSE b/vendor/github.com/chzyer/readline/LICENSE new file mode 100644 index 000000000..c9afab3dc --- /dev/null +++ b/vendor/github.com/chzyer/readline/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Chzyer + +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/vendor/github.com/chzyer/readline/README.md b/vendor/github.com/chzyer/readline/README.md new file mode 100644 index 000000000..4b0a5ff58 --- /dev/null +++ b/vendor/github.com/chzyer/readline/README.md @@ -0,0 +1,114 @@ +[![Build Status](https://travis-ci.org/chzyer/readline.svg?branch=master)](https://travis-ci.org/chzyer/readline) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE.md) +[![Version](https://img.shields.io/github/tag/chzyer/readline.svg)](https://github.com/chzyer/readline/releases) +[![GoDoc](https://godoc.org/github.com/chzyer/readline?status.svg)](https://godoc.org/github.com/chzyer/readline) +[![OpenCollective](https://opencollective.com/readline/badge/backers.svg)](#backers) +[![OpenCollective](https://opencollective.com/readline/badge/sponsors.svg)](#sponsors) + +

+ + + +

+ +A powerful readline library in `Linux` `macOS` `Windows` `Solaris` `AIX` + +## Guide + +* [Demo](example/readline-demo/readline-demo.go) +* [Shortcut](doc/shortcut.md) + +## Repos using readline + +[![cockroachdb](https://img.shields.io/github/stars/cockroachdb/cockroach.svg?label=cockroachdb/cockroach)](https://github.com/cockroachdb/cockroach) +[![robertkrimen/otto](https://img.shields.io/github/stars/robertkrimen/otto.svg?label=robertkrimen/otto)](https://github.com/robertkrimen/otto) +[![empire](https://img.shields.io/github/stars/remind101/empire.svg?label=remind101/empire)](https://github.com/remind101/empire) +[![mehrdadrad/mylg](https://img.shields.io/github/stars/mehrdadrad/mylg.svg?label=mehrdadrad/mylg)](https://github.com/mehrdadrad/mylg) +[![knq/usql](https://img.shields.io/github/stars/knq/usql.svg?label=knq/usql)](https://github.com/knq/usql) +[![youtube/doorman](https://img.shields.io/github/stars/youtube/doorman.svg?label=youtube/doorman)](https://github.com/youtube/doorman) +[![bom-d-van/harp](https://img.shields.io/github/stars/bom-d-van/harp.svg?label=bom-d-van/harp)](https://github.com/bom-d-van/harp) +[![abiosoft/ishell](https://img.shields.io/github/stars/abiosoft/ishell.svg?label=abiosoft/ishell)](https://github.com/abiosoft/ishell) +[![Netflix/hal-9001](https://img.shields.io/github/stars/Netflix/hal-9001.svg?label=Netflix/hal-9001)](https://github.com/Netflix/hal-9001) +[![docker/go-p9p](https://img.shields.io/github/stars/docker/go-p9p.svg?label=docker/go-p9p)](https://github.com/docker/go-p9p) + + +## Feedback + +If you have any questions, please submit a github issue and any pull requests is welcomed :) + +* [https://twitter.com/chzyer](https://twitter.com/chzyer) +* [http://weibo.com/2145262190](http://weibo.com/2145262190) + + +## Backers + +Love Readline? Help me keep it alive by donating funds to cover project expenses!
+[[Become a backer](https://opencollective.com/readline#backer)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +## Sponsors + +Become a sponsor and get your logo here on our Github page. [[Become a sponsor](https://opencollective.com/readline#sponsor)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vendor/github.com/chzyer/readline/ansi_windows.go b/vendor/github.com/chzyer/readline/ansi_windows.go new file mode 100644 index 000000000..63b908c18 --- /dev/null +++ b/vendor/github.com/chzyer/readline/ansi_windows.go @@ -0,0 +1,249 @@ +// +build windows + +package readline + +import ( + "bufio" + "io" + "strconv" + "strings" + "sync" + "unicode/utf8" + "unsafe" +) + +const ( + _ = uint16(0) + COLOR_FBLUE = 0x0001 + COLOR_FGREEN = 0x0002 + COLOR_FRED = 0x0004 + COLOR_FINTENSITY = 0x0008 + + COLOR_BBLUE = 0x0010 + COLOR_BGREEN = 0x0020 + COLOR_BRED = 0x0040 + COLOR_BINTENSITY = 0x0080 + + COMMON_LVB_UNDERSCORE = 0x8000 + COMMON_LVB_BOLD = 0x0007 +) + +var ColorTableFg = []word{ + 0, // 30: Black + COLOR_FRED, // 31: Red + COLOR_FGREEN, // 32: Green + COLOR_FRED | COLOR_FGREEN, // 33: Yellow + COLOR_FBLUE, // 34: Blue + COLOR_FRED | COLOR_FBLUE, // 35: Magenta + COLOR_FGREEN | COLOR_FBLUE, // 36: Cyan + COLOR_FRED | COLOR_FBLUE | COLOR_FGREEN, // 37: White +} + +var ColorTableBg = []word{ + 0, // 40: Black + COLOR_BRED, // 41: Red + COLOR_BGREEN, // 42: Green + COLOR_BRED | COLOR_BGREEN, // 43: Yellow + COLOR_BBLUE, // 44: Blue + COLOR_BRED | COLOR_BBLUE, // 45: Magenta + COLOR_BGREEN | COLOR_BBLUE, // 46: Cyan + COLOR_BRED | COLOR_BBLUE | COLOR_BGREEN, // 47: White +} + +type ANSIWriter struct { + target io.Writer + wg sync.WaitGroup + ctx *ANSIWriterCtx + sync.Mutex +} + +func NewANSIWriter(w io.Writer) *ANSIWriter { + a := &ANSIWriter{ + target: w, + ctx: NewANSIWriterCtx(w), + } + return a +} + +func (a *ANSIWriter) Close() error { + a.wg.Wait() + return nil +} + +type ANSIWriterCtx struct { + isEsc bool + isEscSeq bool + arg []string + target *bufio.Writer + wantFlush bool +} + +func NewANSIWriterCtx(target io.Writer) *ANSIWriterCtx { + return &ANSIWriterCtx{ + target: bufio.NewWriter(target), + } +} + +func (a *ANSIWriterCtx) Flush() { + a.target.Flush() +} + +func (a *ANSIWriterCtx) process(r rune) bool { + if a.wantFlush { + if r == 0 || r == CharEsc { + a.wantFlush = false + a.target.Flush() + } + } + if a.isEscSeq { + a.isEscSeq = a.ioloopEscSeq(a.target, r, &a.arg) + return true + } + + switch r { + case CharEsc: + a.isEsc = true + case '[': + if a.isEsc { + a.arg = nil + a.isEscSeq = true + a.isEsc = false + break + } + fallthrough + default: + a.target.WriteRune(r) + a.wantFlush = true + } + return true +} + +func (a *ANSIWriterCtx) ioloopEscSeq(w *bufio.Writer, r rune, argptr *[]string) bool { + arg := *argptr + var err error + + if r >= 'A' && r <= 'D' { + count := short(GetInt(arg, 1)) + info, err := GetConsoleScreenBufferInfo() + if err != nil { + return false + } + switch r { + case 'A': // up + info.dwCursorPosition.y -= count + case 'B': // down + info.dwCursorPosition.y += count + case 'C': // right + info.dwCursorPosition.x += count + case 'D': // left + info.dwCursorPosition.x -= count + } + SetConsoleCursorPosition(&info.dwCursorPosition) + return false + } + + switch r { + case 'J': + killLines() + case 'K': + eraseLine() + case 'm': + color := word(0) + for _, item := range arg { + var c int + c, err = strconv.Atoi(item) + if err != nil { + w.WriteString("[" + strings.Join(arg, ";") + "m") + break + } + if c >= 30 && c < 40 { + color ^= COLOR_FINTENSITY + color |= ColorTableFg[c-30] + } else if c >= 40 && c < 50 { + color ^= COLOR_BINTENSITY + color |= ColorTableBg[c-40] + } else if c == 4 { + color |= COMMON_LVB_UNDERSCORE | ColorTableFg[7] + } else if c == 1 { + color |= COMMON_LVB_BOLD | COLOR_FINTENSITY + } else { // unknown code treat as reset + color = ColorTableFg[7] + } + } + if err != nil { + break + } + kernel.SetConsoleTextAttribute(stdout, uintptr(color)) + case '\007': // set title + case ';': + if len(arg) == 0 || arg[len(arg)-1] != "" { + arg = append(arg, "") + *argptr = arg + } + return true + default: + if len(arg) == 0 { + arg = append(arg, "") + } + arg[len(arg)-1] += string(r) + *argptr = arg + return true + } + *argptr = nil + return false +} + +func (a *ANSIWriter) Write(b []byte) (int, error) { + a.Lock() + defer a.Unlock() + + off := 0 + for len(b) > off { + r, size := utf8.DecodeRune(b[off:]) + if size == 0 { + return off, io.ErrShortWrite + } + off += size + a.ctx.process(r) + } + a.ctx.Flush() + return off, nil +} + +func killLines() error { + sbi, err := GetConsoleScreenBufferInfo() + if err != nil { + return err + } + + size := (sbi.dwCursorPosition.y - sbi.dwSize.y) * sbi.dwSize.x + size += sbi.dwCursorPosition.x + + var written int + kernel.FillConsoleOutputAttribute(stdout, uintptr(ColorTableFg[7]), + uintptr(size), + sbi.dwCursorPosition.ptr(), + uintptr(unsafe.Pointer(&written)), + ) + return kernel.FillConsoleOutputCharacterW(stdout, uintptr(' '), + uintptr(size), + sbi.dwCursorPosition.ptr(), + uintptr(unsafe.Pointer(&written)), + ) +} + +func eraseLine() error { + sbi, err := GetConsoleScreenBufferInfo() + if err != nil { + return err + } + + size := sbi.dwSize.x + sbi.dwCursorPosition.x = 0 + var written int + return kernel.FillConsoleOutputCharacterW(stdout, uintptr(' '), + uintptr(size), + sbi.dwCursorPosition.ptr(), + uintptr(unsafe.Pointer(&written)), + ) +} diff --git a/vendor/github.com/chzyer/readline/complete.go b/vendor/github.com/chzyer/readline/complete.go new file mode 100644 index 000000000..c08c99414 --- /dev/null +++ b/vendor/github.com/chzyer/readline/complete.go @@ -0,0 +1,285 @@ +package readline + +import ( + "bufio" + "bytes" + "fmt" + "io" +) + +type AutoCompleter interface { + // Readline will pass the whole line and current offset to it + // Completer need to pass all the candidates, and how long they shared the same characters in line + // Example: + // [go, git, git-shell, grep] + // Do("g", 1) => ["o", "it", "it-shell", "rep"], 1 + // Do("gi", 2) => ["t", "t-shell"], 2 + // Do("git", 3) => ["", "-shell"], 3 + Do(line []rune, pos int) (newLine [][]rune, length int) +} + +type TabCompleter struct{} + +func (t *TabCompleter) Do([]rune, int) ([][]rune, int) { + return [][]rune{[]rune("\t")}, 0 +} + +type opCompleter struct { + w io.Writer + op *Operation + width int + + inCompleteMode bool + inSelectMode bool + candidate [][]rune + candidateSource []rune + candidateOff int + candidateChoise int + candidateColNum int +} + +func newOpCompleter(w io.Writer, op *Operation, width int) *opCompleter { + return &opCompleter{ + w: w, + op: op, + width: width, + } +} + +func (o *opCompleter) doSelect() { + if len(o.candidate) == 1 { + o.op.buf.WriteRunes(o.candidate[0]) + o.ExitCompleteMode(false) + return + } + o.nextCandidate(1) + o.CompleteRefresh() +} + +func (o *opCompleter) nextCandidate(i int) { + o.candidateChoise += i + o.candidateChoise = o.candidateChoise % len(o.candidate) + if o.candidateChoise < 0 { + o.candidateChoise = len(o.candidate) + o.candidateChoise + } +} + +func (o *opCompleter) OnComplete() bool { + if o.width == 0 { + return false + } + if o.IsInCompleteSelectMode() { + o.doSelect() + return true + } + + buf := o.op.buf + rs := buf.Runes() + + if o.IsInCompleteMode() && o.candidateSource != nil && runes.Equal(rs, o.candidateSource) { + o.EnterCompleteSelectMode() + o.doSelect() + return true + } + + o.ExitCompleteSelectMode() + o.candidateSource = rs + newLines, offset := o.op.cfg.AutoComplete.Do(rs, buf.idx) + if len(newLines) == 0 { + o.ExitCompleteMode(false) + return true + } + + // only Aggregate candidates in non-complete mode + if !o.IsInCompleteMode() { + if len(newLines) == 1 { + buf.WriteRunes(newLines[0]) + o.ExitCompleteMode(false) + return true + } + + same, size := runes.Aggregate(newLines) + if size > 0 { + buf.WriteRunes(same) + o.ExitCompleteMode(false) + return true + } + } + + o.EnterCompleteMode(offset, newLines) + return true +} + +func (o *opCompleter) IsInCompleteSelectMode() bool { + return o.inSelectMode +} + +func (o *opCompleter) IsInCompleteMode() bool { + return o.inCompleteMode +} + +func (o *opCompleter) HandleCompleteSelect(r rune) bool { + next := true + switch r { + case CharEnter, CharCtrlJ: + next = false + o.op.buf.WriteRunes(o.op.candidate[o.op.candidateChoise]) + o.ExitCompleteMode(false) + case CharLineStart: + num := o.candidateChoise % o.candidateColNum + o.nextCandidate(-num) + case CharLineEnd: + num := o.candidateColNum - o.candidateChoise%o.candidateColNum - 1 + o.candidateChoise += num + if o.candidateChoise >= len(o.candidate) { + o.candidateChoise = len(o.candidate) - 1 + } + case CharBackspace: + o.ExitCompleteSelectMode() + next = false + case CharTab, CharForward: + o.doSelect() + case CharBell, CharInterrupt: + o.ExitCompleteMode(true) + next = false + case CharNext: + tmpChoise := o.candidateChoise + o.candidateColNum + if tmpChoise >= o.getMatrixSize() { + tmpChoise -= o.getMatrixSize() + } else if tmpChoise >= len(o.candidate) { + tmpChoise += o.candidateColNum + tmpChoise -= o.getMatrixSize() + } + o.candidateChoise = tmpChoise + case CharBackward: + o.nextCandidate(-1) + case CharPrev: + tmpChoise := o.candidateChoise - o.candidateColNum + if tmpChoise < 0 { + tmpChoise += o.getMatrixSize() + if tmpChoise >= len(o.candidate) { + tmpChoise -= o.candidateColNum + } + } + o.candidateChoise = tmpChoise + default: + next = false + o.ExitCompleteSelectMode() + } + if next { + o.CompleteRefresh() + return true + } + return false +} + +func (o *opCompleter) getMatrixSize() int { + line := len(o.candidate) / o.candidateColNum + if len(o.candidate)%o.candidateColNum != 0 { + line++ + } + return line * o.candidateColNum +} + +func (o *opCompleter) OnWidthChange(newWidth int) { + o.width = newWidth +} + +func (o *opCompleter) CompleteRefresh() { + if !o.inCompleteMode { + return + } + lineCnt := o.op.buf.CursorLineCount() + colWidth := 0 + for _, c := range o.candidate { + w := runes.WidthAll(c) + if w > colWidth { + colWidth = w + } + } + colWidth += o.candidateOff + 1 + same := o.op.buf.RuneSlice(-o.candidateOff) + + // -1 to avoid reach the end of line + width := o.width - 1 + colNum := width / colWidth + if colNum != 0 { + colWidth += (width - (colWidth * colNum)) / colNum + } + + o.candidateColNum = colNum + buf := bufio.NewWriter(o.w) + buf.Write(bytes.Repeat([]byte("\n"), lineCnt)) + + colIdx := 0 + lines := 1 + buf.WriteString("\033[J") + for idx, c := range o.candidate { + inSelect := idx == o.candidateChoise && o.IsInCompleteSelectMode() + if inSelect { + buf.WriteString("\033[30;47m") + } + buf.WriteString(string(same)) + buf.WriteString(string(c)) + buf.Write(bytes.Repeat([]byte(" "), colWidth-runes.WidthAll(c)-runes.WidthAll(same))) + + if inSelect { + buf.WriteString("\033[0m") + } + + colIdx++ + if colIdx == colNum { + buf.WriteString("\n") + lines++ + colIdx = 0 + } + } + + // move back + fmt.Fprintf(buf, "\033[%dA\r", lineCnt-1+lines) + fmt.Fprintf(buf, "\033[%dC", o.op.buf.idx+o.op.buf.PromptLen()) + buf.Flush() +} + +func (o *opCompleter) aggCandidate(candidate [][]rune) int { + offset := 0 + for i := 0; i < len(candidate[0]); i++ { + for j := 0; j < len(candidate)-1; j++ { + if i > len(candidate[j]) { + goto aggregate + } + if candidate[j][i] != candidate[j+1][i] { + goto aggregate + } + } + offset = i + } +aggregate: + return offset +} + +func (o *opCompleter) EnterCompleteSelectMode() { + o.inSelectMode = true + o.candidateChoise = -1 + o.CompleteRefresh() +} + +func (o *opCompleter) EnterCompleteMode(offset int, candidate [][]rune) { + o.inCompleteMode = true + o.candidate = candidate + o.candidateOff = offset + o.CompleteRefresh() +} + +func (o *opCompleter) ExitCompleteSelectMode() { + o.inSelectMode = false + o.candidate = nil + o.candidateChoise = -1 + o.candidateOff = -1 + o.candidateSource = nil +} + +func (o *opCompleter) ExitCompleteMode(revent bool) { + o.inCompleteMode = false + o.ExitCompleteSelectMode() +} diff --git a/vendor/github.com/chzyer/readline/complete_helper.go b/vendor/github.com/chzyer/readline/complete_helper.go new file mode 100644 index 000000000..58d724872 --- /dev/null +++ b/vendor/github.com/chzyer/readline/complete_helper.go @@ -0,0 +1,165 @@ +package readline + +import ( + "bytes" + "strings" +) + +// Caller type for dynamic completion +type DynamicCompleteFunc func(string) []string + +type PrefixCompleterInterface interface { + Print(prefix string, level int, buf *bytes.Buffer) + Do(line []rune, pos int) (newLine [][]rune, length int) + GetName() []rune + GetChildren() []PrefixCompleterInterface + SetChildren(children []PrefixCompleterInterface) +} + +type DynamicPrefixCompleterInterface interface { + PrefixCompleterInterface + IsDynamic() bool + GetDynamicNames(line []rune) [][]rune +} + +type PrefixCompleter struct { + Name []rune + Dynamic bool + Callback DynamicCompleteFunc + Children []PrefixCompleterInterface +} + +func (p *PrefixCompleter) Tree(prefix string) string { + buf := bytes.NewBuffer(nil) + p.Print(prefix, 0, buf) + return buf.String() +} + +func Print(p PrefixCompleterInterface, prefix string, level int, buf *bytes.Buffer) { + if strings.TrimSpace(string(p.GetName())) != "" { + buf.WriteString(prefix) + if level > 0 { + buf.WriteString("├") + buf.WriteString(strings.Repeat("─", (level*4)-2)) + buf.WriteString(" ") + } + buf.WriteString(string(p.GetName()) + "\n") + level++ + } + for _, ch := range p.GetChildren() { + ch.Print(prefix, level, buf) + } +} + +func (p *PrefixCompleter) Print(prefix string, level int, buf *bytes.Buffer) { + Print(p, prefix, level, buf) +} + +func (p *PrefixCompleter) IsDynamic() bool { + return p.Dynamic +} + +func (p *PrefixCompleter) GetName() []rune { + return p.Name +} + +func (p *PrefixCompleter) GetDynamicNames(line []rune) [][]rune { + var names = [][]rune{} + for _, name := range p.Callback(string(line)) { + names = append(names, []rune(name+" ")) + } + return names +} + +func (p *PrefixCompleter) GetChildren() []PrefixCompleterInterface { + return p.Children +} + +func (p *PrefixCompleter) SetChildren(children []PrefixCompleterInterface) { + p.Children = children +} + +func NewPrefixCompleter(pc ...PrefixCompleterInterface) *PrefixCompleter { + return PcItem("", pc...) +} + +func PcItem(name string, pc ...PrefixCompleterInterface) *PrefixCompleter { + name += " " + return &PrefixCompleter{ + Name: []rune(name), + Dynamic: false, + Children: pc, + } +} + +func PcItemDynamic(callback DynamicCompleteFunc, pc ...PrefixCompleterInterface) *PrefixCompleter { + return &PrefixCompleter{ + Callback: callback, + Dynamic: true, + Children: pc, + } +} + +func (p *PrefixCompleter) Do(line []rune, pos int) (newLine [][]rune, offset int) { + return doInternal(p, line, pos, line) +} + +func Do(p PrefixCompleterInterface, line []rune, pos int) (newLine [][]rune, offset int) { + return doInternal(p, line, pos, line) +} + +func doInternal(p PrefixCompleterInterface, line []rune, pos int, origLine []rune) (newLine [][]rune, offset int) { + line = runes.TrimSpaceLeft(line[:pos]) + goNext := false + var lineCompleter PrefixCompleterInterface + for _, child := range p.GetChildren() { + childNames := make([][]rune, 1) + + childDynamic, ok := child.(DynamicPrefixCompleterInterface) + if ok && childDynamic.IsDynamic() { + childNames = childDynamic.GetDynamicNames(origLine) + } else { + childNames[0] = child.GetName() + } + + for _, childName := range childNames { + if len(line) >= len(childName) { + if runes.HasPrefix(line, childName) { + if len(line) == len(childName) { + newLine = append(newLine, []rune{' '}) + } else { + newLine = append(newLine, childName) + } + offset = len(childName) + lineCompleter = child + goNext = true + } + } else { + if runes.HasPrefix(childName, line) { + newLine = append(newLine, childName[len(line):]) + offset = len(line) + lineCompleter = child + } + } + } + } + + if len(newLine) != 1 { + return + } + + tmpLine := make([]rune, 0, len(line)) + for i := offset; i < len(line); i++ { + if line[i] == ' ' { + continue + } + + tmpLine = append(tmpLine, line[i:]...) + return doInternal(lineCompleter, tmpLine, len(tmpLine), origLine) + } + + if goNext { + return doInternal(lineCompleter, nil, 0, origLine) + } + return +} diff --git a/vendor/github.com/chzyer/readline/complete_segment.go b/vendor/github.com/chzyer/readline/complete_segment.go new file mode 100644 index 000000000..5ceadd80f --- /dev/null +++ b/vendor/github.com/chzyer/readline/complete_segment.go @@ -0,0 +1,82 @@ +package readline + +type SegmentCompleter interface { + // a + // |- a1 + // |--- a11 + // |- a2 + // b + // input: + // DoTree([], 0) [a, b] + // DoTree([a], 1) [a] + // DoTree([a, ], 0) [a1, a2] + // DoTree([a, a], 1) [a1, a2] + // DoTree([a, a1], 2) [a1] + // DoTree([a, a1, ], 0) [a11] + // DoTree([a, a1, a], 1) [a11] + DoSegment([][]rune, int) [][]rune +} + +type dumpSegmentCompleter struct { + f func([][]rune, int) [][]rune +} + +func (d *dumpSegmentCompleter) DoSegment(segment [][]rune, n int) [][]rune { + return d.f(segment, n) +} + +func SegmentFunc(f func([][]rune, int) [][]rune) AutoCompleter { + return &SegmentComplete{&dumpSegmentCompleter{f}} +} + +func SegmentAutoComplete(completer SegmentCompleter) *SegmentComplete { + return &SegmentComplete{ + SegmentCompleter: completer, + } +} + +type SegmentComplete struct { + SegmentCompleter +} + +func RetSegment(segments [][]rune, cands [][]rune, idx int) ([][]rune, int) { + ret := make([][]rune, 0, len(cands)) + lastSegment := segments[len(segments)-1] + for _, cand := range cands { + if !runes.HasPrefix(cand, lastSegment) { + continue + } + ret = append(ret, cand[len(lastSegment):]) + } + return ret, idx +} + +func SplitSegment(line []rune, pos int) ([][]rune, int) { + segs := [][]rune{} + lastIdx := -1 + line = line[:pos] + pos = 0 + for idx, l := range line { + if l == ' ' { + pos = 0 + segs = append(segs, line[lastIdx+1:idx]) + lastIdx = idx + } else { + pos++ + } + } + segs = append(segs, line[lastIdx+1:]) + return segs, pos +} + +func (c *SegmentComplete) Do(line []rune, pos int) (newLine [][]rune, offset int) { + + segment, idx := SplitSegment(line, pos) + + cands := c.DoSegment(segment, idx) + newLine, offset = RetSegment(segment, cands, idx) + for idx := range newLine { + newLine[idx] = append(newLine[idx], ' ') + } + return newLine, offset +} diff --git a/vendor/github.com/chzyer/readline/history.go b/vendor/github.com/chzyer/readline/history.go new file mode 100644 index 000000000..6b17c464b --- /dev/null +++ b/vendor/github.com/chzyer/readline/history.go @@ -0,0 +1,330 @@ +package readline + +import ( + "bufio" + "container/list" + "fmt" + "os" + "strings" + "sync" +) + +type hisItem struct { + Source []rune + Version int64 + Tmp []rune +} + +func (h *hisItem) Clean() { + h.Source = nil + h.Tmp = nil +} + +type opHistory struct { + cfg *Config + history *list.List + historyVer int64 + current *list.Element + fd *os.File + fdLock sync.Mutex + enable bool +} + +func newOpHistory(cfg *Config) (o *opHistory) { + o = &opHistory{ + cfg: cfg, + history: list.New(), + enable: true, + } + return o +} + +func (o *opHistory) Reset() { + o.history = list.New() + o.current = nil +} + +func (o *opHistory) IsHistoryClosed() bool { + o.fdLock.Lock() + defer o.fdLock.Unlock() + return o.fd.Fd() == ^(uintptr(0)) +} + +func (o *opHistory) Init() { + if o.IsHistoryClosed() { + o.initHistory() + } +} + +func (o *opHistory) initHistory() { + if o.cfg.HistoryFile != "" { + o.historyUpdatePath(o.cfg.HistoryFile) + } +} + +// only called by newOpHistory +func (o *opHistory) historyUpdatePath(path string) { + o.fdLock.Lock() + defer o.fdLock.Unlock() + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0666) + if err != nil { + return + } + o.fd = f + r := bufio.NewReader(o.fd) + total := 0 + for ; ; total++ { + line, err := r.ReadString('\n') + if err != nil { + break + } + // ignore the empty line + line = strings.TrimSpace(line) + if len(line) == 0 { + continue + } + o.Push([]rune(line)) + o.Compact() + } + if total > o.cfg.HistoryLimit { + o.rewriteLocked() + } + o.historyVer++ + o.Push(nil) + return +} + +func (o *opHistory) Compact() { + for o.history.Len() > o.cfg.HistoryLimit && o.history.Len() > 0 { + o.history.Remove(o.history.Front()) + } +} + +func (o *opHistory) Rewrite() { + o.fdLock.Lock() + defer o.fdLock.Unlock() + o.rewriteLocked() +} + +func (o *opHistory) rewriteLocked() { + if o.cfg.HistoryFile == "" { + return + } + + tmpFile := o.cfg.HistoryFile + ".tmp" + fd, err := os.OpenFile(tmpFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC|os.O_APPEND, 0666) + if err != nil { + return + } + + buf := bufio.NewWriter(fd) + for elem := o.history.Front(); elem != nil; elem = elem.Next() { + buf.WriteString(string(elem.Value.(*hisItem).Source) + "\n") + } + buf.Flush() + + // replace history file + if err = os.Rename(tmpFile, o.cfg.HistoryFile); err != nil { + fd.Close() + return + } + + if o.fd != nil { + o.fd.Close() + } + // fd is write only, just satisfy what we need. + o.fd = fd +} + +func (o *opHistory) Close() { + o.fdLock.Lock() + defer o.fdLock.Unlock() + if o.fd != nil { + o.fd.Close() + } +} + +func (o *opHistory) FindBck(isNewSearch bool, rs []rune, start int) (int, *list.Element) { + for elem := o.current; elem != nil; elem = elem.Prev() { + item := o.showItem(elem.Value) + if isNewSearch { + start += len(rs) + } + if elem == o.current { + if len(item) >= start { + item = item[:start] + } + } + idx := runes.IndexAllBckEx(item, rs, o.cfg.HistorySearchFold) + if idx < 0 { + continue + } + return idx, elem + } + return -1, nil +} + +func (o *opHistory) FindFwd(isNewSearch bool, rs []rune, start int) (int, *list.Element) { + for elem := o.current; elem != nil; elem = elem.Next() { + item := o.showItem(elem.Value) + if isNewSearch { + start -= len(rs) + if start < 0 { + start = 0 + } + } + if elem == o.current { + if len(item)-1 >= start { + item = item[start:] + } else { + continue + } + } + idx := runes.IndexAllEx(item, rs, o.cfg.HistorySearchFold) + if idx < 0 { + continue + } + if elem == o.current { + idx += start + } + return idx, elem + } + return -1, nil +} + +func (o *opHistory) showItem(obj interface{}) []rune { + item := obj.(*hisItem) + if item.Version == o.historyVer { + return item.Tmp + } + return item.Source +} + +func (o *opHistory) Prev() []rune { + if o.current == nil { + return nil + } + current := o.current.Prev() + if current == nil { + return nil + } + o.current = current + return runes.Copy(o.showItem(current.Value)) +} + +func (o *opHistory) Next() ([]rune, bool) { + if o.current == nil { + return nil, false + } + current := o.current.Next() + if current == nil { + return nil, false + } + + o.current = current + return runes.Copy(o.showItem(current.Value)), true +} + +// Disable the current history +func (o *opHistory) Disable() { + o.enable = false +} + +// Enable the current history +func (o *opHistory) Enable() { + o.enable = true +} + +func (o *opHistory) debug() { + Debug("-------") + for item := o.history.Front(); item != nil; item = item.Next() { + Debug(fmt.Sprintf("%+v", item.Value)) + } +} + +// save history +func (o *opHistory) New(current []rune) (err error) { + + // history deactivated + if !o.enable { + return nil + } + + current = runes.Copy(current) + + // if just use last command without modify + // just clean lastest history + if back := o.history.Back(); back != nil { + prev := back.Prev() + if prev != nil { + if runes.Equal(current, prev.Value.(*hisItem).Source) { + o.current = o.history.Back() + o.current.Value.(*hisItem).Clean() + o.historyVer++ + return nil + } + } + } + + if len(current) == 0 { + o.current = o.history.Back() + if o.current != nil { + o.current.Value.(*hisItem).Clean() + o.historyVer++ + return nil + } + } + + if o.current != o.history.Back() { + // move history item to current command + currentItem := o.current.Value.(*hisItem) + // set current to last item + o.current = o.history.Back() + + current = runes.Copy(currentItem.Tmp) + } + + // err only can be a IO error, just report + err = o.Update(current, true) + + // push a new one to commit current command + o.historyVer++ + o.Push(nil) + return +} + +func (o *opHistory) Revert() { + o.historyVer++ + o.current = o.history.Back() +} + +func (o *opHistory) Update(s []rune, commit bool) (err error) { + o.fdLock.Lock() + defer o.fdLock.Unlock() + s = runes.Copy(s) + if o.current == nil { + o.Push(s) + o.Compact() + return + } + r := o.current.Value.(*hisItem) + r.Version = o.historyVer + if commit { + r.Source = s + if o.fd != nil { + // just report the error + _, err = o.fd.Write([]byte(string(r.Source) + "\n")) + } + } else { + r.Tmp = append(r.Tmp[:0], s...) + } + o.current.Value = r + o.Compact() + return +} + +func (o *opHistory) Push(s []rune) { + s = runes.Copy(s) + elem := o.history.PushBack(&hisItem{Source: s}) + o.current = elem +} diff --git a/vendor/github.com/chzyer/readline/operation.go b/vendor/github.com/chzyer/readline/operation.go new file mode 100644 index 000000000..b60939a91 --- /dev/null +++ b/vendor/github.com/chzyer/readline/operation.go @@ -0,0 +1,537 @@ +package readline + +import ( + "errors" + "io" + "sync" +) + +var ( + ErrInterrupt = errors.New("Interrupt") +) + +type InterruptError struct { + Line []rune +} + +func (*InterruptError) Error() string { + return "Interrupted" +} + +type Operation struct { + m sync.Mutex + cfg *Config + t *Terminal + buf *RuneBuffer + outchan chan []rune + errchan chan error + w io.Writer + + history *opHistory + *opSearch + *opCompleter + *opPassword + *opVim +} + +func (o *Operation) SetBuffer(what string) { + o.buf.Set([]rune(what)) +} + +type wrapWriter struct { + r *Operation + t *Terminal + target io.Writer +} + +func (w *wrapWriter) Write(b []byte) (int, error) { + if !w.t.IsReading() { + return w.target.Write(b) + } + + var ( + n int + err error + ) + w.r.buf.Refresh(func() { + n, err = w.target.Write(b) + }) + + if w.r.IsSearchMode() { + w.r.SearchRefresh(-1) + } + if w.r.IsInCompleteMode() { + w.r.CompleteRefresh() + } + return n, err +} + +func NewOperation(t *Terminal, cfg *Config) *Operation { + width := cfg.FuncGetWidth() + op := &Operation{ + t: t, + buf: NewRuneBuffer(t, cfg.Prompt, cfg, width), + outchan: make(chan []rune), + errchan: make(chan error, 1), + } + op.w = op.buf.w + op.SetConfig(cfg) + op.opVim = newVimMode(op) + op.opCompleter = newOpCompleter(op.buf.w, op, width) + op.opPassword = newOpPassword(op) + op.cfg.FuncOnWidthChanged(func() { + newWidth := cfg.FuncGetWidth() + op.opCompleter.OnWidthChange(newWidth) + op.opSearch.OnWidthChange(newWidth) + op.buf.OnWidthChange(newWidth) + }) + go op.ioloop() + return op +} + +func (o *Operation) SetPrompt(s string) { + o.buf.SetPrompt(s) +} + +func (o *Operation) SetMaskRune(r rune) { + o.buf.SetMask(r) +} + +func (o *Operation) GetConfig() *Config { + o.m.Lock() + cfg := *o.cfg + o.m.Unlock() + return &cfg +} + +func (o *Operation) ioloop() { + for { + keepInSearchMode := false + keepInCompleteMode := false + r := o.t.ReadRune() + + if o.GetConfig().FuncFilterInputRune != nil { + var process bool + r, process = o.GetConfig().FuncFilterInputRune(r) + if !process { + o.t.KickRead() + o.buf.Refresh(nil) // to refresh the line + continue // ignore this rune + } + } + + if r == 0 { // io.EOF + if o.buf.Len() == 0 { + o.buf.Clean() + select { + case o.errchan <- io.EOF: + } + break + } else { + // if stdin got io.EOF and there is something left in buffer, + // let's flush them by sending CharEnter. + // And we will got io.EOF int next loop. + r = CharEnter + } + } + isUpdateHistory := true + + if o.IsInCompleteSelectMode() { + keepInCompleteMode = o.HandleCompleteSelect(r) + if keepInCompleteMode { + continue + } + + o.buf.Refresh(nil) + switch r { + case CharEnter, CharCtrlJ: + o.history.Update(o.buf.Runes(), false) + fallthrough + case CharInterrupt: + o.t.KickRead() + fallthrough + case CharBell: + continue + } + } + + if o.IsEnableVimMode() { + r = o.HandleVim(r, o.t.ReadRune) + if r == 0 { + continue + } + } + + switch r { + case CharBell: + if o.IsSearchMode() { + o.ExitSearchMode(true) + o.buf.Refresh(nil) + } + if o.IsInCompleteMode() { + o.ExitCompleteMode(true) + o.buf.Refresh(nil) + } + case CharTab: + if o.GetConfig().AutoComplete == nil { + o.t.Bell() + break + } + if o.OnComplete() { + keepInCompleteMode = true + } else { + o.t.Bell() + break + } + + case CharBckSearch: + if !o.SearchMode(S_DIR_BCK) { + o.t.Bell() + break + } + keepInSearchMode = true + case CharCtrlU: + o.buf.KillFront() + case CharFwdSearch: + if !o.SearchMode(S_DIR_FWD) { + o.t.Bell() + break + } + keepInSearchMode = true + case CharKill: + o.buf.Kill() + keepInCompleteMode = true + case MetaForward: + o.buf.MoveToNextWord() + case CharTranspose: + o.buf.Transpose() + case MetaBackward: + o.buf.MoveToPrevWord() + case MetaDelete: + o.buf.DeleteWord() + case CharLineStart: + o.buf.MoveToLineStart() + case CharLineEnd: + o.buf.MoveToLineEnd() + case CharBackspace, CharCtrlH: + if o.IsSearchMode() { + o.SearchBackspace() + keepInSearchMode = true + break + } + + if o.buf.Len() == 0 { + o.t.Bell() + break + } + o.buf.Backspace() + if o.IsInCompleteMode() { + o.OnComplete() + } + case CharCtrlZ: + o.buf.Clean() + o.t.SleepToResume() + o.Refresh() + case CharCtrlL: + ClearScreen(o.w) + o.Refresh() + case MetaBackspace, CharCtrlW: + o.buf.BackEscapeWord() + case CharCtrlY: + o.buf.Yank() + case CharEnter, CharCtrlJ: + if o.IsSearchMode() { + o.ExitSearchMode(false) + } + o.buf.MoveToLineEnd() + var data []rune + if !o.GetConfig().UniqueEditLine { + o.buf.WriteRune('\n') + data = o.buf.Reset() + data = data[:len(data)-1] // trim \n + } else { + o.buf.Clean() + data = o.buf.Reset() + } + o.outchan <- data + if !o.GetConfig().DisableAutoSaveHistory { + // ignore IO error + _ = o.history.New(data) + } else { + isUpdateHistory = false + } + case CharBackward: + o.buf.MoveBackward() + case CharForward: + o.buf.MoveForward() + case CharPrev: + buf := o.history.Prev() + if buf != nil { + o.buf.Set(buf) + } else { + o.t.Bell() + } + case CharNext: + buf, ok := o.history.Next() + if ok { + o.buf.Set(buf) + } else { + o.t.Bell() + } + case CharDelete: + if o.buf.Len() > 0 || !o.IsNormalMode() { + o.t.KickRead() + if !o.buf.Delete() { + o.t.Bell() + } + break + } + + // treat as EOF + if !o.GetConfig().UniqueEditLine { + o.buf.WriteString(o.GetConfig().EOFPrompt + "\n") + } + o.buf.Reset() + isUpdateHistory = false + o.history.Revert() + o.errchan <- io.EOF + if o.GetConfig().UniqueEditLine { + o.buf.Clean() + } + case CharInterrupt: + if o.IsSearchMode() { + o.t.KickRead() + o.ExitSearchMode(true) + break + } + if o.IsInCompleteMode() { + o.t.KickRead() + o.ExitCompleteMode(true) + o.buf.Refresh(nil) + break + } + o.buf.MoveToLineEnd() + o.buf.Refresh(nil) + hint := o.GetConfig().InterruptPrompt + "\n" + if !o.GetConfig().UniqueEditLine { + o.buf.WriteString(hint) + } + remain := o.buf.Reset() + if !o.GetConfig().UniqueEditLine { + remain = remain[:len(remain)-len([]rune(hint))] + } + isUpdateHistory = false + o.history.Revert() + o.errchan <- &InterruptError{remain} + default: + if o.IsSearchMode() { + o.SearchChar(r) + keepInSearchMode = true + break + } + o.buf.WriteRune(r) + if o.IsInCompleteMode() { + o.OnComplete() + keepInCompleteMode = true + } + } + + listener := o.GetConfig().Listener + if listener != nil { + newLine, newPos, ok := listener.OnChange(o.buf.Runes(), o.buf.Pos(), r) + if ok { + o.buf.SetWithIdx(newPos, newLine) + } + } + + o.m.Lock() + if !keepInSearchMode && o.IsSearchMode() { + o.ExitSearchMode(false) + o.buf.Refresh(nil) + } else if o.IsInCompleteMode() { + if !keepInCompleteMode { + o.ExitCompleteMode(false) + o.Refresh() + } else { + o.buf.Refresh(nil) + o.CompleteRefresh() + } + } + if isUpdateHistory && !o.IsSearchMode() { + // it will cause null history + o.history.Update(o.buf.Runes(), false) + } + o.m.Unlock() + } +} + +func (o *Operation) Stderr() io.Writer { + return &wrapWriter{target: o.GetConfig().Stderr, r: o, t: o.t} +} + +func (o *Operation) Stdout() io.Writer { + return &wrapWriter{target: o.GetConfig().Stdout, r: o, t: o.t} +} + +func (o *Operation) String() (string, error) { + r, err := o.Runes() + return string(r), err +} + +func (o *Operation) Runes() ([]rune, error) { + o.t.EnterRawMode() + defer o.t.ExitRawMode() + + listener := o.GetConfig().Listener + if listener != nil { + listener.OnChange(nil, 0, 0) + } + + o.buf.Refresh(nil) // print prompt + o.t.KickRead() + select { + case r := <-o.outchan: + return r, nil + case err := <-o.errchan: + if e, ok := err.(*InterruptError); ok { + return e.Line, ErrInterrupt + } + return nil, err + } +} + +func (o *Operation) PasswordEx(prompt string, l Listener) ([]byte, error) { + cfg := o.GenPasswordConfig() + cfg.Prompt = prompt + cfg.Listener = l + return o.PasswordWithConfig(cfg) +} + +func (o *Operation) GenPasswordConfig() *Config { + return o.opPassword.PasswordConfig() +} + +func (o *Operation) PasswordWithConfig(cfg *Config) ([]byte, error) { + if err := o.opPassword.EnterPasswordMode(cfg); err != nil { + return nil, err + } + defer o.opPassword.ExitPasswordMode() + return o.Slice() +} + +func (o *Operation) Password(prompt string) ([]byte, error) { + return o.PasswordEx(prompt, nil) +} + +func (o *Operation) SetTitle(t string) { + o.w.Write([]byte("\033[2;" + t + "\007")) +} + +func (o *Operation) Slice() ([]byte, error) { + r, err := o.Runes() + if err != nil { + return nil, err + } + return []byte(string(r)), nil +} + +func (o *Operation) Close() { + select { + case o.errchan <- io.EOF: + default: + } + o.history.Close() +} + +func (o *Operation) SetHistoryPath(path string) { + if o.history != nil { + o.history.Close() + } + o.cfg.HistoryFile = path + o.history = newOpHistory(o.cfg) +} + +func (o *Operation) IsNormalMode() bool { + return !o.IsInCompleteMode() && !o.IsSearchMode() +} + +func (op *Operation) SetConfig(cfg *Config) (*Config, error) { + op.m.Lock() + defer op.m.Unlock() + if op.cfg == cfg { + return op.cfg, nil + } + if err := cfg.Init(); err != nil { + return op.cfg, err + } + old := op.cfg + op.cfg = cfg + op.SetPrompt(cfg.Prompt) + op.SetMaskRune(cfg.MaskRune) + op.buf.SetConfig(cfg) + width := op.cfg.FuncGetWidth() + + if cfg.opHistory == nil { + op.SetHistoryPath(cfg.HistoryFile) + cfg.opHistory = op.history + cfg.opSearch = newOpSearch(op.buf.w, op.buf, op.history, cfg, width) + } + op.history = cfg.opHistory + + // SetHistoryPath will close opHistory which already exists + // so if we use it next time, we need to reopen it by `InitHistory()` + op.history.Init() + + if op.cfg.AutoComplete != nil { + op.opCompleter = newOpCompleter(op.buf.w, op, width) + } + + op.opSearch = cfg.opSearch + return old, nil +} + +func (o *Operation) ResetHistory() { + o.history.Reset() +} + +// if err is not nil, it just mean it fail to write to file +// other things goes fine. +func (o *Operation) SaveHistory(content string) error { + return o.history.New([]rune(content)) +} + +func (o *Operation) Refresh() { + if o.t.IsReading() { + o.buf.Refresh(nil) + } +} + +func (o *Operation) Clean() { + o.buf.Clean() +} + +func FuncListener(f func(line []rune, pos int, key rune) (newLine []rune, newPos int, ok bool)) Listener { + return &DumpListener{f: f} +} + +type DumpListener struct { + f func(line []rune, pos int, key rune) (newLine []rune, newPos int, ok bool) +} + +func (d *DumpListener) OnChange(line []rune, pos int, key rune) (newLine []rune, newPos int, ok bool) { + return d.f(line, pos, key) +} + +type Listener interface { + OnChange(line []rune, pos int, key rune) (newLine []rune, newPos int, ok bool) +} + +type Painter interface { + Paint(line []rune, pos int) []rune +} + +type defaultPainter struct{} + +func (p *defaultPainter) Paint(line []rune, _ int) []rune { + return line +} diff --git a/vendor/github.com/chzyer/readline/password.go b/vendor/github.com/chzyer/readline/password.go new file mode 100644 index 000000000..414288c2a --- /dev/null +++ b/vendor/github.com/chzyer/readline/password.go @@ -0,0 +1,33 @@ +package readline + +type opPassword struct { + o *Operation + backupCfg *Config +} + +func newOpPassword(o *Operation) *opPassword { + return &opPassword{o: o} +} + +func (o *opPassword) ExitPasswordMode() { + o.o.SetConfig(o.backupCfg) + o.backupCfg = nil +} + +func (o *opPassword) EnterPasswordMode(cfg *Config) (err error) { + o.backupCfg, err = o.o.SetConfig(cfg) + return +} + +func (o *opPassword) PasswordConfig() *Config { + return &Config{ + EnableMask: true, + InterruptPrompt: "\n", + EOFPrompt: "\n", + HistoryLimit: -1, + Painter: &defaultPainter{}, + + Stdout: o.o.cfg.Stdout, + Stderr: o.o.cfg.Stderr, + } +} diff --git a/vendor/github.com/chzyer/readline/rawreader_windows.go b/vendor/github.com/chzyer/readline/rawreader_windows.go new file mode 100644 index 000000000..073ef150a --- /dev/null +++ b/vendor/github.com/chzyer/readline/rawreader_windows.go @@ -0,0 +1,125 @@ +// +build windows + +package readline + +import "unsafe" + +const ( + VK_CANCEL = 0x03 + VK_BACK = 0x08 + VK_TAB = 0x09 + VK_RETURN = 0x0D + VK_SHIFT = 0x10 + VK_CONTROL = 0x11 + VK_MENU = 0x12 + VK_ESCAPE = 0x1B + VK_LEFT = 0x25 + VK_UP = 0x26 + VK_RIGHT = 0x27 + VK_DOWN = 0x28 + VK_DELETE = 0x2E + VK_LSHIFT = 0xA0 + VK_RSHIFT = 0xA1 + VK_LCONTROL = 0xA2 + VK_RCONTROL = 0xA3 +) + +// RawReader translate input record to ANSI escape sequence. +// To provides same behavior as unix terminal. +type RawReader struct { + ctrlKey bool + altKey bool +} + +func NewRawReader() *RawReader { + r := new(RawReader) + return r +} + +// only process one action in one read +func (r *RawReader) Read(buf []byte) (int, error) { + ir := new(_INPUT_RECORD) + var read int + var err error +next: + err = kernel.ReadConsoleInputW(stdin, + uintptr(unsafe.Pointer(ir)), + 1, + uintptr(unsafe.Pointer(&read)), + ) + if err != nil { + return 0, err + } + if ir.EventType != EVENT_KEY { + goto next + } + ker := (*_KEY_EVENT_RECORD)(unsafe.Pointer(&ir.Event[0])) + if ker.bKeyDown == 0 { // keyup + if r.ctrlKey || r.altKey { + switch ker.wVirtualKeyCode { + case VK_RCONTROL, VK_LCONTROL: + r.ctrlKey = false + case VK_MENU: //alt + r.altKey = false + } + } + goto next + } + + if ker.unicodeChar == 0 { + var target rune + switch ker.wVirtualKeyCode { + case VK_RCONTROL, VK_LCONTROL: + r.ctrlKey = true + case VK_MENU: //alt + r.altKey = true + case VK_LEFT: + target = CharBackward + case VK_RIGHT: + target = CharForward + case VK_UP: + target = CharPrev + case VK_DOWN: + target = CharNext + } + if target != 0 { + return r.write(buf, target) + } + goto next + } + char := rune(ker.unicodeChar) + if r.ctrlKey { + switch char { + case 'A': + char = CharLineStart + case 'E': + char = CharLineEnd + case 'R': + char = CharBckSearch + case 'S': + char = CharFwdSearch + } + } else if r.altKey { + switch char { + case VK_BACK: + char = CharBackspace + } + return r.writeEsc(buf, char) + } + return r.write(buf, char) +} + +func (r *RawReader) writeEsc(b []byte, char rune) (int, error) { + b[0] = '\033' + n := copy(b[1:], []byte(string(char))) + return n + 1, nil +} + +func (r *RawReader) write(b []byte, char rune) (int, error) { + n := copy(b, []byte(string(char))) + return n, nil +} + +func (r *RawReader) Close() error { + return nil +} diff --git a/vendor/github.com/chzyer/readline/readline.go b/vendor/github.com/chzyer/readline/readline.go new file mode 100644 index 000000000..63b917101 --- /dev/null +++ b/vendor/github.com/chzyer/readline/readline.go @@ -0,0 +1,338 @@ +// Readline is a pure go implementation for GNU-Readline kind library. +// +// example: +// rl, err := readline.New("> ") +// if err != nil { +// panic(err) +// } +// defer rl.Close() +// +// for { +// line, err := rl.Readline() +// if err != nil { // io.EOF +// break +// } +// println(line) +// } +// +package readline + +import ( + "io" +) + +type Instance struct { + Config *Config + Terminal *Terminal + Operation *Operation +} + +type Config struct { + // prompt supports ANSI escape sequence, so we can color some characters even in windows + Prompt string + + // readline will persist historys to file where HistoryFile specified + HistoryFile string + // specify the max length of historys, it's 500 by default, set it to -1 to disable history + HistoryLimit int + DisableAutoSaveHistory bool + // enable case-insensitive history searching + HistorySearchFold bool + + // AutoCompleter will called once user press TAB + AutoComplete AutoCompleter + + // Any key press will pass to Listener + // NOTE: Listener will be triggered by (nil, 0, 0) immediately + Listener Listener + + Painter Painter + + // If VimMode is true, readline will in vim.insert mode by default + VimMode bool + + InterruptPrompt string + EOFPrompt string + + FuncGetWidth func() int + + Stdin io.ReadCloser + StdinWriter io.Writer + Stdout io.Writer + Stderr io.Writer + + EnableMask bool + MaskRune rune + + // erase the editing line after user submited it + // it use in IM usually. + UniqueEditLine bool + + // filter input runes (may be used to disable CtrlZ or for translating some keys to different actions) + // -> output = new (translated) rune and true/false if continue with processing this one + FuncFilterInputRune func(rune) (rune, bool) + + // force use interactive even stdout is not a tty + FuncIsTerminal func() bool + FuncMakeRaw func() error + FuncExitRaw func() error + FuncOnWidthChanged func(func()) + ForceUseInteractive bool + + // private fields + inited bool + opHistory *opHistory + opSearch *opSearch +} + +func (c *Config) useInteractive() bool { + if c.ForceUseInteractive { + return true + } + return c.FuncIsTerminal() +} + +func (c *Config) Init() error { + if c.inited { + return nil + } + c.inited = true + if c.Stdin == nil { + c.Stdin = NewCancelableStdin(Stdin) + } + + c.Stdin, c.StdinWriter = NewFillableStdin(c.Stdin) + + if c.Stdout == nil { + c.Stdout = Stdout + } + if c.Stderr == nil { + c.Stderr = Stderr + } + if c.HistoryLimit == 0 { + c.HistoryLimit = 500 + } + + if c.InterruptPrompt == "" { + c.InterruptPrompt = "^C" + } else if c.InterruptPrompt == "\n" { + c.InterruptPrompt = "" + } + if c.EOFPrompt == "" { + c.EOFPrompt = "^D" + } else if c.EOFPrompt == "\n" { + c.EOFPrompt = "" + } + + if c.AutoComplete == nil { + c.AutoComplete = &TabCompleter{} + } + if c.FuncGetWidth == nil { + c.FuncGetWidth = GetScreenWidth + } + if c.FuncIsTerminal == nil { + c.FuncIsTerminal = DefaultIsTerminal + } + rm := new(RawMode) + if c.FuncMakeRaw == nil { + c.FuncMakeRaw = rm.Enter + } + if c.FuncExitRaw == nil { + c.FuncExitRaw = rm.Exit + } + if c.FuncOnWidthChanged == nil { + c.FuncOnWidthChanged = DefaultOnWidthChanged + } + + return nil +} + +func (c Config) Clone() *Config { + c.opHistory = nil + c.opSearch = nil + return &c +} + +func (c *Config) SetListener(f func(line []rune, pos int, key rune) (newLine []rune, newPos int, ok bool)) { + c.Listener = FuncListener(f) +} + +func (c *Config) SetPainter(p Painter) { + c.Painter = p +} + +func NewEx(cfg *Config) (*Instance, error) { + t, err := NewTerminal(cfg) + if err != nil { + return nil, err + } + rl := t.Readline() + if cfg.Painter == nil { + cfg.Painter = &defaultPainter{} + } + return &Instance{ + Config: cfg, + Terminal: t, + Operation: rl, + }, nil +} + +func New(prompt string) (*Instance, error) { + return NewEx(&Config{Prompt: prompt}) +} + +func (i *Instance) ResetHistory() { + i.Operation.ResetHistory() +} + +func (i *Instance) SetPrompt(s string) { + i.Operation.SetPrompt(s) +} + +func (i *Instance) SetMaskRune(r rune) { + i.Operation.SetMaskRune(r) +} + +// change history persistence in runtime +func (i *Instance) SetHistoryPath(p string) { + i.Operation.SetHistoryPath(p) +} + +// readline will refresh automatic when write through Stdout() +func (i *Instance) Stdout() io.Writer { + return i.Operation.Stdout() +} + +// readline will refresh automatic when write through Stdout() +func (i *Instance) Stderr() io.Writer { + return i.Operation.Stderr() +} + +// switch VimMode in runtime +func (i *Instance) SetVimMode(on bool) { + i.Operation.SetVimMode(on) +} + +func (i *Instance) IsVimMode() bool { + return i.Operation.IsEnableVimMode() +} + +func (i *Instance) GenPasswordConfig() *Config { + return i.Operation.GenPasswordConfig() +} + +// we can generate a config by `i.GenPasswordConfig()` +func (i *Instance) ReadPasswordWithConfig(cfg *Config) ([]byte, error) { + return i.Operation.PasswordWithConfig(cfg) +} + +func (i *Instance) ReadPasswordEx(prompt string, l Listener) ([]byte, error) { + return i.Operation.PasswordEx(prompt, l) +} + +func (i *Instance) ReadPassword(prompt string) ([]byte, error) { + return i.Operation.Password(prompt) +} + +type Result struct { + Line string + Error error +} + +func (l *Result) CanContinue() bool { + return len(l.Line) != 0 && l.Error == ErrInterrupt +} + +func (l *Result) CanBreak() bool { + return !l.CanContinue() && l.Error != nil +} + +func (i *Instance) Line() *Result { + ret, err := i.Readline() + return &Result{ret, err} +} + +// err is one of (nil, io.EOF, readline.ErrInterrupt) +func (i *Instance) Readline() (string, error) { + return i.Operation.String() +} + +func (i *Instance) ReadlineWithDefault(what string) (string, error) { + i.Operation.SetBuffer(what) + return i.Operation.String() +} + +func (i *Instance) SaveHistory(content string) error { + return i.Operation.SaveHistory(content) +} + +// same as readline +func (i *Instance) ReadSlice() ([]byte, error) { + return i.Operation.Slice() +} + +// we must make sure that call Close() before process exit. +// if there has a pending reading operation, that reading will be interrupted. +// so you can capture the signal and call Instance.Close(), it's thread-safe. +func (i *Instance) Close() error { + i.Config.Stdin.Close() + i.Operation.Close() + if err := i.Terminal.Close(); err != nil { + return err + } + return nil +} + +// call CaptureExitSignal when you want readline exit gracefully. +func (i *Instance) CaptureExitSignal() { + CaptureExitSignal(func() { + i.Close() + }) +} + +func (i *Instance) Clean() { + i.Operation.Clean() +} + +func (i *Instance) Write(b []byte) (int, error) { + return i.Stdout().Write(b) +} + +// WriteStdin prefill the next Stdin fetch +// Next time you call ReadLine() this value will be writen before the user input +// ie : +// i := readline.New() +// i.WriteStdin([]byte("test")) +// _, _= i.Readline() +// +// gives +// +// > test[cursor] +func (i *Instance) WriteStdin(val []byte) (int, error) { + return i.Terminal.WriteStdin(val) +} + +func (i *Instance) SetConfig(cfg *Config) *Config { + if i.Config == cfg { + return cfg + } + old := i.Config + i.Config = cfg + i.Operation.SetConfig(cfg) + i.Terminal.SetConfig(cfg) + return old +} + +func (i *Instance) Refresh() { + i.Operation.Refresh() +} + +// HistoryDisable the save of the commands into the history +func (i *Instance) HistoryDisable() { + i.Operation.history.Disable() +} + +// HistoryEnable the save of the commands into the history (default on) +func (i *Instance) HistoryEnable() { + i.Operation.history.Enable() +} diff --git a/vendor/github.com/chzyer/readline/remote.go b/vendor/github.com/chzyer/readline/remote.go new file mode 100644 index 000000000..74dbf5690 --- /dev/null +++ b/vendor/github.com/chzyer/readline/remote.go @@ -0,0 +1,475 @@ +package readline + +import ( + "bufio" + "bytes" + "encoding/binary" + "fmt" + "io" + "net" + "os" + "sync" + "sync/atomic" +) + +type MsgType int16 + +const ( + T_DATA = MsgType(iota) + T_WIDTH + T_WIDTH_REPORT + T_ISTTY_REPORT + T_RAW + T_ERAW // exit raw + T_EOF +) + +type RemoteSvr struct { + eof int32 + closed int32 + width int32 + reciveChan chan struct{} + writeChan chan *writeCtx + conn net.Conn + isTerminal bool + funcWidthChan func() + stopChan chan struct{} + + dataBufM sync.Mutex + dataBuf bytes.Buffer +} + +type writeReply struct { + n int + err error +} + +type writeCtx struct { + msg *Message + reply chan *writeReply +} + +func newWriteCtx(msg *Message) *writeCtx { + return &writeCtx{ + msg: msg, + reply: make(chan *writeReply), + } +} + +func NewRemoteSvr(conn net.Conn) (*RemoteSvr, error) { + rs := &RemoteSvr{ + width: -1, + conn: conn, + writeChan: make(chan *writeCtx), + reciveChan: make(chan struct{}), + stopChan: make(chan struct{}), + } + buf := bufio.NewReader(rs.conn) + + if err := rs.init(buf); err != nil { + return nil, err + } + + go rs.readLoop(buf) + go rs.writeLoop() + return rs, nil +} + +func (r *RemoteSvr) init(buf *bufio.Reader) error { + m, err := ReadMessage(buf) + if err != nil { + return err + } + // receive isTerminal + if m.Type != T_ISTTY_REPORT { + return fmt.Errorf("unexpected init message") + } + r.GotIsTerminal(m.Data) + + // receive width + m, err = ReadMessage(buf) + if err != nil { + return err + } + if m.Type != T_WIDTH_REPORT { + return fmt.Errorf("unexpected init message") + } + r.GotReportWidth(m.Data) + + return nil +} + +func (r *RemoteSvr) HandleConfig(cfg *Config) { + cfg.Stderr = r + cfg.Stdout = r + cfg.Stdin = r + cfg.FuncExitRaw = r.ExitRawMode + cfg.FuncIsTerminal = r.IsTerminal + cfg.FuncMakeRaw = r.EnterRawMode + cfg.FuncExitRaw = r.ExitRawMode + cfg.FuncGetWidth = r.GetWidth + cfg.FuncOnWidthChanged = func(f func()) { + r.funcWidthChan = f + } +} + +func (r *RemoteSvr) IsTerminal() bool { + return r.isTerminal +} + +func (r *RemoteSvr) checkEOF() error { + if atomic.LoadInt32(&r.eof) == 1 { + return io.EOF + } + return nil +} + +func (r *RemoteSvr) Read(b []byte) (int, error) { + r.dataBufM.Lock() + n, err := r.dataBuf.Read(b) + r.dataBufM.Unlock() + if n == 0 { + if err := r.checkEOF(); err != nil { + return 0, err + } + } + + if n == 0 && err == io.EOF { + <-r.reciveChan + r.dataBufM.Lock() + n, err = r.dataBuf.Read(b) + r.dataBufM.Unlock() + } + if n == 0 { + if err := r.checkEOF(); err != nil { + return 0, err + } + } + + return n, err +} + +func (r *RemoteSvr) writeMsg(m *Message) error { + ctx := newWriteCtx(m) + r.writeChan <- ctx + reply := <-ctx.reply + return reply.err +} + +func (r *RemoteSvr) Write(b []byte) (int, error) { + ctx := newWriteCtx(NewMessage(T_DATA, b)) + r.writeChan <- ctx + reply := <-ctx.reply + return reply.n, reply.err +} + +func (r *RemoteSvr) EnterRawMode() error { + return r.writeMsg(NewMessage(T_RAW, nil)) +} + +func (r *RemoteSvr) ExitRawMode() error { + return r.writeMsg(NewMessage(T_ERAW, nil)) +} + +func (r *RemoteSvr) writeLoop() { + defer r.Close() + +loop: + for { + select { + case ctx, ok := <-r.writeChan: + if !ok { + break + } + n, err := ctx.msg.WriteTo(r.conn) + ctx.reply <- &writeReply{n, err} + case <-r.stopChan: + break loop + } + } +} + +func (r *RemoteSvr) Close() error { + if atomic.CompareAndSwapInt32(&r.closed, 0, 1) { + close(r.stopChan) + r.conn.Close() + } + return nil +} + +func (r *RemoteSvr) readLoop(buf *bufio.Reader) { + defer r.Close() + for { + m, err := ReadMessage(buf) + if err != nil { + break + } + switch m.Type { + case T_EOF: + atomic.StoreInt32(&r.eof, 1) + select { + case r.reciveChan <- struct{}{}: + default: + } + case T_DATA: + r.dataBufM.Lock() + r.dataBuf.Write(m.Data) + r.dataBufM.Unlock() + select { + case r.reciveChan <- struct{}{}: + default: + } + case T_WIDTH_REPORT: + r.GotReportWidth(m.Data) + case T_ISTTY_REPORT: + r.GotIsTerminal(m.Data) + } + } +} + +func (r *RemoteSvr) GotIsTerminal(data []byte) { + if binary.BigEndian.Uint16(data) == 0 { + r.isTerminal = false + } else { + r.isTerminal = true + } +} + +func (r *RemoteSvr) GotReportWidth(data []byte) { + atomic.StoreInt32(&r.width, int32(binary.BigEndian.Uint16(data))) + if r.funcWidthChan != nil { + r.funcWidthChan() + } +} + +func (r *RemoteSvr) GetWidth() int { + return int(atomic.LoadInt32(&r.width)) +} + +// ----------------------------------------------------------------------------- + +type Message struct { + Type MsgType + Data []byte +} + +func ReadMessage(r io.Reader) (*Message, error) { + m := new(Message) + var length int32 + if err := binary.Read(r, binary.BigEndian, &length); err != nil { + return nil, err + } + if err := binary.Read(r, binary.BigEndian, &m.Type); err != nil { + return nil, err + } + m.Data = make([]byte, int(length)-2) + if _, err := io.ReadFull(r, m.Data); err != nil { + return nil, err + } + return m, nil +} + +func NewMessage(t MsgType, data []byte) *Message { + return &Message{t, data} +} + +func (m *Message) WriteTo(w io.Writer) (int, error) { + buf := bytes.NewBuffer(make([]byte, 0, len(m.Data)+2+4)) + binary.Write(buf, binary.BigEndian, int32(len(m.Data)+2)) + binary.Write(buf, binary.BigEndian, m.Type) + buf.Write(m.Data) + n, err := buf.WriteTo(w) + return int(n), err +} + +// ----------------------------------------------------------------------------- + +type RemoteCli struct { + conn net.Conn + raw RawMode + receiveChan chan struct{} + inited int32 + isTerminal *bool + + data bytes.Buffer + dataM sync.Mutex +} + +func NewRemoteCli(conn net.Conn) (*RemoteCli, error) { + r := &RemoteCli{ + conn: conn, + receiveChan: make(chan struct{}), + } + return r, nil +} + +func (r *RemoteCli) MarkIsTerminal(is bool) { + r.isTerminal = &is +} + +func (r *RemoteCli) init() error { + if !atomic.CompareAndSwapInt32(&r.inited, 0, 1) { + return nil + } + + if err := r.reportIsTerminal(); err != nil { + return err + } + + if err := r.reportWidth(); err != nil { + return err + } + + // register sig for width changed + DefaultOnWidthChanged(func() { + r.reportWidth() + }) + return nil +} + +func (r *RemoteCli) writeMsg(m *Message) error { + r.dataM.Lock() + _, err := m.WriteTo(r.conn) + r.dataM.Unlock() + return err +} + +func (r *RemoteCli) Write(b []byte) (int, error) { + m := NewMessage(T_DATA, b) + r.dataM.Lock() + _, err := m.WriteTo(r.conn) + r.dataM.Unlock() + return len(b), err +} + +func (r *RemoteCli) reportWidth() error { + screenWidth := GetScreenWidth() + data := make([]byte, 2) + binary.BigEndian.PutUint16(data, uint16(screenWidth)) + msg := NewMessage(T_WIDTH_REPORT, data) + + if err := r.writeMsg(msg); err != nil { + return err + } + return nil +} + +func (r *RemoteCli) reportIsTerminal() error { + var isTerminal bool + if r.isTerminal != nil { + isTerminal = *r.isTerminal + } else { + isTerminal = DefaultIsTerminal() + } + data := make([]byte, 2) + if isTerminal { + binary.BigEndian.PutUint16(data, 1) + } else { + binary.BigEndian.PutUint16(data, 0) + } + msg := NewMessage(T_ISTTY_REPORT, data) + if err := r.writeMsg(msg); err != nil { + return err + } + return nil +} + +func (r *RemoteCli) readLoop() { + buf := bufio.NewReader(r.conn) + for { + msg, err := ReadMessage(buf) + if err != nil { + break + } + switch msg.Type { + case T_ERAW: + r.raw.Exit() + case T_RAW: + r.raw.Enter() + case T_DATA: + os.Stdout.Write(msg.Data) + } + } +} + +func (r *RemoteCli) ServeBy(source io.Reader) error { + if err := r.init(); err != nil { + return err + } + + go func() { + defer r.Close() + for { + n, _ := io.Copy(r, source) + if n == 0 { + break + } + } + }() + defer r.raw.Exit() + r.readLoop() + return nil +} + +func (r *RemoteCli) Close() { + r.writeMsg(NewMessage(T_EOF, nil)) +} + +func (r *RemoteCli) Serve() error { + return r.ServeBy(os.Stdin) +} + +func ListenRemote(n, addr string, cfg *Config, h func(*Instance), onListen ...func(net.Listener) error) error { + ln, err := net.Listen(n, addr) + if err != nil { + return err + } + if len(onListen) > 0 { + if err := onListen[0](ln); err != nil { + return err + } + } + for { + conn, err := ln.Accept() + if err != nil { + break + } + go func() { + defer conn.Close() + rl, err := HandleConn(*cfg, conn) + if err != nil { + return + } + h(rl) + }() + } + return nil +} + +func HandleConn(cfg Config, conn net.Conn) (*Instance, error) { + r, err := NewRemoteSvr(conn) + if err != nil { + return nil, err + } + r.HandleConfig(&cfg) + + rl, err := NewEx(&cfg) + if err != nil { + return nil, err + } + return rl, nil +} + +func DialRemote(n, addr string) error { + conn, err := net.Dial(n, addr) + if err != nil { + return err + } + defer conn.Close() + + cli, err := NewRemoteCli(conn) + if err != nil { + return err + } + return cli.Serve() +} diff --git a/vendor/github.com/chzyer/readline/runebuf.go b/vendor/github.com/chzyer/readline/runebuf.go new file mode 100644 index 000000000..d95df1e36 --- /dev/null +++ b/vendor/github.com/chzyer/readline/runebuf.go @@ -0,0 +1,629 @@ +package readline + +import ( + "bufio" + "bytes" + "io" + "strconv" + "strings" + "sync" +) + +type runeBufferBck struct { + buf []rune + idx int +} + +type RuneBuffer struct { + buf []rune + idx int + prompt []rune + w io.Writer + + hadClean bool + interactive bool + cfg *Config + + width int + + bck *runeBufferBck + + offset string + + lastKill []rune + + sync.Mutex +} + +func (r *RuneBuffer) pushKill(text []rune) { + r.lastKill = append([]rune{}, text...) +} + +func (r *RuneBuffer) OnWidthChange(newWidth int) { + r.Lock() + r.width = newWidth + r.Unlock() +} + +func (r *RuneBuffer) Backup() { + r.Lock() + r.bck = &runeBufferBck{r.buf, r.idx} + r.Unlock() +} + +func (r *RuneBuffer) Restore() { + r.Refresh(func() { + if r.bck == nil { + return + } + r.buf = r.bck.buf + r.idx = r.bck.idx + }) +} + +func NewRuneBuffer(w io.Writer, prompt string, cfg *Config, width int) *RuneBuffer { + rb := &RuneBuffer{ + w: w, + interactive: cfg.useInteractive(), + cfg: cfg, + width: width, + } + rb.SetPrompt(prompt) + return rb +} + +func (r *RuneBuffer) SetConfig(cfg *Config) { + r.Lock() + r.cfg = cfg + r.interactive = cfg.useInteractive() + r.Unlock() +} + +func (r *RuneBuffer) SetMask(m rune) { + r.Lock() + r.cfg.MaskRune = m + r.Unlock() +} + +func (r *RuneBuffer) CurrentWidth(x int) int { + r.Lock() + defer r.Unlock() + return runes.WidthAll(r.buf[:x]) +} + +func (r *RuneBuffer) PromptLen() int { + r.Lock() + width := r.promptLen() + r.Unlock() + return width +} + +func (r *RuneBuffer) promptLen() int { + return runes.WidthAll(runes.ColorFilter(r.prompt)) +} + +func (r *RuneBuffer) RuneSlice(i int) []rune { + r.Lock() + defer r.Unlock() + + if i > 0 { + rs := make([]rune, i) + copy(rs, r.buf[r.idx:r.idx+i]) + return rs + } + rs := make([]rune, -i) + copy(rs, r.buf[r.idx+i:r.idx]) + return rs +} + +func (r *RuneBuffer) Runes() []rune { + r.Lock() + newr := make([]rune, len(r.buf)) + copy(newr, r.buf) + r.Unlock() + return newr +} + +func (r *RuneBuffer) Pos() int { + r.Lock() + defer r.Unlock() + return r.idx +} + +func (r *RuneBuffer) Len() int { + r.Lock() + defer r.Unlock() + return len(r.buf) +} + +func (r *RuneBuffer) MoveToLineStart() { + r.Refresh(func() { + if r.idx == 0 { + return + } + r.idx = 0 + }) +} + +func (r *RuneBuffer) MoveBackward() { + r.Refresh(func() { + if r.idx == 0 { + return + } + r.idx-- + }) +} + +func (r *RuneBuffer) WriteString(s string) { + r.WriteRunes([]rune(s)) +} + +func (r *RuneBuffer) WriteRune(s rune) { + r.WriteRunes([]rune{s}) +} + +func (r *RuneBuffer) WriteRunes(s []rune) { + r.Refresh(func() { + tail := append(s, r.buf[r.idx:]...) + r.buf = append(r.buf[:r.idx], tail...) + r.idx += len(s) + }) +} + +func (r *RuneBuffer) MoveForward() { + r.Refresh(func() { + if r.idx == len(r.buf) { + return + } + r.idx++ + }) +} + +func (r *RuneBuffer) IsCursorInEnd() bool { + r.Lock() + defer r.Unlock() + return r.idx == len(r.buf) +} + +func (r *RuneBuffer) Replace(ch rune) { + r.Refresh(func() { + r.buf[r.idx] = ch + }) +} + +func (r *RuneBuffer) Erase() { + r.Refresh(func() { + r.idx = 0 + r.pushKill(r.buf[:]) + r.buf = r.buf[:0] + }) +} + +func (r *RuneBuffer) Delete() (success bool) { + r.Refresh(func() { + if r.idx == len(r.buf) { + return + } + r.pushKill(r.buf[r.idx : r.idx+1]) + r.buf = append(r.buf[:r.idx], r.buf[r.idx+1:]...) + success = true + }) + return +} + +func (r *RuneBuffer) DeleteWord() { + if r.idx == len(r.buf) { + return + } + init := r.idx + for init < len(r.buf) && IsWordBreak(r.buf[init]) { + init++ + } + for i := init + 1; i < len(r.buf); i++ { + if !IsWordBreak(r.buf[i]) && IsWordBreak(r.buf[i-1]) { + r.pushKill(r.buf[r.idx : i-1]) + r.Refresh(func() { + r.buf = append(r.buf[:r.idx], r.buf[i-1:]...) + }) + return + } + } + r.Kill() +} + +func (r *RuneBuffer) MoveToPrevWord() (success bool) { + r.Refresh(func() { + if r.idx == 0 { + return + } + + for i := r.idx - 1; i > 0; i-- { + if !IsWordBreak(r.buf[i]) && IsWordBreak(r.buf[i-1]) { + r.idx = i + success = true + return + } + } + r.idx = 0 + success = true + }) + return +} + +func (r *RuneBuffer) KillFront() { + r.Refresh(func() { + if r.idx == 0 { + return + } + + length := len(r.buf) - r.idx + r.pushKill(r.buf[:r.idx]) + copy(r.buf[:length], r.buf[r.idx:]) + r.idx = 0 + r.buf = r.buf[:length] + }) +} + +func (r *RuneBuffer) Kill() { + r.Refresh(func() { + r.pushKill(r.buf[r.idx:]) + r.buf = r.buf[:r.idx] + }) +} + +func (r *RuneBuffer) Transpose() { + r.Refresh(func() { + if len(r.buf) == 1 { + r.idx++ + } + + if len(r.buf) < 2 { + return + } + + if r.idx == 0 { + r.idx = 1 + } else if r.idx >= len(r.buf) { + r.idx = len(r.buf) - 1 + } + r.buf[r.idx], r.buf[r.idx-1] = r.buf[r.idx-1], r.buf[r.idx] + r.idx++ + }) +} + +func (r *RuneBuffer) MoveToNextWord() { + r.Refresh(func() { + for i := r.idx + 1; i < len(r.buf); i++ { + if !IsWordBreak(r.buf[i]) && IsWordBreak(r.buf[i-1]) { + r.idx = i + return + } + } + + r.idx = len(r.buf) + }) +} + +func (r *RuneBuffer) MoveToEndWord() { + r.Refresh(func() { + // already at the end, so do nothing + if r.idx == len(r.buf) { + return + } + // if we are at the end of a word already, go to next + if !IsWordBreak(r.buf[r.idx]) && IsWordBreak(r.buf[r.idx+1]) { + r.idx++ + } + + // keep going until at the end of a word + for i := r.idx + 1; i < len(r.buf); i++ { + if IsWordBreak(r.buf[i]) && !IsWordBreak(r.buf[i-1]) { + r.idx = i - 1 + return + } + } + r.idx = len(r.buf) + }) +} + +func (r *RuneBuffer) BackEscapeWord() { + r.Refresh(func() { + if r.idx == 0 { + return + } + for i := r.idx - 1; i > 0; i-- { + if !IsWordBreak(r.buf[i]) && IsWordBreak(r.buf[i-1]) { + r.pushKill(r.buf[i:r.idx]) + r.buf = append(r.buf[:i], r.buf[r.idx:]...) + r.idx = i + return + } + } + + r.buf = r.buf[:0] + r.idx = 0 + }) +} + +func (r *RuneBuffer) Yank() { + if len(r.lastKill) == 0 { + return + } + r.Refresh(func() { + buf := make([]rune, 0, len(r.buf)+len(r.lastKill)) + buf = append(buf, r.buf[:r.idx]...) + buf = append(buf, r.lastKill...) + buf = append(buf, r.buf[r.idx:]...) + r.buf = buf + r.idx += len(r.lastKill) + }) +} + +func (r *RuneBuffer) Backspace() { + r.Refresh(func() { + if r.idx == 0 { + return + } + + r.idx-- + r.buf = append(r.buf[:r.idx], r.buf[r.idx+1:]...) + }) +} + +func (r *RuneBuffer) MoveToLineEnd() { + r.Refresh(func() { + if r.idx == len(r.buf) { + return + } + + r.idx = len(r.buf) + }) +} + +func (r *RuneBuffer) LineCount(width int) int { + if width == -1 { + width = r.width + } + return LineCount(width, + runes.WidthAll(r.buf)+r.PromptLen()) +} + +func (r *RuneBuffer) MoveTo(ch rune, prevChar, reverse bool) (success bool) { + r.Refresh(func() { + if reverse { + for i := r.idx - 1; i >= 0; i-- { + if r.buf[i] == ch { + r.idx = i + if prevChar { + r.idx++ + } + success = true + return + } + } + return + } + for i := r.idx + 1; i < len(r.buf); i++ { + if r.buf[i] == ch { + r.idx = i + if prevChar { + r.idx-- + } + success = true + return + } + } + }) + return +} + +func (r *RuneBuffer) isInLineEdge() bool { + if isWindows { + return false + } + sp := r.getSplitByLine(r.buf) + return len(sp[len(sp)-1]) == 0 +} + +func (r *RuneBuffer) getSplitByLine(rs []rune) []string { + return SplitByLine(r.promptLen(), r.width, rs) +} + +func (r *RuneBuffer) IdxLine(width int) int { + r.Lock() + defer r.Unlock() + return r.idxLine(width) +} + +func (r *RuneBuffer) idxLine(width int) int { + if width == 0 { + return 0 + } + sp := r.getSplitByLine(r.buf[:r.idx]) + return len(sp) - 1 +} + +func (r *RuneBuffer) CursorLineCount() int { + return r.LineCount(r.width) - r.IdxLine(r.width) +} + +func (r *RuneBuffer) Refresh(f func()) { + r.Lock() + defer r.Unlock() + + if !r.interactive { + if f != nil { + f() + } + return + } + + r.clean() + if f != nil { + f() + } + r.print() +} + +func (r *RuneBuffer) SetOffset(offset string) { + r.Lock() + r.offset = offset + r.Unlock() +} + +func (r *RuneBuffer) print() { + r.w.Write(r.output()) + r.hadClean = false +} + +func (r *RuneBuffer) output() []byte { + buf := bytes.NewBuffer(nil) + buf.WriteString(string(r.prompt)) + if r.cfg.EnableMask && len(r.buf) > 0 { + buf.Write([]byte(strings.Repeat(string(r.cfg.MaskRune), len(r.buf)-1))) + if r.buf[len(r.buf)-1] == '\n' { + buf.Write([]byte{'\n'}) + } else { + buf.Write([]byte(string(r.cfg.MaskRune))) + } + if len(r.buf) > r.idx { + buf.Write(r.getBackspaceSequence()) + } + + } else { + for _, e := range r.cfg.Painter.Paint(r.buf, r.idx) { + if e == '\t' { + buf.WriteString(strings.Repeat(" ", TabWidth)) + } else { + buf.WriteRune(e) + } + } + if r.isInLineEdge() { + buf.Write([]byte(" \b")) + } + } + // cursor position + if len(r.buf) > r.idx { + buf.Write(r.getBackspaceSequence()) + } + return buf.Bytes() +} + +func (r *RuneBuffer) getBackspaceSequence() []byte { + var sep = map[int]bool{} + + var i int + for { + if i >= runes.WidthAll(r.buf) { + break + } + + if i == 0 { + i -= r.promptLen() + } + i += r.width + + sep[i] = true + } + var buf []byte + for i := len(r.buf); i > r.idx; i-- { + // move input to the left of one + buf = append(buf, '\b') + if sep[i] { + // up one line, go to the start of the line and move cursor right to the end (r.width) + buf = append(buf, "\033[A\r"+"\033["+strconv.Itoa(r.width)+"C"...) + } + } + + return buf + +} + +func (r *RuneBuffer) Reset() []rune { + ret := runes.Copy(r.buf) + r.buf = r.buf[:0] + r.idx = 0 + return ret +} + +func (r *RuneBuffer) calWidth(m int) int { + if m > 0 { + return runes.WidthAll(r.buf[r.idx : r.idx+m]) + } + return runes.WidthAll(r.buf[r.idx+m : r.idx]) +} + +func (r *RuneBuffer) SetStyle(start, end int, style string) { + if end < start { + panic("end < start") + } + + // goto start + move := start - r.idx + if move > 0 { + r.w.Write([]byte(string(r.buf[r.idx : r.idx+move]))) + } else { + r.w.Write(bytes.Repeat([]byte("\b"), r.calWidth(move))) + } + r.w.Write([]byte("\033[" + style + "m")) + r.w.Write([]byte(string(r.buf[start:end]))) + r.w.Write([]byte("\033[0m")) + // TODO: move back +} + +func (r *RuneBuffer) SetWithIdx(idx int, buf []rune) { + r.Refresh(func() { + r.buf = buf + r.idx = idx + }) +} + +func (r *RuneBuffer) Set(buf []rune) { + r.SetWithIdx(len(buf), buf) +} + +func (r *RuneBuffer) SetPrompt(prompt string) { + r.Lock() + r.prompt = []rune(prompt) + r.Unlock() +} + +func (r *RuneBuffer) cleanOutput(w io.Writer, idxLine int) { + buf := bufio.NewWriter(w) + + if r.width == 0 { + buf.WriteString(strings.Repeat("\r\b", len(r.buf)+r.promptLen())) + buf.Write([]byte("\033[J")) + } else { + buf.Write([]byte("\033[J")) // just like ^k :) + if idxLine == 0 { + buf.WriteString("\033[2K") + buf.WriteString("\r") + } else { + for i := 0; i < idxLine; i++ { + io.WriteString(buf, "\033[2K\r\033[A") + } + io.WriteString(buf, "\033[2K\r") + } + } + buf.Flush() + return +} + +func (r *RuneBuffer) Clean() { + r.Lock() + r.clean() + r.Unlock() +} + +func (r *RuneBuffer) clean() { + r.cleanWithIdxLine(r.idxLine(r.width)) +} + +func (r *RuneBuffer) cleanWithIdxLine(idxLine int) { + if r.hadClean || !r.interactive { + return + } + r.hadClean = true + r.cleanOutput(r.w, idxLine) +} diff --git a/vendor/github.com/chzyer/readline/runes.go b/vendor/github.com/chzyer/readline/runes.go new file mode 100644 index 000000000..a669bc48c --- /dev/null +++ b/vendor/github.com/chzyer/readline/runes.go @@ -0,0 +1,223 @@ +package readline + +import ( + "bytes" + "unicode" + "unicode/utf8" +) + +var runes = Runes{} +var TabWidth = 4 + +type Runes struct{} + +func (Runes) EqualRune(a, b rune, fold bool) bool { + if a == b { + return true + } + if !fold { + return false + } + if a > b { + a, b = b, a + } + if b < utf8.RuneSelf && 'A' <= a && a <= 'Z' { + if b == a+'a'-'A' { + return true + } + } + return false +} + +func (r Runes) EqualRuneFold(a, b rune) bool { + return r.EqualRune(a, b, true) +} + +func (r Runes) EqualFold(a, b []rune) bool { + if len(a) != len(b) { + return false + } + for i := 0; i < len(a); i++ { + if r.EqualRuneFold(a[i], b[i]) { + continue + } + return false + } + + return true +} + +func (Runes) Equal(a, b []rune) bool { + if len(a) != len(b) { + return false + } + for i := 0; i < len(a); i++ { + if a[i] != b[i] { + return false + } + } + return true +} + +func (rs Runes) IndexAllBckEx(r, sub []rune, fold bool) int { + for i := len(r) - len(sub); i >= 0; i-- { + found := true + for j := 0; j < len(sub); j++ { + if !rs.EqualRune(r[i+j], sub[j], fold) { + found = false + break + } + } + if found { + return i + } + } + return -1 +} + +// Search in runes from end to front +func (rs Runes) IndexAllBck(r, sub []rune) int { + return rs.IndexAllBckEx(r, sub, false) +} + +// Search in runes from front to end +func (rs Runes) IndexAll(r, sub []rune) int { + return rs.IndexAllEx(r, sub, false) +} + +func (rs Runes) IndexAllEx(r, sub []rune, fold bool) int { + for i := 0; i < len(r); i++ { + found := true + if len(r[i:]) < len(sub) { + return -1 + } + for j := 0; j < len(sub); j++ { + if !rs.EqualRune(r[i+j], sub[j], fold) { + found = false + break + } + } + if found { + return i + } + } + return -1 +} + +func (Runes) Index(r rune, rs []rune) int { + for i := 0; i < len(rs); i++ { + if rs[i] == r { + return i + } + } + return -1 +} + +func (Runes) ColorFilter(r []rune) []rune { + newr := make([]rune, 0, len(r)) + for pos := 0; pos < len(r); pos++ { + if r[pos] == '\033' && r[pos+1] == '[' { + idx := runes.Index('m', r[pos+2:]) + if idx == -1 { + continue + } + pos += idx + 2 + continue + } + newr = append(newr, r[pos]) + } + return newr +} + +var zeroWidth = []*unicode.RangeTable{ + unicode.Mn, + unicode.Me, + unicode.Cc, + unicode.Cf, +} + +var doubleWidth = []*unicode.RangeTable{ + unicode.Han, + unicode.Hangul, + unicode.Hiragana, + unicode.Katakana, +} + +func (Runes) Width(r rune) int { + if r == '\t' { + return TabWidth + } + if unicode.IsOneOf(zeroWidth, r) { + return 0 + } + if unicode.IsOneOf(doubleWidth, r) { + return 2 + } + return 1 +} + +func (Runes) WidthAll(r []rune) (length int) { + for i := 0; i < len(r); i++ { + length += runes.Width(r[i]) + } + return +} + +func (Runes) Backspace(r []rune) []byte { + return bytes.Repeat([]byte{'\b'}, runes.WidthAll(r)) +} + +func (Runes) Copy(r []rune) []rune { + n := make([]rune, len(r)) + copy(n, r) + return n +} + +func (Runes) HasPrefixFold(r, prefix []rune) bool { + if len(r) < len(prefix) { + return false + } + return runes.EqualFold(r[:len(prefix)], prefix) +} + +func (Runes) HasPrefix(r, prefix []rune) bool { + if len(r) < len(prefix) { + return false + } + return runes.Equal(r[:len(prefix)], prefix) +} + +func (Runes) Aggregate(candicate [][]rune) (same []rune, size int) { + for i := 0; i < len(candicate[0]); i++ { + for j := 0; j < len(candicate)-1; j++ { + if i >= len(candicate[j]) || i >= len(candicate[j+1]) { + goto aggregate + } + if candicate[j][i] != candicate[j+1][i] { + goto aggregate + } + } + size = i + 1 + } +aggregate: + if size > 0 { + same = runes.Copy(candicate[0][:size]) + for i := 0; i < len(candicate); i++ { + n := runes.Copy(candicate[i]) + copy(n, n[size:]) + candicate[i] = n[:len(n)-size] + } + } + return +} + +func (Runes) TrimSpaceLeft(in []rune) []rune { + firstIndex := len(in) + for i, r := range in { + if unicode.IsSpace(r) == false { + firstIndex = i + break + } + } + return in[firstIndex:] +} diff --git a/vendor/github.com/chzyer/readline/search.go b/vendor/github.com/chzyer/readline/search.go new file mode 100644 index 000000000..52e8ff099 --- /dev/null +++ b/vendor/github.com/chzyer/readline/search.go @@ -0,0 +1,164 @@ +package readline + +import ( + "bytes" + "container/list" + "fmt" + "io" +) + +const ( + S_STATE_FOUND = iota + S_STATE_FAILING +) + +const ( + S_DIR_BCK = iota + S_DIR_FWD +) + +type opSearch struct { + inMode bool + state int + dir int + source *list.Element + w io.Writer + buf *RuneBuffer + data []rune + history *opHistory + cfg *Config + markStart int + markEnd int + width int +} + +func newOpSearch(w io.Writer, buf *RuneBuffer, history *opHistory, cfg *Config, width int) *opSearch { + return &opSearch{ + w: w, + buf: buf, + cfg: cfg, + history: history, + width: width, + } +} + +func (o *opSearch) OnWidthChange(newWidth int) { + o.width = newWidth +} + +func (o *opSearch) IsSearchMode() bool { + return o.inMode +} + +func (o *opSearch) SearchBackspace() { + if len(o.data) > 0 { + o.data = o.data[:len(o.data)-1] + o.search(true) + } +} + +func (o *opSearch) findHistoryBy(isNewSearch bool) (int, *list.Element) { + if o.dir == S_DIR_BCK { + return o.history.FindBck(isNewSearch, o.data, o.buf.idx) + } + return o.history.FindFwd(isNewSearch, o.data, o.buf.idx) +} + +func (o *opSearch) search(isChange bool) bool { + if len(o.data) == 0 { + o.state = S_STATE_FOUND + o.SearchRefresh(-1) + return true + } + idx, elem := o.findHistoryBy(isChange) + if elem == nil { + o.SearchRefresh(-2) + return false + } + o.history.current = elem + + item := o.history.showItem(o.history.current.Value) + start, end := 0, 0 + if o.dir == S_DIR_BCK { + start, end = idx, idx+len(o.data) + } else { + start, end = idx, idx+len(o.data) + idx += len(o.data) + } + o.buf.SetWithIdx(idx, item) + o.markStart, o.markEnd = start, end + o.SearchRefresh(idx) + return true +} + +func (o *opSearch) SearchChar(r rune) { + o.data = append(o.data, r) + o.search(true) +} + +func (o *opSearch) SearchMode(dir int) bool { + if o.width == 0 { + return false + } + alreadyInMode := o.inMode + o.inMode = true + o.dir = dir + o.source = o.history.current + if alreadyInMode { + o.search(false) + } else { + o.SearchRefresh(-1) + } + return true +} + +func (o *opSearch) ExitSearchMode(revert bool) { + if revert { + o.history.current = o.source + o.buf.Set(o.history.showItem(o.history.current.Value)) + } + o.markStart, o.markEnd = 0, 0 + o.state = S_STATE_FOUND + o.inMode = false + o.source = nil + o.data = nil +} + +func (o *opSearch) SearchRefresh(x int) { + if x == -2 { + o.state = S_STATE_FAILING + } else if x >= 0 { + o.state = S_STATE_FOUND + } + if x < 0 { + x = o.buf.idx + } + x = o.buf.CurrentWidth(x) + x += o.buf.PromptLen() + x = x % o.width + + if o.markStart > 0 { + o.buf.SetStyle(o.markStart, o.markEnd, "4") + } + + lineCnt := o.buf.CursorLineCount() + buf := bytes.NewBuffer(nil) + buf.Write(bytes.Repeat([]byte("\n"), lineCnt)) + buf.WriteString("\033[J") + if o.state == S_STATE_FAILING { + buf.WriteString("failing ") + } + if o.dir == S_DIR_BCK { + buf.WriteString("bck") + } else if o.dir == S_DIR_FWD { + buf.WriteString("fwd") + } + buf.WriteString("-i-search: ") + buf.WriteString(string(o.data)) // keyword + buf.WriteString("\033[4m \033[0m") // _ + fmt.Fprintf(buf, "\r\033[%dA", lineCnt) // move prev + if x > 0 { + fmt.Fprintf(buf, "\033[%dC", x) // move forward + } + o.w.Write(buf.Bytes()) +} diff --git a/vendor/github.com/chzyer/readline/std.go b/vendor/github.com/chzyer/readline/std.go new file mode 100644 index 000000000..61d44b759 --- /dev/null +++ b/vendor/github.com/chzyer/readline/std.go @@ -0,0 +1,197 @@ +package readline + +import ( + "io" + "os" + "sync" + "sync/atomic" +) + +var ( + Stdin io.ReadCloser = os.Stdin + Stdout io.WriteCloser = os.Stdout + Stderr io.WriteCloser = os.Stderr +) + +var ( + std *Instance + stdOnce sync.Once +) + +// global instance will not submit history automatic +func getInstance() *Instance { + stdOnce.Do(func() { + std, _ = NewEx(&Config{ + DisableAutoSaveHistory: true, + }) + }) + return std +} + +// let readline load history from filepath +// and try to persist history into disk +// set fp to "" to prevent readline persisting history to disk +// so the `AddHistory` will return nil error forever. +func SetHistoryPath(fp string) { + ins := getInstance() + cfg := ins.Config.Clone() + cfg.HistoryFile = fp + ins.SetConfig(cfg) +} + +// set auto completer to global instance +func SetAutoComplete(completer AutoCompleter) { + ins := getInstance() + cfg := ins.Config.Clone() + cfg.AutoComplete = completer + ins.SetConfig(cfg) +} + +// add history to global instance manually +// raise error only if `SetHistoryPath` is set with a non-empty path +func AddHistory(content string) error { + ins := getInstance() + return ins.SaveHistory(content) +} + +func Password(prompt string) ([]byte, error) { + ins := getInstance() + return ins.ReadPassword(prompt) +} + +// readline with global configs +func Line(prompt string) (string, error) { + ins := getInstance() + ins.SetPrompt(prompt) + return ins.Readline() +} + +type CancelableStdin struct { + r io.Reader + mutex sync.Mutex + stop chan struct{} + closed int32 + notify chan struct{} + data []byte + read int + err error +} + +func NewCancelableStdin(r io.Reader) *CancelableStdin { + c := &CancelableStdin{ + r: r, + notify: make(chan struct{}), + stop: make(chan struct{}), + } + go c.ioloop() + return c +} + +func (c *CancelableStdin) ioloop() { +loop: + for { + select { + case <-c.notify: + c.read, c.err = c.r.Read(c.data) + select { + case c.notify <- struct{}{}: + case <-c.stop: + break loop + } + case <-c.stop: + break loop + } + } +} + +func (c *CancelableStdin) Read(b []byte) (n int, err error) { + c.mutex.Lock() + defer c.mutex.Unlock() + if atomic.LoadInt32(&c.closed) == 1 { + return 0, io.EOF + } + + c.data = b + select { + case c.notify <- struct{}{}: + case <-c.stop: + return 0, io.EOF + } + select { + case <-c.notify: + return c.read, c.err + case <-c.stop: + return 0, io.EOF + } +} + +func (c *CancelableStdin) Close() error { + if atomic.CompareAndSwapInt32(&c.closed, 0, 1) { + close(c.stop) + } + return nil +} + +// FillableStdin is a stdin reader which can prepend some data before +// reading into the real stdin +type FillableStdin struct { + sync.Mutex + stdin io.Reader + stdinBuffer io.ReadCloser + buf []byte + bufErr error +} + +// NewFillableStdin gives you FillableStdin +func NewFillableStdin(stdin io.Reader) (io.ReadCloser, io.Writer) { + r, w := io.Pipe() + s := &FillableStdin{ + stdinBuffer: r, + stdin: stdin, + } + s.ioloop() + return s, w +} + +func (s *FillableStdin) ioloop() { + go func() { + for { + bufR := make([]byte, 100) + var n int + n, s.bufErr = s.stdinBuffer.Read(bufR) + if s.bufErr != nil { + if s.bufErr == io.ErrClosedPipe { + break + } + } + s.Lock() + s.buf = append(s.buf, bufR[:n]...) + s.Unlock() + } + }() +} + +// Read will read from the local buffer and if no data, read from stdin +func (s *FillableStdin) Read(p []byte) (n int, err error) { + s.Lock() + i := len(s.buf) + if len(p) < i { + i = len(p) + } + if i > 0 { + n := copy(p, s.buf) + s.buf = s.buf[:0] + cerr := s.bufErr + s.bufErr = nil + s.Unlock() + return n, cerr + } + s.Unlock() + n, err = s.stdin.Read(p) + return n, err +} + +func (s *FillableStdin) Close() error { + s.stdinBuffer.Close() + return nil +} diff --git a/vendor/github.com/chzyer/readline/std_windows.go b/vendor/github.com/chzyer/readline/std_windows.go new file mode 100644 index 000000000..b10f91bcb --- /dev/null +++ b/vendor/github.com/chzyer/readline/std_windows.go @@ -0,0 +1,9 @@ +// +build windows + +package readline + +func init() { + Stdin = NewRawReader() + Stdout = NewANSIWriter(Stdout) + Stderr = NewANSIWriter(Stderr) +} diff --git a/vendor/github.com/chzyer/readline/term.go b/vendor/github.com/chzyer/readline/term.go new file mode 100644 index 000000000..ea5db9346 --- /dev/null +++ b/vendor/github.com/chzyer/readline/term.go @@ -0,0 +1,123 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build aix darwin dragonfly freebsd linux,!appengine netbsd openbsd os400 solaris + +// Package terminal provides support functions for dealing with terminals, as +// commonly found on UNIX systems. +// +// Putting a terminal into raw mode is the most common requirement: +// +// oldState, err := terminal.MakeRaw(0) +// if err != nil { +// panic(err) +// } +// defer terminal.Restore(0, oldState) +package readline + +import ( + "io" + "syscall" +) + +// State contains the state of a terminal. +type State struct { + termios Termios +} + +// IsTerminal returns true if the given file descriptor is a terminal. +func IsTerminal(fd int) bool { + _, err := getTermios(fd) + return err == nil +} + +// MakeRaw put the terminal connected to the given file descriptor into raw +// mode and returns the previous state of the terminal so that it can be +// restored. +func MakeRaw(fd int) (*State, error) { + var oldState State + + if termios, err := getTermios(fd); err != nil { + return nil, err + } else { + oldState.termios = *termios + } + + newState := oldState.termios + // This attempts to replicate the behaviour documented for cfmakeraw in + // the termios(3) manpage. + newState.Iflag &^= syscall.IGNBRK | syscall.BRKINT | syscall.PARMRK | syscall.ISTRIP | syscall.INLCR | syscall.IGNCR | syscall.ICRNL | syscall.IXON + // newState.Oflag &^= syscall.OPOST + newState.Lflag &^= syscall.ECHO | syscall.ECHONL | syscall.ICANON | syscall.ISIG | syscall.IEXTEN + newState.Cflag &^= syscall.CSIZE | syscall.PARENB + newState.Cflag |= syscall.CS8 + + newState.Cc[syscall.VMIN] = 1 + newState.Cc[syscall.VTIME] = 0 + + return &oldState, setTermios(fd, &newState) +} + +// GetState returns the current state of a terminal which may be useful to +// restore the terminal after a signal. +func GetState(fd int) (*State, error) { + termios, err := getTermios(fd) + if err != nil { + return nil, err + } + + return &State{termios: *termios}, nil +} + +// Restore restores the terminal connected to the given file descriptor to a +// previous state. +func restoreTerm(fd int, state *State) error { + return setTermios(fd, &state.termios) +} + +// ReadPassword reads a line of input from a terminal without local echo. This +// is commonly used for inputting passwords and other sensitive data. The slice +// returned does not include the \n. +func ReadPassword(fd int) ([]byte, error) { + oldState, err := getTermios(fd) + if err != nil { + return nil, err + } + + newState := oldState + newState.Lflag &^= syscall.ECHO + newState.Lflag |= syscall.ICANON | syscall.ISIG + newState.Iflag |= syscall.ICRNL + if err := setTermios(fd, newState); err != nil { + return nil, err + } + + defer func() { + setTermios(fd, oldState) + }() + + var buf [16]byte + var ret []byte + for { + n, err := syscall.Read(fd, buf[:]) + if err != nil { + return nil, err + } + if n == 0 { + if len(ret) == 0 { + return nil, io.EOF + } + break + } + if buf[n-1] == '\n' { + n-- + } + ret = append(ret, buf[:n]...) + if n < len(buf) { + break + } + } + + return ret, nil +} diff --git a/vendor/github.com/chzyer/readline/term_bsd.go b/vendor/github.com/chzyer/readline/term_bsd.go new file mode 100644 index 000000000..68b56ea6b --- /dev/null +++ b/vendor/github.com/chzyer/readline/term_bsd.go @@ -0,0 +1,29 @@ +// Copyright 2013 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build darwin dragonfly freebsd netbsd openbsd + +package readline + +import ( + "syscall" + "unsafe" +) + +func getTermios(fd int) (*Termios, error) { + termios := new(Termios) + _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCGETA, uintptr(unsafe.Pointer(termios)), 0, 0, 0) + if err != 0 { + return nil, err + } + return termios, nil +} + +func setTermios(fd int, termios *Termios) error { + _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCSETA, uintptr(unsafe.Pointer(termios)), 0, 0, 0) + if err != 0 { + return err + } + return nil +} diff --git a/vendor/github.com/chzyer/readline/term_linux.go b/vendor/github.com/chzyer/readline/term_linux.go new file mode 100644 index 000000000..e3392b4ac --- /dev/null +++ b/vendor/github.com/chzyer/readline/term_linux.go @@ -0,0 +1,33 @@ +// Copyright 2013 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package readline + +import ( + "syscall" + "unsafe" +) + +// These constants are declared here, rather than importing +// them from the syscall package as some syscall packages, even +// on linux, for example gccgo, do not declare them. +const ioctlReadTermios = 0x5401 // syscall.TCGETS +const ioctlWriteTermios = 0x5402 // syscall.TCSETS + +func getTermios(fd int) (*Termios, error) { + termios := new(Termios) + _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(fd), ioctlReadTermios, uintptr(unsafe.Pointer(termios)), 0, 0, 0) + if err != 0 { + return nil, err + } + return termios, nil +} + +func setTermios(fd int, termios *Termios) error { + _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(fd), ioctlWriteTermios, uintptr(unsafe.Pointer(termios)), 0, 0, 0) + if err != 0 { + return err + } + return nil +} diff --git a/vendor/github.com/chzyer/readline/term_nosyscall6.go b/vendor/github.com/chzyer/readline/term_nosyscall6.go new file mode 100644 index 000000000..df9233937 --- /dev/null +++ b/vendor/github.com/chzyer/readline/term_nosyscall6.go @@ -0,0 +1,32 @@ +// Copyright 2013 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build aix os400 solaris + +package readline + +import "golang.org/x/sys/unix" + +// GetSize returns the dimensions of the given terminal. +func GetSize(fd int) (int, int, error) { + ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ) + if err != nil { + return 0, 0, err + } + return int(ws.Col), int(ws.Row), nil +} + +type Termios unix.Termios + +func getTermios(fd int) (*Termios, error) { + termios, err := unix.IoctlGetTermios(fd, unix.TCGETS) + if err != nil { + return nil, err + } + return (*Termios)(termios), nil +} + +func setTermios(fd int, termios *Termios) error { + return unix.IoctlSetTermios(fd, unix.TCSETSF, (*unix.Termios)(termios)) +} diff --git a/vendor/github.com/chzyer/readline/term_unix.go b/vendor/github.com/chzyer/readline/term_unix.go new file mode 100644 index 000000000..d3ea24244 --- /dev/null +++ b/vendor/github.com/chzyer/readline/term_unix.go @@ -0,0 +1,24 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build darwin dragonfly freebsd linux,!appengine netbsd openbsd + +package readline + +import ( + "syscall" + "unsafe" +) + +type Termios syscall.Termios + +// GetSize returns the dimensions of the given terminal. +func GetSize(fd int) (int, int, error) { + var dimensions [4]uint16 + _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(fd), uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&dimensions)), 0, 0, 0) + if err != 0 { + return 0, 0, err + } + return int(dimensions[1]), int(dimensions[0]), nil +} diff --git a/vendor/github.com/chzyer/readline/term_windows.go b/vendor/github.com/chzyer/readline/term_windows.go new file mode 100644 index 000000000..1290e00bc --- /dev/null +++ b/vendor/github.com/chzyer/readline/term_windows.go @@ -0,0 +1,171 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build windows + +// Package terminal provides support functions for dealing with terminals, as +// commonly found on UNIX systems. +// +// Putting a terminal into raw mode is the most common requirement: +// +// oldState, err := terminal.MakeRaw(0) +// if err != nil { +// panic(err) +// } +// defer terminal.Restore(0, oldState) +package readline + +import ( + "io" + "syscall" + "unsafe" +) + +const ( + enableLineInput = 2 + enableEchoInput = 4 + enableProcessedInput = 1 + enableWindowInput = 8 + enableMouseInput = 16 + enableInsertMode = 32 + enableQuickEditMode = 64 + enableExtendedFlags = 128 + enableAutoPosition = 256 + enableProcessedOutput = 1 + enableWrapAtEolOutput = 2 +) + +var kernel32 = syscall.NewLazyDLL("kernel32.dll") + +var ( + procGetConsoleMode = kernel32.NewProc("GetConsoleMode") + procSetConsoleMode = kernel32.NewProc("SetConsoleMode") + procGetConsoleScreenBufferInfo = kernel32.NewProc("GetConsoleScreenBufferInfo") +) + +type ( + coord struct { + x short + y short + } + smallRect struct { + left short + top short + right short + bottom short + } + consoleScreenBufferInfo struct { + size coord + cursorPosition coord + attributes word + window smallRect + maximumWindowSize coord + } +) + +type State struct { + mode uint32 +} + +// IsTerminal returns true if the given file descriptor is a terminal. +func IsTerminal(fd int) bool { + var st uint32 + r, _, e := syscall.Syscall(procGetConsoleMode.Addr(), 2, uintptr(fd), uintptr(unsafe.Pointer(&st)), 0) + return r != 0 && e == 0 +} + +// MakeRaw put the terminal connected to the given file descriptor into raw +// mode and returns the previous state of the terminal so that it can be +// restored. +func MakeRaw(fd int) (*State, error) { + var st uint32 + _, _, e := syscall.Syscall(procGetConsoleMode.Addr(), 2, uintptr(fd), uintptr(unsafe.Pointer(&st)), 0) + if e != 0 { + return nil, error(e) + } + raw := st &^ (enableEchoInput | enableProcessedInput | enableLineInput | enableProcessedOutput) + _, _, e = syscall.Syscall(procSetConsoleMode.Addr(), 2, uintptr(fd), uintptr(raw), 0) + if e != 0 { + return nil, error(e) + } + return &State{st}, nil +} + +// GetState returns the current state of a terminal which may be useful to +// restore the terminal after a signal. +func GetState(fd int) (*State, error) { + var st uint32 + _, _, e := syscall.Syscall(procGetConsoleMode.Addr(), 2, uintptr(fd), uintptr(unsafe.Pointer(&st)), 0) + if e != 0 { + return nil, error(e) + } + return &State{st}, nil +} + +// Restore restores the terminal connected to the given file descriptor to a +// previous state. +func restoreTerm(fd int, state *State) error { + _, _, err := syscall.Syscall(procSetConsoleMode.Addr(), 2, uintptr(fd), uintptr(state.mode), 0) + return err +} + +// GetSize returns the dimensions of the given terminal. +func GetSize(fd int) (width, height int, err error) { + var info consoleScreenBufferInfo + _, _, e := syscall.Syscall(procGetConsoleScreenBufferInfo.Addr(), 2, uintptr(fd), uintptr(unsafe.Pointer(&info)), 0) + if e != 0 { + return 0, 0, error(e) + } + return int(info.size.x), int(info.size.y), nil +} + +// ReadPassword reads a line of input from a terminal without local echo. This +// is commonly used for inputting passwords and other sensitive data. The slice +// returned does not include the \n. +func ReadPassword(fd int) ([]byte, error) { + var st uint32 + _, _, e := syscall.Syscall(procGetConsoleMode.Addr(), 2, uintptr(fd), uintptr(unsafe.Pointer(&st)), 0) + if e != 0 { + return nil, error(e) + } + old := st + + st &^= (enableEchoInput) + st |= (enableProcessedInput | enableLineInput | enableProcessedOutput) + _, _, e = syscall.Syscall(procSetConsoleMode.Addr(), 2, uintptr(fd), uintptr(st), 0) + if e != 0 { + return nil, error(e) + } + + defer func() { + syscall.Syscall(procSetConsoleMode.Addr(), 2, uintptr(fd), uintptr(old), 0) + }() + + var buf [16]byte + var ret []byte + for { + n, err := syscall.Read(syscall.Handle(fd), buf[:]) + if err != nil { + return nil, err + } + if n == 0 { + if len(ret) == 0 { + return nil, io.EOF + } + break + } + if buf[n-1] == '\n' { + n-- + } + if n > 0 && buf[n-1] == '\r' { + n-- + } + ret = append(ret, buf[:n]...) + if n < len(buf) { + break + } + } + + return ret, nil +} diff --git a/vendor/github.com/chzyer/readline/terminal.go b/vendor/github.com/chzyer/readline/terminal.go new file mode 100644 index 000000000..38413d0cf --- /dev/null +++ b/vendor/github.com/chzyer/readline/terminal.go @@ -0,0 +1,254 @@ +package readline + +import ( + "bufio" + "fmt" + "io" + "strings" + "sync" + "sync/atomic" +) + +type Terminal struct { + m sync.Mutex + cfg *Config + outchan chan rune + closed int32 + stopChan chan struct{} + kickChan chan struct{} + wg sync.WaitGroup + isReading int32 + sleeping int32 + + sizeChan chan string +} + +func NewTerminal(cfg *Config) (*Terminal, error) { + if err := cfg.Init(); err != nil { + return nil, err + } + t := &Terminal{ + cfg: cfg, + kickChan: make(chan struct{}, 1), + outchan: make(chan rune), + stopChan: make(chan struct{}, 1), + sizeChan: make(chan string, 1), + } + + go t.ioloop() + return t, nil +} + +// SleepToResume will sleep myself, and return only if I'm resumed. +func (t *Terminal) SleepToResume() { + if !atomic.CompareAndSwapInt32(&t.sleeping, 0, 1) { + return + } + defer atomic.StoreInt32(&t.sleeping, 0) + + t.ExitRawMode() + ch := WaitForResume() + SuspendMe() + <-ch + t.EnterRawMode() +} + +func (t *Terminal) EnterRawMode() (err error) { + return t.cfg.FuncMakeRaw() +} + +func (t *Terminal) ExitRawMode() (err error) { + return t.cfg.FuncExitRaw() +} + +func (t *Terminal) Write(b []byte) (int, error) { + return t.cfg.Stdout.Write(b) +} + +// WriteStdin prefill the next Stdin fetch +// Next time you call ReadLine() this value will be writen before the user input +func (t *Terminal) WriteStdin(b []byte) (int, error) { + return t.cfg.StdinWriter.Write(b) +} + +type termSize struct { + left int + top int +} + +func (t *Terminal) GetOffset(f func(offset string)) { + go func() { + f(<-t.sizeChan) + }() + t.Write([]byte("\033[6n")) +} + +func (t *Terminal) Print(s string) { + fmt.Fprintf(t.cfg.Stdout, "%s", s) +} + +func (t *Terminal) PrintRune(r rune) { + fmt.Fprintf(t.cfg.Stdout, "%c", r) +} + +func (t *Terminal) Readline() *Operation { + return NewOperation(t, t.cfg) +} + +// return rune(0) if meet EOF +func (t *Terminal) ReadRune() rune { + ch, ok := <-t.outchan + if !ok { + return rune(0) + } + return ch +} + +func (t *Terminal) IsReading() bool { + return atomic.LoadInt32(&t.isReading) == 1 +} + +func (t *Terminal) KickRead() { + select { + case t.kickChan <- struct{}{}: + default: + } +} + +func (t *Terminal) ioloop() { + t.wg.Add(1) + defer func() { + t.wg.Done() + close(t.outchan) + }() + + var ( + isEscape bool + isEscapeEx bool + isEscapeSS3 bool + expectNextChar bool + ) + + buf := bufio.NewReader(t.getStdin()) + for { + if !expectNextChar { + atomic.StoreInt32(&t.isReading, 0) + select { + case <-t.kickChan: + atomic.StoreInt32(&t.isReading, 1) + case <-t.stopChan: + return + } + } + expectNextChar = false + r, _, err := buf.ReadRune() + if err != nil { + if strings.Contains(err.Error(), "interrupted system call") { + expectNextChar = true + continue + } + break + } + + if isEscape { + isEscape = false + if r == CharEscapeEx { + // ^][ + expectNextChar = true + isEscapeEx = true + continue + } else if r == CharO { + // ^]O + expectNextChar = true + isEscapeSS3 = true + continue + } + r = escapeKey(r, buf) + } else if isEscapeEx { + isEscapeEx = false + if key := readEscKey(r, buf); key != nil { + r = escapeExKey(key) + // offset + if key.typ == 'R' { + if _, _, ok := key.Get2(); ok { + select { + case t.sizeChan <- key.attr: + default: + } + } + expectNextChar = true + continue + } + } + if r == 0 { + expectNextChar = true + continue + } + } else if isEscapeSS3 { + isEscapeSS3 = false + if key := readEscKey(r, buf); key != nil { + r = escapeSS3Key(key) + } + if r == 0 { + expectNextChar = true + continue + } + } + + expectNextChar = true + switch r { + case CharEsc: + if t.cfg.VimMode { + t.outchan <- r + break + } + isEscape = true + case CharInterrupt, CharEnter, CharCtrlJ, CharDelete: + expectNextChar = false + fallthrough + default: + t.outchan <- r + } + } + +} + +func (t *Terminal) Bell() { + fmt.Fprintf(t, "%c", CharBell) +} + +func (t *Terminal) Close() error { + if atomic.SwapInt32(&t.closed, 1) != 0 { + return nil + } + if closer, ok := t.cfg.Stdin.(io.Closer); ok { + closer.Close() + } + close(t.stopChan) + t.wg.Wait() + return t.ExitRawMode() +} + +func (t *Terminal) GetConfig() *Config { + t.m.Lock() + cfg := *t.cfg + t.m.Unlock() + return &cfg +} + +func (t *Terminal) getStdin() io.Reader { + t.m.Lock() + r := t.cfg.Stdin + t.m.Unlock() + return r +} + +func (t *Terminal) SetConfig(c *Config) error { + if err := c.Init(); err != nil { + return err + } + t.m.Lock() + t.cfg = c + t.m.Unlock() + return nil +} diff --git a/vendor/github.com/chzyer/readline/utils.go b/vendor/github.com/chzyer/readline/utils.go new file mode 100644 index 000000000..0706dd4ec --- /dev/null +++ b/vendor/github.com/chzyer/readline/utils.go @@ -0,0 +1,311 @@ +package readline + +import ( + "bufio" + "bytes" + "container/list" + "fmt" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "syscall" + "time" + "unicode" +) + +var ( + isWindows = false +) + +const ( + CharLineStart = 1 + CharBackward = 2 + CharInterrupt = 3 + CharDelete = 4 + CharLineEnd = 5 + CharForward = 6 + CharBell = 7 + CharCtrlH = 8 + CharTab = 9 + CharCtrlJ = 10 + CharKill = 11 + CharCtrlL = 12 + CharEnter = 13 + CharNext = 14 + CharPrev = 16 + CharBckSearch = 18 + CharFwdSearch = 19 + CharTranspose = 20 + CharCtrlU = 21 + CharCtrlW = 23 + CharCtrlY = 25 + CharCtrlZ = 26 + CharEsc = 27 + CharO = 79 + CharEscapeEx = 91 + CharBackspace = 127 +) + +const ( + MetaBackward rune = -iota - 1 + MetaForward + MetaDelete + MetaBackspace + MetaTranspose +) + +// WaitForResume need to call before current process got suspend. +// It will run a ticker until a long duration is occurs, +// which means this process is resumed. +func WaitForResume() chan struct{} { + ch := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + ticker := time.NewTicker(10 * time.Millisecond) + t := time.Now() + wg.Done() + for { + now := <-ticker.C + if now.Sub(t) > 100*time.Millisecond { + break + } + t = now + } + ticker.Stop() + ch <- struct{}{} + }() + wg.Wait() + return ch +} + +func Restore(fd int, state *State) error { + err := restoreTerm(fd, state) + if err != nil { + // errno 0 means everything is ok :) + if err.Error() == "errno 0" { + return nil + } else { + return err + } + } + return nil +} + +func IsPrintable(key rune) bool { + isInSurrogateArea := key >= 0xd800 && key <= 0xdbff + return key >= 32 && !isInSurrogateArea +} + +// translate Esc[X +func escapeExKey(key *escapeKeyPair) rune { + var r rune + switch key.typ { + case 'D': + r = CharBackward + case 'C': + r = CharForward + case 'A': + r = CharPrev + case 'B': + r = CharNext + case 'H': + r = CharLineStart + case 'F': + r = CharLineEnd + case '~': + if key.attr == "3" { + r = CharDelete + } + default: + } + return r +} + +// translate EscOX SS3 codes for up/down/etc. +func escapeSS3Key(key *escapeKeyPair) rune { + var r rune + switch key.typ { + case 'D': + r = CharBackward + case 'C': + r = CharForward + case 'A': + r = CharPrev + case 'B': + r = CharNext + case 'H': + r = CharLineStart + case 'F': + r = CharLineEnd + default: + } + return r +} + +type escapeKeyPair struct { + attr string + typ rune +} + +func (e *escapeKeyPair) Get2() (int, int, bool) { + sp := strings.Split(e.attr, ";") + if len(sp) < 2 { + return -1, -1, false + } + s1, err := strconv.Atoi(sp[0]) + if err != nil { + return -1, -1, false + } + s2, err := strconv.Atoi(sp[1]) + if err != nil { + return -1, -1, false + } + return s1, s2, true +} + +func readEscKey(r rune, reader *bufio.Reader) *escapeKeyPair { + p := escapeKeyPair{} + buf := bytes.NewBuffer(nil) + for { + if r == ';' { + } else if unicode.IsNumber(r) { + } else { + p.typ = r + break + } + buf.WriteRune(r) + r, _, _ = reader.ReadRune() + } + p.attr = buf.String() + return &p +} + +// translate EscX to Meta+X +func escapeKey(r rune, reader *bufio.Reader) rune { + switch r { + case 'b': + r = MetaBackward + case 'f': + r = MetaForward + case 'd': + r = MetaDelete + case CharTranspose: + r = MetaTranspose + case CharBackspace: + r = MetaBackspace + case 'O': + d, _, _ := reader.ReadRune() + switch d { + case 'H': + r = CharLineStart + case 'F': + r = CharLineEnd + default: + reader.UnreadRune() + } + case CharEsc: + + } + return r +} + +func SplitByLine(start, screenWidth int, rs []rune) []string { + var ret []string + buf := bytes.NewBuffer(nil) + currentWidth := start + for _, r := range rs { + w := runes.Width(r) + currentWidth += w + buf.WriteRune(r) + if currentWidth >= screenWidth { + ret = append(ret, buf.String()) + buf.Reset() + currentWidth = 0 + } + } + ret = append(ret, buf.String()) + return ret +} + +// calculate how many lines for N character +func LineCount(screenWidth, w int) int { + r := w / screenWidth + if w%screenWidth != 0 { + r++ + } + return r +} + +func IsWordBreak(i rune) bool { + switch { + case i >= 'a' && i <= 'z': + case i >= 'A' && i <= 'Z': + case i >= '0' && i <= '9': + default: + return true + } + return false +} + +func GetInt(s []string, def int) int { + if len(s) == 0 { + return def + } + c, err := strconv.Atoi(s[0]) + if err != nil { + return def + } + return c +} + +type RawMode struct { + state *State +} + +func (r *RawMode) Enter() (err error) { + r.state, err = MakeRaw(GetStdin()) + return err +} + +func (r *RawMode) Exit() error { + if r.state == nil { + return nil + } + return Restore(GetStdin(), r.state) +} + +// ----------------------------------------------------------------------------- + +func sleep(n int) { + Debug(n) + time.Sleep(2000 * time.Millisecond) +} + +// print a linked list to Debug() +func debugList(l *list.List) { + idx := 0 + for e := l.Front(); e != nil; e = e.Next() { + Debug(idx, fmt.Sprintf("%+v", e.Value)) + idx++ + } +} + +// append log info to another file +func Debug(o ...interface{}) { + f, _ := os.OpenFile("debug.tmp", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + fmt.Fprintln(f, o...) + f.Close() +} + +func CaptureExitSignal(f func()) { + cSignal := make(chan os.Signal, 1) + signal.Notify(cSignal, os.Interrupt, syscall.SIGTERM) + go func() { + for range cSignal { + f() + } + }() +} diff --git a/vendor/github.com/chzyer/readline/utils_unix.go b/vendor/github.com/chzyer/readline/utils_unix.go new file mode 100644 index 000000000..fc4949232 --- /dev/null +++ b/vendor/github.com/chzyer/readline/utils_unix.go @@ -0,0 +1,83 @@ +// +build aix darwin dragonfly freebsd linux,!appengine netbsd openbsd os400 solaris + +package readline + +import ( + "io" + "os" + "os/signal" + "sync" + "syscall" +) + +type winsize struct { + Row uint16 + Col uint16 + Xpixel uint16 + Ypixel uint16 +} + +// SuspendMe use to send suspend signal to myself, when we in the raw mode. +// For OSX it need to send to parent's pid +// For Linux it need to send to myself +func SuspendMe() { + p, _ := os.FindProcess(os.Getppid()) + p.Signal(syscall.SIGTSTP) + p, _ = os.FindProcess(os.Getpid()) + p.Signal(syscall.SIGTSTP) +} + +// get width of the terminal +func getWidth(stdoutFd int) int { + cols, _, err := GetSize(stdoutFd) + if err != nil { + return -1 + } + return cols +} + +func GetScreenWidth() int { + w := getWidth(syscall.Stdout) + if w < 0 { + w = getWidth(syscall.Stderr) + } + return w +} + +// ClearScreen clears the console screen +func ClearScreen(w io.Writer) (int, error) { + return w.Write([]byte("\033[H")) +} + +func DefaultIsTerminal() bool { + return IsTerminal(syscall.Stdin) && (IsTerminal(syscall.Stdout) || IsTerminal(syscall.Stderr)) +} + +func GetStdin() int { + return syscall.Stdin +} + +// ----------------------------------------------------------------------------- + +var ( + widthChange sync.Once + widthChangeCallback func() +) + +func DefaultOnWidthChanged(f func()) { + widthChangeCallback = f + widthChange.Do(func() { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGWINCH) + + go func() { + for { + _, ok := <-ch + if !ok { + break + } + widthChangeCallback() + } + }() + }) +} diff --git a/vendor/github.com/chzyer/readline/utils_windows.go b/vendor/github.com/chzyer/readline/utils_windows.go new file mode 100644 index 000000000..5bfa55dcc --- /dev/null +++ b/vendor/github.com/chzyer/readline/utils_windows.go @@ -0,0 +1,41 @@ +// +build windows + +package readline + +import ( + "io" + "syscall" +) + +func SuspendMe() { +} + +func GetStdin() int { + return int(syscall.Stdin) +} + +func init() { + isWindows = true +} + +// get width of the terminal +func GetScreenWidth() int { + info, _ := GetConsoleScreenBufferInfo() + if info == nil { + return -1 + } + return int(info.dwSize.x) +} + +// ClearScreen clears the console screen +func ClearScreen(_ io.Writer) error { + return SetConsoleCursorPosition(&_COORD{0, 0}) +} + +func DefaultIsTerminal() bool { + return true +} + +func DefaultOnWidthChanged(func()) { + +} diff --git a/vendor/github.com/chzyer/readline/vim.go b/vendor/github.com/chzyer/readline/vim.go new file mode 100644 index 000000000..bedf2c1a6 --- /dev/null +++ b/vendor/github.com/chzyer/readline/vim.go @@ -0,0 +1,176 @@ +package readline + +const ( + VIM_NORMAL = iota + VIM_INSERT + VIM_VISUAL +) + +type opVim struct { + cfg *Config + op *Operation + vimMode int +} + +func newVimMode(op *Operation) *opVim { + ov := &opVim{ + cfg: op.cfg, + op: op, + } + ov.SetVimMode(ov.cfg.VimMode) + return ov +} + +func (o *opVim) SetVimMode(on bool) { + if o.cfg.VimMode && !on { // turn off + o.ExitVimMode() + } + o.cfg.VimMode = on + o.vimMode = VIM_INSERT +} + +func (o *opVim) ExitVimMode() { + o.vimMode = VIM_INSERT +} + +func (o *opVim) IsEnableVimMode() bool { + return o.cfg.VimMode +} + +func (o *opVim) handleVimNormalMovement(r rune, readNext func() rune) (t rune, handled bool) { + rb := o.op.buf + handled = true + switch r { + case 'h': + t = CharBackward + case 'j': + t = CharNext + case 'k': + t = CharPrev + case 'l': + t = CharForward + case '0', '^': + rb.MoveToLineStart() + case '$': + rb.MoveToLineEnd() + case 'x': + rb.Delete() + if rb.IsCursorInEnd() { + rb.MoveBackward() + } + case 'r': + rb.Replace(readNext()) + case 'd': + next := readNext() + switch next { + case 'd': + rb.Erase() + case 'w': + rb.DeleteWord() + case 'h': + rb.Backspace() + case 'l': + rb.Delete() + } + case 'p': + rb.Yank() + case 'b', 'B': + rb.MoveToPrevWord() + case 'w', 'W': + rb.MoveToNextWord() + case 'e', 'E': + rb.MoveToEndWord() + case 'f', 'F', 't', 'T': + next := readNext() + prevChar := r == 't' || r == 'T' + reverse := r == 'F' || r == 'T' + switch next { + case CharEsc: + default: + rb.MoveTo(next, prevChar, reverse) + } + default: + return r, false + } + return t, true +} + +func (o *opVim) handleVimNormalEnterInsert(r rune, readNext func() rune) (t rune, handled bool) { + rb := o.op.buf + handled = true + switch r { + case 'i': + case 'I': + rb.MoveToLineStart() + case 'a': + rb.MoveForward() + case 'A': + rb.MoveToLineEnd() + case 's': + rb.Delete() + case 'S': + rb.Erase() + case 'c': + next := readNext() + switch next { + case 'c': + rb.Erase() + case 'w': + rb.DeleteWord() + case 'h': + rb.Backspace() + case 'l': + rb.Delete() + } + default: + return r, false + } + + o.EnterVimInsertMode() + return +} + +func (o *opVim) HandleVimNormal(r rune, readNext func() rune) (t rune) { + switch r { + case CharEnter, CharInterrupt: + o.ExitVimMode() + return r + } + + if r, handled := o.handleVimNormalMovement(r, readNext); handled { + return r + } + + if r, handled := o.handleVimNormalEnterInsert(r, readNext); handled { + return r + } + + // invalid operation + o.op.t.Bell() + return 0 +} + +func (o *opVim) EnterVimInsertMode() { + o.vimMode = VIM_INSERT +} + +func (o *opVim) ExitVimInsertMode() { + o.vimMode = VIM_NORMAL +} + +func (o *opVim) HandleVim(r rune, readNext func() rune) rune { + if o.vimMode == VIM_NORMAL { + return o.HandleVimNormal(r, readNext) + } + if r == CharEsc { + o.ExitVimInsertMode() + return 0 + } + + switch o.vimMode { + case VIM_INSERT: + return r + case VIM_VISUAL: + } + return r +} diff --git a/vendor/github.com/chzyer/readline/windows_api.go b/vendor/github.com/chzyer/readline/windows_api.go new file mode 100644 index 000000000..63f4f7b78 --- /dev/null +++ b/vendor/github.com/chzyer/readline/windows_api.go @@ -0,0 +1,152 @@ +// +build windows + +package readline + +import ( + "reflect" + "syscall" + "unsafe" +) + +var ( + kernel = NewKernel() + stdout = uintptr(syscall.Stdout) + stdin = uintptr(syscall.Stdin) +) + +type Kernel struct { + SetConsoleCursorPosition, + SetConsoleTextAttribute, + FillConsoleOutputCharacterW, + FillConsoleOutputAttribute, + ReadConsoleInputW, + GetConsoleScreenBufferInfo, + GetConsoleCursorInfo, + GetStdHandle CallFunc +} + +type short int16 +type word uint16 +type dword uint32 +type wchar uint16 + +type _COORD struct { + x short + y short +} + +func (c *_COORD) ptr() uintptr { + return uintptr(*(*int32)(unsafe.Pointer(c))) +} + +const ( + EVENT_KEY = 0x0001 + EVENT_MOUSE = 0x0002 + EVENT_WINDOW_BUFFER_SIZE = 0x0004 + EVENT_MENU = 0x0008 + EVENT_FOCUS = 0x0010 +) + +type _KEY_EVENT_RECORD struct { + bKeyDown int32 + wRepeatCount word + wVirtualKeyCode word + wVirtualScanCode word + unicodeChar wchar + dwControlKeyState dword +} + +// KEY_EVENT_RECORD KeyEvent; +// MOUSE_EVENT_RECORD MouseEvent; +// WINDOW_BUFFER_SIZE_RECORD WindowBufferSizeEvent; +// MENU_EVENT_RECORD MenuEvent; +// FOCUS_EVENT_RECORD FocusEvent; +type _INPUT_RECORD struct { + EventType word + Padding uint16 + Event [16]byte +} + +type _CONSOLE_SCREEN_BUFFER_INFO struct { + dwSize _COORD + dwCursorPosition _COORD + wAttributes word + srWindow _SMALL_RECT + dwMaximumWindowSize _COORD +} + +type _SMALL_RECT struct { + left short + top short + right short + bottom short +} + +type _CONSOLE_CURSOR_INFO struct { + dwSize dword + bVisible bool +} + +type CallFunc func(u ...uintptr) error + +func NewKernel() *Kernel { + k := &Kernel{} + kernel32 := syscall.NewLazyDLL("kernel32.dll") + v := reflect.ValueOf(k).Elem() + t := v.Type() + for i := 0; i < t.NumField(); i++ { + name := t.Field(i).Name + f := kernel32.NewProc(name) + v.Field(i).Set(reflect.ValueOf(k.Wrap(f))) + } + return k +} + +func (k *Kernel) Wrap(p *syscall.LazyProc) CallFunc { + return func(args ...uintptr) error { + var r0 uintptr + var e1 syscall.Errno + size := uintptr(len(args)) + if len(args) <= 3 { + buf := make([]uintptr, 3) + copy(buf, args) + r0, _, e1 = syscall.Syscall(p.Addr(), size, + buf[0], buf[1], buf[2]) + } else { + buf := make([]uintptr, 6) + copy(buf, args) + r0, _, e1 = syscall.Syscall6(p.Addr(), size, + buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], + ) + } + + if int(r0) == 0 { + if e1 != 0 { + return error(e1) + } else { + return syscall.EINVAL + } + } + return nil + } + +} + +func GetConsoleScreenBufferInfo() (*_CONSOLE_SCREEN_BUFFER_INFO, error) { + t := new(_CONSOLE_SCREEN_BUFFER_INFO) + err := kernel.GetConsoleScreenBufferInfo( + stdout, + uintptr(unsafe.Pointer(t)), + ) + return t, err +} + +func GetConsoleCursorInfo() (*_CONSOLE_CURSOR_INFO, error) { + t := new(_CONSOLE_CURSOR_INFO) + err := kernel.GetConsoleCursorInfo(stdout, uintptr(unsafe.Pointer(t))) + return t, err +} + +func SetConsoleCursorPosition(c *_COORD) error { + return kernel.SetConsoleCursorPosition(stdout, c.ptr()) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index d46c004aa..aead87758 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -68,6 +68,9 @@ github.com/cert-manager/cert-manager/pkg/apis/meta/v1 # github.com/cespare/xxhash/v2 v2.3.0 ## explicit; go 1.11 github.com/cespare/xxhash/v2 +# github.com/chzyer/readline v1.5.1 +## explicit; go 1.15 +github.com/chzyer/readline # github.com/cloudevents/sdk-go/observability/opencensus/v2 v2.15.2 ## explicit; go 1.18 github.com/cloudevents/sdk-go/observability/opencensus/v2/client