1use std::collections::BTreeMap;
10
11use crate::engine::Span;
12
13pub fn interpolate(template: &str, vars: &BTreeMap<String, String>) -> String {
17 let bytes = template.as_bytes();
18 let mut out = String::with_capacity(template.len());
19 let mut i = 0;
20 while i < bytes.len() {
21 if bytes[i] != b'$' {
22 let ch = template[i..].chars().next().unwrap();
23 out.push(ch);
24 i += ch.len_utf8();
25 continue;
26 }
27 if template[i..].starts_with("$$") {
29 out.push('$');
30 i += 2;
31 continue;
32 }
33 let (name, consumed) = if template[i..].starts_with("${") {
35 match template[i + 2..].find('}') {
36 Some(rel) => (&template[i + 2..i + 2 + rel], 2 + rel + 1),
37 None => {
38 out.push('$');
39 i += 1;
40 continue;
41 }
42 }
43 } else {
44 let name_start = i + 1;
46 let mut j = name_start;
47 if j < bytes.len() && is_ident_start(bytes[j]) {
48 j += 1;
49 while j < bytes.len() && is_ident_continue(bytes[j]) {
50 j += 1;
51 }
52 }
53 (&template[name_start..j], j - i)
54 };
55
56 if name.is_empty() {
57 out.push('$');
58 i += 1;
59 continue;
60 }
61 match vars.get(name) {
62 Some(value) => out.push_str(value),
63 None => out.push_str(&template[i..i + consumed]),
65 }
66 i += consumed;
67 }
68 out
69}
70
71fn is_ident_start(b: u8) -> bool {
72 b.is_ascii_alphabetic() || b == b'_'
73}
74
75fn is_ident_continue(b: u8) -> bool {
76 b.is_ascii_alphanumeric() || b == b'_'
77}
78
79#[derive(Debug, Clone)]
81pub struct AppliedEdit {
82 pub span: Span,
84 pub before: String,
86 pub replacement: String,
88}
89
90pub fn splice(source: &str, edits: &[AppliedEdit]) -> String {
100 let mut ordered: Vec<&AppliedEdit> = edits.iter().collect();
101 ordered.sort_by_key(|e| std::cmp::Reverse(e.span.start_byte));
102 let mut out = source.to_string();
103 let mut applied_low = usize::MAX;
106 for edit in ordered {
107 let (start, end) = (edit.span.start_byte, edit.span.end_byte);
108 let in_range = start <= end
109 && end <= out.len()
110 && out.is_char_boundary(start)
111 && out.is_char_boundary(end);
112 let overlaps = end > applied_low;
113 debug_assert!(
114 !overlaps,
115 "splice received overlapping edits; engine should have deduped"
116 );
117 if !in_range || overlaps {
118 continue;
119 }
120 out.replace_range(start..end, &edit.replacement);
121 applied_low = start;
122 }
123 out
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 fn vars(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
131 pairs
132 .iter()
133 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
134 .collect()
135 }
136
137 #[test]
138 fn interpolates_bare_and_braced() {
139 let v = vars(&[("KEY", "userId"), ("NAME", "id")]);
140 assert_eq!(interpolate("{ $KEY: $NAME }", &v), "{ userId: id }");
141 assert_eq!(interpolate("${KEY}_${NAME}", &v), "userId_id");
142 }
143
144 #[test]
145 fn unknown_metavar_left_literal() {
146 let v = vars(&[("KEY", "x")]);
147 assert_eq!(interpolate("$KEY $UNKNOWN", &v), "x $UNKNOWN");
148 }
149
150 #[test]
151 fn escaped_dollar() {
152 let v = vars(&[]);
153 assert_eq!(interpolate("price is $$5", &v), "price is $5");
154 }
155
156 #[test]
157 fn splices_in_reverse_order() {
158 let source = "aaa bbb ccc";
159 let edits = vec![
160 AppliedEdit {
161 span: Span {
162 start_byte: 0,
163 end_byte: 3,
164 start_row: 0,
165 start_col: 0,
166 end_row: 0,
167 end_col: 3,
168 },
169 before: "aaa".into(),
170 replacement: "X".into(),
171 },
172 AppliedEdit {
173 span: Span {
174 start_byte: 8,
175 end_byte: 11,
176 start_row: 0,
177 start_col: 8,
178 end_row: 0,
179 end_col: 11,
180 },
181 before: "ccc".into(),
182 replacement: "ZZZZ".into(),
183 },
184 ];
185 assert_eq!(splice(source, &edits), "X bbb ZZZZ");
186 }
187}