diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000000000000000000000000000000000000..1d3e8a1e7ae3c0cea763b5b613227d26da64670a --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,15 @@ +ratings: + paths: + - "**/*.go" + +exclude_paths: + +engines: + fixme: + enabled: true + golint: + enabled: true + govet: + enabled: true + gofmt: + enabled: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000000000000000000000000000000..4b86d7197f9a6f1a988c503defc8451392cd5e55 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Getting Help on IPFS + url: https://ipfs.io/help + about: All information about how and where to get help on IPFS. + - name: IPFS Official Forum + url: https://discuss.ipfs.io + about: Please post general questions, support requests, and discussions here. diff --git a/.github/ISSUE_TEMPLATE/open_an_issue.md b/.github/ISSUE_TEMPLATE/open_an_issue.md new file mode 100644 index 0000000000000000000000000000000000000000..4fcbd00aca0d513c2729d8df4afb5a01fdbe7d02 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/open_an_issue.md @@ -0,0 +1,19 @@ +--- +name: Open an issue +about: Only for actionable issues relevant to this repository. +title: '' +labels: need/triage +assignees: '' + +--- + diff --git a/.github/config.yml b/.github/config.yml new file mode 100644 index 0000000000000000000000000000000000000000..ed26646a0f7cdda6cf10ede2b0b98cac89cf67b0 --- /dev/null +++ b/.github/config.yml @@ -0,0 +1,68 @@ +# Configuration for welcome - https://github.com/behaviorbot/welcome + +# Configuration for new-issue-welcome - https://github.com/behaviorbot/new-issue-welcome +# Comment to be posted to on first time issues +newIssueWelcomeComment: > + Thank you for submitting your first issue to this repository! A maintainer + will be here shortly to triage and review. + + In the meantime, please double-check that you have provided all the + necessary information to make this process easy! Any information that can + help save additional round trips is useful! We currently aim to give + initial feedback within **two business days**. If this does not happen, feel + free to leave a comment. + + Please keep an eye on how this issue will be labeled, as labels give an + overview of priorities, assignments and additional actions requested by the + maintainers: + + - "Priority" labels will show how urgent this is for the team. + - "Status" labels will show if this is ready to be worked on, blocked, or in progress. + - "Need" labels will indicate if additional input or analysis is required. + + Finally, remember to use https://discuss.ipfs.io if you just need general + support. + +# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome +# Comment to be posted to on PRs from first time contributors in your repository +newPRWelcomeComment: > + Thank you for submitting this PR! + + A maintainer will be here shortly to review it. + + We are super grateful, but we are also overloaded! Help us by making sure + that: + + * The context for this PR is clear, with relevant discussion, decisions + and stakeholders linked/mentioned. + + * Your contribution itself is clear (code comments, self-review for the + rest) and in its best form. Follow the [code contribution + guidelines](https://github.com/ipfs/community/blob/master/CONTRIBUTING.md#code-contribution-guidelines) + if they apply. + + Getting other community members to do a review would be great help too on + complex PRs (you can ask in the chats/forums). If you are unsure about + something, just leave us a comment. + + Next steps: + + * A maintainer will triage and assign priority to this PR, commenting on + any missing things and potentially assigning a reviewer for high + priority items. + + * The PR gets reviews, discussed and approvals as needed. + + * The PR is merged by maintainers when it has been approved and comments addressed. + + We currently aim to provide initial feedback/triaging within **two business + days**. Please keep an eye on any labelling actions, as these will indicate + priorities and status of your contribution. + + We are very grateful for your contribution! + + +# Configuration for first-pr-merge - https://github.com/behaviorbot/first-pr-merge +# Comment to be posted to on pull requests merged by a first time user +# Currently disabled +#firstPRMergeComment: "" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..0dd366fe7e80fdba321fac087d803c0909907e13 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.*.swp +.*.swo +cover.out +c.out +cover.html +examples/adder/local/local +examples/adder/remote/client/client +examples/adder/remote/server/server +coverage.out diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..dfab88961c90fd41f90c5ab42b03a552ca342c2d --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,35 @@ +stages: + - build + - test + +variables: + BUILD_DIR: "/tmp/$CI_CONCURRENT_PROJECT_ID" + +before_script: + - mkdir -p $BUILD_DIR/src + - cd $BUILD_DIR/src + - if [ -d $CI_PROJECT_DIR ] + - then + - echo "soft link $CI_PROJECT_DIR exists" + - else + - echo "creating soft link $CI_PROJECT_DIR" + - ln -s $CI_PROJECT_DIR + - fi + - cd $CI_PROJECT_DIR + +build: + stage: build + tags: + - testing + script: + - echo $CI_JOB_STAGE + - go build + +test: + stage: test + tags: + - testing + script: + - echo $CI_JOB_STAGE + - go test -cover + coverage: '/coverage: \d+.\d+% of statements/' diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000000000000000000000000000000000..3d81468dd3012150e21a6c828998fccbf6c6ea16 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,30 @@ +os: + - linux + +language: go + +go: + - 1.14.2 + +env: + global: + - GOTFLAGS="-race" + matrix: + - BUILD_DEPTYPE=gomod + + +# disable travis install +install: + - true + +script: + - bash <(curl -s https://raw.githubusercontent.com/ipfs/ci-helpers/master/travis-ci/run-standard-tests.sh) + + +cache: + directories: + - $GOPATH/pkg/mod + - $HOME/.cache/go-build + +notifications: + email: false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..833dabb85ef9dec5dba212f672f7e9b2c267eaca --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2016 Juan Batiz-Benet + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 73a1199ec4153a9a1b27fb49fd7fb220f5fe78b1..da3ac5b9e379346bca1ba5b585a7ddc9e8d91965 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,32 @@ -# go-dms3-cmds +# go-ipfs-cmds +[![](https://img.shields.io/badge/made%20by-Protocol%20Labs-blue.svg?style=flat-square)](http://ipn.io) +[![](https://img.shields.io/badge/project-IPFS-blue.svg?style=flat-square)](http://ipfs.io/) +[![](https://img.shields.io/badge/freenode-%23ipfs-blue.svg?style=flat-square)](http://webchat.freenode.net/?channels=%23ipfs) +[![standard-readme compliant](https://img.shields.io/badge/standard--readme-OK-green.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme) + +> ipfs commands library + +cmds offers tools for describing and calling commands both locally and remotely, as well as encoding, formatting and transferring the result. It is the successor of go-ipfs/commands and contains a legacy layer such that it can handle previously defined commands. + +## Lead Maintainer + +[Steven Allen](https://github.com/Stebalien) + +## Documentation + +https://godoc.org/github.com/ipfs/go-ipfs-cmds + +## Contribute + +Feel free to join in. All welcome. Open an [issue](https://github.com/ipfs/go-ipfs-cmds/issues)! + +This repository falls under the IPFS [Code of Conduct](https://github.com/ipfs/community/blob/master/code-of-conduct.md). + +### Want to hack on IPFS? + +[![](https://cdn.rawgit.com/jbenet/contribute-ipfs-gif/master/img/contribute.gif)](https://github.com/ipfs/community/blob/master/contributing.md) + +## License + +MIT diff --git a/argument.go b/argument.go new file mode 100644 index 0000000000000000000000000000000000000000..ac68322c3b9573c2da389025d219cbbc2b95881d --- /dev/null +++ b/argument.go @@ -0,0 +1,56 @@ +package cmds + +type ArgumentType int + +const ( + ArgString ArgumentType = iota + ArgFile +) + +type Argument struct { + Name string + Type ArgumentType + Required bool // error if no value is specified + Variadic bool // unlimited values can be specfied + SupportsStdin bool // can accept stdin as a value + Recursive bool // supports recursive file adding (with '-r' flag) + Description string +} + +func StringArg(name string, required, variadic bool, description string) Argument { + return Argument{ + Name: name, + Type: ArgString, + Required: required, + Variadic: variadic, + Description: description, + } +} + +func FileArg(name string, required, variadic bool, description string) Argument { + return Argument{ + Name: name, + Type: ArgFile, + Required: required, + Variadic: variadic, + Description: description, + } +} + +// TODO: modifiers might need a different API? +// e.g. passing enum values into arg constructors variadically +// (`FileArg("file", ArgRequired, ArgStdin, ArgRecursive)`) + +func (a Argument) EnableStdin() Argument { + a.SupportsStdin = true + return a +} + +func (a Argument) EnableRecursive() Argument { + if a.Type != ArgFile { + panic("Only FileArgs can enable recursive") + } + + a.Recursive = true + return a +} diff --git a/arguments.go b/arguments.go new file mode 100644 index 0000000000000000000000000000000000000000..661a22265af2cc5f916c6181567f231abcf0f09b --- /dev/null +++ b/arguments.go @@ -0,0 +1,92 @@ +package cmds + +import ( + "bufio" + "io" +) + +// StdinArguments is used to iterate through arguments piped through stdin. +// +// It closely mimics the bufio.Scanner interface but also implements the +// ReadCloser interface. +type StdinArguments interface { + io.ReadCloser + + // Scan reads in the next argument and returns true if there is an + // argument to read. + Scan() bool + + // Argument returns the next argument. + Argument() string + + // Err returns any errors encountered when reading in arguments. + Err() error +} + +type arguments struct { + argument string + err error + reader *bufio.Reader + closer io.Closer +} + +func newArguments(r io.ReadCloser) *arguments { + return &arguments{ + reader: bufio.NewReader(r), + closer: r, + } +} + +// Read implements the io.Reader interface. +func (a *arguments) Read(b []byte) (int, error) { + return a.reader.Read(b) +} + +// Close implements the io.Closer interface. +func (a *arguments) Close() error { + return a.closer.Close() +} + +// WriteTo implements the io.WriterTo interface. +func (a *arguments) WriteTo(w io.Writer) (int64, error) { + return a.reader.WriteTo(w) +} + +// Err returns any errors encountered when reading in arguments. +func (a *arguments) Err() error { + if a.err == io.EOF { + return nil + } + return a.err +} + +// Argument returns the last argument read in. +func (a *arguments) Argument() string { + return a.argument +} + +// Scan reads in the next argument and returns true if there is an +// argument to read. +func (a *arguments) Scan() bool { + if a.err != nil { + return false + } + + s, err := a.reader.ReadString('\n') + if err != nil { + a.err = err + if err == io.EOF && len(s) > 0 { + a.argument = s + return true + } + return false + } + + l := len(s) + if l >= 2 && s[l-2] == '\r' { + a.argument = s[:l-2] + } else { + a.argument = s[:l-1] + } + return true +} diff --git a/arguments_test.go b/arguments_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e9ed47ca5479449ffcd835a95d49e5382afa031e --- /dev/null +++ b/arguments_test.go @@ -0,0 +1,112 @@ +package cmds + +import ( + "bytes" + "io/ioutil" + "testing" +) + +func TestArguments(t *testing.T) { + var testCases = []struct { + input string + arguments []string + }{ + { + input: "", + arguments: []string{}, + }, + { + input: "\n", + arguments: []string{""}, + }, + { + input: "\r\n", + arguments: []string{""}, + }, + { + input: "\r", + arguments: []string{"\r"}, + }, + { + input: "one", + arguments: []string{"one"}, + }, + { + input: "one\n", + arguments: []string{"one"}, + }, + { + input: "one\r\n", + arguments: []string{"one"}, + }, + { + input: "one\r", + arguments: []string{"one\r"}, + }, + { + input: "one\n\ntwo", + arguments: []string{"one", "", "two"}, + }, + { + input: "first\nsecond\nthird", + arguments: []string{"first", "second", "third"}, + }, + { + input: "first\r\nsecond\nthird", + arguments: []string{"first", "second", "third"}, + }, + { + input: "first\nsecond\nthird\n", + arguments: []string{"first", "second", "third"}, + }, + { + input: "first\r\nsecond\r\nthird\r\n", + arguments: []string{"first", "second", "third"}, + }, + { + input: "first\nsecond\nthird\n\n", + arguments: []string{"first", "second", "third", ""}, + }, + { + input: "\nfirst\nsecond\nthird\n", + arguments: []string{"", "first", "second", "third"}, + }, + } + + for i, tc := range testCases { + for cut := 0; cut <= len(tc.arguments); cut++ { + args := newArguments(ioutil.NopCloser(bytes.NewBufferString(tc.input))) + for j, arg := range tc.arguments[:cut] { + if !args.Scan() { + t.Errorf("in test case %d, missing argument %d", i, j) + continue + } + got := args.Argument() + if got != arg { + t.Errorf("in test case %d, expected argument %d to be %s, got %s", i, j, arg, got) + } + if args.Err() != nil { + t.Error(args.Err()) + } + } + args = newArguments(args) + // Tests stopping in the middle. + for j, arg := range tc.arguments[cut:] { + if !args.Scan() { + t.Errorf("in test case %d, missing argument %d", i, j+cut) + continue + } + got := args.Argument() + if got != arg { + t.Errorf("in test case %d, expected argument %d to be %s, got %s", i, j+cut, arg, got) + } + if args.Err() != nil { + t.Error(args.Err()) + } + } + if args.Scan() { + t.Errorf("in test case %d, got too many arguments", i) + } + } + } +} diff --git a/chan.go b/chan.go new file mode 100644 index 0000000000000000000000000000000000000000..cab248ddd8fdd92af04520ba6b9ac0c4dd2c0a85 --- /dev/null +++ b/chan.go @@ -0,0 +1,215 @@ +package cmds + +import ( + "context" + "io" + "sync" +) + +func NewChanResponsePair(req *Request) (ResponseEmitter, Response) { + r := &chanResponse{ + req: req, + ch: make(chan interface{}), + waitLen: make(chan struct{}), + closeCh: make(chan struct{}), + } + + re := (*chanResponseEmitter)(r) + + return re, r +} + +// chanStream is the struct of both the Response and ResponseEmitter. +// The methods are defined on chanResponse and chanResponseEmitter, which are +// just type definitions on chanStream. +type chanStream struct { + req *Request + + // ch is used to send values from emitter to response. + // When Emit received a channel close, it returns the error stored in err. + ch chan interface{} + + // wl is a lock for writing calls, i.e. Emit, Close(WithError) and SetLength. + wl sync.Mutex + + // closed stores whether this stream is closed. + // It is protected by wl. + closed bool + + // closeCh is closed when the stream is closed. + // Error checks if the stream has been closed by checking if this channes is closed. + // Its closing is protected by wl. + closeCh chan struct{} + + // err is the error that the stream was closed with. + // It is written once under lock wl, but only read after waitLen is closed (which also happens under wl) + err error + + // waitLen is closed when the first value is emitted or the stream is closed. + // Length waits for waitLen to be closed. + // Its closing is protected by wl. + waitLen chan struct{} + + // length is the length of the response. + // It can be set by calling SetLength, but only before the first call to Emit, Close or CloseWithError. + length uint64 +} + +type chanResponse chanStream + +func (r *chanResponse) Request() *Request { + return r.req +} + +func (r *chanResponse) Error() *Error { + select { + case <-r.closeCh: + if r.err == nil || r.err == io.EOF { + return nil + } + + if e, ok := r.err.(*Error); ok { + return e + } + + return &Error{Message: r.err.Error()} + default: + return nil + } +} + +func (r *chanResponse) Length() uint64 { + <-r.waitLen + + return r.length +} + +func (r *chanResponse) Next() (interface{}, error) { + if r == nil { + return nil, io.EOF + } + + var ctx context.Context + if rctx := r.req.Context; rctx != nil { + ctx = rctx + } else { + ctx = context.Background() + } + + select { + case v, ok := <-r.ch: + if !ok { + return nil, r.err + } + + switch val := v.(type) { + case Single: + return val.Value, nil + default: + return v, nil + } + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +type chanResponseEmitter chanResponse + +func (re *chanResponseEmitter) Emit(v interface{}) error { + // channel emission iteration + if ch, ok := v.(chan interface{}); ok { + v = (<-chan interface{})(ch) + } + if ch, isChan := v.(<-chan interface{}); isChan { + return EmitChan(re, ch) + } + + re.wl.Lock() + defer re.wl.Unlock() + + // unblock Length() + select { + case <-re.waitLen: + default: + close(re.waitLen) + } + + // make sure we check whether the stream is closed *before accessing re.ch*! + // re.ch is set to nil, but is not protected by a shared mutex (because that + // wouldn't make sense). + // re.closed is set in a critical section protected by re.wl (we also took + // that lock), so we can be sure that this check is not racy. + if re.closed { + return ErrClosedEmitter + } + + ctx := re.req.Context + + select { + case re.ch <- v: + if _, ok := v.(Single); ok { + re.closeWithError(nil) + } + + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (re *chanResponseEmitter) Close() error { + return re.CloseWithError(nil) +} + +func (re *chanResponseEmitter) SetLength(l uint64) { + re.wl.Lock() + defer re.wl.Unlock() + + // don't change value after emitting or closing + select { + case <-re.waitLen: + default: + re.length = l + } +} + +func (re *chanResponseEmitter) CloseWithError(err error) error { + re.wl.Lock() + defer re.wl.Unlock() + + if re.closed { + return ErrClosingClosedEmitter + } + + re.closeWithError(err) + return nil +} + +func (re *chanResponseEmitter) closeWithError(err error) { + re.closed = true + + if err == nil { + err = io.EOF + } + + if e, ok := err.(Error); ok { + err = &e + } + + re.err = err + close(re.ch) + + // unblock Length() + select { + case <-re.waitLen: + default: + close(re.waitLen) + } + + // make Error() return the value in res.err instead of nil + select { + case <-re.closeCh: + default: + close(re.closeCh) + } +} diff --git a/chan_test.go b/chan_test.go new file mode 100644 index 0000000000000000000000000000000000000000..84c0246d2d5cdf9b4c2cd26f884516ffae188416 --- /dev/null +++ b/chan_test.go @@ -0,0 +1,158 @@ +package cmds + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + "testing" +) + +func TestChanResponsePair(t *testing.T) { + type testcase struct { + values []interface{} + closeErr error + } + + mkTest := func(tc testcase) func(*testing.T) { + return func(t *testing.T) { + cmd := &Command{} + req, err := NewRequest(context.TODO(), nil, nil, nil, nil, cmd) + if err != nil { + t.Fatal("error building request", err) + } + re, res := NewChanResponsePair(req) + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + for _, v := range tc.values { + v2, err := res.Next() + if err != nil { + t.Error("Next returned unexpected error:", err) + } + if v != v2 { + t.Errorf("Next returned unexpected value %q, expected %q", v2, v) + } + } + + _, err := res.Next() + if tc.closeErr == nil || tc.closeErr == io.EOF { + if err == nil { + t.Error("Next returned nil error, expecting io.EOF") + } else if err != io.EOF { + t.Errorf("Next returned error %q, expecting io.EOF", err) + } + } else { + if err != tc.closeErr { + t.Errorf("Next returned error %q, expecting %q", err, tc.closeErr) + } + } + + wg.Done() + }() + + for _, v := range tc.values { + err := re.Emit(v) + if err != nil { + t.Error("Emit returned unexpected error:", err) + } + } + + re.CloseWithError(tc.closeErr) + + wg.Wait() + } + } + + tcs := []testcase{ + {values: []interface{}{1, 2, 3}}, + {values: []interface{}{1, 2, 3}, closeErr: io.EOF}, + {values: []interface{}{1, 2, 3}, closeErr: errors.New("an error occured")}, + } + + for i, tc := range tcs { + t.Run(fmt.Sprint(i), mkTest(tc)) + } +} + +func TestSingle1(t *testing.T) { + cmd := &Command{} + req, err := NewRequest(context.TODO(), nil, nil, nil, nil, cmd) + if err != nil { + t.Fatal("error building request", err) + } + re, res := NewChanResponsePair(req) + + wait := make(chan struct{}) + + go func() { + re.Emit(Single{42}) + + err := re.Close() + if err != ErrClosingClosedEmitter { + t.Errorf("expected double close error, got %v", err) + } + close(wait) + }() + + v, err := res.Next() + if err != nil { + t.Fatal(err) + } + + if v != 42 { + t.Fatal("expected 42, got", v) + } + + _, err = res.Next() + if err != io.EOF { + t.Fatal("expected EOF, got", err) + } + + <-wait +} + +func TestSingle2(t *testing.T) { + cmd := &Command{} + req, err := NewRequest(context.TODO(), nil, nil, nil, nil, cmd) + if err != nil { + t.Fatal("error building request", err) + } + re, res := NewChanResponsePair(req) + + re.Close() + go func() { + err := re.Emit(Single{42}) + if err != ErrClosedEmitter { + t.Error("expected closed emitter error, got", err) + return + } + }() + + _, err = res.Next() + if err != io.EOF { + t.Fatal("expected EOF, got", err) + } +} + +func TestDoubleClose(t *testing.T) { + cmd := &Command{} + req, err := NewRequest(context.TODO(), nil, nil, nil, nil, cmd) + if err != nil { + t.Fatal("error building request", err) + } + re, _ := NewChanResponsePair(req) + + err = re.Close() + if err != nil { + t.Fatal("unexpected error closing re:", err) + } + + err = re.Close() + if err != ErrClosingClosedEmitter { + t.Fatal("expected closed emitter error, got", err) + } +} diff --git a/channelmarshaler.go b/channelmarshaler.go new file mode 100644 index 0000000000000000000000000000000000000000..0b6b1da6c203621baf6879ce2534195745c96254 --- /dev/null +++ b/channelmarshaler.go @@ -0,0 +1,41 @@ +package cmds + +/* +import "io" + +type ChannelMarshaler struct { + Channel <-chan interface{} + Marshaler func(interface{}) (io.Reader, error) + Res Response + + reader io.Reader +} + +func (cr *ChannelMarshaler) Read(p []byte) (int, error) { + if cr.reader == nil { + val, more := <-cr.Channel + if !more { + //check error in response + if cr.Res.Error() != nil { + return 0, cr.Res.Error() + } + return 0, io.EOF + } + + r, err := cr.Marshaler(val) + if err != nil { + return 0, err + } + cr.reader = r + } + + n, err := cr.reader.Read(p) + if err != nil && err != io.EOF { + return n, err + } + if n == 0 { + cr.reader = nil + } + return n, nil +} +*/ diff --git a/ci/Jenkinsfile b/ci/Jenkinsfile new file mode 100644 index 0000000000000000000000000000000000000000..b2067e6232a4e61367aa25960899afdff1eba73c --- /dev/null +++ b/ci/Jenkinsfile @@ -0,0 +1 @@ +golang() diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000000000000000000000000000000000000..03a1d2379324320da502ba261cf7bfeb30b93382 --- /dev/null +++ b/circle.yml @@ -0,0 +1,21 @@ +machine: + environment: + IMPORT_PATH: "github.com/ipfs/go-ipfs-cmds" + GOPATH: "$HOME/.go_workspace" + + services: + - docker + +dependencies: + + override: + - rm -rf "$HOME/.go_workspace/src/$IMPORT_PATH" + - mkdir -p "$HOME/.go_workspace/src/$IMPORT_PATH" + - cp -aT . "$HOME/.go_workspace/src/$IMPORT_PATH" + - cd "$HOME/.go_workspace/src/$IMPORT_PATH" && make deps + +test: + override: + - go test -race ./...: + pwd: "../.go_workspace/src/$IMPORT_PATH" + parallel: true diff --git a/cli/cmd_suggestion.go b/cli/cmd_suggestion.go new file mode 100644 index 0000000000000000000000000000000000000000..096da4926b637b0eecce9978f23bc716db655f0f --- /dev/null +++ b/cli/cmd_suggestion.go @@ -0,0 +1,95 @@ +package cli + +import ( + "fmt" + "sort" + "strings" + + levenshtein "github.com/texttheater/golang-levenshtein/levenshtein" + cmds "gitlab.dms3.io/dms3/public/go-dms3-cmds" +) + +// Make a custom slice that can be sorted by its levenshtein value +type suggestionSlice []*suggestion + +type suggestion struct { + cmd string + levenshtein int +} + +func (s suggestionSlice) Len() int { + return len(s) +} + +func (s suggestionSlice) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +func (s suggestionSlice) Less(i, j int) bool { + return s[i].levenshtein < s[j].levenshtein +} + +func suggestUnknownCmd(args []string, root *cmds.Command) []string { + if root == nil { + return nil + } + + arg := args[0] + var suggestions []string + sortableSuggestions := make(suggestionSlice, 0) + var sFinal []string + const MinLevenshtein = 3 + + var options levenshtein.Options = levenshtein.Options{ + InsCost: 1, + DelCost: 3, + SubCost: 2, + Matches: func(sourceCharacter rune, targetCharacter rune) bool { + return sourceCharacter == targetCharacter + }, + } + + // Start with a simple strings.Contains check + for name := range root.Subcommands { + if strings.Contains(arg, name) { + suggestions = append(suggestions, name) + } + } + + // If the string compare returns a match, return + if len(suggestions) > 0 { + return suggestions + } + + for name := range root.Subcommands { + lev := levenshtein.DistanceForStrings([]rune(arg), []rune(name), options) + if lev <= MinLevenshtein { + sortableSuggestions = append(sortableSuggestions, &suggestion{name, lev}) + } + } + sort.Sort(sortableSuggestions) + + for _, j := range sortableSuggestions { + sFinal = append(sFinal, j.cmd) + } + return sFinal +} + +func printSuggestions(inputs []string, root *cmds.Command) (err error) { + + suggestions := suggestUnknownCmd(inputs, root) + + if len(suggestions) > 1 { + //lint:ignore ST1005 user facing error + err = fmt.Errorf("Unknown Command \"%s\"\n\nDid you mean any of these?\n\n\t%s", inputs[0], strings.Join(suggestions, "\n\t")) + + } else if len(suggestions) > 0 { + //lint:ignore ST1005 user facing error + err = fmt.Errorf("Unknown Command \"%s\"\n\nDid you mean this?\n\n\t%s", inputs[0], suggestions[0]) + + } else { + //lint:ignore ST1005 user facing error + err = fmt.Errorf("Unknown Command \"%s\"\n", inputs[0]) + } + return +} diff --git a/cli/error.go b/cli/error.go new file mode 100644 index 0000000000000000000000000000000000000000..92fe727878c3581a77be2bea17f2c4cd922f7ca0 --- /dev/null +++ b/cli/error.go @@ -0,0 +1,13 @@ +package cli + +import ( + "os" +) + +func isSyncNotSupportedErr(err error) bool { + perr, ok := err.(*os.PathError) + if !ok { + return false + } + return perr.Op == "sync" && isErrnoNotSupported(perr.Err) +} diff --git a/cli/error_plan9.go b/cli/error_plan9.go new file mode 100644 index 0000000000000000000000000000000000000000..aa04fe3f42b982e6ef66895f0616a46d68828779 --- /dev/null +++ b/cli/error_plan9.go @@ -0,0 +1,15 @@ +package cli + +import "syscall" + +func isErrnoNotSupported(err error) bool { + switch err { + case + // Operation not supported + syscall.EINVAL, syscall.EPLAN9, + // Sync on os.Stdin or os.Stderr returns "permission denied". + syscall.EPERM: + return true + } + return false +} diff --git a/cli/error_posix.go b/cli/error_posix.go new file mode 100644 index 0000000000000000000000000000000000000000..7ab44166d45350a74f8c0568f5d42f24d6f9d1c8 --- /dev/null +++ b/cli/error_posix.go @@ -0,0 +1,25 @@ +//+build !windows,!plan9 + +package cli + +import ( + "syscall" +) + +func isErrnoNotSupported(err error) bool { + switch err { + case + // Operation not supported + syscall.EINVAL, syscall.EROFS, syscall.ENOTSUP, + // File descriptor doesn't support syncing (found on MacOS). + syscall.ENOTTY, syscall.ENODEV, + // MacOS is weird. It returns EBADF when calling fsync on stdout + // when piped. + // + // This is never returned for, e.g., filesystem errors so + // there's nothing we can do but ignore it and continue. + syscall.EBADF: + return true + } + return false +} diff --git a/cli/error_windows.go b/cli/error_windows.go new file mode 100644 index 0000000000000000000000000000000000000000..0a2d9a3174f8f5180c3d4b393d266b063627c6a9 --- /dev/null +++ b/cli/error_windows.go @@ -0,0 +1,20 @@ +//+build windows + +package cli + +import ( + "syscall" +) + +const ( + invalid_file_handle syscall.Errno = 0x6 // console output is not buffered on this platform + invalid_handle_function syscall.Errno = 0x1 // this is specifically returned when NUL is the FlushFileBuffers target +) + +func isErrnoNotSupported(err error) bool { + switch err { + case syscall.EINVAL, syscall.ENOTSUP, syscall.ENOTTY, invalid_file_handle, invalid_handle_function: + return true + } + return false +} diff --git a/cli/helptext.go b/cli/helptext.go new file mode 100644 index 0000000000000000000000000000000000000000..8c88b33b1d0011e0775e51c32f61c41861072af6 --- /dev/null +++ b/cli/helptext.go @@ -0,0 +1,529 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "os" + "sort" + "strings" + "text/template" + + cmds "gitlab.dms3.io/dms3/public/go-dms3-cmds" + "golang.org/x/crypto/ssh/terminal" +) + +const ( + defaultTerminalWidth = 80 + requiredArg = "<%v>" + optionalArg = "[<%v>]" + variadicArg = "%v..." + shortFlag = "-%v" + longFlag = "--%v" + optionType = "(%v)" + + whitespace = "\r\n\t " + + indentStr = " " +) + +type helpFields struct { + Indent string + Usage string + Path string + Tagline string + Arguments string + Options string + Synopsis string + Subcommands string + Description string + MoreHelp bool +} + +// TrimNewlines removes extra newlines from fields. This makes aligning +// commands easier. Below, the leading + tralining newlines are removed: +// Synopsis: ` +// dms3 config - Get value of +// dms3 config - Set value of to +// dms3 config --show - Show config file +// dms3 config --edit - Edit config file in $EDITOR +// ` +func (f *helpFields) TrimNewlines() { + f.Path = strings.Trim(f.Path, "\n") + f.Usage = strings.Trim(f.Usage, "\n") + f.Tagline = strings.Trim(f.Tagline, "\n") + f.Arguments = strings.Trim(f.Arguments, "\n") + f.Options = strings.Trim(f.Options, "\n") + f.Synopsis = strings.Trim(f.Synopsis, "\n") + f.Subcommands = strings.Trim(f.Subcommands, "\n") + f.Description = strings.Trim(f.Description, "\n") +} + +// Indent adds whitespace the lines of fields. +func (f *helpFields) IndentAll() { + indent := func(s string) string { + if s == "" { + return s + } + return indentString(s, indentStr) + } + + f.Usage = indent(f.Usage) + f.Arguments = indent(f.Arguments) + f.Options = indent(f.Options) + f.Synopsis = indent(f.Synopsis) + f.Subcommands = indent(f.Subcommands) + f.Description = indent(f.Description) +} + +const longHelpFormat = `USAGE +{{.Usage}} + +{{if .Synopsis}}SYNOPSIS +{{.Synopsis}} + +{{end}}{{if .Arguments}}ARGUMENTS + +{{.Arguments}} + +{{end}}{{if .Options}}OPTIONS + +{{.Options}} + +{{end}}{{if .Description}}DESCRIPTION + +{{.Description}} + +{{end}}{{if .Subcommands}}SUBCOMMANDS +{{.Subcommands}} + +{{.Indent}}For more information about each command, use: +{{.Indent}}'{{.Path}} --help' +{{end}} +` +const shortHelpFormat = `USAGE +{{.Usage}} +{{if .Synopsis}} +{{.Synopsis}} +{{end}}{{if .Description}} +{{.Description}} +{{end}}{{if .Subcommands}} +SUBCOMMANDS +{{.Subcommands}} +{{end}}{{if .MoreHelp}} +{{.Indent}}For more information about each command, use: +{{.Indent}}'{{.Path}} --help' +{{end}} +` + +var longHelpTemplate *template.Template +var shortHelpTemplate *template.Template + +func getTerminalWidth(out io.Writer) int { + file, ok := out.(*os.File) + if ok { + if terminal.IsTerminal(int(file.Fd())) { + width, _, err := terminal.GetSize(int(file.Fd())) + if err == nil { + return width + } + } + } + return defaultTerminalWidth +} + +func init() { + longHelpTemplate = template.Must(template.New("longHelp").Parse(longHelpFormat)) + shortHelpTemplate = template.Must(template.New("shortHelp").Parse(shortHelpFormat)) +} + +// ErrNoHelpRequested returns when request for help help does not include the +// short nor the long option. +var ErrNoHelpRequested = errors.New("no help requested") + +// HandleHelp writes help to a writer for the given request's command. +func HandleHelp(appName string, req *cmds.Request, out io.Writer) error { + long, _ := req.Options[cmds.OptLongHelp].(bool) + short, _ := req.Options[cmds.OptShortHelp].(bool) + + switch { + case long: + return LongHelp(appName, req.Root, req.Path, out) + case short: + return ShortHelp(appName, req.Root, req.Path, out) + default: + return ErrNoHelpRequested + } +} + +// LongHelp writes a formatted CLI helptext string to a Writer for the given command +func LongHelp(rootName string, root *cmds.Command, path []string, out io.Writer) error { + cmd, err := root.Get(path) + if err != nil { + return err + } + + pathStr := rootName + if len(path) > 0 { + pathStr += " " + strings.Join(path, " ") + } + + fields := helpFields{ + Indent: indentStr, + Path: pathStr, + Tagline: cmd.Helptext.Tagline, + Arguments: cmd.Helptext.Arguments, + Options: cmd.Helptext.Options, + Synopsis: cmd.Helptext.Synopsis, + Subcommands: cmd.Helptext.Subcommands, + Description: cmd.Helptext.ShortDescription, + Usage: cmd.Helptext.Usage, + MoreHelp: (cmd != root), + } + + width := getTerminalWidth(out) - len(indentStr) + + if len(cmd.Helptext.LongDescription) > 0 { + fields.Description = cmd.Helptext.LongDescription + } + + // autogen fields that are empty + if len(cmd.Helptext.Usage) > 0 { + fields.Usage = cmd.Helptext.Usage + } else { + fields.Usage = commandUsageText(width, cmd, rootName, path) + } + if len(fields.Arguments) == 0 { + fields.Arguments = strings.Join(argumentText(width, cmd), "\n") + } + if len(fields.Options) == 0 { + fields.Options = strings.Join(optionText(width, cmd), "\n") + } + if len(fields.Subcommands) == 0 { + fields.Subcommands = strings.Join(subcommandText(width, cmd, rootName, path), "\n") + } + if len(fields.Synopsis) == 0 { + fields.Synopsis = generateSynopsis(width, cmd, pathStr) + } + + // trim the extra newlines (see TrimNewlines doc) + fields.TrimNewlines() + + // indent all fields that have been set + fields.IndentAll() + + return longHelpTemplate.Execute(out, fields) +} + +// ShortHelp writes a formatted CLI helptext string to a Writer for the given command +func ShortHelp(rootName string, root *cmds.Command, path []string, out io.Writer) error { + cmd, err := root.Get(path) + if err != nil { + return err + } + + // default cmd to root if there is no path + if path == nil && cmd == nil { + cmd = root + } + + pathStr := rootName + if len(path) > 0 { + pathStr += " " + strings.Join(path, " ") + } + + fields := helpFields{ + Indent: indentStr, + Path: pathStr, + Tagline: cmd.Helptext.Tagline, + Synopsis: cmd.Helptext.Synopsis, + Description: cmd.Helptext.ShortDescription, + Subcommands: cmd.Helptext.Subcommands, + MoreHelp: (cmd != root), + } + + width := getTerminalWidth(out) - len(indentStr) + + // autogen fields that are empty + if len(cmd.Helptext.Usage) > 0 { + fields.Usage = cmd.Helptext.Usage + } else { + fields.Usage = commandUsageText(width, cmd, rootName, path) + } + if len(fields.Subcommands) == 0 { + fields.Subcommands = strings.Join(subcommandText(width, cmd, rootName, path), "\n") + } + if len(fields.Synopsis) == 0 { + fields.Synopsis = generateSynopsis(width, cmd, pathStr) + } + + // trim the extra newlines (see TrimNewlines doc) + fields.TrimNewlines() + + // indent all fields that have been set + fields.IndentAll() + + return shortHelpTemplate.Execute(out, fields) +} + +func generateSynopsis(width int, cmd *cmds.Command, path string) string { + res := path + currentLineLength := len(res) + appendText := func(text string) { + if currentLineLength+len(text)+1 > width { + res += "\n" + strings.Repeat(" ", len(path)) + currentLineLength = len(path) + } + currentLineLength += len(text) + 1 + res += " " + text + } + for _, opt := range cmd.Options { + valopt, ok := cmd.Helptext.SynopsisOptionsValues[opt.Name()] + if !ok { + valopt = opt.Name() + } + sopt := "" + for i, n := range opt.Names() { + pre := "-" + if len(n) > 1 { + pre = "--" + } + if opt.Type() == cmds.Bool && opt.Default() == true { + pre = "--" + sopt = fmt.Sprintf("%s%s=false", pre, n) + break + } else { + if i == 0 { + if opt.Type() == cmds.Bool { + sopt = fmt.Sprintf("%s%s", pre, n) + } else { + sopt = fmt.Sprintf("%s%s=<%s>", pre, n, valopt) + } + } else { + sopt = fmt.Sprintf("%s | %s%s", sopt, pre, n) + } + } + } + + if opt.Type() == cmds.Strings { + appendText("[" + sopt + "]...") + } else { + appendText("[" + sopt + "]") + } + } + if len(cmd.Arguments) > 0 { + appendText("[--]") + } + for _, arg := range cmd.Arguments { + sarg := fmt.Sprintf("<%s>", arg.Name) + if arg.Variadic { + sarg = sarg + "..." + } + + if !arg.Required { + sarg = fmt.Sprintf("[%s]", sarg) + } + appendText(sarg) + } + return strings.Trim(res, " ") +} + +func argumentText(width int, cmd *cmds.Command) []string { + lines := make([]string, len(cmd.Arguments)) + + for i, arg := range cmd.Arguments { + lines[i] = argUsageText(arg) + } + lines = align(lines) + for i, arg := range cmd.Arguments { + lines[i] += " - " + lines[i] = appendWrapped(lines[i], arg.Description, width) + } + + return lines +} + +func appendWrapped(prefix, text string, width int) string { + offset := len(prefix) + bWidth := width - offset + + text = strings.Trim(text, whitespace) + // Minimum help-text width is 30 characters. + if bWidth < 30 { + prefix += text + return prefix + } + + for len(text) > bWidth { + idx := strings.LastIndexAny(text[:bWidth], whitespace) + if idx < 0 { + idx = strings.IndexAny(text, whitespace) + } + if idx < 0 { + break + } + prefix += text[:idx] + "\n" + strings.Repeat(" ", offset) + text = strings.TrimLeft(text[idx:], whitespace) + } + prefix += text + return prefix +} + +func optionFlag(flag string) string { + if len(flag) == 1 { + return fmt.Sprintf(shortFlag, flag) + } + return fmt.Sprintf(longFlag, flag) +} + +func optionText(width int, cmd ...*cmds.Command) []string { + // get a slice of the options we want to list out + options := make([]cmds.Option, 0) + for _, c := range cmd { + options = append(options, c.Options...) + } + + // add option names to output + lines := make([]string, len(options)) + for i, opt := range options { + flags := sortByLength(opt.Names()) + for j, f := range flags { + flags[j] = optionFlag(f) + } + lines[i] = strings.Join(flags, ", ") + } + lines = align(lines) + + // add option types to output + for i, opt := range options { + lines[i] += " " + fmt.Sprintf("%v", opt.Type()) + } + lines = align(lines) + + // add option descriptions to output + for i, opt := range options { + lines[i] += " - " + lines[i] = appendWrapped(lines[i], opt.Description(), width) + } + + return lines +} + +func subcommandText(width int, cmd *cmds.Command, rootName string, path []string) []string { + prefix := fmt.Sprintf("%v %v", rootName, strings.Join(path, " ")) + if len(path) > 0 { + prefix += " " + } + + // Sorting fixes changing order bug #2981. + sortedNames := make([]string, 0) + for name := range cmd.Subcommands { + sortedNames = append(sortedNames, name) + } + sort.Strings(sortedNames) + + subcmds := make([]*cmds.Command, len(cmd.Subcommands)) + lines := make([]string, len(cmd.Subcommands)) + + for i, name := range sortedNames { + sub := cmd.Subcommands[name] + usage := usageText(sub) + if len(usage) > 0 { + usage = " " + usage + } + + lines[i] = prefix + name + usage + subcmds[i] = sub + } + + lines = align(lines) + for i, sub := range subcmds { + lines[i] += " - " + lines[i] = appendWrapped(lines[i], sub.Helptext.Tagline, width) + } + + return lines +} + +func commandUsageText(width int, cmd *cmds.Command, rootName string, path []string) string { + text := fmt.Sprintf("%v %v", rootName, strings.Join(path, " ")) + argUsage := usageText(cmd) + if len(argUsage) > 0 { + text += " " + argUsage + } + text += " - " + text = appendWrapped(text, cmd.Helptext.Tagline, width) + return text +} + +func usageText(cmd *cmds.Command) string { + s := "" + for i, arg := range cmd.Arguments { + if i != 0 { + s += " " + } + s += argUsageText(arg) + } + + return s +} + +func argUsageText(arg cmds.Argument) string { + s := arg.Name + + if arg.Required { + s = fmt.Sprintf(requiredArg, s) + } else { + s = fmt.Sprintf(optionalArg, s) + } + + if arg.Variadic { + s = fmt.Sprintf(variadicArg, s) + } + + return s +} + +func align(lines []string) []string { + longest := 0 + for _, line := range lines { + length := len(line) + if length > longest { + longest = length + } + } + + for i, line := range lines { + length := len(line) + if length > 0 { + lines[i] += strings.Repeat(" ", longest-length) + } + } + + return lines +} + +func indentString(line string, prefix string) string { + return prefix + strings.Replace(line, "\n", "\n"+prefix, -1) +} + +type lengthSlice []string + +func (ls lengthSlice) Len() int { + return len(ls) +} +func (ls lengthSlice) Swap(a, b int) { + ls[a], ls[b] = ls[b], ls[a] +} +func (ls lengthSlice) Less(a, b int) bool { + return len(ls[a]) < len(ls[b]) +} + +func sortByLength(slice []string) []string { + output := make(lengthSlice, len(slice)) + for i, val := range slice { + output[i] = val + } + sort.Sort(output) + return []string(output) +} diff --git a/cli/helptext_test.go b/cli/helptext_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e59db69ddad8863a132539387bd3677f22b354bf --- /dev/null +++ b/cli/helptext_test.go @@ -0,0 +1,50 @@ +package cli + +import ( + "strings" + "testing" + + cmds "gitlab.dms3.io/dms3/public/go-dms3-cmds" +) + +func TestSynopsisGenerator(t *testing.T) { + command := &cmds.Command{ + Arguments: []cmds.Argument{ + cmds.StringArg("required", true, false, ""), + cmds.StringArg("variadic", false, true, ""), + }, + Options: []cmds.Option{ + cmds.StringOption("opt", "o", "Option"), + cmds.StringsOption("var-opt", "Variadic Option"), + }, + Helptext: cmds.HelpText{ + SynopsisOptionsValues: map[string]string{ + "opt": "OPTION", + }, + }, + } + terminalWidth := 100 + syn := generateSynopsis(terminalWidth, command, "cmd") + t.Logf("Synopsis is: %s", syn) + if !strings.HasPrefix(syn, "cmd ") { + t.Fatal("Synopsis should start with command name") + } + if !strings.Contains(syn, "[--opt=