
Numbers.pudu
Pudu364 lines15.2 KB
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 }131415const HEX: Str = "0123456789abcdef"161718const MOST_PRECISION: Int = 9919202122export 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}333435export fn digitsOfReal(value: Float64) -> Option[Digits] {36 let exact = show(value).toDecimal() ?37 Some(digitsOf(exact))38}39404142export 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}484950export 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}697071export 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}81828384export 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}9899100fn 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}108109110fn 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}130131132fn 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}136137138export 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}145146147fn 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}157158159fn 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}173174175fn 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}179180181fn sign(number: &Digits, places: Int) -> Str {182 if number.negative && !roundTo(number, number.exponent + places).digits.isEmpty() { "-" } else { "" }183}184185186fn 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}195196197fn joined(integer: Str, fraction: Str) -> Str {198 if fraction.isEmpty() { integer } else { integer + "." + fraction }199}200201202203fn 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}213214215216fn 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}225226227fn 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 }243244245246fn 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}259260261fn 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}286287288289fn 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}332333334335336fn 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