diff --git a/README.md b/README.md index 74aedcd..784c556 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ let formatter = text_formatter( show_timestamp=false, field_separator=",", template="[{level}] {target} {message} :: {fields}", + color_mode=ColorMode::Always, ) let logger = Logger::new(text_console_sink(formatter), target="pretty") @@ -180,7 +181,7 @@ logger.info("hello", fields=[field("mode", "pretty")]) ```moonbit let config = parse_logger_config_text( - "{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"field_separator\":\",\",\"template\":\"[{level}] {target} {message} :: {fields}\"}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}", + "{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"field_separator\":\",\",\"template\":\"[{level}] {target} {message} :: {fields}\",\"color_mode\":\"always\"}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}", ) let logger = build_logger(config) @@ -241,6 +242,7 @@ match logger.file_runtime_state() { - 提供 JSON 配置层: `parse_logger_config_text(...)`, `stringify_logger_config(...)`, `build_logger(...)` - `QueueConfig`, `TextFormatterConfig`, `SinkConfig` 可分别通过 `queue_config_to_json(...)` / `stringify_queue_config(...)`, `text_formatter_config_to_json(...)` / `stringify_text_formatter_config(...)`, `sink_config_to_json(...)` / `stringify_sink_config(...)` 单独导出 JSON - 支持字段: `min_level`, `target`, `timestamp`, `sink.kind`, `sink.path`, `sink.append`, `sink.auto_flush`, `sink.rotation`, `sink.text_formatter`, `queue` +- `TextFormatter` / `TextFormatterConfig` 提供 `color_mode = Never | Auto | Always`, 可控制 ANSI 文本着色 - `sink.rotation` 支持 `max_bytes` 与 `max_backups`, 用于基础 size-based rotation 和 backup retention - `file_sink(...)` 提供 `reopen()`, `reopen_with_current_policy()`, `reopen_append()`, `reopen_truncate()`, `open_failures()`, `write_failures()`, `flush_failures()`, `rotation_failures()`, 用于基础可观测性 - `file_sink(...)` 提供 `append_mode()`. 显式传入 `reopen(append=...)` 时, 会更新后续 reopen 使用的 append 策略. `reopen_with_current_policy()` 使用当前保存的策略重开文件, `reopen_append()` / `reopen_truncate()` 提供常见的 append 和 truncate 模式 @@ -264,6 +266,7 @@ match logger.file_runtime_state() { - `file_sink_policy_to_json(...)`, `stringify_file_sink_policy(...)` 可将独立 file policy 直接导出为 JSON, 便于策略快照, 配置对比或诊断上报 - `file_sink_state_to_json(...)`, `stringify_file_sink_state(...)`, `runtime_file_state_to_json(...)`, `stringify_runtime_file_state(...)` 可直接把 file / queued-file 快照导出为 JSON, 便于排障或上报 - `sink.text_formatter.template` 支持固定 token: `{timestamp}`, `{timestamp_ms}`, `{level}`, `{target}`, `{message}`, `{fields}` +- `sink.text_formatter.color_mode` 支持 `never`, `auto`, `always` - 可由配置直接组装的 sink 类型: `console`, `json_console`, `text_console`, `file` - `queue` 作为显式包装层附着在最终 sink 外侧. 这仍然是同步 drain 模型, 不是 async runtime diff --git a/bitlogger/BitLogger_test.mbt b/bitlogger/BitLogger_test.mbt index bab469c..ed53e61 100644 --- a/bitlogger/BitLogger_test.mbt +++ b/bitlogger/BitLogger_test.mbt @@ -50,7 +50,7 @@ test "logger config parser reads core options" { test "logger config parser reads formatter and queue options" { let config = parse_logger_config_text( - "{\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"separator\":\" | \",\"show_timestamp\":false,\"template\":\"[{level}] {message}\"}},\"queue\":{\"max_pending\":32,\"overflow\":\"DropOldest\"}}", + "{\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"separator\":\" | \",\"show_timestamp\":false,\"template\":\"[{level}] {message}\",\"color_mode\":\"always\"}},\"queue\":{\"max_pending\":32,\"overflow\":\"DropOldest\"}}", ) inspect(match config.sink.kind { SinkKind::TextConsole => "TextConsole" @@ -59,6 +59,7 @@ test "logger config parser reads formatter and queue options" { inspect(config.sink.text_formatter.separator, content=" | ") inspect(config.sink.text_formatter.show_timestamp, content="false") inspect(config.sink.text_formatter.template, content="[{level}] {message}") + inspect(color_mode_label(config.sink.text_formatter.color_mode), content="always") match config.queue { Some(queue) => { inspect(queue.max_pending, content="32") @@ -156,9 +157,10 @@ test "config subtype json helpers stringify stable shapes" { separator=" | ", field_separator=",", template="[{level}] {message} :: {fields}", + color_mode=ColorMode::Always, ), ), - content="{\"show_timestamp\":false,\"show_level\":true,\"show_target\":false,\"show_fields\":true,\"separator\":\" | \",\"field_separator\":\",\",\"template\":\"[{level}] {message} :: {fields}\"}", + content="{\"show_timestamp\":false,\"show_level\":true,\"show_target\":false,\"show_fields\":true,\"separator\":\" | \",\"field_separator\":\",\",\"template\":\"[{level}] {message} :: {fields}\",\"color_mode\":\"always\"}", ) inspect( stringify_sink_config( @@ -168,10 +170,10 @@ test "config subtype json helpers stringify stable shapes" { append=false, auto_flush=false, rotation=Some(file_rotation(128, max_backups=2)), - text_formatter=TextFormatterConfig::new(show_timestamp=false), + text_formatter=TextFormatterConfig::new(show_timestamp=false, color_mode=ColorMode::Auto), ), ), - content="{\"kind\":\"file\",\"path\":\"demo.log\",\"append\":false,\"auto_flush\":false,\"text_formatter\":{\"show_timestamp\":false,\"show_level\":true,\"show_target\":true,\"show_fields\":true,\"separator\":\" \",\"field_separator\":\" \",\"template\":\"\"},\"rotation\":{\"max_bytes\":128,\"max_backups\":2}}", + content="{\"kind\":\"file\",\"path\":\"demo.log\",\"append\":false,\"auto_flush\":false,\"text_formatter\":{\"show_timestamp\":false,\"show_level\":true,\"show_target\":true,\"show_fields\":true,\"separator\":\" \",\"field_separator\":\" \",\"template\":\"\",\"color_mode\":\"auto\"},\"rotation\":{\"max_bytes\":128,\"max_backups\":2}}", ) } diff --git a/bitlogger/BitLogger_wbtest.mbt b/bitlogger/BitLogger_wbtest.mbt index cc0f075..d334a41 100644 --- a/bitlogger/BitLogger_wbtest.mbt +++ b/bitlogger/BitLogger_wbtest.mbt @@ -60,6 +60,29 @@ test "text formatter supports template rendering" { ) } +test "text formatter can render ansi level colors" { + let rec = record(Level::Error, "boom", target="svc") + let formatter = text_formatter(color_mode=ColorMode::Always) + inspect( + format_text(rec, formatter=formatter), + content="[\u{001b}[31;1mERROR\u{001b}[0m] [\u{001b}[34msvc\u{001b}[0m] boom", + ) +} + +test "text formatter auto color respects NO_COLOR" { + let rec = record(Level::Warn, "boom", target="svc") + let previous = @env.get_env_var("NO_COLOR") + @env.set_env_var("NO_COLOR", "1") + inspect( + format_text(rec, formatter=text_formatter(color_mode=ColorMode::Auto)), + content="[WARN] [svc] boom", + ) + match previous { + Some(value) => @env.set_env_var("NO_COLOR", value) + None => @env.unset_env_var("NO_COLOR") + } +} + test "text formatter template respects disabled fields" { let rec = record(Level::Warn, "just message", target="svc") let formatter = text_formatter( diff --git a/bitlogger/README.mbt.md b/bitlogger/README.mbt.md index 01f65e6..fcc856c 100644 --- a/bitlogger/README.mbt.md +++ b/bitlogger/README.mbt.md @@ -44,6 +44,8 @@ BitLogger 是一个使用 MoonBit 编写的结构化日志库. - 支持 `text_formatter(...)`, `format_text(...)`, `text_console_sink(...)` 以及模板化 `template` 文本输出 - JSON config parsing via `parse_logger_config_text(...)` and `stringify_logger_config(...)` - 支持 `parse_logger_config_text(...)`, `stringify_logger_config(...)` 进行最小 JSON 配置读写 +- `TextFormatter` / `TextFormatterConfig` now support `color_mode = Never | Auto | Always` +- `TextFormatter` / `TextFormatterConfig` 现支持 `color_mode = Never | Auto | Always` - `QueueConfig` / `TextFormatterConfig` / `SinkConfig` can also be exported independently through dedicated JSON helpers - `QueueConfig` / `TextFormatterConfig` / `SinkConfig` 也可分别通过专用 JSON helper 单独导出 - config-driven logger assembly via `build_logger(...)` @@ -180,6 +182,7 @@ test { show_timestamp=false, field_separator=",", template="[{level}] {target} {message} :: {fields}", + color_mode=ColorMode::Always, ) let logger = Logger::new(text_console_sink(formatter), target="pretty") logger.info("hello", fields=[field("mode", "pretty")]) @@ -189,7 +192,7 @@ test { ```mbt check test { let config = parse_logger_config_text( - "{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"field_separator\":\",\",\"template\":\"[{level}] {target} {message} :: {fields}\"}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}", + "{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"field_separator\":\",\",\"template\":\"[{level}] {target} {message} :: {fields}\",\"color_mode\":\"always\"}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}", ) let logger = build_logger(config) logger.info("configured from json") @@ -200,6 +203,7 @@ test { ## Formatter Template / 模板格式 - supported tokens / 支持的 token: `{timestamp}`, `{timestamp_ms}`, `{level}`, `{target}`, `{message}`, `{fields}` +- `color_mode` / `color_mode`: `never`, `auto`, `always` - disabled or missing parts render as empty text / 被关闭或缺失的部分会渲染为空字符串 - `template` is intentionally a simple token replacement layer, not a full DSL / `template` 使用轻量 token 替换方式, 不是完整 DSL diff --git a/bitlogger/config.mbt b/bitlogger/config.mbt index 2b79766..caeaec4 100644 --- a/bitlogger/config.mbt +++ b/bitlogger/config.mbt @@ -17,6 +17,7 @@ pub struct TextFormatterConfig { separator : String field_separator : String template : String + color_mode : ColorMode } pub fn TextFormatterConfig::new( @@ -27,6 +28,7 @@ pub fn TextFormatterConfig::new( separator~ : String = " ", field_separator~ : String = " ", template~ : String = "", + color_mode~ : ColorMode = ColorMode::Never, ) -> TextFormatterConfig { { show_timestamp, @@ -36,6 +38,7 @@ pub fn TextFormatterConfig::new( separator, field_separator, template, + color_mode, } } @@ -48,6 +51,7 @@ pub fn TextFormatterConfig::to_formatter(self : TextFormatterConfig) -> TextForm separator=self.separator, field_separator=self.field_separator, template=self.template, + color_mode=self.color_mode, ) } @@ -815,6 +819,15 @@ fn parse_sink_kind(name : String) -> SinkKind raise ConfigError { } } +fn parse_color_mode(name : String) -> ColorMode raise ConfigError { + match name.to_upper() { + "NEVER" => ColorMode::Never + "AUTO" => ColorMode::Auto + "ALWAYS" => ColorMode::Always + _ => raise ConfigError::InvalidConfig("Unsupported color mode: " + name) + } +} + fn sink_kind_label(kind : SinkKind) -> String { match kind { SinkKind::Console => "console" @@ -834,6 +847,7 @@ fn parse_text_formatter_config(value : @json_parser.JsonValue) -> TextFormatterC separator=get_string(obj, "separator", default=" "), field_separator=get_string(obj, "field_separator", default=" "), template=get_string(obj, "template", default=""), + color_mode=parse_color_mode(get_string(obj, "color_mode", default="never")), ) } @@ -928,6 +942,7 @@ pub fn text_formatter_config_to_json(config : TextFormatterConfig) -> @json_pars "separator": @json_parser.JsonValue::String(config.separator), "field_separator": @json_parser.JsonValue::String(config.field_separator), "template": @json_parser.JsonValue::String(config.template), + "color_mode": @json_parser.JsonValue::String(color_mode_label(config.color_mode)), }) } diff --git a/bitlogger/formatter.mbt b/bitlogger/formatter.mbt index 47356a9..80f4000 100644 --- a/bitlogger/formatter.mbt +++ b/bitlogger/formatter.mbt @@ -1,5 +1,11 @@ pub type RecordFormatter = (Record) -> String +pub(all) enum ColorMode { + Never + Auto + Always +} + pub struct TextFormatter { show_timestamp : Bool show_level : Bool @@ -8,6 +14,7 @@ pub struct TextFormatter { separator : String field_separator : String template : String + color_mode : ColorMode } pub fn text_formatter( @@ -18,6 +25,7 @@ pub fn text_formatter( separator~ : String = " ", field_separator~ : String = " ", template~ : String = "", + color_mode~ : ColorMode = ColorMode::Never, ) -> TextFormatter { { show_timestamp, @@ -27,6 +35,44 @@ pub fn text_formatter( separator, field_separator, template, + color_mode, + } +} + +pub fn color_mode_label(mode : ColorMode) -> String { + match mode { + ColorMode::Never => "never" + ColorMode::Auto => "auto" + ColorMode::Always => "always" + } +} + +fn use_ansi_color(mode : ColorMode) -> Bool { + match mode { + ColorMode::Never => false + ColorMode::Always => true + ColorMode::Auto => match @env.get_env_var("NO_COLOR") { + Some(_) => false + None => true + } + } +} + +fn ansi_wrap(text : String, code : String, enabled : Bool) -> String { + if !enabled || text == "" { + text + } else { + "\u{001b}[\{code}m\{text}\u{001b}[0m" + } +} + +fn level_ansi_code(level : Level) -> String { + match level { + Level::Trace => "90" + Level::Debug => "36" + Level::Info => "32" + Level::Warn => "33" + Level::Error => "31;1" } } @@ -44,7 +90,7 @@ fn format_fields(fields : Array[Field], separator : String) -> String { fn timestamp_text(rec : Record, formatter : TextFormatter) -> String { if formatter.show_timestamp && rec.timestamp_ms != 0UL { - rec.timestamp_ms.to_string() + ansi_wrap(rec.timestamp_ms.to_string(), "90", use_ansi_color(formatter.color_mode)) } else { "" } @@ -52,7 +98,7 @@ fn timestamp_text(rec : Record, formatter : TextFormatter) -> String { fn level_text(rec : Record, formatter : TextFormatter) -> String { if formatter.show_level { - rec.level.label() + ansi_wrap(rec.level.label(), level_ansi_code(rec.level), use_ansi_color(formatter.color_mode)) } else { "" } @@ -60,7 +106,7 @@ fn level_text(rec : Record, formatter : TextFormatter) -> String { fn target_text(rec : Record, formatter : TextFormatter) -> String { if formatter.show_target && rec.target != "" { - rec.target + ansi_wrap(rec.target, "34", use_ansi_color(formatter.color_mode)) } else { "" } @@ -68,7 +114,7 @@ fn target_text(rec : Record, formatter : TextFormatter) -> String { fn fields_text(rec : Record, formatter : TextFormatter) -> String { if formatter.show_fields && rec.fields.length() != 0 { - format_fields(rec.fields, formatter.field_separator) + ansi_wrap(format_fields(rec.fields, formatter.field_separator), "35", use_ansi_color(formatter.color_mode)) } else { "" } @@ -92,20 +138,20 @@ pub fn format_text(rec : Record, formatter~ : TextFormatter = text_formatter()) } let parts : Array[String] = [] if formatter.show_timestamp && rec.timestamp_ms != 0UL { - parts.push("[\{rec.timestamp_ms.to_string()}]") + parts.push("[\{timestamp_text(rec, formatter)}]") } if formatter.show_level { - parts.push("[\{rec.level.label()}]") + parts.push("[\{level_text(rec, formatter)}]") } if formatter.show_target && rec.target != "" { - parts.push("[\{rec.target}]") + parts.push("[\{target_text(rec, formatter)}]") } parts.push(rec.message) let base = parts.join(formatter.separator) if !formatter.show_fields || rec.fields.length() == 0 { base } else { - let details = format_fields(rec.fields, formatter.field_separator) + let details = fields_text(rec, formatter) "\{base}\{formatter.separator}\{details}" } } diff --git a/docs/README-en.md b/docs/README-en.md index c614eae..d0371d6 100644 --- a/docs/README-en.md +++ b/docs/README-en.md @@ -158,6 +158,7 @@ let formatter = text_formatter( show_timestamp=false, field_separator=",", template="[{level}] {target} {message} :: {fields}", + color_mode=ColorMode::Always, ) let logger = Logger::new(text_console_sink(formatter), target="pretty") @@ -168,7 +169,7 @@ JSON config loading: ```moonbit let config = parse_logger_config_text( - "{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"field_separator\":\",\",\"template\":\"[{level}] {target} {message} :: {fields}\"}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}", + "{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"field_separator\":\",\",\"template\":\"[{level}] {target} {message} :: {fields}\",\"color_mode\":\"always\"}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}", ) let logger = build_logger(config) @@ -225,6 +226,7 @@ match logger.file_runtime_state() { - BitLogger now includes a JSON config layer via `parse_logger_config_text(...)`, `stringify_logger_config(...)`, and `build_logger(...)`. - `QueueConfig`, `TextFormatterConfig`, and `SinkConfig` can also be exported independently through `queue_config_to_json(...)` / `stringify_queue_config(...)`, `text_formatter_config_to_json(...)` / `stringify_text_formatter_config(...)`, and `sink_config_to_json(...)` / `stringify_sink_config(...)`. - Supported keys include `min_level`, `target`, `timestamp`, `sink.kind`, `sink.path`, `sink.append`, `sink.auto_flush`, `sink.rotation`, `sink.text_formatter`, and `queue`. +- `TextFormatter` and `TextFormatterConfig` now include `color_mode = Never | Auto | Always` for ANSI text coloring control. - `sink.rotation` currently supports `max_bytes` and `max_backups` for basic size-based rotation and backup retention. - `file_sink(...)` also exposes `reopen()`, `reopen_with_current_policy()`, `reopen_append()`, `reopen_truncate()`, `open_failures()`, `write_failures()`, `flush_failures()`, and `rotation_failures()` for basic observability. - `file_sink(...)` also exposes `append_mode()`. Passing `append=...` to `reopen(...)` updates the current append policy used by later reopen calls, `reopen_with_current_policy()` makes that stored-policy reopen path explicit, and `reopen_append()` / `reopen_truncate()` cover the two common policy switches directly. @@ -248,6 +250,7 @@ match logger.file_runtime_state() { - `file_sink_policy_to_json(...)` and `stringify_file_sink_policy(...)` can export standalone file-policy snapshots directly as JSON for policy diffing, diagnostics, or reporting. - `file_sink_state_to_json(...)`, `stringify_file_sink_state(...)`, `runtime_file_state_to_json(...)`, and `stringify_runtime_file_state(...)` can export file and queued-file snapshots directly as JSON for diagnostics or reporting. - `sink.text_formatter.template` currently supports fixed tokens: `{timestamp}`, `{timestamp_ms}`, `{level}`, `{target}`, `{message}`, and `{fields}`. +- `sink.text_formatter.color_mode` currently supports `never`, `auto`, and `always`. - Config-driven sink assembly currently supports `console`, `json_console`, `text_console`, and `file`. - `queue` remains a synchronous bounded wrapper around the final sink, not an async runtime. diff --git a/docs/changes/0.4.0.md b/docs/changes/0.4.0.md new file mode 100644 index 0000000..d5b787c --- /dev/null +++ b/docs/changes/0.4.0.md @@ -0,0 +1,25 @@ +## BitLogger Update Changes + +version 0.4.0 + +### Feature + +- feat: add `ColorMode = Never | Auto | Always` for text formatter color control +- feat: add ANSI level, target, timestamp, and field rendering to `format_text(...)` +- feat: add `color_mode` to `TextFormatter` and `TextFormatterConfig` +- feat: support `text_formatter.color_mode` in JSON config parsing and serialization + +### Test + +- test: cover ANSI text formatter rendering in `Always` mode +- test: cover `Auto` mode fallback behavior when `NO_COLOR` is present +- test: cover config parsing and serialization for `color_mode` + +### Example + +- docs: add `color_mode` usage examples to formatter documentation + +### Notes + +- `Auto` currently uses a conservative rule: if `NO_COLOR` exists, ANSI is disabled; otherwise ANSI is enabled +- this batch only covers ANSI formatter output and does not yet include inline style markup or tag registry support