xml_edits.rs

  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}