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