// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// What a reference to a variable that has no value means.
#derive(Eq, Debug)
pub(all) enum Unset {
/// A reference to nothing is an error. The default, and what `set -u` does:
/// a missing setting is nearly always the bug, and a diagnostic at the point
/// of use beats an empty string threaded through the program.
Fail
/// A missing variable reads as the empty string. What a shell does without
/// `set -u`, and what most configuration loaders do.
Empty
/// A missing variable leaves the reference as written, `${A}`, so a later
/// stage can fill it in.
Keep
}
///|
pub extend Unset with Eq::{equal, not_equal}
///|
pub extend Unset with Debug::{to_repr}
///|
/// How far to expand.
#derive(Eq, Debug)
pub(all) struct Knobs {
/// What a reference to a variable with no value means.
unset : Unset
}
///|
pub extend Knobs with Eq::{equal, not_equal}
///|
pub extend Knobs with Debug::{to_repr}
///|
/// A reference to an unset variable is an error.
pub let strict : Knobs = { unset: Fail, }
///|
/// A reference to an unset variable reads as empty.
pub let lenient : Knobs = { unset: Empty, }
///|
/// A reference to an unset variable is left as written.
pub let deferred : Knobs = { unset: Keep, }
///|
/// The limit on nested forms. `${A:-${B:-${C}}}` is three; a text that nests
/// itself a thousand times is a mistake, and the recursion has to stop
/// somewhere short of the stack.
pub let depth : Int32 = 32
///|
/// The variables a text may refer to, and where `${A:=b}` writes.
///
/// Reading is one function and writing is another rather than a map, because
/// the thing a caller has is usually not a map: the process environment cannot
/// be written, a layered configuration writes to its override layer, and a test
/// writes to a small table. A `Vars` with no `set` is read-only, and an
/// assignment through it is refused rather than dropped.
pub(all) struct Vars {
/// The value of a variable, or nothing when it has none.
get : (String) -> String?
/// Where an assignment goes, when there is anywhere to put it.
set : ((String, String) -> Unit)?
}
///|
/// The variables of a function, which cannot be assigned to.
pub fn Vars::of(get : (String) -> String?) -> Vars {
{ get, set: None, }
}
///|
/// The variables of a function, which an assignment also updates.
pub fn Vars::writing(
get : (String) -> String?,
set : (String, String) -> Unit,
) -> Vars {
{ get, set: Some(set), }
}
///|
/// The entries of a map, which an assignment updates.
pub fn Vars::of_map(entries : Map[String, String]) -> Vars {
{
get: fn(name) { entries.get(name) },
set: Some(fn(name, value) { entries.set(name, value) }),
}
}
///|
/// The value of `name`, or nothing.
pub fn Vars::lookup(self : Vars, name : String) -> String? {
(self.get)(name)
}
///|
/// Write `name`, answering whether there was anywhere to write it.
pub fn Vars::write(self : Vars, name : String, value : String) -> Bool {
match self.set {
Some(write) => {
write(name, value)
true
}
None => false
}
}
///|
/// No variables at all.
pub fn Vars::none() -> Vars {
{ get: fn(_) { None }, set: None, }
}
///|
priv struct Reader {
src : StringView
vars : Vars
knobs : Knobs
}
///|
fn Reader::spot(self : Reader, index : Int32) -> At {
let mut line = 1
let mut column = 1
let mut i = 0
while i < index && i < self.src.length() {
if self.src.at(i).to_int() == 0x0A {
line += 1
column = 1
} else {
column += 1
}
i += 1
}
{ at: index, line, column, }
}
///|
/// Whether `c` can start a name.
fn starts_name(c : Int32) -> Bool {
(c >= 'a'.to_int() && c <= 'z'.to_int()) ||
(c >= 'A'.to_int() && c <= 'Z'.to_int()) ||
c == '_'.to_int()
}
///|
/// Whether `c` can continue a name.
fn continues_name(c : Int32) -> Bool {
starts_name(c) || (c >= '0'.to_int() && c <= '9'.to_int())
}
///|
/// A name is a letter or an underscore followed by letters, digits, underscores.
fn is_name(name : String) -> Bool {
let view = name[:]
if view.length() == 0 || !starts_name(view.at(0).to_int()) {
return false
}
let mut i = 1
while i < view.length() {
if !continues_name(view.at(i).to_int()) {
return false
}
i += 1
}
true
}
///|
/// The index of the `}` that closes the expansion opened just before `from`,
/// or nothing when the text ends first.
fn Reader::close_of(self : Reader, from : Int32, to : Int32) -> Int32? {
let mut open_count = 1
let mut i = from
while i < to {
let c = self.src.at(i).to_int()
if c == '$'.to_int() &&
i + 1 < to &&
self.src.at(i + 1).to_int() == '{'.to_int() {
open_count += 1
i += 2
continue
}
if c == '}'.to_int() {
open_count -= 1
if open_count == 0 {
return Some(i)
}
}
i += 1
}
None
}
///|
/// The value of a variable, or what the policy says to do about its absence.
fn Reader::value_or(
self : Reader,
name : String,
open : Int32,
close : Int32,
out : StringBuilder,
) -> String? raise Refused {
match self.vars.lookup(name) {
Some(value) => Some(value)
None =>
match self.knobs.unset {
Fail => raise Undefined(name~, at=self.spot(open))
Empty => Some("")
Keep => {
out.write_string(self.src[open:close].to_owned())
None
}
}
}
}
///|
/// Expand `text[from:to]` into `out`.
fn Reader::run(
self : Reader,
from : Int32,
to : Int32,
level : Int32,
out : StringBuilder,
) -> Unit raise Refused {
if level > depth {
raise TooDeep(at=self.spot(from), limit=depth)
}
let mut i = from
while i < to {
let c = self.src.at(i).to_int()
if c == '\\'.to_int() {
if i + 1 < to {
let next = self.src.at(i + 1).to_int()
if next == '$'.to_int() ||
next == '\\'.to_int() ||
next == '"'.to_int() ||
next == '`'.to_int() {
out.write_char(next.unsafe_to_char())
i += 2
continue
}
}
out.write_char('\\')
i += 1
continue
}
if c != '$'.to_int() {
out.write_char(c.unsafe_to_char())
i += 1
continue
}
if i + 1 >= to {
out.write_char('$')
i += 1
continue
}
let next = self.src.at(i + 1).to_int()
if next == '{'.to_int() {
let close = match self.close_of(i + 2, to) {
Some(index) => index
None => raise Unclosed(at=self.spot(i))
}
self.brace(i, i + 2, close, level, out)
i = close + 1
continue
}
if starts_name(next) {
let mut end = i + 1
while end < to && continues_name(self.src.at(end).to_int()) {
end += 1
}
let name = self.src[i + 1:end].to_owned()
match self.value_or(name, i, end, out) {
Some(value) => out.write_string(value)
None => ()
}
i = end
continue
}
out.write_char('$')
i += 1
}
}
///|
/// One `${…}` form, from the `$` at `open` to the body in `[from:to)`.
fn Reader::brace(
self : Reader,
open : Int32,
from : Int32,
to : Int32,
level : Int32,
out : StringBuilder,
) -> Unit raise Refused {
if from >= to {
raise BadName(at=self.spot(open), got="")
}
// `${#A}` is the length of the value.
if self.src.at(from).to_int() == '#'.to_int() {
let name = self.src[from + 1:to].to_owned()
if !is_name(name) {
raise BadName(at=self.spot(from), got=name)
}
match self.value_or(name, open, to + 1, out) {
Some(value) => out.write_string(value.length().to_string())
None => ()
}
return
}
let mut end = from
while end < to && continues_name(self.src.at(end).to_int()) {
end += 1
}
let name = self.src[from:end].to_owned()
if !is_name(name) {
raise BadName(at=self.spot(from), got=name)
}
if end == to {
match self.value_or(name, open, to + 1, out) {
Some(value) => out.write_string(value)
None => ()
}
return
}
// `:` before an operator means "unset or empty".
let colon = self.src.at(end).to_int() == ':'.to_int()
let at = if colon { end + 1 } else { end }
if at >= to {
raise BadOperator(at=self.spot(end), got=self.src[end:to].to_owned())
}
let word_from = at + 1
match self.src.at(at).to_int() {
'-' => self.defaulted(name, open, word_from, to, level, out, colon, false)
'=' => self.defaulted(name, open, word_from, to, level, out, colon, true)
'?' => self.required(name, open, word_from, to, level, out, colon)
'+' => self.alternate(name, word_from, to, level, out, colon)
_ => raise BadOperator(at=self.spot(at), got=self.src[at:to].to_owned())
}
}
///|
/// The word `[from:to)`, expanded, as a string of its own.
fn Reader::word(
self : Reader,
from : Int32,
to : Int32,
level : Int32,
) -> String raise Refused {
let out = StringBuilder()
self.run(from, to, level + 1, out)
out.to_string()
}
///|
/// `${A-word}` and `${A:-word}`: the word when unset (or empty), the value
/// otherwise. `${A:=word}` also writes the word back.
fn Reader::defaulted(
self : Reader,
name : String,
open : Int32,
word_from : Int32,
to : Int32,
level : Int32,
out : StringBuilder,
colon : Bool,
assign : Bool,
) -> Unit raise Refused {
let absent = match self.vars.lookup(name) {
None => true
Some(value) => colon && value == ""
}
if !absent {
out.write_string(self.vars.lookup(name).unwrap())
return
}
let filled = self.word(word_from, to, level)
if assign && !self.vars.write(name, filled) {
raise ReadOnly(name~, at=self.spot(open))
}
out.write_string(filled)
}
///|
/// `${A:?msg}`: a message when unset (or empty), the value otherwise.
fn Reader::required(
self : Reader,
name : String,
open : Int32,
word_from : Int32,
to : Int32,
level : Int32,
out : StringBuilder,
colon : Bool,
) -> Unit raise Refused {
let present = match self.vars.lookup(name) {
None => false
Some(value) => !(colon && value == "")
}
if present {
out.write_string(self.vars.lookup(name).unwrap())
return
}
let why = self.word(word_from, to, level) catch {
_ => self.src[word_from:to].to_owned()
}
raise Required(name~, why~, at=self.spot(open))
}
///|
/// `${A:+word}`: the word when set (and not empty), nothing otherwise.
fn Reader::alternate(
self : Reader,
name : String,
word_from : Int32,
to : Int32,
level : Int32,
out : StringBuilder,
colon : Bool,
) -> Unit raise Refused {
let present = match self.vars.lookup(name) {
None => false
Some(value) => !(colon && value == "")
}
if present {
out.write_string(self.word(word_from, to, level))
}
}
///|
/// Expand a text in the POSIX shell's parameter syntax.
///
/// `${A}`, `$A`, `${A:-b}`, `${A-b}`, `${A:=b}`, `${A=b}`, `${A:?msg}`, `${A?msg}`,
/// `${A:+b}`, `${A+b}` and `${#A}`, with `\` escaping `$`, `\`, `"` and a
/// backtick, and the word inside any of the forms expanded in turn. The colon
/// forms also treat an empty value as missing, as the shell does.
///
/// A variable with no value is an error by default: a caller who knows an empty
/// string is meant asks for [`lenient`], and one that expands in two passes asks
/// for `{ unset: Keep }`.
pub fn expand(
text : StringView,
vars : Vars,
knobs? : Knobs,
) -> String raise Refused {
let reader = { src: text, vars, knobs: knobs.unwrap_or(strict), }
let out = StringBuilder()
reader.run(0, text.length(), 0, out)
out.to_string()
}