13 Commits

Author SHA1 Message Date
Nanaloveyuki a328414087 Prepare 0.4.1 release documentation 2026-05-12 10:37:43 +08:00
Nanaloveyuki f609b02377 Add cross-target async compatibility runtime 2026-05-12 10:37:15 +08:00
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
17 changed files with 2311 additions and 111 deletions
+12
View File
@@ -43,6 +43,18 @@ jobs:
run: |
moon check bitlogger_async --target native
- name: Check bitlogger_async wasm-gc
run: |
moon check bitlogger_async --target wasm-gc
- name: Check bitlogger_async js
run: |
moon check bitlogger_async --target js
- name: Test bitlogger_async wasm-gc
run: |
moon test bitlogger_async --target wasm-gc
- name: Run basic example
run: |
moon run examples/basic
+100 -2
View File
@@ -14,6 +14,15 @@
BitLogger 是一个使用 MoonBit 编写的结构化日志库
## 🧭 后端兼容
| 模块 / 能力 | native / llvm | js / wasm / wasm-gc |
| --- | --- | --- |
| `bitlogger` 主包 | 支持 | 支持 |
| `file_sink(...)` | 支持 | 不支持, `native_files_supported()` 返回 `false` |
| `bitlogger_async` | 支持原生 worker 语义 | 支持兼容实现 |
| `examples/async_basic` | 支持 | 受 `async fn main` 入口限制, 当前不提供 |
## ❇️ 特点
- 🧩 基础能力: 支持 level, formatter, sink, context field 和全局 logger.
@@ -26,6 +35,7 @@ BitLogger 是一个使用 MoonBit 编写的结构化日志库
- 🧷 可绑定上下文: 支持 `bind(...)``fields(...)`, 便于复用上下文字段.
- 📮 显式队列: 支持 `queued_sink(...)` / `with_queue(...)`, 支持有界积压和溢出策略.
- 🧾 可配置文本格式: 支持 `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 可用.
- 📦 MoonBit 适配: API 和工程结构与 MoonBit 的 package / visibility / toolchain 模型保持一致.
@@ -168,6 +178,7 @@ let formatter = text_formatter(
show_timestamp=false,
field_separator=",",
template="[{level}] {target} {message} :: {fields}",
color_mode=ColorMode::Always,
)
let logger = Logger::new(text_console_sink(formatter), target="pretty")
@@ -176,11 +187,44 @@ logger.info("hello", fields=[field("mode", "pretty")])
</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>
```moonbit
let config = parse_logger_config_text(
"{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"field_separator\":\",\",\"template\":\"[{level}] {target} {message} :: {fields}\"}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}",
"{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"field_separator\":\",\",\"template\":\"[{level}] {target} {message} :: {fields}\",\"color_mode\":\"always\"}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}",
)
let logger = build_logger(config)
@@ -191,6 +235,34 @@ ignore(logger.flush())
</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>
```moonbit
@@ -241,6 +313,17 @@ match logger.file_runtime_state() {
- 提供 JSON 配置层: `parse_logger_config_text(...)`, `stringify_logger_config(...)`, `build_logger(...)`
- `QueueConfig`, `TextFormatterConfig`, `SinkConfig` 可分别通过 `queue_config_to_json(...)` / `stringify_queue_config(...)`, `text_formatter_config_to_json(...)` / `stringify_text_formatter_config(...)`, `sink_config_to_json(...)` / `stringify_sink_config(...)` 单独导出 JSON
- 支持字段: `min_level`, `target`, `timestamp`, `sink.kind`, `sink.path`, `sink.append`, `sink.auto_flush`, `sink.rotation`, `sink.text_formatter`, `queue`
- `TextFormatter` / `TextFormatterConfig` 提供 `color_mode = Never | Auto | Always`, 可控制 ANSI 文本着色
- `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
- `file_sink(...)` 提供 `reopen()`, `reopen_with_current_policy()`, `reopen_append()`, `reopen_truncate()`, `open_failures()`, `write_failures()`, `flush_failures()`, `rotation_failures()`, 用于基础可观测性
- `file_sink(...)` 提供 `append_mode()`. 显式传入 `reopen(append=...)` 时, 会更新后续 reopen 使用的 append 策略. `reopen_with_current_policy()` 使用当前保存的策略重开文件, `reopen_append()` / `reopen_truncate()` 提供常见的 append 和 truncate 模式
@@ -264,6 +347,12 @@ match logger.file_runtime_state() {
- `file_sink_policy_to_json(...)`, `stringify_file_sink_policy(...)` 可将独立 file policy 直接导出为 JSON, 便于策略快照, 配置对比或诊断上报
- `file_sink_state_to_json(...)`, `stringify_file_sink_state(...)`, `runtime_file_state_to_json(...)`, `stringify_runtime_file_state(...)` 可直接把 file / queued-file 快照导出为 JSON, 便于排障或上报
- `sink.text_formatter.template` 支持固定 token: `{timestamp}`, `{timestamp_ms}`, `{level}`, `{target}`, `{message}`, `{fields}`
- `sink.text_formatter.color_mode` 支持 `never`, `auto`, `always`
- `sink.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`
- `queue` 作为显式包装层附着在最终 sink 外侧. 这仍然是同步 drain 模型, 不是 async runtime
@@ -275,8 +364,17 @@ match logger.file_runtime_state() {
- 建议使用 `shutdown()` 停止 worker. 默认模式下, 它会先等待队列清空, 再关闭 queue, 最后等待 worker 退出
- 提供基础生命周期观测: `is_closed()`, `is_running()`, `has_failed()`, `last_error()`
- async worker 支持 `max_batch` 批量消费, 以及 `flush=Never|Batch|Shutdown` 的基础 flush 策略
- 提供 `async_runtime_mode()` / `async_runtime_mode_label(...)` / `async_runtime_supports_background_worker()` 用于探测当前后端是原生 worker 还是兼容实现
- 提供 `async_runtime_state_to_json(...)` / `stringify_async_runtime_state(...)`, 便于在启动日志或诊断输出里直接暴露当前 async runtime 模式
- 示例见 [examples/async_basic/main.mbt](/E:/repo/MooLiteyukiBot/examples/async_basic/main.mbt:1)
- 仅支持 `native/llvm` backend, 与同步 core 分开维护
- `bitlogger_async` 现在支持多端编译: `native/llvm` 保留后台 worker 语义, `js` / `wasm` / `wasm-gc` 提供兼容实现
- 由于 `moonbitlang/async``async fn main` 入口当前仍有限制, `examples/async_basic` 示例仍保持 `native` target
启动时诊断示例:
```moonbit
println(stringify_async_runtime_state(async_runtime_state(), pretty=true))
```
### Async Config
+158 -4
View File
@@ -50,7 +50,7 @@ test "logger config parser reads core options" {
test "logger config parser reads formatter and queue options" {
let config = parse_logger_config_text(
"{\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"separator\":\" | \",\"show_timestamp\":false,\"template\":\"[{level}] {message}\"}},\"queue\":{\"max_pending\":32,\"overflow\":\"DropOldest\"}}",
"{\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"separator\":\" | \",\"show_timestamp\":false,\"template\":\"[{level}] {message}\",\"color_mode\":\"always\"}},\"queue\":{\"max_pending\":32,\"overflow\":\"DropOldest\"}}",
)
inspect(match config.sink.kind {
SinkKind::TextConsole => "TextConsole"
@@ -59,6 +59,7 @@ test "logger config parser reads formatter and queue options" {
inspect(config.sink.text_formatter.separator, content=" | ")
inspect(config.sink.text_formatter.show_timestamp, content="false")
inspect(config.sink.text_formatter.template, content="[{level}] {message}")
inspect(color_mode_label(config.sink.text_formatter.color_mode), content="always")
match config.queue {
Some(queue) => {
inspect(queue.max_pending, content="32")
@@ -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" {
let config = parse_logger_config_text(
"{\"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}")
}
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" {
let text = stringify_logger_config(
LoggerConfig::new(
@@ -156,9 +215,17 @@ test "config subtype json helpers stringify stable shapes" {
separator=" | ",
field_separator=",",
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(
stringify_sink_config(
@@ -168,10 +235,97 @@ test "config subtype json helpers stringify stable shapes" {
append=false,
auto_flush=false,
rotation=Some(file_rotation(128, max_backups=2)),
text_formatter=TextFormatterConfig::new(show_timestamp=false),
text_formatter=TextFormatterConfig::new(show_timestamp=false, color_mode=ColorMode::Auto),
),
),
content="{\"kind\":\"file\",\"path\":\"demo.log\",\"append\":false,\"auto_flush\":false,\"text_formatter\":{\"show_timestamp\":false,\"show_level\":true,\"show_target\":true,\"show_fields\":true,\"separator\":\" \",\"field_separator\":\" \",\"template\":\"\"},\"rotation\":{\"max_bytes\":128,\"max_backups\":2}}",
content="{\"kind\":\"file\",\"path\":\"demo.log\",\"append\":false,\"auto_flush\":false,\"text_formatter\":{\"show_timestamp\":false,\"show_level\":true,\"show_target\":true,\"show_fields\":true,\"separator\":\" \",\"field_separator\":\" \",\"template\":\"\",\"color_mode\":\"auto\",\"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" {
let rec = record(Level::Warn, "just message", target="svc")
let formatter = text_formatter(
+63 -1
View File
@@ -42,8 +42,12 @@ BitLogger 是一个使用 MoonBit 编写的结构化日志库.
- 支持 `queued_sink(...)`, `with_queue(...)`, 有界积压与溢出策略
- configurable text formatting via `text_formatter(...)`, `format_text(...)`, `text_console_sink(...)`, and template-driven `template` output
- 支持 `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(...)`
- 支持 `parse_logger_config_text(...)`, `stringify_logger_config(...)` 进行最小 JSON 配置读写
- `TextFormatter` / `TextFormatterConfig` now support `color_mode = Never | Auto | Always`
- `TextFormatter` / `TextFormatterConfig` 现支持 `color_mode = Never | Auto | Always`
- `QueueConfig` / `TextFormatterConfig` / `SinkConfig` can also be exported independently through dedicated JSON helpers
- `QueueConfig` / `TextFormatterConfig` / `SinkConfig` 也可分别通过专用 JSON helper 单独导出
- config-driven logger assembly via `build_logger(...)`
@@ -180,16 +184,42 @@ test {
show_timestamp=false,
field_separator=",",
template="[{level}] {target} {message} :: {fields}",
color_mode=ColorMode::Always,
)
let logger = Logger::new(text_console_sink(formatter), target="pretty")
logger.info("hello", fields=[field("mode", "pretty")])
}
```
```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
test {
let config = parse_logger_config_text(
"{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"field_separator\":\",\",\"template\":\"[{level}] {target} {message} :: {fields}\"}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}",
"{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"field_separator\":\",\",\"template\":\"[{level}] {target} {message} :: {fields}\",\"color_mode\":\"always\"}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}",
)
let logger = build_logger(config)
logger.info("configured from json")
@@ -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 / 模板格式
- 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 / 被关闭或缺失的部分会渲染为空字符串
- `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
field_separator : 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(
@@ -27,6 +33,12 @@ pub fn TextFormatterConfig::new(
separator~ : String = " ",
field_separator~ : 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 {
{
show_timestamp,
@@ -36,9 +48,23 @@ pub fn TextFormatterConfig::new(
separator,
field_separator,
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 {
text_formatter(
show_timestamp=self.show_timestamp,
@@ -48,6 +74,16 @@ pub fn TextFormatterConfig::to_formatter(self : TextFormatterConfig) -> TextForm
separator=self.separator,
field_separator=self.field_separator,
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(
obj : Map[String, @json_parser.JsonValue],
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 {
match kind {
SinkKind::Console => "console"
@@ -834,9 +909,42 @@ fn parse_text_formatter_config(value : @json_parser.JsonValue) -> TextFormatterC
separator=get_string(obj, "separator", default=" "),
field_separator=get_string(obj, "field_separator", default=" "),
template=get_string(obj, "template", default=""),
color_mode=parse_color_mode(get_string(obj, "color_mode", default="never")),
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 {
let obj = expect_object(value, "queue")
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 {
@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),
@@ -928,7 +1036,42 @@ pub fn text_formatter_config_to_json(config : TextFormatterConfig) -> @json_pars
"separator": @json_parser.JsonValue::String(config.separator),
"field_separator": @json_parser.JsonValue::String(config.field_separator),
"template": @json_parser.JsonValue::String(config.template),
})
"color_mode": @json_parser.JsonValue::String(color_mode_label(config.color_mode)),
"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(
+791 -14
View File
@@ -1,5 +1,185 @@
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 {
show_timestamp : Bool
show_level : Bool
@@ -8,6 +188,12 @@ pub struct TextFormatter {
separator : String
field_separator : 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(
@@ -18,6 +204,12 @@ pub fn text_formatter(
separator~ : String = " ",
field_separator~ : 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 {
{
show_timestamp,
@@ -27,6 +219,588 @@ pub fn text_formatter(
separator,
field_separator,
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)
}
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 {
if formatter.show_timestamp && rec.timestamp_ms != 0UL {
rec.timestamp_ms.to_string()
ansi_wrap(rec.timestamp_ms.to_string(), "90", use_ansi_color(formatter.color_mode))
} else {
""
}
@@ -52,7 +822,7 @@ fn timestamp_text(rec : Record, formatter : TextFormatter) -> String {
fn level_text(rec : Record, formatter : TextFormatter) -> String {
if formatter.show_level {
rec.level.label()
ansi_wrap(rec.level.label(), level_ansi_code(rec.level), use_ansi_color(formatter.color_mode))
} else {
""
}
@@ -60,15 +830,22 @@ fn level_text(rec : Record, formatter : TextFormatter) -> String {
fn target_text(rec : Record, formatter : TextFormatter) -> String {
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 {
""
}
}
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 {
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 {
""
}
@@ -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="{level}", new=level_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))
.trim()
.to_owned()
@@ -92,20 +869,20 @@ pub fn format_text(rec : Record, formatter~ : TextFormatter = text_formatter())
}
let parts : Array[String] = []
if formatter.show_timestamp && rec.timestamp_ms != 0UL {
parts.push("[\{rec.timestamp_ms.to_string()}]")
parts.push("[\{timestamp_text(rec, formatter)}]")
}
if formatter.show_level {
parts.push("[\{rec.level.label()}]")
parts.push("[\{level_text(rec, formatter)}]")
}
if formatter.show_target && rec.target != "" {
parts.push("[\{rec.target}]")
parts.push("[\{target_text(rec, formatter)}]")
}
parts.push(rec.message)
parts.push(render_inline_markup(rec.message, formatter))
let base = parts.join(formatter.separator)
if !formatter.show_fields || rec.fields.length() == 0 {
base
} else {
let details = format_fields(rec.fields, formatter.field_separator)
let details = fields_text(rec, formatter)
"\{base}\{formatter.separator}\{details}"
}
}
+57
View File
@@ -1,4 +1,5 @@
async test "shutdown drains pending records" {
inspect(async_runtime_mode_label(async_runtime_mode()) == "native_worker" || async_runtime_mode_label(async_runtime_mode()) == "compatibility", content="true")
let written : Ref[Array[String]] = Ref::new([])
let flushes : Ref[Int] = Ref::new(0)
let logger = async_logger(
@@ -148,3 +149,59 @@ test "async build config stringify roundtrips nested logger and async fields" {
AsyncFlushPolicy::Shutdown => "Shutdown"
}, content="Shutdown")
}
test "async runtime capability helpers stay consistent" {
let mode = async_runtime_mode()
let state = async_runtime_state()
let worker_supported = match mode {
AsyncRuntimeMode::NativeWorker => true
AsyncRuntimeMode::Compatibility => false
}
inspect(
async_runtime_mode_label(mode) == "native_worker" || async_runtime_mode_label(mode) == "compatibility",
content="true",
)
inspect(async_runtime_supports_background_worker() == worker_supported, content="true")
inspect(async_runtime_mode_label(state.mode) == async_runtime_mode_label(mode), content="true")
inspect(state.background_worker == worker_supported, content="true")
inspect(
stringify_async_runtime_state(state),
content=if worker_supported {
"{\"mode\":\"native_worker\",\"background_worker\":true}"
} else {
"{\"mode\":\"compatibility\",\"background_worker\":false}"
},
)
}
async test "run drains queued records in compatibility backends too" {
let written : Ref[Array[String]] = Ref::new([])
let logger = async_logger(
@bitlogger.callback_sink(fn(rec) {
written.val.push(rec.message)
}),
config=AsyncLoggerConfig::new(
max_pending=4,
overflow=AsyncOverflowPolicy::DropNewest,
max_batch=2,
linger_ms=5,
flush=AsyncFlushPolicy::Never,
),
min_level=@bitlogger.Level::Info,
target="async.compat",
)
@async.with_task_group(group => {
logger.info("one")
logger.info("two")
inspect(logger.pending_count(), content="2")
group.spawn_bg(() => logger.run())
logger.shutdown()
})
inspect(logger.is_closed(), content="true")
inspect(logger.pending_count(), content="0")
inspect(written.val.length(), content="2")
inspect(written.val[0], content="one")
inspect(written.val[1], content="two")
}
+56
View File
@@ -14,6 +14,62 @@ pub(all) enum AsyncFlushPolicy {
Shutdown
}
pub enum AsyncRuntimeMode {
NativeWorker
Compatibility
}
pub fn async_runtime_mode() -> AsyncRuntimeMode {
AsyncRuntimeMode::NativeWorker
}
pub fn async_runtime_mode_label(mode : AsyncRuntimeMode) -> String {
match mode {
AsyncRuntimeMode::NativeWorker => "native_worker"
AsyncRuntimeMode::Compatibility => "compatibility"
}
}
fn all_async_runtime_modes() -> Array[AsyncRuntimeMode] {
[AsyncRuntimeMode::NativeWorker, AsyncRuntimeMode::Compatibility]
}
pub fn async_runtime_supports_background_worker() -> Bool {
ignore(all_async_runtime_modes())
true
}
pub struct AsyncRuntimeState {
mode : AsyncRuntimeMode
background_worker : Bool
}
pub fn async_runtime_state() -> AsyncRuntimeState {
{
mode: async_runtime_mode(),
background_worker: async_runtime_supports_background_worker(),
}
}
pub fn async_runtime_state_to_json(state : AsyncRuntimeState) -> @json_parser.JsonValue {
@json_parser.JsonValue::Object({
"mode": @json_parser.JsonValue::String(async_runtime_mode_label(state.mode)),
"background_worker": @json_parser.JsonValue::Bool(state.background_worker),
})
}
pub fn stringify_async_runtime_state(
state : AsyncRuntimeState,
pretty~ : Bool = false,
) -> String {
let value = async_runtime_state_to_json(state)
if pretty {
@json_parser.stringify_pretty(value, 2)
} else {
@json_parser.stringify(value)
}
}
pub struct AsyncLoggerConfig {
max_pending : Int
overflow : AsyncOverflowPolicy
+466 -80
View File
@@ -14,6 +14,62 @@ pub(all) enum AsyncFlushPolicy {
Shutdown
}
pub enum AsyncRuntimeMode {
NativeWorker
Compatibility
}
pub fn async_runtime_mode() -> AsyncRuntimeMode {
AsyncRuntimeMode::Compatibility
}
pub fn async_runtime_mode_label(mode : AsyncRuntimeMode) -> String {
match mode {
AsyncRuntimeMode::NativeWorker => "native_worker"
AsyncRuntimeMode::Compatibility => "compatibility"
}
}
fn all_async_runtime_modes() -> Array[AsyncRuntimeMode] {
[AsyncRuntimeMode::NativeWorker, AsyncRuntimeMode::Compatibility]
}
pub fn async_runtime_supports_background_worker() -> Bool {
ignore(all_async_runtime_modes())
false
}
pub struct AsyncRuntimeState {
mode : AsyncRuntimeMode
background_worker : Bool
}
pub fn async_runtime_state() -> AsyncRuntimeState {
{
mode: async_runtime_mode(),
background_worker: async_runtime_supports_background_worker(),
}
}
pub fn async_runtime_state_to_json(state : AsyncRuntimeState) -> @json_parser.JsonValue {
@json_parser.JsonValue::Object({
"mode": @json_parser.JsonValue::String(async_runtime_mode_label(state.mode)),
"background_worker": @json_parser.JsonValue::Bool(state.background_worker),
})
}
pub fn stringify_async_runtime_state(
state : AsyncRuntimeState,
pretty~ : Bool = false,
) -> String {
let value = async_runtime_state_to_json(state)
if pretty {
@json_parser.stringify_pretty(value, 2)
} else {
@json_parser.stringify(value)
}
}
pub struct AsyncLoggerConfig {
max_pending : Int
overflow : AsyncOverflowPolicy
@@ -38,7 +94,102 @@ pub fn AsyncLoggerConfig::new(
}
}
pub struct AsyncLogger[S] {}
fn parse_async_overflow(name : String) -> AsyncOverflowPolicy raise {
match name.to_upper() {
"BLOCKING" => AsyncOverflowPolicy::Blocking
"DROPOLDEST" => AsyncOverflowPolicy::DropOldest
"DROPLATEST" => AsyncOverflowPolicy::DropNewest
"DROPNEWEST" => AsyncOverflowPolicy::DropNewest
_ => raise Failure::Failure("Unsupported async overflow policy: " + name)
}
}
fn parse_async_flush(name : String) -> AsyncFlushPolicy raise {
match name.to_upper() {
"NEVER" => AsyncFlushPolicy::Never
"NONE" => AsyncFlushPolicy::Never
"BATCH" => AsyncFlushPolicy::Batch
"SHUTDOWN" => AsyncFlushPolicy::Shutdown
_ => raise Failure::Failure("Unsupported async flush policy: " + name)
}
}
pub fn parse_async_logger_config_text(input : String) -> AsyncLoggerConfig raise {
let root = @json_parser.parse(input)
let obj = match root.as_object() {
Some(obj) => obj
None => raise Failure::Failure("Expected object for async logger config")
}
let max_pending = match obj.get("max_pending") {
Some(value) => match value.as_number() {
Some(number) => number.to_int()
None => raise Failure::Failure("Expected number at async_config.max_pending")
}
None => 0
}
let overflow = match obj.get("overflow") {
Some(value) => match value.as_string() {
Some(text) => parse_async_overflow(text)
None => raise Failure::Failure("Expected string at async_config.overflow")
}
None => AsyncOverflowPolicy::Blocking
}
let max_batch = match obj.get("max_batch") {
Some(value) => match value.as_number() {
Some(number) => number.to_int()
None => raise Failure::Failure("Expected number at async_config.max_batch")
}
None => 1
}
let linger_ms = match obj.get("linger_ms") {
Some(value) => match value.as_number() {
Some(number) => number.to_int()
None => raise Failure::Failure("Expected number at async_config.linger_ms")
}
None => 0
}
let flush = match obj.get("flush") {
Some(value) => match value.as_string() {
Some(text) => parse_async_flush(text)
None => raise Failure::Failure("Expected string at async_config.flush")
}
None => AsyncFlushPolicy::Never
}
AsyncLoggerConfig::new(
max_pending=max_pending,
overflow=overflow,
max_batch=max_batch,
linger_ms=linger_ms,
flush=flush,
)
}
pub fn async_logger_config_to_json(config : AsyncLoggerConfig) -> @json_parser.JsonValue {
@json_parser.JsonValue::Object({
"max_pending": @json_parser.JsonValue::Number(config.max_pending.to_double()),
"max_batch": @json_parser.JsonValue::Number(config.max_batch.to_double()),
"linger_ms": @json_parser.JsonValue::Number(config.linger_ms.to_double()),
"overflow": @json_parser.JsonValue::String(match config.overflow {
AsyncOverflowPolicy::Blocking => "Blocking"
AsyncOverflowPolicy::DropOldest => "DropOldest"
AsyncOverflowPolicy::DropNewest => "DropNewest"
}),
"flush": @json_parser.JsonValue::String(match config.flush {
AsyncFlushPolicy::Never => "Never"
AsyncFlushPolicy::Batch => "Batch"
AsyncFlushPolicy::Shutdown => "Shutdown"
}),
})
}
pub fn stringify_async_logger_config(config : AsyncLoggerConfig, pretty~ : Bool = false) -> String {
let value = async_logger_config_to_json(config)
if pretty {
@json_parser.stringify_pretty(value, 2)
} else {
@json_parser.stringify(value)
}
}
pub struct AsyncLoggerBuildConfig {
logger : @bitlogger.LoggerConfig
@@ -53,182 +204,417 @@ pub fn AsyncLoggerBuildConfig::new(
}
pub fn parse_async_logger_build_config_text(input : String) -> AsyncLoggerBuildConfig raise {
ignore(input)
abort("bitlogger_async currently only supports native/llvm backends")
let root = @json_parser.parse(input)
let obj = match root.as_object() {
Some(obj) => obj
None => raise Failure::Failure("Expected object at async logger build config root")
}
pub fn parse_async_logger_config_text(input : String) -> AsyncLoggerConfig raise {
ignore(input)
abort("bitlogger_async currently only supports native/llvm backends")
let logger = match obj.get("logger") {
Some(value) => @bitlogger.parse_logger_config_text(@json_parser.stringify(value))
None => @bitlogger.default_logger_config()
}
pub fn async_logger_config_to_json(config : AsyncLoggerConfig) -> @json_parser.JsonValue {
ignore(config)
abort("bitlogger_async currently only supports native/llvm backends")
let async_config = match obj.get("async_config") {
Some(value) => parse_async_logger_config_text(@json_parser.stringify(value))
None => AsyncLoggerConfig::new()
}
pub fn stringify_async_logger_config(config : AsyncLoggerConfig, pretty~ : Bool = false) -> String {
ignore(config)
ignore(pretty)
abort("bitlogger_async currently only supports native/llvm backends")
AsyncLoggerBuildConfig::new(logger=logger, async_config=async_config)
}
pub fn async_logger_build_config_to_json(
config : AsyncLoggerBuildConfig,
) -> @json_parser.JsonValue {
ignore(config)
abort("bitlogger_async currently only supports native/llvm backends")
@json_parser.JsonValue::Object({
"logger": @bitlogger.logger_config_to_json(config.logger),
"async_config": async_logger_config_to_json(config.async_config),
})
}
pub fn stringify_async_logger_build_config(
config : AsyncLoggerBuildConfig,
pretty~ : Bool = false,
) -> String {
ignore(config)
ignore(pretty)
abort("bitlogger_async currently only supports native/llvm backends")
let value = async_logger_build_config_to_json(config)
if pretty {
@json_parser.stringify_pretty(value, 2)
} else {
@json_parser.stringify(value)
}
}
pub fn async_logger[S : @bitlogger.Sink](
pub struct AsyncLogger[S] {
min_level : @bitlogger.Level
target : String
timestamp : Bool
overflow : AsyncOverflowPolicy
max_batch : Int
linger_ms : Int
flush_policy : AsyncFlushPolicy
sink : S
flush_sink : (S) -> Int
context_fields : Array[@bitlogger.Field]
filter : (@bitlogger.Record) -> Bool
patch : @bitlogger.RecordPatch
queue : @async.Queue[@bitlogger.Record]
pending_count : Ref[Int]
dropped_count : Ref[Int]
is_closed : Ref[Bool]
is_running : Ref[Bool]
has_failed : Ref[Bool]
last_error : Ref[String]
}
pub fn[S] async_logger(
sink : S,
config~ : AsyncLoggerConfig = AsyncLoggerConfig::new(),
min_level~ : @bitlogger.Level = @bitlogger.Level::Info,
target~ : String = "",
flush~ : (S) -> Int = fn(_) { 0 },
) -> AsyncLogger[S] {
ignore(sink)
ignore(config)
ignore(min_level)
ignore(target)
ignore(flush)
abort("bitlogger_async currently only supports native/llvm backends")
{
min_level,
target,
timestamp: false,
overflow: config.overflow,
max_batch: config.max_batch,
linger_ms: config.linger_ms,
flush_policy: config.flush,
sink,
flush_sink: flush,
context_fields: [],
filter: fn(_) { true },
patch: @bitlogger.identity_patch(),
queue: @async.Queue::new(kind=queue_kind_of(config)),
pending_count: Ref::new(0),
dropped_count: Ref::new(0),
is_closed: Ref::new(false),
is_running: Ref::new(false),
has_failed: Ref::new(false),
last_error: Ref::new(""),
}
}
fn queue_kind_of(config : AsyncLoggerConfig) -> @aqueue.Kind {
let limit = if config.max_pending < 0 { 0 } else { config.max_pending }
match config.overflow {
AsyncOverflowPolicy::Blocking => @aqueue.Kind::Blocking(limit)
AsyncOverflowPolicy::DropOldest => @aqueue.Kind::DiscardOldest(limit)
AsyncOverflowPolicy::DropNewest => @aqueue.Kind::DiscardLatest(limit)
}
}
pub fn[S] AsyncLogger::with_timestamp(self : AsyncLogger[S], enabled~ : Bool = true) -> AsyncLogger[S] {
ignore(self)
ignore(enabled)
abort("bitlogger_async currently only supports native/llvm backends")
{ ..self, timestamp: enabled }
}
pub fn[S] AsyncLogger::with_target(self : AsyncLogger[S], target : String) -> AsyncLogger[S] {
ignore(self)
ignore(target)
abort("bitlogger_async currently only supports native/llvm backends")
{ ..self, target }
}
pub fn[S] AsyncLogger::with_context_fields(
self : AsyncLogger[S],
fields : Array[@bitlogger.Field],
) -> AsyncLogger[S] {
ignore(self)
ignore(fields)
abort("bitlogger_async currently only supports native/llvm backends")
{ ..self, context_fields: fields }
}
pub fn[S] AsyncLogger::with_filter(
self : AsyncLogger[S],
predicate : (@bitlogger.Record) -> Bool,
) -> AsyncLogger[S] {
ignore(self)
ignore(predicate)
abort("bitlogger_async currently only supports native/llvm backends")
let current = self.filter
{
..self,
filter: fn(rec) {
current(rec) && predicate(rec)
},
}
}
pub fn[S] AsyncLogger::with_patch(
self : AsyncLogger[S],
patch : @bitlogger.RecordPatch,
) -> AsyncLogger[S] {
ignore(self)
ignore(patch)
abort("bitlogger_async currently only supports native/llvm backends")
let current = self.patch
{
..self,
patch: fn(rec) {
patch(current(rec))
},
}
}
pub fn[S] AsyncLogger::with_min_level(
self : AsyncLogger[S],
min_level : @bitlogger.Level,
) -> AsyncLogger[S] {
ignore(self)
ignore(min_level)
abort("bitlogger_async currently only supports native/llvm backends")
{ ..self, min_level }
}
fn combine_targets(parent : String, child : String) -> String {
if parent == "" {
child
} else if child == "" {
parent
} else {
"\{parent}.\{child}"
}
}
pub fn[S] AsyncLogger::child(self : AsyncLogger[S], target : String) -> AsyncLogger[S] {
ignore(self)
ignore(target)
abort("bitlogger_async currently only supports native/llvm backends")
{ ..self, target: combine_targets(self.target, target) }
}
pub fn[S] AsyncLogger::is_enabled(self : AsyncLogger[S], level : @bitlogger.Level) -> Bool {
ignore(self)
ignore(level)
abort("bitlogger_async currently only supports native/llvm backends")
level.enabled(self.min_level)
}
fn merge_fields(
left : Array[@bitlogger.Field],
right : Array[@bitlogger.Field],
) -> Array[@bitlogger.Field] {
if left.length() == 0 {
right
} else if right.length() == 0 {
left
} else {
left + right
}
}
pub async fn[S] AsyncLogger::log(
self : AsyncLogger[S],
level : @bitlogger.Level,
message : String,
fields~ : Array[@bitlogger.Field] = [],
target? : String = "",
) -> Unit {
guard !self.is_closed() else {
()
}
guard self.is_enabled(level) else {
()
}
let actual_target = if target == "" { self.target } else { target }
let timestamp_ms = if self.timestamp { @env.now() } else { 0UL }
let rec = @bitlogger.Record::new(
level,
message,
timestamp_ms=timestamp_ms,
target=actual_target,
fields=merge_fields(self.context_fields, fields),
)
let rec = (self.patch)(rec)
guard (self.filter)(rec) else {
()
}
let accepted = self.queue.try_put(rec) catch {
err if err is AsyncLoggerClosed => false
err => raise err
}
if accepted {
self.pending_count.val += 1
} else {
match self.overflow {
AsyncOverflowPolicy::Blocking => {
self.queue.put(rec) catch {
err if err is AsyncLoggerClosed => ()
err => raise err
}
self.pending_count.val += 1
}
AsyncOverflowPolicy::DropOldest | AsyncOverflowPolicy::DropNewest => {
self.dropped_count.val += 1
}
}
}
}
pub async fn[S] AsyncLogger::trace(
self : AsyncLogger[S],
message : String,
fields~ : Array[@bitlogger.Field] = [],
) -> Unit {
self.log(@bitlogger.Level::Trace, message, fields=fields)
}
pub async fn[S] AsyncLogger::debug(
self : AsyncLogger[S],
message : String,
fields~ : Array[@bitlogger.Field] = [],
) -> Unit {
self.log(@bitlogger.Level::Debug, message, fields=fields)
}
pub async fn[S] AsyncLogger::info(
self : AsyncLogger[S],
message : String,
fields~ : Array[@bitlogger.Field] = [],
) -> Unit {
self.log(@bitlogger.Level::Info, message, fields=fields)
}
pub async fn[S] AsyncLogger::warn(
self : AsyncLogger[S],
message : String,
fields~ : Array[@bitlogger.Field] = [],
) -> Unit {
self.log(@bitlogger.Level::Warn, message, fields=fields)
}
pub async fn[S] AsyncLogger::error(
self : AsyncLogger[S],
message : String,
fields~ : Array[@bitlogger.Field] = [],
) -> Unit {
self.log(@bitlogger.Level::Error, message, fields=fields)
}
pub fn[S] AsyncLogger::pending_count(self : AsyncLogger[S]) -> Int {
ignore(self)
abort("bitlogger_async currently only supports native/llvm backends")
self.pending_count.val
}
pub fn[S] AsyncLogger::dropped_count(self : AsyncLogger[S]) -> Int {
ignore(self)
abort("bitlogger_async currently only supports native/llvm backends")
self.dropped_count.val
}
pub fn[S] AsyncLogger::is_closed(self : AsyncLogger[S]) -> Bool {
ignore(self)
abort("bitlogger_async currently only supports native/llvm backends")
self.is_closed.val
}
pub fn[S] AsyncLogger::is_running(self : AsyncLogger[S]) -> Bool {
ignore(self)
abort("bitlogger_async currently only supports native/llvm backends")
self.is_running.val
}
pub fn[S] AsyncLogger::has_failed(self : AsyncLogger[S]) -> Bool {
ignore(self)
abort("bitlogger_async currently only supports native/llvm backends")
self.has_failed.val
}
pub fn[S] AsyncLogger::last_error(self : AsyncLogger[S]) -> String {
ignore(self)
abort("bitlogger_async currently only supports native/llvm backends")
self.last_error.val
}
pub fn[S] AsyncLogger::flush_policy(self : AsyncLogger[S]) -> AsyncFlushPolicy {
ignore(self)
abort("bitlogger_async currently only supports native/llvm backends")
self.flush_policy
}
pub fn[S] AsyncLogger::close(self : AsyncLogger[S], clear? : Bool = false) -> Unit {
ignore(self)
ignore(clear)
abort("bitlogger_async currently only supports native/llvm backends")
self.is_closed.val = true
if clear {
let abandoned = self.pending_count()
if abandoned > 0 {
self.dropped_count.val += abandoned
self.pending_count.val = 0
}
}
self.queue.close(error=AsyncLoggerClosed, clear=clear)
}
pub async fn[S] AsyncLogger::wait_idle(self : AsyncLogger[S]) -> Unit {
ignore(self)
abort("bitlogger_async currently only supports native/llvm backends")
while self.pending_count() > 0 {
if self.has_failed() {
break
}
@async.pause()
}
}
pub async fn[S] AsyncLogger::shutdown(self : AsyncLogger[S], clear? : Bool = false) -> Unit {
ignore(self)
ignore(clear)
abort("bitlogger_async currently only supports native/llvm backends")
if clear {
self.close(clear=true)
} else {
self.wait_idle()
self.close()
}
}
pub async fn[S : @bitlogger.Sink] AsyncLogger::run(self : AsyncLogger[S]) -> Unit {
ignore(self)
abort("bitlogger_async currently only supports native/llvm backends")
self.is_running.val = true
self.has_failed.val = false
self.last_error.val = ""
run_worker(self) catch {
err => {
self.has_failed.val = true
self.last_error.val = err.to_string()
self.is_running.val = false
raise err
}
}
self.is_running.val = false
}
async fn[S : @bitlogger.Sink] run_worker(logger : AsyncLogger[S]) -> Unit {
while true {
let rec = logger.queue.get() catch {
err if err is AsyncLoggerClosed => break
err => raise err
}
logger.sink.write(rec)
if logger.pending_count.val > 0 {
logger.pending_count.val -= 1
}
for drained = 1; drained < logger.max_batch; {
let next = logger.queue.try_get() catch {
err if err is AsyncLoggerClosed => None
err => raise err
}
match next {
Some(next) => {
logger.sink.write(next)
if logger.pending_count.val > 0 {
logger.pending_count.val -= 1
}
continue drained + 1
}
None => {
if logger.linger_ms <= 0 {
break
}
let waited = @async.with_timeout_opt(logger.linger_ms, () => logger.queue.get()) catch {
err if err is AsyncLoggerClosed => None
err => raise err
}
match waited {
Some(next) => {
logger.sink.write(next)
if logger.pending_count.val > 0 {
logger.pending_count.val -= 1
}
continue drained + 1
}
None => break
}
}
}
}
match logger.flush_policy {
AsyncFlushPolicy::Batch => ignore((logger.flush_sink)(logger.sink))
_ => ()
}
}
match logger.flush_policy {
AsyncFlushPolicy::Shutdown => ignore((logger.flush_sink)(logger.sink))
_ => ()
}
}
pub fn build_async_logger(
config : AsyncLoggerBuildConfig,
) -> AsyncLogger[@bitlogger.RuntimeSink] {
ignore(config)
abort("bitlogger_async currently only supports native/llvm backends")
let logger = @bitlogger.build_logger(config.logger)
async_logger(
logger.sink,
config=config.async_config,
min_level=logger.min_level,
target=logger.target,
flush=fn(sink) { sink.flush() },
).with_timestamp(enabled=logger.timestamp)
}
pub fn build_async_text_logger(config : AsyncLoggerBuildConfig) -> AsyncLogger[@bitlogger.FormattedConsoleSink] {
ignore(config)
abort("bitlogger_async currently only supports native/llvm backends")
async_logger(
@bitlogger.text_console_sink(config.logger.sink.text_formatter.to_formatter()),
config=config.async_config,
min_level=config.logger.min_level,
target=config.logger.target,
).with_timestamp(enabled=config.logger.timestamp)
}
-2
View File
@@ -7,8 +7,6 @@ import {
"moonbitlang/core/ref",
}
supported_targets = "+native"
options(
targets: {
"async_logger_native.mbt": [ "native", "llvm" ],
+92 -2
View File
@@ -6,6 +6,15 @@ BitLogger is a structured logging library written in MoonBit.
BitLogger currently provides:
## Backend Compatibility
| Module / capability | native / llvm | js / wasm / wasm-gc |
| --- | --- | --- |
| `bitlogger` core package | Supported | Supported |
| `file_sink(...)` | Supported | Not available, `native_files_supported()` returns `false` |
| `bitlogger_async` | Native worker semantics | Compatibility implementation |
| `examples/async_basic` | Supported | Not shipped currently because `async fn main` entry support is still limited |
- log levels: `Trace`, `Debug`, `Info`, `Warn`, `Error`
- structured key-value fields
- plain console output
@@ -25,6 +34,7 @@ BitLogger currently provides:
- explicit queued delivery via `queued_sink(...)` and `with_queue(...)`
- bounded backlog with `QueueOverflowPolicy::DropNewest` and `QueueOverflowPolicy::DropOldest`
- 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(...)`
- 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
@@ -158,17 +168,47 @@ let formatter = text_formatter(
show_timestamp=false,
field_separator=",",
template="[{level}] {target} {message} :: {fields}",
color_mode=ColorMode::Always,
)
let logger = Logger::new(text_console_sink(formatter), target="pretty")
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:
```moonbit
let config = parse_logger_config_text(
"{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"field_separator\":\",\",\"template\":\"[{level}] {target} {message} :: {fields}\"}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}",
"{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"field_separator\":\",\",\"template\":\"[{level}] {target} {message} :: {fields}\",\"color_mode\":\"always\"}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}",
)
let logger = build_logger(config)
@@ -177,6 +217,30 @@ 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("<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:
```moonbit
@@ -225,6 +289,17 @@ match logger.file_runtime_state() {
- BitLogger now includes a JSON config layer via `parse_logger_config_text(...)`, `stringify_logger_config(...)`, and `build_logger(...)`.
- `QueueConfig`, `TextFormatterConfig`, and `SinkConfig` can also be exported independently through `queue_config_to_json(...)` / `stringify_queue_config(...)`, `text_formatter_config_to_json(...)` / `stringify_text_formatter_config(...)`, and `sink_config_to_json(...)` / `stringify_sink_config(...)`.
- Supported keys include `min_level`, `target`, `timestamp`, `sink.kind`, `sink.path`, `sink.append`, `sink.auto_flush`, `sink.rotation`, `sink.text_formatter`, and `queue`.
- `TextFormatter` and `TextFormatterConfig` now include `color_mode = Never | Auto | Always` for ANSI text coloring control.
- `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.
- `file_sink(...)` also exposes `reopen()`, `reopen_with_current_policy()`, `reopen_append()`, `reopen_truncate()`, `open_failures()`, `write_failures()`, `flush_failures()`, and `rotation_failures()` for basic observability.
- `file_sink(...)` also exposes `append_mode()`. Passing `append=...` to `reopen(...)` updates the current append policy used by later reopen calls, `reopen_with_current_policy()` makes that stored-policy reopen path explicit, and `reopen_append()` / `reopen_truncate()` cover the two common policy switches directly.
@@ -248,6 +323,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_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.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`.
- `queue` remains a synchronous bounded wrapper around the final sink, not an async runtime.
@@ -259,8 +340,17 @@ match logger.file_runtime_state() {
- `shutdown()` is now the recommended way to stop the async worker. By default it waits for the queue to drain, closes the queue, and then waits for the worker to exit.
- Basic lifecycle observability is also available through `is_closed()`, `is_running()`, `has_failed()`, and `last_error()`.
- The async worker now supports batched queue draining via `max_batch` and basic flush policies through `flush=Never|Batch|Shutdown`.
- `async_runtime_mode()`, `async_runtime_mode_label(...)`, and `async_runtime_supports_background_worker()` expose whether the current backend is using the native worker path or the compatibility implementation.
- `async_runtime_state_to_json(...)` and `stringify_async_runtime_state(...)` make it easy to include the current async runtime mode in startup diagnostics or health output.
- The recommended startup pattern is shown in [examples/async_basic/main.mbt](/E:/repo/MooLiteyukiBot/examples/async_basic/main.mbt:1).
- This layer currently targets `native/llvm` only and remains isolated from the synchronous logger core.
- `bitlogger_async` now compiles across multiple targets: `native/llvm` keeps the background worker semantics, while `js` / `wasm` / `wasm-gc` use a compatibility implementation.
- Because `moonbitlang/async` still limits `async fn main` entry support, the `examples/async_basic` executable remains `native`-only for now.
Startup diagnostic example:
```moonbit
println(stringify_async_runtime_state(async_runtime_state(), pretty=true))
```
### Async Config
+51
View File
@@ -0,0 +1,51 @@
## 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
+19
View File
@@ -0,0 +1,19 @@
## BitLogger Update Changes
version 0.4.1
### Feature
- feat: make `bitlogger_async` compile on `js` / `wasm` / `wasm-gc` via a compatibility backend instead of abort-only stubs
- feat: add `async_runtime_mode()` and related helpers so callers can explicitly detect native-worker vs compatibility async behavior
- feat: add `AsyncRuntimeState` JSON/stringify helpers for exposing async runtime mode in diagnostics
### Test
- test: cover async logger compatibility backend queue drain behavior and keep async config roundtrip available on non-native targets
- test: cover async runtime capability helper consistency across backend-specific implementations
### Notes
- `bitlogger_async` now provides a compatibility implementation on non-native targets; the standalone async example remains `native`-only because `async fn main` entry support is still toolchain-limited
- `examples/async_basic` now prints the current async runtime state at startup so downstream projects have a minimal diagnostic pattern to copy
+2
View File
@@ -1,4 +1,6 @@
async fn main {
println(@lib_async.stringify_async_runtime_state(@lib_async.async_runtime_state(), pretty=true))
let raw = "{\"logger\":{\"min_level\":\"info\",\"target\":\"async.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"separator\":\" | \"}}},\"async_config\":{\"max_pending\":2,\"overflow\":\"DropOldest\",\"max_batch\":4,\"linger_ms\":5,\"flush\":\"Batch\"}}"
let config = @lib_async.parse_async_logger_build_config_text(raw) catch {
err => {
+15
View File
@@ -68,6 +68,21 @@ fn main {
)
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() {
let file_logger = @lib.Logger::new(
@lib.file_sink(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Nanaloveyuki/BitLogger",
"version": "0.3.0",
"version": "0.4.1",
"deps": {
"maria/json_parser": "0.1.1",
"moonbitlang/async": "0.18.1"