diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6715a5c --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @coldfgirl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22d0d82 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +vendor diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/instyle.iml b/.idea/instyle.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/instyle.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..5f4ee3f --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7a12277 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 robin + +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 the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6dbc86 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# InStyle + +![demo.gif](demo.gif) + +InStyle is a small library for efficiently decorating strings with ANSI escape codes. + +## Syntax + +The tags follow the following format: + +``` +[!style]text to be styled[/] +``` + +Style can be a named style, or a raw value to be used in an ANSI escape code. +For example, both of these will turn the text red: + +``` +[!red]this text will show up as red[/] +[!31]this text will show up as red[/] +``` + +The ending sequence of `[/]` can be fully omitted for minor performance gains like so: + +``` +[!italic]ending tags need not be included +``` + +### Multiple Styles + +Multiple styles can be added by using the `+` character between each style desired. + +``` +[!magenta+bold]this text has two styles[/] +``` + +### Nesting & Sequential Tags + +Up to 5 tags can be nested. +All unclosed tags are terminated at the end of a string. + +``` +[!cyan]i never said [!bold]you[/] did that[/]... [!italic]somebody else[/] did +``` + +### Named Styles + +
+ +Complete list of default styles. + +#### Text Styling + +- `plain` +- `reset` +- `bold` +- `faint` +- `italic` +- `underline` +- `blink` +- `strike` + +#### Basic Colors + +- `black` +- `red` +- `green` +- `yellow` +- `blue` +- `magenta` +- `cyan` +- `white` +- `default` + +#### Basic Backgrounds + +- `bg-black` +- `bg-red` +- `bg-green` +- `bg-yellow` +- `bg-blue` +- `bg-magenta` +- `bg-cyan` +- `bg-white` +- `bg-default` + +#### Light Colors + +- `light-black` +- `light-red` +- `light-green` +- `light-yellow` +- `light-blue` +- `light-magenta` +- `light-cyan` +- `light-white` + +#### Light Backgrounds + +- `bg-light-black` +- `bg-light-red` +- `bg-light-green` +- `bg-light-yellow` +- `bg-light-blue` +- `bg-light-magenta` +- `bg-light-cyan` +- `bg-light-white` + +
+ +Aside from the named styles, additional styles can be added to a `Styler` instance by using the `Register` method. +This can be used to associated more than one ANSI escape code to a name. + +```go +s := instyle.NewStyler() +s.Register("error", "1;31") // Bold and red +``` + +A style name can only be a maximum of 15 characters long. + +## Performance + +While applying a set of styles, this code runs ~2-3x slower than an unbuffered copy of an array of runes: + +```go +// ideal performance goal: +var dst []rune +for _, r := range []rune("...") { + dst = append(dst, r) +} +``` + +However, when compared a regex solution or using [Lip Gloss](https://github.com/charmbracelet/lipgloss) directly this will perform about 5-10x faster. + +Running on a M2 2022 MacBook Air, the truncated / formatted benchmark results look like: + +``` +BenchmarkBaseline/BestCase-8 15542974 70 ns/op +BenchmarkBaseline/PerformanceGoal-8 4497812 266 ns/op +BenchmarkApply/NoStyle-8 4112559 291 ns/op +BenchmarkApply/WithStyle-8 1775058 677 ns/op +BenchmarkApply/WithStyleToFromString-8 747465 1581 ns/op +BenchmarkSimilarLipGloss-8 129320 9212 ns/op +``` diff --git a/apply.go b/apply.go new file mode 100644 index 0000000..e20fae4 --- /dev/null +++ b/apply.go @@ -0,0 +1,7 @@ +package instyle + +import "fmt" + +func Apply(format string, args ...any) string { + return fmt.Sprintf(string(NewStyler().Apply([]rune(format))), args...) +} diff --git a/apply_test.go b/apply_test.go new file mode 100644 index 0000000..7432ec4 --- /dev/null +++ b/apply_test.go @@ -0,0 +1,18 @@ +package instyle_test + +import ( + "instyle" + "testing" +) + +func TestApply(t *testing.T) { + in := "[!bold]%s[/]" + inParam := "testing [!faint]string[/]" + out := "\033[0m\033[1mtesting [!faint]string[/]\033[0m" + + if result := instyle.Apply(in, inParam); result != out { + t.Logf("Want: %+v", []rune(out)) + t.Logf("Got: %+v", []rune(result)) + t.FailNow() + } +} diff --git a/cmd/instyle/main.go b/cmd/instyle/main.go new file mode 100644 index 0000000..8ba62c6 --- /dev/null +++ b/cmd/instyle/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/coldfgirl/instyle" +) + +func main() { + if len(os.Args) > 1 { + fmt.Println(instyle.Apply(strings.Join(os.Args[1:], " "))) + } else { + _, _ = fmt.Fprintln(os.Stderr, instyle.Apply("[!bold+red]no command line arguments provided")) + } +} diff --git a/demo.gif b/demo.gif new file mode 100644 index 0000000..7bf8122 Binary files /dev/null and b/demo.gif differ diff --git a/demo.tape b/demo.tape new file mode 100644 index 0000000..6f0d8bf --- /dev/null +++ b/demo.tape @@ -0,0 +1,13 @@ +Output demo.gif + +Require echo + +Set Theme "Catppuccin Frappe" + +Set Shell "bash" +Set FontSize 18 +Set Width 1200 +Set Height 200 + +Type@45ms "go run ./cmd/... '[!italic]you can [!cyan]style[/] text with [!bold+magenta]InStyle[/]!!!'" Sleep 100ms Enter +Sleep 10s diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4a26287 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/coldfgirl/instyle + +go 1.21.4 + +require github.com/charmbracelet/lipgloss v0.13.0 + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.1.4 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sys v0.19.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..62605f0 --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +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/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= +github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= +github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/style_set.go b/style_set.go new file mode 100644 index 0000000..c84b389 --- /dev/null +++ b/style_set.go @@ -0,0 +1,275 @@ +package instyle + +import ( + "math" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +const ( + // keySizeMax is used to determine the maximum size of a named style. + // This is important for optimization in that it allows for a map to be used to find styles by name. + keySizeMax = 16 + + // styleDepthMax is used to determine how many levels of nesting are allowed. + styleDepthMax = 5 +) + +var ( + openingBegin = []rune("[!") + openingClose = []rune("]") + closing = []rune("[/]") + + reset = []rune{'\033', '[', '0', 'm'} +) + +type Styler interface { + Apply(original []rune) (output []rune) + Register(key string, value string) (self Styler) + RegisterLipGlossStyle(key string, value lipgloss.Style) (self Styler) +} + +type styleSet struct { + named map[[keySizeMax]rune][]rune +} + +func NewStyler() Styler { + s := new(styleSet) + s.named = make(map[[keySizeMax]rune][]rune) + + s.Register("plain", "22") + + s.Register("reset", "0") + s.Register("bold", "1") + s.Register("faint", "2") + s.Register("italic", "3") + s.Register("underline", "4") + s.Register("blink", "5") + s.Register("strike", "6") + + s.Register("black", "30") + s.Register("red", "31") + s.Register("green", "32") + s.Register("yellow", "33") + s.Register("blue", "34") + s.Register("magenta", "35") + s.Register("cyan", "36") + s.Register("white", "37") + s.Register("default", "39") + + s.Register("bg-black", "40") + s.Register("bg-red", "41") + s.Register("bg-green", "42") + s.Register("bg-yellow", "43") + s.Register("bg-blue", "44") + s.Register("bg-magenta", "45") + s.Register("bg-cyan", "46") + s.Register("bg-white", "47") + s.Register("bg-default", "49") + + s.Register("light-black", "90") + s.Register("light-red", "91") + s.Register("light-green", "92") + s.Register("light-yellow", "93") + s.Register("light-blue", "94") + s.Register("light-magenta", "95") + s.Register("light-cyan", "96") + s.Register("light-white", "97") + + s.Register("bg-light-black", "100") + s.Register("bg-light-red", "101") + s.Register("bg-light-green", "102") + s.Register("bg-light-yellow", "103") + s.Register("bg-light-blue", "104") + s.Register("bg-light-magenta", "105") + s.Register("bg-light-cyan", "106") + s.Register("bg-light-white", "107") + + return s +} + +func (s *styleSet) Register(key string, value string) Styler { + parsed := [keySizeMax]rune{} + for k, v := range key[:int(math.Min(keySizeMax, float64(len(key))))] { + parsed[k] = v + } + + s.named[parsed] = []rune(value) + return s +} + +func (s *styleSet) RegisterLipGlossStyle(key string, value lipgloss.Style) Styler { + p := lipgloss.ColorProfile() + + var sequence []string + + if _, noColor := value.GetForeground().(lipgloss.NoColor); !noColor { + sequence = append(sequence, p.FromColor(value.GetForeground()).Sequence(false)) + } + + if _, noColor := value.GetBackground().(lipgloss.NoColor); !noColor { + sequence = append(sequence, p.FromColor(value.GetBackground()).Sequence(true)) + } + + if value.GetBold() { + sequence = append(sequence, "1") + } + + if value.GetFaint() { + sequence = append(sequence, "2") + } + + if value.GetItalic() { + sequence = append(sequence, "3") + } + + if value.GetUnderline() { + sequence = append(sequence, "4") + } + + if value.GetBlink() { + sequence = append(sequence, "5") + } + + if value.GetStrikethrough() { + sequence = append(sequence, "6") + } + + return s.Register(key, strings.Join(sequence, ";")) +} + +func (s *styleSet) Apply(runes []rune) []rune { + var ( + appliedStyleStack = [styleDepthMax][]rune{} + ok = false + output = make([]rune, 0, len(runes)*4/3+5) // Pre-allocate n * 1.33 + 5 the size of the passed runes. + ) + + output = append(output, reset...) + + for i, nest := 0, 0; i < len(runes); i++ { + r := runes[i] + + if r == openingBegin[0] && nest < styleDepthMax { + if appliedStyleStack[nest], i, ok = s.parseOpening(runes, i); ok { + if nest = nest + 1; nest > 0 { + output = append(output, appliedStyleStack[nest-1]...) + continue + } + } + } + + if r == closing[0] && nest > 0 { + if i, ok = checkSequence(closing, runes, i); ok { + if nest = nest - 1; nest >= 0 { + output = append(output, reset...) + appliedStyleStack[nest] = nil + + if i+1 == len(runes) { + appliedStyleStack[0] = nil + } + + for i := 0; i < len(appliedStyleStack) && i < nest; i++ { + output = append(output, appliedStyleStack[i]...) + } + + continue + } + } + } + + output = append(output, r) + } + + if appliedStyleStack[0] != nil { + output = append(output, reset...) + } + + return output +} + +// parseOpening operates similarly to checkSequence but specifically for the opening of a style tag. +// When a valid style tag is found, the computed sequence of ANSI style runes is returned. +func (s *styleSet) parseOpening(runes []rune, idx int) ([]rune, int, bool) { + after, ok := checkSequence(openingBegin, runes, idx) + if !ok { + return nil, idx, false + } + + sequence := make([]rune, 0, 10) + sequence = append(sequence, '\033', '[') + + first := true + numeric := true + + key := [keySizeMax]rune{} + + for i, count := after+1, 0; i < len(runes); i++ { + r := runes[i] + + if isClose := r == openingClose[0]; isClose || r == '+' { + if count == 0 || count >= keySizeMax { + return nil, idx, false + } + + if !first { + sequence = append(sequence, ';') + } + + first = false + + if found, ok := s.named[key]; ok { + sequence = append(sequence, found...) + } else if numeric { + sequence = append(sequence, key[:count]...) + } else { + return nil, idx, false + } + + if isClose { + var ok bool + if after, ok = checkSequence(openingClose, runes, i); ok { + break + } else { + return nil, idx, false + } + } + + count = 0 + numeric = true + key = [keySizeMax]rune{} + continue + } + + if r < '0' || r > '9' { + numeric = false + } + + key[count] = r + count++ + } + + return append(sequence, 'm'), after, true +} + +// checkSequence will attempt to find a sequence of runes at a given index. +// If the sequence is found, the runes index at the end of the sequence is returned. +func checkSequence(sequence []rune, runes []rune, idx int) (int, bool) { + lenRunes, lenSequence := len(runes), len(sequence) + + // Determine if the sequence would be impossible given current lengths: + if lenRunes < lenSequence || lenRunes-idx < lenSequence { + return idx, false + } + + // Attempt to find the sequence: + for i := 0; i < lenSequence; i++ { + if sequence[i] != runes[idx+i] { + return idx, false + } + } + + // Return the index after the sequence: + return idx + lenSequence - 1, true +} diff --git a/style_set_test.go b/style_set_test.go new file mode 100644 index 0000000..cd1a7fc --- /dev/null +++ b/style_set_test.go @@ -0,0 +1,113 @@ +package instyle_test + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "instyle" + "testing" +) + +const ( + noStyle = "[tag] sending request 3.2 second ago... log id: 10298402358" + withStyle = "[!bold][tag][/] sending request [!faint]3.2 seconds ago...[/] [!bold][!cyan]log id:[/] [!magenta]10298402358[/][/]" +) + +func BenchmarkBaseline(b *testing.B) { + b.Run("BestCase", func(b *testing.B) { + for i := 0; i < b.N; i++ { + runes := make([]rune, 0, len(noStyle)) + for _, r := range []rune(noStyle) { + runes = append(runes, r) + } + } + }) + + b.Run("PerformanceGoal", func(b *testing.B) { // Ideally it's as fast as an unoptimized copy from one rune away to another. + for i := 0; i < b.N; i++ { + var runes []rune + for _, r := range []rune(noStyle) { + runes = append(runes, r) + } + } + }) +} + +func BenchmarkApply(b *testing.B) { + b.Run("NoStyle", func(b *testing.B) { + m := instyle.NewStyler() + for i := 0; i < b.N; i++ { + _ = m.Apply([]rune(noStyle)) + } + }) + + b.Run("WithStyle", func(b *testing.B) { + m := instyle.NewStyler() + withStyleRunes := []rune(withStyle) + for i := 0; i < b.N; i++ { + _ = m.Apply(withStyleRunes) + } + }) + + b.Run("WithStyleToFromString", func(b *testing.B) { + m := instyle.NewStyler() + for i := 0; i < b.N; i++ { + _ = string(m.Apply([]rune(withStyle))) + } + }) +} + +func BenchmarkSimilarLipGloss(b *testing.B) { + styleBold := lipgloss.NewStyle().Bold(true) + styleFaint := lipgloss.NewStyle().Faint(true) + styleBoldCyan := lipgloss.NewStyle().Foreground(lipgloss.Color(ansi.Cyan)).Bold(true) + styleBoldMagenta := lipgloss.NewStyle().Foreground(lipgloss.Color(ansi.Magenta)).Bold(true) + + for i := 0; i < b.N; i++ { + _ = styleBold.Render("[tag]") + + " sending request " + + styleFaint.Render("3.2 seconds ago...") + + " " + + styleBoldCyan.Render("log id:") + + " " + + styleBoldMagenta.Render("10298402358") + } +} + +func TestStyleSet_Apply(t *testing.T) { + tests := map[string]struct { + In string + Expected string + }{ + "Simple": { + In: "[!bold]bolded text[/]", + Expected: "\033[0m\033[1mbolded text\033[0m", + }, + "SequentialTags": { + In: "[!red]one[/] [!blue]two[/] [!black]three[/]", + Expected: "\033[0m\033[31mone\033[0m \033[34mtwo\033[0m \033[30mthree\033[0m", + }, + "NestedTags": { + In: "[!italic]this text is [!bold]bold [!red]red[/]-ish[/] and italic[/]", + Expected: "\033[0m\033[3mthis text is \033[1mbold \033[31mred\033[0m\033[3m\033[1m-ish\033[0m\033[3m and italic\033[0m", + }, + "UnclosedTags": { + In: "[!bold]bold and [!red]red also", + Expected: "\033[0m\033[1mbold and \033[31mred also\033[0m", + }, + "DeDuplicateEndResetTags": { + In: "[!bold]nested and [!red]red[/]", + Expected: "\033[0m\033[1mnested and \033[31mred\033[0m", + }, + } + + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + if result := instyle.NewStyler().Apply([]rune(tc.In)); string(result) != tc.Expected { + t.Logf("Want: %+v", tc.Expected) + t.Logf("Got: %+v", string(result)) + t.FailNow() + } + }) + } +}