From cacb699718c5cc59f4426e9cbab8fd5e7d84fd9d Mon Sep 17 00:00:00 2001 From: Tigor Hutasuhut Date: Sat, 6 Apr 2024 01:22:00 +0700 Subject: [PATCH] initial commit --- .envrc | 1 + .gitignore | 2 + cli/cli.go | 8 ++ cli/serve.go | 16 ++++ config/config.go | 44 +++++++++++ errs/errs.go | 127 ++++++++++++++++++++++++++++++ flake.lock | 122 ++++++++++++++++++++++++++++ flake.nix | 24 ++++++ go.mod | 23 ++++++ go.sum | 35 +++++++++ log/context.go | 21 +++++ log/log.go | 151 +++++++++++++++++++++++++++++++++++ log/nil_handler.go | 13 +++ log/pretty_handler.go | 179 ++++++++++++++++++++++++++++++++++++++++++ main.go | 14 ++++ 15 files changed, 780 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 cli/cli.go create mode 100644 cli/serve.go create mode 100644 config/config.go create mode 100644 errs/errs.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 log/context.go create mode 100644 log/log.go create mode 100644 log/nil_handler.go create mode 100644 log/pretty_handler.go create mode 100644 main.go diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a88ab8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.direnv + diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 0000000..1e4bc95 --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,8 @@ +package cli + +import "github.com/spf13/cobra" + +var RootCmd = &cobra.Command{ + Use: "redmage", + Short: "Redmage is an HTTP server to download images from Reddit.", +} diff --git a/cli/serve.go b/cli/serve.go new file mode 100644 index 0000000..ff553dd --- /dev/null +++ b/cli/serve.go @@ -0,0 +1,16 @@ +package cli + +import "github.com/spf13/cobra" + +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "Starts the HTTP Server", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +func init() { + RootCmd.AddCommand(serveCmd) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..e1312da --- /dev/null +++ b/config/config.go @@ -0,0 +1,44 @@ +package config + +import ( + "github.com/knadh/koanf/providers/confmap" + "github.com/knadh/koanf/v2" +) + +type Config struct { + *koanf.Koanf +} + +func EmptyConfig() *Config { + return NewConfigBuilder().Build() +} + +type ConfigBuilder struct { + koanf *koanf.Koanf + err error +} + +func NewConfigBuilder() *ConfigBuilder { + return &ConfigBuilder{koanf: koanf.New(".")} +} + +func (builder *ConfigBuilder) Build() *Config { + return &Config{Koanf: builder.koanf} +} + +func (builder *ConfigBuilder) BuildHandle() (*Config, error) { + return &Config{Koanf: builder.koanf}, builder.err +} + +func (builder *ConfigBuilder) LoadDefault() *ConfigBuilder { + provider := confmap.Provider(map[string]any{ + "log.enable": true, + "log.source": true, + "log.format": "pretty", + "log.level": "info", + "log.output": "stderr", + }, ".") + + _ = builder.koanf.Load(provider, nil) + return builder +} diff --git a/errs/errs.go b/errs/errs.go new file mode 100644 index 0000000..e898eb0 --- /dev/null +++ b/errs/errs.go @@ -0,0 +1,127 @@ +package errs + +import ( + "context" + "errors" + "log/slog" + "os" + "reflect" + "runtime" + "strings" +) + +type Error interface { + error + Message(msg string, args ...any) Error + GetMessage() string + Code(status int) Error + GetCode() int + Caller(pc uintptr) Error + GetCaller() uintptr + Details(...any) Error + GetDetails() []any + Log(ctx context.Context) Error +} + +var _ Error = (*Err)(nil) + +type Err struct { + msg string + code int + caller uintptr + details []any + origin error +} + +func (er *Err) LogValue() slog.Value { + values := make([]slog.Attr, 0, 5) + + if er.msg != "" { + values = append(values, slog.String("message", er.msg)) + } + + if er.code != 0 { + values = append(values, slog.Int("code", er.code)) + } + if er.caller != 0 { + frame, _ := runtime.CallersFrames([]uintptr{er.caller}).Next() + split := strings.Split(frame.Function, string(os.PathSeparator)) + fnName := split[len(split)-1] + + values = append(values, slog.Group("origin", + slog.String("file", frame.File), + slog.Int("line", frame.Line), + slog.String("function", fnName), + )) + } + + if len(er.details) > 0 { + values = append(values, slog.Group("details", er.details...)) + } + + values = append(values, slog.Group("error", + slog.String("type", reflect.TypeOf(er.origin).String()), + slog.Any("data", er.origin), + )) + + return slog.GroupValue(values...) +} + +func (er *Err) Error() string { + var ( + s = strings.Builder{} + source = er.origin + msg = er.msg + ": " + source.Error() + ) + for unwrap := errors.Unwrap(source); unwrap != nil; source = unwrap { + originMsg := unwrap.Error() + // TODO: Test this! + if cut, found := strings.CutSuffix(msg, originMsg); found { + s.WriteString(cut) + msg = originMsg + } else { + s.WriteString(msg) + } + + s.WriteString(": ") + + unwrap = errors.Unwrap(unwrap) + } + return s.String() +} + +func (er *Err) Message(msg string, args ...any) Error { + panic("not implemented") // TODO: Implement +} + +func (er *Err) GetMessage() string { + panic("not implemented") // TODO: Implement +} + +func (er *Err) Code(status int) Error { + panic("not implemented") // TODO: Implement +} + +func (er *Err) GetCode() int { + panic("not implemented") // TODO: Implement +} + +func (er *Err) Caller(pc uintptr) Error { + panic("not implemented") // TODO: Implement +} + +func (er *Err) GetCaller() uintptr { + panic("not implemented") // TODO: Implement +} + +func (er *Err) Details(_ ...any) Error { + panic("not implemented") // TODO: Implement +} + +func (er *Err) GetDetails() []any { + panic("not implemented") // TODO: Implement +} + +func (er *Err) Log(ctx context.Context) Error { + panic("not implemented") // TODO: Implement +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f09e1c6 --- /dev/null +++ b/flake.lock @@ -0,0 +1,122 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1667395993, + "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "templ", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1694102001, + "narHash": "sha256-vky6VPK1n1od6vXbqzOXnekrQpTL4hbPAwUhT5J9c9E=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "9e21c80adf67ebcb077d75bd5e7d724d21eeafd6", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1712192574, + "narHash": "sha256-LbbVOliJKTF4Zl2b9salumvdMXuQBr2kuKP5+ZwbYq4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "f480f9d09e4b4cf87ee6151eba068197125714de", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "nixpkgs-unstable", + "type": "indirect" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1694422566, + "narHash": "sha256-lHJ+A9esOz9vln/3CJG23FV6Wd2OoOFbDeEs4cMGMqc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "3a2786eea085f040a66ecde1bc3ddc7099f6dbeb", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "templ": "templ" + } + }, + "templ": { + "inputs": { + "gitignore": "gitignore", + "nixpkgs": "nixpkgs_2", + "xc": "xc" + }, + "locked": { + "lastModified": 1706214512, + "narHash": "sha256-Z2WyXMmOFk72U94f35RMzx41LDtji3+8HOfLHcbaJyI=", + "owner": "a-h", + "repo": "templ", + "rev": "1f30f822a6edfdbfbab9e6851b1ff61e0ab01d4f", + "type": "github" + }, + "original": { + "owner": "a-h", + "ref": "v0.2.542", + "repo": "templ", + "type": "github" + } + }, + "xc": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "templ", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1703164129, + "narHash": "sha256-kCcCqqwvjN07H8FPG4tXsRVRcMqT8dUNt9pwW1kKAe8=", + "owner": "joerdav", + "repo": "xc", + "rev": "0655cccfcf036556aeaddfb8f45dc7e8dd1b3680", + "type": "github" + }, + "original": { + "owner": "joerdav", + "repo": "xc", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..3011cf9 --- /dev/null +++ b/flake.nix @@ -0,0 +1,24 @@ +{ + inputs = { + templ.url = "github:a-h/templ/v0.2.542"; # 0.2.542 + nixpkgs.url = "nixpkgs/nixpkgs-unstable"; + }; + + outputs = inputs@{ templ, nixpkgs, ... }: + let + system = "x86_64-linux"; + templPkg = templ.packages.${system}.templ; + pkgs = inputs.nixpkgs.legacyPackages.${system}; + in + { + devShell.${system} = pkgs.mkShell rec { + name = "redmage-shell"; + buildInputs = with pkgs; [ + templPkg + go + modd + nodejs_21 + ]; + }; + }; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a6a70ce --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/tigorlazuardi/redmage + +go 1.22.1 + +require ( + github.com/fatih/color v1.16.0 + github.com/go-chi/chi/v5 v5.0.12 + github.com/knadh/koanf/providers/confmap v0.1.0 + github.com/knadh/koanf/v2 v2.1.1 + github.com/mattn/go-colorable v0.1.13 + github.com/mattn/go-isatty v0.0.20 + github.com/spf13/cobra v1.8.0 +) + +require ( + github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/knadh/koanf/maps v0.1.1 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7ffe413 --- /dev/null +++ b/go.sum @@ -0,0 +1,35 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c= +github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= +github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/providers/confmap v0.1.0 h1:gOkxhHkemwG4LezxxN8DMOFopOPghxRVp7JbIvdvqzU= +github.com/knadh/koanf/providers/confmap v0.1.0/go.mod h1:2uLhxQzJnyHKfxG927awZC7+fyHFdQkd697K4MdLnIU= +github.com/knadh/koanf/v2 v2.1.1 h1:/R8eXqasSTsmDCsAyYj+81Wteg8AqrV9CP6gvsTsOmM= +github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +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/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/log/context.go b/log/context.go new file mode 100644 index 0000000..a4db008 --- /dev/null +++ b/log/context.go @@ -0,0 +1,21 @@ +package log + +import ( + "context" + "log/slog" +) + +type loggerKey struct{} + +func FromContext(ctx context.Context) slog.Handler { + h, _ := ctx.Value(loggerKey{}).(slog.Handler) + return h +} + +func WithContext(ctx context.Context, l slog.Handler) context.Context { + return context.WithValue(ctx, loggerKey{}, l) +} + +func NullHandlerContext(ctx context.Context) context.Context { + return WithContext(ctx, NullHandler{}) +} diff --git a/log/log.go b/log/log.go new file mode 100644 index 0000000..718b804 --- /dev/null +++ b/log/log.go @@ -0,0 +1,151 @@ +package log + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "runtime" + "strings" + "time" + + "github.com/go-chi/chi/v5/middleware" + "github.com/mattn/go-colorable" + "github.com/mattn/go-isatty" + "github.com/tigorlazuardi/redmage/config" +) + +var handler slog.Handler = NullHandler{} + +func NewHandler(cfg *config.Config) slog.Handler { + if !cfg.Bool("log.enable") { + return NullHandler{} + } + var output io.Writer + if strings.ToLower(cfg.String("log.output")) == "stdout" { + output = colorable.NewColorableStdout() + } else { + output = colorable.NewColorableStderr() + } + + var lvl slog.Level + _ = lvl.UnmarshalText(cfg.Bytes("log.level")) + opts := &slog.HandlerOptions{ + AddSource: cfg.Bool("log.source"), + Level: lvl, + } + + format := strings.ToLower(cfg.String("log.format")) + if isatty.IsTerminal(os.Stdout.Fd()) && format == "pretty" { + return NewPrettyHandler(output, opts) + } else { + return slog.NewJSONHandler(output, opts) + } +} + +type Entry struct { + ctx context.Context + handler slog.Handler + caller uintptr + time time.Time +} + +// Log prepares a new entry to write logs. +func Log(ctx context.Context) *Entry { + h := FromContext(ctx) + if h == nil { + h = handler + } + return &Entry{ctx: ctx, handler: h, time: time.Now()} +} + +func (entry *Entry) Caller(pc uintptr) *Entry { + entry.caller = pc + return entry +} + +func (entry *Entry) Info(message string, fields ...any) { + record := slog.NewRecord(entry.time, slog.LevelInfo, message, entry.getCaller()) + record.AddAttrs(entry.getExtra()...) + record.AddAttrs(slog.Group("context", fields...)) + _ = entry.handler.Handle(entry.ctx, record) +} + +func (entry *Entry) Infof(format string, args ...any) { + message := fmt.Sprintf(format, args...) + record := slog.NewRecord(entry.time, slog.LevelInfo, message, entry.getCaller()) + record.AddAttrs(entry.getExtra()...) + _ = entry.handler.Handle(entry.ctx, record) +} + +func (entry *Entry) Error(message string, fields ...any) { + record := slog.NewRecord(entry.time, slog.LevelError, message, entry.getCaller()) + record.AddAttrs(entry.getExtra()...) + record.AddAttrs(slog.Group("context", fields...)) + _ = entry.handler.Handle(entry.ctx, record) +} + +func (entry *Entry) Errorf(format string, args ...any) { + message := fmt.Sprintf(format, args...) + record := slog.NewRecord(entry.time, slog.LevelError, message, entry.getCaller()) + record.AddAttrs(entry.getExtra()...) + _ = entry.handler.Handle(entry.ctx, record) +} + +func (entry *Entry) Debug(message string, fields ...any) { + record := slog.NewRecord(entry.time, slog.LevelDebug, message, entry.getCaller()) + record.AddAttrs(entry.getExtra()...) + record.AddAttrs(slog.Group("context", fields...)) + _ = entry.handler.Handle(entry.ctx, record) +} + +func (entry *Entry) Debugf(format string, args ...any) { + message := fmt.Sprintf(format, args...) + record := slog.NewRecord(entry.time, slog.LevelDebug, message, entry.getCaller()) + record.AddAttrs(entry.getExtra()...) + _ = entry.handler.Handle(entry.ctx, record) +} + +func (entry *Entry) Warn(message string, fields ...any) { + record := slog.NewRecord(entry.time, slog.LevelWarn, message, entry.getCaller()) + record.AddAttrs(entry.getExtra()...) + record.AddAttrs(slog.Group("context", fields...)) + _ = entry.handler.Handle(entry.ctx, record) +} + +func (entry *Entry) Warnf(format string, args ...any) { + message := fmt.Sprintf(format, args...) + record := slog.NewRecord(entry.time, slog.LevelWarn, message, entry.getCaller()) + record.AddAttrs(entry.getExtra()...) + _ = entry.handler.Handle(entry.ctx, record) +} + +func (entry *Entry) getCaller() uintptr { + if entry.caller != 0 { + return entry.caller + } + return GetCaller(4) +} + +func GetCaller(skip int) uintptr { + pc := make([]uintptr, 1) + n := runtime.Callers(skip, pc) + if n == 0 { + return 0 + } + return pc[0] +} + +func (entry *Entry) getExtra() []slog.Attr { + out := make([]slog.Attr, 0, 1) + if reqid := middleware.GetReqID(entry.ctx); reqid != "" { + out = append(out, slog.String("request.id", reqid)) + } + + return out +} + +func SetDefault(h slog.Handler) { + handler = h +} diff --git a/log/nil_handler.go b/log/nil_handler.go new file mode 100644 index 0000000..5d069eb --- /dev/null +++ b/log/nil_handler.go @@ -0,0 +1,13 @@ +package log + +import ( + "context" + "log/slog" +) + +type NullHandler struct{} + +func (NullHandler) Enabled(context.Context, slog.Level) bool { return false } +func (NullHandler) Handle(context.Context, slog.Record) error { return nil } +func (nu NullHandler) WithAttrs([]slog.Attr) slog.Handler { return nu } +func (nu NullHandler) WithGroup(string) slog.Handler { return nu } diff --git a/log/pretty_handler.go b/log/pretty_handler.go new file mode 100644 index 0000000..011bbc8 --- /dev/null +++ b/log/pretty_handler.go @@ -0,0 +1,179 @@ +package log + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "os" + "runtime" + "strings" + "sync" + + "github.com/fatih/color" +) + +type PrettyHandler struct { + mu sync.Mutex + opts *slog.HandlerOptions + output io.Writer + replaceAttr func(groups []string, attr slog.Attr) slog.Attr + withAttrs []slog.Attr + withGroup []string +} + +// NewPrettyHandler creates a human friendly readable logs. +func NewPrettyHandler(writer io.Writer, opts *slog.HandlerOptions) *PrettyHandler { + if opts == nil { + opts = &slog.HandlerOptions{} + } + if opts.ReplaceAttr == nil { + opts.ReplaceAttr = func(groups []string, attr slog.Attr) slog.Attr { return attr } + } + return &PrettyHandler{ + opts: opts, + output: writer, + replaceAttr: func(groups []string, attr slog.Attr) slog.Attr { + if len(groups) > 0 { + return opts.ReplaceAttr(groups, attr) + } + switch attr.Key { + case slog.TimeKey, slog.LevelKey, slog.SourceKey, slog.MessageKey: + return slog.Attr{} + default: + return opts.ReplaceAttr(groups, attr) + } + }, + } +} + +// Enabled implements slog.Handler interface. +func (pr *PrettyHandler) Enabled(ctx context.Context, lvl slog.Level) bool { + return pr.opts.Level.Level() <= lvl +} + +var bufferPool = sync.Pool{ + New: func() interface{} { + buf := &bytes.Buffer{} + buf.Grow(1024) + return buf + }, +} + +func putBuffer(buf *bytes.Buffer) { + const limit = 1024 * 512 // 512KB + if buf.Cap() < limit { + buf.Reset() + bufferPool.Put(buf) + } +} + +// Handle implements slog.Handler interface. +func (pr *PrettyHandler) Handle(ctx context.Context, record slog.Record) error { + var levelColor *color.Color + switch { + case record.Level >= slog.LevelError: + levelColor = color.New(color.FgRed) + case record.Level >= slog.LevelWarn: + levelColor = color.New(color.FgYellow) + case record.Level >= slog.LevelInfo: + levelColor = color.New(color.FgGreen) + default: + levelColor = color.New(color.FgWhite) + } + + buf := bufferPool.Get().(*bytes.Buffer) + jsonBuf := bufferPool.Get().(*bytes.Buffer) + defer putBuffer(buf) + defer putBuffer(jsonBuf) + + if record.PC != 0 && pr.opts.AddSource { + frame := getFrame(record.PC) + levelColor.Fprint(buf, frame.File) + levelColor.Fprint(buf, ":") + levelColor.Fprint(buf, frame.Line) + levelColor.Fprint(buf, " -- ") + split := strings.Split(frame.Function, string(os.PathSeparator)) + fnName := split[len(split)-1] + levelColor.Fprint(buf, fnName) + buf.WriteByte('\n') + } + + if !record.Time.IsZero() { + buf.WriteString(record.Time.Format("[2006-01-02 15:04:05] ")) + } + + buf.WriteByte('[') + levelColor.Add(color.Bold).Fprint(buf, record.Level.String()) + buf.WriteString("] ") + + if record.Message != "" { + buf.WriteString(record.Message) + } + + buf.WriteByte('\n') + + serializer := pr.createSerializer(jsonBuf) + _ = serializer.Handle(ctx, record) + if jsonBuf.Len() > 3 { // Ignore empty json like "{}\n" + _ = json.Indent(buf, jsonBuf.Bytes(), "", " ") + // json indent includes new line, no need to add extra text. + } else { + buf.WriteByte('\n') + } + + pr.mu.Lock() + defer pr.mu.Unlock() + _, err := buf.WriteTo(pr.output) + return err +} + +func (pr *PrettyHandler) createSerializer(w io.Writer) slog.Handler { + var jsonHandler slog.Handler = slog.NewJSONHandler(w, &slog.HandlerOptions{ + Level: slog.LevelDebug, + ReplaceAttr: pr.replaceAttr, + }) + + if len(pr.withAttrs) > 0 { + jsonHandler = jsonHandler.WithAttrs(pr.withAttrs) + } + + if len(pr.withGroup) > 0 { + for _, group := range pr.withGroup { + jsonHandler = jsonHandler.WithGroup(group) + } + } + + return jsonHandler +} + +func getFrame(pc uintptr) runtime.Frame { + frames := runtime.CallersFrames([]uintptr{pc}) + frame, _ := frames.Next() + return frame +} + +func (pr *PrettyHandler) clone() *PrettyHandler { + return &PrettyHandler{ + opts: pr.opts, + output: pr.output, + replaceAttr: pr.replaceAttr, + withAttrs: pr.withAttrs, + withGroup: pr.withGroup, + } +} + +// WithAttrs implements slog.Handler interface. +func (pr *PrettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + p := pr.clone() + p.withAttrs = append(p.withAttrs, attrs...) + return p +} + +// WithGroup implements slog.Handler interface. +func (pr *PrettyHandler) WithGroup(name string) slog.Handler { + p := pr.clone() + p.withGroup = append(p.withGroup, name) + return p +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..0c33468 --- /dev/null +++ b/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "context" + "os" + + "github.com/tigorlazuardi/redmage/cli" +) + +func main() { + if err := cli.RootCmd.ExecuteContext(context.Background()); err != nil { + os.Exit(1) + } +}