From fa31019ff56fd66ce943ff927c04a9dd09e03739 Mon Sep 17 00:00:00 2001 From: Pavel Shevaev Date: Tue, 25 Oct 2022 18:00:25 +0300 Subject: [PATCH] First commit --- colog.go | 435 +++++++++++++++++++++++++++++++++++++++++ colog_test.go | 232 ++++++++++++++++++++++ go.mod | 3 + interfaces.go | 35 ++++ json_formatter.go | 114 +++++++++++ json_formatter_test.go | 74 +++++++ std_formatter.go | 143 ++++++++++++++ std_formatter_test.go | 101 ++++++++++ tty.go | 31 +++ tty_bsd.go | 7 + tty_linux.go | 5 + tty_windows.go | 62 ++++++ 12 files changed, 1242 insertions(+) create mode 100644 colog.go create mode 100644 colog_test.go create mode 100644 go.mod create mode 100644 interfaces.go create mode 100644 json_formatter.go create mode 100644 json_formatter_test.go create mode 100644 std_formatter.go create mode 100644 std_formatter_test.go create mode 100644 tty.go create mode 100644 tty_bsd.go create mode 100644 tty_linux.go create mode 100644 tty_windows.go diff --git a/colog.go b/colog.go new file mode 100644 index 0000000..a38ad9a --- /dev/null +++ b/colog.go @@ -0,0 +1,435 @@ +// Package colog implements prefix based logging by setting itself as output of the standard library +// and parsing the log messages. Level prefixes are called headers in CoLog terms to not confuse with +// log.Prefix() which is independent. +// Basic usage only requires registering: +// func main() { +// colog.Register() +// log.Print("info: that's all it takes!") +// } +// +// CoLog requires the standard logger to submit messages without prefix or flags. So it resets them +// while registering and assigns them to itself, unfortunately CoLog cannot be aware of any output +// previously set. +package colog + +import ( + "bytes" + "fmt" + "io" + "log" + "os" + "runtime" + "sync" + "time" +) + +// std is the global singleton +// analog of the standard log.std +var std = NewCoLog(os.Stderr, "", 0) + +// CoLog encapsulates our log writer +type CoLog struct { + mu sync.Mutex + host string + prefix []byte + minLevel Level + defaultLevel Level + formatter Formatter + customFmt bool + out io.Writer +} + +// Entry represents a message being logged and all attached data +type Entry struct { + Level Level // severity: trace, debug, info, warning, error, alert + Time time.Time // time of the event + Host string // host origin of the message + File string // file where the log was called + Line int // line in the file where the log was called + Message []byte // logged message +} + +// Level represents severity level +type Level uint8 + +// LevelMap links levels with output header bytes +type LevelMap map[Level][]byte + +// HeaderMap links input header strings with levels +type HeaderMap map[string]Level + +const ( + // Unknown severity level + unknown Level = iota + // LDebug represents debug severity level + LDebug + // LInfo represents info severity level + LInfo + // LWarn represents warn severity level + LWarn + // LError represents error severity level + LError +) + +// String implements the Stringer interface for levels +func (level Level) String() string { + switch level { + case LDebug: + return "debug" + case LInfo: + return "info" + case LWarn: + return "warn" + case LError: + return "error" + } + + return "unknown" +} + +var initialMinLevel = LDebug +var initialDefaultLevel = LInfo + +// NewCoLog returns CoLog instance ready to be used in logger.SetOutput() +func NewCoLog(out io.Writer, prefix string, flags int) *CoLog { + cl := new(CoLog) + cl.minLevel = initialMinLevel + cl.defaultLevel = initialDefaultLevel + cl.prefix = []byte(prefix) + cl.formatter = &StdFormatter{Flag: flags} + cl.SetOutput(out) + if host, err := os.Hostname(); err != nil { + cl.host = host + } + + return cl +} + +func (cl *CoLog) Clone() *CoLog { + cl.mu.Lock() + defer cl.mu.Unlock() + + item := new(CoLog) + item.minLevel = cl.minLevel + item.defaultLevel = cl.defaultLevel + item.prefix = append([]byte(nil), cl.prefix...) + item.formatter = cl.formatter + item.customFmt = cl.customFmt + item.out = cl.out + item.host = cl.host + return item +} + +func Clone() *CoLog { + return std.Clone() +} + +// Register sets CoLog as output for the default logger. +// It "hijacks" the standard logger flags and prefix previously set. +// It's not possible to know the output previously set, so the +// default os.Stderr is assumed. +func Register() { + // Inherit standard logger flags and prefix if appropriate + if !std.customFmt { + std.formatter.SetFlags(log.Flags()) + } + + if log.Prefix() != "" && len(std.prefix) == 0 { + std.SetPrefix(log.Prefix()) + } + + // Disable all extras + log.SetPrefix("") + log.SetFlags(0) + + // Set CoLog as output + log.SetOutput(std) +} + +func Get() *CoLog { + return std +} + +func Set(cl *CoLog) { + std = cl +} + +// SetHost sets the logger hostname assigned to the entries +func (cl *CoLog) SetHost(host string) { + cl.mu.Lock() + defer cl.mu.Unlock() + + cl.host = host +} + +func (cl *CoLog) AddPrefix(prefix string) *CoLog { + cl.mu.Lock() + defer cl.mu.Unlock() + + cl.prefix = append(cl.prefix, []byte(prefix)...) + return cl +} + +func (cl *CoLog) SetPrefix(prefix string) { + cl.mu.Lock() + defer cl.mu.Unlock() + + cl.prefix = []byte(prefix) +} + +// SetMinLevel sets the minimum level that will be actually logged +func (cl *CoLog) SetMinLevel(l Level) { + cl.mu.Lock() + defer cl.mu.Unlock() + + cl.minLevel = l +} + +// SetDefaultLevel sets the level that will be used when no level is detected +func (cl *CoLog) SetDefaultLevel(l Level) { + cl.mu.Lock() + defer cl.mu.Unlock() + + cl.defaultLevel = l +} + +// SetFormatter sets the formatter to use +func (cl *CoLog) SetFormatter(f Formatter) { + cl.mu.Lock() + defer cl.mu.Unlock() + + cl.customFmt = true + cl.formatter = f +} + +// Flags returns the output flags for the formatter if any +func (cl *CoLog) Flags() int { + cl.mu.Lock() + defer cl.mu.Unlock() + if cl.formatter == nil { + return 0 + } + + return cl.formatter.Flags() +} + +// SetFlags sets the output flags for the formatter if any +func (cl *CoLog) SetFlags(flags int) { + cl.mu.Lock() + defer cl.mu.Unlock() + if cl.formatter == nil { + return + } + + cl.formatter.SetFlags(flags) +} + +// SetOutput is analog to log.SetOutput sets the output destination. +func (cl *CoLog) SetOutput(w io.Writer) { + cl.mu.Lock() + defer cl.mu.Unlock() + + cl.out = w + + // if we have a color formatter, notify if new output supports color + if _, ok := cl.formatter.(ColorFormatter); ok { + cl.formatter.(ColorFormatter).ColorSupported(cl.colorSupported()) + } +} + +// NewLogger returns a colog-enabled logger +func (cl *CoLog) NewLogger() *log.Logger { + cl.mu.Lock() + defer cl.mu.Unlock() + + return log.New(cl, "", 0) +} + +// Write implements io.Writer interface to that the standard logger uses. +func (cl *CoLog) Write(p []byte) (n int, err error) { + cl.mu.Lock() + defer cl.mu.Unlock() + + e := cl.makeEntry(p, LInfo, 5 /*calldepth*/) + if e.Level != unknown && e.Level < cl.minLevel { + return 0, nil + } + + if e.Level == unknown && cl.defaultLevel < cl.minLevel { + return 0, nil + } + + fp, err := cl.formatter.Format(e) + if err != nil { + fmt.Fprintf(os.Stderr, "colog: failed to format entry: %v\n", err) + return 0, err + } + + n, err = cl.out.Write(fp) + if err != nil { + return n, err + } + + return len(p), nil +} + +func (cl *CoLog) makeEntry(p []byte, level Level, calldepth int) *Entry { + e := &Entry{ + Time: time.Now(), + Host: cl.host, + Level: level, + Message: append(cl.prefix, bytes.TrimRight(p, "\n")...), + } + + // this is a bit expensive, check is anyone might actually need it + if cl.formatter.Flags()&(log.Lshortfile|log.Llongfile) != 0 { + // release lock while getting caller info - it's expensive + // (makeEntry is called under mutex) + cl.mu.Unlock() + e.File, e.Line = getFileLine(calldepth) + cl.mu.Lock() + } + + return e +} + +// get file a line where logger was called +func getFileLine(calldepth int) (string, int) { + + var file string + var line int + + var ok bool + _, file, line, ok = runtime.Caller(calldepth) + if !ok { + file = "???" + line = 0 + } + + return file, line +} + +// figure if output supports color +func (cl *CoLog) colorSupported() bool { + + // ColorSupporters can decide themselves + if ce, ok := cl.out.(ColorSupporter); ok { + return ce.ColorSupported() + } + + // Windows users need ColorSupporter outputs + if runtime.GOOS == "windows" { + return false + } + + // Check for Fd() method + output, ok := cl.out.(interface { + Fd() uintptr + }) + + // If no file descriptor it's not a TTY + if !ok { + return false + } + + return isTerminal(int(output.Fd())) +} + +///////////////////////////////////////////////////////////////////////// +func (cl *CoLog) Log(str string) { + cl.Output(LInfo, 4, str) +} + +func (cl *CoLog) Logf(format string, v ...interface{}) { + cl.Output(LInfo, 4, fmt.Sprintf(format, v...)) +} + +func Log(str string) { + std.Output(LInfo, 4, str) +} + +func Logf(format string, v ...interface{}) { + std.Output(LInfo, 4, fmt.Sprintf(format, v...)) +} + +func (cl *CoLog) Debug(str string) { + cl.Output(LDebug, 4, str) +} + +func (cl *CoLog) Debugf(format string, v ...interface{}) { + cl.Output(LDebug, 4, fmt.Sprintf(format, v...)) +} + +func Debug(str string) { + std.Output(LDebug, 4, str) +} + +func Debugf(format string, v ...interface{}) { + std.Output(LDebug, 4, fmt.Sprintf(format, v...)) +} + +func (cl *CoLog) Error(str string) { + cl.Output(LError, 4, str) +} + +func (cl *CoLog) Errorf(format string, v ...interface{}) { + cl.Output(LError, 4, fmt.Sprintf(format, v...)) +} + +func Error(str string) { + std.Output(LError, 4, str) +} + +func Errorf(format string, v ...interface{}) { + std.Output(LError, 4, fmt.Sprintf(format, v...)) +} + +func (cl *CoLog) Warn(str string) { + cl.Output(LWarn, 4, str) +} + +func (cl *CoLog) Warnf(format string, v ...interface{}) { + cl.Output(LWarn, 4, fmt.Sprintf(format, v...)) +} + +func Warn(str string) { + std.Output(LWarn, 4, str) +} + +func Warnf(format string, v ...interface{}) { + std.Output(LWarn, 4, fmt.Sprintf(format, v...)) +} + +func (cl *CoLog) Output(level Level, calldepth int, str string) (err error) { + //Let's do these checks before locking the mutex + if level != unknown && level < cl.minLevel { + return nil + } + if level == unknown && cl.defaultLevel < cl.minLevel { + return nil + } + + //not using defer cl.mu.Unlock() because we want to have + //more fine grained control over mutex + cl.mu.Lock() + + e := cl.makeEntry([]byte(str), level, calldepth) + + fp, err := cl.formatter.Format(e) + if err != nil { + cl.mu.Unlock() + fmt.Fprintf(os.Stderr, "colog: failed to format entry: %v\n", err) + return + } + + out := cl.out + cl.mu.Unlock() + + _, err = out.Write(fp) + + return +} + +func Output(level Level, calldepth int, str string) (err error) { + return std.Output(level, calldepth, str) +} diff --git a/colog_test.go b/colog_test.go new file mode 100644 index 0000000..96f7a65 --- /dev/null +++ b/colog_test.go @@ -0,0 +1,232 @@ +package colog + +import ( + "fmt" + "log" + "math/rand" + "sync" + "testing" +) + +type outputTest struct { + in string + out string +} + +var outputTests = []outputTest{ + {"Info should be green %s", "[INF] Info should be green %s\n"}, +} + +func TestColors(t *testing.T) { + + log.SetFlags(log.LstdFlags) + Register() + + Get().SetMinLevel(LDebug) + Get().SetDefaultLevel(LDebug) + + for _, tt := range outputTests { + tt.in = fmt.Sprintf(tt.in, "") + log.Println(tt.in) + } +} + +func TestDefaultLevel(t *testing.T) { + + tw := new(mockWriter) + log.SetFlags(0) + Register() + Get().SetOutput(tw) + Get().SetFormatter(&StdFormatter{Colors: false}) + Get().SetDefaultLevel(LDebug) + + log.Println("no prefix text") + if "[INF] no prefix text\n" != tw.String() { + t.Fatalf("Default level failed: %s", tw.String()) + } +} + +func TestMinDefaultLevel(t *testing.T) { + + tw := new(mockWriter) + log.SetFlags(0) + Register() + Get().SetOutput(tw) + Get().SetFormatter(&StdFormatter{Colors: false}) + + Get().SetMinLevel(LDebug) + Get().SetDefaultLevel(LDebug) + log.Println("no prefix text") + if "[INF] no prefix text\n" != tw.String() { + t.Fatalf("Default level failed: %s", tw.String()) + } + + Get().SetMinLevel(LError) + log.Println("should not print") + if "[INF] no prefix text\n" != tw.String() { + t.Fatalf("Default level failed: %s", tw.String()) + } +} + +func TestPrefix(t *testing.T) { + + tw := new(mockWriter) + cl := NewCoLog(tw, "abc ", 0) + cl.SetFormatter(&StdFormatter{Colors: false}) + + logger := cl.NewLogger() + logger.Println("some text") + if "[INF] abc some text\n" != tw.String() { + t.Fatalf("Prefix output failed: %s", tw.String()) + } +} + +func TestSimpleOutput(t *testing.T) { + + tw := new(mockWriter) + cl := NewCoLog(tw, "", 0) + cl.SetFormatter(&StdFormatter{Colors: false}) + logger := cl.NewLogger() + for k, tt := range outputTests { + + seq := randSeq(k) + tt.in = fmt.Sprintf(tt.in, seq) + tt.out = fmt.Sprintf(tt.out, seq) + + logger.Println(tt.in) + if tt.out != tw.String() { + t.Fatalf("Simple output not found:\n %s\n %s", tt.out, string(tw.Data)) + } + } +} + +func TestOutputRace(t *testing.T) { + + wg := sync.WaitGroup{} + wg.Add(1) + + go testStdLoggerOutput(t, &wg) + for i := 0; i < 100; i++ { + wg.Add(1) + go testNewLoggerOutput(t, &wg) + } + + wg.Wait() +} + +func testStdLoggerOutput(t *testing.T, wg *sync.WaitGroup) { + + tb := new(mockBufferWriter) + tb.Data = make(map[string][]byte, len(outputTests)) + log.SetFlags(0) + Register() + Get().SetOutput(tb) + Get().SetMinLevel(LDebug) + Get().SetDefaultLevel(LDebug) + Get().SetFormatter(&StdFormatter{Colors: false}) + + for k, tt := range outputTests { + wg.Add(1) + go func(tt outputTest, k int) { + + seq := randSeq(k) + tt.in = fmt.Sprintf(tt.in, seq) + tt.out = fmt.Sprintf(tt.out, seq) + + log.Println(tt.in) + if !tb.IsWritten(tt.out) { + t.Errorf("Raced std output not found: %s", tt.out) + } + wg.Done() + }(tt, k) + } + + wg.Done() +} + +func testNewLoggerOutput(t *testing.T, wg *sync.WaitGroup) { + + tb := new(mockBufferWriter) + tb.Data = make(map[string][]byte, len(outputTests)) + cl := NewCoLog(tb, "", 0) + cl.SetFormatter(&StdFormatter{Colors: false}) + logger := cl.NewLogger() + + for k, tt := range outputTests { + wg.Add(1) + go func(tt outputTest, k int) { + + seq := randSeq(k) + tt.in = fmt.Sprintf(tt.in, seq) + tt.out = fmt.Sprintf(tt.out, seq) + + logger.Println(tt.in) + if !tb.IsWritten(tt.out) { + t.Errorf("Raced logger output not found: %s", tt.out) + } + wg.Done() + }(tt, k) + } + + wg.Done() +} + +type mockWriter struct { + mux sync.Mutex + Data []byte +} + +func (tw *mockWriter) Write(p []byte) (n int, err error) { + tw.mux.Lock() + defer tw.mux.Unlock() + tw.Data = p + return len(p), nil +} + +func (tw *mockWriter) String() string { + tw.mux.Lock() + defer tw.mux.Unlock() + return string(tw.Data) +} + +type mockBufferWriter struct { + mux sync.Mutex + Data map[string][]byte +} + +func (tb *mockBufferWriter) Write(p []byte) (n int, err error) { + tb.mux.Lock() + defer tb.mux.Unlock() + tb.Data[string(p)] = p + return len(p), nil +} + +func (tb *mockBufferWriter) IsWritten(s string) bool { + tb.mux.Lock() + defer tb.mux.Unlock() + _, ok := tb.Data[s] + return ok +} + +type mockHook struct { + entry *Entry + levels []Level +} + +func (h *mockHook) Levels() []Level { + return h.levels +} + +func (h *mockHook) Fire(e *Entry) error { + h.entry = e + return nil +} + +func randSeq(n int) string { + var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + b := make([]rune, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8685a68 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.bit5.ru/backend/colog + +go 1.13 diff --git a/interfaces.go b/interfaces.go new file mode 100644 index 0000000..d82d422 --- /dev/null +++ b/interfaces.go @@ -0,0 +1,35 @@ +package colog + +// Hook is the interface to be implemented by event hooks +type Hook interface { + Levels() []Level // returns the set of levels for which the hook should be triggered + Fire(*Entry) error // triggers the hook, this function will be called for every eligible log entry +} + +// Formatter interface must be implemented by message formatters +// Format(*Entry) will be called and the resulting bytes sent to output +type Formatter interface { + Format(*Entry) ([]byte, error) // The actual formatter called every time + SetFlags(flags int) // Like the standard log.SetFlags(flags int) + Flags() int // Like the standard log.Flags() int +} + +// ColorFormatter interface can be implemented by formatters +// to get notifications on whether the output supports color +type ColorFormatter interface { + Formatter + ColorSupported(yes bool) +} + +// ColorSupporter interface can be implemented by "smart" +// outputs that want to handle color display themselves +type ColorSupporter interface { + ColorSupported() bool +} + +// Extractor interface must be implemented by data extractors +// the extractor reads the message and tries to extract key-value +// pairs from the message and sets the in the entry +type Extractor interface { + Extract(*Entry) error +} diff --git a/json_formatter.go b/json_formatter.go new file mode 100644 index 0000000..f338bb0 --- /dev/null +++ b/json_formatter.go @@ -0,0 +1,114 @@ +package colog + +import ( + "encoding/json" + "fmt" + "log" + "strconv" +) + +// JSONFormatter serializes entries to JSON +// TimeFormat can be any Go time format, if empty +// it will mimic the standard logger format +// LevelAsNum will use a numeric string "1", "2",... +// for as levels instead of "trace", "debug", .. +type JSONFormatter struct { + TimeFormat string + LevelAsNum bool + Flag int +} + +// JSONEntry is an entry with the final JSON field types +// We can not just implement the Marshaller interface since +// some of the process depends on runtime options +type JSONEntry struct { + Level string `json:"level,omitempty"` + Time string `json:"time,omitempty"` + Host string `json:"host,omitempty"` + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` + Message string `json:"message,omitempty"` +} + +// Format takes and entry and returns the formatted output in bytes +func (jf *JSONFormatter) Format(e *Entry) ([]byte, error) { + + file, line := jf.fileLine(e) + date := jf.date(e) + + var level string + if jf.LevelAsNum { + level = strconv.Itoa(int(e.Level)) + } else { + level = e.Level.String() + } + + je := &JSONEntry{ + Level: level, + Time: date, + Host: e.Host, + File: file, + Line: line, + Message: string(e.Message), + } + + data, err := json.Marshal(je) + return append(data, '\n'), err +} + +// Flags returns the output flags for the formatter. +func (jf *JSONFormatter) Flags() int { + return jf.Flag +} + +// SetFlags sets the output flags for the formatter. +func (jf *JSONFormatter) SetFlags(flags int) { + jf.Flag = flags +} + +func (jf *JSONFormatter) fileLine(e *Entry) (file string, line int) { + if jf.Flag&(log.Lshortfile|log.Llongfile) == 0 { + return + } + + file = e.File + line = e.Line + if jf.Flag&log.Lshortfile != 0 { + short := file + for i := len(file) - 1; i > 0; i-- { + if file[i] == '/' { + short = file[i+1:] + break + } + } + file = short + } + + return file, line +} + +func (jf *JSONFormatter) date(e *Entry) (date string) { + if jf.TimeFormat != "" { + return e.Time.Format(jf.TimeFormat) + } + + if jf.Flag&(log.Ldate|log.Ltime|log.Lmicroseconds) == 0 { + return "" + } + + if jf.Flag&log.Ldate != 0 { + year, month, day := e.Time.Date() + date = fmt.Sprintf("%d/%d/%d", year, month, day) + } + + if jf.Flag&(log.Ltime|log.Lmicroseconds) != 0 { + hour, min, sec := e.Time.Clock() + date = fmt.Sprintf("%s %d:%d:%d", date, hour, min, sec) + if jf.Flag&log.Lmicroseconds != 0 { + date = fmt.Sprintf("%s.%d", date, e.Time.Nanosecond()) + + } + } + + return date +} diff --git a/json_formatter_test.go b/json_formatter_test.go new file mode 100644 index 0000000..6f6e18c --- /dev/null +++ b/json_formatter_test.go @@ -0,0 +1,74 @@ +package colog + +import ( + "log" + "testing" +) + +type JSONFormatTest struct { + entry Entry + prefix string + flags int + tfmt string + lnum bool + output string +} + +var JSONFormatTests = []JSONFormatTest{ + { + entry: Entry{ + Level: LInfo, + Message: []byte("some message"), + }, + output: `{"level":"info","message":"some message"}` + "\n", + }, + { + entry: Entry{ + Time: TTime, + Level: LDebug, + Message: []byte("some message"), + }, + flags: log.Ldate, + output: `{"level":"debug","time":"2015/8/1","message":"some message"}` + "\n", + }, + { + entry: Entry{ + Time: TTime, + Level: LError, + File: "/src/file.go", + Line: 142, + Message: []byte("some message"), + }, + flags: log.Ldate | log.Llongfile, + output: `{"level":"error","time":"2015/8/1","file":"/src/file.go","line":142,"message":"some message"}` + "\n", + }, + { + entry: Entry{ + Time: TTime, + Level: LDebug, + Message: []byte("some message"), + }, + lnum: true, + flags: log.Ldate, + output: `{"level":"1","time":"2015/8/1","message":"some message"}` + "\n", + }, +} + +func TestJSONFormatter(t *testing.T) { + for _, tt := range JSONFormatTests { + f := JSONFormatter{ + Flag: tt.flags, + LevelAsNum: tt.lnum, + TimeFormat: tt.tfmt, + } + + b, err := f.Format(&tt.entry) + if err != nil { + t.Fatal(err) + } + + if string(b) != tt.output { + t.Errorf("Unexpected JSON formatter output: %s", b) + } + } +} diff --git a/std_formatter.go b/std_formatter.go new file mode 100644 index 0000000..f3d1e97 --- /dev/null +++ b/std_formatter.go @@ -0,0 +1,143 @@ +package colog + +import ( + "fmt" + "log" + "path/filepath" + "time" +) + +var colorLabels = LevelMap{ + LDebug: []byte("[\x1b[0;36mDBG\x1b[0m] "), + LInfo: []byte("[\x1b[0;32mINF\x1b[0m] "), + LWarn: []byte("\x1b[0;35m[WARN]\x1b[0m "), + LError: []byte("\x1b[0;31m[ERR]\x1b[0m "), +} + +var plainLabels = LevelMap{ + LDebug: []byte("[DBG] "), + LInfo: []byte("[INF] "), + LWarn: []byte("[WARN] "), + LError: []byte("[ERR] "), +} + +// StdFormatter supports plain and color level headers +// and bold/padded fields +type StdFormatter struct { + Flag int + Colors bool // Force enable colors + NoColors bool // Force disable colors (has preference) + colorSupported bool +} + +// Format takes and entry and returns the formatted output in bytes +func (sf *StdFormatter) Format(e *Entry) ([]byte, error) { + + // Normal headers. time, file, etc + var header, message []byte + sf.stdHeader(&header, e.Time, e.File, e.Line) + + // Level headers + headers := sf.levelHeaders() + message = append(headers[e.Level], append(header, e.Message...)...) + + return append(message, '\n'), nil +} + +// levelHeaders returns plain or color level headers +// depending on user preference and output support +func (sf *StdFormatter) levelHeaders() LevelMap { + switch { + case sf.NoColors: + return plainLabels + case sf.Colors: + return colorLabels + case sf.colorSupported: + return colorLabels + } + return plainLabels +} + +// Flags returns the output flags for the formatter. +func (sf *StdFormatter) Flags() int { + return sf.Flag +} + +// SetFlags sets the output flags for the formatter. +func (sf *StdFormatter) SetFlags(flags int) { + sf.Flag = flags +} + +// ColorSupported enables or disables the colors, this will be called on every +func (sf *StdFormatter) ColorSupported(supp bool) { + sf.Colors = supp +} + +// Adapted replica of log.Logger.formatHeader +func (sf *StdFormatter) stdHeader(buf *[]byte, t time.Time, file string, line int) { + if sf.Flag&(log.Ldate|log.Ltime|log.Lmicroseconds) != 0 { + if sf.Flag&log.Ldate != 0 { + year, month, day := t.Date() + itoa(buf, year, 4) + *buf = append(*buf, '/') + itoa(buf, int(month), 2) + *buf = append(*buf, '/') + itoa(buf, day, 2) + *buf = append(*buf, ' ') + } + if sf.Flag&(log.Ltime|log.Lmicroseconds) != 0 { + hour, min, sec := t.Clock() + itoa(buf, hour, 2) + *buf = append(*buf, ':') + itoa(buf, min, 2) + *buf = append(*buf, ':') + itoa(buf, sec, 2) + if sf.Flag&log.Lmicroseconds != 0 { + *buf = append(*buf, '.') + itoa(buf, t.Nanosecond()/1e3, 6) + } + *buf = append(*buf, ' ') + } + } + if sf.Flag&(log.Lshortfile|log.Llongfile) != 0 { + if sf.Flag&log.Lshortfile != 0 { + short := file + for i := len(file) - 1; i > 0; i-- { + if file[i] == '/' { + short = file[i+1:] + break + } + } + file = short + } else { + file = filepath.Base(filepath.Dir(file)) + "/" + filepath.Base(file) + } + + if sf.Colors { + file = fmt.Sprintf("\x1b[1;30m%s:%d:\x1b[0m ", file, line) + } else { + file = fmt.Sprintf("%s:%d: ", file, line) + } + + *buf = append(*buf, file...) + } +} + +// Replica of log.Logger.itoa +func itoa(buf *[]byte, i int, wid int) { + var u = uint(i) + if u == 0 && wid <= 1 { + *buf = append(*buf, '0') + return + } + + // Assemble decimal in reverse order. + var b [32]byte + bp := len(b) + for ; u > 0 || wid > 0; u /= 10 { + bp-- + wid-- + b[bp] = byte(u%10) + '0' + } + *buf = append(*buf, b[bp:]...) +} diff --git a/std_formatter_test.go b/std_formatter_test.go new file mode 100644 index 0000000..3907c41 --- /dev/null +++ b/std_formatter_test.go @@ -0,0 +1,101 @@ +package colog + +import ( + "log" + "testing" + "time" +) + +type formatTest struct { + entry Entry + prefix string + flags int + width int + colors bool + output string +} + +// TTime is the fixed point in time for all formatting tests +var TTime = time.Date(2015, time.August, 1, 20, 45, 30, 9999, time.UTC) + +var formatterTests = []formatTest{ + { + entry: Entry{ + Level: LInfo, + Message: []byte("some message"), + }, + output: "[INF] some message\n", + }, + { + entry: Entry{ + Time: TTime, + Level: LDebug, + Message: []byte("some message"), + }, + flags: log.Ldate, + output: "[DBG] 2015/08/01 some message\n", + }, + { + entry: Entry{ + Time: TTime, + Level: LDebug, + Message: []byte("some message"), + }, + colors: true, + width: 40, + flags: log.Ldate, + output: "[\x1b[0;36mDBG\x1b[0m] 2015/08/01 some message\n", + }, + { + entry: Entry{ + Time: TTime, + Level: LDebug, + Message: []byte("some message"), + }, + colors: true, + width: 140, + flags: log.Ldate, + output: "[\x1b[0;36mDBG\x1b[0m] 2015/08/01 some message\n"}, + { + entry: Entry{ + Time: TTime, + Level: LDebug, + File: "/src/file.go", + Line: 142, + Message: []byte("some message"), + }, + colors: true, + width: 140, + flags: log.Llongfile, + output: "[\x1b[0;36mDBG\x1b[0m] \x1b[1;30msrc/file.go:142:\x1b[0m some message\n", + }, +} + +func TestStdFormatter(t *testing.T) { + for _, tt := range formatterTests { + f := StdFormatter{ + Flag: tt.flags, + Colors: tt.colors, + } + + if tt.width > 0 { + terminalWidth = fixedWidthTerminal(tt.width) + } + + b, err := f.Format(&tt.entry) + if err != nil { + t.Fatal(err) + } + + if string(b) != tt.output { + t.Errorf("Unexpected formatter output:\n%s\nVS\n%s", b, tt.output) + } + } +} + +// stub for terminal width function +func fixedWidthTerminal(width int) func(int) int { + return func(fd int) int { + return width + } +} diff --git a/tty.go b/tty.go new file mode 100644 index 0000000..17d3807 --- /dev/null +++ b/tty.go @@ -0,0 +1,31 @@ +// +build !windows + +package colog + +import ( + "syscall" + "unsafe" +) + +// Use variable indirection for test stubbing +var isTerminal = isTerminalFunc +var terminalWidth = terminalWidthFunc + +// isTerminalFunc returns true if the given file descriptor is a terminal. +func isTerminalFunc(fd int) bool { + var termios syscall.Termios + _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(fd), ioctlReadTermios, uintptr(unsafe.Pointer(&termios)), 0, 0, 0) + return err == 0 +} + +// terminalWidthFunc returns the width in characters of the terminal. +func terminalWidthFunc(fd int) (width int) { + var dimensions [4]uint16 + + _, _, errno := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(fd), uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&dimensions)), 0, 0, 0) + if errno != 0 { + return -1 + } + + return int(dimensions[1]) +} diff --git a/tty_bsd.go b/tty_bsd.go new file mode 100644 index 0000000..f07a6a9 --- /dev/null +++ b/tty_bsd.go @@ -0,0 +1,7 @@ +// +build darwin dragonfly freebsd netbsd openbsd + +package colog + +import "syscall" + +const ioctlReadTermios = syscall.TIOCGETA diff --git a/tty_linux.go b/tty_linux.go new file mode 100644 index 0000000..5d2899d --- /dev/null +++ b/tty_linux.go @@ -0,0 +1,5 @@ +// +build linux + +package colog + +const ioctlReadTermios = 0x5401 diff --git a/tty_windows.go b/tty_windows.go new file mode 100644 index 0000000..1908d4d --- /dev/null +++ b/tty_windows.go @@ -0,0 +1,62 @@ +package colog + +import ( + "syscall" + "unsafe" +) + +// Use variable indirection for test stubbing +var isTerminal = isTerminalFunc +var terminalWidth = terminalWidthFunc + +var kernel32 = syscall.NewLazyDLL("kernel32.dll") +var procInfo = kernel32.NewProc("GetConsoleScreenBufferInfo") +var procMode = kernel32.NewProc("GetConsoleMode") + +// Not applicable in windows +// define constant to avoid compilation error +const ioctlReadTermios = 0x0 + +// isTerminalFunc returns true if the given file descriptor is a terminal. +func isTerminalFunc(fd int) bool { + var st uint32 + r, _, errno := syscall.Syscall(procMode.Addr(), 2, uintptr(fd), uintptr(unsafe.Pointer(&st)), 0) + if errno != 0 { + return false + } + + return r != 0 +} + +type short int16 +type word uint16 + +type coord struct { + x short + y short +} +type rectangle struct { + left short + top short + right short + bottom short +} + +type termInfo struct { + size coord + cursorPosition coord + attributes word + window rectangle + maximumWindowSize coord +} + +// terminalWidthFunc returns the width in characters of the terminal. +func terminalWidthFunc(fd int) (width int) { + var info termInfo + _, _, errno := syscall.Syscall(procInfo.Addr(), 2, uintptr(fd), uintptr(unsafe.Pointer(&info)), 0) + if errno != 0 { + return -1 + } + + return int(info.size.x) +}