diff --git a/README.md b/README.md index cd1f0fa..c9f358f 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,7 @@ match logger.file_runtime_state() { - `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: `...`, `...`, `<#ff0000>...`, `...` +- 闭合同时支持简写 `` 与具名闭合 ``, ``, `` - 内置语义标签包括: ``, ``, ``, ``, ``, `` - 运行期样式标签 API: `TextStyle`, `StyleTagRegistry`, `style_tag_registry()`, `default_style_tag_registry()`, `set_tag(...)`, `define_alias(...)` - 样式标签优先级: formatter 局部 `style_tags` > 全局 style tag registry > 内置标签 diff --git a/bitlogger/BitLogger_wbtest.mbt b/bitlogger/BitLogger_wbtest.mbt index b12221b..7dee2ab 100644 --- a/bitlogger/BitLogger_wbtest.mbt +++ b/bitlogger/BitLogger_wbtest.mbt @@ -107,6 +107,30 @@ test "text formatter supports nested inline tags" { ) } +test "text formatter supports named closing tags" { + let rec = record(Level::Info, "boom") + 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, "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 keeps unmatched named closing tags as plain text" { + let rec = record(Level::Info, "boom") + inspect( + format_text(rec, formatter=text_formatter(show_level=false, show_target=false)), + content="boom", + ) +} + test "text formatter supports hex inline colors" { let rec = record(Level::Info, "<#ff0000>hot bg") inspect( diff --git a/bitlogger/README.mbt.md b/bitlogger/README.mbt.md index b7206d7..5196366 100644 --- a/bitlogger/README.mbt.md +++ b/bitlogger/README.mbt.md @@ -254,6 +254,7 @@ test { - `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 样式标签: `...`, `...`, `<#ff0000>...`, `...` +- closing tags / 闭合标签: `` 以及具名闭合 ``, ``, `` - builtin semantic tags / 内置语义标签: ``, ``, ``, ``, ``, `` - 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 diff --git a/bitlogger/formatter.mbt b/bitlogger/formatter.mbt index 5f461a4..b9a3421 100644 --- a/bitlogger/formatter.mbt +++ b/bitlogger/formatter.mbt @@ -160,6 +160,11 @@ priv struct StyledSegment { style : InlineStyle } +priv struct StyleFrame { + tag : String + style : InlineStyle +} + fn code_unit(ch : Char) -> Int { ch.to_int() } @@ -529,12 +534,41 @@ fn push_plain_segment( } } +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[InlineStyle] = [default_inline_style()] + let stack : Array[StyleFrame] = [{ tag: "", style: default_inline_style() }] let chars = input.to_array() - let current_style = fn() { stack[stack.length() - 1] } + 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 { @@ -569,10 +603,20 @@ fn parse_inline_markup(input : String, formatter : TextFormatter) -> Array[Style } 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(next_style) + stack.push({ tag: normalize_style_tag_name(tag), style: next_style }) } None => append_raw(i, end + 1) } diff --git a/docs/README-en.md b/docs/README-en.md index 385cd11..e34c67c 100644 --- a/docs/README-en.md +++ b/docs/README-en.md @@ -284,6 +284,7 @@ match logger.file_runtime_state() { - `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 `...`, `...`, `<#ff0000>...`, and `...`. +- Closing tags now support both the short form `` and named closing tags such as ``, ``, and ``. - Builtin semantic tags now include ``, ``, ``, ``, ``, and ``. - Runtime style-tag APIs now include `TextStyle`, `StyleTagRegistry`, `style_tag_registry()`, `default_style_tag_registry()`, `set_tag(...)`, and `define_alias(...)`. - Style-tag lookup priority is formatter-local `style_tags` > global style tag registry > builtin tags. diff --git a/docs/changes/0.4.0.md b/docs/changes/0.4.0.md index c00f24f..0b1d4b0 100644 --- a/docs/changes/0.4.0.md +++ b/docs/changes/0.4.0.md @@ -18,6 +18,7 @@ version 0.4.0 - 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 `` alongside the existing short close `` ### Test @@ -31,6 +32,7 @@ version 0.4.0 - 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 ### Example @@ -41,7 +43,7 @@ version 0.4.0 ### Notes - `Auto` currently uses a conservative rule: if `NO_COLOR` exists, ANSI is disabled; otherwise ANSI is enabled -- inline style markup currently supports short close `` only +- inline style markup supports both short close `` and named closing tags like `` - 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