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

Expander.pudu

Pudu133 lines4.5 KB

GitHub ↗
1/** @Domain.Expander.Module — replaces references to other variables */2module PuduLangEnvironment.Domain.Expander34import Std.Char as Char5import Std.Option as Option6import Std.Text as Text78/** @Domain.Expander.Fallback — what a braced reference answers instead */9type Fallback10  = NoFallback11  | WhenUnsetOrEmpty(Str)12  | WhenUnset(Str)1314/// The value with every `$NAME` and `${NAME}` replaced by the known value of `NAME`, else its15/// process value, else nothing. A `$` after an odd run of backslashes is literal and every run16/// before a `$` is halved; an answered value is final text and is not expanded again.17export fn expand(value: Str, known: &Map[Str, Str], process: &Map[Str, Str]) -> Str {18  let chars = value.chars()19  let size = chars.length()20  var pieces: Array[Str] = []21  var index = 022  while index < size {23    let symbol = chars[index]24    if symbol == '\\' {25      let start = index26      while index < size && chars[index] == '\\' { index = index + 1 }27      let run = index - start28      if index < size && chars[index] == '$' {29        pieces = pieces.push("\\".repeat(run / 2))30        if run % 2 == 1 {31          pieces = pieces.push("$")32          index = index + 133        }34      } else {35        pieces = pieces.push("\\".repeat(run))36      }37    } else if symbol == '$' {38      let replaced = reference(&chars, index, known, process)39      pieces = pieces.push(replaced[0])40      index = replaced[1]41    } else {42      pieces = pieces.push(symbol.toText())43      index = index + 144    }45  }46  pieces.join("")47}4849/// The text a `$` at `at` stands for, and where reading resumes after it.50fn reference(chars: &Array[Char], at: Int, known: &Map[Str, Str], process: &Map[Str, Str]) -> (Str, Int) {51  let size = chars.length()52  let next = at + 153  if next < size && chars[next] == '{' {54    match closingBrace(chars, next + 1) {55      case Some(close) => (braced(Text.fromChars(&chars.slice(next + 1, close)), known, process), close + 1)56      case None => ("$", next)57    }58  } else if next < size && startsName(chars[next]) {59    var end = next + 160    while end < size && continuesName(chars[end]) { end = end + 1 }61    (resolve(Text.fromChars(&chars.slice(next, end)), &NoFallback, known, process), end)62  } else {63    ("$", next)64  }65}6667/// Where the brace balancing an opened one closes, reading from `from`.68fn closingBrace(chars: &Array[Char], from: Int) -> Option[Int] {69  var depth = 170  var at = from71  while at < chars.length() {72    let symbol = chars[at]73    if symbol == '{' {74      depth = depth + 175    } else if symbol == '}' {76      depth = depth - 177      if depth == 0 { return Some(at) }78    }79    at = at + 180  }81  None82}8384/// What the inside of `${…}` answers: its name, split from a default at the first top-level `:-`85/// or `-`.86fn braced(inner: Str, known: &Map[Str, Str], process: &Map[Str, Str]) -> Str {87  let chars = inner.chars()88  var depth = 089  var at = 090  while at < chars.length() {91    let symbol = chars[at]92    if symbol == '{' {93      depth = depth + 194    } else if symbol == '}' {95      depth = depth - 196    } else if depth == 0 && symbol == ':' && at + 1 < chars.length() && chars[at + 1] == '-' {97      return resolve(inner.take(at).trim(), &WhenUnsetOrEmpty(inner.drop(at + 2)), known, process)98    } else if depth == 0 && symbol == '-' {99      return resolve(inner.take(at).trim(), &WhenUnset(inner.drop(at + 1)), known, process)100    }101    at = at + 1102  }103  resolve(inner.trim(), &NoFallback, known, process)104}105106/// The value a name answers under its fallback.107fn resolve(name: Str, fallback: &Fallback, known: &Map[Str, Str], process: &Map[Str, Str]) -> Str {108  let found = lookup(name, known, process)109  match fallback {110    case WhenUnsetOrEmpty(text) => if Option.unwrapOr(found, "").isEmpty() { expand(text, known, process) } else { Option.unwrapOr(found, "") }111    case WhenUnset(text) => match found {112      case Some(held) => held113      case None => expand(text, known, process)114    }115    case NoFallback => Option.unwrapOr(found, "")116  }117}118119/// The known value of a name, else its process value; an empty name has neither.120fn lookup(name: Str, known: &Map[Str, Str], process: &Map[Str, Str]) -> Option[Str] {121  if name.isEmpty() { return None }122  match known.get(name) {123    case Some(held) => Some(held)124    case None => process.get(name)125  }126}127128/// Whether a character may start a name.129fn startsName(symbol: Char) -> Bool { Char.isLower(symbol) || Char.isUpper(symbol) || symbol == '_' }130131/// Whether a character may continue a name.132fn continuesName(symbol: Char) -> Bool { startsName(symbol) || Char.isDigit(symbol) }133