1use anyhow::{Context as _, Result, anyhow};
2use language::{Anchor, BufferSnapshot, OffsetRangeExt as _, TextBufferSnapshot};
3use std::ops::Range;
4use std::path::Path;
5use std::sync::Arc;
6
7pub async fn parse_xml_edits<'a>(
8 input: &'a str,
9 get_buffer: impl Fn(&Path) -> Option<(&'a BufferSnapshot, &'a [Range<Anchor>])> + Send,
10) -> Result<(&'a BufferSnapshot, Vec<(Range<Anchor>, Arc<str>)>)> {
11 parse_xml_edits_inner(input, get_buffer)
12 .await
13 .with_context(|| format!("Failed to parse XML edits:\n{input}"))
14}
15
16async fn parse_xml_edits_inner<'a>(
17 mut input: &'a str,
18 get_buffer: impl Fn(&Path) -> Option<(&'a BufferSnapshot, &'a [Range<Anchor>])> + Send,
19) -> Result<(&'a BufferSnapshot, Vec<(Range<Anchor>, Arc<str>)>)> {
20 let edits_tag = parse_tag(&mut input, "edits")?.context("No edits tag")?;
21
22 input = edits_tag.body;
23
24 let file_path = edits_tag
25 .attributes
26 .trim_start()
27 .strip_prefix("path")
28 .context("no file attribute on edits tag")?
29 .trim_end()
30 .strip_prefix('=')
31 .context("no value for path attribute")?
32 .trim()
33 .trim_start_matches('"')
34 .trim_end_matches('"');
35
36 let (buffer, context_ranges) = get_buffer(file_path.as_ref())
37 .with_context(|| format!("no buffer for file {file_path}"))?;
38
39 let mut edits = vec![];
40 while let Some(old_text_tag) = parse_tag(&mut input, "old_text")? {
41 let new_text_tag =
42 parse_tag(&mut input, "new_text")?.context("no new_text tag following old_text")?;
43 edits.extend(resolve_new_text_old_text_in_buffer(
44 new_text_tag.body,
45 old_text_tag.body,
46 buffer,
47 context_ranges,
48 )?);
49 }
50
51 Ok((buffer, edits))
52}
53
54fn resolve_new_text_old_text_in_buffer(
55 new_text: &str,
56 old_text: &str,
57 buffer: &TextBufferSnapshot,
58 ranges: &[Range<Anchor>],
59) -> Result<impl Iterator<Item = (Range<Anchor>, Arc<str>)>, anyhow::Error> {
60 let context_offset = if old_text.is_empty() {
61 Ok(0)
62 } else {
63 let mut offset = None;
64 for range in ranges {
65 let range = range.to_offset(buffer);
66 let text = buffer.text_for_range(range.clone()).collect::<String>();
67 for (match_offset, _) in text.match_indices(old_text) {
68 if let Some(offset) = offset {
69 let offset_match_point = buffer.offset_to_point(offset);
70 let second_match_point = buffer.offset_to_point(range.start + match_offset);
71 anyhow::bail!(
72 "old_text is not unique enough:\n{}\nFound at {:?} and {:?}",
73 old_text,
74 offset_match_point,
75 second_match_point
76 );
77 }
78 offset = Some(range.start + match_offset);
79 }
80 }
81 offset.ok_or_else(|| {
82 #[cfg(debug_assertions)]
83 if let Some(closest_match) = closest_old_text_match(buffer, old_text) {
84 log::info!(
85 "Closest `old_text` match: {}",
86 pretty_assertions::StrComparison::new(old_text, &closest_match)
87 )
88 }
89 anyhow!("Failed to match old_text:\n{}", old_text)
90 })
91 }?;
92
93 let edits_within_hunk = language::text_diff(&old_text, &new_text);
94 Ok(edits_within_hunk
95 .into_iter()
96 .map(move |(inner_range, inner_text)| {
97 (
98 buffer.anchor_after(context_offset + inner_range.start)
99 ..buffer.anchor_before(context_offset + inner_range.end),
100 inner_text,
101 )
102 }))
103}
104
105#[cfg(debug_assertions)]
106fn closest_old_text_match(buffer: &TextBufferSnapshot, old_text: &str) -> Option<String> {
107 let buffer_text = buffer.text();
108 let mut cursor = 0;
109 let len = old_text.len();
110
111 let mut min_score = usize::MAX;
112 let mut min_start = 0;
113
114 while cursor + len <= buffer_text.len() {
115 let candidate = &buffer_text[cursor..cursor + len];
116 let score = strsim::levenshtein(candidate, old_text);
117 if score < min_score {
118 min_score = score;
119 min_start = cursor;
120 }
121 cursor += 1;
122 }
123 if min_score != usize::MAX {
124 Some(buffer_text[min_start..min_start + len].to_string())
125 } else {
126 None
127 }
128}
129
130struct ParsedTag<'a> {
131 attributes: &'a str,
132 body: &'a str,
133}
134
135fn parse_tag<'a>(input: &mut &'a str, tag: &str) -> Result<Option<ParsedTag<'a>>> {
136 let open_tag = format!("<{}", tag);
137 let close_tag = format!("</{}>", tag);
138 let Some(start_ix) = input.find(&open_tag) else {
139 return Ok(None);
140 };
141 let start_ix = start_ix + open_tag.len();
142 let closing_bracket_ix = start_ix
143 + input[start_ix..]
144 .find('>')
145 .with_context(|| format!("missing > after {tag}"))?;
146 let attributes = &input[start_ix..closing_bracket_ix].trim();
147 let end_ix = closing_bracket_ix
148 + input[closing_bracket_ix..]
149 .find(&close_tag)
150 .with_context(|| format!("no `{close_tag}` tag"))?;
151 let body = &input[closing_bracket_ix + '>'.len_utf8()..end_ix];
152 let body = body.strip_prefix('\n').unwrap_or(body);
153 *input = &input[end_ix + close_tag.len()..];
154 Ok(Some(ParsedTag { attributes, body }))
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use gpui::TestAppContext;
161 use indoc::indoc;
162 use language::Point;
163 use project::{FakeFs, Project};
164 use serde_json::json;
165 use settings::SettingsStore;
166 use util::path;
167
168 #[test]
169 fn test_parse_tags() {
170 let mut input = indoc! {r#"
171 Prelude
172 <tag attr="foo">
173 tag value
174 </tag>
175 "# };
176 let parsed = parse_tag(&mut input, "tag").unwrap().unwrap();
177 assert_eq!(parsed.attributes, "attr=\"foo\"");
178 assert_eq!(parsed.body, "tag value\n");
179 assert_eq!(input, "\n");
180 }
181
182 #[gpui::test]
183 async fn test_parse_xml_edits(cx: &mut TestAppContext) {
184 let fs = init_test(cx);
185
186 let buffer_1_text = indoc! {r#"
187 one two three four
188 five six seven eight
189 nine ten eleven twelve
190 "# };
191
192 fs.insert_tree(
193 path!("/root"),
194 json!({
195 "file1": buffer_1_text,
196 }),
197 )
198 .await;
199
200 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
201 let buffer = project
202 .update(cx, |project, cx| {
203 project.open_local_buffer(path!("/root/file1"), cx)
204 })
205 .await
206 .unwrap();
207 let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
208
209 let edits = indoc! {r#"
210 <edits path="root/file1">
211 <old_text>
212 five six seven eight
213 </old_text>
214 <new_text>
215 five SIX seven eight!
216 </new_text>
217 </edits>
218 "#};
219
220 let (buffer, edits) = parse_xml_edits(edits, |_path| {
221 Some((&buffer_snapshot, &[(Anchor::MIN..Anchor::MAX)] as &[_]))
222 })
223 .await
224 .unwrap();
225
226 let edits = edits
227 .into_iter()
228 .map(|(range, text)| (range.to_point(&buffer), text))
229 .collect::<Vec<_>>();
230 assert_eq!(
231 edits,
232 &[
233 (Point::new(1, 5)..Point::new(1, 8), "SIX".into()),
234 (Point::new(1, 20)..Point::new(1, 20), "!".into())
235 ]
236 );
237 }
238
239 fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
240 cx.update(|cx| {
241 let settings_store = SettingsStore::test(cx);
242 cx.set_global(settings_store);
243 });
244
245 FakeFs::new(cx.background_executor.clone())
246 }
247}