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};
  11use workspace::{Workspace, notifications::NotificationId};
  12
  13use crate::kernels::PythonEnvKernelSpecification;
  14use crate::repl_store::ReplStore;
  15use crate::session::SessionEvent;
  16use crate::{
  17    ClearCurrentOutput, ClearOutputs, Interrupt, JupyterSettings, KernelSpecification, Restart,
  18    Session, Shutdown,
  19};
  20
  21pub fn assign_kernelspec(
  22    kernel_specification: KernelSpecification,
  23    weak_editor: WeakEntity<Editor>,
  24    window: &mut Window,
  25    cx: &mut App,
  26) -> Result<()> {
  27    let store = ReplStore::global(cx);
  28    if !store.read(cx).is_enabled() {
  29        return Ok(());
  30    }
  31
  32    let worktree_id = crate::repl_editor::worktree_id_for_editor(weak_editor.clone(), cx)
  33        .context("editor is not in a worktree")?;
  34
  35    store.update(cx, |store, cx| {
  36        store.set_active_kernelspec(worktree_id, kernel_specification.clone(), cx);
  37    });
  38
  39    let fs = store.read(cx).fs().clone();
  40
  41    if let Some(session) = store.read(cx).get_session(weak_editor.entity_id()).cloned() {
  42        // Drop previous session, start new one
  43        session.update(cx, |session, cx| {
  44            session.clear_outputs(cx);
  45            session.shutdown(window, cx);
  46            cx.notify();
  47        });
  48    }
  49
  50    let session =
  51        cx.new(|cx| Session::new(weak_editor.clone(), fs, kernel_specification, window, cx));
  52
  53    weak_editor
  54        .update(cx, |_editor, cx| {
  55            cx.notify();
  56
  57            cx.subscribe(&session, {
  58                let store = store.clone();
  59                move |_this, _session, event, cx| match event {
  60                    SessionEvent::Shutdown(shutdown_event) => {
  61                        store.update(cx, |store, _cx| {
  62                            store.remove_session(shutdown_event.entity_id());
  63                        });
  64                    }
  65                }
  66            })
  67            .detach();
  68        })
  69        .ok();
  70
  71    store.update(cx, |store, _cx| {
  72        store.insert_session(weak_editor.entity_id(), session.clone());
  73    });
  74
  75    Ok(())
  76}
  77
  78pub fn install_ipykernel_and_assign(
  79    kernel_specification: KernelSpecification,
  80    weak_editor: WeakEntity<Editor>,
  81    window: &mut Window,
  82    cx: &mut App,
  83) -> Result<()> {
  84    let KernelSpecification::PythonEnv(ref env_spec) = kernel_specification else {
  85        return assign_kernelspec(kernel_specification, weak_editor, window, cx);
  86    };
  87
  88    let python_path = env_spec.path.clone();
  89    let env_name = env_spec.name.clone();
  90    let is_uv = env_spec.is_uv();
  91    let env_spec = env_spec.clone();
  92
  93    struct IpykernelInstall;
  94    let notification_id = NotificationId::unique::<IpykernelInstall>();
  95
  96    let workspace = Workspace::for_window(window, cx);
  97    if let Some(workspace) = &workspace {
  98        workspace.update(cx, |workspace, cx| {
  99            workspace.show_toast(
 100                workspace::Toast::new(
 101                    notification_id.clone(),
 102                    format!("Installing ipykernel in {}...", env_name),
 103                ),
 104                cx,
 105            );
 106        });
 107    }
 108
 109    let weak_workspace = workspace.map(|w| w.downgrade());
 110    let window_handle = window.window_handle();
 111
 112    let install_task = cx.background_spawn(async move {
 113        let output = if is_uv {
 114            util::command::new_command("uv")
 115                .args(&[
 116                    "pip",
 117                    "install",
 118                    "ipykernel",
 119                    "--python",
 120                    &python_path.to_string_lossy(),
 121                ])
 122                .output()
 123                .await
 124                .context("failed to run uv pip install ipykernel")?
 125        } else {
 126            util::command::new_command(python_path.to_string_lossy().as_ref())
 127                .args(&["-m", "pip", "install", "ipykernel"])
 128                .output()
 129                .await
 130                .context("failed to run pip install ipykernel")?
 131        };
 132
 133        if output.status.success() {
 134            anyhow::Ok(())
 135        } else {
 136            let stderr = String::from_utf8_lossy(&output.stderr);
 137            anyhow::bail!("{}", stderr.lines().last().unwrap_or("unknown error"))
 138        }
 139    });
 140
 141    cx.spawn(async move |cx| {
 142        let result = install_task.await;
 143
 144        match result {
 145            Ok(()) => {
 146                if let Some(weak_workspace) = &weak_workspace {
 147                    weak_workspace
 148                        .update(cx, |workspace, cx| {
 149                            workspace.dismiss_toast(&notification_id, cx);
 150                            workspace.show_toast(
 151                                workspace::Toast::new(
 152                                    notification_id.clone(),
 153                                    format!("ipykernel installed in {}", env_name),
 154                                )
 155                                .autohide(),
 156                                cx,
 157                            );
 158                        })
 159                        .ok();
 160                }
 161
 162                window_handle
 163                    .update(cx, |_, window, cx| {
 164                        let store = ReplStore::global(cx);
 165                        store.update(cx, |store, cx| {
 166                            store.mark_ipykernel_installed(cx, &env_spec);
 167                        });
 168
 169                        let updated_spec =
 170                            KernelSpecification::PythonEnv(PythonEnvKernelSpecification {
 171                                has_ipykernel: true,
 172                                ..env_spec
 173                            });
 174                        assign_kernelspec(updated_spec, weak_editor, window, cx).ok();
 175                    })
 176                    .ok();
 177            }
 178            Err(error) => {
 179                if let Some(weak_workspace) = &weak_workspace {
 180                    weak_workspace
 181                        .update(cx, |workspace, cx| {
 182                            workspace.dismiss_toast(&notification_id, cx);
 183                            workspace.show_toast(
 184                                workspace::Toast::new(
 185                                    notification_id.clone(),
 186                                    format!(
 187                                        "Failed to install ipykernel in {}: {}",
 188                                        env_name, error
 189                                    ),
 190                                ),
 191                                cx,
 192                            );
 193                        })
 194                        .ok();
 195                }
 196            }
 197        }
 198    })
 199    .detach();
 200
 201    Ok(())
 202}
 203
 204pub fn run(
 205    editor: WeakEntity<Editor>,
 206    move_down: bool,
 207    window: &mut Window,
 208    cx: &mut App,
 209) -> Result<()> {
 210    let store = ReplStore::global(cx);
 211    if !store.read(cx).is_enabled() {
 212        return Ok(());
 213    }
 214    store.update(cx, |store, cx| store.ensure_kernelspecs(cx));
 215
 216    let editor = editor.upgrade().context("editor was dropped")?;
 217    let selected_range = editor
 218        .update(cx, |editor, cx| {
 219            editor
 220                .selections
 221                .newest_adjusted(&editor.display_snapshot(cx))
 222        })
 223        .range();
 224    let multibuffer = editor.read(cx).buffer().clone();
 225    let Some(buffer) = multibuffer.read(cx).as_singleton() else {
 226        return Ok(());
 227    };
 228
 229    let Some(project_path) = buffer.read(cx).project_path(cx) else {
 230        return Ok(());
 231    };
 232
 233    let (runnable_ranges, next_cell_point) =
 234        runnable_ranges(&buffer.read(cx).snapshot(), selected_range, cx);
 235
 236    for runnable_range in runnable_ranges {
 237        let Some(language) = multibuffer.read(cx).language_at(runnable_range.start, cx) else {
 238            continue;
 239        };
 240
 241        let kernel_specification = store
 242            .read(cx)
 243            .active_kernelspec(project_path.worktree_id, Some(language.clone()), cx)
 244            .with_context(|| format!("No kernel found for language: {}", language.name()))?;
 245
 246        let fs = store.read(cx).fs().clone();
 247
 248        let session = if let Some(session) = store.read(cx).get_session(editor.entity_id()).cloned()
 249        {
 250            session
 251        } else {
 252            let weak_editor = editor.downgrade();
 253            let session =
 254                cx.new(|cx| Session::new(weak_editor, fs, kernel_specification, window, cx));
 255
 256            editor.update(cx, |_editor, cx| {
 257                cx.notify();
 258
 259                cx.subscribe(&session, {
 260                    let store = store.clone();
 261                    move |_this, _session, event, cx| match event {
 262                        SessionEvent::Shutdown(shutdown_event) => {
 263                            store.update(cx, |store, _cx| {
 264                                store.remove_session(shutdown_event.entity_id());
 265                            });
 266                        }
 267                    }
 268                })
 269                .detach();
 270            });
 271
 272            store.update(cx, |store, _cx| {
 273                store.insert_session(editor.entity_id(), session.clone());
 274            });
 275
 276            session
 277        };
 278
 279        let selected_text;
 280        let anchor_range;
 281        let next_cursor;
 282        {
 283            let snapshot = multibuffer.read(cx).read(cx);
 284            selected_text = snapshot
 285                .text_for_range(runnable_range.clone())
 286                .collect::<String>();
 287            anchor_range = snapshot.anchor_before(runnable_range.start)
 288                ..snapshot.anchor_after(runnable_range.end);
 289            next_cursor = next_cell_point.map(|point| snapshot.anchor_after(point));
 290        }
 291
 292        session.update(cx, |session, cx| {
 293            session.execute(
 294                selected_text,
 295                anchor_range,
 296                next_cursor,
 297                move_down,
 298                window,
 299                cx,
 300            );
 301        });
 302    }
 303
 304    anyhow::Ok(())
 305}
 306
 307pub enum SessionSupport {
 308    ActiveSession(Entity<Session>),
 309    Inactive(KernelSpecification),
 310    RequiresSetup(LanguageName),
 311    Unsupported,
 312}
 313
 314pub fn worktree_id_for_editor(editor: WeakEntity<Editor>, cx: &mut App) -> Option<WorktreeId> {
 315    editor.upgrade().and_then(|editor| {
 316        editor
 317            .read(cx)
 318            .buffer()
 319            .read(cx)
 320            .as_singleton()?
 321            .read(cx)
 322            .project_path(cx)
 323            .map(|path| path.worktree_id)
 324    })
 325}
 326
 327pub fn session(editor: WeakEntity<Editor>, cx: &mut App) -> SessionSupport {
 328    let store = ReplStore::global(cx);
 329    let entity_id = editor.entity_id();
 330
 331    if let Some(session) = store.read(cx).get_session(entity_id).cloned() {
 332        return SessionSupport::ActiveSession(session);
 333    };
 334
 335    let Some(language) = get_language(editor.clone(), cx) else {
 336        return SessionSupport::Unsupported;
 337    };
 338
 339    let worktree_id = worktree_id_for_editor(editor, cx);
 340
 341    let Some(worktree_id) = worktree_id else {
 342        return SessionSupport::Unsupported;
 343    };
 344
 345    let kernelspec = store
 346        .read(cx)
 347        .active_kernelspec(worktree_id, Some(language.clone()), cx);
 348
 349    match kernelspec {
 350        Some(kernelspec) => SessionSupport::Inactive(kernelspec),
 351        None => {
 352            // For language_supported, need to check available kernels for language
 353            if language_supported(&language, cx) {
 354                SessionSupport::RequiresSetup(language.name())
 355            } else {
 356                SessionSupport::Unsupported
 357            }
 358        }
 359    }
 360}
 361
 362pub fn clear_outputs(editor: WeakEntity<Editor>, cx: &mut App) {
 363    let store = ReplStore::global(cx);
 364    let entity_id = editor.entity_id();
 365    let Some(session) = store.read(cx).get_session(entity_id).cloned() else {
 366        return;
 367    };
 368    session.update(cx, |session, cx| {
 369        session.clear_outputs(cx);
 370        cx.notify();
 371    });
 372}
 373
 374pub fn clear_current_output(editor: WeakEntity<Editor>, cx: &mut App) {
 375    let Some(editor_entity) = editor.upgrade() else {
 376        return;
 377    };
 378
 379    let store = ReplStore::global(cx);
 380    let entity_id = editor.entity_id();
 381    let Some(session) = store.read(cx).get_session(entity_id).cloned() else {
 382        return;
 383    };
 384
 385    let position = editor_entity.read(cx).selections.newest_anchor().head();
 386
 387    session.update(cx, |session, cx| {
 388        session.clear_output_at_position(position, cx);
 389    });
 390}
 391
 392pub fn interrupt(editor: WeakEntity<Editor>, cx: &mut App) {
 393    let store = ReplStore::global(cx);
 394    let entity_id = editor.entity_id();
 395    let Some(session) = store.read(cx).get_session(entity_id).cloned() else {
 396        return;
 397    };
 398
 399    session.update(cx, |session, cx| {
 400        session.interrupt(cx);
 401        cx.notify();
 402    });
 403}
 404
 405pub fn shutdown(editor: WeakEntity<Editor>, window: &mut Window, cx: &mut App) {
 406    let store = ReplStore::global(cx);
 407    let entity_id = editor.entity_id();
 408    let Some(session) = store.read(cx).get_session(entity_id).cloned() else {
 409        return;
 410    };
 411
 412    session.update(cx, |session, cx| {
 413        session.shutdown(window, cx);
 414        cx.notify();
 415    });
 416}
 417
 418pub fn restart(editor: WeakEntity<Editor>, window: &mut Window, cx: &mut App) {
 419    let Some(editor) = editor.upgrade() else {
 420        return;
 421    };
 422
 423    let entity_id = editor.entity_id();
 424
 425    let Some(session) = ReplStore::global(cx)
 426        .read(cx)
 427        .get_session(entity_id)
 428        .cloned()
 429    else {
 430        return;
 431    };
 432
 433    session.update(cx, |session, cx| {
 434        session.restart(window, cx);
 435        cx.notify();
 436    });
 437}
 438
 439pub fn setup_editor_session_actions(editor: &mut Editor, editor_handle: WeakEntity<Editor>) {
 440    editor
 441        .register_action({
 442            let editor_handle = editor_handle.clone();
 443            move |_: &ClearOutputs, _, cx| {
 444                if !JupyterSettings::enabled(cx) {
 445                    return;
 446                }
 447
 448                crate::clear_outputs(editor_handle.clone(), cx);
 449            }
 450        })
 451        .detach();
 452
 453    editor
 454        .register_action({
 455            let editor_handle = editor_handle.clone();
 456            move |_: &ClearCurrentOutput, _, cx| {
 457                if !JupyterSettings::enabled(cx) {
 458                    return;
 459                }
 460
 461                crate::clear_current_output(editor_handle.clone(), cx);
 462            }
 463        })
 464        .detach();
 465
 466    editor
 467        .register_action({
 468            let editor_handle = editor_handle.clone();
 469            move |_: &Interrupt, _, cx| {
 470                if !JupyterSettings::enabled(cx) {
 471                    return;
 472                }
 473
 474                crate::interrupt(editor_handle.clone(), cx);
 475            }
 476        })
 477        .detach();
 478
 479    editor
 480        .register_action({
 481            let editor_handle = editor_handle.clone();
 482            move |_: &Shutdown, window, cx| {
 483                if !JupyterSettings::enabled(cx) {
 484                    return;
 485                }
 486
 487                crate::shutdown(editor_handle.clone(), window, cx);
 488            }
 489        })
 490        .detach();
 491
 492    editor
 493        .register_action({
 494            let editor_handle = editor_handle;
 495            move |_: &Restart, window, cx| {
 496                if !JupyterSettings::enabled(cx) {
 497                    return;
 498                }
 499
 500                crate::restart(editor_handle.clone(), window, cx);
 501            }
 502        })
 503        .detach();
 504}
 505
 506fn cell_range(buffer: &BufferSnapshot, start_row: u32, end_row: u32) -> Range<Point> {
 507    let mut snippet_end_row = end_row;
 508    while buffer.is_line_blank(snippet_end_row) && snippet_end_row > start_row {
 509        snippet_end_row -= 1;
 510    }
 511    Point::new(start_row, 0)..Point::new(snippet_end_row, buffer.line_len(snippet_end_row))
 512}
 513
 514// Returns the ranges of the snippets in the buffer and the next point for moving the cursor to
 515fn jupytext_cells(
 516    buffer: &BufferSnapshot,
 517    range: Range<Point>,
 518) -> (Vec<Range<Point>>, Option<Point>) {
 519    let mut current_row = range.start.row;
 520
 521    let Some(language) = buffer.language() else {
 522        return (Vec::new(), None);
 523    };
 524
 525    let default_scope = language.default_scope();
 526    let comment_prefixes = default_scope.line_comment_prefixes();
 527    if comment_prefixes.is_empty() {
 528        return (Vec::new(), None);
 529    }
 530
 531    let jupytext_prefixes = comment_prefixes
 532        .iter()
 533        .map(|comment_prefix| format!("{comment_prefix}%%"))
 534        .collect::<Vec<_>>();
 535
 536    let mut snippet_start_row = None;
 537    loop {
 538        if jupytext_prefixes
 539            .iter()
 540            .any(|prefix| buffer.contains_str_at(Point::new(current_row, 0), prefix))
 541        {
 542            snippet_start_row = Some(current_row);
 543            break;
 544        } else if current_row > 0 {
 545            current_row -= 1;
 546        } else {
 547            break;
 548        }
 549    }
 550
 551    let mut snippets = Vec::new();
 552    if let Some(mut snippet_start_row) = snippet_start_row {
 553        for current_row in range.start.row + 1..=buffer.max_point().row {
 554            if jupytext_prefixes
 555                .iter()
 556                .any(|prefix| buffer.contains_str_at(Point::new(current_row, 0), prefix))
 557            {
 558                snippets.push(cell_range(buffer, snippet_start_row, current_row - 1));
 559
 560                if current_row <= range.end.row {
 561                    snippet_start_row = current_row;
 562                } else {
 563                    // Return our snippets as well as the next point for moving the cursor to
 564                    return (snippets, Some(Point::new(current_row, 0)));
 565                }
 566            }
 567        }
 568
 569        // Go to the end of the buffer (no more jupytext cells found)
 570        snippets.push(cell_range(
 571            buffer,
 572            snippet_start_row,
 573            buffer.max_point().row,
 574        ));
 575    }
 576
 577    (snippets, None)
 578}
 579
 580fn runnable_ranges(
 581    buffer: &BufferSnapshot,
 582    range: Range<Point>,
 583    cx: &mut App,
 584) -> (Vec<Range<Point>>, Option<Point>) {
 585    if let Some(language) = buffer.language()
 586        && language.name() == "Markdown"
 587    {
 588        return (markdown_code_blocks(buffer, range, cx), None);
 589    }
 590
 591    let (jupytext_snippets, next_cursor) = jupytext_cells(buffer, range.clone());
 592    if !jupytext_snippets.is_empty() {
 593        return (jupytext_snippets, next_cursor);
 594    }
 595
 596    let snippet_range = cell_range(buffer, range.start.row, range.end.row);
 597
 598    // Check if the snippet range is entirely blank, if so, skip forward to find code
 599    let is_blank =
 600        (snippet_range.start.row..=snippet_range.end.row).all(|row| buffer.is_line_blank(row));
 601
 602    if is_blank {
 603        // Search forward for the next non-blank line
 604        let max_row = buffer.max_point().row;
 605        let mut next_row = snippet_range.end.row + 1;
 606        while next_row <= max_row && buffer.is_line_blank(next_row) {
 607            next_row += 1;
 608        }
 609
 610        if next_row <= max_row {
 611            // Found a non-blank line, find the extent of this cell
 612            let next_snippet_range = cell_range(buffer, next_row, next_row);
 613            let start_language = buffer.language_at(next_snippet_range.start);
 614            let end_language = buffer.language_at(next_snippet_range.end);
 615
 616            if start_language
 617                .zip(end_language)
 618                .is_some_and(|(start, end)| start == end)
 619            {
 620                return (vec![next_snippet_range], None);
 621            }
 622        }
 623
 624        return (Vec::new(), None);
 625    }
 626
 627    let start_language = buffer.language_at(snippet_range.start);
 628    let end_language = buffer.language_at(snippet_range.end);
 629
 630    if start_language
 631        .zip(end_language)
 632        .is_some_and(|(start, end)| start == end)
 633    {
 634        (vec![snippet_range], None)
 635    } else {
 636        (Vec::new(), None)
 637    }
 638}
 639
 640// We allow markdown code blocks to end in a trailing newline in order to render the output
 641// below the final code fence. This is different than our behavior for selections and Jupytext cells.
 642fn markdown_code_blocks(
 643    buffer: &BufferSnapshot,
 644    range: Range<Point>,
 645    cx: &mut App,
 646) -> Vec<Range<Point>> {
 647    buffer
 648        .injections_intersecting_range(range)
 649        .filter(|(_, language)| language_supported(language, cx))
 650        .map(|(content_range, _)| {
 651            buffer.offset_to_point(content_range.start)..buffer.offset_to_point(content_range.end)
 652        })
 653        .collect()
 654}
 655
 656fn language_supported(language: &Arc<Language>, cx: &mut App) -> bool {
 657    let store = ReplStore::global(cx);
 658    let store_read = store.read(cx);
 659
 660    store_read
 661        .pure_jupyter_kernel_specifications()
 662        .any(|spec| language.matches_kernel_language(spec.language().as_ref()))
 663}
 664
 665fn get_language(editor: WeakEntity<Editor>, cx: &mut App) -> Option<Arc<Language>> {
 666    editor
 667        .update(cx, |editor, cx| {
 668            let display_snapshot = editor.display_snapshot(cx);
 669            let selection = editor
 670                .selections
 671                .newest::<MultiBufferOffset>(&display_snapshot);
 672            display_snapshot
 673                .buffer_snapshot()
 674                .language_at(selection.head())
 675                .cloned()
 676        })
 677        .ok()
 678        .flatten()
 679}
 680
 681#[cfg(test)]
 682mod tests {
 683    use super::*;
 684    use gpui::App;
 685    use indoc::indoc;
 686    use language::{Buffer, Language, LanguageConfig, LanguageRegistry};
 687
 688    #[gpui::test]
 689    fn test_snippet_ranges(cx: &mut App) {
 690        // Create a test language
 691        let test_language = Arc::new(Language::new(
 692            LanguageConfig {
 693                name: "TestLang".into(),
 694                line_comments: vec!["# ".into()],
 695                ..Default::default()
 696            },
 697            None,
 698        ));
 699
 700        let buffer = cx.new(|cx| {
 701            Buffer::local(
 702                indoc! { r#"
 703                    print(1 + 1)
 704                    print(2 + 2)
 705
 706                    print(4 + 4)
 707
 708
 709                "# },
 710                cx,
 711            )
 712            .with_language(test_language, cx)
 713        });
 714        let snapshot = buffer.read(cx).snapshot();
 715
 716        // Single-point selection
 717        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 4)..Point::new(0, 4), cx);
 718        let snippets = snippets
 719            .into_iter()
 720            .map(|range| snapshot.text_for_range(range).collect::<String>())
 721            .collect::<Vec<_>>();
 722        assert_eq!(snippets, vec!["print(1 + 1)"]);
 723
 724        // Multi-line selection
 725        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 5)..Point::new(2, 0), cx);
 726        let snippets = snippets
 727            .into_iter()
 728            .map(|range| snapshot.text_for_range(range).collect::<String>())
 729            .collect::<Vec<_>>();
 730        assert_eq!(
 731            snippets,
 732            vec![indoc! { r#"
 733                print(1 + 1)
 734                print(2 + 2)"# }]
 735        );
 736
 737        // Trimming multiple trailing blank lines
 738        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 5)..Point::new(5, 0), cx);
 739
 740        let snippets = snippets
 741            .into_iter()
 742            .map(|range| snapshot.text_for_range(range).collect::<String>())
 743            .collect::<Vec<_>>();
 744        assert_eq!(
 745            snippets,
 746            vec![indoc! { r#"
 747                print(1 + 1)
 748                print(2 + 2)
 749
 750                print(4 + 4)"# }]
 751        );
 752    }
 753
 754    #[gpui::test]
 755    fn test_jupytext_snippet_ranges(cx: &mut App) {
 756        // Create a test language
 757        let test_language = Arc::new(Language::new(
 758            LanguageConfig {
 759                name: "TestLang".into(),
 760                line_comments: vec!["# ".into()],
 761                ..Default::default()
 762            },
 763            None,
 764        ));
 765
 766        let buffer = cx.new(|cx| {
 767            Buffer::local(
 768                indoc! { r#"
 769                    # Hello!
 770                    # %% [markdown]
 771                    # This is some arithmetic
 772                    print(1 + 1)
 773                    print(2 + 2)
 774
 775                    # %%
 776                    print(3 + 3)
 777                    print(4 + 4)
 778
 779                    print(5 + 5)
 780
 781
 782
 783                "# },
 784                cx,
 785            )
 786            .with_language(test_language, cx)
 787        });
 788        let snapshot = buffer.read(cx).snapshot();
 789
 790        // Jupytext snippet surrounding an empty selection
 791        let (snippets, _) = runnable_ranges(&snapshot, Point::new(2, 5)..Point::new(2, 5), cx);
 792
 793        let snippets = snippets
 794            .into_iter()
 795            .map(|range| snapshot.text_for_range(range).collect::<String>())
 796            .collect::<Vec<_>>();
 797        assert_eq!(
 798            snippets,
 799            vec![indoc! { r#"
 800                # %% [markdown]
 801                # This is some arithmetic
 802                print(1 + 1)
 803                print(2 + 2)"# }]
 804        );
 805
 806        // Jupytext snippets intersecting a non-empty selection
 807        let (snippets, _) = runnable_ranges(&snapshot, Point::new(2, 5)..Point::new(6, 2), cx);
 808        let snippets = snippets
 809            .into_iter()
 810            .map(|range| snapshot.text_for_range(range).collect::<String>())
 811            .collect::<Vec<_>>();
 812        assert_eq!(
 813            snippets,
 814            vec![
 815                indoc! { r#"
 816                    # %% [markdown]
 817                    # This is some arithmetic
 818                    print(1 + 1)
 819                    print(2 + 2)"#
 820                },
 821                indoc! { r#"
 822                    # %%
 823                    print(3 + 3)
 824                    print(4 + 4)
 825
 826                    print(5 + 5)"#
 827                }
 828            ]
 829        );
 830    }
 831
 832    #[gpui::test]
 833    fn test_markdown_code_blocks(cx: &mut App) {
 834        use crate::kernels::LocalKernelSpecification;
 835        use jupyter_protocol::JupyterKernelspec;
 836
 837        // Initialize settings
 838        settings::init(cx);
 839        editor::init(cx);
 840
 841        // Initialize the ReplStore with a fake filesystem
 842        let fs = Arc::new(project::RealFs::new(None, cx.background_executor().clone()));
 843        ReplStore::init(fs, cx);
 844
 845        // Add mock kernel specifications for TypeScript and Python
 846        let store = ReplStore::global(cx);
 847        store.update(cx, |store, cx| {
 848            let typescript_spec = KernelSpecification::Jupyter(LocalKernelSpecification {
 849                name: "typescript".into(),
 850                kernelspec: JupyterKernelspec {
 851                    argv: vec![],
 852                    display_name: "TypeScript".into(),
 853                    language: "typescript".into(),
 854                    interrupt_mode: None,
 855                    metadata: None,
 856                    env: None,
 857                },
 858                path: std::path::PathBuf::new(),
 859            });
 860
 861            let python_spec = KernelSpecification::Jupyter(LocalKernelSpecification {
 862                name: "python".into(),
 863                kernelspec: JupyterKernelspec {
 864                    argv: vec![],
 865                    display_name: "Python".into(),
 866                    language: "python".into(),
 867                    interrupt_mode: None,
 868                    metadata: None,
 869                    env: None,
 870                },
 871                path: std::path::PathBuf::new(),
 872            });
 873
 874            store.set_kernel_specs_for_testing(vec![typescript_spec, python_spec], cx);
 875        });
 876
 877        let markdown = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
 878        let typescript = languages::language(
 879            "typescript",
 880            tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
 881        );
 882        let python = languages::language("python", tree_sitter_python::LANGUAGE.into());
 883        let language_registry = Arc::new(LanguageRegistry::new(cx.background_executor().clone()));
 884        language_registry.add(markdown.clone());
 885        language_registry.add(typescript);
 886        language_registry.add(python);
 887
 888        // Two code blocks intersecting with selection
 889        let buffer = cx.new(|cx| {
 890            let mut buffer = Buffer::local(
 891                indoc! { r#"
 892                    Hey this is Markdown!
 893
 894                    ```typescript
 895                    let foo = 999;
 896                    console.log(foo + 1999);
 897                    ```
 898
 899                    ```typescript
 900                    console.log("foo")
 901                    ```
 902                    "#
 903                },
 904                cx,
 905            );
 906            buffer.set_language_registry(language_registry.clone());
 907            buffer.set_language(Some(markdown.clone()), cx);
 908            buffer
 909        });
 910        let snapshot = buffer.read(cx).snapshot();
 911
 912        let (snippets, _) = runnable_ranges(&snapshot, Point::new(3, 5)..Point::new(8, 5), cx);
 913        let snippets = snippets
 914            .into_iter()
 915            .map(|range| snapshot.text_for_range(range).collect::<String>())
 916            .collect::<Vec<_>>();
 917
 918        assert_eq!(
 919            snippets,
 920            vec![
 921                indoc! { r#"
 922                    let foo = 999;
 923                    console.log(foo + 1999);
 924                    "#
 925                },
 926                "console.log(\"foo\")\n"
 927            ]
 928        );
 929
 930        // Three code blocks intersecting with selection
 931        let buffer = cx.new(|cx| {
 932            let mut buffer = Buffer::local(
 933                indoc! { r#"
 934                    Hey this is Markdown!
 935
 936                    ```typescript
 937                    let foo = 999;
 938                    console.log(foo + 1999);
 939                    ```
 940
 941                    ```ts
 942                    console.log("foo")
 943                    ```
 944
 945                    ```typescript
 946                    console.log("another code block")
 947                    ```
 948                "# },
 949                cx,
 950            );
 951            buffer.set_language_registry(language_registry.clone());
 952            buffer.set_language(Some(markdown.clone()), cx);
 953            buffer
 954        });
 955        let snapshot = buffer.read(cx).snapshot();
 956
 957        let (snippets, _) = runnable_ranges(&snapshot, Point::new(3, 5)..Point::new(12, 5), cx);
 958        let snippets = snippets
 959            .into_iter()
 960            .map(|range| snapshot.text_for_range(range).collect::<String>())
 961            .collect::<Vec<_>>();
 962
 963        assert_eq!(
 964            snippets,
 965            vec![
 966                indoc! { r#"
 967                    let foo = 999;
 968                    console.log(foo + 1999);
 969                    "#
 970                },
 971                "console.log(\"foo\")\n",
 972                "console.log(\"another code block\")\n",
 973            ]
 974        );
 975
 976        // Python code block
 977        let buffer = cx.new(|cx| {
 978            let mut buffer = Buffer::local(
 979                indoc! { r#"
 980                    Hey this is Markdown!
 981
 982                    ```python
 983                    print("hello there")
 984                    print("hello there")
 985                    print("hello there")
 986                    ```
 987                "# },
 988                cx,
 989            );
 990            buffer.set_language_registry(language_registry.clone());
 991            buffer.set_language(Some(markdown.clone()), cx);
 992            buffer
 993        });
 994        let snapshot = buffer.read(cx).snapshot();
 995
 996        let (snippets, _) = runnable_ranges(&snapshot, Point::new(4, 5)..Point::new(5, 5), cx);
 997        let snippets = snippets
 998            .into_iter()
 999            .map(|range| snapshot.text_for_range(range).collect::<String>())
1000            .collect::<Vec<_>>();
1001
1002        assert_eq!(
1003            snippets,
1004            vec![indoc! { r#"
1005                print("hello there")
1006                print("hello there")
1007                print("hello there")
1008                "#
1009            },]
1010        );
1011    }
1012
1013    #[gpui::test]
1014    fn test_skip_blank_lines_to_next_cell(cx: &mut App) {
1015        let test_language = Arc::new(Language::new(
1016            LanguageConfig {
1017                name: "TestLang".into(),
1018                line_comments: vec!["# ".into()],
1019                ..Default::default()
1020            },
1021            None,
1022        ));
1023
1024        let buffer = cx.new(|cx| {
1025            Buffer::local(
1026                indoc! { r#"
1027                    print(1 + 1)
1028
1029                    print(2 + 2)
1030                "# },
1031                cx,
1032            )
1033            .with_language(test_language.clone(), cx)
1034        });
1035        let snapshot = buffer.read(cx).snapshot();
1036
1037        // Selection on blank line should skip to next non-blank cell
1038        let (snippets, _) = runnable_ranges(&snapshot, Point::new(1, 0)..Point::new(1, 0), cx);
1039        let snippets = snippets
1040            .into_iter()
1041            .map(|range| snapshot.text_for_range(range).collect::<String>())
1042            .collect::<Vec<_>>();
1043        assert_eq!(snippets, vec!["print(2 + 2)"]);
1044
1045        // Multiple blank lines should also skip forward
1046        let buffer = cx.new(|cx| {
1047            Buffer::local(
1048                indoc! { r#"
1049                    print(1 + 1)
1050
1051
1052
1053                    print(2 + 2)
1054                "# },
1055                cx,
1056            )
1057            .with_language(test_language.clone(), cx)
1058        });
1059        let snapshot = buffer.read(cx).snapshot();
1060
1061        let (snippets, _) = runnable_ranges(&snapshot, Point::new(2, 0)..Point::new(2, 0), cx);
1062        let snippets = snippets
1063            .into_iter()
1064            .map(|range| snapshot.text_for_range(range).collect::<String>())
1065            .collect::<Vec<_>>();
1066        assert_eq!(snippets, vec!["print(2 + 2)"]);
1067
1068        // Blank lines at end of file should return nothing
1069        let buffer = cx.new(|cx| {
1070            Buffer::local(
1071                indoc! { r#"
1072                    print(1 + 1)
1073
1074                "# },
1075                cx,
1076            )
1077            .with_language(test_language, cx)
1078        });
1079        let snapshot = buffer.read(cx).snapshot();
1080
1081        let (snippets, _) = runnable_ranges(&snapshot, Point::new(1, 0)..Point::new(1, 0), cx);
1082        assert!(snippets.is_empty());
1083    }
1084}