From 10be56d8cb26333b9e3d3f5db69bfd72e80149a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Thu, 19 Oct 2023 14:45:59 +0300 Subject: [PATCH] Replace survey library with bubbletea (#54) --- examples/variable-types/recipe.yml | 21 +- examples/variable-types/templates/README.md | 13 +- go.mod | 23 +- go.sum | 79 ++-- internal/cli/execute.go | 37 +- internal/cli/option/colors.go | 32 ++ internal/cli/option/values.go | 22 +- internal/cli/upgrade.go | 31 +- pkg/recipe/execute.go | 2 +- pkg/recipe/variable.go | 58 ++- pkg/recipe/variable_test.go | 8 +- pkg/recipeutil/prompt.go | 126 ------ pkg/recipeutil/values.go | 28 +- pkg/recipeutil/values_test.go | 2 +- pkg/survey/editable/model.go | 456 ++++++++++++++++++++ pkg/survey/prompt/confirm.go | 104 +++++ pkg/survey/prompt/prompt.go | 12 + pkg/survey/prompt/select.go | 146 +++++++ pkg/survey/prompt/string.go | 132 ++++++ pkg/survey/prompt/table.go | 153 +++++++ pkg/survey/survey.go | 223 ++++++++++ pkg/survey/survey_test.go | 96 +++++ pkg/survey/util/util.go | 32 ++ 23 files changed, 1604 insertions(+), 232 deletions(-) create mode 100644 internal/cli/option/colors.go delete mode 100644 pkg/recipeutil/prompt.go create mode 100644 pkg/survey/editable/model.go create mode 100644 pkg/survey/prompt/confirm.go create mode 100644 pkg/survey/prompt/prompt.go create mode 100644 pkg/survey/prompt/select.go create mode 100644 pkg/survey/prompt/string.go create mode 100644 pkg/survey/prompt/table.go create mode 100644 pkg/survey/survey.go create mode 100644 pkg/survey/survey_test.go create mode 100644 pkg/survey/util/util.go diff --git a/examples/variable-types/recipe.yml b/examples/variable-types/recipe.yml index 48fc48d1..a6860c09 100644 --- a/examples/variable-types/recipe.yml +++ b/examples/variable-types/recipe.yml @@ -12,7 +12,7 @@ vars: - name: BOOLEAN_VAR description: | - Boolean variable can have value either `true` or `false`. + Boolean variable can have value either `true` or `false`. Defined by: `confirm: true`. confirm: true @@ -32,7 +32,7 @@ vars: {{ .Variables.TABLE_VAR[0].COLUMN_1 }} You can pre-set the table variable by using CSV with having '\n' between the rows, for example: - `jalapeno execute examples/variables `--set 'TABLE_VAR=a;b;c\nx;y;z'` + `jalapeno execute examples/variables `--set 'TABLE_VAR=a,b,c\nx,y,z'` Defined by: non-empty `columns` property. columns: [COLUMN_1, COLUMN_2, COLUMN_3] @@ -63,7 +63,16 @@ vars: - name: VAR_WITH_VALIDATOR description: | - Regular expression validators can be set for a variable by defining `regexp` property - regexp: - pattern: ".*" - help: "If the check doesn't pass, this help message will be shown" + Regular expression validators can be set for a variable by defining `validators` property + validators: + - pattern: ".+" + help: "If the value is empty, this help message will be shown" + + - name: TABLE_VAR_WITH_VALIDATOR + description: | + Regular expression validators can be set for a table variable by defining `validators` and `column` property + columns: [NOT_EMPTY_COL, CAN_BE_EMPTY_COL] + validators: + - pattern: ".+" + column: NOT_EMPTY_COL + help: "If the cell is empty, this help message will be shown" diff --git a/examples/variable-types/templates/README.md b/examples/variable-types/templates/README.md index 6afcbece..c6f87cdb 100644 --- a/examples/variable-types/templates/README.md +++ b/examples/variable-types/templates/README.md @@ -1,18 +1,13 @@ -# String variable +# String variable: {{ .Variables.STRING_VAR }} -{{- .Variables.STRING_VAR }} +# Boolean variable: {{ .Variables.BOOLEAN_VAR }} -# Boolean variable - -{{- .Variables.BOOLEAN_VAR }} - -# Select variable - -{{- .Variables.SELECT_VAR }} +# Select variable: {{ .Variables.SELECT_VAR }} # Table variable | COLUMN_1 | COLUMN_2 | COLUMN_3 | +| --- | --- | --- | {{- range $val := .Variables.TABLE_VAR }} | {{ $val.COLUMN_1 }} | {{ $val.COLUMN_2 }} | {{ $val.COLUMN_3 }} | {{- end}} diff --git a/go.mod b/go.mod index a918ffb3..d02cc849 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,16 @@ module github.com/futurice/jalapeno go 1.21 require ( - github.com/AlecAivazis/survey/v2 v2.3.7 github.com/Masterminds/sprig v2.22.0+incompatible github.com/antonmedv/expr v1.15.3 github.com/carlmjohnson/versioninfo v0.22.5 + github.com/charmbracelet/bubbles v0.16.1 + github.com/charmbracelet/bubbletea v0.24.2 + github.com/charmbracelet/lipgloss v0.9.1 + github.com/charmbracelet/x/exp/teatest v0.0.0-20231010190216-1cb11efc897d github.com/cucumber/godog v0.13.0 github.com/docker/cli v24.0.6+incompatible + github.com/muesli/termenv v0.15.2 github.com/opencontainers/image-spec v1.1.0-rc5 github.com/spf13/cobra v1.7.0 github.com/xlab/treeprint v1.2.0 @@ -18,16 +22,28 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymanbagabas/go-udiff v0.1.0 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect github.com/cucumber/messages/go/v21 v21.0.1 // indirect github.com/docker/docker v24.0.6+incompatible // indirect github.com/docker/docker-credential-helpers v0.8.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/term v0.5.0 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + github.com/sahilm/fuzzy v0.1.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect @@ -53,10 +69,7 @@ require ( github.com/huandu/xstrings v1.4.0 // indirect github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect - github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/opencontainers/runc v1.1.9 // indirect @@ -65,7 +78,7 @@ require ( github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.5 golang.org/x/crypto v0.13.0 // indirect - golang.org/x/sys v0.12.0 // indirect + golang.org/x/sys v0.13.0 // indirect golang.org/x/term v0.12.0 // indirect golang.org/x/text v0.13.0 // indirect golang.org/x/tools v0.13.0 // indirect diff --git a/go.sum b/go.sum index 3bf34d1b..b0631d59 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= -github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -10,20 +8,33 @@ github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZC github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/antonmedv/expr v1.15.3 h1:q3hOJZNvLvhqE8OHBs1cFRdbXFNKuA+bHmRaI+AmRmI= github.com/antonmedv/expr v1.15.3/go.mod h1:0E/6TxnOlRNp81GMzX9QfDPAmHo2Phg00y4JUv1ihsE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.1.0 h1:9Dpklm2oBBhMxIFbMffmPvDaF7vOYfv9B5HXVr42KMU= +github.com/aymanbagabas/go-udiff v0.1.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= +github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= +github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= +github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/charmbracelet/x/exp/teatest v0.0.0-20231010190216-1cb11efc897d h1:WDZRDaKD2usd2HV2qqLATuVB+khVYzwyFIHrvtaSCi8= +github.com/charmbracelet/x/exp/teatest v0.0.0-20231010190216-1cb11efc897d/go.mod h1:TckAxPtan3aJ5wbTgBkySpc50SZhXJRZ8PtYICnZJEw= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/containerd/continuity v0.4.2 h1:v3y/4Yz5jwnvqPKJJ+7Wf93fyWoCB3F5EclWG023MDM= github.com/containerd/continuity v0.4.2/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= @@ -72,16 +83,12 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= -github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= @@ -89,18 +96,19 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2 h1:hRGSmZu7j271trc9sneMrpOW7GN5ngLm8YUZIPzf394= github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +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-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= -github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -109,6 +117,14 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +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.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI= @@ -121,7 +137,13 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= @@ -132,7 +154,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -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/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -151,53 +172,38 @@ github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/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-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/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/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -205,7 +211,6 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/cli/execute.go b/internal/cli/execute.go index c889804f..385671cc 100644 --- a/internal/cli/execute.go +++ b/internal/cli/execute.go @@ -2,13 +2,16 @@ package cli import ( "context" + "errors" "os" "strings" + "github.com/charmbracelet/lipgloss" "github.com/futurice/jalapeno/internal/cli/option" "github.com/futurice/jalapeno/pkg/oci" "github.com/futurice/jalapeno/pkg/recipe" "github.com/futurice/jalapeno/pkg/recipeutil" + "github.com/futurice/jalapeno/pkg/survey" "github.com/gofrs/uuid" "github.com/spf13/cobra" ) @@ -16,6 +19,7 @@ import ( type executeOptions struct { RecipeURL string option.Values + option.Styles option.OCIRepository option.WorkingDirectory option.Common @@ -84,10 +88,11 @@ func runExecute(cmd *cobra.Command, opts executeOptions) { return } - cmd.Printf("Recipe name: %s\n", re.Metadata.Name) + style := lipgloss.NewStyle().Foreground(opts.Colors.Primary) + cmd.Printf("%s: %s\n", style.Render("Recipe name"), re.Metadata.Name) if re.Metadata.Description != "" { - cmd.Printf("Description: %s\n", re.Metadata.Description) + cmd.Printf("%s: %s\n", style.Render("Description"), re.Metadata.Description) } // Load all existing sauces @@ -113,26 +118,30 @@ func runExecute(cmd *cobra.Command, opts executeOptions) { } } - providedValues, err := recipeutil.ParseProvidedValues(re.Variables, opts.Values.Flags) + providedValues, err := recipeutil.ParseProvidedValues(re.Variables, opts.Values.Flags, opts.Values.CSVDelimiter) if err != nil { - cmd.PrintErrf("Error when parsing provided values: %v\n", err) + cmd.PrintErrf("Error when parsing provided values: %s\n", err) return } - predefinedValues := recipeutil.MergeValues(reusedValues, providedValues) + values := recipeutil.MergeValues(reusedValues, providedValues) // Filter out variables which don't have value yet - filteredVariables := recipeutil.FilterVariablesWithoutValues(re.Variables, predefinedValues) - promptedValues, err := recipeutil.PromptUserForValues(filteredVariables, predefinedValues) - if err != nil { - cmd.PrintErrf("Error when prompting for values: %v\n", err) - return + varsWithoutValues := recipeutil.FilterVariablesWithoutValues(re.Variables, values) + if len(varsWithoutValues) > 0 { + promptedValues, err := survey.PromptUserForValues(cmd.InOrStdin(), cmd.OutOrStdout(), varsWithoutValues, values) + if err != nil { + if errors.Is(err, survey.ErrUserAborted) { + return + } else { + cmd.PrintErrf("Error when prompting for values: %s\n", err) + return + } + } + values = recipeutil.MergeValues(values, promptedValues) } - sauce, err := re.Execute( - recipeutil.MergeValues(predefinedValues, promptedValues), - uuid.Must(uuid.NewV4()), - ) + sauce, err := re.Execute(values, uuid.Must(uuid.NewV4())) if err != nil { cmd.PrintErrf("Error: %s", err) return diff --git a/internal/cli/option/colors.go b/internal/cli/option/colors.go new file mode 100644 index 00000000..2820ce19 --- /dev/null +++ b/internal/cli/option/colors.go @@ -0,0 +1,32 @@ +package option + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "github.com/spf13/pflag" +) + +type Styles struct { + NoColors bool + Colors +} + +type Colors struct { + Primary lipgloss.Color + Secondary lipgloss.Color +} + +func (opts *Styles) ApplyFlags(fs *pflag.FlagSet) { + fs.BoolVar(&opts.NoColors, "no-color", false, "If specified, output won't contain any color") +} + +func (opts *Styles) Parse() error { + if opts.NoColors { + lipgloss.SetColorProfile(termenv.Ascii) + return nil + } + + opts.Colors.Primary = lipgloss.Color("#EF4136") + opts.Colors.Secondary = lipgloss.Color("#26A568") + return nil +} diff --git a/internal/cli/option/values.go b/internal/cli/option/values.go index b22b0133..a8366f62 100644 --- a/internal/cli/option/values.go +++ b/internal/cli/option/values.go @@ -1,13 +1,33 @@ package option -import "github.com/spf13/pflag" +import ( + "errors" + + "github.com/spf13/pflag" +) type Values struct { ReuseSauceValues bool + CSVDelimiter rune Flags []string + + delimiter string } func (opts *Values) ApplyFlags(fs *pflag.FlagSet) { fs.StringArrayVarP(&opts.Flags, "set", "s", []string{}, "Predefine values to be used in the templates. Example: `--set \"MY_VAR=foo\"`") + fs.StringVar(&opts.delimiter, "delimiter", ",", "Delimiter used when setting table variables") fs.BoolVarP(&opts.ReuseSauceValues, "reuse-sauce-values", "r", false, "By default each sauce has their own set of values even if the variable names are same in both recipes. Setting this to `true` will reuse previous sauce values if the variable name match") } + +func (opts *Values) Parse() error { + if opts.delimiter == "" { + return errors.New("delimiter cannot be empty") + } + if len(opts.delimiter) != 1 { + return errors.New("delimiter can be only one character long") + } + + opts.CSVDelimiter = rune(opts.delimiter[0]) + return nil +} diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 55eb5034..0563337a 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -8,10 +8,10 @@ import ( "path/filepath" "strings" - "github.com/AlecAivazis/survey/v2" "github.com/futurice/jalapeno/internal/cli/option" "github.com/futurice/jalapeno/pkg/recipe" "github.com/futurice/jalapeno/pkg/recipeutil" + "github.com/futurice/jalapeno/pkg/survey" "github.com/spf13/cobra" "golang.org/x/mod/semver" ) @@ -113,13 +113,14 @@ func runUpgrade(cmd *cobra.Command, opts upgradeOptions) { } } - providedValues, err := recipeutil.ParseProvidedValues(re.Variables, opts.Values.Flags) + providedValues, err := recipeutil.ParseProvidedValues(re.Variables, opts.Values.Flags, opts.CSVDelimiter) if err != nil { cmd.PrintErrf("Error when parsing provided values: %v\n", err) return } predefinedValues := recipeutil.MergeValues(reusedValues, providedValues) + values := recipeutil.MergeValues(oldSauce.Values, predefinedValues) // Don't prompt variables which already has a value in existing sauce or is predefined varsWithoutValues := make([]recipe.Variable, 0, len(re.Variables)) @@ -131,13 +132,19 @@ func runUpgrade(cmd *cobra.Command, opts upgradeOptions) { } } - values, err := recipeutil.PromptUserForValues(varsWithoutValues, predefinedValues) - if err != nil { - cmd.PrintErrf("Error: %s", err) - return + if len(varsWithoutValues) > 0 { + promptedValues, err := survey.PromptUserForValues(cmd.InOrStdin(), cmd.OutOrStdout(), varsWithoutValues, predefinedValues) + if err != nil { + if !errors.Is(err, survey.ErrUserAborted) { + cmd.PrintErrf("Error when prompting for values: %s\n", err) + } + return + } + + values = recipeutil.MergeValues(values, promptedValues) } - newSauce, err := re.Execute(recipeutil.MergeValues(values, oldSauce.Values, predefinedValues), oldSauce.ID) + newSauce, err := re.Execute(values, oldSauce.ID) if err != nil { cmd.PrintErrf("Error: %s", err) return @@ -189,12 +196,12 @@ func runUpgrade(cmd *cobra.Command, opts upgradeOptions) { // TODO: We could do better in terms of merge conflict management. Like show the diff or something var override bool - prompt := &survey.Confirm{ - Message: path, - Default: true, - } + // prompt := &survey.Confirm{ + // Message: path, + // Default: true, + // } - err = survey.AskOne(prompt, &override) + // err = survey.AskOne(prompt, &override) if err != nil { cmd.PrintErrf("Error when prompting for question: %s", err) return diff --git a/pkg/recipe/execute.go b/pkg/recipe/execute.go index c8a25044..33de723b 100644 --- a/pkg/recipe/execute.go +++ b/pkg/recipe/execute.go @@ -9,7 +9,7 @@ import ( "github.com/gofrs/uuid" ) -// Renders recipe templates +// Execute executes the recipe and returns a sauce func (re *Recipe) Execute(values VariableValues, id uuid.UUID) (*Sauce, error) { if re.engine == nil { return nil, errors.New("render engine has not been set") diff --git a/pkg/recipe/variable.go b/pkg/recipe/variable.go index 18ae06cf..d95b53ca 100644 --- a/pkg/recipe/variable.go +++ b/pkg/recipe/variable.go @@ -25,8 +25,8 @@ type Variable struct { // The user selects the value from a list of options Options []string `yaml:"options,omitempty"` - // Regular expression validator for the variable value - RegExp VariableRegExpValidator `yaml:"regexp,omitempty"` + // Regular expression validators for the variable value + Validators []VariableValidator `yaml:"validators,omitempty"` // Makes the variable conditional based on the result of the expression. The result of the evaluation needs to be a boolean value. Uses https://github.com/antonmedv/expr If string `yaml:"if,omitempty"` @@ -35,12 +35,15 @@ type Variable struct { Columns []string `yaml:"columns,omitempty"` } -type VariableRegExpValidator struct { +type VariableValidator struct { // Regular expression pattern to match the input against Pattern string `yaml:"pattern,omitempty"` // If the regular expression validation fails, this help message will be shown to the user Help string `yaml:"help,omitempty"` + + // Apply the validator to a column if the variable type is table + Column string `yaml:"column,omitempty"` } // VariableValues stores values for each variable @@ -59,26 +62,61 @@ func (v *Variable) Validate() error { } } - if v.RegExp.Pattern != "" { - if _, err := regexp.Compile(v.RegExp.Pattern); err != nil { - return fmt.Errorf("invalid variable regexp pattern: %w", err) + for i, validator := range v.Validators { + baseErr := fmt.Errorf("validator %d", i+1) + if v.Confirm { + return fmt.Errorf("%s: validators for boolean variables are not supported", baseErr) + } + + if len(v.Options) > 0 { + return fmt.Errorf("%s: validators for select variables are not supported", baseErr) + } + + if len(v.Columns) > 0 && validator.Column == "" { + return fmt.Errorf("%s: validator need to have `column` property defined since the variable is table type", baseErr) + } + + if validator.Pattern == "" { + return fmt.Errorf("%s: regexp pattern is empty", baseErr) + } + + if validator.Column != "" { + if len(v.Columns) == 0 { + return fmt.Errorf("%s: validator is defined for column while the variable has not defined any", baseErr) + } + + found := false + for _, c := range v.Columns { + if c == validator.Column { + found = true + break + } + } + + if !found { + return fmt.Errorf("%s: column %s does not exist in the variable", baseErr, validator.Column) + } + } + + if _, err := regexp.Compile(validator.Pattern); err != nil { + return fmt.Errorf("%s: invalid variable regexp pattern: %w", baseErr, err) } } if v.If != "" { if _, err := expr.Compile(v.If); err != nil { - return fmt.Errorf("invalid variable 'if' expression: %w", err) + return fmt.Errorf("invalid 'if' expression: %w", err) } } return nil } -func (r *VariableRegExpValidator) CreateValidatorFunc() func(input interface{}) error { +func (r *VariableValidator) CreateValidatorFunc() func(input string) error { reg := regexp.MustCompile(r.Pattern) - return func(input interface{}) error { - if match := reg.MatchString(fmt.Sprint(input)); !match { + return func(input string) error { + if match := reg.MatchString(input); !match { if r.Help != "" { return errors.New(r.Help) } else { diff --git a/pkg/recipe/variable_test.go b/pkg/recipe/variable_test.go index 33949530..37e34a85 100644 --- a/pkg/recipe/variable_test.go +++ b/pkg/recipe/variable_test.go @@ -6,12 +6,14 @@ func TestVariableRegExpValidation(t *testing.T) { variable := &Variable{ Name: "foo", Description: "foo description", - RegExp: VariableRegExpValidator{ - Pattern: "^[a-zA-Z0-9_.()-]{0,89}[a-zA-Z0-9_()-]$", + Validators: []VariableValidator{ + { + Pattern: "^[a-zA-Z0-9_.()-]{0,89}[a-zA-Z0-9_()-]$", + }, }, } - validatorFunc := variable.RegExp.CreateValidatorFunc() + validatorFunc := variable.Validators[0].CreateValidatorFunc() err := validatorFunc("") if err == nil { diff --git a/pkg/recipeutil/prompt.go b/pkg/recipeutil/prompt.go deleted file mode 100644 index 49e9bb20..00000000 --- a/pkg/recipeutil/prompt.go +++ /dev/null @@ -1,126 +0,0 @@ -package recipeutil - -import ( - "fmt" - "strings" - - "github.com/AlecAivazis/survey/v2" - "github.com/antonmedv/expr" - "github.com/futurice/jalapeno/pkg/recipe" -) - -func PromptUserForValues(variables []recipe.Variable, existingValues recipe.VariableValues) (recipe.VariableValues, error) { - // TODO: This command does not respect stdio defined by the Cobra cmd, so - // capturing and examining the output of this function does not work at the moment - values := recipe.VariableValues{} - headerAdded := false - - for _, variable := range variables { - if !headerAdded { - fmt.Println("\nProvide the following variables:") - headerAdded = true - } - - var prompt survey.Prompt - var askFunc AskFunc = askString - - if variable.If != "" { - result, err := expr.Eval(variable.If, MergeValues(existingValues, values)) - if err != nil { - return nil, fmt.Errorf("error when evaluating 'if' expression: %w", err) - } - - variableShouldBePrompted, ok := result.(bool) - if !ok { - return nil, fmt.Errorf("result of 'if' expression was not a boolean value, was %T instead", result) - } - - if !variableShouldBePrompted { - continue - } - } - - // Select with predefined options - if len(variable.Options) != 0 { - prompt = &survey.Select{ - Message: variable.Name, - Help: variable.Description, - Options: variable.Options, - } - - // Yes/No question - } else if variable.Confirm { - prompt = &survey.Confirm{ - Message: variable.Name, - Help: variable.Description, - Default: variable.Default == "true", - } - askFunc = askBool - - // NOTE: The multiline prompt works quite poorly to provide values for the table, - // and for some reason the "help" field does not work - } else if len(variable.Columns) > 0 { - prompt = &survey.Multiline{ - Message: fmt.Sprintf("%s [EXPERIMENTAL] (columns: %s)", variable.Name, strings.Join(variable.Columns, ", ")), - Help: variable.Description, - } - - // Free input question - } else { - prompt = &survey.Input{ - Message: variable.Name, - Default: variable.Default, - Help: variable.Description, - } - } - - opts := make([]survey.AskOpt, 0) - - if !(variable.Optional || variable.If != "") { - opts = append(opts, survey.WithValidator(survey.Required)) - } - - if variable.RegExp.Pattern != "" { - validator := variable.RegExp.CreateValidatorFunc() - opts = append(opts, survey.WithValidator(validator)) - } - - answer, err := askFunc(prompt, opts) - if err != nil { - return nil, err - } - - if len(variable.Columns) > 0 { - raw := answer.(string) - answer, err = CSVToTable(variable.Columns, raw) - if err != nil { - return nil, err - } - } - - values[variable.Name] = answer - } - - return values, nil -} - -// NOTE: Since survey.AskOne tries to cast the answer to the type of the response -// value pointer and the type of response value can not be interface{}, -// we need to create different ask functions for each response type and return interface{} -type AskFunc func(prompt survey.Prompt, opts []survey.AskOpt) (interface{}, error) - -func askString(prompt survey.Prompt, opts []survey.AskOpt) (interface{}, error) { - return ask[string](prompt, opts) -} - -func askBool(prompt survey.Prompt, opts []survey.AskOpt) (interface{}, error) { - return ask[bool](prompt, opts) -} - -func ask[T string | bool](prompt survey.Prompt, opts []survey.AskOpt) (T, error) { - var answer T - if err := survey.AskOne(prompt, &answer, opts...); err != nil { - return answer, err - } - return answer, nil -} diff --git a/pkg/recipeutil/values.go b/pkg/recipeutil/values.go index e6684e8e..2813eedf 100644 --- a/pkg/recipeutil/values.go +++ b/pkg/recipeutil/values.go @@ -16,7 +16,7 @@ var ( ErrVarNotDefinedInRecipe = errors.New("following variable does not exist in the recipe") ) -func ParseProvidedValues(variables []recipe.Variable, flags []string) (recipe.VariableValues, error) { +func ParseProvidedValues(variables []recipe.Variable, flags []string, delimiter rune) (recipe.VariableValues, error) { values := make(recipe.VariableValues) for _, env := range os.Environ() { if !strings.HasPrefix(env, ValueEnvVarPrefix) { @@ -49,9 +49,9 @@ func ParseProvidedValues(variables []recipe.Variable, flags []string) (recipe.Va return nil, fmt.Errorf("%w: %s", ErrVarNotDefinedInRecipe, varName) } - if targetedVariable.RegExp.Pattern != "" { - validator := targetedVariable.RegExp.CreateValidatorFunc() - if err := validator(varValue); err != nil { + for i := range targetedVariable.Validators { + validatorFunc := targetedVariable.Validators[i].CreateValidatorFunc() + if err := validatorFunc(varValue); err != nil { return nil, fmt.Errorf("validator failed for value '%s=%s': %w", varName, varValue, err) } } @@ -67,7 +67,7 @@ func ParseProvidedValues(variables []recipe.Variable, flags []string) (recipe.Va } case len(targetedVariable.Columns) > 0: varValue = strings.ReplaceAll(varValue, "\\n", "\n") - table, err := CSVToTable(targetedVariable.Columns, varValue) + table, err := CSVToTable(targetedVariable.Columns, varValue, delimiter) if err != nil { return nil, fmt.Errorf("failed to parse table from CSV for variable '%s': %w", varName, err) } @@ -81,6 +81,8 @@ func ParseProvidedValues(variables []recipe.Variable, flags []string) (recipe.Va return values, nil } +// MergeValues merges multiple VariableValues into one. If a key exists in multiple VariableValues, the value from the +// last VariableValues will be used. func MergeValues(valuesSlice ...recipe.VariableValues) recipe.VariableValues { merged := make(recipe.VariableValues) for _, values := range valuesSlice { @@ -103,10 +105,10 @@ func FilterVariablesWithoutValues(variables []recipe.Variable, values recipe.Var return variablesWithoutValues } -func CSVToTable(columns []string, str string) ([]map[string]string, error) { +func CSVToTable(columns []string, str string, delimiter rune) ([]map[string]string, error) { reader := csv.NewReader(strings.NewReader(str)) reader.FieldsPerRecord = len(columns) - reader.Comma = ';' + reader.Comma = delimiter reader.TrimLeadingSpace = true rows, err := reader.ReadAll() @@ -124,3 +126,15 @@ func CSVToTable(columns []string, str string) ([]map[string]string, error) { return table, nil } + +func RowsToTable(columns []string, rows [][]string) ([]map[string]string, error) { + table := make([]map[string]string, len(rows)) + for i, row := range rows { + table[i] = make(map[string]string) + for j, cell := range row { + table[i][columns[j]] = cell + } + } + + return table, nil +} diff --git a/pkg/recipeutil/values_test.go b/pkg/recipeutil/values_test.go index fd4f0af9..30c2d42c 100644 --- a/pkg/recipeutil/values_test.go +++ b/pkg/recipeutil/values_test.go @@ -79,7 +79,7 @@ func TestParsePredefinedValues(t *testing.T) { defer os.Unsetenv(envName) } - actual, err := recipeutil.ParseProvidedValues(test.vars, test.flags) + actual, err := recipeutil.ParseProvidedValues(test.vars, test.flags, ',') if err != nil { if test.expectedErr == nil { t.Fatalf("parser returned error when not expected, error: %+v", err) diff --git a/pkg/survey/editable/model.go b/pkg/survey/editable/model.go new file mode 100644 index 00000000..0263ea9d --- /dev/null +++ b/pkg/survey/editable/model.go @@ -0,0 +1,456 @@ +package editable + +import ( + "errors" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +type Model struct { + KeyMap KeyMap + + cols []Column + rows []Row + cursorX int + cursorY int + focus bool + + styles Styles + table *table.Table +} + +var _ tea.Model = Model{} +var _ table.Data = Model{} + +type Row []Cell + +type Cell struct { + input textinput.Model + err error +} + +type Column struct { + Title string + Width int + Validators []func(string) error +} + +type KeyMap struct { + CellUp key.Binding + CellDown key.Binding + CellLeft key.Binding + CellRight key.Binding + NextCell key.Binding + NewRow key.Binding + PageUp key.Binding + PageDown key.Binding + GotoTop key.Binding + GotoBottom key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + CellUp: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("↑", "up"), + ), + CellDown: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("↓", "down"), + ), + CellLeft: key.NewBinding( + key.WithKeys("left"), + key.WithHelp("←", "left"), + ), + CellRight: key.NewBinding( + key.WithKeys("right"), + key.WithHelp("→", "right"), + ), + NextCell: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next cell"), + ), + NewRow: key.NewBinding( + key.WithKeys("ctrl+n"), + key.WithHelp("ctrl + n", "new"), + ), + GotoTop: key.NewBinding( + key.WithKeys("home"), + key.WithHelp("home", "go to start"), + ), + GotoBottom: key.NewBinding( + key.WithKeys("end"), + key.WithHelp("end", "go to end"), + ), + } +} + +type Styles struct { + Header lipgloss.Style + Cell lipgloss.Style + Selected lipgloss.Style + Error lipgloss.Style +} + +func DefaultStyles() Styles { + return Styles{ + Selected: lipgloss.NewStyle(). + Bold(true). + Background(lipgloss.Color("236")). + Foreground(lipgloss.Color("212")). + Padding(0, 1), + Header: lipgloss.NewStyle(). + Bold(true). + Padding(0, 1), + Cell: lipgloss.NewStyle(). + Padding(0, 1), + Error: lipgloss.NewStyle(). + Foreground(lipgloss.Color("9")), + } +} + +func (m *Model) SetStyles(s Styles) { + m.styles = s + +} + +// Option is used to set options in New. For example: +// +// table := New(WithColumns([]Column{{Title: "ID", Width: 10}})) +type Option func(*Model) + +func NewModel(opts ...Option) Model { + m := Model{ + cursorX: 0, + cursorY: 0, + + KeyMap: DefaultKeyMap(), + styles: DefaultStyles(), + table: table.New(), + } + + for _, opt := range opts { + opt(&m) + } + + m.AddRow() + + return m +} + +func (m Model) At(row, cell int) string { + return m.rows[row][cell].input.View() +} + +func (m Model) Columns() int { + return len(m.cols) +} + +func (m Model) Rows() int { + return len(m.rows) +} + +func WithColumns(columns []Column) Option { + return func(m *Model) { + m.cols = columns + cols := make([]string, len(m.cols)) + for i := range cols { + cols[i] = m.cols[i].Title + } + m.table.Headers(cols...) + } +} + +func WithRows(rows []Row) Option { + return func(m *Model) { + m.rows = rows + } +} + +func WithStyles(s Styles) Option { + return func(m *Model) { + m.styles = s + } +} + +func WithKeyMap(km KeyMap) Option { + return func(m *Model) { + m.KeyMap = km + } +} + +func (m Model) Init() tea.Cmd { + return tea.Batch( + m.rows[0][0].input.Focus(), // Focus on the first cell + textinput.Blink, + ) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd = nil + if !m.focus { + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.KeyMap.CellUp): + cmd = m.MoveUp(1) + case key.Matches(msg, m.KeyMap.CellDown): + cmd = m.MoveDown(1) + case key.Matches(msg, m.KeyMap.CellLeft): + cmd = m.MoveLeft(1) + case key.Matches(msg, m.KeyMap.CellRight): + cmd = m.MoveRight(1) + case key.Matches(msg, m.KeyMap.NextCell): + cmd = m.MoveToNextCell() + case key.Matches(msg, m.KeyMap.NewRow): + m.AddRow() + case key.Matches(msg, m.KeyMap.GotoTop): + cmd = m.GotoTop() + case key.Matches(msg, m.KeyMap.GotoBottom): + cmd = m.GotoBottom() + } + if cmd != nil { + return m, cmd + } + } + + m.rows[m.cursorY][m.cursorX].input, cmd = m.rows[m.cursorY][m.cursorX].input.Update(msg) + return m, cmd +} + +func (m *Model) Focus() { + m.focus = true +} + +func (m *Model) Blur() { + m.focus = false + m.rows[m.cursorY][m.cursorX].input.Blur() +} + +func (m Model) View() string { + var s strings.Builder + s.WriteString(m.table. + StyleFunc(func(y, x int) lipgloss.Style { + switch { + case y == 0: + return m.styles.Header + case y == m.cursorY+1 && x == m.cursorX: + return m.styles.Selected + default: + return m.styles.Cell + } + }). + Data(m). + Render()) + + s.WriteRune('\n') + if errs := m.Errors(); len(errs) != 0 { + for _, err := range errs { + s.WriteString(m.styles.Error.Render(fmt.Sprintf("• %s", err.Error()))) + s.WriteRune('\n') + } + } + return s.String() +} + +func (m *Model) AddRow() { + row := make(Row, len(m.cols)) + for i := range row { + row[i].input = m.newTextInput(m.cols[i]) + } + + m.rows = append(m.rows, row) +} + +func (m *Model) RemoveRow(n int) { + m.rows = append(m.rows[:n], m.rows[n+1:]...) +} + +func (m *Model) SetColumns(c []Column) { + m.cols = c +} + +func (m Model) Cursor() (int, int) { + return m.cursorY, m.cursorX +} + +func (m *Model) MoveUp(n int) tea.Cmd { + return m.Move(-n, 0) +} + +func (m *Model) MoveDown(n int) tea.Cmd { + return m.Move(n, 0) +} + +func (m *Model) MoveLeft(n int) tea.Cmd { + return m.Move(0, -n) +} + +func (m *Model) MoveRight(n int) tea.Cmd { + return m.Move(0, n) +} + +func (m *Model) MoveToNextCell() tea.Cmd { + // If we're not on the last column, move right + if m.cursorX < len(m.cols)-1 { + return m.MoveRight(1) + } + + // else move to the first cell of the next row + return m.Move(1, -(len(m.cols) - 1)) +} + +func (m *Model) GotoTop() tea.Cmd { + return m.Move(-m.cursorY, 0) +} + +func (m *Model) GotoBottom() tea.Cmd { + if m.cursorY == len(m.rows)-1 { + return nil + } + + return m.Move(len(m.rows)-1, 0) +} + +func (m *Model) Move(y, x int) tea.Cmd { + if y == 0 && x == 0 { + return nil + } + + m.rows[m.cursorY][m.cursorX].input.Blur() + m.validateCell(m.cursorY, m.cursorX) + + if x != 0 { + m.cursorX = clamp(m.cursorX+x, 0, len(m.cols)-1) + } + + if y != 0 { + if m.cursorY+y >= len(m.rows) { + for i := 0; i < m.cursorY+y-len(m.rows)+1; i++ { + m.AddRow() + } + } + + if m.cursorY == len(m.rows)-1 && y < 0 && len(m.rows) > 1 { + isEmpty := true + for n := 0; n > y; n-- { + for _, cell := range m.rows[m.cursorY+n] { + if cell.input.Value() != "" { + isEmpty = false + break + } + } + if isEmpty && len(m.rows) > 1 { + m.RemoveRow(m.cursorY + n) + } + } + } + + m.cursorY = clamp(m.cursorY+y, 0, len(m.rows)-1) + } + + // Focus on the new cell + return m.rows[m.cursorY][m.cursorX].input.Focus() +} + +func (m Model) Values() [][]string { + values := make([][]string, len(m.rows)) + for i, row := range m.rows { + values[i] = make([]string, len(row)) + for j, cell := range row { + values[i][j] = cell.input.Value() + } + } + + return values +} + +func (m *Model) Validate() { + for y := range m.rows { + for x := range m.rows[y] { + m.validateCell(y, x) + } + } +} + +func (m Model) Errors() []error { + errs := make([]error, 0, len(m.rows)*len(m.cols)) + for y := range m.rows { + for x := range m.rows[y] { + if m.rows[y][x].err != nil { + errs = append(errs, fmt.Errorf("cell (%d, %d): %w", y, x, m.rows[y][x].err)) + } + } + } + return errs +} + +func (m *Model) validateCell(y, x int) { + cell := &m.rows[y][x] + if m.cols[x].Validators == nil { + return + } + + errs := make([]error, 0, len(m.cols[x].Validators)) + for i := range m.cols[x].Validators { + err := m.cols[x].Validators[i](cell.input.Value()) + if err != nil { + errs = append(errs, err) + } + } + + if len(errs) == 0 { + cell.err = nil + return + } + + if len(errs) == 1 { + cell.err = errs[0] + } + + errStr := make([]string, len(errs)) + for i := range errs { + errStr[i] = errs[i].Error() + } + + cell.err = errors.New(strings.Join(errStr, ", ")) +} + +// newTextInput initializes a text input which is used inside a cell. +func (m Model) newTextInput(c Column) textinput.Model { + ti := textinput.New() + ti.Prompt = "" + + ti.Blur() + + return ti +} + +func max(a, b int) int { + if a > b { + return a + } + + return b +} + +func min(a, b int) int { + if a < b { + return a + } + + return b +} + +func clamp(v, low, high int) int { + return min(max(v, low), high) +} diff --git a/pkg/survey/prompt/confirm.go b/pkg/survey/prompt/confirm.go new file mode 100644 index 00000000..3632a3f3 --- /dev/null +++ b/pkg/survey/prompt/confirm.go @@ -0,0 +1,104 @@ +package prompt + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/futurice/jalapeno/pkg/recipe" + "github.com/futurice/jalapeno/pkg/survey/util" +) + +type ConfirmModel struct { + variable recipe.Variable + styles util.Styles + value bool + submitted bool + showDescription bool +} + +var _ Model = ConfirmModel{} + +func NewConfirmModel(v recipe.Variable, styles util.Styles) ConfirmModel { + return ConfirmModel{ + variable: v, + styles: styles, + value: v.Default == "true", + } +} + +func (m ConfirmModel) Init() tea.Cmd { + return nil +} + +func (m ConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + m.submitted = true + case tea.KeyRight: + m.value = true + case tea.KeyLeft: + m.value = false + case tea.KeyRunes: + switch string(msg.Runes) { + case "?": + if m.variable.Description != "" && !m.showDescription { + m.showDescription = true + return m, nil + } + case "y", "Y": + m.value = true + case "n", "N": + m.value = false + } + } + } + + return m, nil +} + +func (m ConfirmModel) View() string { + var s strings.Builder + s.WriteString(m.styles.VariableName.Render(m.variable.Name)) + if m.submitted { + s.WriteString(": ") + if m.value { + s.WriteString("Yes") + } else { + s.WriteString("No") + } + return s.String() + } + + if m.variable.Description != "" && !m.showDescription { + s.WriteString(m.styles.HelpText.Render(" [type ? for more info]")) + } + + s.WriteRune('\n') + if m.showDescription { + s.WriteString(m.variable.Description) + s.WriteRune('\n') + } + + if m.value { + s.WriteString(fmt.Sprintf("> No/%s", m.styles.Bold.Render("Yes"))) + } else { + s.WriteString(fmt.Sprintf("> %s/Yes", m.styles.Bold.Render("No"))) + } + + return s.String() +} + +func (m ConfirmModel) Name() string { + return m.variable.Name +} + +func (m ConfirmModel) Value() interface{} { + return m.value +} + +func (m ConfirmModel) IsSubmitted() bool { + return m.submitted +} diff --git a/pkg/survey/prompt/prompt.go b/pkg/survey/prompt/prompt.go new file mode 100644 index 00000000..b242e7c6 --- /dev/null +++ b/pkg/survey/prompt/prompt.go @@ -0,0 +1,12 @@ +package prompt + +import tea "github.com/charmbracelet/bubbletea" + +type Model interface { + tea.Model + IsSubmitted() bool + Name() string + Value() interface{} +} + +var _ tea.Model = Model(nil) diff --git a/pkg/survey/prompt/select.go b/pkg/survey/prompt/select.go new file mode 100644 index 00000000..b768cb40 --- /dev/null +++ b/pkg/survey/prompt/select.go @@ -0,0 +1,146 @@ +package prompt + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/futurice/jalapeno/pkg/recipe" + "github.com/futurice/jalapeno/pkg/survey/util" +) + +const listHeight = 14 + +var ( + itemStyle = lipgloss.NewStyle().PaddingLeft(2) + selectedItemStyle = lipgloss.NewStyle().PaddingLeft(0).Foreground(lipgloss.Color("170")) +) + +type SelectModel struct { + variable recipe.Variable + list list.Model + styles util.Styles + value string + showDescription bool + submitted bool +} + +var _ Model = SelectModel{} + +type item string + +var _ list.Item = item("") + +func (i item) FilterValue() string { return "" } + +type itemDelegate struct{} + +var _ list.ItemDelegate = itemDelegate{} + +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 0 } +func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(item) + if !ok { + return + } + + fn := itemStyle.Render + if index == m.Index() { + fn = func(s ...string) string { + return selectedItemStyle.Render("> " + strings.Join(s, " ")) + } + } + + fmt.Fprint(w, fn(string(i))) +} + +func NewSelectModel(v recipe.Variable, styles util.Styles) SelectModel { + items := make([]list.Item, len(v.Options)) + for i := range v.Options { + items[i] = item(v.Options[i]) + } + + const defaultWidth = 20 + + l := list.New(items, itemDelegate{}, defaultWidth, listHeight) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.SetShowHelp(false) + l.SetShowTitle(false) + + return SelectModel{ + variable: v, + list: l, + styles: styles, + } +} + +func (m SelectModel) Init() tea.Cmd { + return nil +} + +func (m SelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.list.SetWidth(msg.Width) + return m, nil + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + m.submitted = true + m.value = string(m.list.SelectedItem().(item)) + case tea.KeyRunes: + switch string(msg.Runes) { + case "?": + if m.variable.Description != "" && !m.showDescription { + m.showDescription = true + return m, nil + } + } + } + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m SelectModel) View() string { + var s strings.Builder + s.WriteString(m.styles.VariableName.Render(m.variable.Name)) + if m.submitted { + s.WriteString(fmt.Sprintf(": %s", m.value)) + return s.String() + } + + if m.variable.Description != "" && !m.showDescription { + s.WriteString(m.styles.HelpText.Render(" [type ? for more info]")) + } + + s.WriteRune('\n') + if m.showDescription { + s.WriteString(m.variable.Description) + s.WriteRune('\n') + } + + s.WriteString(m.list.View()) + return s.String() +} + +func (m SelectModel) Name() string { + return m.variable.Name +} + +func (m SelectModel) Value() interface{} { + return m.value +} + +func (m SelectModel) IsSubmitted() bool { + return m.submitted +} diff --git a/pkg/survey/prompt/string.go b/pkg/survey/prompt/string.go new file mode 100644 index 00000000..6be32131 --- /dev/null +++ b/pkg/survey/prompt/string.go @@ -0,0 +1,132 @@ +package prompt + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/futurice/jalapeno/pkg/recipe" + "github.com/futurice/jalapeno/pkg/survey/util" +) + +type StringModel struct { + variable recipe.Variable + textInput textinput.Model + styles util.Styles + submitted bool + showDescription bool + err error +} + +var _ Model = StringModel{} + +func NewStringModel(v recipe.Variable, styles util.Styles) StringModel { + ti := textinput.New() + ti.Focus() + ti.CharLimit = 156 + ti.Width = 20 + + if v.Default != "" { + ti.SetValue(v.Default) + } + + return StringModel{ + variable: v, + textInput: ti, + err: nil, + styles: styles, + } +} + +func (m StringModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m StringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + if err := m.Validate(); err != nil { + m.err = err + return m, nil + } + m.submitted = true + case tea.KeyRunes: + switch string(msg.Runes) { + case "?": + if m.textInput.Value() == "" && m.variable.Description != "" && !m.showDescription { + m.showDescription = true + return m, nil + } + } + } + } + + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m StringModel) View() string { + var s strings.Builder + s.WriteString(m.styles.VariableName.Render(m.variable.Name)) + + if m.submitted { + s.WriteString(": ") + s.WriteString(m.textInput.Value()) + return s.String() + } + + if m.variable.Description != "" && !m.showDescription { + s.WriteString(m.styles.HelpText.Render(" [type ? for more info]")) + } + + s.WriteRune('\n') + if m.showDescription { + s.WriteString(m.variable.Description) + s.WriteRune('\n') + } + + s.WriteString(m.textInput.View()) + + if m.err != nil { + s.WriteRune('\n') + errMsg := m.err.Error() + errMsg = strings.ToUpper(errMsg[:1]) + errMsg[1:] + s.WriteString(m.styles.ErrorText.Render(errMsg)) + } + + return s.String() +} + +func (m StringModel) Name() string { + return m.variable.Name +} + +func (m StringModel) Value() interface{} { + return m.textInput.Value() +} + +func (m StringModel) IsSubmitted() bool { + return m.submitted +} + +func (m StringModel) Validate() error { + if !m.variable.Optional && m.textInput.Value() == "" { + return util.ErrRequired + } + + for _, v := range m.variable.Validators { + if v.Pattern != "" { + validatorFunc := v.CreateValidatorFunc() + if err := validatorFunc(m.textInput.Value()); err != nil { + return fmt.Errorf("%w: %s", util.ErrRegExFailed, err) + } + } + } + + return nil +} diff --git a/pkg/survey/prompt/table.go b/pkg/survey/prompt/table.go new file mode 100644 index 00000000..b94f1d15 --- /dev/null +++ b/pkg/survey/prompt/table.go @@ -0,0 +1,153 @@ +package prompt + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/futurice/jalapeno/pkg/recipe" + "github.com/futurice/jalapeno/pkg/recipeutil" + "github.com/futurice/jalapeno/pkg/survey/editable" + "github.com/futurice/jalapeno/pkg/survey/util" +) + +type TableModel struct { + variable recipe.Variable + table editable.Model + styles util.Styles + submitted bool + showDescription bool + + // Save the table as CSV for the final output. This speeds up the + // rendering after the user has submitted the form. + tableAsCSV string +} + +var _ Model = TableModel{} + +func NewTableModel(v recipe.Variable, styles util.Styles) TableModel { + cols := make([]editable.Column, len(v.Columns)) + + validators := make(map[string][]func(string) error) + for i, validator := range v.Validators { + if validator.Column != "" { + if validators[validator.Column] == nil { + validators[validator.Column] = make([]func(string) error, 0) + } + + validators[validator.Column] = append(validators[validator.Column], v.Validators[i].CreateValidatorFunc()) + } + } + + for i, c := range v.Columns { + cols[i] = editable.Column{ + Title: c, + Width: len(c), + Validators: validators[c], + } + } + table := editable.NewModel(editable.WithColumns(cols)) + table.Focus() + + return TableModel{ + variable: v, + table: table, + styles: styles, + } +} + +func (m TableModel) Init() tea.Cmd { + return m.table.Init() +} + +func (m TableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + // Validate the table. If there are errors, don't submit the form. + m.table.Validate() + if errs := m.table.Errors(); len(errs) != 0 { + return m, nil + } + + m.submitted = true + m.tableAsCSV = m.ValueAsCSV() + m.table.Blur() + case tea.KeyRunes: + switch string(msg.Runes) { + case "?": + if m.variable.Description != "" && !m.showDescription { + m.showDescription = true + return m, nil + } + } + } + } + tm, cmd := m.table.Update(msg) + m.table = tm.(editable.Model) + return m, cmd +} + +func (m TableModel) View() string { + var s strings.Builder + s.WriteString(m.styles.VariableName.Render(m.variable.Name)) + + if m.submitted { + s.WriteString(": ") + s.WriteString(m.tableAsCSV) + return s.String() + } + + if !m.showDescription { + s.WriteString(m.styles.HelpText.Render(" [type ? for more info]")) + } + + s.WriteRune('\n') + if m.showDescription { + if m.variable.Description != "" { + s.WriteString(m.variable.Description) + s.WriteRune('\n') + } + s.WriteString(m.styles.HelpText.Render(`Table controls: +- arrow keys: to move between cells +- tab: to move to the next cells +- ctrl+n or move past last row: create a new row +`)) + s.WriteRune('\n') + } + + s.WriteString(m.table.View()) + return s.String() +} + +func (m TableModel) Name() string { + return m.variable.Name +} + +func (m TableModel) Value() interface{} { + values, _ := recipeutil.RowsToTable(m.variable.Columns, m.table.Values()) + return values +} + +func (m TableModel) IsSubmitted() bool { + return m.submitted +} + +var ( + csvSeparator = lipgloss.NewStyle().Foreground(lipgloss.Color("#999999")).SetString(",") + csvNewLine = lipgloss.NewStyle().Foreground(lipgloss.Color("#999999")).SetString("\\n") +) + +func (m TableModel) ValueAsCSV() string { + rows := m.table.Values() + s := "" + for y := range rows { + s += strings.Join(rows[y], csvSeparator.String()) + if y < len(rows)-1 { + s += csvNewLine.String() + } + } + + return s +} diff --git a/pkg/survey/survey.go b/pkg/survey/survey.go new file mode 100644 index 00000000..1685126e --- /dev/null +++ b/pkg/survey/survey.go @@ -0,0 +1,223 @@ +package survey + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/antonmedv/expr" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/futurice/jalapeno/pkg/recipe" + "github.com/futurice/jalapeno/pkg/recipeutil" + "github.com/futurice/jalapeno/pkg/survey/prompt" + "github.com/futurice/jalapeno/pkg/survey/util" + "github.com/muesli/termenv" +) + +type SurveyModel struct { + cursor int + submitted bool + variables []recipe.Variable + existingValues recipe.VariableValues + prompts []prompt.Model + styles util.Styles + err error +} + +var ( + ErrUserAborted = errors.New("user aborted") +) + +func NewModel(variables []recipe.Variable, existingValues recipe.VariableValues) SurveyModel { + model := SurveyModel{ + prompts: make([]prompt.Model, 0, len(variables)), + variables: variables, + existingValues: existingValues, + styles: util.DefaultStyles(), + } + + p, err := model.createNextPrompt() + if err != nil { + model.err = err + } + + if p != nil { + model.prompts = append(model.prompts, p) + } + + return model +} + +func (m SurveyModel) Init() tea.Cmd { + if m.err != nil { + return tea.Quit + } + + // Initialize the first prompt (if any) + if len(m.prompts) > 0 { + return m.prompts[0].Init() + } + + return nil +} + +func (m SurveyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + } + } + + // Check if we have already submitted the survey + if m.submitted { + return m, nil + } + + cmds := make([]tea.Cmd, 0, 3) + submit := func() (tea.Model, tea.Cmd) { + m.submitted = true + cmds = append(cmds, tea.Quit) + return m, tea.Batch(cmds...) + } + + if len(m.prompts) == 0 { + return submit() + } + + lastPrompt := &m.prompts[len(m.prompts)-1] + promptModel, promptCmd := (*lastPrompt).Update(msg) + *lastPrompt = promptModel.(prompt.Model) + + if (*lastPrompt).IsSubmitted() { + cmds = append(cmds, promptCmd) + + // Otherwise, move to the next prompt + if p, err := m.createNextPrompt(); err != nil { + m.err = err + cmds = append(cmds, tea.Quit) + } else if p == nil { + return submit() + } else { + m.prompts = append(m.prompts, p) + cmds = append(cmds, p.Init()) + } + + return m, tea.Batch(cmds...) + } + + return m, promptCmd +} + +func (m SurveyModel) View() string { + var s strings.Builder + if len(m.prompts) > 0 && !m.submitted && m.err == nil { + s.WriteString("Provide the following variables:\n\n") + } + + for i := range m.prompts { + isLastPrompt := i == len(m.prompts)-1 && len(m.prompts) > 1 && !m.submitted + if isLastPrompt { + s.WriteRune('\n') + } + + s.WriteString(m.prompts[i].View()) + s.WriteRune('\n') + } + + if m.submitted || m.err != nil { + s.WriteRune('\n') + } + + return s.String() +} + +func (m SurveyModel) Values() recipe.VariableValues { + values := make(recipe.VariableValues, len(m.prompts)) + for _, prompt := range m.prompts { + if prompt.IsSubmitted() { + values[prompt.Name()] = prompt.Value() + } + } + + return values +} + +func (m *SurveyModel) createNextPrompt() (prompt.Model, error) { + if len(m.prompts) > 0 { + m.cursor++ + } + + if m.cursor >= len(m.variables) { + return nil, nil + } + + if p, err := m.createPrompt(m.variables[m.cursor]); err != nil { + return nil, err + } else if p == nil { + return m.createNextPrompt() + } else { + return p, nil + } +} + +// createPrompt creates a prompt for the given variable. Returns nil if the variable should be skipped. +func (m SurveyModel) createPrompt(v recipe.Variable) (prompt.Model, error) { + // Check if variable should be skipped + if v.If != "" { + result, err := expr.Eval(v.If, recipeutil.MergeValues(m.existingValues, m.Values())) + if err != nil { + return nil, fmt.Errorf("error when evaluating variable \"%s\" 'if' expression: %w", v.Name, err) + } + variableShouldBePrompted, ok := result.(bool) + if !ok { + return nil, fmt.Errorf("result of 'if' expression of variable \"%s\" was not a boolean value, was %T instead", v.Name, result) + } + + if !variableShouldBePrompted { + return nil, nil + } + } + + var p prompt.Model + switch { + case len(v.Options) != 0: + p = prompt.NewSelectModel(v, m.styles) + case v.Confirm: + p = prompt.NewConfirmModel(v, m.styles) + case len(v.Columns) > 0: + p = prompt.NewTableModel(v, m.styles) + default: + p = prompt.NewStringModel(v, m.styles) + } + + return p, nil +} + +// PromptUserForValues prompts the user for values for the given variables +func PromptUserForValues(in io.Reader, out io.Writer, variables []recipe.Variable, existingValues recipe.VariableValues) (recipe.VariableValues, error) { + // https://github.com/charmbracelet/lipgloss/issues/73#issuecomment-1144921037 + lipgloss.SetHasDarkBackground(termenv.HasDarkBackground()) + + p := tea.NewProgram(NewModel(variables, existingValues), tea.WithInput(in), tea.WithOutput(out)) + if m, err := p.Run(); err != nil { + return nil, err + } else { + survey, ok := m.(SurveyModel) + if !ok { + return nil, errors.New("internal error: unexpected model type") + } + if survey.err != nil { + return nil, survey.err + } + + if survey.submitted { + return m.(SurveyModel).Values(), nil + } + + return nil, ErrUserAborted + } +} diff --git a/pkg/survey/survey_test.go b/pkg/survey/survey_test.go new file mode 100644 index 00000000..78eb1bc1 --- /dev/null +++ b/pkg/survey/survey_test.go @@ -0,0 +1,96 @@ +package survey_test + +import ( + "reflect" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" + "github.com/futurice/jalapeno/pkg/recipe" + "github.com/futurice/jalapeno/pkg/survey" +) + +func TestPromptUserForValues(t *testing.T) { + testCases := []struct { + name string + variables []recipe.Variable + existingValues recipe.VariableValues + expected recipe.VariableValues + input string + }{ + { + name: "string_variable", + variables: []recipe.Variable{ + {Name: "VAR_1"}, + }, + expected: recipe.VariableValues{ + "VAR_1": "foo", + }, + input: "foo\n", + }, + { + name: "select_variable", + variables: []recipe.Variable{ + {Name: "VAR_1", Options: []string{"a", "b", "c"}}, + }, + expected: recipe.VariableValues{ + "VAR_1": "c", + }, + input: "↓↓\n", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(tt *testing.T) { + tm := teatest.NewTestModel( + tt, + survey.NewModel(tc.variables, tc.existingValues), + teatest.WithInitialTermSize(300, 100), + ) + + for _, r := range tc.input { + tm.Send(RuneToKey(r)) + } + + m := tm.FinalModel(tt, teatest.WithFinalTimeout(time.Second)).(survey.SurveyModel) + m.Values() + + // Assert that the result is correct + result := m.Values() + if !reflect.DeepEqual(result, tc.expected) { + t.Errorf("Unexpected result. Got %v, expected %v", result, tc.expected) + } + }) + } +} + +func RuneToKey(r rune) tea.KeyMsg { + switch r { + case '\n': + return tea.KeyMsg{ + Type: tea.KeyEnter, + } + case '↑': + return tea.KeyMsg{ + Type: tea.KeyUp, + } + case '↓': + return tea.KeyMsg{ + Type: tea.KeyDown, + } + case '←': + return tea.KeyMsg{ + Type: tea.KeyLeft, + } + case '→': + return tea.KeyMsg{ + Type: tea.KeyRight, + } + default: + return tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune{r}, + } + } +} diff --git a/pkg/survey/util/util.go b/pkg/survey/util/util.go new file mode 100644 index 00000000..4cf33e45 --- /dev/null +++ b/pkg/survey/util/util.go @@ -0,0 +1,32 @@ +package util + +import ( + "errors" + + "github.com/charmbracelet/lipgloss" +) + +var ( + ErrRequired = errors.New("value can not be empty") + ErrRegExFailed = errors.New("validation failed") +) + +type Styles struct { + VariableName lipgloss.Style + ErrorText lipgloss.Style + HelpText lipgloss.Style + Bold lipgloss.Style +} + +func DefaultStyles() Styles { + return Styles{ + VariableName: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#04B575")), + ErrorText: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")), + HelpText: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#999999")), + Bold: lipgloss.NewStyle().Bold(true), + } +}