///|
/// Values available while rendering one build edge.
///
/// The context is deliberately explicit: portable planning never reads the
/// process environment, so WASM-GC, JS, and native hosts render the same
/// command from the same inputs.
pub(all) struct ExpansionContext {
inputs : Array[String]
outputs : Array[String]
variables : Map[String, String]
} derive(Debug, Eq)
///|
pub fn ExpansionContext::new(
inputs~ : Array[String],
outputs~ : Array[String],
variables~ : Map[String, String],
) -> ExpansionContext {
{ inputs, outputs, variables }
}
///|
fn expansion_value(
name : String,
context : ExpansionContext,
resolving : Map[String, Bool],
depth : Int,
) -> Result[String, String] {
if depth > 32 {
return Err("variable expansion exceeded the maximum depth of 32")
}
if name == "in" {
return Ok(join_strings(context.inputs, " "))
}
if name == "out" {
return Ok(join_strings(context.outputs, " "))
}
if name == "in_newline" {
return Ok(join_strings(context.inputs, "\n"))
}
match context.variables.get(name) {
None => Ok("")
Some(value) => {
if resolving.contains(name) {
return Err("recursive variable expansion detected for $" + name)
}
resolving[name] = true
let result = expand_template(value, context, resolving, depth + 1)
resolving.remove(name)
result
}
}
}
///|
fn variable_name_end(text : String, start : Int) -> Int {
let mut cursor = start
while cursor < text.length() {
let ch = get_char_at(text, cursor)
if (ch >= 'a' && ch <= 'z') ||
(ch >= 'A' && ch <= 'Z') ||
(ch >= '0' && ch <= '9') ||
ch == '_' ||
ch == '.' {
cursor += 1
} else {
break
}
}
cursor
}
///|
fn closing_brace(text : String, start : Int) -> Int? {
let mut cursor = start
while cursor < text.length() {
if get_char_at(text, cursor) == '}' {
return Some(cursor)
}
cursor += 1
}
None
}
///|
fn expand_template(
text : String,
context : ExpansionContext,
resolving : Map[String, Bool],
depth : Int,
) -> Result[String, String] {
let mut result = ""
let mut cursor = 0
while cursor < text.length() {
let ch = get_char_at(text, cursor)
if ch != '$' {
result += "\{ch}"
cursor += 1
continue
}
if cursor + 1 >= text.length() {
result += "$"
cursor += 1
continue
}
let next = get_char_at(text, cursor + 1)
if next == '$' {
result += "$"
cursor += 2
continue
}
if next == '{' {
let end = closing_brace(text, cursor + 2)
match end {
None => return Err("unterminated variable reference")
Some(close) => {
let name = text[cursor + 2:close].to_owned()
match expansion_value(name, context, resolving, depth) {
Ok(value) => result += value
Err(error) => return Err(error)
}
cursor = close + 1
continue
}
}
}
let end = variable_name_end(text, cursor + 1)
if end == cursor + 1 {
result += "$"
cursor += 1
continue
}
let name = text[cursor + 1:end].to_owned()
match expansion_value(name, context, resolving, depth) {
Ok(value) => result += value
Err(error) => return Err(error)
}
cursor = end
}
Ok(result)
}
///|
/// Expand command placeholders. Missing named variables intentionally become
/// empty strings, matching the permissive behavior of common build tools.
pub fn expand_command_checked(
text : String,
context : ExpansionContext,
) -> Result[String, String] {
expand_template(text, context, Map([]), 0)
}
///|
/// Expand a command and return an empty string for invalid recursive input.
/// Call `expand_command_checked` when the caller needs the diagnostic.
pub fn expand_command(text : String, context : ExpansionContext) -> String {
match expand_command_checked(text, context) {
Ok(value) => value
Err(_) => ""
}
}
///|
pub fn BuildEdge::render_command_with_variables(
self : BuildEdge,
rules : Map[String, Rule],
variables : Map[String, String],
) -> Result[String, String] {
match rules.get(self.rule) {
None => Err("Unknown rule: " + self.rule)
Some(rule) =>
expand_command_checked(
rule.command,
ExpansionContext::new(
inputs=self.inputs,
outputs=self.outputs,
variables~,
),
)
}
}