diff --git a/bitlogger/BitLogger_wbtest.mbt b/bitlogger/BitLogger_wbtest.mbt index b768f73..7f728cb 100644 --- a/bitlogger/BitLogger_wbtest.mbt +++ b/bitlogger/BitLogger_wbtest.mbt @@ -123,6 +123,80 @@ test "text formatter keeps unknown inline tags as plain text" { ) } +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, "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, "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, "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, "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, "boom") + inspect( + format_text(rec, formatter=formatter), + content="\u{001b}[31mboom\u{001b}[0m", + ) +} + 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 eb2e5d6..5f6421c 100644 --- a/bitlogger/formatter.mbt +++ b/bitlogger/formatter.mbt @@ -6,8 +6,137 @@ pub(all) enum ColorMode { Always } +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("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? bold : Bool dim : Bool italic : Bool @@ -36,6 +165,7 @@ pub struct TextFormatter { field_separator : String template : String color_mode : ColorMode + style_tags : StyleTagRegistry? } pub fn text_formatter( @@ -47,6 +177,7 @@ pub fn text_formatter( field_separator~ : String = " ", template~ : String = "", color_mode~ : ColorMode = ColorMode::Never, + style_tags~ : StyleTagRegistry? = None, ) -> TextFormatter { { show_timestamp, @@ -57,9 +188,14 @@ pub fn text_formatter( field_separator, template, color_mode, + style_tags, } } +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" @@ -96,6 +232,10 @@ fn ansi_wrap_with_style(text : String, style : InlineStyle, enabled : Bool) -> S Some(code) => codes.push(code) None => () } + match style.bg_code { + Some(code) => codes.push(code) + None => () + } if style.bold { codes.push("1") } @@ -118,7 +258,7 @@ fn ansi_wrap_with_style(text : String, style : InlineStyle, enabled : Bool) -> S } fn default_inline_style() -> InlineStyle { - { fg_code: None, bold: false, dim: false, italic: false, underline: false } + { fg_code: None, bg_code: None, bold: false, dim: false, italic: false, underline: false } } fn named_color_code(tag : String) -> String? { @@ -143,6 +283,28 @@ fn named_color_code(tag : String) -> String? { } } +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 is_hex_color(value : String) -> Bool { if value.length() != 7 { return false @@ -191,25 +353,107 @@ fn rgb_bg_code(value : String) -> String { "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 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) -> InlineStyle? { + let fg_code = match style.fg { + None => Some(base.fg_code) + Some(value) => match named_color_code(normalize_style_tag_name(value)) { + Some(code) => Some(Some(code)) + None => if is_hex_color(value) { + Some(Some(rgb_fg_code(value))) + } else { + None } } } + let bg_code = match style.bg { + None => Some(base.bg_code) + Some(value) => match named_bg_color_code(normalize_style_tag_name(value)) { + Some(code) => Some(Some(code)) + None => if is_hex_color(value) { + Some(Some(rgb_bg_code_from_hex(value))) + } else { + None + } + } + } + match fg_code { + Some(next_fg) => match bg_code { + Some(next_bg) => Some({ + fg_code: next_fg, + bg_code: next_bg, + 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"))) + "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_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) { + Some({ ..style, fg_code: Some(rgb_fg_code(tag)) }) + } else if normalized.length() == 10 && normalized.has_prefix("bg:") && is_hex_color(normalized[3:].to_owned()) { + Some({ ..style, bg_code: Some(rgb_bg_code(normalized)) }) + } else { + match resolve_registered_text_style(normalized, formatter) { + Some(spec) => inline_style_from_text_style(style, spec) + None => None + } + } } fn push_plain_segment( @@ -224,7 +468,7 @@ fn push_plain_segment( } } -fn parse_inline_markup(input : String) -> Array[StyledSegment] { +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()] @@ -264,7 +508,7 @@ fn parse_inline_markup(input : String) -> Array[StyledSegment] { } continue end + 1 } - match apply_inline_tag(current_style(), tag) { + match apply_inline_tag(current_style(), tag, formatter) { Some(next_style) => { flush() stack.push(next_style) @@ -279,7 +523,7 @@ fn parse_inline_markup(input : String) -> Array[StyledSegment] { fn render_inline_markup(message : String, formatter : TextFormatter) -> String { let enabled = use_ansi_color(formatter.color_mode) - let segments = parse_inline_markup(message) + let segments = parse_inline_markup(message, formatter) let out = StringBuilder::new() for segment in segments { out.write_string(ansi_wrap_with_style(segment.text, segment.style, enabled)) diff --git a/docs/changes/0.4.0.md b/docs/changes/0.4.0.md index 172ede6..0cd62e1 100644 --- a/docs/changes/0.4.0.md +++ b/docs/changes/0.4.0.md @@ -11,6 +11,8 @@ version 0.4.0 - 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 +- 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(...)` ### Test @@ -19,6 +21,7 @@ version 0.4.0 - 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 ### Example @@ -29,4 +32,4 @@ version 0.4.0 - `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 - 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 +- formatter-local tag lookup currently takes precedence over global tag lookup, and global lookup takes precedence over builtin tags