example.rs

  1use std::{
  2    borrow::Cow,
  3    cell::RefCell,
  4    fmt::{self, Display},
  5    fs,
  6    io::Write,
  7    mem,
  8    path::{Path, PathBuf},
  9    sync::{Arc, OnceLock},
 10};
 11
 12use crate::headless::ZetaCliAppState;
 13use anyhow::{Context as _, Result, anyhow};
 14use clap::ValueEnum;
 15use cloud_zeta2_prompt::CURSOR_MARKER;
 16use collections::HashMap;
 17use edit_prediction_context::Line;
 18use futures::{
 19    AsyncWriteExt as _,
 20    lock::{Mutex, OwnedMutexGuard},
 21};
 22use futures::{FutureExt as _, future::Shared};
 23use gpui::{AppContext as _, AsyncApp, Entity, Task, http_client::Url};
 24use language::{Anchor, Buffer};
 25use project::{Project, ProjectPath};
 26use pulldown_cmark::CowStr;
 27use serde::{Deserialize, Serialize};
 28use util::{paths::PathStyle, rel_path::RelPath};
 29use zeta2::{Zeta, udiff::OpenedBuffers};
 30
 31use crate::paths::{REPOS_DIR, WORKTREES_DIR};
 32
 33const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff";
 34const EDIT_HISTORY_HEADING: &str = "Edit History";
 35const CURSOR_POSITION_HEADING: &str = "Cursor Position";
 36const EXPECTED_PATCH_HEADING: &str = "Expected Patch";
 37const EXPECTED_CONTEXT_HEADING: &str = "Expected Context";
 38const REPOSITORY_URL_FIELD: &str = "repository_url";
 39const REVISION_FIELD: &str = "revision";
 40
 41#[derive(Debug, Clone)]
 42pub struct NamedExample {
 43    pub name: String,
 44    pub example: Example,
 45}
 46
 47#[derive(Clone, Debug, Serialize, Deserialize)]
 48pub struct Example {
 49    pub repository_url: String,
 50    pub revision: String,
 51    pub uncommitted_diff: String,
 52    pub cursor_path: PathBuf,
 53    pub cursor_position: String,
 54    pub edit_history: String,
 55    pub expected_patch: String,
 56    pub expected_context: Vec<ExpectedContextEntry>,
 57}
 58
 59pub type ActualExcerpt = Excerpt;
 60
 61#[derive(Clone, Debug, Serialize, Deserialize)]
 62pub struct Excerpt {
 63    pub path: PathBuf,
 64    pub text: String,
 65}
 66
 67#[derive(Default, Clone, Debug, Serialize, Deserialize)]
 68pub struct ExpectedContextEntry {
 69    pub heading: String,
 70    pub alternatives: Vec<ExpectedExcerptSet>,
 71}
 72
 73#[derive(Default, Clone, Debug, Serialize, Deserialize)]
 74pub struct ExpectedExcerptSet {
 75    pub heading: String,
 76    pub excerpts: Vec<ExpectedExcerpt>,
 77}
 78
 79#[derive(Clone, Debug, Serialize, Deserialize)]
 80pub struct ExpectedExcerpt {
 81    pub path: PathBuf,
 82    pub text: String,
 83    pub required_lines: Vec<Line>,
 84}
 85
 86#[derive(ValueEnum, Debug, Clone)]
 87pub enum ExampleFormat {
 88    Json,
 89    Toml,
 90    Md,
 91}
 92
 93impl NamedExample {
 94    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
 95        let path = path.as_ref();
 96        let content = std::fs::read_to_string(path)?;
 97        let ext = path.extension();
 98
 99        match ext.and_then(|s| s.to_str()) {
100            Some("json") => Ok(Self {
101                name: path.file_stem().unwrap_or_default().display().to_string(),
102                example: serde_json::from_str(&content)?,
103            }),
104            Some("toml") => Ok(Self {
105                name: path.file_stem().unwrap_or_default().display().to_string(),
106                example: toml::from_str(&content)?,
107            }),
108            Some("md") => Self::parse_md(&content),
109            Some(_) => {
110                anyhow::bail!("Unrecognized example extension: {}", ext.unwrap().display());
111            }
112            None => {
113                anyhow::bail!(
114                    "Failed to determine example type since the file does not have an extension."
115                );
116            }
117        }
118    }
119
120    pub fn parse_md(input: &str) -> Result<Self> {
121        use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd};
122
123        let parser = Parser::new(input);
124
125        let mut named = NamedExample {
126            name: String::new(),
127            example: Example {
128                repository_url: String::new(),
129                revision: String::new(),
130                uncommitted_diff: String::new(),
131                cursor_path: PathBuf::new(),
132                cursor_position: String::new(),
133                edit_history: String::new(),
134                expected_patch: String::new(),
135                expected_context: Vec::new(),
136            },
137        };
138
139        let mut text = String::new();
140        let mut block_info: CowStr = "".into();
141
142        #[derive(PartialEq)]
143        enum Section {
144            UncommittedDiff,
145            EditHistory,
146            CursorPosition,
147            ExpectedExcerpts,
148            ExpectedPatch,
149            Other,
150        }
151
152        let mut current_section = Section::Other;
153
154        for event in parser {
155            match event {
156                Event::Text(line) => {
157                    text.push_str(&line);
158
159                    if !named.name.is_empty()
160                        && current_section == Section::Other
161                        // in h1 section
162                        && let Some((field, value)) = line.split_once('=')
163                    {
164                        match field.trim() {
165                            REPOSITORY_URL_FIELD => {
166                                named.example.repository_url = value.trim().to_string();
167                            }
168                            REVISION_FIELD => {
169                                named.example.revision = value.trim().to_string();
170                            }
171                            _ => {}
172                        }
173                    }
174                }
175                Event::End(TagEnd::Heading(HeadingLevel::H1)) => {
176                    if !named.name.is_empty() {
177                        anyhow::bail!(
178                            "Found multiple H1 headings. There should only be one with the name of the example."
179                        );
180                    }
181                    named.name = mem::take(&mut text);
182                }
183                Event::End(TagEnd::Heading(HeadingLevel::H2)) => {
184                    let title = mem::take(&mut text);
185                    current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) {
186                        Section::UncommittedDiff
187                    } else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) {
188                        Section::EditHistory
189                    } else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) {
190                        Section::CursorPosition
191                    } else if title.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) {
192                        Section::ExpectedPatch
193                    } else if title.eq_ignore_ascii_case(EXPECTED_CONTEXT_HEADING) {
194                        Section::ExpectedExcerpts
195                    } else {
196                        Section::Other
197                    };
198                }
199                Event::End(TagEnd::Heading(HeadingLevel::H3)) => {
200                    let heading = mem::take(&mut text);
201                    match current_section {
202                        Section::ExpectedExcerpts => {
203                            named.example.expected_context.push(ExpectedContextEntry {
204                                heading,
205                                alternatives: Vec::new(),
206                            });
207                        }
208                        _ => {}
209                    }
210                }
211                Event::End(TagEnd::Heading(HeadingLevel::H4)) => {
212                    let heading = mem::take(&mut text);
213                    match current_section {
214                        Section::ExpectedExcerpts => {
215                            let expected_context = &mut named.example.expected_context;
216                            let last_entry = expected_context.last_mut().unwrap();
217                            last_entry.alternatives.push(ExpectedExcerptSet {
218                                heading,
219                                excerpts: Vec::new(),
220                            })
221                        }
222                        _ => {}
223                    }
224                }
225                Event::End(TagEnd::Heading(level)) => {
226                    anyhow::bail!("Unexpected heading level: {level}");
227                }
228                Event::Start(Tag::CodeBlock(kind)) => {
229                    match kind {
230                        CodeBlockKind::Fenced(info) => {
231                            block_info = info;
232                        }
233                        CodeBlockKind::Indented => {
234                            anyhow::bail!("Unexpected indented codeblock");
235                        }
236                    };
237                }
238                Event::Start(_) => {
239                    text.clear();
240                    block_info = "".into();
241                }
242                Event::End(TagEnd::CodeBlock) => {
243                    let block_info = block_info.trim();
244                    match current_section {
245                        Section::UncommittedDiff => {
246                            named.example.uncommitted_diff = mem::take(&mut text);
247                        }
248                        Section::EditHistory => {
249                            named.example.edit_history.push_str(&mem::take(&mut text));
250                        }
251                        Section::CursorPosition => {
252                            named.example.cursor_path = block_info.into();
253                            named.example.cursor_position = mem::take(&mut text);
254                        }
255                        Section::ExpectedExcerpts => {
256                            let text = mem::take(&mut text);
257                            for excerpt in text.split("\n\n") {
258                                let (mut text, required_lines) = extract_required_lines(&excerpt);
259                                if !text.ends_with('\n') {
260                                    text.push('\n');
261                                }
262                                let alternatives = &mut named
263                                    .example
264                                    .expected_context
265                                    .last_mut()
266                                    .unwrap()
267                                    .alternatives;
268
269                                if alternatives.is_empty() {
270                                    alternatives.push(ExpectedExcerptSet {
271                                        heading: String::new(),
272                                        excerpts: vec![],
273                                    });
274                                }
275
276                                alternatives
277                                    .last_mut()
278                                    .unwrap()
279                                    .excerpts
280                                    .push(ExpectedExcerpt {
281                                        path: block_info.into(),
282                                        text,
283                                        required_lines,
284                                    });
285                            }
286                        }
287                        Section::ExpectedPatch => {
288                            named.example.expected_patch = mem::take(&mut text);
289                        }
290                        Section::Other => {}
291                    }
292                }
293                _ => {}
294            }
295        }
296
297        if named.example.cursor_path.as_path() == Path::new("")
298            || named.example.cursor_position.is_empty()
299        {
300            anyhow::bail!("Missing cursor position codeblock");
301        }
302
303        Ok(named)
304    }
305
306    pub fn write(&self, format: ExampleFormat, mut out: impl Write) -> Result<()> {
307        match format {
308            ExampleFormat::Json => Ok(serde_json::to_writer(out, &self.example)?),
309            ExampleFormat::Toml => {
310                Ok(out.write_all(toml::to_string_pretty(&self.example)?.as_bytes())?)
311            }
312            ExampleFormat::Md => Ok(write!(out, "{}", self)?),
313        }
314    }
315
316    pub async fn setup_project<'a>(
317        &'a self,
318        app_state: &Arc<ZetaCliAppState>,
319        repetitions: u16,
320        cx: &mut AsyncApp,
321    ) -> Result<(Entity<Project>, Vec<Entity<Zeta>>, OpenedBuffers<'a>)> {
322        let worktree_path = self.setup_worktree().await?;
323
324        static AUTHENTICATED: OnceLock<Shared<Task<()>>> = OnceLock::new();
325
326        AUTHENTICATED
327            .get_or_init(|| {
328                let client = app_state.client.clone();
329                cx.spawn(async move |cx| {
330                    client
331                        .sign_in_with_optional_connect(true, cx)
332                        .await
333                        .unwrap();
334                })
335                .shared()
336            })
337            .clone()
338            .await;
339
340        let project = cx.update(|cx| {
341            Project::local(
342                app_state.client.clone(),
343                app_state.node_runtime.clone(),
344                app_state.user_store.clone(),
345                app_state.languages.clone(),
346                app_state.fs.clone(),
347                None,
348                cx,
349            )
350        })?;
351
352        let worktree = project
353            .update(cx, |project, cx| {
354                project.create_worktree(&worktree_path, true, cx)
355            })?
356            .await?;
357        worktree
358            .read_with(cx, |worktree, _cx| {
359                worktree.as_local().unwrap().scan_complete()
360            })?
361            .await;
362
363        let buffer_store = project.read_with(cx, |project, _| project.buffer_store().clone())?;
364
365        let zetas = (0..repetitions)
366            .map(|_| {
367                let zeta = cx.new(|cx| {
368                    zeta2::Zeta::new(app_state.client.clone(), app_state.user_store.clone(), cx)
369                })?;
370
371                cx.subscribe(&buffer_store, {
372                    let project = project.clone();
373                    let zeta = zeta.clone();
374                    move |_, event, cx| match event {
375                        project::buffer_store::BufferStoreEvent::BufferAdded(buffer) => {
376                            zeta.update(cx, |zeta, cx| zeta.register_buffer(&buffer, &project, cx));
377                        }
378                        _ => {}
379                    }
380                })?
381                .detach();
382
383                anyhow::Ok(zeta)
384            })
385            .collect::<Result<Vec<_>>>()?;
386
387        let edited_buffers = self.apply_edit_history(&project, cx).await?;
388
389        anyhow::Ok((project, zetas, edited_buffers))
390    }
391
392    pub async fn setup_worktree(&self) -> Result<PathBuf> {
393        let (repo_owner, repo_name) = self.repo_name()?;
394        let file_name = self.file_name();
395
396        let repo_dir = REPOS_DIR.join(repo_owner.as_ref()).join(repo_name.as_ref());
397        let repo_lock = lock_repo(&repo_dir).await;
398
399        if !repo_dir.is_dir() {
400            fs::create_dir_all(&repo_dir)?;
401            run_git(&repo_dir, &["init"]).await?;
402            run_git(
403                &repo_dir,
404                &["remote", "add", "origin", &self.example.repository_url],
405            )
406            .await?;
407        }
408
409        // Resolve the example to a revision, fetching it if needed.
410        let revision = run_git(
411            &repo_dir,
412            &[
413                "rev-parse",
414                &format!("{}^{{commit}}", self.example.revision),
415            ],
416        )
417        .await;
418        let revision = if let Ok(revision) = revision {
419            revision
420        } else {
421            run_git(
422                &repo_dir,
423                &["fetch", "--depth", "1", "origin", &self.example.revision],
424            )
425            .await?;
426            let revision = run_git(&repo_dir, &["rev-parse", "FETCH_HEAD"]).await?;
427            if revision != self.example.revision {
428                run_git(&repo_dir, &["tag", &self.example.revision, &revision]).await?;
429            }
430            revision
431        };
432
433        // Create the worktree for this example if needed.
434        let worktree_path = WORKTREES_DIR.join(&file_name).join(repo_name.as_ref());
435        if worktree_path.is_dir() {
436            run_git(&worktree_path, &["clean", "--force", "-d"]).await?;
437            run_git(&worktree_path, &["reset", "--hard", "HEAD"]).await?;
438            run_git(&worktree_path, &["checkout", revision.as_str()]).await?;
439        } else {
440            let worktree_path_string = worktree_path.to_string_lossy();
441            run_git(&repo_dir, &["branch", "-f", &file_name, revision.as_str()]).await?;
442            run_git(
443                &repo_dir,
444                &["worktree", "add", "-f", &worktree_path_string, &file_name],
445            )
446            .await?;
447        }
448        drop(repo_lock);
449
450        // Apply the uncommitted diff for this example.
451        if !self.example.uncommitted_diff.is_empty() {
452            let mut apply_process = smol::process::Command::new("git")
453                .current_dir(&worktree_path)
454                .args(&["apply", "-"])
455                .stdin(std::process::Stdio::piped())
456                .spawn()?;
457
458            let mut stdin = apply_process.stdin.take().unwrap();
459            stdin
460                .write_all(self.example.uncommitted_diff.as_bytes())
461                .await?;
462            stdin.close().await?;
463            drop(stdin);
464
465            let apply_result = apply_process.output().await?;
466            if !apply_result.status.success() {
467                anyhow::bail!(
468                    "Failed to apply uncommitted diff patch with status: {}\nstderr:\n{}\nstdout:\n{}",
469                    apply_result.status,
470                    String::from_utf8_lossy(&apply_result.stderr),
471                    String::from_utf8_lossy(&apply_result.stdout),
472                );
473            }
474        }
475
476        Ok(worktree_path)
477    }
478
479    pub fn file_name(&self) -> String {
480        self.name
481            .chars()
482            .map(|c| {
483                if c.is_whitespace() {
484                    '-'
485                } else {
486                    c.to_ascii_lowercase()
487                }
488            })
489            .collect()
490    }
491
492    fn repo_name(&self) -> Result<(Cow<'_, str>, Cow<'_, str>)> {
493        // git@github.com:owner/repo.git
494        if self.example.repository_url.contains('@') {
495            let (owner, repo) = self
496                .example
497                .repository_url
498                .split_once(':')
499                .context("expected : in git url")?
500                .1
501                .split_once('/')
502                .context("expected / in git url")?;
503            Ok((
504                Cow::Borrowed(owner),
505                Cow::Borrowed(repo.trim_end_matches(".git")),
506            ))
507        // http://github.com/owner/repo.git
508        } else {
509            let url = Url::parse(&self.example.repository_url)?;
510            let mut segments = url.path_segments().context("empty http url")?;
511            let owner = segments
512                .next()
513                .context("expected owner path segment")?
514                .to_string();
515            let repo = segments
516                .next()
517                .context("expected repo path segment")?
518                .trim_end_matches(".git")
519                .to_string();
520            assert!(segments.next().is_none());
521
522            Ok((owner.into(), repo.into()))
523        }
524    }
525
526    pub async fn cursor_position(
527        &self,
528        project: &Entity<Project>,
529        cx: &mut AsyncApp,
530    ) -> Result<(Entity<Buffer>, Anchor)> {
531        let worktree = project.read_with(cx, |project, cx| {
532            project.visible_worktrees(cx).next().unwrap()
533        })?;
534        let cursor_path = RelPath::new(&self.example.cursor_path, PathStyle::Posix)?.into_arc();
535        let cursor_buffer = project
536            .update(cx, |project, cx| {
537                project.open_buffer(
538                    ProjectPath {
539                        worktree_id: worktree.read(cx).id(),
540                        path: cursor_path,
541                    },
542                    cx,
543                )
544            })?
545            .await?;
546        let cursor_offset_within_excerpt = self
547            .example
548            .cursor_position
549            .find(CURSOR_MARKER)
550            .ok_or_else(|| anyhow!("missing cursor marker"))?;
551        let mut cursor_excerpt = self.example.cursor_position.clone();
552        cursor_excerpt.replace_range(
553            cursor_offset_within_excerpt..(cursor_offset_within_excerpt + CURSOR_MARKER.len()),
554            "",
555        );
556        let excerpt_offset = cursor_buffer.read_with(cx, |buffer, _cx| {
557            let text = buffer.text();
558
559            let mut matches = text.match_indices(&cursor_excerpt);
560            let Some((excerpt_offset, _)) = matches.next() else {
561                anyhow::bail!(
562                    "\nExcerpt:\n\n{cursor_excerpt}\nBuffer text:\n{text}\n.Cursor excerpt did not exist in buffer."
563                );
564            };
565            assert!(matches.next().is_none());
566
567            Ok(excerpt_offset)
568        })??;
569
570        let cursor_offset = excerpt_offset + cursor_offset_within_excerpt;
571        let cursor_anchor =
572            cursor_buffer.read_with(cx, |buffer, _| buffer.anchor_after(cursor_offset))?;
573        Ok((cursor_buffer, cursor_anchor))
574    }
575
576    #[must_use]
577    pub async fn apply_edit_history(
578        &self,
579        project: &Entity<Project>,
580        cx: &mut AsyncApp,
581    ) -> Result<OpenedBuffers<'_>> {
582        zeta2::udiff::apply_diff(&self.example.edit_history, project, cx).await
583    }
584}
585
586fn extract_required_lines(text: &str) -> (String, Vec<Line>) {
587    const MARKER: &str = "[ZETA]";
588    let mut new_text = String::new();
589    let mut required_lines = Vec::new();
590    let mut skipped_lines = 0_u32;
591
592    for (row, mut line) in text.split('\n').enumerate() {
593        if let Some(marker_column) = line.find(MARKER) {
594            let mut strip_column = marker_column;
595
596            while strip_column > 0 {
597                let prev_char = line[strip_column - 1..].chars().next().unwrap();
598                if prev_char.is_whitespace() || ['/', '#'].contains(&prev_char) {
599                    strip_column -= 1;
600                } else {
601                    break;
602                }
603            }
604
605            let metadata = &line[marker_column + MARKER.len()..];
606            if metadata.contains("required") {
607                required_lines.push(Line(row as u32 - skipped_lines));
608            }
609
610            if strip_column == 0 {
611                skipped_lines += 1;
612                continue;
613            }
614
615            line = &line[..strip_column];
616        }
617
618        new_text.push_str(line);
619        new_text.push('\n');
620    }
621
622    new_text.pop();
623
624    (new_text, required_lines)
625}
626
627async fn run_git(repo_path: &Path, args: &[&str]) -> Result<String> {
628    let output = smol::process::Command::new("git")
629        .current_dir(repo_path)
630        .args(args)
631        .output()
632        .await?;
633
634    anyhow::ensure!(
635        output.status.success(),
636        "`git {}` within `{}` failed with status: {}\nstderr:\n{}\nstdout:\n{}",
637        args.join(" "),
638        repo_path.display(),
639        output.status,
640        String::from_utf8_lossy(&output.stderr),
641        String::from_utf8_lossy(&output.stdout),
642    );
643    Ok(String::from_utf8(output.stdout)?.trim().to_string())
644}
645
646impl Display for NamedExample {
647    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
648        write!(f, "# {}\n\n", self.name)?;
649        write!(
650            f,
651            "{REPOSITORY_URL_FIELD} = {}\n",
652            self.example.repository_url
653        )?;
654        write!(f, "{REVISION_FIELD} = {}\n\n", self.example.revision)?;
655
656        write!(f, "## {UNCOMMITTED_DIFF_HEADING}\n\n")?;
657        write!(f, "`````diff\n")?;
658        write!(f, "{}", self.example.uncommitted_diff)?;
659        write!(f, "`````\n")?;
660
661        if !self.example.edit_history.is_empty() {
662            write!(f, "`````diff\n{}`````\n", self.example.edit_history)?;
663        }
664
665        write!(
666            f,
667            "## {CURSOR_POSITION_HEADING}\n\n`````{}\n{}`````\n",
668            self.example.cursor_path.display(),
669            self.example.cursor_position
670        )?;
671        write!(f, "## {EDIT_HISTORY_HEADING}\n\n")?;
672
673        if !self.example.expected_patch.is_empty() {
674            write!(
675                f,
676                "\n## {EXPECTED_PATCH_HEADING}\n\n`````diff\n{}`````\n",
677                self.example.expected_patch
678            )?;
679        }
680
681        if !self.example.expected_context.is_empty() {
682            write!(f, "\n## {EXPECTED_CONTEXT_HEADING}\n\n")?;
683
684            for entry in &self.example.expected_context {
685                write!(f, "\n### {}\n\n", entry.heading)?;
686
687                let skip_h4 =
688                    entry.alternatives.len() == 1 && entry.alternatives[0].heading.is_empty();
689
690                for excerpt_set in &entry.alternatives {
691                    if !skip_h4 {
692                        write!(f, "\n#### {}\n\n", excerpt_set.heading)?;
693                    }
694
695                    for excerpt in &excerpt_set.excerpts {
696                        write!(
697                            f,
698                            "`````{}{}\n{}`````\n\n",
699                            excerpt
700                                .path
701                                .extension()
702                                .map(|ext| format!("{} ", ext.to_string_lossy()))
703                                .unwrap_or_default(),
704                            excerpt.path.display(),
705                            excerpt.text
706                        )?;
707                    }
708                }
709            }
710        }
711
712        Ok(())
713    }
714}
715
716thread_local! {
717    static REPO_LOCKS: RefCell<HashMap<PathBuf, Arc<Mutex<()>>>> = RefCell::new(HashMap::default());
718}
719
720#[must_use]
721pub async fn lock_repo(path: impl AsRef<Path>) -> OwnedMutexGuard<()> {
722    REPO_LOCKS
723        .with(|cell| {
724            cell.borrow_mut()
725                .entry(path.as_ref().to_path_buf())
726                .or_default()
727                .clone()
728        })
729        .lock_owned()
730        .await
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use indoc::indoc;
737    use pretty_assertions::assert_eq;
738
739    #[test]
740    fn test_extract_required_lines() {
741        let input = indoc! {"
742            zero
743            one // [ZETA] required
744            two
745            // [ZETA] something
746            three
747            four # [ZETA] required
748            five
749        "};
750
751        let expected_updated_input = indoc! {"
752            zero
753            one
754            two
755            three
756            four
757            five
758        "};
759
760        let expected_required_lines = vec![Line(1), Line(4)];
761
762        let (updated_input, required_lines) = extract_required_lines(input);
763        assert_eq!(updated_input, expected_updated_input);
764        assert_eq!(required_lines, expected_required_lines);
765    }
766}