11 Commits

Author SHA1 Message Date
Nanaloveyuki 3124d7a445 🔖 Release 0.4.0 2026-05-10 16:02:19 +08:00
Nanaloveyuki 4b54005401 Add ANSI color support fallback modes 2026-05-10 15:32:48 +08:00
Nanaloveyuki 2d2388c79f Support named style closing tags 2026-05-10 15:22:56 +08:00
Nanaloveyuki e78183d267 Add scoped target and field style markup 2026-05-10 15:17:13 +08:00
Nanaloveyuki 4be861acce Add builtin semantic style tags 2026-05-10 15:10:29 +08:00
Nanaloveyuki 20f79bbe2a Add configurable style markup modes 2026-05-10 15:06:20 +08:00
Nanaloveyuki f3e903b578 Add formatter style_tags config support 2026-05-10 15:00:37 +08:00
Nanaloveyuki f1b223f203 📝 Document styled formatter usage 2026-05-10 14:52:17 +08:00
Nanaloveyuki 3b6536f980 Add style tag registry overrides 2026-05-10 14:50:06 +08:00
Nanaloveyuki b1b2235160 Add inline style markup 2026-05-10 14:41:57 +08:00
Nanaloveyuki 90af009e93 Add ANSI color mode 2026-05-10 14:33:49 +08:00
10 changed files with 1661 additions and 24 deletions
+81 -1
View File
@@ -26,6 +26,7 @@ BitLogger 是一个使用 MoonBit 编写的结构化日志库
- 🧷 可绑定上下文: 支持 `bind(...)``fields(...)`, 便于复用上下文字段. - 🧷 可绑定上下文: 支持 `bind(...)``fields(...)`, 便于复用上下文字段.
- 📮 显式队列: 支持 `queued_sink(...)` / `with_queue(...)`, 支持有界积压和溢出策略. - 📮 显式队列: 支持 `queued_sink(...)` / `with_queue(...)`, 支持有界积压和溢出策略.
- 🧾 可配置文本格式: 支持 `text_formatter(...)`, `format_text(...)`, `text_console_sink(...)`, `formatted_callback_sink(...)` 和模板化 `template` 输出. - 🧾 可配置文本格式: 支持 `text_formatter(...)`, `format_text(...)`, `text_console_sink(...)`, `formatted_callback_sink(...)` 和模板化 `template` 输出.
- 🎨 轻量样式标签: 支持 `color_mode`, inline markup, `TextStyle`, `StyleTagRegistry`, 自定义标签与内置标签覆盖.
- 💾 Native 文件输出: 支持 `file_sink(...)`, 基础 size rotation / backup retention, 显式 `reopen()` / `reopen_with_current_policy()` / `reopen_append()` / `reopen_truncate()` 与失败计数, 仅在 `native/llvm` backend 可用. - 💾 Native 文件输出: 支持 `file_sink(...)`, 基础 size rotation / backup retention, 显式 `reopen()` / `reopen_with_current_policy()` / `reopen_append()` / `reopen_truncate()` 与失败计数, 仅在 `native/llvm` backend 可用.
- 📦 MoonBit 适配: API 和工程结构与 MoonBit 的 package / visibility / toolchain 模型保持一致. - 📦 MoonBit 适配: API 和工程结构与 MoonBit 的 package / visibility / toolchain 模型保持一致.
@@ -168,6 +169,7 @@ let formatter = text_formatter(
show_timestamp=false, show_timestamp=false,
field_separator=",", field_separator=",",
template="[{level}] {target} {message} :: {fields}", template="[{level}] {target} {message} :: {fields}",
color_mode=ColorMode::Always,
) )
let logger = Logger::new(text_console_sink(formatter), target="pretty") let logger = Logger::new(text_console_sink(formatter), target="pretty")
@@ -176,11 +178,44 @@ logger.info("hello", fields=[field("mode", "pretty")])
</details> </details>
<details><summary>inline style tag 示例</summary>
```moonbit
let formatter = text_formatter(
show_timestamp=false,
color_mode=ColorMode::Always,
).with_style_tags(
default_style_tag_registry()
.set_tag("accent", fg=Some("#4cc9f0"), bold=true)
.define_alias("danger", "red"),
)
let logger = Logger::new(text_console_sink(formatter), target="styled")
logger.info("<accent>styled</> output and <danger>alert</>")
```
</details>
<details><summary>关闭 style markup 解析示例</summary>
```moonbit
let formatter = text_formatter(
color_mode=ColorMode::Always,
).without_style_markup()
let logger = Logger::new(text_console_sink(formatter), target="raw")
logger.info("<red>kept as raw text</>")
```
</details>
<details><summary>JSON 配置加载示例</summary> <details><summary>JSON 配置加载示例</summary>
```moonbit ```moonbit
let config = parse_logger_config_text( 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) let logger = build_logger(config)
@@ -191,6 +226,34 @@ ignore(logger.flush())
</details> </details>
<details><summary>JSON style_tags 示例</summary>
```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("<accent>styled from json</>")
```
</details>
<details><summary>JSON style_markup 模式示例</summary>
```moonbit
let config = parse_logger_config_text(
"{\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"color_mode\":\"always\",\"style_markup\":\"disabled\"}}}",
)
let logger = build_logger(config)
logger.info("<red>still raw</>")
```
</details>
<details><summary>native 文件 sink 示例</summary> <details><summary>native 文件 sink 示例</summary>
```moonbit ```moonbit
@@ -241,6 +304,17 @@ match logger.file_runtime_state() {
- 提供 JSON 配置层: `parse_logger_config_text(...)`, `stringify_logger_config(...)`, `build_logger(...)` - 提供 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 - `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` - 支持字段: `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 文本着色
- `TextFormatter` / `TextFormatterConfig` 提供 `color_support = basic | truecolor`, 可控制 hex / RGB 样式是否降级到基础 ANSI 色
- `TextFormatter` / `TextFormatterConfig` 提供 `style_markup = disabled | builtin | full`, 可决定是否解析 style markup 以及是否启用 custom style tag
- `target_style_markup``fields_style_markup` 可独立控制 `target``fields` 是否解析 style markup
- `message` 支持轻量 inline style tag: `<red>...</>`, `<b>...</>`, `<#ff0000>...</>`, `<bg:#202020>...</>`
- 闭合同时支持简写 `</>` 与具名闭合 `</red>`, `</danger>`, `</b>`
- 内置语义标签包括: `<accent>`, `<info>`, `<success>`, `<warning>`, `<danger>`, `<muted>`
- 运行期样式标签 API: `TextStyle`, `StyleTagRegistry`, `style_tag_registry()`, `default_style_tag_registry()`, `set_tag(...)`, `define_alias(...)`
- 样式标签优先级: formatter 局部 `style_tags` > 全局 style tag registry > 内置标签
- `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 - `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(...)` 提供 `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 模式 - `file_sink(...)` 提供 `append_mode()`. 显式传入 `reopen(append=...)` 时, 会更新后续 reopen 使用的 append 策略. `reopen_with_current_policy()` 使用当前保存的策略重开文件, `reopen_append()` / `reopen_truncate()` 提供常见的 append 和 truncate 模式
@@ -264,6 +338,12 @@ match logger.file_runtime_state() {
- `file_sink_policy_to_json(...)`, `stringify_file_sink_policy(...)` 可将独立 file policy 直接导出为 JSON, 便于策略快照, 配置对比或诊断上报 - `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, 便于排障或上报 - `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.template` 支持固定 token: `{timestamp}`, `{timestamp_ms}`, `{level}`, `{target}`, `{message}`, `{fields}`
- `sink.text_formatter.color_mode` 支持 `never`, `auto`, `always`
- `sink.text_formatter.color_support` 支持 `basic`, `truecolor`
- `sink.text_formatter.style_markup` 支持 `disabled`, `builtin`, `full`
- `sink.text_formatter.target_style_markup``sink.text_formatter.fields_style_markup` 支持 `disabled`, `builtin`, `full`
- `sink.text_formatter.style_tags.<name>` 支持 `fg`, `bg`, `bold`, `dim`, `italic`, `underline`
- `fields_style_markup` 当前只解析 field value, 不解析 field key
- 可由配置直接组装的 sink 类型: `console`, `json_console`, `text_console`, `file` - 可由配置直接组装的 sink 类型: `console`, `json_console`, `text_console`, `file`
- `queue` 作为显式包装层附着在最终 sink 外侧. 这仍然是同步 drain 模型, 不是 async runtime - `queue` 作为显式包装层附着在最终 sink 外侧. 这仍然是同步 drain 模型, 不是 async runtime
+158 -4
View File
@@ -50,7 +50,7 @@ test "logger config parser reads core options" {
test "logger config parser reads formatter and queue options" { test "logger config parser reads formatter and queue options" {
let config = parse_logger_config_text( 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 { inspect(match config.sink.kind {
SinkKind::TextConsole => "TextConsole" 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.separator, content=" | ")
inspect(config.sink.text_formatter.show_timestamp, content="false") inspect(config.sink.text_formatter.show_timestamp, content="false")
inspect(config.sink.text_formatter.template, content="[{level}] {message}") inspect(config.sink.text_formatter.template, content="[{level}] {message}")
inspect(color_mode_label(config.sink.text_formatter.color_mode), content="always")
match config.queue { match config.queue {
Some(queue) => { Some(queue) => {
inspect(queue.max_pending, content="32") inspect(queue.max_pending, content="32")
@@ -71,6 +72,32 @@ 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\",\"color_support\":\"basic\",\"style_markup\":\"builtin\",\"target_style_markup\":\"builtin\",\"fields_style_markup\":\"disabled\",\"style_tags\":{\"accent\":{\"fg\":\"#4cc9f0\",\"bold\":true},\"panel\":{\"bg\":\"#202020\",\"underline\":true}}}}}",
)
inspect(style_markup_mode_label(config.sink.text_formatter.style_markup), content="builtin")
inspect(color_support_label(config.sink.text_formatter.color_support), content="basic")
inspect(style_markup_mode_label(config.sink.text_formatter.target_style_markup), content="builtin")
inspect(style_markup_mode_label(config.sink.text_formatter.fields_style_markup), content="disabled")
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" { test "logger config parser reads file rotation options" {
let config = parse_logger_config_text( let config = parse_logger_config_text(
"{\"sink\":{\"kind\":\"file\",\"path\":\"bitlogger.log\",\"rotation\":{\"max_bytes\":128,\"max_backups\":3}}}", "{\"sink\":{\"kind\":\"file\",\"path\":\"bitlogger.log\",\"rotation\":{\"max_bytes\":128,\"max_backups\":3}}}",
@@ -118,6 +145,38 @@ test "logger config stringify roundtrips stable fields" {
inspect(config.sink.text_formatter.template, content="[{level}] {target} {message}") 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,
color_support=ColorSupport::Basic,
style_markup=StyleMarkupMode::Builtin,
target_style_markup=StyleMarkupMode::Builtin,
fields_style_markup=StyleMarkupMode::Disabled,
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(color_support_label(config.sink.text_formatter.color_support), content="basic")
inspect(style_markup_mode_label(config.sink.text_formatter.style_markup), content="builtin")
inspect(style_markup_mode_label(config.sink.text_formatter.target_style_markup), content="builtin")
inspect(style_markup_mode_label(config.sink.text_formatter.fields_style_markup), content="disabled")
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" { test "logger config stringify roundtrips file rotation fields" {
let text = stringify_logger_config( let text = stringify_logger_config(
LoggerConfig::new( LoggerConfig::new(
@@ -156,9 +215,17 @@ test "config subtype json helpers stringify stable shapes" {
separator=" | ", separator=" | ",
field_separator=",", field_separator=",",
template="[{level}] {message} :: {fields}", template="[{level}] {message} :: {fields}",
color_mode=ColorMode::Always,
color_support=ColorSupport::Basic,
style_markup=StyleMarkupMode::Builtin,
target_style_markup=StyleMarkupMode::Builtin,
fields_style_markup=StyleMarkupMode::Disabled,
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}\"}", content="{\"show_timestamp\":false,\"show_level\":true,\"show_target\":false,\"show_fields\":true,\"separator\":\" | \",\"field_separator\":\",\",\"template\":\"[{level}] {message} :: {fields}\",\"color_mode\":\"always\",\"color_support\":\"basic\",\"style_markup\":\"builtin\",\"target_style_markup\":\"builtin\",\"fields_style_markup\":\"disabled\",\"style_tags\":{\"accent\":{\"bold\":true,\"dim\":false,\"italic\":false,\"underline\":false,\"fg\":\"#4cc9f0\"}}}",
) )
inspect( inspect(
stringify_sink_config( stringify_sink_config(
@@ -168,10 +235,97 @@ test "config subtype json helpers stringify stable shapes" {
append=false, append=false,
auto_flush=false, auto_flush=false,
rotation=Some(file_rotation(128, max_backups=2)), 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\",\"color_support\":\"truecolor\",\"style_markup\":\"full\",\"target_style_markup\":\"disabled\",\"fields_style_markup\":\"disabled\"},\"rotation\":{\"max_bytes\":128,\"max_backups\":2}}",
)
}
test "config basic color support downgrades hex colors" {
let formatter = TextFormatterConfig::new(
show_level=false,
show_target=false,
color_mode=ColorMode::Always,
color_support=ColorSupport::Basic,
)
let rendered = format_text(
Record::new(Level::Info, "<#ff0000>hot</> <bg:#010203>bg</>"),
formatter=formatter.to_formatter(),
)
inspect(rendered, content="\u{001b}[31mhot\u{001b}[0m \u{001b}[100mbg\u{001b}[0m")
}
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, "<accent>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 "config builtin style markup ignores custom tags" {
let formatter = TextFormatterConfig::new(
show_level=false,
show_target=false,
color_mode=ColorMode::Always,
style_markup=StyleMarkupMode::Builtin,
style_tags={
"brand": text_style(fg=Some("#4cc9f0"), bold=true),
},
)
let rendered = format_text(
Record::new(Level::Info, "<brand>custom</> <red>builtin</>"),
formatter=formatter.to_formatter(),
)
inspect(rendered, content="<brand>custom</> \u{001b}[31mbuiltin\u{001b}[0m")
}
test "config disabled style markup keeps raw tags" {
let formatter = TextFormatterConfig::new(
show_level=false,
show_target=false,
color_mode=ColorMode::Always,
style_markup=StyleMarkupMode::Disabled,
style_tags={
"accent": text_style(fg=Some("#4cc9f0"), bold=true),
},
)
let rendered = format_text(
Record::new(Level::Info, "<accent>raw</> <red>tag</>"),
formatter=formatter.to_formatter(),
)
inspect(rendered, content="<accent>raw</> <red>tag</>")
}
test "config target and fields markup modes render separately" {
let formatter = TextFormatterConfig::new(
show_level=false,
color_mode=ColorMode::Always,
style_markup=StyleMarkupMode::Disabled,
target_style_markup=StyleMarkupMode::Builtin,
fields_style_markup=StyleMarkupMode::Builtin,
)
let rendered = format_text(
Record::new(
Level::Info,
"<danger>message stays raw</>",
target="<danger>svc</>",
fields=[field("status", "<success>ok</>")],
),
formatter=formatter.to_formatter(),
)
inspect(
rendered,
content="[\u{001b}[34m\u{001b}[31;1msvc\u{001b}[0m\u{001b}[0m] <danger>message stays raw</> \u{001b}[35mstatus=\u{001b}[32;1mok\u{001b}[0m\u{001b}[0m",
) )
} }
+280
View File
@@ -60,6 +60,286 @@ 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 renders named inline color tags in ansi mode" {
let rec = record(Level::Info, "<red>boom</>", target="svc")
inspect(
format_text(rec, formatter=text_formatter(color_mode=ColorMode::Always)),
content="[\u{001b}[32mINFO\u{001b}[0m] [\u{001b}[34msvc\u{001b}[0m] \u{001b}[31mboom\u{001b}[0m",
)
}
test "text formatter strips inline tags in plain mode" {
let rec = record(Level::Info, "<red>boom</> <b>bold</>", target="svc")
inspect(
format_text(rec, formatter=text_formatter(color_mode=ColorMode::Never)),
content="[INFO] [svc] boom bold",
)
}
test "text formatter supports nested inline tags" {
let rec = record(Level::Info, "<red><b>fatal</></>")
inspect(
format_text(rec, formatter=text_formatter(show_level=false, show_target=false, color_mode=ColorMode::Always)),
content="\u{001b}[31;1mfatal\u{001b}[0m",
)
}
test "text formatter supports named closing tags" {
let rec = record(Level::Info, "<red>boom</red>")
inspect(
format_text(rec, formatter=text_formatter(show_level=false, show_target=false, color_mode=ColorMode::Always)),
content="\u{001b}[31mboom\u{001b}[0m",
)
}
test "text formatter supports mixed short and named closing tags" {
let rec = record(Level::Info, "<red><b>fatal</b></red>")
inspect(
format_text(rec, formatter=text_formatter(show_level=false, show_target=false, color_mode=ColorMode::Always)),
content="\u{001b}[31;1mfatal\u{001b}[0m",
)
}
test "text formatter keeps unmatched named closing tags as plain text" {
let rec = record(Level::Info, "boom</red>")
inspect(
format_text(rec, formatter=text_formatter(show_level=false, show_target=false)),
content="boom</red>",
)
}
test "text formatter supports hex inline colors" {
let rec = record(Level::Info, "<#ff0000>hot</> <bg:#010203>bg</>")
inspect(
format_text(rec, formatter=text_formatter(show_level=false, show_target=false, color_mode=ColorMode::Always)),
content="\u{001b}[38;2;255;0;0mhot\u{001b}[0m \u{001b}[48;2;1;2;3mbg\u{001b}[0m",
)
}
test "text formatter can downgrade hex colors to basic ansi" {
let rec = record(Level::Info, "<#ff0000>hot</> <bg:#010203>bg</>")
inspect(
format_text(
rec,
formatter=text_formatter(
show_level=false,
show_target=false,
color_mode=ColorMode::Always,
color_support=ColorSupport::Basic,
),
),
content="\u{001b}[31mhot\u{001b}[0m \u{001b}[100mbg\u{001b}[0m",
)
}
test "text formatter keeps unknown inline tags as plain text" {
let rec = record(Level::Info, "<unknown>boom</>")
inspect(
format_text(rec, formatter=text_formatter(show_level=false, show_target=false)),
content="<unknown>boom</>",
)
}
test "text formatter can disable style markup parsing" {
let rec = record(Level::Info, "<red>boom</> <b>bold</>", target="svc")
inspect(
format_text(
rec,
formatter=text_formatter(color_mode=ColorMode::Always).without_style_markup(),
),
content="[\u{001b}[32mINFO\u{001b}[0m] [\u{001b}[34msvc\u{001b}[0m] <red>boom</> <b>bold</>",
)
}
test "text formatter builtin style markup mode ignores custom tags" {
let formatter = text_formatter(
show_level=false,
show_target=false,
color_mode=ColorMode::Always,
style_markup=StyleMarkupMode::Builtin,
).with_style_tags(
style_tag_registry().set_tag("brand", fg=Some("#4cc9f0"), bold=true),
)
let rec = record(Level::Info, "<brand>custom</> <red>builtin</>")
inspect(
format_text(rec, formatter=formatter),
content="<brand>custom</> \u{001b}[31mbuiltin\u{001b}[0m",
)
}
test "style markup mode label is stable" {
inspect(style_markup_mode_label(StyleMarkupMode::Disabled), content="disabled")
inspect(style_markup_mode_label(StyleMarkupMode::Builtin), content="builtin")
inspect(style_markup_mode_label(StyleMarkupMode::Full), content="full")
}
test "text formatter supports custom style tags" {
let formatter = text_formatter(
show_level=false,
show_target=false,
color_mode=ColorMode::Always,
).with_style_tags(
style_tag_registry().set_tag("accent", fg=Some("#4cc9f0"), bold=true),
)
let rec = record(Level::Info, "<accent>api</>")
inspect(
format_text(rec, formatter=formatter),
content="\u{001b}[38;2;76;201;240;1mapi\u{001b}[0m",
)
}
test "text formatter can override builtin style tags" {
let formatter = text_formatter(
show_level=false,
show_target=false,
color_mode=ColorMode::Always,
).with_style_tags(
default_style_tag_registry().set_tag("red", fg=Some("#ff5a5f"), underline=true),
)
let rec = record(Level::Info, "<red>alert</>")
inspect(
format_text(rec, formatter=formatter),
content="\u{001b}[38;2;255;90;95;4malert\u{001b}[0m",
)
}
test "formatter style tags take priority over global style tags" {
let previous = global_style_tag_registry()
set_global_style_tag_registry(style_tag_registry().set_tag("accent", fg=Some("#123456")))
let formatter = text_formatter(
show_level=false,
show_target=false,
color_mode=ColorMode::Always,
).with_style_tags(
style_tag_registry().set_tag("accent", fg=Some("#abcdef")),
)
let rec = record(Level::Info, "<accent>tag</>")
inspect(
format_text(rec, formatter=formatter),
content="\u{001b}[38;2;171;205;239mtag\u{001b}[0m",
)
set_global_style_tag_registry(previous)
}
test "global style tags apply when formatter has no local registry" {
let previous = global_style_tag_registry()
set_global_style_tag_registry(style_tag_registry().set_tag("accent", fg=Some("#102030"), dim=true))
let rec = record(Level::Info, "<accent>tag</>")
inspect(
format_text(rec, formatter=text_formatter(show_level=false, show_target=false, color_mode=ColorMode::Always)),
content="\u{001b}[38;2;16;32;48;2mtag\u{001b}[0m",
)
set_global_style_tag_registry(previous)
}
test "style tag alias can reuse builtin tags" {
let formatter = text_formatter(
show_level=false,
show_target=false,
color_mode=ColorMode::Always,
).with_style_tags(
style_tag_registry().define_alias("danger", "red"),
)
let rec = record(Level::Info, "<danger>boom</>")
inspect(
format_text(rec, formatter=formatter),
content="\u{001b}[31mboom\u{001b}[0m",
)
}
test "builtin semantic style tags are available" {
let formatter = text_formatter(
show_level=false,
show_target=false,
color_mode=ColorMode::Always,
style_markup=StyleMarkupMode::Builtin,
)
let rec = record(Level::Info, "<success>ok</> <warning>careful</> <danger>boom</> <muted>quiet</>")
inspect(
format_text(rec, formatter=formatter),
content="\u{001b}[32;1mok\u{001b}[0m \u{001b}[33;1mcareful\u{001b}[0m \u{001b}[31;1mboom\u{001b}[0m \u{001b}[90;2mquiet\u{001b}[0m",
)
}
test "builtin semantic style tags can still be overridden" {
let formatter = text_formatter(
show_level=false,
show_target=false,
color_mode=ColorMode::Always,
).with_style_tags(
default_style_tag_registry().set_tag("success", fg=Some("#00ffaa"), underline=true),
)
let rec = record(Level::Info, "<success>ok</>")
inspect(
format_text(rec, formatter=formatter),
content="\u{001b}[38;2;0;255;170;4mok\u{001b}[0m",
)
}
test "text formatter can enable markup for target separately" {
let formatter = text_formatter(
show_level=false,
color_mode=ColorMode::Always,
target_style_markup=StyleMarkupMode::Builtin,
)
let rec = record(Level::Info, "hello", target="<danger>svc</>")
inspect(
format_text(rec, formatter=formatter),
content="[\u{001b}[34m\u{001b}[31;1msvc\u{001b}[0m\u{001b}[0m] hello",
)
}
test "text formatter can enable markup for field values separately" {
let formatter = text_formatter(
show_level=false,
show_target=false,
color_mode=ColorMode::Always,
fields_style_markup=StyleMarkupMode::Builtin,
)
let rec = record(Level::Info, "hello", fields=[field("status", "<success>ok</>")])
inspect(
format_text(rec, formatter=formatter),
content="hello \u{001b}[35mstatus=\u{001b}[32;1mok\u{001b}[0m\u{001b}[0m",
)
}
test "text formatter leaves field keys raw when field markup is enabled" {
let formatter = text_formatter(
show_level=false,
show_target=false,
color_mode=ColorMode::Always,
fields_style_markup=StyleMarkupMode::Builtin,
)
let rec = record(Level::Info, "hello", fields=[field("<danger>status</>", "ok")])
inspect(
format_text(rec, formatter=formatter),
content="hello \u{001b}[35m<danger>status</>=ok\u{001b}[0m",
)
}
test "text formatter template respects disabled fields" { test "text formatter template respects disabled fields" {
let rec = record(Level::Warn, "just message", target="svc") let rec = record(Level::Warn, "just message", target="svc")
let formatter = text_formatter( let formatter = text_formatter(
+63 -1
View File
@@ -42,8 +42,12 @@ BitLogger 是一个使用 MoonBit 编写的结构化日志库.
- 支持 `queued_sink(...)`, `with_queue(...)`, 有界积压与溢出策略 - 支持 `queued_sink(...)`, `with_queue(...)`, 有界积压与溢出策略
- configurable text formatting via `text_formatter(...)`, `format_text(...)`, `text_console_sink(...)`, and template-driven `template` output - configurable text formatting via `text_formatter(...)`, `format_text(...)`, `text_console_sink(...)`, and template-driven `template` output
- 支持 `text_formatter(...)`, `format_text(...)`, `text_console_sink(...)` 以及模板化 `template` 文本输出 - 支持 `text_formatter(...)`, `format_text(...)`, `text_console_sink(...)` 以及模板化 `template` 文本输出
- lightweight style tags via `color_mode`, inline markup, `TextStyle`, `StyleTagRegistry`, custom tags, and builtin-tag overrides
- 支持 `color_mode`, inline markup, `TextStyle`, `StyleTagRegistry`, 自定义标签与内置标签覆盖
- JSON config parsing via `parse_logger_config_text(...)` and `stringify_logger_config(...)` - JSON config parsing via `parse_logger_config_text(...)` and `stringify_logger_config(...)`
- 支持 `parse_logger_config_text(...)`, `stringify_logger_config(...)` 进行最小 JSON 配置读写 - 支持 `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` can also be exported independently through dedicated JSON helpers
- `QueueConfig` / `TextFormatterConfig` / `SinkConfig` 也可分别通过专用 JSON helper 单独导出 - `QueueConfig` / `TextFormatterConfig` / `SinkConfig` 也可分别通过专用 JSON helper 单独导出
- config-driven logger assembly via `build_logger(...)` - config-driven logger assembly via `build_logger(...)`
@@ -180,16 +184,42 @@ test {
show_timestamp=false, show_timestamp=false,
field_separator=",", field_separator=",",
template="[{level}] {target} {message} :: {fields}", template="[{level}] {target} {message} :: {fields}",
color_mode=ColorMode::Always,
) )
let logger = Logger::new(text_console_sink(formatter), target="pretty") let logger = Logger::new(text_console_sink(formatter), target="pretty")
logger.info("hello", fields=[field("mode", "pretty")]) logger.info("hello", fields=[field("mode", "pretty")])
} }
``` ```
```mbt check
test {
let formatter = text_formatter(
show_timestamp=false,
color_mode=ColorMode::Always,
).with_style_tags(
default_style_tag_registry()
.set_tag("accent", fg=Some("#4cc9f0"), bold=true)
.define_alias("danger", "red"),
)
let logger = Logger::new(text_console_sink(formatter), target="styled")
logger.info("<accent>styled</> output and <danger>alert</>")
}
```
```mbt check
test {
let formatter = text_formatter(
color_mode=ColorMode::Always,
).without_style_markup()
let logger = Logger::new(text_console_sink(formatter), target="raw")
logger.info("<red>kept as raw text</>")
}
```
```mbt check ```mbt check
test { test {
let config = parse_logger_config_text( 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) let logger = build_logger(config)
logger.info("configured from json") logger.info("configured from json")
@@ -197,9 +227,41 @@ 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("<accent>styled from json</>")
}
```
```mbt check
test {
let config = parse_logger_config_text(
"{\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"color_mode\":\"always\",\"style_markup\":\"disabled\"}}}",
)
let logger = build_logger(config)
logger.info("<red>still raw</>")
}
```
## Formatter Template / 模板格式 ## Formatter Template / 模板格式
- supported tokens / 支持的 token: `{timestamp}`, `{timestamp_ms}`, `{level}`, `{target}`, `{message}`, `{fields}` - supported tokens / 支持的 token: `{timestamp}`, `{timestamp_ms}`, `{level}`, `{target}`, `{message}`, `{fields}`
- `color_mode` / `color_mode`: `never`, `auto`, `always`
- `color_support` / `color_support`: `basic`, `truecolor`
- `style_markup` / `style_markup`: `disabled`, `builtin`, `full`
- `target_style_markup` / `target_style_markup`, `fields_style_markup` / `fields_style_markup`: `disabled`, `builtin`, `full`
- inline style tags / inline 样式标签: `<red>...</>`, `<b>...</>`, `<#ff0000>...</>`, `<bg:#202020>...</>`
- closing tags / 闭合标签: `</>` 以及具名闭合 `</red>`, `</danger>`, `</b>`
- builtin semantic tags / 内置语义标签: `<accent>`, `<info>`, `<success>`, `<warning>`, `<danger>`, `<muted>`
- 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
- `sink.text_formatter.style_tags` / `sink.text_formatter.style_tags` 现支持最小对象映射: `fg`, `bg`, `bold`, `dim`, `italic`, `underline`
- `fields_style_markup` currently affects values only / `fields_style_markup` 当前仅影响 field value, 不影响 field key
- `define_alias(...)` is still runtime-only / `define_alias(...)` 目前仍为运行期 API
- disabled or missing parts render as empty text / 被关闭或缺失的部分会渲染为空字符串 - disabled or missing parts render as empty text / 被关闭或缺失的部分会渲染为空字符串
- `template` is intentionally a simple token replacement layer, not a full DSL / `template` 使用轻量 token 替换方式, 不是完整 DSL - `template` is intentionally a simple token replacement layer, not a full DSL / `template` 使用轻量 token 替换方式, 不是完整 DSL
+145 -2
View File
@@ -17,6 +17,12 @@ pub struct TextFormatterConfig {
separator : String separator : String
field_separator : String field_separator : String
template : String template : String
color_mode : ColorMode
color_support : ColorSupport
style_markup : StyleMarkupMode
target_style_markup : StyleMarkupMode
fields_style_markup : StyleMarkupMode
style_tags : Map[String, TextStyle]
} }
pub fn TextFormatterConfig::new( pub fn TextFormatterConfig::new(
@@ -27,6 +33,12 @@ pub fn TextFormatterConfig::new(
separator~ : String = " ", separator~ : String = " ",
field_separator~ : String = " ", field_separator~ : String = " ",
template~ : String = "", template~ : String = "",
color_mode~ : ColorMode = ColorMode::Never,
color_support~ : ColorSupport = ColorSupport::TrueColor,
style_markup~ : StyleMarkupMode = StyleMarkupMode::Full,
target_style_markup~ : StyleMarkupMode = StyleMarkupMode::Disabled,
fields_style_markup~ : StyleMarkupMode = StyleMarkupMode::Disabled,
style_tags~ : Map[String, TextStyle] = {},
) -> TextFormatterConfig { ) -> TextFormatterConfig {
{ {
show_timestamp, show_timestamp,
@@ -36,9 +48,23 @@ pub fn TextFormatterConfig::new(
separator, separator,
field_separator, field_separator,
template, template,
color_mode,
color_support,
style_markup,
target_style_markup,
fields_style_markup,
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 { pub fn TextFormatterConfig::to_formatter(self : TextFormatterConfig) -> TextFormatter {
text_formatter( text_formatter(
show_timestamp=self.show_timestamp, show_timestamp=self.show_timestamp,
@@ -48,6 +74,16 @@ pub fn TextFormatterConfig::to_formatter(self : TextFormatterConfig) -> TextForm
separator=self.separator, separator=self.separator,
field_separator=self.field_separator, field_separator=self.field_separator,
template=self.template, template=self.template,
color_mode=self.color_mode,
color_support=self.color_support,
style_markup=self.style_markup,
target_style_markup=self.target_style_markup,
fields_style_markup=self.fields_style_markup,
style_tags=if self.style_tags.length() == 0 {
None
} else {
Some(style_tag_registry_from_config(self.style_tags))
},
) )
} }
@@ -755,6 +791,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( fn get_bool(
obj : Map[String, @json_parser.JsonValue], obj : Map[String, @json_parser.JsonValue],
key : String, key : String,
@@ -815,6 +864,32 @@ 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 parse_color_support(name : String) -> ColorSupport raise ConfigError {
match name.to_upper() {
"BASIC" => ColorSupport::Basic
"TRUECOLOR" => ColorSupport::TrueColor
_ => raise ConfigError::InvalidConfig("Unsupported color support: " + name)
}
}
fn parse_style_markup_mode(name : String) -> StyleMarkupMode raise ConfigError {
match name.to_upper() {
"DISABLED" => StyleMarkupMode::Disabled
"BUILTIN" => StyleMarkupMode::Builtin
"FULL" => StyleMarkupMode::Full
_ => raise ConfigError::InvalidConfig("Unsupported style markup mode: " + name)
}
}
fn sink_kind_label(kind : SinkKind) -> String { fn sink_kind_label(kind : SinkKind) -> String {
match kind { match kind {
SinkKind::Console => "console" SinkKind::Console => "console"
@@ -834,9 +909,42 @@ fn parse_text_formatter_config(value : @json_parser.JsonValue) -> TextFormatterC
separator=get_string(obj, "separator", default=" "), separator=get_string(obj, "separator", default=" "),
field_separator=get_string(obj, "field_separator", default=" "), field_separator=get_string(obj, "field_separator", default=" "),
template=get_string(obj, "template", default=""), template=get_string(obj, "template", default=""),
color_mode=parse_color_mode(get_string(obj, "color_mode", default="never")),
color_support=parse_color_support(get_string(obj, "color_support", default="truecolor")),
style_markup=parse_style_markup_mode(get_string(obj, "style_markup", default="full")),
target_style_markup=parse_style_markup_mode(get_string(obj, "target_style_markup", default="disabled")),
fields_style_markup=parse_style_markup_mode(get_string(obj, "fields_style_markup", default="disabled")),
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 { fn parse_queue_config(value : @json_parser.JsonValue) -> QueueConfig raise ConfigError {
let obj = expect_object(value, "queue") let obj = expect_object(value, "queue")
QueueConfig::new( QueueConfig::new(
@@ -920,7 +1028,7 @@ pub fn stringify_queue_config(queue : QueueConfig, pretty~ : Bool = false) -> St
} }
pub fn text_formatter_config_to_json(config : TextFormatterConfig) -> @json_parser.JsonValue { 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_timestamp": @json_parser.JsonValue::Bool(config.show_timestamp),
"show_level": @json_parser.JsonValue::Bool(config.show_level), "show_level": @json_parser.JsonValue::Bool(config.show_level),
"show_target": @json_parser.JsonValue::Bool(config.show_target), "show_target": @json_parser.JsonValue::Bool(config.show_target),
@@ -928,7 +1036,42 @@ pub fn text_formatter_config_to_json(config : TextFormatterConfig) -> @json_pars
"separator": @json_parser.JsonValue::String(config.separator), "separator": @json_parser.JsonValue::String(config.separator),
"field_separator": @json_parser.JsonValue::String(config.field_separator), "field_separator": @json_parser.JsonValue::String(config.field_separator),
"template": @json_parser.JsonValue::String(config.template), "template": @json_parser.JsonValue::String(config.template),
}) "color_mode": @json_parser.JsonValue::String(color_mode_label(config.color_mode)),
"color_support": @json_parser.JsonValue::String(color_support_label(config.color_support)),
"style_markup": @json_parser.JsonValue::String(style_markup_mode_label(config.style_markup)),
"target_style_markup": @json_parser.JsonValue::String(style_markup_mode_label(config.target_style_markup)),
"fields_style_markup": @json_parser.JsonValue::String(style_markup_mode_label(config.fields_style_markup)),
}
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( pub fn stringify_text_formatter_config(
+791 -14
View File
@@ -1,5 +1,185 @@
pub type RecordFormatter = (Record) -> String pub type RecordFormatter = (Record) -> String
pub(all) enum ColorMode {
Never
Auto
Always
}
pub(all) enum ColorSupport {
Basic
TrueColor
}
pub(all) enum StyleMarkupMode {
Disabled
Builtin
Full
}
pub struct TextStyle {
fg : String?
bg : String?
bold : Bool
dim : Bool
italic : Bool
underline : Bool
}
pub fn text_style(
fg~ : String? = None,
bg~ : String? = None,
bold~ : Bool = false,
dim~ : Bool = false,
italic~ : Bool = false,
underline~ : Bool = false,
) -> TextStyle {
{ fg, bg, bold, dim, italic, underline }
}
pub struct StyleTagRegistry {
entries : Map[String, TextStyle]
}
fn normalize_style_tag_name(name : String) -> String {
name.trim().to_lower().to_owned()
}
pub fn style_tag_registry() -> StyleTagRegistry {
{ entries: {} }
}
fn merge_text_style(base : TextStyle, overlay : TextStyle) -> TextStyle {
{
fg: match overlay.fg {
Some(_) => overlay.fg
None => base.fg
},
bg: match overlay.bg {
Some(_) => overlay.bg
None => base.bg
},
bold: base.bold || overlay.bold,
dim: base.dim || overlay.dim,
italic: base.italic || overlay.italic,
underline: base.underline || overlay.underline,
}
}
pub fn StyleTagRegistry::set_tag(
self : StyleTagRegistry,
name : String,
style~ : TextStyle = text_style(),
fg~ : String? = None,
bg~ : String? = None,
bold~ : Bool = false,
dim~ : Bool = false,
italic~ : Bool = false,
underline~ : Bool = false,
) -> StyleTagRegistry {
let overlay = text_style(fg=fg, bg=bg, bold=bold, dim=dim, italic=italic, underline=underline)
self.entries[normalize_style_tag_name(name)] = merge_text_style(style, overlay)
self
}
pub fn StyleTagRegistry::get(self : StyleTagRegistry, name : String) -> TextStyle? {
self.entries.get(normalize_style_tag_name(name))
}
pub fn StyleTagRegistry::contains(self : StyleTagRegistry, name : String) -> Bool {
self.entries.contains(normalize_style_tag_name(name))
}
pub fn StyleTagRegistry::define_alias(
self : StyleTagRegistry,
name : String,
target : String,
) -> StyleTagRegistry {
match self.get(target) {
Some(style) => self.set_tag(name, style=style)
None => match global_style_tag_registry().get(target) {
Some(style) => self.set_tag(name, style=style)
None => match builtin_text_style_for_tag(normalize_style_tag_name(target)) {
Some(style) => self.set_tag(name, style=style)
None => self
}
}
}
}
pub fn default_style_tag_registry() -> StyleTagRegistry {
style_tag_registry()
.set_tag("black", fg=Some("black"))
.set_tag("red", fg=Some("red"))
.set_tag("green", fg=Some("green"))
.set_tag("yellow", fg=Some("yellow"))
.set_tag("blue", fg=Some("blue"))
.set_tag("magenta", fg=Some("magenta"))
.set_tag("cyan", fg=Some("cyan"))
.set_tag("white", fg=Some("white"))
.set_tag("bright_black", fg=Some("bright_black"))
.set_tag("bright_red", fg=Some("bright_red"))
.set_tag("bright_green", fg=Some("bright_green"))
.set_tag("bright_yellow", fg=Some("bright_yellow"))
.set_tag("bright_blue", fg=Some("bright_blue"))
.set_tag("bright_magenta", fg=Some("bright_magenta"))
.set_tag("bright_cyan", fg=Some("bright_cyan"))
.set_tag("bright_white", fg=Some("bright_white"))
.set_tag("accent", fg=Some("bright_cyan"), bold=true)
.set_tag("info", fg=Some("cyan"))
.set_tag("success", fg=Some("green"), bold=true)
.set_tag("warning", fg=Some("yellow"), bold=true)
.set_tag("danger", fg=Some("red"), bold=true)
.set_tag("muted", fg=Some("bright_black"), dim=true)
.set_tag("b", bold=true)
.set_tag("dim", dim=true)
.set_tag("i", italic=true)
.set_tag("u", underline=true)
}
let global_style_tag_registry_ref : Ref[StyleTagRegistry] = Ref::new(style_tag_registry())
pub fn global_style_tag_registry() -> StyleTagRegistry {
global_style_tag_registry_ref.val
}
pub fn set_global_style_tag_registry(registry : StyleTagRegistry) -> Unit {
global_style_tag_registry_ref.val = registry
}
pub fn reset_global_style_tag_registry() -> Unit {
global_style_tag_registry_ref.val = style_tag_registry()
}
priv struct InlineStyle {
fg_code : String?
bg_code : String?
fg_basic_name : String?
bg_basic_name : String?
bold : Bool
dim : Bool
italic : Bool
underline : Bool
}
priv struct StyledSegment {
text : String
style : InlineStyle
}
priv struct StyleFrame {
tag : String
style : InlineStyle
}
fn code_unit(ch : Char) -> Int {
ch.to_int()
}
fn code_unit16(ch : UInt16) -> Int {
ch.to_int()
}
pub struct TextFormatter { pub struct TextFormatter {
show_timestamp : Bool show_timestamp : Bool
show_level : Bool show_level : Bool
@@ -8,6 +188,12 @@ pub struct TextFormatter {
separator : String separator : String
field_separator : String field_separator : String
template : String template : String
color_mode : ColorMode
color_support : ColorSupport
style_markup : StyleMarkupMode
target_style_markup : StyleMarkupMode
fields_style_markup : StyleMarkupMode
style_tags : StyleTagRegistry?
} }
pub fn text_formatter( pub fn text_formatter(
@@ -18,6 +204,12 @@ pub fn text_formatter(
separator~ : String = " ", separator~ : String = " ",
field_separator~ : String = " ", field_separator~ : String = " ",
template~ : String = "", template~ : String = "",
color_mode~ : ColorMode = ColorMode::Never,
color_support~ : ColorSupport = ColorSupport::TrueColor,
style_markup~ : StyleMarkupMode = StyleMarkupMode::Full,
target_style_markup~ : StyleMarkupMode = StyleMarkupMode::Disabled,
fields_style_markup~ : StyleMarkupMode = StyleMarkupMode::Disabled,
style_tags~ : StyleTagRegistry? = None,
) -> TextFormatter { ) -> TextFormatter {
{ {
show_timestamp, show_timestamp,
@@ -27,6 +219,588 @@ pub fn text_formatter(
separator, separator,
field_separator, field_separator,
template, template,
color_mode,
color_support,
style_markup,
target_style_markup,
fields_style_markup,
style_tags,
}
}
pub fn color_support_label(support : ColorSupport) -> String {
match support {
ColorSupport::Basic => "basic"
ColorSupport::TrueColor => "truecolor"
}
}
pub fn TextFormatter::with_color_support(self : TextFormatter, color_support : ColorSupport) -> TextFormatter {
{ ..self, color_support }
}
pub fn style_markup_mode_label(mode : StyleMarkupMode) -> String {
match mode {
StyleMarkupMode::Disabled => "disabled"
StyleMarkupMode::Builtin => "builtin"
StyleMarkupMode::Full => "full"
}
}
pub fn TextFormatter::with_style_markup(self : TextFormatter, style_markup : StyleMarkupMode) -> TextFormatter {
{ ..self, style_markup }
}
pub fn TextFormatter::without_style_markup(self : TextFormatter) -> TextFormatter {
{ ..self, style_markup: StyleMarkupMode::Disabled }
}
pub fn TextFormatter::with_target_style_markup(
self : TextFormatter,
style_markup : StyleMarkupMode,
) -> TextFormatter {
{ ..self, target_style_markup: style_markup }
}
pub fn TextFormatter::with_fields_style_markup(
self : TextFormatter,
style_markup : StyleMarkupMode,
) -> TextFormatter {
{ ..self, fields_style_markup: style_markup }
}
pub fn TextFormatter::with_style_tags(self : TextFormatter, style_tags : StyleTagRegistry) -> TextFormatter {
{ ..self, style_tags: Some(style_tags) }
}
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 ansi_wrap_with_style(text : String, style : InlineStyle, enabled : Bool) -> String {
if !enabled || text == "" {
text
} else {
let codes : Array[String] = []
match style.fg_code {
Some(code) => codes.push(code)
None => ()
}
match style.bg_code {
Some(code) => codes.push(code)
None => ()
}
if style.bold {
codes.push("1")
}
if style.dim {
codes.push("2")
}
if style.italic {
codes.push("3")
}
if style.underline {
codes.push("4")
}
if codes.length() == 0 {
text
} else {
let joined = codes.join(";")
"\u{001b}[\{joined}m\{text}\u{001b}[0m"
}
}
}
fn default_inline_style() -> InlineStyle {
{
fg_code: None,
bg_code: None,
fg_basic_name: None,
bg_basic_name: None,
bold: false,
dim: false,
italic: false,
underline: false,
}
}
fn named_color_code(tag : String) -> String? {
match tag {
"black" => Some("30")
"red" => Some("31")
"green" => Some("32")
"yellow" => Some("33")
"blue" => Some("34")
"magenta" => Some("35")
"cyan" => Some("36")
"white" => Some("37")
"bright_black" => Some("90")
"bright_red" => Some("91")
"bright_green" => Some("92")
"bright_yellow" => Some("93")
"bright_blue" => Some("94")
"bright_magenta" => Some("95")
"bright_cyan" => Some("96")
"bright_white" => Some("97")
_ => None
}
}
fn named_bg_color_code(tag : String) -> String? {
match tag {
"black" => Some("40")
"red" => Some("41")
"green" => Some("42")
"yellow" => Some("43")
"blue" => Some("44")
"magenta" => Some("45")
"cyan" => Some("46")
"white" => Some("47")
"bright_black" => Some("100")
"bright_red" => Some("101")
"bright_green" => Some("102")
"bright_yellow" => Some("103")
"bright_blue" => Some("104")
"bright_magenta" => Some("105")
"bright_cyan" => Some("106")
"bright_white" => Some("107")
_ => None
}
}
fn basic_name_from_hex(value : String) -> String {
let r = hex_pair_to_int(value.unsafe_get(1), value.unsafe_get(2))
let g = hex_pair_to_int(value.unsafe_get(3), value.unsafe_get(4))
let b = hex_pair_to_int(value.unsafe_get(5), value.unsafe_get(6))
let max_value = if r >= g && r >= b { r } else if g >= b { g } else { b }
if max_value < 48 {
"bright_black"
} else {
let min_value = if r <= g && r <= b { r } else if g <= b { g } else { b }
let spread = max_value - min_value
if spread < 24 {
if max_value > 170 {
"white"
} else if max_value > 96 {
"bright_black"
} else {
"black"
}
} else if r >= g && r >= b {
if g > 96 && b < 96 {
"yellow"
} else if b > 96 && g < 96 {
"magenta"
} else {
"red"
}
} else if g >= r && g >= b {
if r > 96 && b < 96 {
"yellow"
} else if b > 96 && r < 96 {
"cyan"
} else {
"green"
}
} else {
if r > 96 && g < 96 {
"magenta"
} else if g > 96 && r < 96 {
"cyan"
} else {
"blue"
}
}
}
}
fn resolve_inline_color_code(
formatter : TextFormatter,
basic_name : String?,
truecolor_code : String,
) -> String {
match formatter.color_support {
ColorSupport::TrueColor => truecolor_code
ColorSupport::Basic => match basic_name {
Some(name) => named_code_from_basic_name(name).unwrap_or(truecolor_code)
None => truecolor_code
}
}
}
fn named_code_from_basic_name(name : String) -> String? {
named_color_code(name)
}
fn named_bg_code_from_basic_name(name : String) -> String? {
named_bg_color_code(name)
}
fn resolve_inline_bg_code(
formatter : TextFormatter,
basic_name : String?,
truecolor_code : String,
) -> String {
match formatter.color_support {
ColorSupport::TrueColor => truecolor_code
ColorSupport::Basic => match basic_name {
Some(name) => named_bg_code_from_basic_name(name).unwrap_or(truecolor_code)
None => truecolor_code
}
}
}
fn is_hex_color(value : String) -> Bool {
if value.length() != 7 {
return false
}
if value.unsafe_get(0) != '#' {
return false
}
for i = 1; i < value.length(); i = i + 1 {
let ch = value.unsafe_get(i)
let is_digit = ch >= '0' && ch <= '9'
let is_lower = ch >= 'a' && ch <= 'f'
let is_upper = ch >= 'A' && ch <= 'F'
if !(is_digit || is_lower || is_upper) {
return false
}
}
true
}
fn hex_to_int(ch : UInt16) -> Int {
let code = code_unit16(ch)
if code >= code_unit('0') && code <= code_unit('9') {
code_unit16(ch) - code_unit('0')
} else if code >= code_unit('a') && code <= code_unit('f') {
code_unit16(ch) - code_unit('a') + 10
} else {
code_unit16(ch) - code_unit('A') + 10
}
}
fn hex_pair_to_int(high : UInt16, low : UInt16) -> Int {
hex_to_int(high) * 16 + hex_to_int(low)
}
fn rgb_fg_code(value : String) -> String {
let r = hex_pair_to_int(value.unsafe_get(1), value.unsafe_get(2))
let g = hex_pair_to_int(value.unsafe_get(3), value.unsafe_get(4))
let b = hex_pair_to_int(value.unsafe_get(5), value.unsafe_get(6))
"38;2;\{r};\{g};\{b}"
}
fn rgb_bg_code(value : String) -> String {
let r = hex_pair_to_int(value.unsafe_get(4), value.unsafe_get(5))
let g = hex_pair_to_int(value.unsafe_get(6), value.unsafe_get(7))
let b = hex_pair_to_int(value.unsafe_get(8), value.unsafe_get(9))
"48;2;\{r};\{g};\{b}"
}
fn rgb_bg_code_from_hex(value : String) -> String {
let r = hex_pair_to_int(value.unsafe_get(1), value.unsafe_get(2))
let g = hex_pair_to_int(value.unsafe_get(3), value.unsafe_get(4))
let b = hex_pair_to_int(value.unsafe_get(5), value.unsafe_get(6))
"48;2;\{r};\{g};\{b}"
}
fn inline_style_from_text_style(
base : InlineStyle,
style : TextStyle,
formatter : TextFormatter,
) -> InlineStyle? {
let fg = match style.fg {
None => Some((base.fg_code, base.fg_basic_name))
Some(value) => {
let normalized = normalize_style_tag_name(value)
match named_color_code(normalized) {
Some(code) => Some((Some(code), Some(normalized)))
None => if is_hex_color(value) {
let basic_name = basic_name_from_hex(value)
Some((
Some(resolve_inline_color_code(formatter, Some(basic_name), rgb_fg_code(value))),
Some(basic_name),
))
} else {
None
}
}
}
}
let bg = match style.bg {
None => Some((base.bg_code, base.bg_basic_name))
Some(value) => {
let normalized = normalize_style_tag_name(value)
match named_bg_color_code(normalized) {
Some(code) => {
let bg_name = if normalized.has_prefix("bright_") {
normalized
} else {
normalized
}
Some((Some(code), Some(bg_name)))
}
None => if is_hex_color(value) {
let basic_name = basic_name_from_hex(value)
Some((
Some(resolve_inline_bg_code(formatter, Some(basic_name), rgb_bg_code_from_hex(value))),
Some(basic_name),
))
} else {
None
}
}
}
}
match fg {
Some((next_fg_code, next_fg_name)) => match bg {
Some((next_bg_code, next_bg_name)) => Some({
fg_code: next_fg_code,
bg_code: next_bg_code,
fg_basic_name: next_fg_name,
bg_basic_name: next_bg_name,
bold: base.bold || style.bold,
dim: base.dim || style.dim,
italic: base.italic || style.italic,
underline: base.underline || style.underline,
})
None => None
}
None => None
}
}
fn builtin_text_style_for_tag(tag : String) -> TextStyle? {
match normalize_style_tag_name(tag) {
"black" => Some(text_style(fg=Some("black")))
"red" => Some(text_style(fg=Some("red")))
"green" => Some(text_style(fg=Some("green")))
"yellow" => Some(text_style(fg=Some("yellow")))
"blue" => Some(text_style(fg=Some("blue")))
"magenta" => Some(text_style(fg=Some("magenta")))
"cyan" => Some(text_style(fg=Some("cyan")))
"white" => Some(text_style(fg=Some("white")))
"bright_black" => Some(text_style(fg=Some("bright_black")))
"bright_red" => Some(text_style(fg=Some("bright_red")))
"bright_green" => Some(text_style(fg=Some("bright_green")))
"bright_yellow" => Some(text_style(fg=Some("bright_yellow")))
"bright_blue" => Some(text_style(fg=Some("bright_blue")))
"bright_magenta" => Some(text_style(fg=Some("bright_magenta")))
"bright_cyan" => Some(text_style(fg=Some("bright_cyan")))
"bright_white" => Some(text_style(fg=Some("bright_white")))
"accent" => Some(text_style(fg=Some("bright_cyan"), bold=true))
"info" => Some(text_style(fg=Some("cyan")))
"success" => Some(text_style(fg=Some("green"), bold=true))
"warning" => Some(text_style(fg=Some("yellow"), bold=true))
"danger" => Some(text_style(fg=Some("red"), bold=true))
"muted" => Some(text_style(fg=Some("bright_black"), dim=true))
"b" => Some(text_style(bold=true))
"dim" => Some(text_style(dim=true))
"i" => Some(text_style(italic=true))
"u" => Some(text_style(underline=true))
_ => None
}
}
fn resolve_registered_text_style(tag : String, formatter : TextFormatter) -> TextStyle? {
let normalized = normalize_style_tag_name(tag)
match formatter.style_markup {
StyleMarkupMode::Builtin => return builtin_text_style_for_tag(normalized)
_ => ()
}
match formatter.style_tags {
Some(registry) => match registry.get(normalized) {
Some(style) => Some(style)
None => match global_style_tag_registry().get(normalized) {
Some(style) => Some(style)
None => builtin_text_style_for_tag(normalized)
}
}
None => match global_style_tag_registry().get(normalized) {
Some(style) => Some(style)
None => builtin_text_style_for_tag(normalized)
}
}
}
fn apply_inline_tag(style : InlineStyle, tag : String, formatter : TextFormatter) -> InlineStyle? {
let normalized = normalize_style_tag_name(tag)
if is_hex_color(tag) {
let basic_name = basic_name_from_hex(tag)
Some({
..style,
fg_code: Some(resolve_inline_color_code(formatter, Some(basic_name), rgb_fg_code(tag))),
fg_basic_name: Some(basic_name),
})
} else if normalized.length() == 10 && normalized.has_prefix("bg:") && is_hex_color(normalized[3:].to_owned()) {
let basic_name = basic_name_from_hex(normalized[3:].to_owned())
Some({
..style,
bg_code: Some(resolve_inline_bg_code(formatter, Some(basic_name), rgb_bg_code(normalized))),
bg_basic_name: Some(basic_name),
})
} else {
match resolve_registered_text_style(normalized, formatter) {
Some(spec) => inline_style_from_text_style(style, spec, formatter)
None => None
}
}
}
fn push_plain_segment(
segments : Array[StyledSegment],
buffer : StringBuilder,
style : InlineStyle,
) -> Unit {
let text = buffer.to_string()
if text != "" {
segments.push({ text, style })
buffer.reset()
}
}
fn pop_named_style_frame(stack : Array[StyleFrame], tag : String) -> Bool {
let normalized = normalize_style_tag_name(tag)
if stack.length() <= 1 {
return false
}
for i = stack.length() - 1; i > 0; i = i - 1 {
if stack[i].tag == normalized {
while stack.length() - 1 >= i {
ignore(stack.pop())
}
return true
}
}
false
}
fn has_named_style_frame(stack : Array[StyleFrame], tag : String) -> Bool {
let normalized = normalize_style_tag_name(tag)
if stack.length() <= 1 {
return false
}
for i = stack.length() - 1; i > 0; i = i - 1 {
if stack[i].tag == normalized {
return true
}
}
false
}
fn parse_inline_markup(input : String, formatter : TextFormatter) -> Array[StyledSegment] {
let segments : Array[StyledSegment] = []
let buffer = StringBuilder::new()
let stack : Array[StyleFrame] = [{ tag: "", style: default_inline_style() }]
let chars = input.to_array()
let current_style = fn() { stack[stack.length() - 1].style }
let flush = fn() { push_plain_segment(segments, buffer, current_style()) }
let append_raw = fn(start : Int, finish : Int) {
for i = start; i < finish; i = i + 1 {
buffer.write_char(chars.unsafe_get(i))
}
}
let find_tag_end = fn(start : Int) -> Int {
for i = start; i < chars.length(); i = i + 1 {
if chars.unsafe_get(i) == '>' {
return i
}
}
-1
}
for i = 0; i < chars.length(); {
if chars.unsafe_get(i) != '<' {
buffer.write_char(chars.unsafe_get(i))
continue i + 1
}
let end = find_tag_end(i + 1)
if end == -1 {
buffer.write_char(chars[i])
continue i + 1
}
let tag = input[i + 1:end].to_owned()
if tag == "/" {
if stack.length() > 1 {
flush()
ignore(stack.pop())
} else {
append_raw(i, end + 1)
}
continue end + 1
}
if tag.has_prefix("/") {
let close_tag = tag[1:].to_owned()
if close_tag != "" && has_named_style_frame(stack, close_tag) {
flush()
ignore(pop_named_style_frame(stack, close_tag))
} else {
append_raw(i, end + 1)
}
continue end + 1
}
match apply_inline_tag(current_style(), tag, formatter) {
Some(next_style) => {
flush()
stack.push({ tag: normalize_style_tag_name(tag), style: next_style })
}
None => append_raw(i, end + 1)
}
continue end + 1
}
flush()
segments
}
fn render_styled_text(text : String, formatter : TextFormatter, mode : StyleMarkupMode) -> String {
match mode {
StyleMarkupMode::Disabled => return text
_ => ()
}
let enabled = use_ansi_color(formatter.color_mode)
let scoped = { ..formatter, style_markup: mode }
let segments = parse_inline_markup(text, scoped)
let out = StringBuilder::new()
for segment in segments {
out.write_string(ansi_wrap_with_style(segment.text, segment.style, enabled))
}
out.to_string()
}
fn render_inline_markup(message : String, formatter : TextFormatter) -> String {
render_styled_text(message, formatter, formatter.style_markup)
}
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"
} }
} }
@@ -38,13 +812,9 @@ fn fields_to_json(fields : Array[Field]) -> Json {
Json::object(obj) Json::object(obj)
} }
fn format_fields(fields : Array[Field], separator : String) -> String {
fields.map(fn(f) { "\{f.key}=\{f.value}" }).join(separator)
}
fn timestamp_text(rec : Record, formatter : TextFormatter) -> String { fn timestamp_text(rec : Record, formatter : TextFormatter) -> String {
if formatter.show_timestamp && rec.timestamp_ms != 0UL { 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 { } else {
"" ""
} }
@@ -52,7 +822,7 @@ fn timestamp_text(rec : Record, formatter : TextFormatter) -> String {
fn level_text(rec : Record, formatter : TextFormatter) -> String { fn level_text(rec : Record, formatter : TextFormatter) -> String {
if formatter.show_level { if formatter.show_level {
rec.level.label() ansi_wrap(rec.level.label(), level_ansi_code(rec.level), use_ansi_color(formatter.color_mode))
} else { } else {
"" ""
} }
@@ -60,15 +830,22 @@ fn level_text(rec : Record, formatter : TextFormatter) -> String {
fn target_text(rec : Record, formatter : TextFormatter) -> String { fn target_text(rec : Record, formatter : TextFormatter) -> String {
if formatter.show_target && rec.target != "" { if formatter.show_target && rec.target != "" {
rec.target let rendered = render_styled_text(rec.target, formatter, formatter.target_style_markup)
ansi_wrap(rendered, "34", use_ansi_color(formatter.color_mode))
} else { } else {
"" ""
} }
} }
fn format_field_text(field : Field, formatter : TextFormatter) -> String {
let value = render_styled_text(field.value, formatter, formatter.fields_style_markup)
"\{field.key}=\{value}"
}
fn fields_text(rec : Record, formatter : TextFormatter) -> String { fn fields_text(rec : Record, formatter : TextFormatter) -> String {
if formatter.show_fields && rec.fields.length() != 0 { if formatter.show_fields && rec.fields.length() != 0 {
format_fields(rec.fields, formatter.field_separator) let content = rec.fields.map(fn(field) { format_field_text(field, formatter) }).join(formatter.field_separator)
ansi_wrap(content, "35", use_ansi_color(formatter.color_mode))
} else { } else {
"" ""
} }
@@ -80,7 +857,7 @@ fn render_template(rec : Record, formatter : TextFormatter) -> String {
.replace_all(old="{timestamp_ms}", new=timestamp_text(rec, formatter)) .replace_all(old="{timestamp_ms}", new=timestamp_text(rec, formatter))
.replace_all(old="{level}", new=level_text(rec, formatter)) .replace_all(old="{level}", new=level_text(rec, formatter))
.replace_all(old="{target}", new=target_text(rec, formatter)) .replace_all(old="{target}", new=target_text(rec, formatter))
.replace_all(old="{message}", new=rec.message) .replace_all(old="{message}", new=render_inline_markup(rec.message, formatter))
.replace_all(old="{fields}", new=fields_text(rec, formatter)) .replace_all(old="{fields}", new=fields_text(rec, formatter))
.trim() .trim()
.to_owned() .to_owned()
@@ -92,20 +869,20 @@ pub fn format_text(rec : Record, formatter~ : TextFormatter = text_formatter())
} }
let parts : Array[String] = [] let parts : Array[String] = []
if formatter.show_timestamp && rec.timestamp_ms != 0UL { 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 { if formatter.show_level {
parts.push("[\{rec.level.label()}]") parts.push("[\{level_text(rec, formatter)}]")
} }
if formatter.show_target && rec.target != "" { if formatter.show_target && rec.target != "" {
parts.push("[\{rec.target}]") parts.push("[\{target_text(rec, formatter)}]")
} }
parts.push(rec.message) parts.push(render_inline_markup(rec.message, formatter))
let base = parts.join(formatter.separator) let base = parts.join(formatter.separator)
if !formatter.show_fields || rec.fields.length() == 0 { if !formatter.show_fields || rec.fields.length() == 0 {
base base
} else { } else {
let details = format_fields(rec.fields, formatter.field_separator) let details = fields_text(rec, formatter)
"\{base}\{formatter.separator}\{details}" "\{base}\{formatter.separator}\{details}"
} }
} }
+73 -1
View File
@@ -25,6 +25,7 @@ BitLogger currently provides:
- explicit queued delivery via `queued_sink(...)` and `with_queue(...)` - explicit queued delivery via `queued_sink(...)` and `with_queue(...)`
- bounded backlog with `QueueOverflowPolicy::DropNewest` and `QueueOverflowPolicy::DropOldest` - bounded backlog with `QueueOverflowPolicy::DropNewest` and `QueueOverflowPolicy::DropOldest`
- configurable text formatting via `text_formatter(...)`, `format_text(...)`, `text_console_sink(...)`, and template-driven `template` output - configurable text formatting via `text_formatter(...)`, `format_text(...)`, `text_console_sink(...)`, and template-driven `template` output
- lightweight style tags via `color_mode`, inline markup, `TextStyle`, `StyleTagRegistry`, custom tags, and builtin-tag overrides
- formatter-based callback integration via `formatted_callback_sink(...)` - formatter-based callback integration via `formatted_callback_sink(...)`
- native-only file output via `file_sink(...)`, with basic size rotation, backup retention, explicit `reopen()` / `reopen_with_current_policy()` / `reopen_append()` / `reopen_truncate()`, and failure counters - native-only file output via `file_sink(...)`, with basic size rotation, backup retention, explicit `reopen()` / `reopen_with_current_policy()` / `reopen_append()` / `reopen_truncate()`, and failure counters
- `native_files_supported()` for backend capability detection - `native_files_supported()` for backend capability detection
@@ -158,17 +159,47 @@ let formatter = text_formatter(
show_timestamp=false, show_timestamp=false,
field_separator=",", field_separator=",",
template="[{level}] {target} {message} :: {fields}", template="[{level}] {target} {message} :: {fields}",
color_mode=ColorMode::Always,
) )
let logger = Logger::new(text_console_sink(formatter), target="pretty") let logger = Logger::new(text_console_sink(formatter), target="pretty")
logger.info("hello", fields=[field("mode", "pretty")]) logger.info("hello", fields=[field("mode", "pretty")])
``` ```
Inline style tags:
```moonbit
let formatter = text_formatter(
show_timestamp=false,
color_mode=ColorMode::Always,
).with_style_tags(
default_style_tag_registry()
.set_tag("accent", fg=Some("#4cc9f0"), bold=true)
.define_alias("danger", "red"),
)
let logger = Logger::new(text_console_sink(formatter), target="styled")
logger.info("<accent>styled</> output and <danger>alert</>")
```
Disable style markup parsing:
```moonbit
let formatter = text_formatter(
color_mode=ColorMode::Always,
).without_style_markup()
let logger = Logger::new(text_console_sink(formatter), target="raw")
logger.info("<red>kept as raw text</>")
```
JSON config loading: JSON config loading:
```moonbit ```moonbit
let config = parse_logger_config_text( 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) let logger = build_logger(config)
@@ -177,6 +208,30 @@ logger.info("configured from json")
ignore(logger.flush()) 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("<accent>styled from json</>")
```
JSON `style_markup` mode:
```moonbit
let config = parse_logger_config_text(
"{\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"color_mode\":\"always\",\"style_markup\":\"disabled\"}}}",
)
let logger = build_logger(config)
logger.info("<red>still raw</>")
```
Native file sink: Native file sink:
```moonbit ```moonbit
@@ -225,6 +280,17 @@ match logger.file_runtime_state() {
- BitLogger now includes a JSON config layer via `parse_logger_config_text(...)`, `stringify_logger_config(...)`, and `build_logger(...)`. - 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(...)`. - `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`. - 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.
- `TextFormatter` and `TextFormatterConfig` also include `color_support = basic | truecolor` so hex / RGB styling can be forced to downgrade to basic ANSI colors.
- `TextFormatter` and `TextFormatterConfig` also include `style_markup = disabled | builtin | full` so callers can choose whether style markup is parsed and whether custom tags are active.
- `target_style_markup` and `fields_style_markup` independently control whether `target` and `fields` are parsed for style markup.
- `message` also supports lightweight inline style tags such as `<red>...</>`, `<b>...</>`, `<#ff0000>...</>`, and `<bg:#202020>...</>`.
- Closing tags now support both the short form `</>` and named closing tags such as `</red>`, `</danger>`, and `</b>`.
- Builtin semantic tags now include `<accent>`, `<info>`, `<success>`, `<warning>`, `<danger>`, and `<muted>`.
- 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.
- `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. - `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 `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. - `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 +314,12 @@ 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_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. - `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.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.color_support` currently supports `basic` and `truecolor`.
- `sink.text_formatter.style_markup` currently supports `disabled`, `builtin`, and `full`.
- `sink.text_formatter.target_style_markup` and `sink.text_formatter.fields_style_markup` currently support `disabled`, `builtin`, and `full`.
- `sink.text_formatter.style_tags.<name>` currently supports `fg`, `bg`, `bold`, `dim`, `italic`, and `underline`.
- `fields_style_markup` currently applies to field values only, not field keys.
- Config-driven sink assembly currently supports `console`, `json_console`, `text_console`, and `file`. - 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. - `queue` remains a synchronous bounded wrapper around the final sink, not an async runtime.
+54
View File
@@ -0,0 +1,54 @@
## 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
- feat: add inline style markup support in message text for ANSI text formatter output
- feat: support named color tags like `<red>...</>`, style tags like `<b>...</>`, and hex tags like `<#ff0000>...</>` / `<bg:#010203>...</>`
- 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
- feat: add `StyleMarkupMode = Disabled | Builtin | Full` plus formatter helpers so callers can explicitly disable style parsing or allow builtin-only parsing
- feat: support `sink.text_formatter.style_markup` in JSON config parsing and serialization
- feat: add builtin semantic style tags such as `accent`, `info`, `success`, `warning`, `danger`, and `muted`
- feat: add independent `target_style_markup` and `fields_style_markup` controls for `TextFormatter` and `TextFormatterConfig`
- feat: support named closing tags like `</red>` alongside the existing short close `</>`
- feat: add `ColorSupport = Basic | TrueColor` and support `sink.text_formatter.color_support` so hex / RGB styles can downgrade to basic ANSI colors
### 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`
- 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
- test: cover disabled markup mode, builtin-only mode, and config-driven `style_markup` behavior
- test: cover builtin semantic tag rendering and confirm user overrides still take precedence
- test: cover target and field markup rendering, plus config roundtrip for the new formatter markup scopes
- test: cover named closing tags, mixed short/named closing, and unmatched named-close fallback behavior
- test: cover basic color-support downgrade for hex foreground/background rendering in runtime and config paths
### Example
- docs: add `color_mode` usage examples to formatter documentation
- docs: add runtime and JSON `style_tags` examples, and document current JSON schema scope
- docs: add runtime and JSON examples for toggling style markup parsing
### Notes
- `Auto` currently uses a conservative rule: if `NO_COLOR` exists, ANSI is disabled; otherwise ANSI is enabled
- inline style markup supports both short close `</>` and named closing tags like `</red>`
- 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
- users can now decide whether custom style parsing is enabled through runtime formatter APIs or `sink.text_formatter.style_markup`
- `fields_style_markup` currently styles field values only and intentionally leaves field keys raw
- `basic` color support currently keeps ANSI styling but downgrades hex / RGB colors to the nearest basic ANSI color family
+15
View File
@@ -68,6 +68,21 @@ fn main {
) )
pretty_logger.info("custom text format", fields=[@lib.field("mode", "pretty")]) pretty_logger.info("custom text format", fields=[@lib.field("mode", "pretty")])
let styled_formatter = @lib.text_formatter(
show_timestamp=false,
color_mode=@lib.ColorMode::Always,
).with_style_tags(
@lib.default_style_tag_registry()
.set_tag("accent", fg=Some("#4cc9f0"), bold=true)
.define_alias("danger", "red"),
)
let styled_logger = @lib.Logger::new(
@lib.text_console_sink(styled_formatter),
min_level=@lib.Level::Info,
target="styled",
)
styled_logger.info("<accent>styled</> output and <danger>alert</>")
if @lib.native_files_supported() { if @lib.native_files_supported() {
let file_logger = @lib.Logger::new( let file_logger = @lib.Logger::new(
@lib.file_sink( @lib.file_sink(
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "Nanaloveyuki/BitLogger", "name": "Nanaloveyuki/BitLogger",
"version": "0.3.0", "version": "0.4.0",
"deps": { "deps": {
"maria/json_parser": "0.1.1", "maria/json_parser": "0.1.1",
"moonbitlang/async": "0.18.1" "moonbitlang/async": "0.18.1"