milpa, pero para golang

This commit is contained in:
Roberto Hidalgo 2022-12-18 21:04:34 -06:00
commit c622ba72be
28 changed files with 3145 additions and 0 deletions

13
.editorconfig Normal file
View File

@ -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

40
.golangci.yml Normal file
View File

@ -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

1
.tool-versions Normal file
View File

@ -0,0 +1 @@
golang 1.18.2

13
LICENSE.txt Normal file
View File

@ -0,0 +1,13 @@
Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
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.

41
go.mod Normal file
View File

@ -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
)

114
go.sum Normal file
View File

@ -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=

View File

@ -0,0 +1,83 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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))

View File

@ -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}}

70
internal/errors/errors.go Normal file
View File

@ -0,0 +1,70 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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)
}

View File

@ -0,0 +1,76 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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)
}

65
internal/exec/exec.go Normal file
View File

@ -0,0 +1,65 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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
}

108
internal/exec/exec_test.go Normal file
View File

@ -0,0 +1,108 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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)
}
}

119
internal/registry/cobra.go Normal file
View File

@ -0,0 +1,119 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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
}

View File

@ -0,0 +1,227 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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
}

70
internal/render/render.go Normal file
View File

@ -0,0 +1,70 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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)
}

View File

@ -0,0 +1,87 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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)
}
})
}
}

26
main.go Normal file
View File

@ -0,0 +1,26 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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)
}

283
pkg/command/arguments.go Normal file
View File

@ -0,0 +1,283 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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
}

View File

@ -0,0 +1,425 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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 <first>. 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 <first>. 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 <first>. 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)
}
})
}
}

139
pkg/command/command.go Normal file
View File

@ -0,0 +1,139 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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
}

88
pkg/command/help.go Normal file
View File

@ -0,0 +1,88 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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
}

234
pkg/command/options.go Normal file
View File

@ -0,0 +1,234 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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
}

60
pkg/command/root.go Normal file
View File

@ -0,0 +1,60 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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",
},
},
}

54
pkg/command/validation.go Normal file
View File

@ -0,0 +1,54 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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
}

234
pkg/command/value.go Normal file
View File

@ -0,0 +1,234 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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
}

164
pkg/command/value_test.go Normal file
View File

@ -0,0 +1,164 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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)
}
})
}
}

105
pkg/runtime/runtime.go Normal file
View File

@ -0,0 +1,105 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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
}

136
pkg/runtime/runtime_test.go Normal file
View File

@ -0,0 +1,136 @@
// Copyright © 2022 Roberto Hidalgo <chinampa@un.rob.mx>
//
// 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)
}
}