// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// A line template: literal text with `{token}` or `{token:options}` holes, and
/// `{{` and `}}` for literal braces.
///
/// Every token but `time` may end with `N` or `^N` to pad to N columns,
/// then `.M` to keep at most M characters. `^` puts the extra space on the
/// right, which is what centred columns do in the loggers this reproduces.
pub struct Pattern {
source : String
pieces : Array[Piece]
shows_error : Bool
labels : Profile?
}
///|
/// One part of a pattern: a hole, or the text between two of them.
struct Piece {
kind : Kind
fit : Fit
}
///|
priv struct Fit {
align : (Char, Int32)?
max : Int32?
}
///|
/// What a hole asks for.
priv enum Kind {
Text(String)
Time(Clock)
Level(Bool)
Target(Int32)
Module
Func
File(Bool)
Line
Thread
Tid
Task
Pid
Host
App
Env
Instance
Msg(Bool)
Fields(Bool)
Tags
Error
Elapsed
Seq
Spans
}
///|
/// A pattern, or the name of a preset standing for one.
pub fn Pattern::parse(text : StringView) -> Pattern raise Refused {
let preset = preset_of(text)
let source = match preset {
Some(profile) => profile.pattern().to_owned()
None => text.to_owned()
}
let pieces : Array[Piece] = []
let literal = StringBuilder()
let chars = source.to_array()
let mut i = 0
let mut shows_error = false
while i < chars.length() {
let c = chars[i]
if c == '{' && i + 1 < chars.length() && chars[i + 1] == '{' {
literal.write_char('{')
i = i + 2
} else if c == '}' && i + 1 < chars.length() && chars[i + 1] == '}' {
literal.write_char('}')
i = i + 2
} else if c == '{' {
let mut end = i + 1
while end < chars.length() && chars[end] != '}' {
end = end + 1
}
if end == chars.length() {
raise refuse(i, "{", "a closing `}`")
}
flush_literal(literal, pieces)
let hole = chars_to_string(chars[i + 1:end])
let piece = read_piece(hole, i)
if piece.kind is Error {
shows_error = true
}
pieces.push(piece)
i = end + 1
} else if c == '}' {
raise refuse(i, "}", "`}}` for a literal brace")
} else {
literal.write_char(c)
i = i + 1
}
}
flush_literal(literal, pieces)
{ source: text.to_owned(), pieces, shows_error, labels: preset, }
}
///|
fn flush_literal(literal : StringBuilder, pieces : Array[Piece]) -> Unit {
let text = literal.to_string()
if text.length() > 0 {
pieces.push({ kind: Text(text), fit: { align: None, max: None, }, })
ignore(literal.reset())
}
}
///|
/// Reads `name` or `name:options` into a piece.
fn read_piece(hole : String, at : Int32) -> Piece raise Refused {
let (name, spec) = match hole.find((":" : StringView)) {
Some(i) => (hole[0:i], hole[i + 1:])
None => (hole, "")
}
let name = name.trim()
if name == ("time" : StringView) {
return { kind: Time(Clock::parse(spec)), fit: { align: None, max: None, }, }
}
let mut rest = spec
let kind = match name {
"level" => {
let (on, tail) = option(rest, "lower")
rest = tail
Level(on)
}
"target" => {
let (n, tail) = short_target(rest)
rest = tail
Target(n)
}
"file" => {
let (on, tail) = option(rest, "base")
rest = tail
File(on)
}
"msg" => {
let (on, tail) = option(rest, "q")
rest = tail
Msg(on)
}
"fields" => {
let (on, tail) = option(rest, "q")
rest = tail
Fields(on)
}
"error" => {
let (_on, tail) = option(rest, "stack")
rest = tail
Error
}
"module" => Module
"func" => Func
"line" => Line
"thread" => Thread
"tid" => Tid
"task" => Task
"pid" => Pid
"host" => Host
"app" => App
"env" => Env
"instance" => Instance
"tags" => Tags
"elapsed" => Elapsed
"seq" => Seq
"spans" => Spans
_ => raise refuse(at, name.to_owned(), "a token this library knows")
}
{ kind, fit: read_fit(rest, at), }
}
///|
/// `~N` after `target`: shorten leading segments to fit N columns.
fn short_target(spec : StringView) -> (Int32, StringView) {
if !spec.has_prefix("~") {
return (-1, spec)
}
let digits = digits_prefix(spec[1:])
if digits == 0 {
return (-1, spec)
}
(parse_number(spec[1:1 + digits]), spec[1 + digits:])
}
///|
fn option(spec : StringView, word : String) -> (Bool, StringView) {
if spec.has_prefix(word) {
(true, spec[word.length():])
} else {
(false, spec)
}
}
///|
/// The width and truncation a spec ends with.
fn read_fit(spec : StringView, at : Int32) -> Fit raise Refused {
if spec.length() == 0 {
return { align: None, max: None, }
}
let chars = spec.to_array()
let mut i = 0
let mut align = None
if chars[0] == '<' || chars[0] == '>' || chars[0] == '^' {
let n = digits_prefix(spec[1:])
if n == 0 {
raise refuse(at, spec.to_owned(), "a width after the alignment mark")
}
align = Some((chars[0], parse_number(spec[1:1 + n])))
i = 1 + n
}
let rest = spec[i:]
let mut max = None
if rest.length() > 0 {
if !rest.has_prefix(".") {
raise refuse(at, rest.to_owned(), "a width such as `<7` or `.4`")
}
let n = digits_prefix(rest[1:])
if n == 0 || n != rest.length() - 1 {
raise refuse(at, rest.to_owned(), "a number after `.`")
}
max = Some(parse_number(rest[1:]))
}
{ align, max, }
}
///|
fn digits_prefix(text : StringView) -> Int32 {
let chars = text.to_array()
let mut n = 0
while n < chars.length() && chars[n] >= '0' && chars[n] <= '9' {
n = n + 1
}
n
}
///|
fn parse_number(text : StringView) -> Int32 {
let mut n = 0
for c in text {
n = n * 10 + (c.to_int() - 48)
}
n
}
///|
/// The text it was parsed from.
pub fn Pattern::source(self : Pattern) -> String {
self.source
}
///|
/// Writes one record through the pattern.
fn Pattern::render_line(
self : Pattern,
record : Record,
scene : Scene,
) -> String {
let out = StringBuilder()
for piece in self.pieces {
match piece.kind {
Text(text) => out.write_string(text)
kind => {
let token = StringBuilder()
write_token(token, kind, record, scene, self.labels, self.shows_error)
let word = fit(token.to_string(), piece.fit)
if scene.colour && kind is Level(_) {
out.write_string(record.level.colour.ansi())
out.write_string(word)
out.write_string(reset)
} else {
out.write_string(word)
}
}
}
}
out.to_string()
}
///|
/// Truncates to the maximum, then pads to the width.
fn fit(text : String, fit : Fit) -> String {
let mut word = text
match fit.max {
Some(max) => {
let chars = word.to_array()
if chars.length() > max {
word = chars_to_string(chars[0:max])
}
}
None => ()
}
match fit.align {
Some((mark, width)) => pad(word, width, mark)
None => word
}
}
///|
/// Pads `text` to `width` characters; `^` leaves the extra space on the right.
fn pad(text : StringView, width : Int32, mark : Char) -> String {
let length = text.char_length()
if length >= width {
return text.to_owned()
}
let gap = width - length
if mark == '>' {
spaces(gap) + text.to_owned()
} else if mark == '^' {
let left = gap / 2
spaces(left) + text.to_owned() + spaces(gap - left)
} else {
text.to_owned() + spaces(gap)
}
}
///|
fn spaces(n : Int32) -> String {
let out = StringBuilder()
for _ in 0.. Unit {
match kind {
Text(text) => out.write_string(text)
Time(clock) =>
clock.write(out, record.time, (scene.identity.offset)(record.time))
Level(lower) =>
if lower {
out.write_string(record.level.name())
} else {
let profile = match labels {
Some(profile) => profile
None => scene.profile
}
out.write_string(record.level.label(profile))
}
Target(shorten_for) =>
if shorten_for < 0 {
out.write_string(record.target)
} else {
out.write_string(shorten(record.target, shorten_for))
}
Module =>
match record.caller.module_path {
Some(path) => out.write_string(path)
None => out.write_string(record.target)
}
Func =>
match record.caller.func {
Some(func) => out.write_string(func)
None => out.write_char('?')
}
File(base) =>
match record.caller.file {
Some(file) =>
if base {
out.write_string(base_name(file))
} else {
out.write_string(file)
}
None => out.write_char('?')
}
Line =>
match record.caller.line {
Some(line) => out.write_string(line.to_string())
None => out.write_char('?')
}
Thread =>
match record.thread {
Some(thread) => out.write_string(thread)
None => out.write_char('?')
}
Tid | Task => out.write_char('?')
Pid => out.write_string(scene.identity.pid.to_string())
Host => out.write_string(scene.identity.host)
App => out.write_string(scene.identity.app)
Env => out.write_string(scene.identity.env)
Instance => out.write_string(scene.identity.instance)
Msg(quoted) =>
if quoted {
write_logfmt(out, record.msg)
} else {
out.write_string(record.msg)
}
Fields(quoted) => write_fields(out, record, quoted, shows_error)
Tags =>
for i, tag in record.tags {
if i > 0 {
out.write_char(',')
}
out.write_string(tag)
}
Error =>
match record.field("error") {
Some(value) => out.write_string(value.to_text())
None => ()
}
Elapsed => out.write_string(elapsed_ms(record, scene.identity).to_string())
Seq => out.write_string(record.seq.to_string())
Spans =>
match record.spans {
Some(chain) =>
if chain.length() > 0 {
out.write_string(chain)
out.write_string(": ")
}
None => ()
}
}
}