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

Parser.pudu

Pudu156 lines5.9 KB

GitHub ↗
1/** @Log.Domain.Parser.Module — message template text into tokens */2module PuduLangLog.Domain.Parser34import Std.Char as Char5import Std.List as List6import Std.Option as Option7import Std.Text as Text8import PuduLangLog as Log910/// The largest alignment width or hole position a template may name.11const LARGEST: Int = 21474836471213/// The template the text denotes. Text that does not form a valid hole is kept as literal text, so14/// every string parses.15export fn parse(text: Str) -> Log.Template {16  let characters = text.chars()17  var tokens: Array[Log.Token] = []18  if characters.isEmpty() { tokens = [Log.Literal("")] }19  var next = 020  while next < characters.length() {21    let (literal, afterLiteral) = literalAt(&characters, next)22    if afterLiteral > next { tokens = tokens.push(Log.Literal(literal)) }23    next = afterLiteral24    if next < characters.length() {25      let (token, afterHole) = holeAt(text, &characters, next)26      tokens = tokens.push(token)27      next = afterHole28    }29  }30  var holes: Array[Log.Hole] = []31  for token in tokens {32    if let Log.Placeholder(hole) = token { holes = holes.push(hole) }33  }34  Log.Template{text: text, tokens: tokens, holes: holes, binding: bindingOf(&holes)}35}3637/// Whether a name can be given to a property: any text that is not blank.38export fn isValidName(name: Str) -> Bool { !name.trim().isEmpty() }3940/// How the holes take their arguments: by position only when every hole is numbered.41fn bindingOf(holes: &Array[Log.Hole]) -> Log.Binding {42  if holes.isEmpty() { return Log.Unbound }43  for hole in holes {44    if hole.position == None { return Log.Named }45  }46  Log.Positional47}4849/// The literal text from `start` up to the next hole, with doubled braces read as one, and the50/// index after it.51fn literalAt(characters: &Array[Char], start: Int) -> (Str, Int) {52  var pieces: Array[Str] = []53  var index = start54  while index < characters.length() {55    let character = characters[index]56    let doubled = index + 1 < characters.length() && characters[index + 1] == character57    if character == '{' {58      if !doubled { return (pieces.join(""), index) }59      pieces = pieces.push("\{")60      index = index + 261    } else if character == '}' {62      pieces = pieces.push("\}")63      index = if doubled { index + 2 } else { index + 1 }64    } else {65      pieces = pieces.push(character.toText())66      index = index + 167    }68  }69  (pieces.join(""), index)70}7172/// The token starting at the opening brace at `start`, and the index after it: a hole when the73/// text up to the next closing brace is valid, and literal text otherwise.74fn holeAt(text: Str, characters: &Array[Char], start: Int) -> (Log.Token, Int) {75  var close = start + 176  while close < characters.length() && characters[close] != '}' { close = close + 1 }77  if close == characters.length() { return (Log.Literal(text.slice(start, close)), close) }78  let raw = text.slice(start, close + 1)79  let token = match holeOf(text.slice(start + 1, close), raw) {80    case Some(hole) => Log.Placeholder(hole)81    case None => Log.Literal(raw)82  }83  (token, close + 1)84}8586/// The hole the text between braces describes, when it is valid.87fn holeOf(content: Str, raw: Str) -> Option[Log.Hole] {88  let (head, format, alignment) = split(content)89  let hint = match List.first(&head.chars()) {90    case Some('@') => Log.Destructure91    case Some('$') => Log.Stringify92    case _ => Log.Default93  }94  let name = if hint == Log.Default { head } else { head.drop(1) }95  let characters = name.chars()96  let first = List.first(&characters) ?97  let numbered = Char.isDigit(first)98  if numbered && !Text.allChars(name, |character: Char| Char.isDigit(character)) { return None }99  if !numbered && !isIdentifier(&characters) { return None }100  let position = if numbered { numberOf(&characters) } else { None }101  let aligned = Option.andThen(alignment, |text: Str| alignmentOf(text))102  if aligned == None && alignment != None { return None }103  Some(Log.Hole{name: name, raw: raw, format: format, alignment: aligned, hint: hint, position: position})104}105106/// The name with its hint, the format after the first `:`, and the alignment after a `,` that107/// precedes it. An empty format is none; an empty alignment is kept as empty text, which no width108/// reads.109fn split(content: Str) -> (Str, Option[Str], Option[Str]) {110  let (head, formatPart) = Text.breakOn(content, ":")111  let (name, alignmentPart) = Text.breakOn(head, ",")112  let alignment = if alignmentPart.isEmpty() { None } else { Some(alignmentPart.drop(1)) }113  (name, formatOf(formatPart.drop(1)), alignment)114}115116/// A format, absent when empty.117fn formatOf(text: Str) -> Option[Str] { if text.isEmpty() { None } else { Some(text) } }118119/// The alignment text read as a width, left-aligned when it starts with `-`.120fn alignmentOf(text: Str) -> Option[Log.Alignment] {121  let left = text.startsWith("-")122  let digits = if left { text.drop(1) } else { text }123  let width = numberOf(&digits.chars()) ?124  Some(Log.Alignment{left: left, width: width})125}126127/// The characters read as a decimal number no larger than `LARGEST`, when they are all digits.128fn numberOf(characters: &Array[Char]) -> Option[Int] {129  if characters.isEmpty() { return None }130  var value = 0131  for character in characters {132    value = value * 10 + Char.toDigit(character) ?133    if value > LARGEST { return None }134  }135  Some(value)136}137138/// Whether the characters are identifiers joined by single dots, each starting with a letter or `_`.139fn isIdentifier(characters: &Array[Char]) -> Bool {140  var starting = true141  for character in characters {142    if starting {143      if !isLetter(character) && character != '_' { return false }144      starting = false145    } else if character == '.' {146      starting = true147    } else if !isLetter(character) && !Char.isDigit(character) && character != '_' {148      return false149    }150  }151  !starting152}153154/// Whether a character is a letter: an ASCII letter or any character beyond ASCII.155fn isLetter(character: Char) -> Bool { Char.isLetter(character) || !Char.isAscii(character) }156