repl_editor.rs

  1//! REPL operations on an [`Editor`].
  2
  3use std::ops::Range;
  4use std::sync::Arc;
  5
  6use anyhow::{Context as _, Result};
  7use editor::{Editor, MultiBufferOffset};
  8use gpui::{App, Entity, WeakEntity, Window, prelude::*};
  9use language::{BufferSnapshot, Language, LanguageName, Point};
 10use project::{ProjectItem as _, WorktreeId};
 11
 12use crate::repl_store::ReplStore;
 13use crate::session::SessionEvent;
 14use crate::{
 15    ClearOutputs, Interrupt, JupyterSettings, KernelSpecification, Restart, Session, Shutdown,
 16};
 17
 18pub fn assign_kernelspec(
 19    kernel_specification: KernelSpecification,
 20    weak_editor: WeakEntity<Editor>,
 21    window: &mut Window,
 22    cx: &mut App,
 23) -> Result<()> {
 24    let store = ReplStore::global(cx);
 25    if !store.read(cx).is_enabled() {
 26        return Ok(());
 27    }
 28
 29    let worktree_id = crate::repl_editor::worktree_id_for_editor(weak_editor.clone(), cx)
 30        .context("editor is not in a worktree")?;
 31
 32    store.update(cx, |store, cx| {
 33        store.set_active_kernelspec(worktree_id, kernel_specification.clone(), cx);
 34    });
 35
 36    let fs = store.read(cx).fs().clone();
 37
 38    if let Some(session) = store.read(cx).get_session(weak_editor.entity_id()).cloned() {
 39        // Drop previous session, start new one
 40        session.update(cx, |session, cx| {
 41            session.clear_outputs(cx);
 42            session.shutdown(window, cx);
 43            cx.notify();
 44        });
 45    }
 46
 47    let session =
 48        cx.new(|cx| Session::new(weak_editor.clone(), fs, kernel_specification, window, cx));
 49
 50    weak_editor
 51        .update(cx, |_editor, cx| {
 52            cx.notify();
 53
 54            cx.subscribe(&session, {
 55                let store = store.clone();
 56                move |_this, _session, event, cx| match event {
 57                    SessionEvent::Shutdown(shutdown_event) => {
 58                        store.update(cx, |store, _cx| {
 59                            store.remove_session(shutdown_event.entity_id());
 60                        });
 61                    }
 62                }
 63            })
 64            .detach();
 65        })
 66        .ok();
 67
 68    store.update(cx, |store, _cx| {
 69        store.insert_session(weak_editor.entity_id(), session.clone());
 70    });
 71
 72    Ok(())
 73}
 74
 75pub fn run(
 76    editor: WeakEntity<Editor>,
 77    move_down: bool,
 78    window: &mut Window,
 79    cx: &mut App,
 80) -> Result<()> {
 81    let store = ReplStore::global(cx);
 82    if !store.read(cx).is_enabled() {
 83        return Ok(());
 84    }
 85
 86    let editor = editor.upgrade().context("editor was dropped")?;
 87    let selected_range = editor
 88        .update(cx, |editor, cx| {
 89            editor
 90                .selections
 91                .newest_adjusted(&editor.display_snapshot(cx))
 92        })
 93        .range();
 94    let multibuffer = editor.read(cx).buffer().clone();
 95    let Some(buffer) = multibuffer.read(cx).as_singleton() else {
 96        return Ok(());
 97    };
 98
 99    let Some(project_path) = buffer.read(cx).project_path(cx) else {
100        return Ok(());
101    };
102
103    let (runnable_ranges, next_cell_point) =
104        runnable_ranges(&buffer.read(cx).snapshot(), selected_range, cx);
105
106    for runnable_range in runnable_ranges {
107        let Some(language) = multibuffer.read(cx).language_at(runnable_range.start, cx) else {
108            continue;
109        };
110
111        let kernel_specification = store
112            .read(cx)
113            .active_kernelspec(project_path.worktree_id, Some(language.clone()), cx)
114            .with_context(|| format!("No kernel found for language: {}", language.name()))?;
115
116        let fs = store.read(cx).fs().clone();
117
118        let session = if let Some(session) = store.read(cx).get_session(editor.entity_id()).cloned()
119        {
120            session
121        } else {
122            let weak_editor = editor.downgrade();
123            let session =
124                cx.new(|cx| Session::new(weak_editor, fs, kernel_specification, window, cx));
125
126            editor.update(cx, |_editor, cx| {
127                cx.notify();
128
129                cx.subscribe(&session, {
130                    let store = store.clone();
131                    move |_this, _session, event, cx| match event {
132                        SessionEvent::Shutdown(shutdown_event) => {
133                            store.update(cx, |store, _cx| {
134                                store.remove_session(shutdown_event.entity_id());
135                            });
136                        }
137                    }
138                })
139                .detach();
140            });
141
142            store.update(cx, |store, _cx| {
143                store.insert_session(editor.entity_id(), session.clone());
144            });
145
146            session
147        };
148
149        let selected_text;
150        let anchor_range;
151        let next_cursor;
152        {
153            let snapshot = multibuffer.read(cx).read(cx);
154            selected_text = snapshot
155                .text_for_range(runnable_range.clone())
156                .collect::<String>();
157            anchor_range = snapshot.anchor_before(runnable_range.start)
158                ..snapshot.anchor_after(runnable_range.end);
159            next_cursor = next_cell_point.map(|point| snapshot.anchor_after(point));
160        }
161
162        session.update(cx, |session, cx| {
163            session.execute(
164                selected_text,
165                anchor_range,
166                next_cursor,
167                move_down,
168                window,
169                cx,
170            );
171        });
172    }
173
174    anyhow::Ok(())
175}
176
177pub enum SessionSupport {
178    ActiveSession(Entity<Session>),
179    Inactive(KernelSpecification),
180    RequiresSetup(LanguageName),
181    Unsupported,
182}
183
184pub fn worktree_id_for_editor(editor: WeakEntity<Editor>, cx: &mut App) -> Option<WorktreeId> {
185    editor.upgrade().and_then(|editor| {
186        editor
187            .read(cx)
188            .buffer()
189            .read(cx)
190            .as_singleton()?
191            .read(cx)
192            .project_path(cx)
193            .map(|path| path.worktree_id)
194    })
195}
196
197pub fn session(editor: WeakEntity<Editor>, cx: &mut App) -> SessionSupport {
198    let store = ReplStore::global(cx);
199    let entity_id = editor.entity_id();
200
201    if let Some(session) = store.read(cx).get_session(entity_id).cloned() {
202        return SessionSupport::ActiveSession(session);
203    };
204
205    let Some(language) = get_language(editor.clone(), cx) else {
206        return SessionSupport::Unsupported;
207    };
208
209    let worktree_id = worktree_id_for_editor(editor, cx);
210
211    let Some(worktree_id) = worktree_id else {
212        return SessionSupport::Unsupported;
213    };
214
215    let kernelspec = store
216        .read(cx)
217        .active_kernelspec(worktree_id, Some(language.clone()), cx);
218
219    match kernelspec {
220        Some(kernelspec) => SessionSupport::Inactive(kernelspec),
221        None => {
222            // For language_supported, need to check available kernels for language
223            if language_supported(&language, cx) {
224                SessionSupport::RequiresSetup(language.name())
225            } else {
226                SessionSupport::Unsupported
227            }
228        }
229    }
230}
231
232pub fn clear_outputs(editor: WeakEntity<Editor>, cx: &mut App) {
233    let store = ReplStore::global(cx);
234    let entity_id = editor.entity_id();
235    let Some(session) = store.read(cx).get_session(entity_id).cloned() else {
236        return;
237    };
238    session.update(cx, |session, cx| {
239        session.clear_outputs(cx);
240        cx.notify();
241    });
242}
243
244pub fn interrupt(editor: WeakEntity<Editor>, cx: &mut App) {
245    let store = ReplStore::global(cx);
246    let entity_id = editor.entity_id();
247    let Some(session) = store.read(cx).get_session(entity_id).cloned() else {
248        return;
249    };
250
251    session.update(cx, |session, cx| {
252        session.interrupt(cx);
253        cx.notify();
254    });
255}
256
257pub fn shutdown(editor: WeakEntity<Editor>, window: &mut Window, cx: &mut App) {
258    let store = ReplStore::global(cx);
259    let entity_id = editor.entity_id();
260    let Some(session) = store.read(cx).get_session(entity_id).cloned() else {
261        return;
262    };
263
264    session.update(cx, |session, cx| {
265        session.shutdown(window, cx);
266        cx.notify();
267    });
268}
269
270pub fn restart(editor: WeakEntity<Editor>, window: &mut Window, cx: &mut App) {
271    let Some(editor) = editor.upgrade() else {
272        return;
273    };
274
275    let entity_id = editor.entity_id();
276
277    let Some(session) = ReplStore::global(cx)
278        .read(cx)
279        .get_session(entity_id)
280        .cloned()
281    else {
282        return;
283    };
284
285    session.update(cx, |session, cx| {
286        session.restart(window, cx);
287        cx.notify();
288    });
289}
290
291pub fn setup_editor_session_actions(editor: &mut Editor, editor_handle: WeakEntity<Editor>) {
292    editor
293        .register_action({
294            let editor_handle = editor_handle.clone();
295            move |_: &ClearOutputs, _, cx| {
296                if !JupyterSettings::enabled(cx) {
297                    return;
298                }
299
300                crate::clear_outputs(editor_handle.clone(), cx);
301            }
302        })
303        .detach();
304
305    editor
306        .register_action({
307            let editor_handle = editor_handle.clone();
308            move |_: &Interrupt, _, cx| {
309                if !JupyterSettings::enabled(cx) {
310                    return;
311                }
312
313                crate::interrupt(editor_handle.clone(), cx);
314            }
315        })
316        .detach();
317
318    editor
319        .register_action({
320            let editor_handle = editor_handle.clone();
321            move |_: &Shutdown, window, cx| {
322                if !JupyterSettings::enabled(cx) {
323                    return;
324                }
325
326                crate::shutdown(editor_handle.clone(), window, cx);
327            }
328        })
329        .detach();
330
331    editor
332        .register_action({
333            let editor_handle = editor_handle;
334            move |_: &Restart, window, cx| {
335                if !JupyterSettings::enabled(cx) {
336                    return;
337                }
338
339                crate::restart(editor_handle.clone(), window, cx);
340            }
341        })
342        .detach();
343}
344
345fn cell_range(buffer: &BufferSnapshot, start_row: u32, end_row: u32) -> Range<Point> {
346    let mut snippet_end_row = end_row;
347    while buffer.is_line_blank(snippet_end_row) && snippet_end_row > start_row {
348        snippet_end_row -= 1;
349    }
350    Point::new(start_row, 0)..Point::new(snippet_end_row, buffer.line_len(snippet_end_row))
351}
352
353// Returns the ranges of the snippets in the buffer and the next point for moving the cursor to
354fn jupytext_cells(
355    buffer: &BufferSnapshot,
356    range: Range<Point>,
357) -> (Vec<Range<Point>>, Option<Point>) {
358    let mut current_row = range.start.row;
359
360    let Some(language) = buffer.language() else {
361        return (Vec::new(), None);
362    };
363
364    let default_scope = language.default_scope();
365    let comment_prefixes = default_scope.line_comment_prefixes();
366    if comment_prefixes.is_empty() {
367        return (Vec::new(), None);
368    }
369
370    let jupytext_prefixes = comment_prefixes
371        .iter()
372        .map(|comment_prefix| format!("{comment_prefix}%%"))
373        .collect::<Vec<_>>();
374
375    let mut snippet_start_row = None;
376    loop {
377        if jupytext_prefixes
378            .iter()
379            .any(|prefix| buffer.contains_str_at(Point::new(current_row, 0), prefix))
380        {
381            snippet_start_row = Some(current_row);
382            break;
383        } else if current_row > 0 {
384            current_row -= 1;
385        } else {
386            break;
387        }
388    }
389
390    let mut snippets = Vec::new();
391    if let Some(mut snippet_start_row) = snippet_start_row {
392        for current_row in range.start.row + 1..=buffer.max_point().row {
393            if jupytext_prefixes
394                .iter()
395                .any(|prefix| buffer.contains_str_at(Point::new(current_row, 0), prefix))
396            {
397                snippets.push(cell_range(buffer, snippet_start_row, current_row - 1));
398
399                if current_row <= range.end.row {
400                    snippet_start_row = current_row;
401                } else {
402                    // Return our snippets as well as the next point for moving the cursor to
403                    return (snippets, Some(Point::new(current_row, 0)));
404                }
405            }
406        }
407
408        // Go to the end of the buffer (no more jupytext cells found)
409        snippets.push(cell_range(
410            buffer,
411            snippet_start_row,
412            buffer.max_point().row,
413        ));
414    }
415
416    (snippets, None)
417}
418
419fn runnable_ranges(
420    buffer: &BufferSnapshot,
421    range: Range<Point>,
422    cx: &mut App,
423) -> (Vec<Range<Point>>, Option<Point>) {
424    if let Some(language) = buffer.language()
425        && language.name() == "Markdown".into()
426    {
427        return (markdown_code_blocks(buffer, range, cx), None);
428    }
429
430    let (jupytext_snippets, next_cursor) = jupytext_cells(buffer, range.clone());
431    if !jupytext_snippets.is_empty() {
432        return (jupytext_snippets, next_cursor);
433    }
434
435    let snippet_range = cell_range(buffer, range.start.row, range.end.row);
436    let start_language = buffer.language_at(snippet_range.start);
437    let end_language = buffer.language_at(snippet_range.end);
438
439    if start_language
440        .zip(end_language)
441        .is_some_and(|(start, end)| start == end)
442    {
443        (vec![snippet_range], None)
444    } else {
445        (Vec::new(), None)
446    }
447}
448
449// We allow markdown code blocks to end in a trailing newline in order to render the output
450// below the final code fence. This is different than our behavior for selections and Jupytext cells.
451fn markdown_code_blocks(
452    buffer: &BufferSnapshot,
453    range: Range<Point>,
454    cx: &mut App,
455) -> Vec<Range<Point>> {
456    buffer
457        .injections_intersecting_range(range)
458        .filter(|(_, language)| language_supported(language, cx))
459        .map(|(content_range, _)| {
460            buffer.offset_to_point(content_range.start)..buffer.offset_to_point(content_range.end)
461        })
462        .collect()
463}
464
465fn language_supported(language: &Arc<Language>, cx: &mut App) -> bool {
466    let store = ReplStore::global(cx);
467    let store_read = store.read(cx);
468
469    // Since we're just checking for general language support, we only need to look at
470    // the pure Jupyter kernels - these are all the globally available ones
471    store_read.pure_jupyter_kernel_specifications().any(|spec| {
472        // Convert to lowercase for case-insensitive comparison since kernels might report "python" while our language is "Python"
473        spec.language().as_ref().to_lowercase() == language.name().as_ref().to_lowercase()
474    })
475}
476
477fn get_language(editor: WeakEntity<Editor>, cx: &mut App) -> Option<Arc<Language>> {
478    editor
479        .update(cx, |editor, cx| {
480            let display_snapshot = editor.display_snapshot(cx);
481            let selection = editor
482                .selections
483                .newest::<MultiBufferOffset>(&display_snapshot);
484            display_snapshot
485                .buffer_snapshot()
486                .language_at(selection.head())
487                .cloned()
488        })
489        .ok()
490        .flatten()
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use gpui::App;
497    use indoc::indoc;
498    use language::{Buffer, Language, LanguageConfig, LanguageRegistry};
499
500    #[gpui::test]
501    fn test_snippet_ranges(cx: &mut App) {
502        // Create a test language
503        let test_language = Arc::new(Language::new(
504            LanguageConfig {
505                name: "TestLang".into(),
506                line_comments: vec!["# ".into()],
507                ..Default::default()
508            },
509            None,
510        ));
511
512        let buffer = cx.new(|cx| {
513            Buffer::local(
514                indoc! { r#"
515                    print(1 + 1)
516                    print(2 + 2)
517
518                    print(4 + 4)
519
520
521                "# },
522                cx,
523            )
524            .with_language_immediate(test_language, cx)
525        });
526        let snapshot = buffer.read(cx).snapshot();
527
528        // Single-point selection
529        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 4)..Point::new(0, 4), cx);
530        let snippets = snippets
531            .into_iter()
532            .map(|range| snapshot.text_for_range(range).collect::<String>())
533            .collect::<Vec<_>>();
534        assert_eq!(snippets, vec!["print(1 + 1)"]);
535
536        // Multi-line selection
537        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 5)..Point::new(2, 0), cx);
538        let snippets = snippets
539            .into_iter()
540            .map(|range| snapshot.text_for_range(range).collect::<String>())
541            .collect::<Vec<_>>();
542        assert_eq!(
543            snippets,
544            vec![indoc! { r#"
545                print(1 + 1)
546                print(2 + 2)"# }]
547        );
548
549        // Trimming multiple trailing blank lines
550        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 5)..Point::new(5, 0), cx);
551
552        let snippets = snippets
553            .into_iter()
554            .map(|range| snapshot.text_for_range(range).collect::<String>())
555            .collect::<Vec<_>>();
556        assert_eq!(
557            snippets,
558            vec![indoc! { r#"
559                print(1 + 1)
560                print(2 + 2)
561
562                print(4 + 4)"# }]
563        );
564    }
565
566    #[gpui::test]
567    fn test_jupytext_snippet_ranges(cx: &mut App) {
568        // Create a test language
569        let test_language = Arc::new(Language::new(
570            LanguageConfig {
571                name: "TestLang".into(),
572                line_comments: vec!["# ".into()],
573                ..Default::default()
574            },
575            None,
576        ));
577
578        let buffer = cx.new(|cx| {
579            Buffer::local(
580                indoc! { r#"
581                    # Hello!
582                    # %% [markdown]
583                    # This is some arithmetic
584                    print(1 + 1)
585                    print(2 + 2)
586
587                    # %%
588                    print(3 + 3)
589                    print(4 + 4)
590
591                    print(5 + 5)
592
593
594
595                "# },
596                cx,
597            )
598            .with_language_immediate(test_language, cx)
599        });
600        let snapshot = buffer.read(cx).snapshot();
601
602        // Jupytext snippet surrounding an empty selection
603        let (snippets, _) = runnable_ranges(&snapshot, Point::new(2, 5)..Point::new(2, 5), cx);
604
605        let snippets = snippets
606            .into_iter()
607            .map(|range| snapshot.text_for_range(range).collect::<String>())
608            .collect::<Vec<_>>();
609        assert_eq!(
610            snippets,
611            vec![indoc! { r#"
612                # %% [markdown]
613                # This is some arithmetic
614                print(1 + 1)
615                print(2 + 2)"# }]
616        );
617
618        // Jupytext snippets intersecting a non-empty selection
619        let (snippets, _) = runnable_ranges(&snapshot, Point::new(2, 5)..Point::new(6, 2), cx);
620        let snippets = snippets
621            .into_iter()
622            .map(|range| snapshot.text_for_range(range).collect::<String>())
623            .collect::<Vec<_>>();
624        assert_eq!(
625            snippets,
626            vec![
627                indoc! { r#"
628                    # %% [markdown]
629                    # This is some arithmetic
630                    print(1 + 1)
631                    print(2 + 2)"#
632                },
633                indoc! { r#"
634                    # %%
635                    print(3 + 3)
636                    print(4 + 4)
637
638                    print(5 + 5)"#
639                }
640            ]
641        );
642    }
643
644    #[gpui::test]
645    fn test_markdown_code_blocks(cx: &mut App) {
646        use crate::kernels::LocalKernelSpecification;
647        use jupyter_protocol::JupyterKernelspec;
648
649        // Initialize settings
650        settings::init(cx);
651        editor::init(cx);
652
653        // Initialize the ReplStore with a fake filesystem
654        let fs = Arc::new(project::RealFs::new(None, cx.background_executor().clone()));
655        ReplStore::init(fs, cx);
656
657        // Add mock kernel specifications for TypeScript and Python
658        let store = ReplStore::global(cx);
659        store.update(cx, |store, cx| {
660            let typescript_spec = KernelSpecification::Jupyter(LocalKernelSpecification {
661                name: "typescript".into(),
662                kernelspec: JupyterKernelspec {
663                    argv: vec![],
664                    display_name: "TypeScript".into(),
665                    language: "typescript".into(),
666                    interrupt_mode: None,
667                    metadata: None,
668                    env: None,
669                },
670                path: std::path::PathBuf::new(),
671            });
672
673            let python_spec = KernelSpecification::Jupyter(LocalKernelSpecification {
674                name: "python".into(),
675                kernelspec: JupyterKernelspec {
676                    argv: vec![],
677                    display_name: "Python".into(),
678                    language: "python".into(),
679                    interrupt_mode: None,
680                    metadata: None,
681                    env: None,
682                },
683                path: std::path::PathBuf::new(),
684            });
685
686            store.set_kernel_specs_for_testing(vec![typescript_spec, python_spec], cx);
687        });
688
689        let markdown = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
690        let typescript = languages::language(
691            "typescript",
692            tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
693        );
694        let python = languages::language("python", tree_sitter_python::LANGUAGE.into());
695        let language_registry = Arc::new(LanguageRegistry::new(cx.background_executor().clone()));
696        language_registry.add(markdown.clone());
697        language_registry.add(typescript);
698        language_registry.add(python);
699
700        // Two code blocks intersecting with selection
701        let buffer = cx.new(|cx| {
702            let mut buffer = Buffer::local(
703                indoc! { r#"
704                    Hey this is Markdown!
705
706                    ```typescript
707                    let foo = 999;
708                    console.log(foo + 1999);
709                    ```
710
711                    ```typescript
712                    console.log("foo")
713                    ```
714                    "#
715                },
716                cx,
717            );
718            buffer.set_language_registry(language_registry.clone());
719            buffer.set_language_immediate(Some(markdown.clone()), cx);
720            buffer
721        });
722        let snapshot = buffer.read(cx).snapshot();
723
724        let (snippets, _) = runnable_ranges(&snapshot, Point::new(3, 5)..Point::new(8, 5), cx);
725        let snippets = snippets
726            .into_iter()
727            .map(|range| snapshot.text_for_range(range).collect::<String>())
728            .collect::<Vec<_>>();
729
730        assert_eq!(
731            snippets,
732            vec![
733                indoc! { r#"
734                    let foo = 999;
735                    console.log(foo + 1999);
736                    "#
737                },
738                "console.log(\"foo\")\n"
739            ]
740        );
741
742        // Three code blocks intersecting with selection
743        let buffer = cx.new(|cx| {
744            let mut buffer = Buffer::local(
745                indoc! { r#"
746                    Hey this is Markdown!
747
748                    ```typescript
749                    let foo = 999;
750                    console.log(foo + 1999);
751                    ```
752
753                    ```ts
754                    console.log("foo")
755                    ```
756
757                    ```typescript
758                    console.log("another code block")
759                    ```
760                "# },
761                cx,
762            );
763            buffer.set_language_registry(language_registry.clone());
764            buffer.set_language_immediate(Some(markdown.clone()), cx);
765            buffer
766        });
767        let snapshot = buffer.read(cx).snapshot();
768
769        let (snippets, _) = runnable_ranges(&snapshot, Point::new(3, 5)..Point::new(12, 5), cx);
770        let snippets = snippets
771            .into_iter()
772            .map(|range| snapshot.text_for_range(range).collect::<String>())
773            .collect::<Vec<_>>();
774
775        assert_eq!(
776            snippets,
777            vec![
778                indoc! { r#"
779                    let foo = 999;
780                    console.log(foo + 1999);
781                    "#
782                },
783                "console.log(\"foo\")\n",
784                "console.log(\"another code block\")\n",
785            ]
786        );
787
788        // Python code block
789        let buffer = cx.new(|cx| {
790            let mut buffer = Buffer::local(
791                indoc! { r#"
792                    Hey this is Markdown!
793
794                    ```python
795                    print("hello there")
796                    print("hello there")
797                    print("hello there")
798                    ```
799                "# },
800                cx,
801            );
802            buffer.set_language_registry(language_registry.clone());
803            buffer.set_language_immediate(Some(markdown.clone()), cx);
804            buffer
805        });
806        let snapshot = buffer.read(cx).snapshot();
807
808        let (snippets, _) = runnable_ranges(&snapshot, Point::new(4, 5)..Point::new(5, 5), cx);
809        let snippets = snippets
810            .into_iter()
811            .map(|range| snapshot.text_for_range(range).collect::<String>())
812            .collect::<Vec<_>>();
813
814        assert_eq!(
815            snippets,
816            vec![indoc! { r#"
817                print("hello there")
818                print("hello there")
819                print("hello there")
820                "#
821            },]
822        );
823    }
824}