update structure | 'internal/color' for color type and theme | 'internal/transform/color' for colorization | 'internal/transform/markdown' for markdown-like formatting | 'clifmt.go' tests | make colorization + markdown-like format available for nesting (and cross-nesting?)

This commit is contained in:
xdrm-brackets 2019-01-25 16:14:45 +01:00
parent 699b413b2a
commit 7c12b9a9db
12 changed files with 567 additions and 280 deletions

42
clifmt.go Normal file
View File

@ -0,0 +1,42 @@
package clifmt
import (
"fmt"
"git.xdrm.io/go/clifmt/internal/color"
colorTransform "git.xdrm.io/go/clifmt/internal/transform/color"
mdTransform "git.xdrm.io/go/clifmt/internal/transform/markdown"
)
var theme = color.DefaultTheme()
// Sprintf returns a terminal-colorized output following the coloring format
func Sprintf(format string, a ...interface{}) (string, error) {
// 1. Pre-process format with 'fmt'
formatted := fmt.Sprintf(format, a...)
// 2. Colorize
colorized, err := colorTransform.Transform(formatted, theme)
if err != nil {
return "", err
}
// 3. Markdown format
markdown, err := mdTransform.Transform(colorized)
if err != nil {
return "", err
}
// 3. return final output
return markdown, nil
}
// Printf prints a terminal-colorized output following the coloring format
func Printf(format string, a ...interface{}) error {
s, err := Sprintf(format, a...)
if err != nil {
return err
}
fmt.Print(s)
return nil
}

162
clifmt_test.go Normal file
View File

@ -0,0 +1,162 @@
package clifmt
import (
"strings"
"testing"
)
func TestColoring(t *testing.T) {
tests := []struct {
Input string
Output string
}{
// foreground + background
{
"start ${some text input}(#ff0000:#00ff00) end\n",
"start \x1b[38;2;255;0;0;48;2;0;255;0msome text input\x1b[0m end\n",
}, {
"start ${some text input}(#f00:#0f0) end\n",
"start \x1b[38;2;255;0;0;48;2;0;255;0msome text input\x1b[0m end\n",
}, {
"start ${some text input}(red:green) end\n",
"start \x1b[38;2;255;0;0;48;2;0;255;0msome text input\x1b[0m end\n",
},
// mixed notations
{
"start ${some text input}(red:#00ff00) end\n",
"start \x1b[38;2;255;0;0;48;2;0;255;0msome text input\x1b[0m end\n",
}, {
"start ${some text input}(red:#0f0) end\n",
"start \x1b[38;2;255;0;0;48;2;0;255;0msome text input\x1b[0m end\n",
}, {
"start ${some text input}(#ff0000:green) end\n",
"start \x1b[38;2;255;0;0;48;2;0;255;0msome text input\x1b[0m end\n",
}, {
"start ${some text input}(#f00:green) end\n",
"start \x1b[38;2;255;0;0;48;2;0;255;0msome text input\x1b[0m end\n",
},
// foreground only
{
"start ${some text input}(red) end\n",
"start \x1b[38;2;255;0;0msome text input\x1b[0m end\n",
}, {
"start ${some text input}(#ff0000) end\n",
"start \x1b[38;2;255;0;0msome text input\x1b[0m end\n",
}, {
"start ${some text input}(#f00) end\n",
"start \x1b[38;2;255;0;0msome text input\x1b[0m end\n",
},
// background only
{
"start ${some text input}(:blue) end\n",
"start \x1b[48;2;0;0;255msome text input\x1b[0m end\n",
}, {
"start ${some text input}(:#0000ff) end\n",
"start \x1b[48;2;0;0;255msome text input\x1b[0m end\n",
}, {
"start ${some text input}(:#00f) end\n",
"start \x1b[48;2;0;0;255msome text input\x1b[0m end\n",
},
// multi matches
{
"start ${text1}(red) separation ${text2}(#0f0) end\n",
"start \x1b[38;2;255;0;0mtext1\x1b[0m separation \x1b[38;2;0;255;0mtext2\x1b[0m end\n",
}, {
"start ${text1}(:red) separation ${text2}(:#0f0) end\n",
"start \x1b[48;2;255;0;0mtext1\x1b[0m separation \x1b[48;2;0;255;0mtext2\x1b[0m end\n",
}}
for i, test := range tests {
output, err := Sprintf(test.Input)
if err != nil {
t.Errorf("[%d] unexpected error <%v>", i, err)
break
}
if output != test.Output {
t.Errorf("[%d] expected '%s', got '%s'", i, test.Output, output)
}
}
}
func TestMarkdown(t *testing.T) {
tests := []struct {
Input string
Output string
}{
// each notation
{
"start **bold text** end\n",
"start \x1b[1mbold text\x1b[22m end\n",
}, {
"start *italic text* end\n",
"start \x1b[3mitalic text\x1b[23m end\n",
}, {
"start _underlined text_ end\n",
"start \x1b[4munderlined text\x1b[24m end\n",
},
// mixed notations
{
"start ***bold italic*** end\n",
"start \x1b[3m\x1b[1mbold italic\x1b[23m\x1b[22m end\n",
}, {
"start **_bold underline_** end\n",
"start \x1b[1m\x1b[4mbold underline\x1b[24m\x1b[22m end\n",
}, {
"start _**bold underline**_ end\n",
"start \x1b[4m\x1b[1mbold underline\x1b[22m\x1b[24m end\n",
}, {
"start *_italic underline_* end\n",
"start \x1b[3m\x1b[4mitalic underline\x1b[24m\x1b[23m end\n",
}, {
"start _*italic underline*_ end\n",
"start \x1b[4m\x1b[3mitalic underline\x1b[23m\x1b[24m end\n",
}, {
"start _***bold italic underline***_ end\n",
"start \x1b[4m\x1b[3m\x1b[1mbold italic underline\x1b[23m\x1b[22m\x1b[24m end\n",
}, {
"start **_*bold italic underline*_** end\n",
"start \x1b[1m\x1b[4m\x1b[3mbold italic underline\x1b[23m\x1b[24m\x1b[22m end\n",
}, {
"start *_**bold italic underline**_* end\n",
"start \x1b[3m\x1b[4m\x1b[1mbold italic underline\x1b[22m\x1b[24m\x1b[23m end\n",
}, {
"start _***bold italic underline***_ end\n",
"start \x1b[4m\x1b[3m\x1b[1mbold italic underline\x1b[23m\x1b[22m\x1b[24m end\n",
},
// mixed notations not matching
{
"start ***bold** italic* end\n",
"start \x1b[3m\x1b[1mbold\x1b[22m italic\x1b[23m end\n",
}, {
"start **_bold** underline_ end\n",
"start \x1b[1m\x1b[4mbold\x1b[22m underline\x1b[24m end\n",
}, {
"start _**bold_ underline** end\n",
"start \x1b[4m\x1b[1mbold\x1b[24m underline\x1b[22m end\n",
},
}
for i, test := range tests {
output, err := Sprintf(test.Input)
if err != nil {
t.Errorf("[%d] unexpected error <%v>", i, err)
break
}
if output != test.Output {
t.Errorf("[%d] expected '%s'\n", i, strings.Replace(strings.Replace(test.Output, "\n", "\\n", -1), "\x1b", "\\e", -1))
t.Errorf("[%d] got '%s'\n", i, strings.Replace(strings.Replace(output, "\n", "\\n", -1), "\x1b", "\\e", -1))
}
}
}

View File

@ -1,99 +0,0 @@
package clifmt
import (
"fmt"
"io"
"strconv"
)
type terminalColor uint32
// Theme is used to associate color names to integer color values ;
// the color names in the theme are available in the colorizing format
type Theme map[string]terminalColor
var theme Theme = make(map[string]terminalColor)
func init() {
theme["black"] = 0x000000
theme["white"] = 0xffffff
theme["red"] = 0xff0000
theme["green"] = 0x00ff00
theme["blue"] = 0x0000ff
theme["yellow"] = 0xffff00
theme["orange"] = 0xff8c00
theme["purple"] = 0x800080
theme["navy"] = 0x000080
theme["aqua"] = 0x00ffff
theme["gray"] = 0x808080
theme["silver"] = 0xc0c0c0
theme["fuchsia"] = 0xff00ff
theme["olive"] = 0x808000
theme["teal"] = 0x008080
theme["brown"] = 0x800000
}
// fromName returns the integer value of a color name
// from the built-in color map ; it is case insensitive
func fromName(s string) (terminalColor, error) {
value, ok := theme[s]
if !ok {
return 0, fmt.Errorf("unknown color name '%s'", s)
}
return value, nil
}
// fromHex returns the integer value associated with
// an hexadecimal string (full-sized or short version)
// the format is 'abc' or 'abcdef'
func fromHex(s string) (terminalColor, error) {
if len(s) != 3 && len(s) != 6 {
return 0, fmt.Errorf("expect a size of 3 or 6 (remove the '#' prefix)")
}
// short version
input := s
if len(s) == 3 {
input = fmt.Sprintf("%c%c%c%c%c%c", s[0], s[0], s[1], s[1], s[2], s[2])
}
n, err := strconv.ParseUint(input, 16, 32)
if err != nil {
return 0, err
}
return terminalColor(n), nil
}
// parseColor tries to parse a color string (can be a name or an hexa value)
func parseColor(s string) (terminalColor, error) {
// (0) ...
if len(s) < 1 {
return 0, io.ErrUnexpectedEOF
}
// (1) hexa
if s[0] == '#' {
return fromHex(s[1:])
}
// (2) name
return fromName(s)
}
// Red component of the color
func (c terminalColor) Red() uint8 {
return uint8((c >> 16) & 0xff)
}
// Green component of the color
func (c terminalColor) Green() uint8 {
return uint8((c >> 8) & 0xff)
}
// Blue component of the color
func (c terminalColor) Blue() uint8 {
return uint8(c & 0xff)
}

75
internal/color/color.go Normal file
View File

@ -0,0 +1,75 @@
package color
import (
"fmt"
"io"
"strconv"
)
// T represents a color
type T uint32
// FromName returns the integer value of a color name
// from the built-in color map ; it is case insensitive
func FromName(t Theme, s string) (T, error) {
value, ok := t[s]
if !ok {
return 0, fmt.Errorf("unknown color name '%s'", s)
}
return value, nil
}
// FromHex returns the integer value associated with
// an hexadecimal string (full-sized or short version)
// the format is 'abc' or 'abcdef'
func FromHex(s string) (T, error) {
if len(s) != 3 && len(s) != 6 {
return 0, fmt.Errorf("expect a size of 3 or 6 (without the '#' prefix)")
}
// short version
input := s
if len(s) == 3 {
input = fmt.Sprintf("%c%c%c%c%c%c", s[0], s[0], s[1], s[1], s[2], s[2])
}
n, err := strconv.ParseUint(input, 16, 32)
if err != nil {
return 0, err
}
return T(n), nil
}
// Parse tries to parse a color string (can be a name or an hexa value)
func Parse(t Theme, s string) (T, error) {
// (0) ...
if len(s) < 1 {
return 0, io.ErrUnexpectedEOF
}
// (1) hexa
if s[0] == '#' {
return FromHex(s[1:])
}
// (2) name
return FromName(t, s)
}
// Red component of the color
func (c T) Red() uint8 {
return uint8((c >> 16) & 0xff)
}
// Green component of the color
func (c T) Green() uint8 {
return uint8((c >> 8) & 0xff)
}
// Blue component of the color
func (c T) Blue() uint8 {
return uint8(c & 0xff)
}

28
internal/color/theme.go Normal file
View File

@ -0,0 +1,28 @@
package color
// Theme is used to associate color names to integer color values ;
// the color names in the theme are available in the colorizing format
type Theme map[string]T
// Default loads sets the default theme associations
func DefaultTheme() Theme {
theme := make(Theme)
theme["black"] = 0x000000
theme["white"] = 0xffffff
theme["red"] = 0xff0000
theme["green"] = 0x00ff00
theme["blue"] = 0x0000ff
theme["yellow"] = 0xffff00
theme["orange"] = 0xff8c00
theme["purple"] = 0x800080
theme["navy"] = 0x000080
theme["aqua"] = 0x00ffff
theme["gray"] = 0x808080
theme["silver"] = 0xc0c0c0
theme["fuchsia"] = 0xff00ff
theme["olive"] = 0x808000
theme["teal"] = 0x008080
theme["brown"] = 0x800000
return theme
}

View File

@ -0,0 +1,92 @@
package color
import (
"fmt"
"git.xdrm.io/go/clifmt/internal/color"
"regexp"
)
// extractor helps extract features from the coloring format defined as follows :
//
// - [Color] -> [a-z] # named color
// - [Color] -> #[0-9a-f]{3} # hexa color (shortcode)
// - [Color] -> #[0-9a-f]{6} # hexa color (full-sized)
// - [Text] -> ANY
// - [Format] -> ${Text}(Color:Color) # foreground, background colors
// - [Format] -> ${Text}(Color) # foreground color only
// - [Format] -> ${Text}(:Color) # background color only
var extractor = regexp.MustCompile(`(?m)\${([^$]+)}\(((?:[a-z]+|#(?:[0-9a-f]{3}|[0-9a-f]{6})))?(?:\:((?:[a-z]+|#(?:[0-9a-f]{3}|[0-9a-f]{6}))))?\)`)
// colorize returns the terminal-formatted @text colorized with the @fg and @bg colors
func colorize(t string, fg *color.T, bg *color.T) string {
// no coloring
if fg == nil && bg == nil {
return t
}
// only foreground
if bg == nil {
return fmt.Sprintf("\x1b[38;2;%d;%d;%dm%s\x1b[0m", fg.Red(), fg.Green(), fg.Blue(), t)
}
// only background
if fg == nil {
return fmt.Sprintf("\x1b[48;2;%d;%d;%dm%s\x1b[0m", bg.Red(), bg.Green(), bg.Blue(), t)
}
// both colors
return fmt.Sprintf("\x1b[38;2;%d;%d;%d;48;2;%d;%d;%dm%s\x1b[0m", fg.Red(), fg.Green(), fg.Blue(), bg.Red(), bg.Green(), bg.Blue(), t)
}
// Transform the @input text colorized according to the @extractor format
func Transform(input string, theme color.Theme) (string, error) {
output := ""
cursor := int(0)
// 1. Replace for each match
for _, match := range extractor.FindAllStringSubmatchIndex(input, -1) {
// (1) add gap between input start OR previous match
output += input[cursor:match[0]]
cursor = match[1]
// (2) extract features
var (
text = ""
sFg = ""
sBg = ""
fg *color.T = nil
bg *color.T = nil
)
if match[3]-match[2] > 0 {
text = input[match[2]:match[3]]
}
if match[5]-match[4] > 0 {
sFg = input[match[4]:match[5]]
fgv, err := color.Parse(theme, sFg)
if err != nil {
return "", err
}
fg = &fgv
}
if match[7]-match[6] > 0 {
sBg = input[match[6]:match[7]]
bgv, err := color.Parse(theme, sBg)
if err != nil {
return "", err
}
bg = &bgv
}
// (3) replace text with colorized text
output += colorize(text, fg, bg)
}
// 2. Add end of input
if cursor < len(input)-1 {
output += input[cursor:]
}
// 3. print final output
return output, nil
}

View File

@ -0,0 +1,48 @@
package markdown
import (
"fmt"
"regexp"
"strings"
)
var boldRe = regexp.MustCompile(`(?m)\*\*((?:[^\*]+\*?)+)\*\*`)
// boldify returns the terminal-formatted bold text @t
func boldify(t string) string {
return fmt.Sprintf("\x1b[1m%s\x1b[22m", strings.Replace(t, "\x1b[0m", "\x1b[0m\x1b[1m", -1))
}
// boldTransform the @input text using markdown-like syntax :
// - "normal **bold** normal"
func boldTransform(input string) (string, error) {
output := ""
cursor := int(0)
// 1. Replace for each match
for _, match := range boldRe.FindAllStringSubmatchIndex(input, -1) {
// (1) add gap between input start OR previous match
output += input[cursor:match[0]]
cursor = match[1]
// (2) extract features
text := ""
if match[3]-match[2] > 0 {
text = input[match[2]:match[3]]
}
// (3) replace text with bold text
output += boldify(text)
}
// 2. Add end of input
if cursor < len(input)-1 {
output += input[cursor:]
}
// 3. print final output
return output, nil
}

View File

@ -0,0 +1,48 @@
package markdown
import (
"fmt"
"regexp"
"strings"
)
var italicRe = regexp.MustCompile(`(?m)\*([^\*]+)\*`)
// italic returns the terminal-formatted italic text @t
func italic(t string) string {
return fmt.Sprintf("\x1b[3m%s\x1b[23m", strings.Replace(t, "\x1b[0m", "\x1b[0m\x1b[3m", -1))
}
// italicTransform the @input text using markdown-like syntax :
// - "normal *italic* normal"
func italicTransform(input string) (string, error) {
output := ""
cursor := int(0)
// 1. Replace for each match
for _, match := range italicRe.FindAllStringSubmatchIndex(input, -1) {
// (1) add gap between input start OR previous match
output += input[cursor:match[0]]
cursor = match[1]
// (2) extract features
text := ""
if match[3]-match[2] > 0 {
text = input[match[2]:match[3]]
}
// (3) replace text with bold text
output += italic(text)
}
// 2. Add end of input
if cursor < len(input)-1 {
output += input[cursor:]
}
// 3. print final output
return output, nil
}

View File

@ -0,0 +1,24 @@
package markdown
func Transform(input string) (string, error) {
// 1. bold
bold, err := boldTransform(input)
if err != nil {
return "", err
}
// 2. italic
italic, err := italicTransform(bold)
if err != nil {
return "", err
}
// 3. italic
underline, err := underlineTransform(italic)
if err != nil {
return "", err
}
return underline, nil
}

View File

@ -0,0 +1,48 @@
package markdown
import (
"fmt"
"regexp"
"strings"
)
var underlineRe = regexp.MustCompile(`(?m)_([^_]+)_`)
// underline returns the terminal-formatted underline text @t
func underline(t string) string {
return fmt.Sprintf("\x1b[4m%s\x1b[24m", strings.Replace(t, "\x1b[0m", "\x1b[0m\x1b[4m", -1))
}
// underlineTransform the @input text using markdown-like syntax :
// - "normal _underline_ normal"
func underlineTransform(input string) (string, error) {
output := ""
cursor := int(0)
// 1. Replace for each match
for _, match := range underlineRe.FindAllStringSubmatchIndex(input, -1) {
// (1) add gap between input start OR previous match
output += input[cursor:match[0]]
cursor = match[1]
// (2) extract features
text := ""
if match[3]-match[2] > 0 {
text = input[match[2]:match[3]]
}
// (3) replace text with bold text
output += underline(text)
}
// 2. Add end of input
if cursor < len(input)-1 {
output += input[cursor:]
}
// 3. print final output
return output, nil
}

View File

@ -1,96 +0,0 @@
package clifmt
import (
"fmt"
"regexp"
)
// extractor helps extract features from the coloring format defined as follows :
//
// - [Color] -> [a-z] # named color
// - [Color] -> #[0-9a-f]{3} # hexa color (shortcode)
// - [Color] -> #[0-9a-f]{6} # hexa color (full-sized)
// - [Text] -> ANY
// - [Format] -> ${Text}(Color:Color) # foreground, background colors
// - [Format] -> ${Text}(Color) # foreground color only
// - [Format] -> ${Text}(:Color) # background color only
var extractor = regexp.MustCompile(`(?m)\${([^$]+)}\(((?:[a-z]+|#(?:[0-9a-f]{3}|[0-9a-f]{6})))?(?:\:((?:[a-z]+|#(?:[0-9a-f]{3}|[0-9a-f]{6}))))?\)`)
// Sprintf returns a terminal-colorized output following the coloring format
func Sprintf(format string, a ...interface{}) (string, error) {
// 1. Pre-process format with 'fmt'
input := fmt.Sprintf(format, a...)
output := ""
cursor := int(0)
// 2. extract color format matches
for _, match := range extractor.FindAllStringSubmatchIndex(input, -1) {
// (1) add gap between input start OR previous match
output += input[cursor:match[0]]
cursor = match[1]
// (2) extract features
var err error
var (
text = ""
sForeground = ""
sBackground = ""
foreground = terminalColor(0)
background = terminalColor(0)
)
if match[3]-match[2] > 0 {
text = input[match[2]:match[3]]
}
if match[5]-match[4] > 0 {
sForeground = input[match[4]:match[5]]
foreground, err = parseColor(sForeground)
if err != nil {
return "", err
}
}
if match[7]-match[6] > 0 {
sBackground = input[match[6]:match[7]]
background, err = parseColor(sBackground)
if err != nil {
return "", err
}
}
// (3) replace text with colorized text
if len(sForeground) > 0 {
text = colorize(text, true, foreground)
}
if len(sBackground) > 0 {
text = colorize(text, false, background)
}
output += text
}
// 3. Add end of input
if cursor < len(input)-1 {
output += input[cursor:]
}
// 3. print final output
return output, nil
}
// Printf prints a terminal-colorized output following the coloring format
func Printf(format string, a ...interface{}) error {
s, err := Sprintf(format, a...)
if err != nil {
return err
}
fmt.Print(s)
return nil
}
func colorize(text string, foregound bool, color terminalColor) string {
if foregound {
return fmt.Sprintf("\033[38;2;%d;%d;%dm%s\033[0m", color.Red(), color.Green(), color.Blue(), text)
}
return fmt.Sprintf("\033[48;2;%d;%d;%dm%s\033[0m", color.Red(), color.Green(), color.Blue(), text)
}

View File

@ -1,85 +0,0 @@
package clifmt
import (
"testing"
)
func TestColoring(t *testing.T) {
tests := []struct {
Input string
Output string
}{
// foreground + background
{
"start ${some text input}(#ff0000:#00ff00) end\n",
"start \033[48;2;0;255;0m\033[38;2;255;0;0msome text input\033[0m\033[0m end\n",
}, {
"start ${some text input}(#f00:#0f0) end\n",
"start \033[48;2;0;255;0m\033[38;2;255;0;0msome text input\033[0m\033[0m end\n",
}, {
"start ${some text input}(red:green) end\n",
"start \033[48;2;0;255;0m\033[38;2;255;0;0msome text input\033[0m\033[0m end\n",
},
// mixed notations
{
"start ${some text input}(red:#00ff00) end\n",
"start \033[48;2;0;255;0m\033[38;2;255;0;0msome text input\033[0m\033[0m end\n",
}, {
"start ${some text input}(red:#0f0) end\n",
"start \033[48;2;0;255;0m\033[38;2;255;0;0msome text input\033[0m\033[0m end\n",
}, {
"start ${some text input}(#ff0000:green) end\n",
"start \033[48;2;0;255;0m\033[38;2;255;0;0msome text input\033[0m\033[0m end\n",
}, {
"start ${some text input}(#f00:green) end\n",
"start \033[48;2;0;255;0m\033[38;2;255;0;0msome text input\033[0m\033[0m end\n",
},
// foreground only
{
"start ${some text input}(red) end\n",
"start \033[38;2;255;0;0msome text input\033[0m end\n",
}, {
"start ${some text input}(#ff0000) end\n",
"start \033[38;2;255;0;0msome text input\033[0m end\n",
}, {
"start ${some text input}(#f00) end\n",
"start \033[38;2;255;0;0msome text input\033[0m end\n",
},
// background only
{
"start ${some text input}(:blue) end\n",
"start \033[48;2;0;0;255msome text input\033[0m end\n",
}, {
"start ${some text input}(:#0000ff) end\n",
"start \033[48;2;0;0;255msome text input\033[0m end\n",
}, {
"start ${some text input}(:#00f) end\n",
"start \033[48;2;0;0;255msome text input\033[0m end\n",
},
// multi matches
{
"start ${text1}(red) separation ${text2}(#0f0) end\n",
"start \033[38;2;255;0;0mtext1\033[0m separation \033[38;2;0;255;0mtext2\033[0m end\n",
}, {
"start ${text1}(:red) separation ${text2}(:#0f0) end\n",
"start \033[48;2;255;0;0mtext1\033[0m separation \033[48;2;0;255;0mtext2\033[0m end\n",
}}
for i, test := range tests {
output, err := Sprintf(test.Input)
if err != nil {
t.Errorf("[%d] unexpected error <%v>", i, err)
break
}
if output != test.Output {
t.Errorf("[%d] expected '%s', got '%s'", i, test.Output, output)
}
}
}