From b1b22351601f0def2d2f33d345d3d1f099c017f7 Mon Sep 17 00:00:00 2001 From: Nanaloveyuki Date: Sun, 10 May 2026 14:41:57 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20inline=20style=20markup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bitlogger/BitLogger_wbtest.mbt | 40 ++++++ bitlogger/formatter.mbt | 225 ++++++++++++++++++++++++++++++++- docs/changes/0.4.0.md | 9 +- 3 files changed, 271 insertions(+), 3 deletions(-) diff --git a/bitlogger/BitLogger_wbtest.mbt b/bitlogger/BitLogger_wbtest.mbt index d334a41..b768f73 100644 --- a/bitlogger/BitLogger_wbtest.mbt +++ b/bitlogger/BitLogger_wbtest.mbt @@ -83,6 +83,46 @@ test "text formatter auto color respects NO_COLOR" { } } +test "text formatter renders named inline color tags in ansi mode" { + let rec = record(Level::Info, "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, "boom 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, "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 hex inline colors" { + let rec = record(Level::Info, "<#ff0000>hot 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 keeps unknown inline 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 template respects disabled fields" { let rec = record(Level::Warn, "just message", target="svc") let formatter = text_formatter( diff --git a/bitlogger/formatter.mbt b/bitlogger/formatter.mbt index 80f4000..eb2e5d6 100644 --- a/bitlogger/formatter.mbt +++ b/bitlogger/formatter.mbt @@ -6,6 +6,27 @@ pub(all) enum ColorMode { Always } +priv struct InlineStyle { + fg_code : String? + bold : Bool + dim : Bool + italic : Bool + underline : Bool +} + +priv struct StyledSegment { + text : 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 @@ -66,6 +87,206 @@ fn ansi_wrap(text : String, code : String, enabled : Bool) -> String { } } +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 => () + } + 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, 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 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 apply_inline_tag(style : InlineStyle, tag : String) -> InlineStyle? { + match tag { + "b" => Some({ ..style, bold: true }) + "dim" => Some({ ..style, dim: true }) + "i" => Some({ ..style, italic: true }) + "u" => Some({ ..style, underline: true }) + _ => match named_color_code(tag) { + Some(code) => Some({ ..style, fg_code: Some(code) }) + None => { + if is_hex_color(tag) { + Some({ ..style, fg_code: Some(rgb_fg_code(tag)) }) + } else if tag.length() == 10 && tag.has_prefix("bg:") && is_hex_color(tag[3:].to_owned()) { + Some({ ..style, fg_code: Some(rgb_bg_code(tag)) }) + } else { + 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 parse_inline_markup(input : String) -> Array[StyledSegment] { + let segments : Array[StyledSegment] = [] + let buffer = StringBuilder::new() + let stack : Array[InlineStyle] = [default_inline_style()] + let chars = input.to_array() + let current_style = fn() { stack[stack.length() - 1] } + 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 + } + match apply_inline_tag(current_style(), tag) { + Some(next_style) => { + flush() + stack.push(next_style) + } + None => append_raw(i, end + 1) + } + continue end + 1 + } + flush() + segments +} + +fn render_inline_markup(message : String, formatter : TextFormatter) -> String { + let enabled = use_ansi_color(formatter.color_mode) + let segments = parse_inline_markup(message) + let out = StringBuilder::new() + for segment in segments { + out.write_string(ansi_wrap_with_style(segment.text, segment.style, enabled)) + } + out.to_string() +} + fn level_ansi_code(level : Level) -> String { match level { Level::Trace => "90" @@ -126,7 +347,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() @@ -146,7 +367,7 @@ pub fn format_text(rec : Record, formatter~ : TextFormatter = text_formatter()) if formatter.show_target && 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 diff --git a/docs/changes/0.4.0.md b/docs/changes/0.4.0.md index d5b787c..172ede6 100644 --- a/docs/changes/0.4.0.md +++ b/docs/changes/0.4.0.md @@ -8,12 +8,17 @@ version 0.4.0 - 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 `...`, style tags like `...`, and hex tags like `<#ff0000>...` / `...` +- feat: keep JSON formatter output unchanged and limit inline style parsing to text message rendering only ### 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 ### Example @@ -22,4 +27,6 @@ version 0.4.0 ### Notes - `Auto` currently uses a conservative rule: if `NO_COLOR` exists, ANSI is disabled; otherwise ANSI is enabled -- this batch only covers ANSI formatter output and does not yet include inline style markup or tag registry support +- inline style markup currently supports short close `` only +- unknown or invalid inline tags currently fall back to plain text and do not raise formatter errors +- tag registry override and configurable style aliases are planned for a later `0.4` batch