diff --git a/README.md b/README.md index 292af81..e508bbb 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,20 @@ ignore(logger.flush()) +
JSON style_tags 示例 + +```moonbit +let config = parse_logger_config_text( + "{\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"color_mode\":\"always\",\"style_tags\":{\"accent\":{\"fg\":\"#4cc9f0\",\"bold\":true}}}}}", +) + +let logger = build_logger(config) + +logger.info("styled from json") +``` + +
+
native 文件 sink 示例 ```moonbit @@ -266,7 +280,8 @@ match logger.file_runtime_state() { - `message` 支持轻量 inline style tag: `...`, `...`, `<#ff0000>...`, `...` - 运行期样式标签 API: `TextStyle`, `StyleTagRegistry`, `style_tag_registry()`, `default_style_tag_registry()`, `set_tag(...)`, `define_alias(...)` - 样式标签优先级: formatter 局部 `style_tags` > 全局 style tag registry > 内置标签 -- 自定义 style tag 当前仅支持运行期 API, 还不支持通过 JSON config 声明 +- `sink.text_formatter.style_tags` 现支持最小对象映射配置, 可声明 `fg`, `bg`, `bold`, `dim`, `italic`, `underline` +- `define_alias(...)` 仍为运行期 API, 当前不在 JSON schema 中 - `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 模式 @@ -291,6 +306,7 @@ match logger.file_runtime_state() { - `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.text_formatter.style_tags.` 支持 `fg`, `bg`, `bold`, `dim`, `italic`, `underline` - 可由配置直接组装的 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 ed53e61..c5ce637 100644 --- a/bitlogger/BitLogger_test.mbt +++ b/bitlogger/BitLogger_test.mbt @@ -72,6 +72,28 @@ test "logger config parser reads formatter and queue options" { } } +test "logger config parser reads formatter style tags" { + let config = parse_logger_config_text( + "{\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"color_mode\":\"always\",\"style_tags\":{\"accent\":{\"fg\":\"#4cc9f0\",\"bold\":true},\"panel\":{\"bg\":\"#202020\",\"underline\":true}}}}}", + ) + inspect(config.sink.text_formatter.style_tags.length(), content="2") + match config.sink.text_formatter.style_tags.get("accent") { + Some(style) => { + inspect(style.fg.unwrap(), content="#4cc9f0") + inspect(style.bold, content="true") + inspect(style.bg is None, content="true") + } + None => inspect(false, content="true") + } + match config.sink.text_formatter.style_tags.get("panel") { + Some(style) => { + inspect(style.bg.unwrap(), content="#202020") + inspect(style.underline, content="true") + } + None => inspect(false, content="true") + } +} + test "logger config parser reads file rotation options" { let config = parse_logger_config_text( "{\"sink\":{\"kind\":\"file\",\"path\":\"bitlogger.log\",\"rotation\":{\"max_bytes\":128,\"max_backups\":3}}}", @@ -119,6 +141,30 @@ test "logger config stringify roundtrips stable fields" { inspect(config.sink.text_formatter.template, content="[{level}] {target} {message}") } +test "logger config stringify roundtrips formatter style tags" { + let text = stringify_logger_config( + LoggerConfig::new( + sink=SinkConfig::new( + kind=SinkKind::TextConsole, + text_formatter=TextFormatterConfig::new( + color_mode=ColorMode::Always, + style_tags={ + "accent": text_style(fg=Some("#4cc9f0"), bold=true), + "panel": text_style(bg=Some("#202020"), dim=true), + }, + ), + ), + ), + ) + let config = parse_logger_config_text(text) + inspect(color_mode_label(config.sink.text_formatter.color_mode), content="always") + inspect(config.sink.text_formatter.style_tags.length(), content="2") + inspect(config.sink.text_formatter.style_tags.get("accent").unwrap().fg.unwrap(), content="#4cc9f0") + inspect(config.sink.text_formatter.style_tags.get("accent").unwrap().bold, content="true") + inspect(config.sink.text_formatter.style_tags.get("panel").unwrap().bg.unwrap(), content="#202020") + inspect(config.sink.text_formatter.style_tags.get("panel").unwrap().dim, content="true") +} + test "logger config stringify roundtrips file rotation fields" { let text = stringify_logger_config( LoggerConfig::new( @@ -158,9 +204,12 @@ test "config subtype json helpers stringify stable shapes" { field_separator=",", template="[{level}] {message} :: {fields}", color_mode=ColorMode::Always, + style_tags={ + "accent": text_style(fg=Some("#4cc9f0"), bold=true), + }, ), ), - content="{\"show_timestamp\":false,\"show_level\":true,\"show_target\":false,\"show_fields\":true,\"separator\":\" | \",\"field_separator\":\",\",\"template\":\"[{level}] {message} :: {fields}\",\"color_mode\":\"always\"}", + content="{\"show_timestamp\":false,\"show_level\":true,\"show_target\":false,\"show_fields\":true,\"separator\":\" | \",\"field_separator\":\",\",\"template\":\"[{level}] {message} :: {fields}\",\"color_mode\":\"always\",\"style_tags\":{\"accent\":{\"bold\":true,\"dim\":false,\"italic\":false,\"underline\":false,\"fg\":\"#4cc9f0\"}}}", ) inspect( stringify_sink_config( @@ -177,6 +226,22 @@ test "config subtype json helpers stringify stable shapes" { ) } +test "config formatter style tags render in built logger" { + let formatter = TextFormatterConfig::new( + show_timestamp=false, + show_target=false, + color_mode=ColorMode::Always, + style_tags={ + "accent": text_style(fg=Some("#4cc9f0"), bold=true), + }, + ) + let rendered = format_text( + Record::new(Level::Info, "tag"), + formatter=formatter.to_formatter(), + ) + inspect(rendered, content="[\u{001b}[32mINFO\u{001b}[0m] \u{001b}[38;2;76;201;240;1mtag\u{001b}[0m") +} + test "build logger from config supports queued text console" { let logger = build_logger( LoggerConfig::new( diff --git a/bitlogger/README.mbt.md b/bitlogger/README.mbt.md index 26e364e..0fc3472 100644 --- a/bitlogger/README.mbt.md +++ b/bitlogger/README.mbt.md @@ -217,6 +217,16 @@ test { } ``` +```mbt check +test { + let config = parse_logger_config_text( + "{\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"color_mode\":\"always\",\"style_tags\":{\"accent\":{\"fg\":\"#4cc9f0\",\"bold\":true}}}}}", + ) + let logger = build_logger(config) + logger.info("styled from json") +} +``` + ## Formatter Template / 模板格式 - supported tokens / 支持的 token: `{timestamp}`, `{timestamp_ms}`, `{level}`, `{target}`, `{message}`, `{fields}` @@ -224,7 +234,8 @@ test { - inline style tags / inline 样式标签: `...`, `...`, `<#ff0000>...`, `...` - runtime style tags / 运行期样式标签: `TextStyle`, `StyleTagRegistry`, `style_tag_registry()`, `default_style_tag_registry()`, `set_tag(...)`, `define_alias(...)` - style tag priority / 标签优先级: formatter local `style_tags` > global style tag registry > builtin tags -- custom style tags are runtime-only for now / 自定义样式标签当前仅支持运行期 API +- `sink.text_formatter.style_tags` / `sink.text_formatter.style_tags` 现支持最小对象映射: `fg`, `bg`, `bold`, `dim`, `italic`, `underline` +- `define_alias(...)` is still runtime-only / `define_alias(...)` 目前仍为运行期 API - 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 caeaec4..6999be0 100644 --- a/bitlogger/config.mbt +++ b/bitlogger/config.mbt @@ -18,6 +18,7 @@ pub struct TextFormatterConfig { field_separator : String template : String color_mode : ColorMode + style_tags : Map[String, TextStyle] } pub fn TextFormatterConfig::new( @@ -29,6 +30,7 @@ pub fn TextFormatterConfig::new( field_separator~ : String = " ", template~ : String = "", color_mode~ : ColorMode = ColorMode::Never, + style_tags~ : Map[String, TextStyle] = {}, ) -> TextFormatterConfig { { show_timestamp, @@ -39,9 +41,18 @@ pub fn TextFormatterConfig::new( field_separator, template, color_mode, + style_tags, } } +fn style_tag_registry_from_config(style_tags : Map[String, TextStyle]) -> StyleTagRegistry { + let registry = style_tag_registry() + for name, style in style_tags { + ignore(registry.set_tag(name, style=style)) + } + registry +} + pub fn TextFormatterConfig::to_formatter(self : TextFormatterConfig) -> TextFormatter { text_formatter( show_timestamp=self.show_timestamp, @@ -52,6 +63,11 @@ pub fn TextFormatterConfig::to_formatter(self : TextFormatterConfig) -> TextForm field_separator=self.field_separator, template=self.template, color_mode=self.color_mode, + style_tags=if self.style_tags.length() == 0 { + None + } else { + Some(style_tag_registry_from_config(self.style_tags)) + }, ) } @@ -759,6 +775,19 @@ fn get_string( } } +fn get_optional_string( + obj : Map[String, @json_parser.JsonValue], + key : String, +) -> String? raise ConfigError { + match obj.get(key) { + None => None + Some(value) => match value.as_string() { + Some(text) => Some(text) + None => raise ConfigError::InvalidConfig("Expected string at key " + key) + } + } +} + fn get_bool( obj : Map[String, @json_parser.JsonValue], key : String, @@ -848,9 +877,37 @@ fn parse_text_formatter_config(value : @json_parser.JsonValue) -> TextFormatterC 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")), + style_tags=match obj.get("style_tags") { + None => {} + Some(inner) => parse_style_tags_config(inner) + }, ) } +fn parse_text_style_config( + value : @json_parser.JsonValue, + context : String, +) -> TextStyle raise ConfigError { + let obj = expect_object(value, context) + text_style( + fg=get_optional_string(obj, "fg"), + bg=get_optional_string(obj, "bg"), + bold=get_bool(obj, "bold", default=false), + dim=get_bool(obj, "dim", default=false), + italic=get_bool(obj, "italic", default=false), + underline=get_bool(obj, "underline", default=false), + ) +} + +fn parse_style_tags_config(value : @json_parser.JsonValue) -> Map[String, TextStyle] raise ConfigError { + let obj = expect_object(value, "text_formatter.style_tags") + let style_tags : Map[String, TextStyle] = {} + for name, item in obj { + style_tags[name] = parse_text_style_config(item, "text_formatter.style_tags." + name) + } + style_tags +} + fn parse_queue_config(value : @json_parser.JsonValue) -> QueueConfig raise ConfigError { let obj = expect_object(value, "queue") QueueConfig::new( @@ -934,7 +991,7 @@ pub fn stringify_queue_config(queue : QueueConfig, pretty~ : Bool = false) -> St } pub fn text_formatter_config_to_json(config : TextFormatterConfig) -> @json_parser.JsonValue { - @json_parser.JsonValue::Object({ + let obj : Map[String, @json_parser.JsonValue] = { "show_timestamp": @json_parser.JsonValue::Bool(config.show_timestamp), "show_level": @json_parser.JsonValue::Bool(config.show_level), "show_target": @json_parser.JsonValue::Bool(config.show_target), @@ -943,7 +1000,37 @@ pub fn text_formatter_config_to_json(config : TextFormatterConfig) -> @json_pars "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)), - }) + } + if config.style_tags.length() != 0 { + obj["style_tags"] = style_tags_config_to_json(config.style_tags) + } + @json_parser.JsonValue::Object(obj) +} + +fn text_style_config_to_json(style : TextStyle) -> @json_parser.JsonValue { + let obj : Map[String, @json_parser.JsonValue] = { + "bold": @json_parser.JsonValue::Bool(style.bold), + "dim": @json_parser.JsonValue::Bool(style.dim), + "italic": @json_parser.JsonValue::Bool(style.italic), + "underline": @json_parser.JsonValue::Bool(style.underline), + } + match style.fg { + Some(value) => obj["fg"] = @json_parser.JsonValue::String(value) + None => () + } + match style.bg { + Some(value) => obj["bg"] = @json_parser.JsonValue::String(value) + None => () + } + @json_parser.JsonValue::Object(obj) +} + +fn style_tags_config_to_json(style_tags : Map[String, TextStyle]) -> @json_parser.JsonValue { + let obj : Map[String, @json_parser.JsonValue] = {} + for name, style in style_tags { + obj[name] = text_style_config_to_json(style) + } + @json_parser.JsonValue::Object(obj) } pub fn stringify_text_formatter_config( diff --git a/docs/README-en.md b/docs/README-en.md index 9787503..8c35bcb 100644 --- a/docs/README-en.md +++ b/docs/README-en.md @@ -196,6 +196,18 @@ logger.info("configured from json") ignore(logger.flush()) ``` +JSON `style_tags`: + +```moonbit +let config = parse_logger_config_text( + "{\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"color_mode\":\"always\",\"style_tags\":{\"accent\":{\"fg\":\"#4cc9f0\",\"bold\":true}}}}}", +) + +let logger = build_logger(config) + +logger.info("styled from json") +``` + Native file sink: ```moonbit @@ -248,7 +260,8 @@ match logger.file_runtime_state() { - `message` also supports lightweight inline style tags such as `...`, `...`, `<#ff0000>...`, and `...`. - Runtime style-tag APIs now include `TextStyle`, `StyleTagRegistry`, `style_tag_registry()`, `default_style_tag_registry()`, `set_tag(...)`, and `define_alias(...)`. - Style-tag lookup priority is formatter-local `style_tags` > global style tag registry > builtin tags. -- Custom style tags are runtime-only for now and are not yet part of the JSON config schema. +- `sink.text_formatter.style_tags` now supports a minimal object mapping with `fg`, `bg`, `bold`, `dim`, `italic`, and `underline`. +- `define_alias(...)` remains a runtime-only API and is not yet part of the JSON config schema. - `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. @@ -273,6 +286,7 @@ match logger.file_runtime_state() { - `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`. +- `sink.text_formatter.style_tags.` currently supports `fg`, `bg`, `bold`, `dim`, `italic`, and `underline`. - 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 index 0cd62e1..386919f 100644 --- a/docs/changes/0.4.0.md +++ b/docs/changes/0.4.0.md @@ -13,6 +13,7 @@ version 0.4.0 - feat: keep JSON formatter output unchanged and limit inline style parsing to text message rendering only - feat: add `TextStyle`, `StyleTagRegistry`, `style_tag_registry()`, and `default_style_tag_registry()` for reusable inline style tags - feat: add formatter-local `style_tags`, global style tag registry helpers, builtin-tag override support, and alias reuse via `define_alias(...)` +- feat: support minimal `sink.text_formatter.style_tags` JSON config parsing and serialization for custom formatter tag styles ### Test @@ -22,10 +23,12 @@ version 0.4.0 - test: cover named inline color tags in ANSI mode - test: cover plain mode tag stripping, nested tags, hex tags, and unknown-tag fallback behavior - test: cover custom tags, builtin-tag override, formatter-vs-global priority, global registry fallback, and alias reuse +- test: cover formatter `style_tags` JSON parsing, config roundtrip, JSON helper export, and config-driven styled formatter rendering ### Example - docs: add `color_mode` usage examples to formatter documentation +- docs: add runtime and JSON `style_tags` examples, and document current JSON schema scope ### Notes @@ -33,3 +36,4 @@ version 0.4.0 - inline style markup currently supports short close `` only - unknown or invalid inline tags currently fall back to plain text and do not raise formatter errors - formatter-local tag lookup currently takes precedence over global tag lookup, and global lookup takes precedence over builtin tags +- JSON config currently supports concrete style objects only; alias-style declarations remain runtime-only