From c622ba72beb5fceb2375c2a0f58c4e0c57ab10a9 Mon Sep 17 00:00:00 2001 From: Roberto Hidalgo Date: Sun, 18 Dec 2022 21:04:34 -0600 Subject: [PATCH] milpa, pero para golang --- .editorconfig | 13 + .golangci.yml | 40 +++ .tool-versions | 1 + LICENSE.txt | 13 + go.mod | 41 +++ go.sum | 114 +++++++++ internal/constants/constants.go | 83 +++++++ internal/constants/help.md | 70 ++++++ internal/errors/errors.go | 70 ++++++ internal/errors/handler.go | 76 ++++++ internal/exec/exec.go | 65 +++++ internal/exec/exec_test.go | 108 ++++++++ internal/registry/cobra.go | 119 +++++++++ internal/registry/registry.go | 227 +++++++++++++++++ internal/render/render.go | 70 ++++++ internal/render/render_test.go | 87 +++++++ main.go | 26 ++ pkg/command/arguments.go | 283 +++++++++++++++++++++ pkg/command/arguments_test.go | 425 ++++++++++++++++++++++++++++++++ pkg/command/command.go | 139 +++++++++++ pkg/command/help.go | 88 +++++++ pkg/command/options.go | 234 ++++++++++++++++++ pkg/command/root.go | 60 +++++ pkg/command/validation.go | 54 ++++ pkg/command/value.go | 234 ++++++++++++++++++ pkg/command/value_test.go | 164 ++++++++++++ pkg/runtime/runtime.go | 105 ++++++++ pkg/runtime/runtime_test.go | 136 ++++++++++ 28 files changed, 3145 insertions(+) create mode 100644 .editorconfig create mode 100644 .golangci.yml create mode 100644 .tool-versions create mode 100644 LICENSE.txt create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/constants/constants.go create mode 100644 internal/constants/help.md create mode 100644 internal/errors/errors.go create mode 100644 internal/errors/handler.go create mode 100644 internal/exec/exec.go create mode 100644 internal/exec/exec_test.go create mode 100644 internal/registry/cobra.go create mode 100644 internal/registry/registry.go create mode 100644 internal/render/render.go create mode 100644 internal/render/render_test.go create mode 100644 main.go create mode 100644 pkg/command/arguments.go create mode 100644 pkg/command/arguments_test.go create mode 100644 pkg/command/command.go create mode 100644 pkg/command/help.go create mode 100644 pkg/command/options.go create mode 100644 pkg/command/root.go create mode 100644 pkg/command/validation.go create mode 100644 pkg/command/value.go create mode 100644 pkg/command/value_test.go create mode 100644 pkg/runtime/runtime.go create mode 100644 pkg/runtime/runtime_test.go diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6992289 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 +max_line_length = 120 diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..fa3240e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,40 @@ +linters-settings: + gocyclo: + min-complexity: 21 + tagliatelle: + case: + rules: + yaml: kebab + +linters: + fast: false + enable: + - deadcode + - errcheck + - exportloopref + - goconst + - gocritic + - gocyclo + - godot + - gofmt + - goimports + - gosec + - gosimple + - govet + - ifshort + - ineffassign + - misspell + - nakedret + - nilerr + - prealloc + - revive + - staticcheck + - structcheck + - stylecheck + - tagliatelle + - typecheck + - unconvert + - unparam + - unused + - varcheck + - whitespace diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..67076f3 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +golang 1.18.2 diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..8f63315 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,13 @@ +Copyright © 2022 Roberto Hidalgo + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bec0f67 --- /dev/null +++ b/go.mod @@ -0,0 +1,41 @@ +module git.rob.mx/nidito/chinampa + +go 1.18 + +require ( + github.com/charmbracelet/glamour v0.6.0 + github.com/fatih/color v1.13.0 + github.com/go-playground/validator/v10 v10.11.1 + github.com/sirupsen/logrus v1.9.0 + github.com/spf13/cobra v1.6.1 + github.com/spf13/pflag v1.0.5 + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 +) + +require ( + github.com/alecthomas/chroma v0.10.0 // indirect + github.com/aymanbagabas/go-osc52 v1.0.3 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/dlclark/regexp2 v1.4.0 // indirect + github.com/go-playground/locales v0.14.0 // indirect + github.com/go-playground/universal-translator v0.18.0 // indirect + github.com/gorilla/css v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.0.1 // indirect + github.com/leodido/go-urn v1.2.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.9 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/microcosm-cc/bluemonday v1.0.21 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.13.0 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/stretchr/testify v1.7.1 // indirect + github.com/yuin/goldmark v1.5.2 // indirect + github.com/yuin/goldmark-emoji v1.0.1 // indirect + golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect + golang.org/x/net v0.0.0-20221002022538-bcab6841153b // indirect + golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 // indirect + golang.org/x/text v0.3.7 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..82edae3 --- /dev/null +++ b/go.sum @@ -0,0 +1,114 @@ +github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= +github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= +github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg= +github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc= +github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= +github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ= +github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= +github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg= +github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0= +github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= +github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU= +github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= +github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20221002022538-bcab6841153b h1:6e93nYa3hNqAvLr0pD4PN1fFS+gKzp2zAXqrnTCstqU= +golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 h1:v6hYoSR9T5oet+pMXwUWkbiVqx/63mlHjefrHmxwfeY= +golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/constants/constants.go b/internal/constants/constants.go new file mode 100644 index 0000000..f4a2215 --- /dev/null +++ b/internal/constants/constants.go @@ -0,0 +1,83 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package constants + +import ( + "strings" + "text/template" + + // Embed requires an import so the compiler knows what's up. Golint requires a comment. Gotta please em both. + _ "embed" +) + +const HelpCommandName = "help" + +// Environment Variables. +const EnvVarHelpUnstyled = "HELP_STYLE_PLAIN" +const EnvVarHelpStyle = "HELP_STYLE" +const EnvVarMilpaVerbose = "VERBOSE" +const EnvVarMilpaSilent = "SILENT" +const EnvVarMilpaUnstyled = "NO_COLOR" +const EnvVarMilpaForceColor = "COLOR" +const EnvVarValidationDisabled = "SKIP_VALIDATION" +const EnvVarDebug = "DEBUG" + +// EnvFlagNames are flags also available as environment variables. +var EnvFlagNames = map[string]string{ + "no-color": EnvVarMilpaUnstyled, + "color": EnvVarMilpaForceColor, + "silent": EnvVarMilpaSilent, + "verbose": EnvVarMilpaVerbose, + "skip-validation": EnvVarValidationDisabled, +} + +// Exit statuses +// see man sysexits || grep "#define EX" /usr/include/sysexits.h +// and https://tldp.org/LDP/abs/html/exitcodes.html +const ( + // 0 means everything is fine. + ExitStatusOk = 0 + // 42 provides answers to life, the universe and everything; also, renders help. + ExitStatusRenderHelp = 42 + // 64 bad arguments + // EX_USAGE The command was used incorrectly, e.g., with the wrong number of arguments, a bad flag, a bad syntax in a parameter, or whatever. + ExitStatusUsage = 64 + // EX_SOFTWARE An internal software error has been detected. This should be limited to non-operating system related errors as possible. + ExitStatusProgrammerError = 70 + // EX_CONFIG Something was found in an unconfigured or misconfigured state. + ExitStatusConfigError = 78 + // 127 command not found. + ExitStatusNotFound = 127 +) + +// ContextKeyRuntimeIndex is the string key used to store context in a cobra Command. +const ContextKeyRuntimeIndex = "x-chinampa-runtime-index" + +//go:embed help.md +var helpTemplateText string + +// TemplateFuncs is a FuncMap with aliases to the strings package. +var TemplateFuncs = template.FuncMap{ + "contains": strings.Contains, + "hasSuffix": strings.HasSuffix, + "hasPrefix": strings.HasPrefix, + "replace": strings.ReplaceAll, + "toUpper": strings.ToUpper, + "toLower": strings.ToLower, + "trim": strings.TrimSpace, + "trimSuffix": strings.TrimSuffix, + "trimPrefix": strings.TrimPrefix, +} + +// TemplateCommandHelp holds a template for rendering command help. +var TemplateCommandHelp = template.Must(template.New("help").Funcs(TemplateFuncs).Parse(helpTemplateText)) diff --git a/internal/constants/help.md b/internal/constants/help.md new file mode 100644 index 0000000..f884171 --- /dev/null +++ b/internal/constants/help.md @@ -0,0 +1,70 @@ +{{- if not .HTMLOutput }} +# {{ if and (not (eq .Spec.FullName "joao")) (not (eq .Command.Name "help")) }}joao {{ end }}{{ .Spec.FullName }}{{if eq .Command.Name "help"}} help{{end}} +{{- else }} +--- +description: {{ .Command.Short }} +--- +{{- end }} + +{{ .Command.Short }} + +## Usage + + ﹅{{ replace .Command.UseLine " [flags]" "" }}{{if .Command.HasAvailableSubCommands}} SUBCOMMAND{{end}}﹅ + +{{ if .Command.HasAvailableSubCommands -}} +## Subcommands + +{{ $hh := .HTMLOutput -}} +{{ range .Command.Commands -}} +{{- if (or .IsAvailableCommand (eq .Name "help")) -}} +- {{ if $hh -}} +[﹅{{ .Name }}﹅]({{.Name}}) +{{- else -}} +﹅{{ .Name }}﹅ +{{- end }} - {{.Short}} +{{ end }} +{{- end -}} +{{- end -}} + +{{- if .Spec.Arguments -}} +## Arguments + +{{ range .Spec.Arguments -}} + +- ﹅{{ .Name | toUpper }}{{ if .Variadic}}...{{ end }}﹅{{ if .Required }} _required_{{ end }} - {{ .Description }} +{{ end -}} +{{- end -}} + + +{{ if and (eq .Spec.FullName "joao") (not (eq .Command.Name "help")) }} +## Description + +{{ .Spec.Description }} +{{ end -}} +{{- if .Spec.HasAdditionalHelp }} +{{ .Spec.AdditionalHelp .HTMLOutput }} +{{ end -}} + + +{{- if .Command.HasAvailableLocalFlags}} +## Options + +{{ range $name, $opt := .Spec.Options -}} +- ﹅--{{ $name }}﹅ (_{{$opt.Type}}_): {{ trimSuffix $opt.Description "."}}.{{ if $opt.Default }} Default: _{{ $opt.Default }}_.{{ end }} +{{ end -}} +{{- end -}} + +{{- if not (eq .Spec.FullName "joao") }} +## Description + +{{ if not (eq .Command.Long "") }}{{ .Command.Long }}{{ else }}{{ .Spec.Description }}{{end}} +{{ end }} + +{{- if .Command.HasAvailableInheritedFlags }} +## Global Options + +{{ range $name, $opt := .GlobalOptions -}} +- ﹅--{{ $name }}﹅ (_{{$opt.Type}}_): {{$opt.Description}}.{{ if $opt.Default }} Default: _{{ $opt.Default }}_.{{ end }} +{{ end -}} +{{end}} diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..a40e7e4 --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,70 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package errors + +import "fmt" + +type NotFound struct { + Msg string + Group []string +} + +type BadArguments struct { + Msg string +} + +type NotExecutable struct { + Msg string +} + +type ConfigError struct { + Err error + Config string +} + +type EnvironmentError struct { + Err error +} + +type SubCommandExit struct { + Err error + ExitCode int +} + +func (err NotFound) Error() string { + return err.Msg +} + +func (err BadArguments) Error() string { + return err.Msg +} + +func (err NotExecutable) Error() string { + return err.Msg +} + +func (err SubCommandExit) Error() string { + if err.Err != nil { + return err.Err.Error() + } + + return "" +} + +func (err ConfigError) Error() string { + return fmt.Sprintf("Invalid configuration %s: %v", err.Config, err.Err) +} + +func (err EnvironmentError) Error() string { + return fmt.Sprintf("Invalid MILPA_ environment: %v", err.Err) +} diff --git a/internal/errors/handler.go b/internal/errors/handler.go new file mode 100644 index 0000000..70d0cfb --- /dev/null +++ b/internal/errors/handler.go @@ -0,0 +1,76 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package errors + +import ( + "os" + "strings" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + _c "git.rob.mx/nidito/chinampa/internal/constants" +) + +func showHelp(cmd *cobra.Command) { + if cmd.Name() != _c.HelpCommandName { + err := cmd.Help() + if err != nil { + os.Exit(_c.ExitStatusProgrammerError) + } + } +} + +func HandleCobraExit(cmd *cobra.Command, err error) { + if err == nil { + ok, err := cmd.Flags().GetBool(_c.HelpCommandName) + if cmd.Name() == _c.HelpCommandName || err == nil && ok { + os.Exit(_c.ExitStatusRenderHelp) + } + + os.Exit(42) + } + + switch tErr := err.(type) { + case SubCommandExit: + logrus.Debugf("Sub-command failed with: %s", err.Error()) + os.Exit(tErr.ExitCode) + case BadArguments: + showHelp(cmd) + logrus.Error(err) + os.Exit(_c.ExitStatusUsage) + case NotFound: + showHelp(cmd) + logrus.Error(err) + os.Exit(_c.ExitStatusNotFound) + case ConfigError: + showHelp(cmd) + logrus.Error(err) + os.Exit(_c.ExitStatusConfigError) + case EnvironmentError: + logrus.Error(err) + os.Exit(_c.ExitStatusConfigError) + default: + if strings.HasPrefix(err.Error(), "unknown command") { + showHelp(cmd) + os.Exit(_c.ExitStatusNotFound) + } else if strings.HasPrefix(err.Error(), "unknown flag") || strings.HasPrefix(err.Error(), "unknown shorthand flag") { + showHelp(cmd) + logrus.Error(err) + os.Exit(_c.ExitStatusUsage) + } + } + + logrus.Errorf("Unknown error: %s", err) + os.Exit(2) +} diff --git a/internal/exec/exec.go b/internal/exec/exec.go new file mode 100644 index 0000000..21cc9a2 --- /dev/null +++ b/internal/exec/exec.go @@ -0,0 +1,65 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package exec + +import ( + "bytes" + "context" + "fmt" + os_exec "os/exec" + "strings" + "time" + + "git.rob.mx/nidito/chinampa/internal/errors" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +// ExecFunc is replaced in tests. +var ExecFunc = WithSubshell + +func WithSubshell(ctx context.Context, env []string, executable string, args ...string) (bytes.Buffer, bytes.Buffer, error) { + cmd := os_exec.CommandContext(ctx, executable, args...) // #nosec G204 + var stdout bytes.Buffer + cmd.Stdout = &stdout + var stderr bytes.Buffer + cmd.Stderr = &stderr + cmd.Env = env + return stdout, stderr, cmd.Run() +} + +// Exec runs a subprocess and returns a list of lines from stdout. +func Exec(name string, args []string, env []string, timeout time.Duration) ([]string, cobra.ShellCompDirective, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() // The cancel should be deferred so resources are cleaned up + + logrus.Debugf("executing %s", args) + executable := args[0] + args = args[1:] + + stdout, _, err := ExecFunc(ctx, env, executable, args...) + + if ctx.Err() == context.DeadlineExceeded { + fmt.Println("Sub-command timed out") + logrus.Debugf("timeout running %s %s: %s", executable, args, stdout.String()) + return []string{}, cobra.ShellCompDirectiveError, fmt.Errorf("timed out resolving %s %s", executable, args) + } + + if err != nil { + logrus.Debugf("error running %s %s: %s", executable, args, err) + return []string{}, cobra.ShellCompDirectiveError, errors.BadArguments{Msg: fmt.Sprintf("could not validate argument for command %s, ran <%s %s> failed: %s", name, executable, strings.Join(args, " "), err)} + } + + logrus.Debugf("done running %s %s: %s", executable, args, stdout.String()) + return strings.Split(strings.TrimSuffix(stdout.String(), "\n"), "\n"), cobra.ShellCompDirectiveDefault, nil +} diff --git a/internal/exec/exec_test.go b/internal/exec/exec_test.go new file mode 100644 index 0000000..79cee0d --- /dev/null +++ b/internal/exec/exec_test.go @@ -0,0 +1,108 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package exec_test + +import ( + "bytes" + "context" + "fmt" + "strings" + "testing" + "time" + + . "git.rob.mx/nidito/chinampa/internal/exec" + "github.com/spf13/cobra" +) + +func TestSubshellExec(t *testing.T) { + ExecFunc = WithSubshell + stdout, directive, err := Exec("test-command", []string{"bash", "-c", `echo "stdout"; echo "stderr" >&2;`}, []string{}, 1*time.Second) + if err != nil { + t.Fatalf("good subshell errored: %v", err) + } + + if len(stdout) != 1 && stdout[0] == "stdout" { + t.Fatalf("good subshell returned wrong stdout: %v", stdout) + } + + if directive != cobra.ShellCompDirectiveDefault { + t.Fatalf("good subshell returned wrong directive: %v", directive) + } + + stdout, directive, err = Exec("test-command", []string{"bash", "-c", `echo "stdout"; echo "stderr" >&2; exit 2`}, []string{}, 1*time.Second) + if err == nil { + t.Fatalf("bad subshell did not error; stdout: %v", stdout) + } + + if len(stdout) != 0 { + t.Fatalf("bad subshell returned non-empty stdout: %v", stdout) + } + + if directive != cobra.ShellCompDirectiveError { + t.Fatalf("bad subshell returned wrong directive: %v", directive) + } +} + +func TestExecTimesOut(t *testing.T) { + ExecFunc = func(ctx context.Context, env []string, executable string, args ...string) (bytes.Buffer, bytes.Buffer, error) { + time.Sleep(100 * time.Nanosecond) + return bytes.Buffer{}, bytes.Buffer{}, context.DeadlineExceeded + } + _, _, err := Exec("test-command", []string{"bash", "-c", "sleep", "2"}, []string{}, 10*time.Nanosecond) + if err == nil { + t.Fatalf("timeout didn't happen after 10ms: %v", err) + } +} + +func TestExecWorksFine(t *testing.T) { + ExecFunc = func(ctx context.Context, env []string, executable string, args ...string) (bytes.Buffer, bytes.Buffer, error) { + var out bytes.Buffer + fmt.Fprint(&out, strings.Join([]string{ + "a", + "b", + "c", + }, "\n")) + return out, bytes.Buffer{}, nil + } + args := []string{"a", "b", "c"} + res, directive, err := Exec("test-command", append([]string{"bash", "-c", "echo"}, args...), []string{}, 1*time.Second) + if err != nil { + t.Fatalf("good command failed: %v", err) + } + + if directive != 0 { + t.Fatalf("good command resulted in wrong directive, expected %d, got %d", 0, directive) + } + + if strings.Join(args, "-") != strings.Join(res, "-") { + t.Fatalf("good command resulted in wrong results, expected %v, got %v", res, args) + } +} + +func TestExecErrors(t *testing.T) { + ExecFunc = func(ctx context.Context, env []string, executable string, args ...string) (bytes.Buffer, bytes.Buffer, error) { + return bytes.Buffer{}, bytes.Buffer{}, fmt.Errorf("bad command is bad") + } + res, directive, err := Exec("test-command", []string{"bash", "-c", "bad-command"}, []string{}, 1*time.Second) + if err == fmt.Errorf("bad command is bad") { + t.Fatalf("bad command didn't fail: %v", res) + } + + if directive != cobra.ShellCompDirectiveError { + t.Fatalf("bad command resulted in wrong directive, expected %d, got %d", cobra.ShellCompDirectiveError, directive) + } + + if len(res) > 0 { + t.Fatalf("bad command returned values, got %v", res) + } +} diff --git a/internal/registry/cobra.go b/internal/registry/cobra.go new file mode 100644 index 0000000..3e8e677 --- /dev/null +++ b/internal/registry/cobra.go @@ -0,0 +1,119 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package registry + +import ( + "fmt" + "strings" + + _c "git.rob.mx/nidito/chinampa/internal/constants" + "git.rob.mx/nidito/chinampa/internal/errors" + "git.rob.mx/nidito/chinampa/pkg/command" + "git.rob.mx/nidito/chinampa/pkg/runtime" + "github.com/fatih/color" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var ccRoot = &cobra.Command{ + Use: "joao [--silent|-v|--verbose] [--[no-]color] [-h|--help] [--version]", + Annotations: map[string]string{ + _c.ContextKeyRuntimeIndex: "joao", + }, + DisableAutoGenTag: true, + SilenceUsage: true, + SilenceErrors: true, + ValidArgs: []string{""}, + Args: func(cmd *cobra.Command, args []string) error { + err := cobra.OnlyValidArgs(cmd, args) + if err != nil { + + suggestions := []string{} + bold := color.New(color.Bold) + for _, l := range cmd.SuggestionsFor(args[len(args)-1]) { + suggestions = append(suggestions, bold.Sprint(l)) + } + errMessage := fmt.Sprintf("Unknown subcommand %s", bold.Sprint(strings.Join(args, " "))) + if len(suggestions) > 0 { + errMessage += ". Perhaps you meant " + strings.Join(suggestions, ", ") + "?" + } + return errors.NotFound{Msg: errMessage, Group: []string{}} + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if ok, err := cmd.Flags().GetBool("version"); err == nil && ok { + _, err := cmd.OutOrStdout().Write([]byte(cmd.Root().Annotations["version"])) + return err + } + return errors.NotFound{Msg: "No subcommand provided", Group: []string{}} + } + + return nil + }, +} + +func toCobra(cmd *command.Command, globalOptions command.Options) *cobra.Command { + localName := cmd.Name() + useSpec := []string{localName, "[options]"} + for _, arg := range cmd.Arguments { + useSpec = append(useSpec, arg.ToDesc()) + } + + cc := &cobra.Command{ + Use: strings.Join(useSpec, " "), + Short: cmd.Summary, + DisableAutoGenTag: true, + SilenceUsage: true, + SilenceErrors: true, + Annotations: map[string]string{ + _c.ContextKeyRuntimeIndex: cmd.FullName(), + }, + Args: func(cc *cobra.Command, supplied []string) error { + skipValidation, _ := cc.Flags().GetBool("skip-validation") + if !skipValidation && runtime.ValidationEnabled() { + cmd.Arguments.Parse(supplied) + return cmd.Arguments.AreValid() + } + return nil + }, + RunE: cmd.Run, + } + + cc.SetFlagErrorFunc(func(c *cobra.Command, e error) error { + return errors.BadArguments{Msg: e.Error()} + }) + + cc.ValidArgsFunction = cmd.Arguments.CompletionFunction + + cc.Flags().AddFlagSet(cmd.FlagSet()) + + for name, opt := range cmd.Options { + if err := cc.RegisterFlagCompletionFunc(name, opt.CompletionFunction); err != nil { + logrus.Errorf("Failed setting up autocompletion for option <%s> of command <%s>", name, cmd.FullName()) + } + } + + cc.SetHelpFunc(cmd.HelpRenderer(globalOptions)) + cmd.SetCobra(cc) + return cc +} + +func fromCobra(cc *cobra.Command) *command.Command { + rtidx, hasAnnotation := cc.Annotations[_c.ContextKeyRuntimeIndex] + if hasAnnotation { + return Get(rtidx) + } + return nil +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go new file mode 100644 index 0000000..a025d44 --- /dev/null +++ b/internal/registry/registry.go @@ -0,0 +1,227 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package registry + +import ( + "fmt" + "os" + "sort" + "strings" + + _c "git.rob.mx/nidito/chinampa/internal/constants" + "git.rob.mx/nidito/chinampa/internal/errors" + "git.rob.mx/nidito/chinampa/pkg/command" + "github.com/fatih/color" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var registry = &CommandRegistry{ + kv: map[string]*command.Command{}, +} + +type ByPath []*command.Command + +func (cmds ByPath) Len() int { return len(cmds) } +func (cmds ByPath) Swap(i, j int) { cmds[i], cmds[j] = cmds[j], cmds[i] } +func (cmds ByPath) Less(i, j int) bool { return cmds[i].FullName() < cmds[j].FullName() } + +type CommandTree struct { + Command *command.Command `json:"command"` + Children []*CommandTree `json:"children"` +} + +func (t *CommandTree) Traverse(fn func(cmd *command.Command) error) error { + for _, child := range t.Children { + if err := fn(child.Command); err != nil { + return err + } + + if err := child.Traverse(fn); err != nil { + return err + } + } + return nil +} + +type CommandRegistry struct { + kv map[string]*command.Command + byPath []*command.Command + tree *CommandTree +} + +func Register(cmd *command.Command) { + logrus.Debugf("Registering %s", cmd.FullName()) + registry.kv[cmd.FullName()] = cmd +} + +func Get(id string) *command.Command { + return registry.kv[id] +} + +func CommandList() []*command.Command { + if len(registry.byPath) == 0 { + list := []*command.Command{} + for _, v := range registry.kv { + list = append(list, v) + } + sort.Sort(ByPath(list)) + registry.byPath = list + } + + return registry.byPath +} + +func BuildTree(cc *cobra.Command, depth int) { + tree := &CommandTree{ + Command: fromCobra(cc), + Children: []*CommandTree{}, + } + + var populateTree func(cmd *cobra.Command, ct *CommandTree, maxDepth int, depth int) + populateTree = func(cmd *cobra.Command, ct *CommandTree, maxDepth int, depth int) { + newDepth := depth + 1 + for _, subcc := range cmd.Commands() { + if subcc.Hidden { + continue + } + + if cmd := fromCobra(subcc); cmd != nil { + leaf := &CommandTree{Children: []*CommandTree{}} + leaf.Command = cmd + ct.Children = append(ct.Children, leaf) + + if newDepth < maxDepth { + populateTree(subcc, leaf, maxDepth, newDepth) + } + } + } + } + populateTree(cc, tree, depth, 0) + + registry.tree = tree +} + +func SerializeTree(serializationFn func(any) ([]byte, error)) (string, error) { + bytes, err := serializationFn(registry.tree) + if err != nil { + return "", err + } + return string(bytes), nil +} + +func ChildrenNames() []string { + if registry.tree == nil { + return []string{} + } + + ret := make([]string, len(registry.tree.Children)) + for idx, cmd := range registry.tree.Children { + ret[idx] = cmd.Command.Name() + } + return ret +} + +func Execute(version string) error { + cmdRoot := command.Root + ccRoot.Short = cmdRoot.Summary + ccRoot.Long = cmdRoot.Description + ccRoot.Annotations["version"] = version + ccRoot.CompletionOptions.HiddenDefaultCmd = true + ccRoot.Flags().AddFlagSet(cmdRoot.FlagSet()) + + for name, opt := range cmdRoot.Options { + if err := ccRoot.RegisterFlagCompletionFunc(name, opt.CompletionFunction); err != nil { + logrus.Errorf("Failed setting up autocompletion for option <%s> of command <%s>", name, cmdRoot.FullName()) + } + } + ccRoot.SetHelpFunc(cmdRoot.HelpRenderer(cmdRoot.Options)) + + for _, cmd := range CommandList() { + cmd := cmd + leaf := toCobra(cmd, cmdRoot.Options) + container := ccRoot + for idx, cp := range cmd.Path { + if idx == len(cmd.Path)-1 { + // logrus.Debugf("adding command %s to %s", leaf.Name(), cmd.Path[0:idx]) + container.AddCommand(leaf) + break + } + + query := []string{cp} + if cc, _, err := container.Find(query); err == nil && cc != container { + container = cc + } else { + groupName := strings.Join(query, " ") + groupPath := append(cmd.Path[0:idx], query...) // nolint:gocritic + cc := &cobra.Command{ + Use: cp, + Short: fmt.Sprintf("%s subcommands", groupName), + DisableAutoGenTag: true, + SuggestionsMinimumDistance: 2, + SilenceUsage: true, + SilenceErrors: true, + Annotations: map[string]string{ + _c.ContextKeyRuntimeIndex: strings.Join(groupPath, " "), + }, + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.OnlyValidArgs(cmd, args); err == nil { + return nil + } + + suggestions := []string{} + bold := color.New(color.Bold) + for _, l := range cmd.SuggestionsFor(args[len(args)-1]) { + suggestions = append(suggestions, bold.Sprint(l)) + } + last := len(args) - 1 + parent := cmd.CommandPath() + errMessage := fmt.Sprintf("Unknown subcommand %s of known command %s", bold.Sprint(args[last]), bold.Sprint(parent)) + if len(suggestions) > 0 { + errMessage += ". Perhaps you meant " + strings.Join(suggestions, ", ") + "?" + } + return errors.NotFound{Msg: errMessage, Group: []string{}} + }, + ValidArgs: []string{""}, + RunE: func(cc *cobra.Command, args []string) error { + if len(args) == 0 { + return errors.NotFound{Msg: "No subcommand provided", Group: []string{}} + } + os.Exit(_c.ExitStatusNotFound) + return nil + }, + } + + groupParent := &command.Command{ + Path: cmd.Path[0 : len(cmd.Path)-1], + Summary: fmt.Sprintf("%s subcommands", groupName), + Description: fmt.Sprintf("Runs subcommands within %s", groupName), + Arguments: command.Arguments{}, + Options: command.Options{}, + } + Register(groupParent) + cc.SetHelpFunc(groupParent.HelpRenderer(command.Options{})) + container.AddCommand(cc) + container = cc + } + } + } + cmdRoot.SetCobra(ccRoot) + + err := ccRoot.Execute() + if err != nil { + errors.HandleCobraExit(ccRoot, err) + } + + return err +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..c81168e --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,70 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package render + +import ( + "bytes" + "os" + + _c "git.rob.mx/nidito/chinampa/internal/constants" + "git.rob.mx/nidito/chinampa/pkg/runtime" + "github.com/charmbracelet/glamour" + "github.com/sirupsen/logrus" + "golang.org/x/term" +) + +func addBackticks(str []byte) []byte { + return bytes.ReplaceAll(str, []byte("﹅"), []byte("`")) +} + +func Markdown(content []byte, withColor bool) ([]byte, error) { + content = addBackticks(content) + + if runtime.UnstyledHelpEnabled() { + return content, nil + } + + width, _, err := term.GetSize(0) + if err != nil { + logrus.Debugf("Could not get terminal width") + width = 80 + } + + var styleFunc glamour.TermRendererOption + + if withColor { + style := os.Getenv(_c.EnvVarHelpStyle) + switch style { + case "dark": + styleFunc = glamour.WithStandardStyle("dark") + case "light": + styleFunc = glamour.WithStandardStyle("light") + default: + styleFunc = glamour.WithStandardStyle("auto") + } + } else { + styleFunc = glamour.WithStandardStyle("notty") + } + + renderer, err := glamour.NewTermRenderer( + styleFunc, + glamour.WithEmoji(), + glamour.WithWordWrap(width), + ) + + if err != nil { + return content, err + } + + return renderer.RenderBytes(content) +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..46e5b2c --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,87 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package render_test + +import ( + "fmt" + "os" + "reflect" + "testing" + + _c "git.rob.mx/nidito/chinampa/internal/constants" + "git.rob.mx/nidito/chinampa/internal/render" +) + +func TestMarkdownUnstyled(t *testing.T) { + content := []byte("# hello") + os.Setenv(_c.EnvVarHelpUnstyled, "true") + res, err := render.Markdown(content, false) + + if err != nil { + t.Fatalf("Unexpected error %s", err) + } + + expected := []byte("# hello") // nolint:ifshort + if !reflect.DeepEqual(res, expected) { + t.Fatalf("Unexpected response ---\n%s\n---\n wanted:\n---\n%s\n---", res, expected) + } +} + +func TestMarkdownNoColor(t *testing.T) { + os.Unsetenv(_c.EnvVarHelpUnstyled) + content := []byte("# hello ﹅world﹅") + res, err := render.Markdown(content, false) + + if err != nil { + t.Fatalf("Unexpected error %s", err) + } + + // account for 80 character width word wrapping + // our string is 15 characters, there's 2 for padding at the start + spaces := " " + + expected := []byte("\n # hello `world`" + spaces + "\n\n") // nolint:ifshort + if !reflect.DeepEqual(res, expected) { + t.Fatalf("Unexpected response ---\n%s\n---\n wanted:\n---\n%s\n---", res, expected) + } +} + +var autoStyleTestRender = "\n\x1b[38;5;228;48;5;63;1m\x1b[0m\x1b[38;5;228;48;5;63;1m\x1b[0m \x1b[38;5;228;48;5;63;1m \x1b[0m\x1b[38;5;228;48;5;63;1mhello\x1b[0m\x1b[38;5;228;48;5;63;1m \x1b[0m\x1b[38;5;252m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[0m\n\x1b[0m\n" + +const lightStyleTestRender = "\n\x1b[38;5;228;48;5;63;1m\x1b[0m\x1b[38;5;228;48;5;63;1m\x1b[0m \x1b[38;5;228;48;5;63;1m \x1b[0m\x1b[38;5;228;48;5;63;1mhello\x1b[0m\x1b[38;5;228;48;5;63;1m \x1b[0m\x1b[38;5;234m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[0m\n\x1b[0m\n" + +func TestMarkdownColor(t *testing.T) { + os.Unsetenv(_c.EnvVarHelpUnstyled) + content := []byte("# hello") + + styles := map[string][]byte{ + "": []byte(autoStyleTestRender), + "dark": []byte(autoStyleTestRender), + "auto": []byte(autoStyleTestRender), + "light": []byte(lightStyleTestRender), + } + for style, expected := range styles { + t.Run(fmt.Sprintf("style %s", style), func(t *testing.T) { + os.Setenv(_c.EnvVarHelpStyle, style) + res, err := render.Markdown(content, true) + + if err != nil { + t.Fatalf("Unexpected error %s", err) + } + + if !reflect.DeepEqual(res, expected) { + t.Fatalf("Unexpected response ---\n%v\n---\n wanted:\n---\n%v\n---", res, expected) + } + }) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f033530 --- /dev/null +++ b/main.go @@ -0,0 +1,26 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package chinampa + +import ( + "git.rob.mx/nidito/chinampa/internal/registry" + "git.rob.mx/nidito/chinampa/pkg/command" +) + +func Register(cmd *command.Command) { + registry.Register(cmd) +} + +func Execute(version string) error { + return registry.Execute(version) +} diff --git a/pkg/command/arguments.go b/pkg/command/arguments.go new file mode 100644 index 0000000..557d98d --- /dev/null +++ b/pkg/command/arguments.go @@ -0,0 +1,283 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package command + +import ( + "fmt" + "strings" + + "git.rob.mx/nidito/chinampa/internal/errors" + "github.com/spf13/cobra" +) + +func contains(haystack []string, needle string) bool { + for _, validValue := range haystack { + if needle == validValue { + return true + } + } + return false +} + +// Arguments is an ordered list of Argument. +type Arguments []*Argument + +func (args *Arguments) AllKnown() map[string]any { + col := map[string]any{} + for _, arg := range *args { + col[arg.Name] = arg.ToValue() + } + return col +} + +func (args *Arguments) AllKnownStr() map[string]string { + col := map[string]string{} + for _, arg := range *args { + col[arg.Name] = arg.ToString() + } + return col +} + +func (args *Arguments) Parse(supplied []string) { + for idx, arg := range *args { + argumentProvided := idx < len(supplied) + + if !argumentProvided { + if arg.Default != nil { + if arg.Variadic { + defaultSlice := []string{} + for _, valI := range arg.Default.([]any) { + defaultSlice = append(defaultSlice, valI.(string)) + } + arg.provided = &defaultSlice + } else { + defaultString := arg.Default.(string) + if defaultString != "" { + arg.provided = &[]string{defaultString} + } + } + } + continue + } + + if arg.Variadic { + values := append([]string{}, supplied[idx:]...) + arg.SetValue(values) + } else { + arg.SetValue([]string{supplied[idx]}) + } + } +} + +func (args *Arguments) AreValid() error { + for _, arg := range *args { + if err := arg.Validate(); err != nil { + return err + } + } + + return nil +} + +// CompletionFunction is called by cobra when asked to complete arguments. +func (args *Arguments) CompletionFunction(cc *cobra.Command, provided []string, toComplete string) ([]string, cobra.ShellCompDirective) { + expectedArgLen := len(*args) + values := []string{} + directive := cobra.ShellCompDirectiveError + + if expectedArgLen > 0 { + argsCompleted := len(provided) + lastArg := (*args)[len(*args)-1] + hasVariadicArg := expectedArgLen > 0 && lastArg.Variadic + lastArg.Command.Options.Parse(cc.Flags()) + args.Parse(provided) + + directive = cobra.ShellCompDirectiveDefault + if argsCompleted < expectedArgLen || hasVariadicArg { + var arg *Argument + if hasVariadicArg && argsCompleted >= expectedArgLen { + // completing a variadic argument + arg = lastArg + } else { + // completing regular argument (maybe variadic!) + arg = (*args)[argsCompleted] + } + + if arg.Values != nil { + var err error + arg.Values.command = lastArg.Command + arg.Command = lastArg.Command + values, directive, err = arg.Resolve(toComplete) + if err != nil { + return []string{err.Error()}, cobra.ShellCompDirectiveDefault + } + } else { + directive = cobra.ShellCompDirectiveError + } + values = cobra.AppendActiveHelp(values, arg.Description) + } + + if toComplete != "" { + filtered := []string{} + for _, value := range values { + if strings.HasPrefix(value, toComplete) { + filtered = append(filtered, value) + } + } + values = filtered + } + } + + return values, directive +} + +// Argument represents a single command-line argument. +type Argument struct { + // Name is how this variable will be exposed to the underlying command. + Name string `json:"name" yaml:"name" validate:"required,excludesall=!$\\/%^@#?:'\""` + // Description is what this argument is for. + Description string `json:"description" yaml:"description" validate:"required"` + // Default is the default value for this argument if none is provided. + Default any `json:"default,omitempty" yaml:"default,omitempty" validate:"excluded_with=Required"` + // Variadic makes an argument a list of all values from this one on. + Variadic bool `json:"variadic" yaml:"variadic"` + // Required raises an error if an argument is not provided. + Required bool `json:"required" yaml:"required" validate:"excluded_with=Default"` + // Values describes autocompletion and validation for an argument + Values *ValueSource `json:"values,omitempty" yaml:"values" validate:"omitempty"` + Command *Command `json:"-" yaml:"-" validate:"-"` + provided *[]string +} + +func (arg *Argument) EnvName() string { + return strings.ToUpper(strings.ReplaceAll(arg.Name, "-", "_")) +} + +func (arg *Argument) SetValue(value []string) { + arg.provided = &value +} + +func (arg *Argument) IsKnown() bool { + return arg.provided != nil && len(*arg.provided) > 0 +} + +func (arg *Argument) ToString() string { + val := arg.ToValue() + + if arg.Variadic { + val := val.([]string) + return strings.Join(val, " ") + } + + return val.(string) +} + +func (arg *Argument) ToValue() any { + var value any + if arg.IsKnown() { + if arg.Variadic { + value = *arg.provided + } else { + vals := *arg.provided + value = vals[0] + } + } else { + if arg.Default != nil { + if arg.Variadic { + defaultSlice := []string{} + for _, valI := range arg.Default.([]any) { + valStr := valI.(string) + defaultSlice = append(defaultSlice, valStr) + } + + value = defaultSlice + } else { + value = arg.Default.(string) + } + } else { + if arg.Variadic { + value = []string{} + } else { + value = "" + } + } + } + + return value +} + +func (arg *Argument) Validate() error { + if !arg.IsKnown() { + if arg.Required { + return errors.BadArguments{Msg: fmt.Sprintf("Missing argument for %s", strings.ToUpper(arg.Name))} + } + + return nil + } + + if !arg.Validates() { + return nil + } + + validValues, _, err := arg.Resolve(strings.Join(*arg.provided, " ")) + if err != nil { + return err + } + + if arg.Variadic { + for _, current := range *arg.provided { + if !contains(validValues, current) { + return errors.BadArguments{Msg: fmt.Sprintf("%s is not a valid value for argument <%s>. Valid options are: %s", current, arg.Name, strings.Join(validValues, ", "))} + } + } + } else { + current := arg.ToValue().(string) + if !contains(validValues, current) { + return errors.BadArguments{Msg: fmt.Sprintf("%s is not a valid value for argument <%s>. Valid options are: %s", current, arg.Name, strings.Join(validValues, ", "))} + } + } + + return nil +} + +// Validates tells if the user-supplied value needs validation. +func (arg *Argument) Validates() bool { + return arg.Values != nil && arg.Values.Validates() +} + +// ToDesc prints out the description of an argument for help and docs. +func (arg *Argument) ToDesc() string { + spec := arg.EnvName() + if arg.Variadic { + spec = fmt.Sprintf("%s...", spec) + } + + if !arg.Required { + spec = fmt.Sprintf("[%s]", spec) + } + + return spec +} + +// Resolve returns autocomplete values for an argument. +func (arg *Argument) Resolve(current string) (values []string, flag cobra.ShellCompDirective, err error) { + if arg.Values != nil { + values, flag, err = arg.Values.Resolve(current) + if err != nil { + flag = cobra.ShellCompDirectiveError + return + } + } + + return +} diff --git a/pkg/command/arguments_test.go b/pkg/command/arguments_test.go new file mode 100644 index 0000000..ee433aa --- /dev/null +++ b/pkg/command/arguments_test.go @@ -0,0 +1,425 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package command_test + +import ( + "reflect" + "strings" + "testing" + + . "git.rob.mx/nidito/chinampa/pkg/command" +) + +func testCommand() *Command { + return (&Command{ + Arguments: []*Argument{ + { + Name: "first", + Default: "default", + }, + { + Name: "variadic", + Default: []any{"defaultVariadic0", "defaultVariadic1"}, + Variadic: true, + }, + }, + Options: Options{ + "option": { + Default: "default", + Type: "string", + }, + "bool": { + Type: "bool", + Default: false, + }, + }, + }).SetBindings() +} + +func TestParse(t *testing.T) { + cmd := testCommand() + cmd.Arguments.Parse([]string{"asdf", "one", "two", "three"}) + known := cmd.Arguments.AllKnown() + + if !cmd.Arguments[0].IsKnown() { + t.Fatalf("first argument isn't known") + } + val, exists := known["first"] + if !exists { + t.Fatalf("first argument isn't on AllKnown map: %v", known) + } + + if val != "asdf" { + t.Fatalf("first argument does not match. expected: %s, got %s", "asdf", val) + } + + if !cmd.Arguments[1].IsKnown() { + t.Fatalf("variadic argument isn't known") + } + val, exists = known["variadic"] + if !exists { + t.Fatalf("variadic argument isn't on AllKnown map: %v", known) + } + + if !reflect.DeepEqual(val, []string{"one", "two", "three"}) { + t.Fatalf("Known argument does not match. expected: %s, got %s", "one two three", val) + } + + cmd = testCommand() + cmd.Arguments.Parse([]string{"asdf"}) + known = cmd.Arguments.AllKnown() + + if !cmd.Arguments[0].IsKnown() { + t.Fatalf("first argument is not known") + } + + val, exists = known["first"] + if !exists { + t.Fatalf("first argument isn't on AllKnown map: %v", known) + } + + if val != "asdf" { + t.Fatalf("first argument does not match. expected: %s, got %s", "asdf", val) + } + + val, exists = known["variadic"] + if !exists { + t.Fatalf("variadic argument isn't on AllKnown map: %v", known) + } + + expected := []string{"defaultVariadic0", "defaultVariadic1"} + if !reflect.DeepEqual(val, expected) { + t.Fatalf("variadic argument does not match. expected: %s, got %s", expected, val) + } +} + +func TestBeforeParse(t *testing.T) { + cmd := testCommand() + known := cmd.Arguments.AllKnown() + + if cmd.Arguments[0].IsKnown() { + t.Fatalf("first argument is known") + } + + val, exists := known["first"] + if !exists { + t.Fatalf("first argument isn't on AllKnown map: %v", known) + } + + if val != "default" { + t.Fatalf("first argument does not match. expected: %s, got %s", "asdf", val) + } + + val, exists = known["variadic"] + if !exists { + t.Fatalf("variadic argument isn't on AllKnown map: %v", known) + } + + expected := []string{"defaultVariadic0", "defaultVariadic1"} + if !reflect.DeepEqual(val, expected) { + t.Fatalf("variadic argument does not match. expected: %s, got %s", expected, val) + } +} + +func TestArgumentsValidate(t *testing.T) { + staticArgument := func(name string, def string, values []string, variadic bool) *Argument { + return &Argument{ + Name: name, + Default: def, + Variadic: variadic, + Required: def == "", + Values: &ValueSource{ + Static: &values, + }, + } + } + + cases := []struct { + Command *Command + Args []string + ErrorSuffix string + Env []string + }{ + { + Command: (&Command{ + // Name: []string{"test", "required", "failure"}, + Arguments: []*Argument{ + { + Name: "first", + Required: true, + }, + }, + }).SetBindings(), + ErrorSuffix: "Missing argument for FIRST", + }, + { + Args: []string{"bad"}, + ErrorSuffix: "bad is not a valid value for argument . Valid options are: good, default", + Command: (&Command{ + // Name: []string{"test", "script", "bad"}, + Arguments: []*Argument{ + { + Name: "first", + Default: "default", + Values: &ValueSource{ + Script: "echo good; echo default", + }, + }, + }, + }).SetBindings(), + }, + { + Args: []string{"bad"}, + ErrorSuffix: "bad is not a valid value for argument . Valid options are: default, good", + Command: (&Command{ + // Name: []string{"test", "static", "errors"}, + Arguments: []*Argument{staticArgument("first", "default", []string{"default", "good"}, false)}, + }).SetBindings(), + }, + { + Args: []string{"default", "good", "bad"}, + ErrorSuffix: "bad is not a valid value for argument . Valid options are: default, good", + Command: (&Command{ + // Name: []string{"test", "static", "errors"}, + Arguments: []*Argument{staticArgument("first", "default", []string{"default", "good"}, true)}, + }).SetBindings(), + }, + { + Args: []string{"good"}, + ErrorSuffix: "could not validate argument for command test script bad-exit, ran", + Command: (&Command{ + Path: []string{"test", "script", "bad-exit"}, + Arguments: []*Argument{ + { + Name: "first", + Default: "default", + Values: &ValueSource{ + Script: "echo good; echo default; exit 2", + }, + }, + }, + }).SetBindings(), + }, + } + + t.Run("good command is good", func(t *testing.T) { + cmd := testCommand() + cmd.Arguments[0] = staticArgument("first", "default", []string{"default", "good"}, false) + cmd.Arguments[1] = staticArgument("second", "", []string{"one", "two", "three"}, true) + cmd.SetBindings() + + cmd.Arguments.Parse([]string{"first", "one", "three", "two"}) + + err := cmd.Arguments.AreValid() + if err == nil { + t.Fatalf("Unexpected failure validating: %s", err) + } + }) + + for _, c := range cases { + t.Run(c.Command.FullName(), func(t *testing.T) { + c.Command.Arguments.Parse(c.Args) + + err := c.Command.Arguments.AreValid() + if err == nil { + t.Fatalf("Expected failure but got none") + } + if !strings.HasPrefix(err.Error(), c.ErrorSuffix) { + t.Fatalf("Could not find error <%s> got <%s>", c.ErrorSuffix, err) + } + }) + } +} + +// func TestArgumentsToEnv(t *testing.T) { +// cases := []struct { +// Command *Command +// Args []string +// Expect []string +// Env []string +// }{ +// { +// Args: []string{"something"}, +// Expect: []string{"export MILPA_ARG_FIRST=something"}, +// Command: &Command{ +// // Name: []string{"test", "required", "present"}, +// Arguments: []*Argument{ +// { +// Name: "first", +// Required: true, +// }, +// }, +// }, +// }, +// { +// Args: []string{}, +// Expect: []string{"export MILPA_ARG_FIRST=default"}, +// Command: &Command{ +// // Name: []string{"test", "default", "present"}, +// Arguments: []*Argument{ +// { +// Name: "first", +// Default: "default", +// }, +// }, +// }, +// }, +// { +// Args: []string{"zero", "one", "two", "three"}, +// Expect: []string{ +// "export MILPA_ARG_FIRST=zero", +// "declare -a MILPA_ARG_VARIADIC='( one two three )'", +// }, +// Command: &Command{ +// // Name: []string{"test", "variadic"}, +// Arguments: []*Argument{ +// { +// Name: "first", +// Default: "default", +// }, +// { +// Name: "variadic", +// Variadic: true, +// }, +// }, +// }, +// }, +// { +// Args: []string{}, +// Expect: []string{"export MILPA_ARG_FIRST=default"}, +// Command: &Command{ +// // Name: []string{"test", "static", "default"}, +// Arguments: []*Argument{ +// { +// Name: "first", +// Default: "default", +// Values: &ValueSource{ +// Static: &[]string{ +// "default", +// "good", +// }, +// }, +// }, +// }, +// }, +// }, +// { +// Args: []string{"good"}, +// Expect: []string{"export MILPA_ARG_FIRST=good"}, +// Command: &Command{ +// // Name: []string{"test", "static", "good"}, +// Arguments: []*Argument{ +// { +// Name: "first", +// Default: "default", +// Values: &ValueSource{ +// Static: &[]string{ +// "default", +// "good", +// }, +// }, +// }, +// }, +// }, +// }, +// { +// Args: []string{"good"}, +// Expect: []string{"export MILPA_ARG_FIRST=good"}, +// Command: &Command{ +// // Name: []string{"test", "script", "good"}, +// Arguments: []*Argument{ +// { +// Name: "first", +// Default: "default", +// Values: &ValueSource{ +// Script: "echo good; echo default", +// }, +// }, +// }, +// }, +// }, +// } + +// for _, c := range cases { +// t.Run(c.Command.FullName(), func(t *testing.T) { +// dst := []string{} +// c.Command.SetBindings() +// c.Command.Arguments.Parse(c.Args) +// c.Command.Arguments.ToEnv(c.Command, &dst, "export ") + +// err := c.Command.Arguments.AreValid() +// if err != nil { +// t.Fatalf("Unexpected failure validating: %s", err) +// } + +// for _, expected := range c.Expect { +// found := false +// for _, actual := range dst { +// if strings.HasPrefix(actual, expected) { +// found = true +// break +// } +// } + +// if !found { +// t.Fatalf("Expected line %v not found in %v", expected, dst) +// } +// } +// }) +// } +// } + +func TestArgumentToDesc(t *testing.T) { + cases := []struct { + Arg *Argument + Spec string + }{ + { + Arg: &Argument{ + Name: "regular", + }, + Spec: "[REGULAR]", + }, + { + Arg: &Argument{ + Name: "required", + Required: true, + }, + Spec: "REQUIRED", + }, + { + Arg: &Argument{ + Name: "variadic-regular", + Variadic: true, + }, + Spec: "[VARIADIC_REGULAR...]", + }, + { + Arg: &Argument{ + Name: "variadic-required", + Variadic: true, + Required: true, + }, + Spec: "VARIADIC_REQUIRED...", + }, + } + + for _, c := range cases { + t.Run(c.Arg.Name, func(t *testing.T) { + res := c.Arg.ToDesc() + if res != c.Spec { + t.Fatalf("Expected %s got %s", c.Spec, res) + } + }) + } +} diff --git a/pkg/command/command.go b/pkg/command/command.go new file mode 100644 index 0000000..7caf276 --- /dev/null +++ b/pkg/command/command.go @@ -0,0 +1,139 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package command + +import ( + "fmt" + "strings" + + "git.rob.mx/nidito/chinampa/internal/errors" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type HelpFunc func(printLinks bool) string +type Action func(cmd *Command) error + +type Command struct { + Path []string + // Summary is a short description of a command, on supported shells this is part of the autocomplete prompt + Summary string `json:"summary" yaml:"summary" validate:"required"` + // Description is a long form explanation of how a command works its magic. Markdown is supported + Description string `json:"description" yaml:"description" validate:"required"` + // A list of arguments for a command + Arguments Arguments `json:"arguments" yaml:"arguments" validate:"dive"` + // A map of option names to option definitions + Options Options `json:"options" yaml:"options" validate:"dive"` + HelpFunc HelpFunc `json:"-" yaml:"-"` + // The action to take upon running + Action Action + runtimeFlags *pflag.FlagSet + Cobra *cobra.Command +} + +func (cmd *Command) SetBindings() *Command { + ptr := cmd + for _, opt := range cmd.Options { + opt.Command = ptr + if opt.Validates() { + opt.Values.command = ptr + } + } + + for _, arg := range cmd.Arguments { + arg.Command = ptr + if arg.Validates() { + arg.Values.command = ptr + } + } + return ptr +} + +func (cmd *Command) Name() string { + return cmd.Path[len(cmd.Path)-1] +} + +func (cmd *Command) FullName() string { + return strings.Join(cmd.Path, " ") +} + +func (cmd *Command) FlagSet() *pflag.FlagSet { + if cmd.runtimeFlags == nil { + fs := pflag.NewFlagSet(strings.Join(cmd.Path, " "), pflag.ContinueOnError) + fs.SortFlags = false + fs.Usage = func() {} + + for name, opt := range cmd.Options { + switch opt.Type { + case ValueTypeBoolean: + def := false + if opt.Default != nil { + def = opt.Default.(bool) + } + fs.Bool(name, def, opt.Description) + case ValueTypeDefault, ValueTypeString: + opt.Type = ValueTypeString + def := "" + if opt.Default != nil { + def = fmt.Sprintf("%s", opt.Default) + } + fs.String(name, def, opt.Description) + default: + // ignore flag + logrus.Warnf("Ignoring unknown option type <%s> for option <%s>", opt.Type, name) + continue + } + } + + cmd.runtimeFlags = fs + } + return cmd.runtimeFlags +} + +func (cmd *Command) ParseInput(cc *cobra.Command, args []string) error { + cmd.Arguments.Parse(args) + skipValidation, _ := cc.Flags().GetBool("skip-validation") + cmd.Options.Parse(cc.Flags()) + if !skipValidation { + logrus.Debug("Validating arguments") + if err := cmd.Arguments.AreValid(); err != nil { + return err + } + + logrus.Debug("Validating flags") + if err := cmd.Options.AreValid(); err != nil { + return err + } + } + + return nil +} + +func (cmd *Command) Run(cc *cobra.Command, args []string) error { + logrus.Debugf("running command %s", cmd.FullName()) + + if err := cmd.ParseInput(cc, args); err != nil { + return err + } + + err := cmd.Action(cmd) + if err != nil { + errors.HandleCobraExit(cmd.Cobra, err) + } + return err +} + +func (cmd *Command) SetCobra(cc *cobra.Command) { + cmd.Cobra = cc +} diff --git a/pkg/command/help.go b/pkg/command/help.go new file mode 100644 index 0000000..c60e3bd --- /dev/null +++ b/pkg/command/help.go @@ -0,0 +1,88 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package command + +import ( + "bytes" + + _c "git.rob.mx/nidito/chinampa/internal/constants" + "git.rob.mx/nidito/chinampa/internal/render" + "git.rob.mx/nidito/chinampa/pkg/runtime" + "github.com/spf13/cobra" +) + +type combinedCommand struct { + Spec *Command + Command *cobra.Command + GlobalOptions Options + HTMLOutput bool +} + +func (cmd *Command) HasAdditionalHelp() bool { + return cmd.HelpFunc != nil +} + +func (cmd *Command) AdditionalHelp(printLinks bool) *string { + if cmd.HelpFunc != nil { + str := cmd.HelpFunc(printLinks) + return &str + } + return nil +} + +func (cmd *Command) HelpRenderer(globalOptions Options) func(cc *cobra.Command, args []string) { + return func(cc *cobra.Command, args []string) { + // some commands don't have a binding until help is rendered + // like virtual ones (sub command groups) + cmd.SetCobra(cc) + content, err := cmd.ShowHelp(globalOptions, args) + if err != nil { + panic(err) + } + _, err = cc.OutOrStderr().Write(content) + if err != nil { + panic(err) + } + } +} + +func (cmd *Command) ShowHelp(globalOptions Options, args []string) ([]byte, error) { + var buf bytes.Buffer + c := &combinedCommand{ + Spec: cmd, + Command: cmd.Cobra, + GlobalOptions: globalOptions, + HTMLOutput: runtime.UnstyledHelpEnabled(), + } + err := _c.TemplateCommandHelp.Execute(&buf, c) + if err != nil { + return nil, err + } + + colorEnabled := runtime.ColorEnabled() + flags := cmd.Cobra.Flags() + ncf := cmd.Cobra.Flag("no-color") // nolint:ifshort + cf := cmd.Cobra.Flag("color") // nolint:ifshort + + if noColorFlag, err := flags.GetBool("no-color"); err == nil && ncf.Changed { + colorEnabled = !noColorFlag + } else if colorFlag, err := flags.GetBool("color"); err == nil && cf.Changed { + colorEnabled = colorFlag + } + + content, err := render.Markdown(buf.Bytes(), colorEnabled) + if err != nil { + return nil, err + } + return content, nil +} diff --git a/pkg/command/options.go b/pkg/command/options.go new file mode 100644 index 0000000..f332c99 --- /dev/null +++ b/pkg/command/options.go @@ -0,0 +1,234 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package command + +import ( + "fmt" + "strconv" + "strings" + + "git.rob.mx/nidito/chinampa/internal/errors" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Options is a map of name to Option. +type Options map[string]*Option + +func (opts *Options) AllKnown() map[string]any { + col := map[string]any{} + for name, opt := range *opts { + col[name] = opt.ToValue() + } + return col +} + +func (opts *Options) AllKnownStr() map[string]string { + col := map[string]string{} + for name, opt := range *opts { + col[name] = opt.ToString() + } + return col +} + +// func envValue(opts Options, f *pflag.Flag) (*string, *string) { +// name := f.Name +// if name == _c.HelpCommandName { +// return nil, nil +// } +// envName := "" +// value := f.Value.String() + +// if cname, ok := _c.EnvFlagNames[name]; ok { +// if value == "false" { +// return nil, nil +// } +// envName = cname +// } else { +// envName = fmt.Sprintf("%s%s", _c.OutputPrefixOpt, strings.ToUpper(strings.ReplaceAll(name, "-", "_"))) +// opt := opts[name] +// if opt != nil { +// value = opt.ToString(true) +// } + +// if value == "false" && opt.Type == ValueTypeBoolean { +// // makes dealing with false flags in shell easier +// value = "" +// } +// } + +// return &envName, &value +// } + +// // ToEnv writes shell variables to dst. +// func (opts *Options) ToEnv(command *Command, dst *[]string, prefix string) { +// command.cc.Flags().VisitAll(func(f *pflag.Flag) { +// envName, value := envValue(*opts, f) +// if envName != nil && value != nil { +// *dst = append(*dst, fmt.Sprintf("%s%s=%s", prefix, *envName, *value)) +// } +// }) +// } + +// func (opts *Options) EnvMap(command *Command, dst *map[string]string) { +// command.cc.Flags().VisitAll(func(f *pflag.Flag) { +// envName, value := envValue(*opts, f) +// if envName != nil && value != nil { +// (*dst)[*envName] = *value +// } +// }) +// } + +func (opts *Options) Parse(supplied *pflag.FlagSet) { + // logrus.Debugf("Parsing supplied flags, %v", supplied) + for name, opt := range *opts { + switch opt.Type { + case ValueTypeBoolean: + if val, err := supplied.GetBool(name); err == nil { + opt.provided = val + continue + } + default: + opt.Type = ValueTypeString + if val, err := supplied.GetString(name); err == nil { + opt.provided = val + continue + } + } + } +} + +func (opts *Options) AreValid() error { + for name, opt := range *opts { + if err := opt.Validate(name); err != nil { + return err + } + } + + return nil +} + +// Option represents a command line flag. +type Option struct { + ShortName string `json:"short-name,omitempty" yaml:"short-name,omitempty"` // nolint:tagliatelle + Type ValueType `json:"type" yaml:"type" validate:"omitempty,oneof=string bool"` + Description string `json:"description" yaml:"description" validate:"required"` + Default any `json:"default,omitempty" yaml:"default,omitempty"` + Values *ValueSource `json:"values,omitempty" yaml:"values,omitempty" validate:"omitempty"` + Repeated bool `json:"repeated" yaml:"repeated" validate:"omitempty"` + Command *Command `json:"-" yaml:"-" validate:"-"` + provided any +} + +func (opt *Option) IsKnown() bool { + return opt.provided != nil +} + +func (opt *Option) ToValue() any { + if opt.IsKnown() { + return opt.provided + } + return opt.Default +} + +func (opt *Option) ToString() string { + value := opt.ToValue() + stringValue := "" + if opt.Type == "bool" { + if value == nil { + stringValue = "" + } else { + stringValue = strconv.FormatBool(value.(bool)) + } + } else { + if value != nil { + stringValue = value.(string) + } + } + + return stringValue +} + +func (opt *Option) Validate(name string) error { + if !opt.Validates() { + return nil + } + + current := opt.ToString() // nolint:ifshort + + if current == "" { + return nil + } + + validValues, _, err := opt.Resolve(current) + if err != nil { + return err + } + + if !contains(validValues, current) { + return errors.BadArguments{Msg: fmt.Sprintf("%s is not a valid value for option <%s>. Valid options are: %s", current, name, strings.Join(validValues, ", "))} + } + + return nil +} + +// Validates tells if the user-supplied value needs validation. +func (opt *Option) Validates() bool { + return opt.Values != nil && opt.Values.Validates() +} + +// providesAutocomplete tells if this option provides autocomplete values. +func (opt *Option) providesAutocomplete() bool { + return opt.Values != nil +} + +// Resolve returns autocomplete values for an option. +func (opt *Option) Resolve(currentValue string) (values []string, flag cobra.ShellCompDirective, err error) { + if opt.Values != nil { + if opt.Values.command == nil { + opt.Values.command = opt.Command + } + return opt.Values.Resolve(currentValue) + } + + return +} + +// CompletionFunction is called by cobra when asked to complete an option. +func (opt *Option) CompletionFunction(cmd *cobra.Command, args []string, toComplete string) (values []string, flag cobra.ShellCompDirective) { + if !opt.providesAutocomplete() { + flag = cobra.ShellCompDirectiveNoFileComp + return + } + + opt.Command.Arguments.Parse(args) + opt.Command.Options.Parse(cmd.Flags()) + + var err error + values, flag, err = opt.Resolve(toComplete) + if err != nil { + return values, cobra.ShellCompDirectiveError + } + + if toComplete != "" { + filtered := []string{} + for _, value := range values { + if strings.HasPrefix(value, toComplete) { + filtered = append(filtered, value) + } + } + values = filtered + } + + return cobra.AppendActiveHelp(values, opt.Description), flag +} diff --git a/pkg/command/root.go b/pkg/command/root.go new file mode 100644 index 0000000..b9bc228 --- /dev/null +++ b/pkg/command/root.go @@ -0,0 +1,60 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package command + +import ( + _c "git.rob.mx/nidito/chinampa/internal/constants" + "git.rob.mx/nidito/chinampa/pkg/runtime" +) + +var Root = &Command{ + Summary: "Helps organize config for roberto", + Description: `﹅joao﹅ makes yaml, json, 1password and vault play along nicely.`, + Path: []string{"joao"}, + Options: Options{ + _c.HelpCommandName: &Option{ + ShortName: "h", + Type: "bool", + Description: "Display help for any command", + }, + "verbose": &Option{ + ShortName: "v", + Type: "bool", + Default: runtime.VerboseEnabled(), + Description: "Log verbose output to stderr", + }, + "version": &Option{ + Type: "bool", + Default: false, + Description: "Display program version and exit", + }, + "no-color": &Option{ + Type: "bool", + Description: "Disable printing of colors to stderr", + Default: !runtime.ColorEnabled(), + }, + "color": &Option{ + Type: "bool", + Description: "Always print colors to stderr", + Default: runtime.ColorEnabled(), + }, + "silent": &Option{ + Type: "bool", + Description: "Silence non-error logging", + }, + "skip-validation": &Option{ + Type: "bool", + Description: "Do not validate any arguments or options", + }, + }, +} diff --git a/pkg/command/validation.go b/pkg/command/validation.go new file mode 100644 index 0000000..a6634c7 --- /dev/null +++ b/pkg/command/validation.go @@ -0,0 +1,54 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package command + +import ( + "fmt" + "strings" + + "github.com/go-playground/validator/v10" +) + +type varSearchMap struct { + Status int + Name string + Usage string +} + +func (cmd *Command) Validate() (report map[string]int) { + report = map[string]int{} + + validate := validator.New() + if err := validate.Struct(cmd); err != nil { + verrs := err.(validator.ValidationErrors) + for _, issue := range verrs { + // todo: output better errors, see validator.FieldError + report[fmt.Sprint(issue)] = 1 + } + } + + vars := map[string]map[string]*varSearchMap{ + "argument": {}, + "option": {}, + } + + for _, arg := range cmd.Arguments { + vars["argument"][strings.ToUpper(strings.ReplaceAll(arg.Name, "-", "_"))] = &varSearchMap{2, arg.Name, ""} + } + + for name := range cmd.Options { + vars["option"][strings.ToUpper(strings.ReplaceAll(name, "-", "_"))] = &varSearchMap{2, name, ""} + } + + return report +} diff --git a/pkg/command/value.go b/pkg/command/value.go new file mode 100644 index 0000000..78b587f --- /dev/null +++ b/pkg/command/value.go @@ -0,0 +1,234 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package command + +import ( + "bytes" + "context" + "fmt" + "os" + "strings" + "text/template" + "time" + + _c "git.rob.mx/nidito/chinampa/internal/constants" + "git.rob.mx/nidito/chinampa/internal/exec" + "github.com/spf13/cobra" +) + +// ValueType represent the kinds of or option. +type ValueType string + +const ( + // ValueTypeDefault is the empty string, maps to ValueTypeString. + ValueTypeDefault ValueType = "" + // ValueTypeString a value treated like a string. + ValueTypeString ValueType = "string" + // ValueTypeBoolean is a value treated like a boolean. + ValueTypeBoolean ValueType = "bool" +) + +type SourceCommand struct { + Path []string + Args string +} + +type CompletionFunc func(cmd *Command, currentValue string) (values []string, flag cobra.ShellCompDirective, err error) + +// ValueSource represents the source for an auto-completed and/or validated option/argument. +type ValueSource struct { + // Directories prompts for directories with the given prefix. + Directories *string `json:"dirs,omitempty" yaml:"dirs,omitempty" validate:"omitempty,excluded_with=Command Files Func Script Static"` + // Files prompts for files with the given extensions + Files *[]string `json:"files,omitempty" yaml:"files,omitempty" validate:"omitempty,excluded_with=Command Func Directories Script Static"` + // Script runs the provided command with `bash -c "$script"` and returns an option for every line of stdout. + Script string `json:"script,omitempty" yaml:"script,omitempty" validate:"omitempty,excluded_with=Command Directories Files Func Static"` + // Static returns the given list. + Static *[]string `json:"static,omitempty" yaml:"static,omitempty" validate:"omitempty,excluded_with=Command Directories Files Func Script"` + // Command runs a subcommand and returns an option for every line of stdout. + Command *SourceCommand `json:"command,omitempty" yaml:"command,omitempty" validate:"omitempty,excluded_with=Directories Files Func Script Static"` + // Func runs a function + Func CompletionFunc `json:"func,omitempty" yaml:"func,omitempty" validate:"omitempty,excluded_with=Command Directories Files Script Static"` + // Timeout is the maximum amount of time we will wait for a Script, Command, or Func before giving up on completions/validations. + Timeout int `json:"timeout,omitempty" yaml:"timeout,omitempty" validate:"omitempty,excluded_with=Directories Files Static"` + // Suggestion if provided will only suggest autocomplete values but will not perform validation of a given value + Suggestion bool `json:"suggest-only" yaml:"suggest-only" validate:"omitempty"` // nolint:tagliatelle + // SuggestRaw if provided the shell will not add a space after autocompleting + SuggestRaw bool `json:"suggest-raw" yaml:"suggest-raw" validate:"omitempty"` // nolint:tagliatelle + command *Command `json:"-" yaml:"-" validate:"-"` + computed *[]string + flag cobra.ShellCompDirective +} + +// Validates tells if a value needs to be validated. +func (vs *ValueSource) Validates() bool { + if vs.Directories != nil || vs.Files != nil { + return false + } + + return !vs.Suggestion +} + +// Resolve returns the values for autocomplete and validation. +func (vs *ValueSource) Resolve(currentValue string) (values []string, flag cobra.ShellCompDirective, err error) { + if vs.computed != nil { + return *vs.computed, vs.flag, nil + } + + if vs.Timeout == 0 { + vs.Timeout = 5 + } + + flag = cobra.ShellCompDirectiveDefault + timeout := time.Duration(vs.Timeout) + + switch { + case vs.Static != nil: + values = *vs.Static + case vs.Files != nil: + flag = cobra.ShellCompDirectiveFilterFileExt + values = *vs.Files + case vs.Directories != nil: + flag = cobra.ShellCompDirectiveFilterDirs + values = []string{*vs.Directories} + case vs.Func != nil: + ctx, cancel := context.WithTimeout(context.Background(), timeout*time.Second) + defer cancel() + + done := make(chan error, 1) + panicChan := make(chan any, 1) + go func() { + defer func() { + if p := recover(); p != nil { + panicChan <- p + } + }() + + values, flag, err = vs.Func(vs.command, currentValue) + done <- err + }() + select { + case err = <-done: + return + case p := <-panicChan: + panic(p) + case <-ctx.Done(): + flag = cobra.ShellCompDirectiveError + err = ctx.Err() + return + } + + case vs.Command != nil: + if vs.command == nil { + return nil, cobra.ShellCompDirectiveError, fmt.Errorf("bug: command is nil") + } + argString, err := vs.command.ResolveTemplate(vs.Command.Args, currentValue) + if err != nil { + return nil, cobra.ShellCompDirectiveError, err + } + args := strings.Split(argString, " ") + sub, _, err := vs.command.Cobra.Root().Find(vs.Command.Path) + if err != nil { + return nil, cobra.ShellCompDirectiveError, fmt.Errorf("could not find a command named %s", vs.Command.Path) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout*time.Second) + defer cancel() // The cancel should be deferred so resources are cleaned up + + sub.SetArgs(args) + var stdout bytes.Buffer + sub.SetOut(&stdout) + var stderr bytes.Buffer + sub.SetErr(&stderr) + err = sub.ExecuteContext(ctx) + if err != nil { + return nil, cobra.ShellCompDirectiveError, err + } + + values = strings.Split(stdout.String(), "\n") + flag = cobra.ShellCompDirectiveDefault + case vs.Script != "": + if vs.command == nil { + return nil, cobra.ShellCompDirectiveError, fmt.Errorf("bug: command is nil") + } + cmd, err := vs.command.ResolveTemplate(vs.Script, currentValue) + if err != nil { + return nil, cobra.ShellCompDirectiveError, err + } + + args := append([]string{"/bin/bash", "-c"}, cmd) + + values, flag, err = exec.Exec(vs.command.FullName(), args, os.Environ(), timeout*time.Second) + if err != nil { + return nil, flag, err + } + } + + vs.computed = &values + + if vs.SuggestRaw { + flag |= cobra.ShellCompDirectiveNoSpace + } + + vs.flag = flag + return values, flag, err +} + +type AutocompleteTemplate struct { + Args map[string]string + Opts map[string]string +} + +func (tpl *AutocompleteTemplate) Opt(name string) string { + if val, ok := tpl.Opts[name]; ok { + return fmt.Sprintf("--%s %s", name, val) + } + + return "" +} + +func (tpl *AutocompleteTemplate) Arg(name string) string { + return tpl.Args[name] +} + +func (cmd *Command) ResolveTemplate(templateString string, currentValue string) (string, error) { + var buf bytes.Buffer + + tplData := &AutocompleteTemplate{ + Args: cmd.Arguments.AllKnownStr(), + Opts: cmd.Options.AllKnownStr(), + } + + fnMap := template.FuncMap{ + "Opt": tplData.Opt, + "Arg": tplData.Arg, + "Current": func() string { return currentValue }, + } + + for k, v := range _c.TemplateFuncs { + fnMap[k] = v + } + + tpl, err := template.New("subcommand").Funcs(fnMap).Parse(templateString) + + if err != nil { + return "", err + } + + err = tpl.Execute(&buf, tplData) + if err != nil { + return "", err + } + + return buf.String(), nil +} diff --git a/pkg/command/value_test.go b/pkg/command/value_test.go new file mode 100644 index 0000000..a725c4a --- /dev/null +++ b/pkg/command/value_test.go @@ -0,0 +1,164 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package command_test + +import ( + "testing" + + . "git.rob.mx/nidito/chinampa/pkg/command" + "github.com/spf13/pflag" +) + +func TestResolveTemplate(t *testing.T) { + overrideFlags := &pflag.FlagSet{} + overrideFlags.String("option", "override", "stuff") + overrideFlags.Bool("bool", false, "stuff") + overrideFlags.Bool("help", false, "stuff") + overrideFlags.Bool("no-color", false, "stuff") + overrideFlags.Bool("skip-validation", false, "stuff") + err := overrideFlags.Parse([]string{"--option", "override", "--bool", "--help", "--no-color", "--skip-validation"}) + if err != nil { + t.Fatalf("Could not parse test flags") + } + + cases := []struct { + Tpl string + Expected string + Args []string + Flags *pflag.FlagSet + Errors bool + }{ + { + Tpl: "adds nothing to nothing", + Expected: "adds nothing to nothing", + Errors: false, + Args: []string{}, + Flags: &pflag.FlagSet{}, + }, + { + Tpl: `prints default option as {{ Opt "option" }}`, + Expected: "prints default option as --option default", + Errors: false, + Args: []string{}, + Flags: &pflag.FlagSet{}, + }, + { + Tpl: `prints default option value as {{ .Opts.option }}`, + Expected: "prints default option value as default", + Errors: false, + Args: []string{}, + Flags: &pflag.FlagSet{}, + }, + { + Tpl: `prints default argument as {{ Arg "argument_0" }}`, + Expected: "prints default argument as default", + Errors: false, + Args: []string{}, + Flags: &pflag.FlagSet{}, + }, + { + Tpl: `prints default argument value as {{ .Args.argument_0 }}`, + Expected: "prints default argument value as default", + Errors: false, + Args: []string{}, + Flags: &pflag.FlagSet{}, + }, + { + Tpl: `overrides default option as {{ Opt "option" }}`, + Expected: "overrides default option as --option override", + Errors: false, + Args: []string{}, + Flags: overrideFlags, + }, + { + Tpl: `overrides default argument as {{ Arg "argument_0" }}`, + Expected: "overrides default argument as override", + Errors: false, + Args: []string{"override"}, + Flags: &pflag.FlagSet{}, + }, + { + Tpl: `combines defaults as {{ Opt "option" }} {{ Opt "bool"}} {{ Arg "argument_0" }}`, + Expected: "combines defaults as --option default --bool false default", + Errors: false, + Args: []string{}, + Flags: &pflag.FlagSet{}, + }, + { + Tpl: `combines overrides as {{ Opt "option" }} {{ Opt "bool" }} {{ Arg "argument_0" }}`, + Expected: "combines overrides as --option override --bool true twice", + Errors: false, + Args: []string{"twice"}, + Flags: overrideFlags, + }, + { + Tpl: `prints variadic as {{ Arg "argument_0" }} {{ Arg "argument_n" }}`, + Expected: "prints variadic as override a b", + Errors: false, + Args: []string{"override", "a", "b"}, + Flags: &pflag.FlagSet{}, + }, + { + Tpl: `doesn't error on bad names {{ Opt "bad-option" }} {{ Arg "bad-argument" }}`, + Expected: "doesn't error on bad names ", + Errors: false, + Args: []string{}, + Flags: &pflag.FlagSet{}, + }, + { + Tpl: `errors on bad templates {{ BadFunc }}`, + Args: []string{}, + Flags: &pflag.FlagSet{}, + Errors: true, + }, + } + + for _, test := range cases { + test := test + t.Run(test.Expected, func(t *testing.T) { + cmd := (&Command{ + Arguments: []*Argument{ + { + Name: "argument_0", + Default: "default", + }, + { + Name: "argument_n", + Variadic: true, + }, + }, + Options: Options{ + "option": { + Default: "default", + Type: "string", + }, + "bool": { + Type: "bool", + Default: false, + }, + }, + }).SetBindings() + cmd.Arguments.Parse(test.Args) + cmd.Options.Parse(test.Flags) + res, err := cmd.ResolveTemplate(test.Tpl, "") + + if err != nil && !test.Errors { + t.Fatalf("good template failed: %s", err) + } + + if res != test.Expected { + t.Fatalf("expected '%s' got '%s'", test.Expected, res) + } + }) + } +} diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go new file mode 100644 index 0000000..4175d61 --- /dev/null +++ b/pkg/runtime/runtime.go @@ -0,0 +1,105 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package runtime + +import ( + "os" + "strconv" + + _c "git.rob.mx/nidito/chinampa/internal/constants" +) + +var falseIshValues = []string{ + "", + "0", + "no", + "false", + "disable", + "disabled", + "off", + "never", +} + +var trueIshValues = []string{ + "1", + "yes", + "true", + "enable", + "enabled", + "on", + "always", +} + +func isFalseIsh(val string) bool { + for _, negative := range falseIshValues { + if val == negative { + return true + } + } + + return false +} + +func isTrueIsh(val string) bool { + for _, positive := range trueIshValues { + if val == positive { + return true + } + } + + return false +} + +func DebugEnabled() bool { + return isTrueIsh(os.Getenv(_c.EnvVarDebug)) +} + +func ValidationEnabled() bool { + return isFalseIsh(os.Getenv(_c.EnvVarValidationDisabled)) +} + +func VerboseEnabled() bool { + return isTrueIsh(os.Getenv(_c.EnvVarMilpaVerbose)) +} + +func ColorEnabled() bool { + return isFalseIsh(os.Getenv(_c.EnvVarMilpaUnstyled)) && !UnstyledHelpEnabled() +} + +func UnstyledHelpEnabled() bool { + return isTrueIsh(os.Getenv(_c.EnvVarHelpUnstyled)) +} + +// EnvironmentMap returns the resolved environment map. +func EnvironmentMap() map[string]string { + env := map[string]string{} + trueString := strconv.FormatBool(true) + + if !ColorEnabled() { + env[_c.EnvVarMilpaUnstyled] = trueString + } else if isTrueIsh(os.Getenv(_c.EnvVarMilpaForceColor)) { + env[_c.EnvVarMilpaForceColor] = "always" + } + + if DebugEnabled() { + env[_c.EnvVarDebug] = trueString + } + + if VerboseEnabled() { + env[_c.EnvVarMilpaVerbose] = trueString + } else if isTrueIsh(os.Getenv(_c.EnvVarMilpaSilent)) { + env[_c.EnvVarMilpaSilent] = trueString + } + + return env +} diff --git a/pkg/runtime/runtime_test.go b/pkg/runtime/runtime_test.go new file mode 100644 index 0000000..70eb48a --- /dev/null +++ b/pkg/runtime/runtime_test.go @@ -0,0 +1,136 @@ +// Copyright © 2022 Roberto Hidalgo +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package runtime_test + +import ( + "fmt" + "os" + "reflect" + "runtime" + "strconv" + "testing" + + _c "git.rob.mx/nidito/chinampa/internal/constants" + . "git.rob.mx/nidito/chinampa/pkg/runtime" +) + +func TestEnabled(t *testing.T) { + defer func() { os.Setenv(_c.EnvVarMilpaVerbose, "") }() + + cases := []struct { + Name string + Func func() bool + Expects bool + }{ + { + Name: _c.EnvVarMilpaVerbose, + Func: VerboseEnabled, + Expects: true, + }, + { + Name: _c.EnvVarValidationDisabled, + Func: ValidationEnabled, + }, + { + Name: _c.EnvVarMilpaUnstyled, + Func: ColorEnabled, + }, + { + Name: _c.EnvVarHelpUnstyled, + Func: ColorEnabled, + }, + { + Name: _c.EnvVarDebug, + Func: DebugEnabled, + Expects: true, + }, + { + Name: _c.EnvVarHelpUnstyled, + Func: UnstyledHelpEnabled, + Expects: true, + }, + } + + for _, c := range cases { + fname := runtime.FuncForPC(reflect.ValueOf(c.Func).Pointer()).Name() + name := fmt.Sprintf("%v/%s", fname, c.Name) + enabled := []string{ + "yes", "true", "1", "enabled", + } + for _, val := range enabled { + t.Run("enabled-"+val, func(t *testing.T) { + os.Setenv(c.Name, val) + if c.Func() != c.Expects { + t.Fatalf("%s wasn't enabled with a valid value: %s", name, val) + } + }) + } + + disabled := []string{"", "no", "false", "0", "disabled"} + for _, val := range disabled { + t.Run("disabled-"+val, func(t *testing.T) { + os.Setenv(c.Name, val) + if c.Func() == c.Expects { + t.Fatalf("%s was enabled with falsy value: %s", name, val) + } + }) + } + } +} + +func TestEnvironmentMapEnabled(t *testing.T) { + trueString := strconv.FormatBool(true) + os.Setenv(_c.EnvVarMilpaForceColor, trueString) + os.Setenv(_c.EnvVarDebug, trueString) + os.Setenv(_c.EnvVarMilpaVerbose, trueString) + + res := EnvironmentMap() + if res == nil { + t.Fatalf("Expected map, got nil") + } + + expected := map[string]string{ + _c.EnvVarMilpaForceColor: "always", + _c.EnvVarDebug: trueString, + _c.EnvVarMilpaVerbose: trueString, + } + + if !reflect.DeepEqual(res, expected) { + t.Fatalf("Unexpected result from enabled environment. Wanted %v, got %v", res, expected) + } +} + +func TestEnvironmentMapDisabled(t *testing.T) { + trueString := strconv.FormatBool(true) + // clear COLOR + os.Unsetenv(_c.EnvVarMilpaForceColor) + // set NO_COLOR + os.Setenv(_c.EnvVarMilpaUnstyled, trueString) + os.Unsetenv(_c.EnvVarDebug) + os.Unsetenv(_c.EnvVarMilpaVerbose) + os.Setenv(_c.EnvVarMilpaSilent, trueString) + + res := EnvironmentMap() + if res == nil { + t.Fatalf("Expected map, got nil") + } + + expected := map[string]string{ + _c.EnvVarMilpaUnstyled: trueString, + _c.EnvVarMilpaSilent: trueString, + } + + if !reflect.DeepEqual(res, expected) { + t.Fatalf("Unexpected result from disabled environment. Wanted %v, got %v", res, expected) + } +}