Pudu programming language
Menu
Package

@chrismichaelps / pudu-lang-log

Structured event logging for Pudu: message templates, enrichment, filtering, formatting, and sinks

0.1.0Apache-2.01

InstallClose

Dates.pudu

Pudu228 lines9.1 KB

GitHub ↗
1/** @Log.Domain.Dates.Module — timestamp and duration format specifiers */2module PuduLangLog.Domain.Dates34import Std.Map as Map5import Std.Math as Math6import Std.Text as Text7import Std.Time.Format as Calendar8import PuduLangLog as Log910/// Abbreviated weekday names, Sunday first.11const WEEKDAYS: Array[Str] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]1213/// Full weekday names, Sunday first.14const WEEKDAYS_FULL: Array[Str] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]1516/// Abbreviated month names, January first.17const MONTHS: Array[Str] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]1819/// Full month names, January first.20const MONTHS_FULL: Array[Str] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]2122/// The custom pattern each standard timestamp format stands for.23const STANDARD: Map[Str, Str] = mapOf([24    ("d", "MM/dd/yyyy"), ("D", "dddd, dd MMMM yyyy"), ("f", "dddd, dd MMMM yyyy HH:mm"),25    ("F", "dddd, dd MMMM yyyy HH:mm:ss"), ("g", "MM/dd/yyyy HH:mm"), ("G", "MM/dd/yyyy HH:mm:ss"),26    ("m", "MMMM dd"), ("M", "MMMM dd"), ("o", "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffzzz"),27    ("O", "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffzzz"), ("r", "ddd, dd MMM yyyy HH':'mm':'ss 'GMT'"),28    ("R", "ddd, dd MMM yyyy HH':'mm':'ss 'GMT'"), ("s", "yyyy'-'MM'-'dd'T'HH':'mm':'ss"), ("t", "HH:mm"),29    ("T", "HH:mm:ss"), ("u", "yyyy'-'MM'-'dd HH':'mm':'ss'Z'"), ("U", "dddd, dd MMMM yyyy HH:mm:ss"),30    ("y", "yyyy MMMM"), ("Y", "yyyy MMMM")31  ])3233/// Standard timestamp formats that render the moment in UTC.34const UNIVERSAL: Array[Str] = ["r", "R", "u", "U"]3536/// The pattern a timestamp renders with when no format is given.37const DEFAULT_MOMENT: Str = "MM/dd/yyyy HH:mm:ss zzz"3839/// Milliseconds in a day.40const DAY: Int = 864000004142/// Milliseconds in an hour.43const HOUR: Int = 36000004445/// Milliseconds in a minute.46const MINUTE: Int = 600004748/// The timestamp under a format: a standard one-letter format, a custom pattern, or none for49/// `MM/dd/yyyy HH:mm:ss zzz`.50export fn formatMoment(moment: &Log.Timestamp, format: &Option[Str]) -> Str {51  let given = match format {52    case Some(text) => text53    case None => DEFAULT_MOMENT54  }55  match Map.get(&STANDARD, given) {56    case Some(pattern) => {57      let shown = if UNIVERSAL.contains(given) { Log.Timestamp{..*moment, offset: 0} } else { *moment }58      render(&shown, pattern)59    }60    case None => render(moment, given)61  }62}6364/// The timestamp in round-trip form, `2026-09-28T18:03:00.1230000+02:00`.65export fn roundTrip(moment: &Log.Timestamp) -> Str { formatMoment(moment, &Some("o")) }6667/// The duration under a format: `c` or none for `[-][d.]hh:mm:ss[.fffffff]`, `g` for68/// `[-][d:]h:mm:ss[.FFFFFFF]`, `G` for `[-]d:hh:mm:ss.fffffff`, or a custom pattern of `d`, `h`,69/// `m`, `s`, `f`, and `F`.70export fn formatSpan(millis: Int, format: &Option[Str]) -> Str {71  let given = match format {72    case Some(text) => text73    case None => "c"74  }75  let minus = if millis < 0 { "-" } else { "" }76  let size = Math.abs(millis)77  let days = size / DAY78  let hours = size % DAY / HOUR79  let minutes = size % HOUR / MINUTE80  let seconds = size % MINUTE / 100081  let fraction = Text.padLeft(show(size % 1000), 3, "0") + "0000"82  let clock = two(minutes) + ":" + two(seconds)83  if given == "c" {84    let dayPart = if days > 0 { show(days) + "." } else { "" }85    let tail = if size % 1000 > 0 { "." + fraction } else { "" }86    return minus + dayPart + two(hours) + ":" + clock + tail87  }88  if given == "g" {89    let dayPart = if days > 0 { show(days) + ":" } else { "" }90    let trimmed = Text.dropWhileEnd(fraction, |character: Char| character == '0')91    let tail = if trimmed.isEmpty() { "" } else { "." + trimmed }92    return minus + dayPart + show(hours) + ":" + clock + tail93  }94  if given == "G" { return minus + show(days) + ":" + two(hours) + ":" + clock + "." + fraction }95  renderSpan(days, hours, minutes, seconds, fraction, given)96}9798/// Two-digit text of a number.99fn two(value: Int) -> Str { Text.padLeft(show(value), 2, "0") }100101/// The pattern's specifiers replaced by the timestamp's local fields.102fn render(moment: &Log.Timestamp, pattern: Str) -> Str {103  let local = moment.millis + moment.offset * MINUTE104  let parts = Calendar.partsOf(local)105  let characters = pattern.chars()106  var out: Array[Str] = []107  var index = 0108  while index < characters.length() {109    let character = characters[index]110    let count = runOf(&characters, index)111    if character == '\'' || character == '"' {112      let end = closing(&characters, index + 1, character)113      out = out.push(pattern.slice(index + 1, end))114      index = end + 1115    } else if character == '\\' || character == '%' {116      if character == '\\' { out = out.push(pattern.slice(index + 1, index + 2)) }117      index = index + if character == '\\' { 2 } else { 1 }118    } else {119      out = out.push(field(character, count, moment, &parts, local))120      index = index + (if isSpecifier(character) { count } else { 1 })121    }122  }123  out.join("")124}125126/// The number of times the character at `index` repeats from there.127fn runOf(characters: &Array[Char], index: Int) -> Int {128  var end = index129  while end < characters.length() && characters[end] == characters[index] { end = end + 1 }130  end - index131}132133/// The index of the quote closing a literal, or the end of the pattern.134fn closing(characters: &Array[Char], from: Int, quote: Char) -> Int {135  var end = from136  while end < characters.length() && characters[end] != quote { end = end + 1 }137  end138}139140/// Whether a character is a timestamp specifier rather than literal text.141fn isSpecifier(character: Char) -> Bool { "dfFghHKmMstyz".contains(character.toText()) }142143/// One specifier of `count` repetitions rendered from the local fields.144fn field(character: Char, count: Int, moment: &Log.Timestamp, parts: &Calendar.Parts, local: Int) -> Str {145  let pair = Math.min(count, 2)146  match character {147    case 'y' => if count <= 2 { padded(parts.year % 100, count) } else { padded(parts.year, count) }148    case 'M' => named(parts.month, count, &MONTHS, &MONTHS_FULL, parts.month - 1)149    case 'd' => named(parts.day, count, &WEEKDAYS, &WEEKDAYS_FULL, Calendar.weekdayOf(local))150    case 'H' => padded(parts.hour, pair)151    case 'h' => padded(twelveHour(parts.hour), pair)152    case 'm' => padded(parts.minute, pair)153    case 's' => padded(parts.second, pair)154    case 'f' => fractionOf(parts.milli, count, false)155    case 'F' => fractionOf(parts.milli, count, true)156    case 't' => (if parts.hour < 12 { "AM" } else { "PM" }).take(pair)157    case 'z' => offsetOf(moment.offset, count)158    case 'K' => offsetOf(moment.offset, 3)159    case 'g' => "A.D."160    case _ => character.toText()161  }162}163164/// A number padded with zeros to a width.165fn padded(value: Int, width: Int) -> Str { Text.padLeft(show(value), width, "0") }166167/// The hour on a twelve-hour clock.168fn twelveHour(hour: Int) -> Int {169  let wrapped = hour % 12170  if wrapped == 0 { 12 } else { wrapped }171}172173/// A day or month as a number for one or two repetitions, abbreviated for three, and full for more.174fn named(value: Int, count: Int, short: &Array[Str], full: &Array[Str], position: Int) -> Str {175  if count <= 2 { return padded(value, count) }176  if count == 3 { return short[position] }177  full[position]178}179180/// Up to seven fraction-of-second digits from milliseconds; `F` drops trailing zeros.181fn fractionOf(milli: Int, count: Int, trimmed: Bool) -> Str {182  let digits = (Text.padLeft(show(milli), 3, "0") + "0000").take(Math.min(count, 7))183  if trimmed { Text.dropWhileEnd(digits, |character: Char| character == '0') } else { digits }184}185186/// The offset as `+2` for one repetition, `+02` for two, and `+02:00` for more.187fn offsetOf(offset: Int, count: Int) -> Str {188  let mark = if offset < 0 { "-" } else { "+" }189  let size = Math.abs(offset)190  if count == 1 { return mark + show(size / 60) }191  if count == 2 { return mark + two(size / 60) }192  mark + two(size / 60) + ":" + two(size % 60)193}194195/// A custom duration pattern rendered from its fields.196fn renderSpan(days: Int, hours: Int, minutes: Int, seconds: Int, fraction: Str, pattern: Str) -> Str {197  let characters = pattern.chars()198  var out: Array[Str] = []199  var index = 0200  while index < characters.length() {201    let character = characters[index]202    let count = runOf(&characters, index)203    if character == '\'' || character == '"' {204      let end = closing(&characters, index + 1, character)205      out = out.push(pattern.slice(index + 1, end))206      index = end + 1207    } else if character == '\\' {208      out = out.push(pattern.slice(index + 1, index + 2))209      index = index + 2210    } else {211      let pair = Math.min(count, 2)212      let digits = fraction.take(Math.min(count, 7))213      let (text, used) = match character {214        case 'd' => (padded(days, count), count)215        case 'h' => (padded(hours, pair), count)216        case 'm' => (padded(minutes, pair), count)217        case 's' => (padded(seconds, pair), count)218        case 'f' => (digits, count)219        case 'F' => (Text.dropWhileEnd(digits, |digit: Char| digit == '0'), count)220        case _ => (character.toText(), 1)221      }222      out = out.push(text)223      index = index + used224    }225  }226  out.join("")227}228