first commit

This commit is contained in:
2026-02-25 22:39:00 +03:00
commit 928d17af74
13 changed files with 1351 additions and 0 deletions

108
README.md Normal file
View File

@@ -0,0 +1,108 @@
# consolex
Small standalone console toolkit:
- colored `slog` to terminal + file
- log rotation/compression
- interactive readline loop with autocomplete
- built-in commands: `help`, `clear/cls`, `uptime`, `pid`, `echo`
- fluent chalk-like styling API
- prebuilt themes and palette helpers
- pipeline logging architecture (`parse -> processors -> render`)
## Minimal usage
```go
package main
import (
"log/slog"
"github.com/VexoraDevelopment/consolex"
)
func main() {
logFile, err := consolex.SetupDefaultSlog(consolex.LoggerConfig{
LogFilePath: "server.log",
ArchiveDir: "logs",
Level: slog.LevelDebug,
Theme: consolex.NordTheme(),
FieldProvider: consolex.StaticFieldProvider{
"proto_id": consolex.New().White().BgBlue().Bold(),
"raddr": consolex.New().Black().BgYellow(),
},
FieldTransform: consolex.FieldTransformFunc(func(key, value string) (string, bool) {
if key == "raddr" {
return "\"***hidden***\"", true
}
return "", false
}),
})
if err != nil {
panic(err)
}
defer logFile.Close()
loop := consolex.NewLoop(consolex.Options{
Resolve: func(name, args string) bool {
// handle your custom commands here
return false
},
})
<-loop.Start()
}
```
## Chalk-like style API
```go
ch := consolex.New()
println(ch.Bold().BrightGreen().Sprint("server online"))
println(ch.Hex("#66ccff").Underline().Sprint("hello"))
println(ch.BgHex("#202020").White().Sprint("with background"))
```
## Preset palettes
```go
p := consolex.DefaultPalette()
println(p.Success("ok"))
println(p.Warn("careful"))
println(p.Error("boom"))
println(p.KV("proto", 924))
```
## Structure
- `consolex/style`: chalk API + themes
- `consolex/logging`: colored slog + rotation + pipeline
- `consolex/cmdline`: interactive command loop/autocomplete
- `consolex/term`: terminal ANSI helpers
- `consolex` root: facade API for easy import
- `consolex/examples/basic`: minimal integration example
- `consolex/examples/pipeline`: pipeline/profile/processors example
## Logging Pipeline
`consolex/logging` now uses `LogRecord` pipeline:
1. Parse raw slog text line to structured `LogRecord`.
2. Run processors (`FieldTransform`, `FieldProvider`, extra custom processors).
3. Render record with renderer (`defaultRenderer` by default).
You can inject custom processors and renderer via `LoggerConfig`:
```go
cfg := consolex.LoggerConfig{
Theme: consolex.NordTheme(),
Profile: consolex.DefaultProfile(),
Processors: []consolex.Processor{
consolex.ProcessorFunc(func(rec *consolex.LogRecord) {
// custom mutation logic
}),
},
Renderer: consolex.RendererFunc(func(rec *consolex.LogRecord) string {
// custom final formatting
return rec.Raw
}),
}
```

53
api.go Normal file
View File

@@ -0,0 +1,53 @@
package consolex
import (
"os"
"github.com/VexoraDevelopment/consolex/cmdline"
"github.com/VexoraDevelopment/consolex/logging"
"github.com/VexoraDevelopment/consolex/style"
)
type Chalk = style.Chalk
type Theme = style.Theme
func New() Chalk { return style.New() }
func Disabled() Chalk { return style.Disabled() }
func DefaultTheme() Theme { return style.DefaultTheme() }
func NordTheme() Theme { return style.NordTheme() }
func SunsetTheme() Theme { return style.SunsetTheme() }
type LoggerConfig = logging.LoggerConfig
type Profile = logging.Profile
type LogRecord = logging.LogRecord
type RecordField = logging.RecordField
type Processor = logging.Processor
type ProcessorFunc = logging.ProcessorFunc
type Renderer = logging.Renderer
type RendererFunc = logging.RendererFunc
type FieldStyleProvider = logging.FieldStyleProvider
type FieldStyleFunc = logging.FieldStyleFunc
type StaticFieldProvider = logging.StaticFieldProvider
type FieldTransformer = logging.FieldTransformer
type FieldTransformFunc = logging.FieldTransformFunc
func DefaultProfile() Profile { return logging.DefaultProfile() }
func ParseTextLogLine(line string) *LogRecord { return logging.ParseTextLogLine(line) }
func SetupDefaultSlog(cfg LoggerConfig) (*os.File, error) {
return logging.SetupDefaultSlog(cfg)
}
func RotateAndCompressLog(srcPath, archiveDir string) error {
return logging.RotateAndCompressLog(srcPath, archiveDir)
}
func ColorizeLogLine(line string) string { return logging.ColorizeLogLine(line) }
func StripANSI(s string) string { return style.StripANSI(s) }
type Command = cmdline.Command
type Options = cmdline.Options
type Loop = cmdline.Loop
func NewLoop(opts Options) *Loop { return cmdline.NewLoop(opts) }

328
cmdline/loop.go Normal file
View File

@@ -0,0 +1,328 @@
package cmdline
import (
"errors"
"fmt"
"log/slog"
"os"
"sort"
"strings"
"time"
"unicode"
"github.com/chzyer/readline"
)
type Command struct {
Name string
Aliases []string
Description string
Execute func(args string)
Complete func(argPos int, prefix string) []string
}
type Options struct {
Prompt string
HistoryLimit int
InterruptPrompt string
EOFPrompt string
OnInterrupt func()
OnUnknown func(name string)
Resolve func(name, args string) bool
CommandNames func(prefix string) []string
ArgSuggestions func(name string, argPos int, prefix string) []string
Out *os.File
Log *slog.Logger
}
type Loop struct {
opts Options
done chan struct{}
commands map[string]*Command
started time.Time
}
func NewLoop(opts Options) *Loop {
if strings.TrimSpace(opts.Prompt) == "" {
opts.Prompt = "> "
}
if opts.HistoryLimit <= 0 {
opts.HistoryLimit = 200
}
if strings.TrimSpace(opts.InterruptPrompt) == "" {
opts.InterruptPrompt = "^C"
}
if strings.TrimSpace(opts.EOFPrompt) == "" {
opts.EOFPrompt = "exit"
}
if opts.Out == nil {
opts.Out = os.Stdout
}
if opts.Log == nil {
opts.Log = slog.Default()
}
l := &Loop{
opts: opts,
done: make(chan struct{}),
commands: map[string]*Command{},
started: time.Now(),
}
l.Register(Command{
Name: "help",
Description: "Show available console commands",
Execute: func(string) {
names := l.availableNames("")
_, _ = fmt.Fprintln(opts.Out, "Available commands:")
for _, n := range names {
_, _ = fmt.Fprintln(opts.Out, " - "+n)
}
},
})
l.Register(Command{
Name: "clear",
Aliases: []string{"cls"},
Description: "Clear terminal screen",
Execute: func(string) {
_, _ = fmt.Fprint(opts.Out, "\x1b[H\x1b[2J")
},
})
l.Register(Command{
Name: "uptime",
Description: "Show console loop uptime",
Execute: func(string) {
_, _ = fmt.Fprintf(opts.Out, "uptime: %s\n", time.Since(l.started).Truncate(time.Second))
},
})
l.Register(Command{
Name: "pid",
Description: "Show current process id",
Execute: func(string) {
_, _ = fmt.Fprintf(opts.Out, "pid: %d\n", os.Getpid())
},
})
l.Register(Command{
Name: "echo",
Description: "Print text back to console",
Execute: func(args string) {
_, _ = fmt.Fprintln(opts.Out, args)
},
})
return l
}
func (l *Loop) Register(c Command) {
name := strings.ToLower(strings.TrimSpace(c.Name))
if name == "" {
return
}
if len(c.Aliases) == 0 {
c.Aliases = []string{name}
} else {
seen := map[string]struct{}{}
out := make([]string, 0, len(c.Aliases)+1)
out = append(out, name)
seen[name] = struct{}{}
for _, a := range c.Aliases {
s := strings.ToLower(strings.TrimSpace(a))
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
c.Aliases = out
}
cmdCopy := c
for _, a := range c.Aliases {
l.commands[a] = &cmdCopy
}
}
func (l *Loop) Start() <-chan struct{} {
go func() {
defer close(l.done)
rl, err := readline.NewEx(&readline.Config{
Prompt: l.opts.Prompt,
HistoryLimit: l.opts.HistoryLimit,
InterruptPrompt: l.opts.InterruptPrompt,
EOFPrompt: l.opts.EOFPrompt,
AutoComplete: &completer{loop: l},
})
if err != nil {
l.opts.Log.Warn("console readline init failed", "err", err)
return
}
defer func(rl *readline.Instance) {
_ = rl.Close()
}(rl)
l.opts.Log.Info("console command input enabled")
for {
line, err := rl.Readline()
if errors.Is(err, readline.ErrInterrupt) {
if l.opts.OnInterrupt != nil {
l.opts.OnInterrupt()
}
return
}
if err != nil {
break
}
name, args, ok := splitCommand(line)
if !ok {
continue
}
if cmd, found := l.commands[name]; found {
if cmd.Execute != nil {
cmd.Execute(args)
}
continue
}
if l.opts.Resolve != nil && l.opts.Resolve(name, args) {
continue
}
if l.opts.OnUnknown != nil {
l.opts.OnUnknown(name)
}
}
l.opts.Log.Info("console input stopped")
}()
return l.done
}
type completer struct {
loop *Loop
}
func (c *completer) Do(line []rune, pos int) ([][]rune, int) {
input := string(line[:pos])
trimmed := strings.TrimLeftFunc(input, unicode.IsSpace)
if strings.HasPrefix(trimmed, "/") {
trimmed = strings.TrimPrefix(trimmed, "/")
}
if trimmed == "" {
return toRunes(c.loop.availableNames("")), 0
}
hasTrailingSpace := len(trimmed) > 0 && unicode.IsSpace(rune(trimmed[len(trimmed)-1]))
parts := strings.Fields(trimmed)
if len(parts) == 0 {
return toRunes(completionTail(c.loop.availableNames(""), "")), 0
}
if len(parts) == 1 && !hasTrailingSpace {
prefix := strings.ToLower(parts[0])
return toRunes(completionTail(c.loop.availableNames(prefix), prefix)), len([]rune(prefix))
}
name := strings.ToLower(parts[0])
argPos := 0
prefix := ""
if hasTrailingSpace {
argPos = len(parts) - 1
} else {
argPos = len(parts) - 2
prefix = parts[len(parts)-1]
}
if argPos < 0 {
return nil, 0
}
suggestions := map[string]struct{}{}
if cmd, ok := c.loop.commands[name]; ok && cmd.Complete != nil {
for _, s := range cmd.Complete(argPos, prefix) {
if matchPrefix(s, prefix) {
suggestions[s] = struct{}{}
}
}
} else if c.loop.opts.ArgSuggestions != nil {
for _, s := range c.loop.opts.ArgSuggestions(name, argPos, prefix) {
if matchPrefix(s, prefix) {
suggestions[s] = struct{}{}
}
}
}
if len(suggestions) == 0 {
return nil, 0
}
out := make([]string, 0, len(suggestions))
for s := range suggestions {
out = append(out, s)
}
sort.Strings(out)
return toRunes(completionTail(out, prefix)), len([]rune(prefix))
}
func (l *Loop) availableNames(prefix string) []string {
names := map[string]struct{}{}
for alias := range l.commands {
if matchPrefix(alias, prefix) {
names[alias] = struct{}{}
}
}
if l.opts.CommandNames != nil {
for _, n := range l.opts.CommandNames(prefix) {
s := strings.ToLower(strings.TrimSpace(n))
if s == "" {
continue
}
if matchPrefix(s, prefix) {
names[s] = struct{}{}
}
}
}
out := make([]string, 0, len(names))
for n := range names {
out = append(out, n)
}
sort.Strings(out)
return out
}
func splitCommand(line string) (name, args string, ok bool) {
line = strings.TrimSpace(line)
if line == "" {
return "", "", false
}
if strings.HasPrefix(line, "/") {
line = strings.TrimSpace(strings.TrimPrefix(line, "/"))
}
parts := strings.SplitN(line, " ", 2)
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
return "", "", false
}
name = strings.ToLower(strings.TrimSpace(parts[0]))
if len(parts) > 1 {
args = strings.TrimSpace(parts[1])
}
return name, args, true
}
func matchPrefix(value, prefix string) bool {
if prefix == "" {
return true
}
return strings.HasPrefix(strings.ToLower(value), strings.ToLower(prefix))
}
func completionTail(options []string, prefix string) []string {
out := make([]string, 0, len(options))
prefixRunes := []rune(prefix)
prefixLen := len(prefixRunes)
for _, option := range options {
optRunes := []rune(option)
if prefixLen > 0 && len(optRunes) >= prefixLen &&
strings.EqualFold(string(optRunes[:prefixLen]), prefix) {
out = append(out, string(optRunes[prefixLen:]))
continue
}
out = append(out, option)
}
return out
}
func toRunes(items []string) [][]rune {
out := make([][]rune, 0, len(items))
for _, item := range items {
out = append(out, []rune(item))
}
return out
}

34
examples/basic/main.go Normal file
View File

@@ -0,0 +1,34 @@
package main
import (
"log/slog"
"strings"
"github.com/VexoraDevelopment/consolex"
)
func main() {
logFile, err := consolex.SetupDefaultSlog(consolex.LoggerConfig{
LogFilePath: "server.log",
ArchiveDir: "logs",
Level: slog.LevelDebug,
Theme: consolex.NordTheme(),
FieldProvider: consolex.StaticFieldProvider{
"addr": consolex.New().Black().BgCyan().Bold(),
"proto_id": consolex.New().White().BgBlue().Bold(),
},
FieldTransform: consolex.FieldTransformFunc(func(key, value string) (string, bool) {
if key == "dimension" {
return "\"" + strings.ToUpper(strings.Trim(value, "\"")) + "\"", true
}
return "", false
}),
})
if err != nil {
panic(err)
}
defer logFile.Close()
slog.Info("Listener running.", "addr", "[::]:19132")
slog.Debug("Loading dimension...", "dimension", "overworld")
}

47
examples/pipeline/main.go Normal file
View File

@@ -0,0 +1,47 @@
package main
import (
"log/slog"
"github.com/VexoraDevelopment/consolex"
)
func main() {
profile := consolex.DefaultProfile()
profile.HideKeys["name"] = false
profile.HideKeys["dimension"] = false
logFile, err := consolex.SetupDefaultSlog(consolex.LoggerConfig{
LogFilePath: "server.log",
ArchiveDir: "logs",
Level: slog.LevelDebug,
Theme: consolex.DefaultTheme(),
Profile: profile,
Processors: []consolex.Processor{
consolex.ProcessorFunc(func(rec *consolex.LogRecord) {
// Example: force showing key for world pointer fields in compact layout.
for i := range rec.Fields {
if rec.Fields[i].Key == "ptr" {
rec.Fields[i].ShowKey = true
}
}
}),
},
FieldProvider: consolex.FieldStyleFunc(func(key, value string) (consolex.Chalk, bool) {
switch key {
case "name":
return consolex.New().Black().BgYellow(), true
case "ptr":
return consolex.New().White().BgMagenta(), true
default:
return consolex.Chalk{}, false
}
}),
})
if err != nil {
panic(err)
}
defer logFile.Close()
slog.Info("world register", "name", "hub_snow", "ptr", "0xc004ba0ea0")
}

7
go.mod Normal file
View File

@@ -0,0 +1,7 @@
module github.com/VexoraDevelopment/consolex
go 1.25
require github.com/chzyer/readline v1.5.1
require golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect

8
go.sum Normal file
View File

@@ -0,0 +1,8 @@
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

516
logging/logging.go Normal file
View File

@@ -0,0 +1,516 @@
package logging
import (
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/VexoraDevelopment/consolex/style"
"github.com/VexoraDevelopment/consolex/term"
)
type RecordField struct {
Key string
Value string
ValueOut string
ShowKey bool
Styled bool
Style style.Chalk
}
type LogRecord struct {
Raw string
Time string
Level string
Message string
Fields []RecordField
}
type Profile struct {
HideKeys map[string]bool
LevelLabels map[string]string
CompactMode bool
}
func DefaultProfile() Profile {
return Profile{
HideKeys: map[string]bool{
"time": true,
"level": true,
"msg": true,
"err": true,
"error": true,
"player": true,
"world": true,
},
LevelLabels: map[string]string{
"DEBUG": "DBG",
"INFO": "INF",
"WARN": "WRN",
"ERROR": "ERR",
},
CompactMode: true,
}
}
func normalizeProfile(p Profile) Profile {
d := DefaultProfile()
if p.HideKeys == nil {
p.HideKeys = d.HideKeys
}
if p.LevelLabels == nil {
p.LevelLabels = d.LevelLabels
}
if !p.CompactMode {
p.CompactMode = d.CompactMode
}
return p
}
type Processor interface {
Process(rec *LogRecord)
}
type ProcessorFunc func(rec *LogRecord)
func (f ProcessorFunc) Process(rec *LogRecord) {
f(rec)
}
type Renderer interface {
Render(rec *LogRecord) string
}
type RendererFunc func(rec *LogRecord) string
func (f RendererFunc) Render(rec *LogRecord) string {
return f(rec)
}
type FieldStyleProvider interface {
StyleField(key, value string) (style.Chalk, bool)
}
type FieldTransformer interface {
TransformField(key, value string) (string, bool)
}
type FieldStyleFunc func(key, value string) (style.Chalk, bool)
func (f FieldStyleFunc) StyleField(key, value string) (style.Chalk, bool) {
return f(key, value)
}
type FieldTransformFunc func(key, value string) (string, bool)
func (f FieldTransformFunc) TransformField(key, value string) (string, bool) {
return f(key, value)
}
type StaticFieldProvider map[string]style.Chalk
func (p StaticFieldProvider) StyleField(key, _ string) (style.Chalk, bool) {
st, ok := p[key]
return st, ok
}
type fieldTransformProcessor struct {
transformer FieldTransformer
}
func (p fieldTransformProcessor) Process(rec *LogRecord) {
if p.transformer == nil {
return
}
for i := range rec.Fields {
if out, ok := p.transformer.TransformField(rec.Fields[i].Key, rec.Fields[i].Value); ok {
rec.Fields[i].ValueOut = out
}
}
}
type fieldStyleProcessor struct {
provider FieldStyleProvider
}
func (p fieldStyleProcessor) Process(rec *LogRecord) {
if p.provider == nil {
return
}
for i := range rec.Fields {
value := rec.Fields[i].Value
if rec.Fields[i].ValueOut != "" {
value = rec.Fields[i].ValueOut
}
if st, ok := p.provider.StyleField(rec.Fields[i].Key, value); ok {
rec.Fields[i].Style = st
rec.Fields[i].Styled = true
}
}
}
type errorFieldProcessor struct {
errStyle style.Chalk
}
func (p errorFieldProcessor) Process(rec *LogRecord) {
for i := range rec.Fields {
if rec.Fields[i].Styled {
continue
}
if rec.Fields[i].Key == "err" || rec.Fields[i].Key == "error" {
rec.Fields[i].Style = p.errStyle
rec.Fields[i].Styled = true
}
}
}
type Pipeline struct {
processors []Processor
renderer Renderer
}
func NewPipeline(theme style.Theme, profile Profile, provider FieldStyleProvider, transformer FieldTransformer, extras []Processor, renderer Renderer) *Pipeline {
processors := []Processor{
fieldTransformProcessor{transformer: transformer},
fieldStyleProcessor{provider: provider},
errorFieldProcessor{errStyle: theme.ErrKey},
}
processors = append(processors, extras...)
if renderer == nil {
renderer = newDefaultRenderer(theme, profile)
}
return &Pipeline{processors: processors, renderer: renderer}
}
func (p *Pipeline) Colorize(line string) string {
rec := ParseTextLogLine(line)
for _, proc := range p.processors {
proc.Process(rec)
}
return p.renderer.Render(rec)
}
type defaultRenderer struct {
theme style.Theme
profile Profile
}
func newDefaultRenderer(theme style.Theme, profile Profile) Renderer {
return defaultRenderer{theme: theme, profile: normalizeProfile(profile)}
}
func (r defaultRenderer) Render(rec *LogRecord) string {
parts := make([]string, 0, 3+len(rec.Fields))
if rec.Time != "" {
parts = append(parts, r.theme.TimeValue.Dim().Wrap(rec.Time))
}
if rec.Level != "" {
parts = append(parts, r.levelBadge(rec.Level))
}
if rec.Message != "" {
parts = append(parts, rec.Message)
}
for _, f := range rec.Fields {
value := f.Value
if f.ValueOut != "" {
value = f.ValueOut
}
if f.Styled {
value = f.Style.Wrap(value)
}
showKey := f.ShowKey
if r.profile.CompactMode {
if hidden, ok := r.profile.HideKeys[f.Key]; ok && hidden {
showKey = false
}
}
if !showKey {
parts = append(parts, value)
continue
}
parts = append(parts, f.Key+"="+value)
}
return strings.Join(parts, " ")
}
func (r defaultRenderer) levelBadge(level string) string {
lvl := strings.ToUpper(level)
label := lvl
if l, ok := r.profile.LevelLabels[lvl]; ok {
label = l
}
switch lvl {
case "DEBUG":
return r.theme.Debug.Wrap(label)
case "WARN":
return r.theme.Warn.Wrap(label)
case "ERROR":
return r.theme.Error.Wrap(label)
default:
return r.theme.Info.Wrap(label)
}
}
func ParseTextLogLine(line string) *LogRecord {
rec := &LogRecord{Raw: line, Fields: make([]RecordField, 0, 16)}
tokens := splitQuotedTokens(line)
for _, tok := range tokens {
i := strings.IndexByte(tok, '=')
if i <= 0 {
continue
}
key := tok[:i]
value := tok[i+1:]
switch key {
case "time":
rec.Time = value
case "level":
rec.Level = strings.Trim(value, "\"")
case "msg":
rec.Message = value
default:
rec.Fields = append(rec.Fields, RecordField{
Key: key,
Value: value,
ShowKey: true,
})
}
}
return rec
}
func splitQuotedTokens(s string) []string {
tokens := make([]string, 0, 16)
start := -1
inQuotes := false
escaped := false
for i := 0; i < len(s); i++ {
c := s[i]
if start == -1 && c != ' ' {
start = i
}
if start == -1 {
continue
}
if inQuotes {
if escaped {
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if c == '"' {
inQuotes = false
}
continue
}
if c == '"' {
inQuotes = true
continue
}
if c == ' ' {
tokens = append(tokens, s[start:i])
start = -1
}
}
if start != -1 {
tokens = append(tokens, s[start:])
}
return tokens
}
var (
stateMu sync.RWMutex
currentTheme = style.DefaultTheme()
currentProf = DefaultProfile()
pipeline = NewPipeline(currentTheme, currentProf, nil, nil, nil, nil)
)
type LoggerConfig struct {
LogFilePath string
ArchiveDir string
Level slog.Level
Theme style.Theme
Profile Profile
FieldProvider FieldStyleProvider
FieldTransform FieldTransformer
Processors []Processor
Renderer Renderer
}
func SetupDefaultSlog(cfg LoggerConfig) (*os.File, error) {
term.EnableConsoleANSI()
theme := cfg.Theme
if theme.TimeKey.Wrap("x") == "x" {
theme = style.DefaultTheme()
}
prof := normalizeProfile(cfg.Profile)
pl := NewPipeline(theme, prof, cfg.FieldProvider, cfg.FieldTransform, cfg.Processors, cfg.Renderer)
stateMu.Lock()
currentTheme = theme
currentProf = prof
pipeline = pl
stateMu.Unlock()
logPath := strings.TrimSpace(cfg.LogFilePath)
if logPath == "" {
logPath = "server.log"
}
archiveDir := strings.TrimSpace(cfg.ArchiveDir)
if archiveDir == "" {
archiveDir = "logs"
}
if err := os.MkdirAll(archiveDir, 0o755); err != nil {
return nil, fmt.Errorf("create logs dir: %w", err)
}
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return nil, fmt.Errorf("open %s: %w", logPath, err)
}
consoleOut := NewColorizingWriter(os.Stdout)
consoleHandler := slog.NewTextHandler(consoleOut, &slog.HandlerOptions{Level: cfg.Level})
fileHandler := slog.NewTextHandler(file, &slog.HandlerOptions{Level: cfg.Level})
slog.SetDefault(slog.New(fanoutHandler{handlers: []slog.Handler{consoleHandler, fileHandler}}))
return file, nil
}
func RotateAndCompressLog(srcPath, archiveDir string) error {
srcPath = strings.TrimSpace(srcPath)
if srcPath == "" {
srcPath = "server.log"
}
archiveDir = strings.TrimSpace(archiveDir)
if archiveDir == "" {
archiveDir = "logs"
}
if err := os.MkdirAll(archiveDir, 0o755); err != nil {
return err
}
info, err := os.Stat(srcPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.Size() == 0 {
return nil
}
ts := time.Now().Format("2006-01-02_15-04-05")
dst := filepath.Join(archiveDir, fmt.Sprintf("server_%s.log.gz", ts))
in, err := os.Open(srcPath)
if err != nil {
return err
}
defer func(in *os.File) {
_ = in.Close()
}(in)
out, err := os.Create(dst)
if err != nil {
return err
}
gz := gzip.NewWriter(out)
_, copyErr := io.Copy(gz, in)
closeErr := gz.Close()
outCloseErr := out.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
if outCloseErr != nil {
return outCloseErr
}
return os.Truncate(srcPath, 0)
}
type fanoutHandler struct {
handlers []slog.Handler
}
func (h fanoutHandler) Enabled(ctx context.Context, level slog.Level) bool {
for _, next := range h.handlers {
if next.Enabled(ctx, level) {
return true
}
}
return false
}
func (h fanoutHandler) Handle(ctx context.Context, rec slog.Record) error {
var firstErr error
for _, next := range h.handlers {
if err := next.Handle(ctx, rec); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (h fanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
out := make([]slog.Handler, 0, len(h.handlers))
for _, next := range h.handlers {
out = append(out, next.WithAttrs(attrs))
}
return fanoutHandler{handlers: out}
}
func (h fanoutHandler) WithGroup(name string) slog.Handler {
out := make([]slog.Handler, 0, len(h.handlers))
for _, next := range h.handlers {
out = append(out, next.WithGroup(name))
}
return fanoutHandler{handlers: out}
}
type ColorizingWriter struct {
dst io.Writer
buf []byte
}
func NewColorizingWriter(dst io.Writer) *ColorizingWriter {
return &ColorizingWriter{dst: dst}
}
func (w *ColorizingWriter) Write(p []byte) (int, error) {
w.buf = append(w.buf, p...)
for {
i := bytes.IndexByte(w.buf, '\n')
if i < 0 {
break
}
line := string(w.buf[:i])
w.buf = w.buf[i+1:]
if _, err := io.WriteString(w.dst, ColorizeLogLine(line)+"\n"); err != nil {
return len(p), err
}
}
return len(p), nil
}
func ColorizeLogLine(line string) string {
stateMu.RLock()
pl := pipeline
stateMu.RUnlock()
if pl == nil {
return line
}
return pl.Colorize(line)
}

26
palette.go Normal file
View File

@@ -0,0 +1,26 @@
package consolex
import "fmt"
type Palette struct {
Theme Theme
}
func NewPalette(theme Theme) Palette {
return Palette{Theme: theme}
}
func DefaultPalette() Palette { return NewPalette(DefaultTheme()) }
func NordPalette() Palette { return NewPalette(NordTheme()) }
func SunsetPalette() Palette { return NewPalette(SunsetTheme()) }
func (p Palette) Success(v ...any) string { return p.Theme.Info.Bold().Sprint(v...) }
func (p Palette) Info(v ...any) string { return p.Theme.Info.Sprint(v...) }
func (p Palette) Warn(v ...any) string { return p.Theme.Warn.Bold().Sprint(v...) }
func (p Palette) Error(v ...any) string { return p.Theme.Error.Bold().Sprint(v...) }
func (p Palette) Debug(v ...any) string { return p.Theme.Debug.Sprint(v...) }
func (p Palette) Muted(v ...any) string { return p.Theme.TimeKey.Sprint(v...) }
func (p Palette) KV(key string, value any) string {
return p.Theme.MsgKey.Wrap(key) + "=" + p.Theme.TimeValue.Wrap(fmt.Sprint(value))
}

125
style/chalk.go Normal file
View File

@@ -0,0 +1,125 @@
package style
import (
"fmt"
"regexp"
"strconv"
"strings"
)
type Chalk struct {
enabled bool
codes []string
}
func New() Chalk {
return Chalk{enabled: true}
}
func Disabled() Chalk {
return Chalk{enabled: false}
}
func (c Chalk) WithEnabled(enabled bool) Chalk {
c.enabled = enabled
return c
}
func (c Chalk) cloneWith(code string) Chalk {
out := Chalk{enabled: c.enabled, codes: make([]string, 0, len(c.codes)+1)}
out.codes = append(out.codes, c.codes...)
out.codes = append(out.codes, code)
return out
}
func (c Chalk) code(v int) Chalk { return c.cloneWith(strconv.Itoa(v)) }
func (c Chalk) Bold() Chalk { return c.code(1) }
func (c Chalk) Dim() Chalk { return c.code(2) }
func (c Chalk) Italic() Chalk { return c.code(3) }
func (c Chalk) Underline() Chalk { return c.code(4) }
func (c Chalk) Inverse() Chalk { return c.code(7) }
func (c Chalk) Strikethrough() Chalk { return c.code(9) }
func (c Chalk) Black() Chalk { return c.code(30) }
func (c Chalk) Red() Chalk { return c.code(31) }
func (c Chalk) Green() Chalk { return c.code(32) }
func (c Chalk) Yellow() Chalk { return c.code(33) }
func (c Chalk) Blue() Chalk { return c.code(34) }
func (c Chalk) Magenta() Chalk { return c.code(35) }
func (c Chalk) Cyan() Chalk { return c.code(36) }
func (c Chalk) White() Chalk { return c.code(37) }
func (c Chalk) Gray() Chalk { return c.code(90) }
func (c Chalk) BrightBlack() Chalk { return c.code(90) }
func (c Chalk) BrightRed() Chalk { return c.code(91) }
func (c Chalk) BrightGreen() Chalk { return c.code(92) }
func (c Chalk) BrightYellow() Chalk { return c.code(93) }
func (c Chalk) BrightBlue() Chalk { return c.code(94) }
func (c Chalk) BrightMagenta() Chalk { return c.code(95) }
func (c Chalk) BrightCyan() Chalk { return c.code(96) }
func (c Chalk) BrightWhite() Chalk { return c.code(97) }
func (c Chalk) BgBlack() Chalk { return c.code(40) }
func (c Chalk) BgRed() Chalk { return c.code(41) }
func (c Chalk) BgGreen() Chalk { return c.code(42) }
func (c Chalk) BgYellow() Chalk { return c.code(43) }
func (c Chalk) BgBlue() Chalk { return c.code(44) }
func (c Chalk) BgMagenta() Chalk { return c.code(45) }
func (c Chalk) BgCyan() Chalk { return c.code(46) }
func (c Chalk) BgWhite() Chalk { return c.code(47) }
func (c Chalk) RGB(r, g, b uint8) Chalk {
return c.cloneWith(fmt.Sprintf("38;2;%d;%d;%d", r, g, b))
}
func (c Chalk) BgRGB(r, g, b uint8) Chalk {
return c.cloneWith(fmt.Sprintf("48;2;%d;%d;%d", r, g, b))
}
func (c Chalk) Hex(hex string) Chalk {
if r, g, b, ok := parseHexColor(hex); ok {
return c.RGB(r, g, b)
}
return c
}
func (c Chalk) BgHex(hex string) Chalk {
if r, g, b, ok := parseHexColor(hex); ok {
return c.BgRGB(r, g, b)
}
return c
}
func (c Chalk) Wrap(text string) string {
if !c.enabled || len(c.codes) == 0 || text == "" {
return text
}
return "\x1b[" + strings.Join(c.codes, ";") + "m" + text + "\x1b[0m"
}
func (c Chalk) Sprint(v ...any) string {
return c.Wrap(fmt.Sprint(v...))
}
func (c Chalk) Sprintf(format string, v ...any) string {
return c.Wrap(fmt.Sprintf(format, v...))
}
var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func StripANSI(s string) string {
return ansiPattern.ReplaceAllString(s, "")
}
func parseHexColor(hex string) (uint8, uint8, uint8, bool) {
h := strings.TrimSpace(strings.TrimPrefix(hex, "#"))
if len(h) != 6 {
return 0, 0, 0, false
}
v, err := strconv.ParseUint(h, 16, 32)
if err != nil {
return 0, 0, 0, false
}
return uint8(v >> 16), uint8((v >> 8) & 0xff), uint8(v & 0xff), true
}

59
style/theme.go Normal file
View File

@@ -0,0 +1,59 @@
package style
type Theme struct {
TimeKey Chalk
TimeValue Chalk
MsgKey Chalk
Debug Chalk
Info Chalk
Warn Chalk
Error Chalk
ErrKey Chalk
PlayerKey Chalk
WorldKey Chalk
}
func DefaultTheme() Theme {
return Theme{
TimeKey: New().Gray(),
TimeValue: New().BrightBlue(),
MsgKey: New().BrightWhite(),
Debug: New().Black().BgCyan().Bold(),
Info: New().Black().BgHex("#40E0D0").Bold(),
Warn: New().Black().BgYellow().Bold(),
Error: New().White().BgRed().Bold(),
ErrKey: New().White().BgRed().Bold(),
PlayerKey: New().BrightCyan(),
WorldKey: New().Magenta(),
}
}
func NordTheme() Theme {
return Theme{
TimeKey: New().Hex("#81A1C1"),
TimeValue: New().Hex("#88C0D0"),
MsgKey: New().Hex("#ECEFF4").Bold(),
Debug: New().Black().BgHex("#88C0D0").Bold(),
Info: New().Black().BgHex("#8FBCBB").Bold(),
Warn: New().Black().BgHex("#EBCB8B").Bold(),
Error: New().Hex("#ECEFF4").BgHex("#BF616A").Bold(),
ErrKey: New().Hex("#ECEFF4").BgHex("#D08770").Bold(),
PlayerKey: New().Hex("#B48EAD"),
WorldKey: New().Hex("#5E81AC"),
}
}
func SunsetTheme() Theme {
return Theme{
TimeKey: New().Hex("#F8C8DC"),
TimeValue: New().Hex("#F4A261"),
MsgKey: New().Hex("#FFF1E6"),
Debug: New().Black().BgHex("#9BF6FF").Bold(),
Info: New().Black().BgHex("#70E0C0").Bold(),
Warn: New().Black().BgHex("#FFD166").Bold(),
Error: New().Hex("#FFF1E6").BgHex("#FF6B6B").Bold(),
ErrKey: New().Hex("#FFF1E6").BgHex("#FF8FA3").Bold(),
PlayerKey: New().Hex("#CDB4DB"),
WorldKey: New().Hex("#A0C4FF"),
}
}

5
term/ansi_other.go Normal file
View File

@@ -0,0 +1,5 @@
//go:build !windows
package term
func EnableConsoleANSI() {}

35
term/ansi_windows.go Normal file
View File

@@ -0,0 +1,35 @@
//go:build windows
package term
import (
"os"
"syscall"
"unsafe"
)
const enableVirtualTerminalProcessing = 0x0004
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procGetConsoleMode = kernel32.NewProc("GetConsoleMode")
procSetConsoleMode = kernel32.NewProc("SetConsoleMode")
)
func EnableConsoleANSI() {
enableHandleANSI(os.Stdout)
enableHandleANSI(os.Stderr)
}
func enableHandleANSI(f *os.File) {
if f == nil {
return
}
var mode uint32
fd := f.Fd()
r1, _, _ := procGetConsoleMode.Call(fd, uintptr(unsafe.Pointer(&mode)))
if r1 == 0 {
return
}
_, _, _ = procSetConsoleMode.Call(fd, uintptr(mode|enableVirtualTerminalProcessing))
}