diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml new file mode 100644 index 0000000..a08e9dd --- /dev/null +++ b/.buildkite/pipeline.yaml @@ -0,0 +1,23 @@ +# Copyright 2019 Soluble Inc +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +steps: + - label: ":rocket: Go" + command: "golangci-lint run -E gofmt -E stylecheck && go test -cover" + plugins: + - docker#v3.3.0: + image: gcr.io/soluble-ci/go-jnode-ci:latest + always-pull: true + shell: [ "/bin/bash", "-e", "-c" ] + propagate-uid-gid: true \ No newline at end of file diff --git a/README.md b/README.md index eca0b90..a67b568 100644 --- a/README.md +++ b/README.md @@ -1 +1,18 @@ -# go-colorize \ No newline at end of file +# go-colorize + +Go from this: + +```go +Colorize("\n{primary:%s} eat {bg-success:pizza :pizza:}{bg-primary: and drink }{warning:beer} %s, but not {bg-danger:%s}\n\n", + "Most folks like to", ":beer:", "everyone") +``` + +To this: + +![Screenshot](screenshot.png) + +Works with https://github.com/kyokomi/emoji and https://github.com/fatih/color. + +Default styles are: + +![Default Styles](default_styles.png) diff --git a/colorize.go b/colorize.go new file mode 100644 index 0000000..8d48f80 --- /dev/null +++ b/colorize.go @@ -0,0 +1,163 @@ +// Copyright 2019 Soluble Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* +colorize does ANSI colored output and emoji substitution. + +Colorize takes a fmt.Printf() template string and looks for styles +in the format: + + {style-name:text} + +And inserts the color esacpe sequences around the text. It then passes +the template off to emoji.Sprintf() from https://github.com/kyokomi/emoji. + +It uses the color attribute names, color.Output, and color.NoColor from +https://github.com/fatih/color. + +The style names are modeled off the basic bootstrap color names. + +Example use: + + colorize.Colorize("{primary:We} want {bg-success:beer :beer:} and {warning:pizza} :pizza:") + +*/ +package colorize + +import ( + "fmt" + "strconv" + "strings" + + "github.com/fatih/color" + "github.com/kyokomi/emoji" +) + +const escape = "\x1b" + +// Style is a ANSI escape code sequence +type Style struct { + sequence string + enable *bool +} + +// NewStyle creates a style from a set of attributes +func NewStyle(attributes ...color.Attribute) *Style { + codes := make([]string, len(attributes)) + for i, attr := range attributes { + codes[i] = strconv.Itoa(int(attr)) + } + sequence := fmt.Sprintf("%s[%sm", escape, strings.Join(codes, ";")) + return &Style{sequence, nil} +} + +// Styles maps style names to styles +var Styles = map[string]*Style{ + "primary": NewStyle(color.FgHiBlue), + "secondary": NewStyle(color.FgBlue), + "light": NewStyle(color.BgHiBlack), + "info": nil, + "success": NewStyle(color.FgGreen), + "warning": NewStyle(color.FgYellow), + "danger": NewStyle(color.FgHiRed), + "bg-primary": NewStyle(color.FgHiWhite, color.BgHiBlue), + "bg-secondary": NewStyle(color.FgHiWhite, color.BgBlue), + "bg-success": NewStyle(color.FgHiWhite, color.BgGreen), + "bg-warning": NewStyle(color.FgBlack, color.BgYellow), + "bg-danger": NewStyle(color.FgBlack, color.BgHiRed), +} +var reset = NewStyle(color.Reset) + +// IsEnabled returns true if the style is enabled, either specifically +// to this style or globally via color.NoColor +func (style *Style) IsEnabled() bool { + if style == nil { + return false + } + if style.enable != nil { + return *style.enable + } + return !color.NoColor +} + +// Enable or disable this style +func (style *Style) Enable(val bool) *Style { + style.enable = &val + return style +} + +// Colorize some text, printing the output to color.Output +func Colorize(template string, values ...interface{}) { + fmt.Fprint(color.Output, SColorize(template, values...)) +} + +// Colorize some text, returning a string +func SColorize(template string, values ...interface{}) string { + var b strings.Builder + state := ' ' + var nameStart int + var style *Style + var frag strings.Builder + for i, ch := range template { + switch state { + case ' ': + if ch == '{' { + nameStart = i + state = ch + } else { + b.WriteRune(ch) + if ch == '\\' { + state = ch + } + } + case '\\': + b.WriteRune(ch) + state = ' ' + case '{': + if ch == ':' { + name := template[nameStart+1 : i] + style = Styles[name] + frag.Reset() + state = '}' + } + case '}': + if ch == '\\' { + state = ']' + } else if ch == '}' { + var enabled = style.IsEnabled() + if enabled { + b.WriteString(style.sequence) + } + b.WriteString(frag.String()) + if enabled { + b.WriteString(reset.sequence) + } + state = ' ' + } else { + frag.WriteRune(ch) + } + case ']': + frag.WriteRune(ch) + state = '}' + + } + } + switch state { + case '{', '}', ']': + // if we found a '{' w/o completion, append everything from the start + b.WriteString(template[nameStart:]) + } + template = b.String() + return emoji.Sprintf(template, values...) +} diff --git a/colorize_test.go b/colorize_test.go new file mode 100644 index 0000000..0ddd352 --- /dev/null +++ b/colorize_test.go @@ -0,0 +1,81 @@ +// Copyright 2019 Soluble Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package colorize + +import ( + "fmt" + "github.com/fatih/color" + "sort" + "strings" + "testing" +) + +func TestDemoDefaults(t *testing.T) { + t.SkipNow() + var template strings.Builder + params := make([]interface{}, 0, len(Styles)) + names := make([]string, 0, len(Styles)) + for k := range Styles { + names = append(names, k) + } + sort.Strings(names) + for _, k := range names { + template.WriteString(fmt.Sprintf("{%s: %%12s }\n", k)) + params = append(params, k) + } + Colorize(template.String(), params...) + Colorize("\n{primary:%s} eat {bg-success:pizza :pizza:}{bg-primary: and drink }{warning:beer} %s, but not {bg-danger:%s}\n\n", + "Most folks like to", ":beer:", "everyone") +} + +func TestBasic(t *testing.T) { + s := SColorize("{primary:%s} %s", "hello", "world") + if !strings.Contains(s, "hello") || !strings.Contains(s, "world") { + t.Errorf("string contents wrong") + } + Styles["primary"].Enable(false) + if Styles["primary"].IsEnabled() { + t.Error("primary is enabled") + } + if Styles["nope"].IsEnabled() { + t.Error("nope is enabled") + } + if SColorize("{primary:hello} world") != "hello world" { + t.Error("disable style didn't work") + } + color.Output = &strings.Builder{} + Colorize("hello, world") + if color.Output.(*strings.Builder).String() != "hello, world" { + t.Error("string didn't output") + } +} + +func TestEscapes(t *testing.T) { + if e := SColorize("\\\n\r\t\v"); e != "\\\n\r\t\v" { + t.Errorf("escape sequences wrong: %s", e) + } +} + +func TestIncomplete(t *testing.T) { + if s := SColorize("{primary:foo"); s != "{primary:foo" { + t.Error(s) + } + if s := SColorize("{nope:xxx\\}}"); s != "xxx}" { + t.Error(s) + } + if s := SColorize("{nope:foo\\ bar"); s != "{nope:foo\\ bar" { + t.Error(s) + } +} diff --git a/default_styles.png b/default_styles.png new file mode 100644 index 0000000..4cdcfa8 Binary files /dev/null and b/default_styles.png differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bdb1e21 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/soluble-ai/go-colorize + +go 1.13 + +require ( + github.com/fatih/color v1.7.0 + github.com/kyokomi/emoji v2.1.0+incompatible + github.com/mattn/go-colorable v0.1.4 // indirect + github.com/mattn/go-isatty v0.0.10 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4fb48c2 --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/kyokomi/emoji v2.1.0+incompatible h1:+DYU2RgpI6OHG4oQkM5KlqD3Wd3UPEsX8jamTo1Mp6o= +github.com/kyokomi/emoji v2.1.0+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191008105621-543471e840be h1:QAcqgptGM8IQBC9K/RC4o+O9YmqEm0diQn9QmZw/0mU= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..799a353 Binary files /dev/null and b/screenshot.png differ