From 928d17af7486962ac1076cc8a922e2bcea0c9bb1 Mon Sep 17 00:00:00 2001 From: Andrewkydev Date: Wed, 25 Feb 2026 22:39:00 +0300 Subject: [PATCH] first commit --- README.md | 108 ++++++++ api.go | 53 ++++ cmdline/loop.go | 328 ++++++++++++++++++++++++ examples/basic/main.go | 34 +++ examples/pipeline/main.go | 47 ++++ go.mod | 7 + go.sum | 8 + logging/logging.go | 516 ++++++++++++++++++++++++++++++++++++++ palette.go | 26 ++ style/chalk.go | 125 +++++++++ style/theme.go | 59 +++++ term/ansi_other.go | 5 + term/ansi_windows.go | 35 +++ 13 files changed, 1351 insertions(+) create mode 100644 README.md create mode 100644 api.go create mode 100644 cmdline/loop.go create mode 100644 examples/basic/main.go create mode 100644 examples/pipeline/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 logging/logging.go create mode 100644 palette.go create mode 100644 style/chalk.go create mode 100644 style/theme.go create mode 100644 term/ansi_other.go create mode 100644 term/ansi_windows.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..b25116d --- /dev/null +++ b/README.md @@ -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 + }), +} +``` diff --git a/api.go b/api.go new file mode 100644 index 0000000..91d274c --- /dev/null +++ b/api.go @@ -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) } diff --git a/cmdline/loop.go b/cmdline/loop.go new file mode 100644 index 0000000..f6a08cb --- /dev/null +++ b/cmdline/loop.go @@ -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 +} diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..593418f --- /dev/null +++ b/examples/basic/main.go @@ -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") +} diff --git a/examples/pipeline/main.go b/examples/pipeline/main.go new file mode 100644 index 0000000..a3a2ce1 --- /dev/null +++ b/examples/pipeline/main.go @@ -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") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..927acf2 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b99eb42 --- /dev/null +++ b/go.sum @@ -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= diff --git a/logging/logging.go b/logging/logging.go new file mode 100644 index 0000000..789e0ae --- /dev/null +++ b/logging/logging.go @@ -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) +} diff --git a/palette.go b/palette.go new file mode 100644 index 0000000..d090986 --- /dev/null +++ b/palette.go @@ -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)) +} diff --git a/style/chalk.go b/style/chalk.go new file mode 100644 index 0000000..1f19b40 --- /dev/null +++ b/style/chalk.go @@ -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 +} diff --git a/style/theme.go b/style/theme.go new file mode 100644 index 0000000..47d23b2 --- /dev/null +++ b/style/theme.go @@ -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"), + } +} diff --git a/term/ansi_other.go b/term/ansi_other.go new file mode 100644 index 0000000..13d46d3 --- /dev/null +++ b/term/ansi_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package term + +func EnableConsoleANSI() {} diff --git a/term/ansi_windows.go b/term/ansi_windows.go new file mode 100644 index 0000000..1479a2f --- /dev/null +++ b/term/ansi_windows.go @@ -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)) +}