mirror of
https://github.com/Nanaloveyuki/BitLogger.git
synced 2026-05-30 15:42:25 +00:00
✨ Add inline style markup
This commit is contained in:
@@ -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, "<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 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 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 template respects disabled fields" {
|
test "text formatter template respects disabled fields" {
|
||||||
let rec = record(Level::Warn, "just message", target="svc")
|
let rec = record(Level::Warn, "just message", target="svc")
|
||||||
let formatter = text_formatter(
|
let formatter = text_formatter(
|
||||||
|
|||||||
+223
-2
@@ -6,6 +6,27 @@ pub(all) enum ColorMode {
|
|||||||
Always
|
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 {
|
pub struct TextFormatter {
|
||||||
show_timestamp : Bool
|
show_timestamp : Bool
|
||||||
show_level : 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 {
|
fn level_ansi_code(level : Level) -> String {
|
||||||
match level {
|
match level {
|
||||||
Level::Trace => "90"
|
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="{timestamp_ms}", new=timestamp_text(rec, formatter))
|
||||||
.replace_all(old="{level}", new=level_text(rec, formatter))
|
.replace_all(old="{level}", new=level_text(rec, formatter))
|
||||||
.replace_all(old="{target}", new=target_text(rec, formatter))
|
.replace_all(old="{target}", new=target_text(rec, formatter))
|
||||||
.replace_all(old="{message}", new=rec.message)
|
.replace_all(old="{message}", new=render_inline_markup(rec.message, formatter))
|
||||||
.replace_all(old="{fields}", new=fields_text(rec, formatter))
|
.replace_all(old="{fields}", new=fields_text(rec, formatter))
|
||||||
.trim()
|
.trim()
|
||||||
.to_owned()
|
.to_owned()
|
||||||
@@ -146,7 +367,7 @@ pub fn format_text(rec : Record, formatter~ : TextFormatter = text_formatter())
|
|||||||
if formatter.show_target && rec.target != "" {
|
if formatter.show_target && rec.target != "" {
|
||||||
parts.push("[\{target_text(rec, formatter)}]")
|
parts.push("[\{target_text(rec, formatter)}]")
|
||||||
}
|
}
|
||||||
parts.push(rec.message)
|
parts.push(render_inline_markup(rec.message, formatter))
|
||||||
let base = parts.join(formatter.separator)
|
let base = parts.join(formatter.separator)
|
||||||
if !formatter.show_fields || rec.fields.length() == 0 {
|
if !formatter.show_fields || rec.fields.length() == 0 {
|
||||||
base
|
base
|
||||||
|
|||||||
@@ -8,12 +8,17 @@ version 0.4.0
|
|||||||
- feat: add ANSI level, target, timestamp, and field rendering to `format_text(...)`
|
- feat: add ANSI level, target, timestamp, and field rendering to `format_text(...)`
|
||||||
- feat: add `color_mode` to `TextFormatter` and `TextFormatterConfig`
|
- feat: add `color_mode` to `TextFormatter` and `TextFormatterConfig`
|
||||||
- feat: support `text_formatter.color_mode` in JSON config parsing and serialization
|
- 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
|
||||||
|
|
||||||
### Test
|
### Test
|
||||||
|
|
||||||
- test: cover ANSI text formatter rendering in `Always` mode
|
- test: cover ANSI text formatter rendering in `Always` mode
|
||||||
- test: cover `Auto` mode fallback behavior when `NO_COLOR` is present
|
- test: cover `Auto` mode fallback behavior when `NO_COLOR` is present
|
||||||
- test: cover config parsing and serialization for `color_mode`
|
- 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
|
### Example
|
||||||
|
|
||||||
@@ -22,4 +27,6 @@ version 0.4.0
|
|||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
- `Auto` currently uses a conservative rule: if `NO_COLOR` exists, ANSI is disabled; otherwise ANSI is enabled
|
- `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
|
||||||
|
|||||||
Reference in New Issue
Block a user