├── .gitignore ├── .shellcheckrc ├── .shellspec ├── .tool-versions ├── CHANGELOG.md ├── .editorconfig ├── doc └── demo.png ├── tools ├── alpine ├── specs └── coverage ├── spec ├── spec_helper.sh ├── dye_out_spec.sh ├── dye_detect_spec.sh └── dye_spec.sh ├── LICENSE.md ├── demo ├── dye.sh └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | -------------------------------------------------------------------------------- /.shellcheckrc: -------------------------------------------------------------------------------- 1 | enable=all 2 | -------------------------------------------------------------------------------- /.shellspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | shellspec 0.28.1 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.0.0 2 | 3 | Initial version. 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.sh] 2 | indent_style = tab 3 | indent_size = 8 4 | -------------------------------------------------------------------------------- /doc/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieb/dye/main/doc/demo.png -------------------------------------------------------------------------------- /tools/alpine: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | docker run \ 4 | -it \ 5 | --rm \ 6 | -v "${PWD}:/mnt" \ 7 | alpine sh 8 | 9 | -------------------------------------------------------------------------------- /tools/specs: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | for shell in bash dash ksh zsh; do 6 | shell_path="$(command -v "${shell}")" 7 | if [ -x "${shell_path}" ]; then 8 | shellspec -s "${shell_path}" 9 | fi 10 | done 11 | 12 | -------------------------------------------------------------------------------- /tools/coverage: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | uid="$(id -u)" 6 | gid="$(id -g)" 7 | 8 | docker run \ 9 | -it \ 10 | --rm \ 11 | -u "${uid}:${gid}" \ 12 | -v "${PWD}:/src" \ 13 | shellspec/shellspec:kcov --kcov --shell bash 14 | 15 | echo "${PWD}/coverage/index.html" 16 | -------------------------------------------------------------------------------- /spec/spec_helper.sh: -------------------------------------------------------------------------------- 1 | # shellcheck shell=sh 2 | 3 | # Defining variables and functions here will affect all specfiles. 4 | # Change shell options inside a function may cause different behavior, 5 | # so it is better to set them here. 6 | set -eu 7 | 8 | # This callback function will be invoked only once before loading specfiles. 9 | spec_helper_precheck() { 10 | # Available functions: info, warn, error, abort, setenv, unsetenv 11 | # Available variables: VERSION, SHELL_TYPE, SHELL_VERSION 12 | : minimum_version "0.28.1" 13 | } 14 | 15 | # This callback function will be invoked after a specfile has been loaded. 16 | spec_helper_loaded() { 17 | : 18 | } 19 | 20 | # This callback function will be invoked after core modules has been loaded. 21 | spec_helper_configure() { 22 | # Available functions: import, before_each, after_each, before_all, after_all 23 | : import 'support/custom_matcher' 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | dye is Copyright 2025 Mattie Behrens. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to one of the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /spec/dye_out_spec.sh: -------------------------------------------------------------------------------- 1 | # shellcheck shell=sh disable=SC2034,SC2329 2 | 3 | Describe "dye_out" 4 | Include "./dye.sh" 5 | 6 | Example "only prints third argument if DYE_COLORS is unset" 7 | tput() { 8 | called=1 9 | %preserve called 10 | } 11 | 12 | unset DYE_COLORS 13 | When call dye_out "setaf 1" "sgr0" "red text" 14 | The variable called should be undefined 15 | The output should equal "red text" 16 | End 17 | 18 | Example "calls tput once if DYE_COLORS is set and no text is present" 19 | tput() { 20 | arg1="$1" 21 | arg2="${2-}" 22 | %preserve arg1 arg2 23 | } 24 | 25 | DYE_COLORS=256 26 | When call dye_out "setaf 1" "sgr0" 27 | The variable arg1 should equal "setaf" 28 | The variable arg2 should equal "1" 29 | End 30 | 31 | Example "outputs start sequence, text, and end sequence if DYE_COLORS is set and text is present" 32 | tput() { 33 | test "$1" = "setaf" -a "${2-}" = "1" && printf "%s" "!RED!" && return 0 34 | test "$1" = "sgr0" && printf "%s" "!RESET!" && return 0 35 | return 1 36 | } 37 | 38 | DYE_COLORS=256 39 | When call dye_out "setaf 1" "sgr0" "red text" 40 | The output should equal "!RED!red text!RESET!" 41 | End 42 | End -------------------------------------------------------------------------------- /demo: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # shellcheck disable=SC2312 3 | # 4 | # Demos many of dye's features. 5 | # 6 | # The command use is intentionally varied, using wrapped mode or manual, 7 | # as well as several aliases for several emphases. 8 | # 9 | 10 | set -eu 11 | 12 | # shellcheck disable=SC1091 13 | . ./dye.sh 14 | 15 | dye setup 16 | 17 | cat <")") Wrapping quoted text: $( 101 | dye so "Like \$(dye green \"this\").") 102 | 103 | $(dye bold "$(dye blue "==>")") Unquoted text: $( 104 | dye so "Like \$(dye green this, if whitespace is not important.)") 105 | 106 | $(dye bold "$(dye blue "==>")") Manual: $( 107 | dye so "Like \$(dye green)this\$(dye reset).") 108 | 109 | You can embed it or source it, depending on your needs. 110 | 111 | You can also just use parts of it, like the enable-color detection. 112 | 113 | And it's $(dye ul "completely tested") with $(dye bold "shellspec")! 114 | 115 | $(dye brightblue)$(dye bold)https://mattiebee.dev/dye$(dye reset) 116 | 117 | EOT 118 | -------------------------------------------------------------------------------- /dye.sh: -------------------------------------------------------------------------------- 1 | # shellcheck shell=sh 2 | # 3 | # Copyright 2025 Mattie Behrens. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the “Software”), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to one of the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | dye_detect() { 24 | command -v tput >/dev/null || return 1 25 | test -n "${NO_COLOR-}" && return 1 26 | test -n "${CLICOLOR_FORCE-}" && return 27 | test -t 1 || return 1 28 | test -n "${CLICOLOR-}" && return 29 | test "${1-}" = "default-off" && return 1 30 | return 0 31 | } 32 | 33 | dye_out() ( 34 | _1="$1" 35 | _2="${2-}" 36 | test -n "$1" && shift 37 | test -n "${1-}" && shift 38 | if [ -z "${DYE_COLORS-}" ]; then 39 | printf "%s" "$*" 40 | return 41 | fi 42 | if [ -z "${_2}" ] || [ -z "${1-}" ]; then 43 | eval "tput ${_1}" || true 44 | return 45 | fi 46 | eval "tput ${_1}" || true 47 | printf "%s" "$*" 48 | eval "tput ${_2}" || true 49 | ) 50 | 51 | dye_color() ( 52 | case "$1" in 53 | black) echo 0 ;; 54 | red) echo 1 ;; 55 | green) echo 2 ;; 56 | yellow) echo 3 ;; 57 | blue) echo 4 ;; 58 | magenta) echo 5 ;; 59 | cyan) echo 6 ;; 60 | white | brightgray) echo 7 ;; 61 | gray) echo 8 ;; 62 | bright*) 63 | base="$(dye_color "${1##*bright}")" 64 | echo "$((8 + base))" 65 | ;; 66 | '' | *[!0-9]*) return 1 ;; 67 | *) echo "$1" ;; 68 | esac 69 | ) 70 | 71 | dye_synth() { 72 | if [ "${DYE_COLORS}" = "8" ] && [ "$1" -ge 8 ] && [ "$1" -le 15 ]; then 73 | echo $(($1 - 8)) 74 | return 1 75 | fi 76 | echo "$1" 77 | } 78 | 79 | dye() { 80 | if [ "$1" = "setup" ]; then 81 | shift 82 | dye_detect "$@" && DYE_COLORS="$(tput colors)" 83 | return 0 84 | fi 85 | 86 | ( 87 | _1="$1" 88 | shift 89 | case "${_1}" in 90 | fg) 91 | c="$(dye_color "$1")" || return 92 | c="$(dye_synth "${c}")" || dye_out bold 93 | shift 94 | dye_out "setaf ${c}" "sgr0" "$@" 95 | ;; 96 | bg) 97 | c="$(dye_color "$1")" || return 98 | c="$(dye_synth "${c}")" || true 99 | shift 100 | dye_out "setab ${c}" "sgr0" "$@" 101 | ;; 102 | dim) dye_out "dim" "sgr0" "$@" ;; 103 | bold) dye_out "bold" "sgr0" "$@" ;; 104 | reverse) dye_out "rev" "sgr0" "$@" ;; 105 | reset) dye_out "sgr0" ;; 106 | i | italic) dye_out "sitm" "ritm" "$@" ;; 107 | so | standout) dye_out "smso" "rmso" "$@" ;; 108 | u | ul | underline) dye_out "smul" "rmul" "$@" ;; 109 | begin) dye "$@" ;; 110 | end) 111 | case "$1" in 112 | i | italic) dye_out "ritm" ;; 113 | so | standout) dye_out "rmso" ;; 114 | u | ul | underline) dye_out "rmul" ;; 115 | *) return 1 ;; 116 | esac 117 | ;; 118 | *) 119 | dye fg "${_1}" "$@" 120 | ;; 121 | esac 122 | ) 123 | } 124 | -------------------------------------------------------------------------------- /spec/dye_detect_spec.sh: -------------------------------------------------------------------------------- 1 | # shellcheck shell=sh disable=SC2034 2 | 3 | Describe "dye_detect" 4 | Include "./dye.sh" 5 | 6 | Parameters 7 | # tput tty NO_COLOR CLICOLOR_FORCE CLICOLOR default result 8 | present yes "" "" "" "" success 9 | present yes "" "" "" default-off failure 10 | present yes set "" "" "" failure 11 | present yes set "" "" default-off failure 12 | present yes "" set "" "" success 13 | present yes "" set "" default-off success 14 | present yes set set "" "" failure 15 | present yes set set "" default-off failure 16 | present yes "" "" set "" success 17 | present yes "" "" set default-off success 18 | present yes set "" set "" failure 19 | present yes set "" set default-off failure 20 | present yes "" set set "" success 21 | present yes "" set set default-off success 22 | present yes set set set "" failure 23 | present yes set set set default-off failure 24 | present no "" "" "" "" failure 25 | present no "" "" "" default-off failure 26 | present no set "" "" "" failure 27 | present no set "" "" default-off failure 28 | present no "" set "" "" success 29 | present no "" set "" default-off success 30 | present no set set "" "" failure 31 | present no set set "" default-off failure 32 | present no "" "" set "" failure 33 | present no "" "" set default-off failure 34 | present no set "" set "" failure 35 | present no set "" set default-off failure 36 | present no "" set set "" success 37 | present no "" set set default-off success 38 | present no set set set "" failure 39 | present no set set set default-off failure 40 | absent yes "" "" "" "" failure 41 | absent yes "" "" "" default-off failure 42 | absent yes set "" "" "" failure 43 | absent yes set "" "" default-off failure 44 | absent yes "" set "" "" failure 45 | absent yes "" set "" default-off failure 46 | absent yes set set "" "" failure 47 | absent yes set set "" default-off failure 48 | absent yes "" "" set "" failure 49 | absent yes "" "" set default-off failure 50 | absent yes set "" set "" failure 51 | absent yes set "" set default-off failure 52 | absent yes "" set set "" failure 53 | absent yes "" set set default-off failure 54 | absent yes set set set "" failure 55 | absent yes set set set default-off failure 56 | absent no "" "" "" "" failure 57 | absent no "" "" "" default-off failure 58 | absent no set "" "" "" failure 59 | absent no set "" "" default-off failure 60 | absent no "" set "" "" failure 61 | absent no "" set "" default-off failure 62 | absent no set set "" "" failure 63 | absent no set set "" default-off failure 64 | absent no "" "" set "" failure 65 | absent no "" "" set default-off failure 66 | absent no set "" set "" failure 67 | absent no set "" set default-off failure 68 | absent no "" set set "" failure 69 | absent no "" set set default-off failure 70 | absent no set set set "" failure 71 | absent no set set set default-off failure 72 | End 73 | 74 | Example "tput $1, tty $2, NO_COLOR=\"$3\", CLICOLOR_FORCE=\"$4\", CLICOLOR=\"$5\", \$1=\"$6\" → $7" 75 | if [ "$1" = "present" ]; then 76 | command() { 77 | [ "$1" = "-v" ] && [ "$2" = "tput" ] && echo "/usr/bin/tput" && return 0 78 | return 1 79 | } 80 | else 81 | command() { 82 | return 1 83 | } 84 | fi 85 | 86 | if [ "$2" = "yes" ]; then 87 | test() 88 | { 89 | [ "$1" = "-t" ] && [ "$2" = "1" ] && return 0 90 | # shellcheck disable=SC2244 91 | [ "$@" ] 92 | } 93 | else 94 | test() 95 | { 96 | [ "$1" = "-t" ] && [ "$2" = "1" ] && return 1 97 | # shellcheck disable=SC2244 98 | [ "$@" ] 99 | } 100 | fi 101 | 102 | unset NO_COLOR 103 | unset CLICOLOR_FORCE 104 | unset CLICOLOR 105 | test -n "$3" && NO_COLOR="$3" 106 | test -n "$4" && CLICOLOR_FORCE="$4" 107 | test -n "$5" && CLICOLOR="$5" 108 | 109 | if [ -n "$6" ]; then 110 | When call dye_detect "$6" 111 | else 112 | When call dye_detect 113 | fi 114 | 115 | The status should be "$7" 116 | End 117 | End 118 | -------------------------------------------------------------------------------- /spec/dye_spec.sh: -------------------------------------------------------------------------------- 1 | # shellcheck shell=sh disable=SC2034,SC2329 2 | 3 | Describe "dye" 4 | Include "./dye.sh" 5 | 6 | # useful for all dye tests 7 | tput() { 8 | test -n "${2-}" && printf "%s" "\e{$1;$2}" && return 9 | test -n "$1" -a "${2-unset}" = "unset" && printf "%s" "\e{$1}" && return 10 | } 11 | 12 | Describe "color" 13 | Parameters 14 | # name number 15 | black 0 16 | red 1 17 | green 2 18 | yellow 3 19 | blue 4 20 | magenta 5 21 | cyan 6 22 | white 7 23 | gray 8 24 | brightred 9 25 | brightgreen 10 26 | brightyellow 11 27 | brightblue 12 28 | brightmagenta 13 29 | brightcyan 14 30 | brightwhite 15 31 | brightgray 7 32 | brightbrightred 9 # treat many "bright"s as one to avoid infinite recursion 33 | 55 55 34 | End 35 | 36 | DYE_COLORS=256 37 | 38 | Example "$1 without text does not reset" 39 | name="$1" 40 | number="$2" 41 | 42 | When call dye "${name}" 43 | The output should equal "\e{setaf;${number}}" 44 | End 45 | 46 | Example "fg $1 without text does not reset" 47 | name="$1" 48 | number="$2" 49 | 50 | When call dye fg "${name}" 51 | The output should equal "\e{setaf;${number}}" 52 | End 53 | 54 | Example "bg $1 without text does not reset" 55 | name="$1" 56 | number="$2" 57 | 58 | When call dye bg "${name}" 59 | The output should equal "\e{setab;${number}}" 60 | End 61 | 62 | Example "$1 with text reset after text" 63 | name="$1" 64 | number="$2" 65 | 66 | When call dye "${name}" "${name} text" 67 | The output should equal "\e{setaf;${number}}${name} text\e{sgr0}" 68 | End 69 | 70 | Example "fg $1 with text reset after text" 71 | name="$1" 72 | number="$2" 73 | 74 | When call dye "${name}" "${name} text" 75 | The output should equal "\e{setaf;${number}}${name} text\e{sgr0}" 76 | End 77 | 78 | Example "bg $1 with text reset after text" 79 | name="$1" 80 | number="$2" 81 | 82 | When call dye bg "${name}" "${name} text" 83 | The output should equal "\e{setab;${number}}${name} text\e{sgr0}" 84 | End 85 | End 86 | 87 | Describe "synthesized high color" 88 | Parameters 89 | # name number bold 90 | black 0 "" 91 | red 1 "" 92 | green 2 "" 93 | yellow 3 "" 94 | blue 4 "" 95 | magenta 5 "" 96 | cyan 6 "" 97 | white 7 "" 98 | gray 0 bold 99 | brightred 1 bold 100 | brightgreen 2 bold 101 | brightyellow 3 bold 102 | brightblue 4 bold 103 | brightmagenta 5 bold 104 | brightcyan 6 bold 105 | brightwhite 7 bold 106 | brightgray 7 "" 107 | 55 55 "" 108 | End 109 | 110 | DYE_COLORS=8 111 | 112 | Example "$1 without text does not reset, synthesizes" 113 | name="$1" 114 | number="$2" 115 | bold="$3" 116 | expect_bold="" 117 | if [ -n "${bold}" ]; then 118 | expect_bold="\e{bold}" 119 | fi 120 | 121 | When call dye "${name}" 122 | The output should equal "${expect_bold}\e{setaf;${number}}" 123 | End 124 | 125 | Example "fg $1 without text does not reset, synthesizes" 126 | name="$1" 127 | number="$2" 128 | bold="$3" 129 | expect_bold="" 130 | if [ -n "${bold}" ]; then 131 | expect_bold="\e{bold}" 132 | fi 133 | 134 | When call dye fg "${name}" 135 | The output should equal "${expect_bold}\e{setaf;${number}}" 136 | End 137 | 138 | Example "bg $1 without text does not reset, does not synthesize" 139 | name="$1" 140 | number="$2" 141 | 142 | When call dye bg "${name}" 143 | The output should equal "\e{setab;${number}}" 144 | End 145 | 146 | Example "$1 with text reset after text, synthesizes" 147 | name="$1" 148 | number="$2" 149 | bold="$3" 150 | expect_bold="" 151 | if [ -n "${bold}" ]; then 152 | expect_bold="\e{bold}" 153 | fi 154 | 155 | When call dye "${name}" "${name} text" 156 | The output should equal "${expect_bold}\e{setaf;${number}}${name} text\e{sgr0}" 157 | End 158 | 159 | Example "fg $1 with text reset after text, synthesizes" 160 | name="$1" 161 | number="$2" 162 | bold="$3" 163 | expect_bold="" 164 | if [ -n "${bold}" ]; then 165 | expect_bold="\e{bold}" 166 | fi 167 | 168 | When call dye "${name}" "${name} text" 169 | The output should equal "${expect_bold}\e{setaf;${number}}${name} text\e{sgr0}" 170 | End 171 | 172 | Example "bg $1 with text reset after text, does not synthesize" 173 | name="$1" 174 | number="$2" 175 | 176 | When call dye bg "${name}" "${name} text" 177 | The output should equal "\e{setab;${number}}${name} text\e{sgr0}" 178 | End 179 | End 180 | 181 | Describe "invalid color" 182 | DYE_COLORS=256 183 | 184 | Example "x55 does not output anything and fails" 185 | When call dye x55 186 | The output should equal "" 187 | The status should be failure 188 | End 189 | 190 | Example "fg x55 does not output anything" 191 | When call dye fg x55 192 | The output should equal "" 193 | The status should be failure 194 | End 195 | 196 | Example "bg x55 does not output anything" 197 | When call dye bg x55 198 | The output should equal "" 199 | The status should be failure 200 | End 201 | End 202 | 203 | Describe "sgr0-resettable" 204 | Parameters 205 | # name cap 206 | dim dim 207 | bold bold 208 | reverse rev 209 | End 210 | 211 | DYE_COLORS=256 212 | 213 | Example "$1 without text does not reset" 214 | name="$1" 215 | cap="$2" 216 | 217 | When call dye "${name}" 218 | The output should equal "\e{${cap}}" 219 | End 220 | 221 | Example "begin $1 without text does not reset" 222 | name="$1" 223 | cap="$2" 224 | 225 | When call dye begin "${name}" 226 | The output should equal "\e{${cap}}" 227 | End 228 | 229 | Example "$1 with text resets after text" 230 | name="$1" 231 | cap="$2" 232 | 233 | When call dye "${name}" "${name} text" 234 | The output should equal "\e{${cap}}${name} text\e{sgr0}" 235 | End 236 | End 237 | 238 | Describe "endable" 239 | Parameters 240 | # name begin end 241 | italic sitm ritm 242 | i sitm ritm 243 | standout smso rmso 244 | so smso rmso 245 | underline smul rmul 246 | u smul rmul 247 | ul smul rmul 248 | End 249 | 250 | DYE_COLORS=256 251 | 252 | Example "$1 without text does not end" 253 | name="$1" 254 | begin="$2" 255 | 256 | When call dye "${name}" 257 | The output should equal "\e{${begin}}" 258 | End 259 | 260 | Example "begin $1 without text does not end" 261 | name="$1" 262 | begin="$2" 263 | 264 | When call dye begin "${name}" 265 | The output should equal "\e{${begin}}" 266 | End 267 | 268 | Example "end $1 ends" 269 | name="$1" 270 | end="$3" 271 | 272 | When call dye end "${name}" 273 | The output should equal "\e{${end}}" 274 | End 275 | 276 | Example "$1 with text ends after text" 277 | name="$1" 278 | begin="$2" 279 | end="$3" 280 | 281 | When call dye "${name}" "${name} text" 282 | The output should equal "\e{${begin}}${name} text\e{${end}}" 283 | End 284 | End 285 | 286 | Describe "invalid endable" 287 | Parameters 288 | dim 289 | bold 290 | reverse 291 | invalid 292 | End 293 | 294 | DYE_COLORS=256 295 | 296 | Example "end $1 does not output anything and fails" 297 | name="$1" 298 | 299 | When call dye end "${name}" 300 | The output should equal "" 301 | The status should be failure 302 | End 303 | End 304 | 305 | Example "reset" 306 | DYE_COLORS=256 307 | When call dye reset 308 | The output should equal "\e{sgr0}" 309 | End 310 | 311 | Example "text does not necessarily need to be quoted" 312 | DYE_COLORS=256 313 | When call dye underline we don\'t need quoting here 314 | The output should equal "\e{smul}we don't need quoting here\e{rmul}" 315 | End 316 | 317 | Describe "setup" 318 | Example "no arguments" 319 | dye_detect() { 320 | args="$*" 321 | %preserve args 322 | } 323 | 324 | When call dye setup 325 | The variable args should equal "" 326 | End 327 | 328 | Example "default-off" 329 | dye_detect() { 330 | args="$*" 331 | %preserve args 332 | } 333 | 334 | When call dye setup default-off 335 | The variable args should equal "default-off" 336 | End 337 | 338 | Example "sets DYE_COLORS if dye_detect succeeds" 339 | dye_detect() { 340 | return 0 341 | } 342 | tput() { 343 | [ "$1" = "colors" ] && echo 256 344 | } 345 | 346 | unset DYE_COLORS 347 | When call dye setup 348 | The variable DYE_COLORS should equal "256" 349 | End 350 | 351 | Example "leaves DYE_COLORS unset if dye_detect fails, does not query for colors" 352 | dye_detect() { 353 | return 1 354 | } 355 | tput() { 356 | return 1 357 | } 358 | 359 | unset DYE_COLORS 360 | When call dye setup 361 | The variable DYE_COLORS should be undefined 362 | End 363 | End 364 | End 365 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [dye](https://mattiebee.dev/dye) 2 | 3 | - [About](#about) 4 | - [Usage](#usage) 5 | - [Development](#development) 6 | 7 | ## Demo 8 | 9 | ![A screenshot of the "demo" script in action](./doc/demo.png) 10 | 11 | ## About 12 | 13 | dye is a **portable** and **respectful** library for adding color and emphasis to the output of shell scripts. 14 | 15 | It's portable because 16 | 17 | - it works on many Unix systems, including macOS, Linux, and OpenBSD; 18 | 19 | - it is written to [the POSIX shell standard](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#tag_19), so it works in many shells that are POSIX-compatible, such as [ash and Dash](https://en.wikipedia.org/wiki/Almquist_shell), [Bash](https://www.gnu.org/software/bash/), [ksh](https://en.wikipedia.org/wiki/KornShell), and [Zsh](https://zsh.sourceforge.io); 20 | 21 | - it uses [tput(1)](https://man.openbsd.org/tput) instead of hard-wired ANSI sequences, so it will work wherever the appropriate [terminal capabilities](https://man.openbsd.org/terminfo.5) are available, and gracefully degrade where they are not; and 22 | 23 | - it additionally degrades gracefully if tput(1) is not available (e.g. in [a Docker alpine image](https://hub.docker.com/_/alpine) where ncurses is not installed). 24 | 25 | It's respectful because: 26 | 27 | - it will disable if ["NO_COLOR"](https://no-color.org) is set; 28 | 29 | - it will enable if ["CLICOLOR"](https://bixense.com/clicolors/) is set, but only if stdout is a tty; 30 | 31 | - it will unconditionally enable (e.g. if stdout is not a [tty](https://en.wikipedia.org/wiki/Tty_(Unix))) if "CLICOLOR_FORCE" is set; 32 | 33 | - it allows script developers to choose to default to color off unless the user has opted in with environment variables; and 34 | 35 | - it is written to only put functions and environment variables prefixed with "dye" or "DYE" into the shell's global namespace, and carefully avoids clobbering any existing shell variables during operation. 36 | 37 | dye does call tput(1) for every terminal sequence it needs to output, so it's not screamingly fast. However, in practice, it's more than fast enough for the job I need it for: making the output of shell scripts colorful to make them easier to read and scan. If you're working with lots of color (for example, creating [ANSI art](https://en.wikipedia.org/wiki/ANSI_art)), it's probably best to stick to a solution that caches ANSI sequences and forgo the portability. 38 | 39 | I wrote dye to replace my previous project, [portable-color](https://mattiebee.dev/portable-color). portable-color was fine, but would load lots of functions into the shell's global namespace. dye has a better API, with more capabilities and conveniences. 40 | 41 | ## Usage 42 | 43 | ### Embedding 44 | 45 | You can use dye in your script by copying the contents of [dye.sh](./dye.sh) above the place you will use it. 46 | 47 | This the method I recommend. Shell scripts that I write are generally made to be self-contained, and embedding makes them very easy to download and use. 48 | 49 | A couple notes on this strategy: 50 | 51 | - If you end up changing dye's code, consider changing the names of the dye functions and variables that end up in the shell's global namespace, to avoid conflicts with the standard dye code that may be depended on elsewhere—particularly if you're removing functionality you do not use. 52 | 53 | - If you distribute your script with dye's code inline, you must include a copy of [the license](./LICENSE.md) in some way with your script. This specifically so others understand their rights in regard to the use of this software. 54 | 55 | ### Sourcing 56 | 57 | If you have [dye.sh](./dye.sh) available [on your PATH](https://mattiebee.io/44251/a-proposal-for-shell-libraries) (executable bit not necessary), you can load it very simply: 58 | 59 | ```shell 60 | . dye.sh || exit 1 61 | ``` 62 | 63 | If you include a copy alongside your script, you can also load it from a specific directory: 64 | 65 | ```shell 66 | . ./dye.sh || exit 1 67 | ``` 68 | 69 | ### Initialization 70 | 71 | dye must be initialized once before use, or no text changes will happen when you use the color or emphasis routines: 72 | 73 | ```shell 74 | dye setup 75 | ``` 76 | 77 | "setup" is where dye will check things like whether stdout is a tty, whether environment variables like "NO_COLOR" and "CLICOLOR" are set, and make a decision whether or not to set the variable "DYE_COLORS" to the number of available colors, which the other routines will use. 78 | 79 | An alternate mode for "setup" will not enable color by default, but will enable it if the user has "CLICOLOR" set: 80 | 81 | ```shell 82 | dye setup default-off 83 | ``` 84 | 85 | ### Wrapping text 86 | 87 | For simple [color](#colors) and [emphasis](#emphases), using dye to wrap quoted text is the most convenient method. 88 | 89 | ```shell 90 | echo "$(dye green "It's not easy being... well, you know.")" 91 | ``` 92 | 93 | ```shell 94 | echo "So $(dye bold "bold"), it's not recommended for human consumption\!" 95 | ``` 96 | 97 | Quoting text is also not *strictly* necessary, but can result in the need to use many more escapes (just like it would if using "echo" straight up). It also means whitespace gets collapsed, so beware! 98 | 99 | ```shell 100 | echo Quotes\? Quotes\? $(dye italic We don\'t need no stinking quotes\!) 101 | ``` 102 | 103 | #### Resets 104 | 105 | When wrapping text, one key caveat applies: all colors and several emphases do not have a matching ending terminal sequence—they can only be turned off by sending an "sgr0" terminal capability to reset *all* color and emphasis. 106 | 107 | dye will send this reset sequence at the end of wrapped text for colors and select emphases, so it's best not to stack wrappers. It gets unreadable really fast, anyway, so it's better to use [manual control](#manual-control). 108 | 109 | ### Manual control 110 | 111 | More complex markup is easier to manage with manual control: 112 | 113 | ```shell 114 | echo "$(dye cyan)$(dye bold)Cyan$(dye reset), $(dye magenta)$(dye bold 115 | )magenta$(dye reset), and $(dye bold)white$(dye reset 116 | ) ought to be enough for anybody." 117 | ``` 118 | 119 | Using lots of manual control can make lines pretty long, but as you can see, you can also leverage the fact that line breaks are valid inside command substitution to break them up. 120 | 121 | ### Colors 122 | 123 | Many colors are available for use, subject to terminal support. 124 | 125 | There's the basic ANSI color set: 126 | 127 | - `black` (or `0`) 128 | - `red` (or `1`) 129 | - `green` (or `2`) 130 | - `yellow` (or `3`) 131 | - `blue` (or `4`) 132 | - `magenta` (or `5`) 133 | - `cyan` (or `6`) 134 | - `white` (or `7`, or `brightgray`) 135 | - `gray` (or `8`) 136 | - `brightred` (or `9`) 137 | - `brightgreen` (or `10`) 138 | - `brightyellow` (or `11`) 139 | - `brightblue` (or `12`) 140 | - `brightmagenta` (or `13`) 141 | - `brightcyan` (or `14`) 142 | - `brightwhite` (or `15`) 143 | 144 | "dye yellow" will set the foreground color to yellow, for example. There are also "fg" and "bg" commands that will explicitly set the foreground or background color, respectively: 145 | 146 | ```shell 147 | echo "$(dye bg blue)$(dye fg yellow)In Ann Arbor, everything is this color.$(dye reset)" 148 | ``` 149 | 150 | #### High colors 151 | 152 | Some terminal definitions, like "ansi" and "xterm", don't recognize colors higher than 7. If "DYE_COLORS" is 8, indicating this scenario, dye will synthesize "bright" colors by turning on "bold" and setting the non-bright equivalent. 153 | 154 | Note that this also means that "bold" may be turned on unexpectedly if you're using "bright" colors—so keep this situation in mind: 155 | 156 | - If you're nesting wrapped text, make sure that nested text deals with the fact that "bold" might be on if you're using a "bright" color. 157 | 158 | - If you're using manual control, be sure to reset at the appropriate time if the possiblity that "bold" might be on. 159 | 160 | You can test to see how your code is working by setting TERM to "ansi" or "xterm" on many systems. 161 | 162 | ### Emphases 163 | 164 | Several emphases are available as well. 165 | 166 | #### Resettable emphases 167 | 168 | The first group, like colors, must be [reset](#resets) to turn them off: 169 | 170 | - `dim` (makes things darker) 171 | - `bold` 172 | - `reverse` (see also "standout" below) 173 | 174 | They can be used just like colors, and wrapping text with them will automatically send a reset at the end. 175 | 176 | #### Endable emphases 177 | 178 | The second group have "end" terminal sequences that can turn them off explicitly, with all other settings remaining in play: 179 | 180 | - `italic` (or `i`) 181 | - `standout` (or `so`, often displayed as reversed foreground and background) 182 | - `underline` (or `ul`, or `u`) 183 | 184 | When using one of these, you don't have to re-enable other modes: 185 | 186 | ```shell 187 | echo "$(dye magenta)Mary $(dye italic "had") a little lamb.$(dye reset)" 188 | echo "$(dye magenta)Mary had a $(dye italic "little") lamb.$(dye reset)" 189 | ``` 190 | 191 | For manual control, the "end" command can be used for these: 192 | 193 | ```shell 194 | echo "Visit $(dye ul)https://mattiebee.dev/dye$(dye end ul) to get the code." 195 | ``` 196 | 197 | To match "end", "begin" is also available (and works with all emphases). It behaves the same way as just using the emphasis, e.g. "dye begin italic" is equivalent to "dye italic". 198 | 199 | ## Development 200 | 201 | ### Testing 202 | 203 | Unit tests are exhaustively written in [shellspec](https://shellspec.info). The [specs](./tools/specs) script will look for shells on your system that are expected to be compatible, and run the suite for each, stopping on the first failures. 204 | 205 | [coverage](./tools/coverage) pairs shellspec with [kcov](http://simonkagstrom.github.io/kcov/) in [Docker](https://www.docker.com) to gather coverage while running against [Bash](https://www.gnu.org/software/bash/). (I'm using Docker here because I can't get shellspec with kcov to work on macOS.) 100% is impossible to reach due to kcov thinking some syntax isn't covered. But all meaningful lines of [dye.sh](./dye.sh) are covered. 206 | 207 | ### Standards 208 | 209 | Code should all be written to [the POSIX shell spec](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html). Deviations are probably bugs. 🐜 210 | 211 | This is particularly important because dye should run everywhere it can, including in very limited systems. If it starts getting loaded up with [Bashisms](https://www.bowmanjd.com/bash-not-bash-posix/), it won't work in some places. 212 | 213 | ### Notes 214 | 215 | #### tput 216 | 217 | During the development of dye, I did explore things like caching the output of tput(1) so it didn't have to be invoked quite so much. 218 | 219 | The added complexity was really not worth it, since tput(1) is still fast enough (i.e. not at all noticeably slow) for most purposes where a shell script is doing work for at least a small amount of time. The cache would also need to be filled, and most scripts just don't switch colors enough to make it worthwhile. 220 | 221 | The sequences dye generally uses are simple and unconcerned with this, but there are also interesting details with certain terminal control sequences on certain systems that tput(1) can handle if invoked directly, such as embedded delays. So, the practice also encourages maximum compatibility. 222 | --------------------------------------------------------------------------------