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

Numbers.pudu

Pudu364 lines15.2 KB

GitHub ↗
1/** @Log.Domain.Numbers.Module — numeric format specifiers over exact digits */2module PuduLangLog.Domain.Numbers34import Std.Char as Char5import Std.Decimal as Decimal6import Std.List as List7import Std.Math as Math8import Std.Option as Option9import Std.Text as Text1011/** @Log.Domain.Numbers.Digits — a number as sign, digits, and exponent */12export type Digits = { negative: Bool, digits: Str, exponent: Int }1314/// Hexadecimal digits by value.15const HEX: Str = "0123456789abcdef"1617/// The largest precision a standard format may carry.18const MOST_PRECISION: Int = 991920/// The digits of an exact number: `0.d1d2… × 10^exponent`, without leading or trailing zeros.21/// Zero has no digits.22export fn digitsOf(value: Decimal) -> Digits {23  let text = Decimal.toText(value)24  let negative = text.startsWith("-")25  let unsigned = if negative { text.drop(1) } else { text }26  let (whole, fraction) = Text.breakOn(unsigned, ".")27  let all = whole + fraction.drop(1)28  let leading = all.spanOf("0")29  let trimmed = Text.dropWhileEnd(all.drop(leading), |character: Char| character == '0')30  let exponent = if trimmed.isEmpty() { 0 } else { whole.length() - leading }31  Digits{negative: negative && !trimmed.isEmpty(), digits: trimmed, exponent: exponent}32}3334/// The digits of a float read through its shortest text, or `None` for infinities and NaN.35export fn digitsOfReal(value: Float64) -> Option[Digits] {36  let exact = show(value).toDecimal() ?37  Some(digitsOf(exact))38}3940/// The float in its shortest round-trip form: fixed notation for decimal exponents from -4 to 14,41/// and `d.dddE+XX` outside them.42export fn realText(value: Float64) -> Str {43  match digitsOfReal(value) {44    case Some(number) => general(&number, 17, 15, "E")45    case None => show(value)46  }47}4849/// The integer under a format, or `None` when the format is not understood.50export fn formatInteger(value: Int, format: Str) -> Option[Str] {51  let (letter, precision) = match standard(format) {52    case Some(parts) => parts53    case None => { return custom(&digitsOf(Decimal.fromInt(value)), format) }54  }55  let lower = letter.toLower()56  if lower == "d" {57    let number = digitsOf(Decimal.fromInt(value))58    let (text, _fraction) = fixed(&number, 0)59    return Some(sign(&number, 0) + Text.padLeft(text, Option.unwrapOr(precision, 0), "0"))60  }61  if lower == "x" {62    let text = hex(value)63    let cased = if letter == "X" { text.toUpper() } else { text }64    return Some(Text.padLeft(cased, Option.unwrapOr(precision, 0), "0"))65  }66  if lower == "r" || (lower == "g" && precision == None) { return Some(show(value)) }67  formatStandard(&digitsOf(Decimal.fromInt(value)), letter, precision)68}6970/// The exact number under a format, or `None` when the format is not understood.71export fn formatExact(value: Decimal, format: Str) -> Option[Str] {72  match standard(format) {73    case Some((letter, precision)) => {74      let lower = letter.toLower()75      if lower == "r" || (lower == "g" && precision == None) { return Some(Decimal.toText(value)) }76      formatStandard(&digitsOf(value), letter, precision)77    }78    case None => custom(&digitsOf(value), format)79  }80}8182/// The float under a format, or `None` when the format is not understood. Infinities and NaN83/// render as their names under every format.84export fn formatReal(value: Float64, format: Str) -> Option[Str] {85  let number = match digitsOfReal(value) {86    case Some(found) => found87    case None => { return Some(show(value)) }88  }89  match standard(format) {90    case Some((letter, precision)) => {91      let lower = letter.toLower()92      if lower == "r" || (lower == "g" && precision == None) { return Some(realText(value)) }93      formatStandard(&number, letter, precision)94    }95    case None => custom(&number, format)96  }97}9899/// The letter and optional precision of a standard format: one letter and up to two digits.100fn standard(format: Str) -> Option[(Str, Option[Int])] {101  let letter = List.first(&format.chars()) ?102  if !Char.isLetter(letter) || format.length() > 3 { return None }103  let rest = format.drop(1)104  if rest.isEmpty() { return Some((letter.toText(), None)) }105  if !Text.allChars(rest, |character: Char| Char.isDigit(character)) { return None }106  Some((letter.toText(), Text.countOf(rest)))107}108109/// A standard numeric format other than `D`, `X`, and `R`.110fn formatStandard(number: &Digits, letter: Str, precision: Option[Int]) -> Option[Str] {111  let lower = letter.toLower()112  let places = Option.unwrapOr(precision, 2)113  if lower == "f" { return Some(fixedText(number, places, false)) }114  if lower == "n" { return Some(fixedText(number, places, true)) }115  if lower == "c" {116    let (integer, fraction) = fixed(number, places)117    return Some(sign(number, places) + "¤" + joined(grouped(integer), fraction))118  }119  if lower == "p" {120    let scaled = Digits{..*number, exponent: number.exponent + 2}121    return Some(fixedText(&scaled, places, true) + " %")122  }123  if lower == "e" { return Some(scientific(number, Option.unwrapOr(precision, 6) + 1, letter, 3, false)) }124  if lower == "g" {125    let count = Option.unwrapOr(precision, 15)126    return Some(general(number, count, count, if letter == "G" { "E" } else { "e" }))127  }128  None129}130131/// Fixed notation with a number of decimals, optionally grouped in thousands.132fn fixedText(number: &Digits, places: Int, grouping: Bool) -> Str {133  let (integer, fraction) = fixed(number, places)134  sign(number, places) + joined(if grouping { grouped(integer) } else { integer }, fraction)135}136137/// The integer digits and exactly `places` fraction digits of a number rounded half away from zero.138export fn fixed(number: &Digits, places: Int) -> (Str, Str) {139  let rounded = roundTo(number, number.exponent + places)140  let integer = if rounded.exponent <= 0 { "0" } else { Text.padRight(rounded.digits.take(rounded.exponent), rounded.exponent, "0") }141  let leading = "0".repeat(Math.max(0, 0 - rounded.exponent))142  let fraction = Text.padRight(leading + rounded.digits.drop(Math.max(0, rounded.exponent)), places, "0").take(places)143  (integer, fraction)144}145146/// The number kept to `count` significant digits, rounding half away from zero.147fn roundTo(number: &Digits, count: Int) -> Digits {148  if count >= number.digits.length() { return *number }149  if count < 0 { return digitsOf(Decimal.zero()) }150  let kept = number.digits.take(count)151  let next = Option.unwrapOr(Char.toDigit(number.digits.chars()[count]), 0)152  if next < 5 { return trim(Digits{..*number, digits: kept}) }153  let (carried, overflow) = increment(kept)154  if overflow { return Digits{..*number, digits: "1", exponent: number.exponent + 1} }155  trim(Digits{..*number, digits: carried})156}157158/// The digits plus one in their last place, and whether the carry left the first place.159fn increment(digits: Str) -> (Str, Bool) {160  var characters = digits.chars()161  var index = characters.length() - 1162  while index >= 0 {163    let digit = Option.unwrapOr(Char.toDigit(characters[index]), 0)164    if digit < 9 {165      characters = List.take(&characters, index).push(Option.unwrapOr(Char.fromDigit(digit + 1), '0')).concat(List.drop(&characters, index + 1))166      return (characters.map(|character: Char| character.toText()).join(""), false)167    }168    characters = List.take(&characters, index).push('0').concat(List.drop(&characters, index + 1))169    index = index - 1170  }171  ("", true)172}173174/// The number without trailing zero digits; a number left without digits is zero.175fn trim(number: Digits) -> Digits {176  let digits = Text.dropWhileEnd(number.digits, |character: Char| character == '0')177  if digits.isEmpty() { digitsOf(Decimal.zero()) } else { Digits{..number, digits: digits} }178}179180/// A minus sign when the number is still negative after rounding to `places` decimals.181fn sign(number: &Digits, places: Int) -> Str {182  if number.negative && !roundTo(number, number.exponent + places).digits.isEmpty() { "-" } else { "" }183}184185/// Integer digits with a comma between each group of three.186fn grouped(integer: Str) -> Str {187  var groups: Array[Str] = []188  var rest = integer189  while rest.length() > 3 {190    groups = [rest.drop(rest.length() - 3)].concat(groups)191    rest = rest.take(rest.length() - 3)192  }193  [rest].concat(groups).join(",")194}195196/// Integer and fraction digits joined by a point, without the point when there is no fraction.197fn joined(integer: Str, fraction: Str) -> Str {198  if fraction.isEmpty() { integer } else { integer + "." + fraction }199}200201/// Scientific notation with `count` significant digits and an exponent of at least `width` digits,202/// trimming trailing fraction zeros when `trimmed`.203fn scientific(number: &Digits, count: Int, letter: Str, width: Int, trimmed: Bool) -> Str {204  let rounded = roundTo(number, count)205  let digits = if trimmed { rounded.digits } else { Text.padRight(rounded.digits, count, "0") }206  let first = if digits.isEmpty() { "0" } else { digits.take(1) }207  let rest = if trimmed { digits.drop(1) } else { Text.padRight(digits.drop(1), count - 1, "0") }208  let power = if rounded.digits.isEmpty() { 0 } else { rounded.exponent - 1 }209  let mark = if power < 0 { "-" } else { "+" }210  let minus = if number.negative && !rounded.digits.isEmpty() { "-" } else { "" }211  minus + joined(first, rest) + letter + mark + Text.padLeft(show(Math.abs(power)), width, "0")212}213214/// General notation with `count` significant digits: fixed for decimal exponents from -4 up to215/// but not including `threshold`, scientific with a two-digit exponent otherwise.216fn general(number: &Digits, count: Int, threshold: Int, letter: Str) -> Str {217  let rounded = roundTo(number, count)218  if rounded.digits.isEmpty() { return "0" }219  let power = rounded.exponent - 1220  if power < -4 || power >= threshold { return scientific(number, count, letter, 2, true) }221  let places = Math.max(0, rounded.digits.length() - rounded.exponent)222  let (integer, fraction) = fixed(&rounded, places)223  (if rounded.negative { "-" } else { "" }) + joined(integer, fraction)224}225226/// The integer's hexadecimal digits; a negative integer is its 64-bit two's complement.227fn hex(value: Int) -> Str {228  if value == 0 { return "0" }229  var digits: Array[Str] = []230  var rest = value231  var count = 0232  while rest != 0 && count < 16 {233    let digit = ((rest % 16) + 16) % 16234    digits = [HEX.slice(digit, digit + 1)].concat(digits)235    rest = (rest - digit) / 16236    count = count + 1237  }238  digits.join("")239}240241/** @Log.Domain.Numbers.Pattern — a parsed custom numeric section */242type Pattern = { pieces: Array[Str], integers: Int, minimumIntegers: Int, fractions: Int, minimumFractions: Int, grouping: Bool, percent: Bool }243244/// The number under a custom format of `0`, `#`, `.`, `,`, `%`, quoted literals, and up to three245/// `;`-separated sections for positive, negative, and zero values.246fn custom(number: &Digits, format: Str) -> Option[Str] {247  let sections = sectionsOf(format)248  let zero = number.digits.isEmpty()249  let chosen = if zero && sections.length() > 2 { sections[2] } else if number.negative && sections.length() > 1 { sections[1] } else { sections[0] }250  let pattern = patternOf(chosen)251  let scaled = if pattern.percent { Digits{..*number, exponent: number.exponent + 2} } else { *number }252  let (integer, fraction) = fixed(&scaled, pattern.fractions)253  let shown = Text.dropWhileEnd(fraction, |character: Char| character == '0')254  let fractionDigits = Text.padRight(shown, pattern.minimumFractions, "0")255  let integerDigits = if integer == "0" && pattern.minimumIntegers == 0 { "" } else { Text.padLeft(integer, pattern.minimumIntegers, "0") }256  let minus = if number.negative && sections.length() < 2 && !(integer.spanOf("0") == integer.length() && fraction.spanOf("0") == fraction.length()) { "-" } else { "" }257  Some(minus + assemble(&pattern, if pattern.grouping { grouped(integerDigits) } else { integerDigits }, fractionDigits))258}259260/// The format's sections, split at `;` outside quotes.261fn sectionsOf(format: Str) -> Array[Str] {262  var sections: Array[Str] = []263  var current: Array[Str] = []264  var quote = ' '265  var escaped = false266  for character in format.chars() {267    if escaped {268      escaped = false269      current = current.push(character.toText())270    } else if quote != ' ' {271      if character == quote { quote = ' ' }272      current = current.push(character.toText())273    } else if character == '\\' {274      escaped = true275      current = current.push(character.toText())276    } else if character == ';' {277      sections = sections.push(current.join(""))278      current = []279    } else {280      if character == '\'' || character == '"' { quote = character }281      current = current.push(character.toText())282    }283  }284  sections.push(current.join(""))285}286287/// A section read into literal pieces and digit placeholders. Each piece is one literal character,288/// or `0`, `#`, or `.` standing for a placeholder.289fn patternOf(section: Str) -> Pattern {290  var pieces: Array[Str] = []291  var integers = 0292  var firstZero = 0293  var fractions = 0294  var minimumFractions = 0295  var grouping = false296  var percent = false297  var inFraction = false298  var quote = ' '299  var escaped = false300  for character in section.chars() {301    if escaped {302      pieces = pieces.push("\\" + character.toText())303      escaped = false304    } else if quote != ' ' {305      if character == quote { quote = ' ' } else { pieces = pieces.push("\\" + character.toText()) }306    } else if character == '\'' || character == '"' {307      quote = character308    } else if character == '\\' {309      escaped = true310    } else if character == '0' || character == '#' {311      pieces = pieces.push(character.toText())312      if inFraction {313        fractions = fractions + 1314        if character == '0' { minimumFractions = fractions }315      } else {316        integers = integers + 1317        if character == '0' && firstZero == 0 { firstZero = integers }318      }319    } else if character == '.' && !inFraction {320      inFraction = true321      pieces = pieces.push(".")322    } else if character == ',' && !inFraction {323      grouping = integers > 0324    } else {325      if character == '%' { percent = true }326      pieces = pieces.push("\\" + character.toText())327    }328  }329  let required = if firstZero > 0 { integers - firstZero + 1 } else { 0 }330  Pattern{pieces: pieces, integers: integers, minimumIntegers: required, fractions: fractions, minimumFractions: minimumFractions, grouping: grouping, percent: percent}331}332333/// The pattern's literals with integer digits laid right to left over the integer placeholders334/// (any excess at the first, or all of them before the point when there is no integer placeholder)335/// and fraction digits left to right over the fraction placeholders.336fn assemble(pattern: &Pattern, integer: Str, fraction: Str) -> Str {337  var out: Array[Str] = []338  var seen = 0339  var fractionIndex = 0340  var inFraction = false341  let digits = integer.chars()342  let excess = Math.max(0, digits.length() - pattern.integers)343  for piece in pattern.pieces {344    if piece.startsWith("\\") {345      out = out.push(piece.drop(1))346    } else if piece == "." {347      inFraction = true348      if pattern.integers == 0 { out = out.push(integer) }349      if !fraction.isEmpty() { out = out.push(".") }350    } else if inFraction {351      out = out.push(fraction.drop(fractionIndex).take(1))352      fractionIndex = fractionIndex + 1353    } else {354      let slot = pattern.integers - seen355      let taken = if seen == 0 { excess + 1 } else { 1 }356      let end = digits.length() - slot + 1357      let start = end - taken358      out = out.push(List.drop(&List.take(&digits, Math.max(0, end)), Math.max(0, start)).map(|character: Char| character.toText()).join(""))359      seen = seen + 1360    }361  }362  out.join("")363}364