package diary import ( "context" "fmt" "io" "log/slog" "path/filepath" "runtime" "strconv" "strings" "sync" "sync/atomic" "time" "unicode" "git.golem15.com/golem15/summercms/modules/bonfire" "charm.land/lipgloss/v2" "github.com/charmbracelet/colorprofile" ) // componentWidth is the column width of the component prefix. const componentWidth = 10 // ConsoleOptions configures NewConsoleHandler. type ConsoleOptions struct { // Level is the minimum level; nil means info. Level slog.Leveler // Profile is the color profile applied to the output; NoTTY writes no // escape codes at all. Profile colorprofile.Profile // AddSource appends the caller's file:line to each line. AddSource bool } // NewConsoleHandler returns a slog.Handler that writes one styled line per // record to w in bonfire's palette: time, level, the component attribute as // a fixed-width prefix, the message, then key=value attributes with groups // as dotted key prefixes. Control characters other than tab are removed from // the message and attribute values, so logged data cannot inject terminal // escape sequences. Each line is written with one Write call, serialized // across the handler and its WithAttrs and WithGroup clones. func NewConsoleHandler(w io.Writer, opts ConsoleOptions) slog.Handler { source := new(atomic.Bool) source.Store(opts.AddSource) return newConsoleHandler(w, opts, source) } func newConsoleHandler(w io.Writer, opts ConsoleOptions, source *atomic.Bool) *consoleHandler { level := opts.Level if level == nil { level = slog.LevelInfo } return &consoleHandler{ out: &colorprofile.Writer{Forward: w, Profile: opts.Profile}, mu: new(sync.Mutex), level: level, source: source, palette: bonfire.DefaultPalette(), } } type consoleAttr struct { key string value string } type consoleHandler struct { out io.Writer mu *sync.Mutex level slog.Leveler source *atomic.Bool palette bonfire.Palette component string attrs []consoleAttr groups []string } func (h *consoleHandler) Enabled(_ context.Context, l slog.Level) bool { return l >= h.level.Level() } func (h *consoleHandler) clone() *consoleHandler { c := *h c.attrs = h.attrs[:len(h.attrs):len(h.attrs)] c.groups = h.groups[:len(h.groups):len(h.groups)] return &c } func (h *consoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler { if len(attrs) == 0 { return h } c := h.clone() prefix := groupPrefix(c.groups) for _, a := range attrs { if len(c.groups) == 0 && a.Key == ComponentKey && a.Value.Kind() == slog.KindString { c.component = a.Value.String() continue } c.attrs = appendAttr(c.attrs, prefix, a) } return c } func (h *consoleHandler) WithGroup(name string) slog.Handler { if name == "" { return h } c := h.clone() c.groups = append(c.groups, name) return c } func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error { component := h.component prefix := groupPrefix(h.groups) var recAttrs []consoleAttr r.Attrs(func(a slog.Attr) bool { if len(h.groups) == 0 && a.Key == ComponentKey && a.Value.Kind() == slog.KindString { component = a.Value.String() return true } recAttrs = appendAttr(recAttrs, prefix, a) return true }) p := h.palette var b strings.Builder if !r.Time.IsZero() { b.WriteString(p.Muted.Render(r.Time.Format(time.TimeOnly))) b.WriteByte(' ') } label, style := levelLabel(r.Level, p) b.WriteString(style.Render(label)) b.WriteString(strings.Repeat(" ", max(0, 5-len(label))+1)) name := sanitize(component) if name != "" { b.WriteString(p.Header.Render(name)) } b.WriteString(strings.Repeat(" ", max(0, componentWidth-len(name))+1)) b.WriteString(sanitize(r.Message)) for _, list := range [][]consoleAttr{h.attrs, recAttrs} { for _, a := range list { b.WriteByte(' ') b.WriteString(p.Muted.Render(a.key + "=")) b.WriteString(a.value) } } if h.source != nil && h.source.Load() && r.PC != 0 { frame, _ := runtime.CallersFrames([]uintptr{r.PC}).Next() if frame.File != "" { b.WriteByte(' ') b.WriteString(p.Muted.Render(filepath.Base(frame.File) + ":" + strconv.Itoa(frame.Line))) } } b.WriteByte('\n') h.mu.Lock() defer h.mu.Unlock() _, err := io.WriteString(h.out, b.String()) return err } func levelLabel(l slog.Level, p bonfire.Palette) (string, lipgloss.Style) { switch { case l < slog.LevelInfo: return l.String(), p.Muted case l < slog.LevelWarn: return l.String(), p.Current case l < slog.LevelError: return l.String(), p.Warning default: return l.String(), p.ErrorBold } } func groupPrefix(groups []string) string { if len(groups) == 0 { return "" } return strings.Join(groups, ".") + "." } // appendAttr flattens a into key=value pairs: empty attributes are skipped, // groups add their key as a dotted prefix, and groups with an empty key are // inlined (testing/slogtest semantics). func appendAttr(out []consoleAttr, prefix string, a slog.Attr) []consoleAttr { a.Value = a.Value.Resolve() if a.Equal(slog.Attr{}) { return out } if a.Value.Kind() == slog.KindGroup { group := a.Value.Group() if len(group) == 0 { return out } inner := prefix if a.Key != "" { inner = prefix + a.Key + "." } for _, g := range group { out = appendAttr(out, inner, g) } return out } return append(out, consoleAttr{key: sanitize(prefix + a.Key), value: formatValue(a.Value)}) } func formatValue(v slog.Value) string { var s string switch v.Kind() { case slog.KindString: s = v.String() case slog.KindTime: s = v.Time().Format(time.RFC3339) case slog.KindAny: if err, ok := v.Any().(error); ok { s = err.Error() } else { s = fmt.Sprintf("%+v", v.Any()) } default: s = v.String() } s = sanitize(s) if s == "" || strings.ContainsAny(s, " \t\"=") { return strconv.Quote(s) } return s } // sanitize removes control characters other than tab; line breaks become // spaces so a multi-line value stays on one line. func sanitize(s string) string { clean := true for _, r := range s { if r != '\t' && unicode.IsControl(r) { clean = false break } } if clean { return s } var b strings.Builder for _, r := range s { switch { case r == '\n' || r == '\r': b.WriteByte(' ') case r != '\t' && unicode.IsControl(r): default: b.WriteRune(r) } } return b.String() }