Add $$ escape mechanism for variable expansion

Allows literal $ signs in prompts and DOT files by writing $$. Also
refactors VariableExpansionTransform to use expand_vars instead of
string replace, fixing a substring-matching bug with $goal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-05 21:44:50 -05:00
parent 4605f821e8
commit 69a203c2a8
3 changed files with 68 additions and 6 deletions

View file

@ -189,7 +189,10 @@ pub fn expand_vars(source: &str, vars: &HashMap<String, String>) -> anyhow::Resu
while i < len {
if bytes[i] == b'$' {
let start = i + 1;
if start < len && (bytes[start].is_ascii_alphabetic() || bytes[start] == b'_') {
if start < len && bytes[start] == b'$' {
result.push('$');
i = start + 1;
} else if start < len && (bytes[start].is_ascii_alphabetic() || bytes[start] == b'_') {
let mut end = start + 1;
while end < len && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') {
end += 1;
@ -315,6 +318,27 @@ language = "python"
assert_eq!(expand_vars("costs $5", &vars).unwrap(), "costs $5");
}
#[test]
fn expand_escaped_dollar() {
let vars = HashMap::from([("name".to_string(), "world".to_string())]);
assert_eq!(
expand_vars("literal $$name here", &vars).unwrap(),
"literal $name here"
);
}
#[test]
fn expand_escaped_dollar_at_end() {
let vars = HashMap::new();
assert_eq!(expand_vars("trailing $$", &vars).unwrap(), "trailing $");
}
#[test]
fn expand_escaped_dollar_before_non_ident() {
let vars = HashMap::new();
assert_eq!(expand_vars("price is $$5", &vars).unwrap(), "price is $5");
}
#[test]
fn parse_toml_with_sandbox() {
let toml = r#"

View file

@ -1,3 +1,5 @@
use std::collections::HashMap;
use crate::graph::{AttrValue, Edge, Graph, Node};
use crate::stylesheet::{apply_stylesheet, parse_stylesheet};
@ -12,12 +14,14 @@ pub struct VariableExpansionTransform;
impl Transform for VariableExpansionTransform {
fn apply(&self, graph: &mut Graph) {
let goal = graph.goal().to_string();
let vars = HashMap::from([("goal".to_string(), goal)]);
for node in graph.nodes.values_mut() {
if let Some(AttrValue::String(prompt)) = node.attrs.get("prompt") {
if prompt.contains("$goal") {
let expanded = prompt.replace("$goal", &goal);
node.attrs
.insert("prompt".to_string(), AttrValue::String(expanded));
if let Ok(expanded) = crate::cli::run_config::expand_vars(prompt, &vars) {
if expanded != *prompt {
node.attrs
.insert("prompt".to_string(), AttrValue::String(expanded));
}
}
}
}
@ -192,6 +196,32 @@ mod tests {
assert!(!graph.nodes["plan"].attrs.contains_key("prompt"));
}
#[test]
fn variable_expansion_escaped_dollar_goal() {
let mut graph = Graph::new("test");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Fix bugs".to_string()),
);
let mut node = Node::new("plan");
node.attrs.insert(
"prompt".to_string(),
AttrValue::String("literal $$goal here".to_string()),
);
graph.nodes.insert("plan".to_string(), node);
let transform = VariableExpansionTransform;
transform.apply(&mut graph);
let prompt = graph.nodes["plan"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str)
.unwrap();
assert_eq!(prompt, "literal $goal here");
}
#[test]
fn stylesheet_transform_empty_stylesheet() {
let mut graph = Graph::new("test");

View file

@ -42,7 +42,15 @@ When launched with `arc run start run.toml`, Arc replaces `$repo_name`, `$repo_u
If a `$variable` in the DOT file has no matching entry in `[vars]`, Arc raises an error. This catches typos early — a misspelled `$langauge` fails immediately rather than passing a literal `$langauge` to the LLM.
A bare `$` not followed by an identifier character (e.g. `costs $5`) is left as-is.
### Escaping `$`
To include a literal `$` in the output, write `$$`:
```dot
test [prompt="The env var is $$HOME"]
```
This produces `The env var is $HOME` without treating `$HOME` as a variable reference. A bare `$` not followed by an identifier character (e.g. `costs $5`) does not need escaping.
## The `$goal` variable