Pudu programming language
Menu
Package

@chrismichaelps / pudu-lang-environment

Environment files for Pudu: load .env variables safely with layered files, discovery, expansion, typed reads, and secrets kept out of every message

0.1.0Apache-2.01

InstallClose

Parser.pudu

Pudu106 lines4.0 KB

GitHub ↗
1/** @Domain.Parser.Module — lines of an environment file to entries */2module PuduLangEnvironment.Domain.Parser34import Std.Char as Char5import Std.Text as Text6import PuduLangEnvironment.Constants.Names as Names7import PuduLangEnvironment.Domain.Expander as Expander8import PuduLangEnvironment.Domain.Layers as Layers910/** @Domain.Parser.Rules — the reading rules the options set */11export type Rules = { trimValues: Bool, exportSyntax: Bool, inlineComments: Bool, expansion: Bool, overwrite: Bool }1213/** @Domain.Parser.Unclosed — a quote opened and never closed */14export type Unclosed = { line: Int, key: Str }1516/** @Domain.Parser.Parsed — entries and what later expansions know */17export type Parsed = { entries: Array[Layers.Entry], known: Map[Str, Str] }1819/// The entries the lines assign, in order, with `known` grown by each one the overwrite rule20/// keeps. Expansion reads `known` before `process`. A quote never closed answers its opening line,21/// counted from 1, and its key.22export fn parse(rows: &Array[Str], rules: &Rules, known: Map[Str, Str], process: &Map[Str, Str]) -> Result[Parsed, Unclosed] {23  var entries: Array[Layers.Entry] = []24  var seen = known25  var index = 026  let size = rows.length()27  while index < size {28    let line = rows[index]29    let number = index + 130    index = index + 131    let visible = Text.trimStart(line)32    let equals = line.indexOf("=")33    if visible.isEmpty() || visible.startsWith("#") || equals < 1 { continue }34    let key = keyOf(line.take(equals), rules.exportSyntax)35    if key.isEmpty() { continue }36    let raw = line.drop(equals + 1)37    let opened = Text.trimStart(raw)38    var value = ""39    if opened.startsWith("'") || opened.startsWith("\"") {40      let quote = opened.take(1)41      var body = ""42      var current = opened.drop(1)43      var close = closingQuote(current, quote)44      while close < 0 {45        if index >= size { return Err(Unclosed{line: number, key: key}) }46        body = body + current + "\n"47        current = rows[index]48        index = index + 149        close = closingQuote(current, quote)50      }51      let unquoted = (body + current.take(close)).replace("\\" + quote, quote)52      let expanded = if rules.expansion && quote == "\"" { Expander.expand(unquoted, &seen, process) } else { unquoted }53      value = expanded.replace("\\\\", "\\")54    } else {55      let bare = if rules.inlineComments { withoutComment(raw) } else { raw }56      value = if rules.expansion { Expander.expand(bare, &seen, process) } else { bare }57    }58    if rules.trimValues { value = value.trim() }59    seen = Layers.keep(&seen, key, value, rules.overwrite)60    entries = entries.push(Layers.Entry{key: key, value: value})61  }62  Ok(Parsed{entries: entries, known: seen})63}6465/// The lines of a text split on `\n`, each without the `\r` that ends a CRLF line, and without a66/// byte-order mark at the start of the text.67export fn lines(text: Str) -> Array[Str] {68  Text.stripPrefix(text, Names.BYTE_ORDER_MARK).split("\n").map(|line: Str| if line.endsWith("\r") { line.take(line.length() - 1) } else { line })69}7071/// The key written before `=`, trimmed, without a leading `export` word when export syntax is on.72fn keyOf(text: Str, exportSyntax: Bool) -> Str {73  let key = text.trim()74  if exportSyntax && key.startsWith(Names.EXPORT_KEYWORD) {75    let rest = key.drop(Names.EXPORT_KEYWORD.length())76    if Text.trimStart(rest) != rest { return rest.trim() }77  }78  key79}8081/// Where the first `quote` not escaped by an odd run of backslashes sits, or -1.82fn closingQuote(text: Str, quote: Str) -> Int {83  let chars = text.chars()84  var escaped = false85  var at = 086  while at < chars.length() {87    let symbol = chars[at].toText()88    if symbol == quote && !escaped { return at }89    escaped = symbol == "\\" && !escaped90    at = at + 191  }92  -193}9495/// The value without the comment that starts at the first `#` following whitespace, and without96/// the whitespace before it.97fn withoutComment(value: Str) -> Str {98  let chars = value.chars()99  var at = 1100  while at < chars.length() {101    if chars[at] == '#' && Char.isWhitespace(chars[at - 1]) { return Text.trimEnd(value.take(at)) }102    at = at + 1103  }104  value105}106